22 KiB
Script-log Engine Emit + Scripted-Alarm Runtime — Implementation Plan
For Claude: REQUIRED SUB-SKILL: use superpowers-extended-cc:subagent-driven-development (or executing-plans) to implement this plan task-by-task.
Goal: Make the Script-log page tail real script output, and stand up scripted alarms end-to-end including real OPC UA Part 9 condition nodes + client ack.
Architecture: Three sequenced layers off one shared seam (a root script logger
fanning to file + companion + a new DPS topic sink). Layer 0 = emit (F8 live). Layer 1
= F9 engine runtime on the Akka equipment-namespace runtime. Layer 2 = F14b real Part 9
nodes + events + inbound ack. Design: docs/plans/2026-06-10-script-log-and-scripted-alarm-runtime-design.md.
Verified gap analysis: pending.md.
Tech: .NET 10, Akka.NET, EF Core (SQL prod / InMemory tests), Serilog, OPC Foundation UA .NET Standard, xUnit + Shouldly, Akka TestKit. No bUnit.
Hard rules (every task): stage by explicit path — never git add .; never stage
sql_login.txt or src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/; never echo the gateway API
key into a new tracked file; no force-push, no --no-verify. No Configuration entity
/ EF migration change (ScriptedAlarmState table already exists). Agent does not
sign in to the AdminUI — the user drives live /run.
Branch: feat/scriptlog-alarm-runtime off master @ df4c2657 (design committed there).
Reference patterns to mirror: VirtualTagHostActor (host actor shape),
EfAlarmActorStateStore (EF store shape), the {{equip}} two-seam parity work
(Phase7Composer ↔ DeploymentArtifact), RoslynVirtualTagEvaluator (evaluator).
LAYER 0 — Shared script-log emit + F8 live
Task 0: Branch + test-project check
Classification: small · ~2 min · Parallelizable with: none
Files: none created (branch + verification only)
Steps:
git switch -c feat/scriptlog-alarm-runtime(offmaster @ df4c2657).- Confirm
tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/exists and is in the.slnx(it does —ScriptLoggerFactoryTests.cslives there). New Layer-0 tests land here. Confirmtests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/for Layer-1 tests. dotnet build ZB.MOM.WW.OtOpcUa.slnx— green baseline. Commit nothing.
Task 1: IScriptLogPublisher + ScriptLogTopicSink
Classification: standard · ~4 min · Parallelizable with: none
Files:
- Create:
src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/IScriptLogPublisher.cs - Create:
src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogTopicSink.cs - Test:
tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptLogTopicSinkTests.cs - Maybe modify:
Core.Scripting.csproj(add ProjectReference toCommonsforScriptLogEntryif not already referenced — verify first).
Step 1 — failing tests (ScriptLogTopicSinkTests):
- A
LogEvent(Information) with propertiesScriptId="S1",VirtualTagId="V1",EquipmentId="EQ1", message"hello"→ publisher receives oneScriptLogEntrywith those fields,Level=="Information",Message=="hello". AlarmIdproperty maps toScriptLogEntry.AlarmId; absent properties → null fields.- A
Debugevent with defaultminLevel=Information→ publisher receives nothing. - Template message renders (
"v={V}"+ prop V=3 →"v=3"). Use a fakeIScriptLogPublishercapturing entries.
Step 2 — run, expect fail (types don't exist).
Step 3 — implement:
public interface IScriptLogPublisher { void Publish(ScriptLogEntry entry); }
public sealed class ScriptLogTopicSink : ILogEventSink
{
private readonly IScriptLogPublisher _publisher;
private readonly LogEventLevel _min;
public ScriptLogTopicSink(IScriptLogPublisher publisher,
LogEventLevel min = LogEventLevel.Information) { _publisher = publisher; _min = min; }
public void Emit(LogEvent e)
{
if (e is null || e.Level < _min) return;
string? P(string k) => e.Properties.TryGetValue(k, out var v)
&& v is ScalarValue { Value: string s } ? s : null;
_publisher.Publish(new ScriptLogEntry(
ScriptId: P("ScriptId") ?? P("ScriptName") ?? "unknown",
Level: e.Level.ToString(),
Message: e.RenderMessage(),
TimestampUtc: e.Timestamp.UtcDateTime,
VirtualTagId: P("VirtualTagId"), AlarmId: P("AlarmId"), EquipmentId: P("EquipmentId")));
}
}
(Property-name constants — reuse/extend ScriptLoggerFactory's ScriptNameProperty;
add ScriptIdProperty/VirtualTagIdProperty/AlarmIdProperty/EquipmentIdProperty.)
Step 4 — run tests, expect pass. Step 5 — commit (git add the 3 files by path).
Task 2: Root script logger + DpsScriptLogPublisher + Host wiring
Classification: standard · ~5 min · Parallelizable with: none (depends T1)
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Scripting/DpsScriptLogPublisher.cs(or Host — wherever theActorSystem/Mediatoris reachable at construction). - Create:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Logging/ScriptRootLoggerFactory.cs(builds the rootILogger: rollingscripts-*.log+ScriptLogCompanionSink+ScriptLogTopicSink). - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs(build + register root logger; registerIScriptLogPublisher). - Test:
tests/.../Core.Scripting.Tests/ScriptRootLoggerFanoutTests.cs(or Host.Tests).
Steps (TDD):
- Failing test: a logger built by
ScriptRootLoggerFactorywith a fake publisher + in-memory companion → anErrorevent reaches the companion mirror AND the topic publisher; aDebugevent reaches neither topic nor companion (file only). (Assert via fakes; don't assert the physical file.) - Implement
DpsScriptLogPublisher— ctor takes the DPS mediatorIActorRef(orActorSystem);Publish→mediator.Tell(new Publish("script-logs", entry))(topic constantVirtualTagActor.ScriptLogsTopic). - Implement
ScriptRootLoggerFactory.Build(IScriptLogPublisher, config)→LoggerConfiguration().WriteTo.File(...).WriteTo.Sink(new ScriptLogCompanionSink(Log.Logger)) .WriteTo.Sink(new ScriptLogTopicSink(publisher, minLevel)).CreateLogger(). Program.cs: resolve the mediator after the ActorSystem is up; registerIScriptLogPublisher(singleton) + the rootILogger(keyed/named for scripts). Min-level from config (Scripting:LogTopicMinLevel, defaultInformation).- Run + commit by path.
Task 3: Rewire evaluators to the root script logger
Classification: standard · ~5 min · Parallelizable with: none (depends T1, T2)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs - Modify:
src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs(bind the full property set, not justScriptName). - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs(inject root logger into the evaluators). - Test:
tests/.../Core.Scripting.Tests/— evaluator emits via fake publisher.
Steps:
- Failing test: a
RoslynVirtualTagEvaluatorbuilt with a root logger wired to a fake publisher; evaluate a scriptctx.Logger.Information("hi"); return 1;→ publisher gets one entry withScriptId/VirtualTagIdbound andMessage=="hi". - Replace the static
ScriptLoggerfield with a ctor-injected rootILogger. Per evaluation,var log = _root.ForContext("ScriptId", id).ForContext("VirtualTagId", virtualTagId)(+EquipmentIdwhen available) and pass into theVirtualTagContext. Same for the alarm evaluator (bindsAlarmId). ScriptLoggerFactory: add aCreate(scriptId, virtualTagId?, alarmId?, equipmentId?)overload binding the standard properties (keep the oldCreate(scriptName)for compatibility).Program.cs: pass the root logger to both evaluator registrations.- Run + commit by path.
Note:
IVirtualTagEvaluator.EvaluatecarriesvirtualTagId; in the live pathscriptId == virtualTagId, so Layer 0 binds both from it. Threading a distinctEquipmentId(nice-to-have on the page) is optional here — if it requires an interface change, defer it to a Layer-1 follow-up rather than expanding T3.
Task 4: Live-verify Layer 0
Classification: verification · Parallelizable with: none (depends T2, T3)
Steps:
- Rebuild docker-dev central nodes (user-driven
/run). Author a virtual tag whose script callsctx.Logger.Information(...). - Open
/script-log; drive the dependency so the script evaluates; confirm the line appears live with the right ScriptId/level. Confirm Debug stays off the page, Information+ shows. - Agent does not sign in — user signs in and drives. Record outcome. No code unless a defect surfaces (→ new fix task).
LAYER 1 — F9 engine runtime
Task 5: EquipmentScriptedAlarmPlan + Phase7Composer enrichment
Classification: standard · ~5 min · Parallelizable with: Task 7, Task 8
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs(new record + build the enriched list fromScriptedAlarm+Scriptrows). - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Server.Tests/Phase7/(or wherever Phase7Composer is tested) — newPhase7ComposerScriptedAlarmTests.cs.
Steps:
- Failing test: compose two equipments each with a scripted alarm referencing a script;
assert each
EquipmentScriptedAlarmPlancarries the resolvedPredicateSource, extractedDependencyRefs(viaDependencyExtractor),AlarmType,Severity,MessageTemplate,HistorizeToAveva,Retain,Enabled,Name. - Add
public sealed record EquipmentScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string Name, string AlarmType, int Severity, string MessageTemplate, string PredicateScriptId, string PredicateSource, IReadOnlyList<string> DependencyRefs, bool HistorizeToAveva, bool Retain, bool Enabled); - In
Compose: joinScriptedAlarm.PredicateScriptId → Script.SourceCode; runDependencyExtractor.Extract(source).Reads(∪MessageTemplatetoken paths) forDependencyRefs; project into the new list on the composition result. SkipEnabled=falsealarms (or carry the flag — carry it; host decides). Drop alarms whose script is missing with a structured warning (don't throw the whole compose). - Run + commit by path.
Task 6: DeploymentArtifact parity for the alarm plan
Classification: standard · ~5 min · Parallelizable with: Task 7, Task 8 (depends T5)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs(encode/decodeEquipmentScriptedAlarmPlan; addPhase7CompositionResult.EquipmentScriptedAlarms; filter-by-equipment likeEquipmentVirtualTagsat :263). - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/— artifact round-trip + parity with the Composer for the same input.
Steps:
- Failing test: build a composition via
Phase7Composer, serialize to artifact, parse back →EquipmentScriptedAlarmsis byte-identical (same discipline as the{{equip}}parity tests). Equipment-filter test (only alarms for resident equipment survive). - Add the field to
Phase7CompositionResult; mirror theEquipmentVirtualTagsencode/decode/filter exactly (:202,:263). - Run + commit by path.
Task 7: DependencyMuxTagUpstreamSource
Classification: standard · ~4 min · Parallelizable with: Task 5, Task 6, Task 8
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/DependencyMuxTagUpstreamSource.cs(implementsCore.ScriptedAlarms/Core.VirtualTagsITagUpstreamSource). - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/DependencyMuxTagUpstreamSourceTests.cs
Steps:
- Failing tests:
Push(path, snapshot)updates cache soReadTag(path)returns it;SubscribeTag(path, obs)→obsfires on the nextPush;ReadTagfor an unknown path returns a Bad-quality snapshot; dispose removes the observer. - Implement: a thread-safe cache (
ConcurrentDictionary<string, DataValueSnapshot>) + per-path observer list;Pushupdates cache then invokes observers;ReadTagreads cache (Bad if absent);SubscribeTagreturns anIDisposablethat deregisters. The host actor callsPushfrom itsDependencyValueChangedhandler. Value wrap:new DataValueSnapshot(value, StatusCode:0, ts, ts). - Run + commit by path.
Task 8: EfAlarmConditionStateStore : IAlarmStateStore
Classification: standard · ~5 min · Parallelizable with: Task 5, Task 6, Task 7
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/EfAlarmConditionStateStore.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/EfAlarmConditionStateStoreTests.cs(in-memory EF).
Steps:
- Failing tests (in-memory
OtOpcUaConfigDbContext):SaveAsync(state)thenLoadAsync(alarmId)round-trips Enabled/Acked/Confirmed/Shelving(+UnshelveAtUtc)/ LastAck*/LastConfirm*/Comments;LoadAsyncof an unknown id → null;ActiveStateis not persisted (a saved state's Active is ignored on load — load returns the stored operator state, Active defaults). Comments JSON round-trips. - Implement mapping
AlarmConditionState↔ScriptedAlarmStateentity (mirrorEfAlarmActorStateStore'sIDbContextFactoryupsert pattern; serializeImmutableList<AlarmComment>↔CommentsJson). Map enum states ↔ the entity's string columns. - Run + commit by path.
Task 9: ScriptedAlarmHostActor
Classification: high-risk · ~5 min · Parallelizable with: none (depends T6, T7, T8; needs Layer 0 T2/T3 root logger)
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ScriptedAlarms/ScriptedAlarmHostActorTests.cs(Akka TestKit + a real engine with the fake upstream, or a fake engine seam).
Design: mirrors VirtualTagHostActor. Owns one ScriptedAlarmEngine (built with the
DependencyMuxTagUpstreamSource, the EfAlarmConditionStateStore, a ScriptLoggerFactory
wrapping the Layer 0 root logger, and the engine's root logger). Message
ApplyScriptedAlarms(IReadOnlyList<EquipmentScriptedAlarmPlan> Plans).
Steps:
- Failing TestKit tests:
ApplyScriptedAlarmswith one alarm → engine loaded (assert via a probe/seam); registers interest with the (probe) mux for the alarm's dep refs.- A
DependencyValueChangedthat makes the predicate true → the host tells the (probe)OpcUaPublishActoraWriteAlarmState(alarmId, active:true, …), tells the (probe) historian anAlarmHistorianEvent(whenHistorizeToAveva), and publishes anAlarmTransitionEventonalerts. - Re-
ApplyScriptedAlarmswith a different set reloads the engine (LoadAsync replace).
- Implement: on
ApplyScriptedAlarms, buildScriptedAlarmDefinitions from the plans (mapAlarmType→AlarmKind,Severity→AlarmSeverity,EquipmentId→EquipmentPath),engine.LoadAsync; register mux interest for⋃ DependencyRefs; onDependencyValueChanged→_upstream.Push(...). Subscribeengine.OnEventonce → mapScriptedAlarmEvent.Conditionto(active, acknowledged)→OpcUaPublishActor.WriteAlarmState; map →AlarmHistorianEvent→ historian (if Historize); publishAlarmTransitionEventonalerts. Dispose engine inPostStop. - Run targeted tests (
dotnet test --filter ScriptedAlarmHostActor). Commit by path.
Task 10: Spawn + apply in DriverHostActor
Classification: standard · ~4 min · Parallelizable with: none (depends T9)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs(spawnScriptedAlarmHostActornext toVirtualTagHostActor~:197; tellApplyScriptedAlarms(composition.EquipmentScriptedAlarms)next to the vtag apply ~:532; add an override field for tests like_virtualTagHostOverride). - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.csif the host needs new injected deps (EF store factory, root logger, historian ref). - Test: extend
DriverHostActorTests— apply pushesApplyScriptedAlarms.
Steps: mirror the VirtualTag spawn/apply exactly; thread _opcUaPublishActor,
_dependencyMux, the EF store, the root logger, the historian actor ref. Run + commit.
Task 11: Retire the orphaned actor + F9b evaluator
Classification: small · ~3 min · Parallelizable with: none (depends T9, T10)
Files:
- Delete:
src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmActor.cs(+ its tests) andsrc/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs. - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs(remove F9b DI registration, lines ~110-114) and anyIScriptedAlarmEvaluatorreferences. KeepEfAlarmActorStateStoreonly if nothing else uses it — otherwise delete with the actor.
Steps: delete, fix build, run full dotnet test for the touched projects, commit by
path. (If something unexpectedly depends on these, stop and surface — don't expand scope.)
Task 12: Live-verify Layer 1
Classification: verification · Parallelizable with: none (depends T10, T11)
Steps: rebuild docker-dev; author a scripted alarm whose predicate references a live
tag; drive the tag; confirm the alarm node flips active/clear, the historian queue
advances (/alarms/historian), the alerts/Alerts page shows it, and predicate
ctx.Logger output appears on /script-log. User drives sign-in. Defects → new tasks.
LAYER 2 — F14b real Part 9 + client ack
Task 13: SDK research spike (DeepWiki)
Classification: small (research) · ~5 min · Parallelizable with: Layer-1 tasks
Steps: Use the DeepWiki MCP (OPCFoundation/UA-.NETStandard) to confirm: how to
create + add an AlarmConditionState (and Limit/OffNormal/Discrete subtypes) under a
parent in a CustomNodeManager2; how to set ActiveState/AckedState/ConfirmedState/
ShelvingState/Severity/Retain; how transitions fire events (ReportEvent); how inbound
Acknowledge/Shelve/Confirm method calls are dispatched + where to hook them. Write
findings to docs/v2/f14b-part9-sdk-notes.md (committed). This de-risks T14-T17.
Task 14: Real condition-node materialisation
Classification: high-risk · ~5 min · Parallelizable with: none (depends T13)
Files: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs (replace the
placeholder [active, ack] variable in WriteAlarmState / add a MaterialiseAlarmCondition
path per AlarmType); Phase7Applier.cs (call the new materialiser); tests where the
SDK allows (node existence/type assertions).
Steps: create real condition nodes on materialise; keep WriteAlarmState as a thin
shim during transition or replace its callers. Run + commit. (SDK threading: all via the
pinned OpcUaPublishActor dispatcher.)
Task 15: Richer alarm-state bridge
Classification: standard · ~4 min · Parallelizable with: Task 17 (depends T14, T9)
Files: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs (new message
carrying the full AlarmConditionState, not 2 bools); ScriptedAlarmHostActor bridge
(send the richer message); OtOpcUaNodeManager (apply full state to the condition).
Tests: message mapping.
Task 16: Event firing on transition
Classification: high-risk · ~5 min · Parallelizable with: none (depends T14, T15)
Files: OtOpcUaNodeManager.cs (condition.ReportEvent(...) on state change). Tests:
mapping/coverage where feasible; behaviour proven in T19.
Task 17: Inbound method dispatch + ack plumbing
Classification: high-risk · ~5 min · Parallelizable with: Task 15 (depends T14)
Files: OtOpcUaNodeManager.cs (wire Acknowledge/Confirm/AddComment/OneShotShelve/
TimedShelve/Unshelve handlers → route to a control-plane message → ScriptedAlarmHostActor
→ engine.<Op>Async(conditionId, principal, comment, ct)); the security gate at the
AlarmAck tier (reuse the LDAP-group→OPC-UA-permission map). Tests: method→engine routing
with a fake engine; permission gate allows/denies by tier.
Task 18: AdminUI ack/shelve control
Classification: standard · ~4 min · Parallelizable with: none (depends T17)
Files: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptedAlarms.razor
(+ a control-plane command service) — ack/shelve buttons route to the same engine ops as
T17. Tests: the control-plane command service (no bUnit; live-verify the razor in T19).
Task 19: Live-verify Layer 2 (Client.CLI)
Classification: verification · Parallelizable with: none (depends T16, T17, T18)
Steps: with docker-dev up, use Client.CLI alarms + subscribe to confirm a real
condition appears, fires events on transition, and a client Acknowledge round-trips
(state flips, event fires, persists). Confirm AdminUI ack does the same. User drives.
Task 20: Docs + finish
Classification: small · ~5 min · Parallelizable with: none (depends all)
Files: update docs/ScriptedAlarms.md, docs/VirtualTags.md, docs/v2/Runtime.md
(F8/F9 now wired), correct the stale docs/v2/phase-7-status.md alarm-runtime status,
add a CLAUDE.md note for the script-log emit + scripted-alarm runtime. Delete/condense
pending.md (its content now lives in the design + these docs). Then run
superpowers-extended-cc:finishing-a-development-branch (full dotnet test, merge to
master).
Execution notes
- Parallel dispatch: Layer 0 is serial (T1→T2→T3→T4). Layer 1: T5→T6 serial (composer→artifact parity); T7, T8 parallel with T5/T6 (disjoint files); T9 waits on T6/T7/T8; T10→T11→T12 serial. Layer 2: T13 first; T15 ∥ T17 after T14; T16 after T15; T18 after T17; T19/T20 last.
- One writer at a time within a shared file (Program.cs touched by T2/T3/T11; OtOpcUaNodeManager by T14/T15/T16/T17 — serialize those).
- Layer boundaries are natural checkpoints — Layer 0 is independently shippable; pause for review after T4 and after T12 before committing to the Layer 2 SDK epic.