diff --git a/docs/plans/2026-09-09-flow-debugger-step-logs-design.md b/docs/plans/2026-09-09-flow-debugger-step-logs-design.md new file mode 100644 index 00000000..a282c56e --- /dev/null +++ b/docs/plans/2026-09-09-flow-debugger-step-logs-design.md @@ -0,0 +1,143 @@ +# Flow Debugger — Step Logs and Exceptions (design) + +**Date:** 2026-09-09 +**Status:** proposed (autonomous brainstorm; decisions below are assumptions to confirm), implementation plan `2026-09-09-flow-debugger-step-logs.md` +**Extends:** `2026-09-05-flow-debugger-design.md` (§5 trace model, §7 central components), `2026-09-06-flow-debugger-outbound-calls.md` (folded nodes), register row 110 (console capture on `ScriptRun`) +**Mockup:** `docs/plans/2026-09-09-flow-debugger-step-logs-mockup.html` (published as a Claude artifact — link in the session recap) + +## 1. What changes + +A traced Flow Debugger run shows, **on the node that produced them**: + +1. **The exception a failing step threw** — type, message, stack, inner exception — not just the one-line + `Script 'X' on instance 'Y' failed: …` summary the `ScriptRun` row carries today. +2. **Log lines** — a new `Log` global every script surface exposes (`Log.Debug/Info/Warn/Error(message, + exception?)`), plus the warnings the runtime logs on the script's behalf while a helper step runs. + +Rendering: a per-level count badge in the node footer; a failed node's subtitle becomes `ExceptionType: +message`; a **Log** tab in the detail panel (exception block first, then the lines, `All | Warn +` filter, +copy); run-wide `n errors · n warnings · n info` in the status strip. Live and Replay read the same rows. + +## 2. What exists (verified 2026-09-09) + +- **Scripts cannot log.** `ScriptGlobals` (site) and `InboundScriptContext` (central) expose no `Log`, + `Logger` or `ILogger`; the four script imports have no `Console`. `ScriptRuntimeContext._logger` and + `_siteEventLogger` are private. So "logger messages" needs a new API surface, not a rewiring. +- **The failure row exists but lacks the detail.** `ScriptRunLauncher.RunScriptAsync` emits a Failed + `ScriptRun` row on both catch paths (`ScriptRunLauncher.cs:458-506`) with `errorMessage` only; + `errorDetail` is never passed (`:588`) although `ScadaBridgeAuditEventFactory.Create` accepts it and the + DB helpers already fill it with `ex.ToString()`. `FlowStepProjector.Project` drops `AuditDetails.ErrorDetail`. + Centrally, `InboundScriptExecutor.ExecuteAsync` swallows the exception into `"Internal script error"` / + `SCRIPT_ERROR` (`InboundScriptExecutor.cs:548-552`) and the runner's inbound row gets that text. +- **No execution-id enrichment on node loggers.** Zero `LogContext.PushProperty` calls; `Enrich.FromLogContext` + is not configured; the `TraceContextEnricher` stamps W3C ids only. `site_events` has no execution-id column. + Capturing what *other* actors log for a run is therefore out of reach without a separate enrichment + initiative (§9). +- **The plumbing to reuse:** `ScriptRuntimeContext.EmitTrace` (collector first, writer second, same + `AuditEvent` both ways); `FlowTraceCollector` (500 events / 1 MB, redacted, `Complete()` at reply time); + `RouteHelper.TraceSink` and the runner's central collector; `GetTraceEventsAsync` already returns every + `ScriptTrace` row of the tree; `FlowGraphBuilder.Fold` already folds multi-row operations into one step; + the Test Run sandbox already has a `ConsoleOutput` pane (`SandboxRunResult.ConsoleOutput`). + +## 3. Decisions + +| # | Decision | Rejected / why | +|---|----------|----------------| +| D1 | **A log line is an audit row:** new `AuditKind.ScriptLog` in channel `ScriptTrace` (appended last in the enum), one row per line, never a node — `FlowGraphBuilder` folds it into its owning step's new `Logs` list. | A side channel on the reply (`RouteToCallResponse.LogLines`) — Replay would have nothing; a second store to redact/retain/purge. Folding into the `ScriptRun` row's `Extra` (register row 110's sketch) — the 8 KB payload cap on that row would silently drop lines, and helper-step attribution would need every helper to buffer. | +| D2 | **Attribution is explicit.** `Extra.stepEventId` names the step; absent, the line belongs to the execution's **anchor** (site: the `ScriptRun` row of that `ExecutionId`; central: the `InboundRequest` row, or the debugger's synthesized `RouteCall` root for a script entry). No anchor and no matching step id → the first step of that execution; no step at all → dropped and counted (`FlowGraph.UnattachedLogLines`, shown in the strip). | Attribution by time window (`OccurredAtUtc` inside a step's `[start, start+duration]`) — step rows record different instants per kind and a 1 ms burst of writes makes it a coin toss. | +| D3 | **Exception detail rides `errorDetail`.** Site: `EmitScriptRun` passes `ex.ToString()` on the exception path (timeouts stay detail-less — an `OperationCanceledException` stack is noise). Central: `InboundScriptResult` gains an internal `ErrorDetail` (never returned to the HTTP caller); `FlowDebugRunner` puts it on the inbound row, and the HTTP path stashes it in `HttpContext.Items` for `AuditWriteMiddleware` so a `TraceAllCalls` Replay has it too. `FlowStep` gains `ErrorDetail`. | A separate `ScriptLog` row for the throw — duplicates the failure row and needs an ordering rule against it. | +| D4 | **`Log` global on every surface:** `ScriptGlobals.Log` and `InboundScriptContext.Log` at runtime; `ScriptCompileSurface.Log` (`CompileLog`) and `SandboxScriptHost.Log` / `SandboxInboundScriptHost.Log` for parity (the sandbox mirror appends to the Test Run `ConsoleOutput`). Signature `void Debug/Info/Warn/Error(string message, Exception? exception = null)`; synchronous; never throws into the script. `Log` is not on `ScriptTrustPolicy`'s deny list (it is a deny-list over API roots), pinned by a test that `Log.Info(...)` passes the gate and binds on all surfaces. | A `Console.WriteLine` shim — `Console` is not imported today and a stdout metaphor has no level. | +| D5 | **Untraced runs pay nothing.** `Log.*` always forwards to the node's `ILogger` (category `ScadaBridge.ScriptLog`, structured `Instance`/`Script`/`ExecutionId`/`Level`) and emits a `ScriptLog` row only when `_isTraced`. No site event. `TraceAllCalls` methods gain lines in Replay at ~1 row per line. | Always auditing lines (volume: timer/value-change scripts run at high rates). A no-op when untraced (authors would lose the messages they wrote). | +| D6 | **Runtime lines through one seam.** When traced, `ScriptRuntimeContext` wraps its `_logger` in a `TraceCapturingLogger` that forwards everything and copies **Information and above** into `ScriptLog` rows with `logSource = "runtime"`. Attribution uses an ambient `TraceStepScope` (`AsyncLocal`): the three attribute helpers (`SetAttribute`, `WriteBatchAndWait`, `WaitInternal`) and the sync `ExternalSystem.Call` pre-mint their step's `EventId`, open the scope for their body, and pass the id to `EmitTrace` (new optional `eventId` parameter). Everything else falls back to the anchor (D2). The launcher's own `LogError` for the run's exception is **not** captured as a line — it is already the exception block (D3). | Editing every `_logger.LogWarning` call site to also emit a row. Capturing Debug (internal chatter). | +| D7 | **Bounded.** `MaxLogLinesPerRun` (default 200; `SiteRuntimeOptions` + `InboundApiOptions`, validated `> 0`) and a 2 000-char message cap at the API; past the line cap, lines are dropped and one closing runtime `warn` row names the dropped count at run end. Inline, `ScriptLog` rows use `FlowTraceCollector.AddLowPriority`: they stop at `max − 50` events / 75 % of the byte budget so the step rows emitted at run end (the `ScriptRun` anchor is written **last**) are never crowded out; a dropped low-priority row sets `LogsTruncated`, surfaced separately from `Truncated` as *log lines truncated inline — Replay is complete*. | Raising the collector cap (the reply also carries the return value under the 4 MB gRPC limit). | +| D8 | **UI rules.** Badge: highest severity first, `✕ n` danger, `! n` warn, `≡ n` faint; hidden when no lines. Failed subtitle: `Type: message` (type without namespace). Detail: **Log (n)** tab after Output; a step with an exception opens on Log, every other step on Input; the Output tab's existing alert stays and links to Log. Strip: `Log: n errors · n warnings · n info` counting lines, not steps. Ordering inside a step: `(OccurredAtUtc, Extra.seq)` — one clock per execution, `seq` breaks same-ms ties. | Drawing lines as nodes (a 40-line script would be unreadable). | +| D9 | **Replay and CLI.** `GetTraceEventsAsync` needs no change (channel filter already includes all of `ScriptTrace`). CLI `flow-debug run-*|replay` table gains a `logs` column (count) and `--show-logs` prints lines indented under their step. | — | + +## 4. Trace model (additive) + +`ScriptLog` row: `OccurredAtUtc` = line time; `Status = Delivered`; `Target` = script name (as `ScriptRun`); +`RequestSummary` = message; `ErrorMessage` / `ErrorDetail` = the attached exception's message / `ToString()`; +`ExecutionId` / `ParentExecutionId` / `SourceSiteId` / `SourceInstanceId` / `SourceNode` as the run's other +rows. `ScriptTraceExtra` gains `LogLevel` (`debug|info|warn|error`), `LogSource` (`script|runtime`), +`StepEventId` (`Guid?`), `Seq` (`int?`). Redaction (payload caps, header/body regexes) applies unchanged; +`AuditLog:PerChannelRetentionDays:ScriptTrace` covers the new kind. + +`FlowStep` gains `ErrorDetail` (`string?`) and `Logs` (`IReadOnlyList?`); +`FlowLogLine(EventId, OccurredAtUtc, Level, Source, Message, ExceptionMessage, ExceptionDetail)`. +`FlowGraph` gains `LogsTruncated` (`bool`) and `UnattachedLogLines` (`int`). None of this is on the wire. + +**Mixed versions.** An OLD central ingesting `ScriptLog` rows from a NEW site projects them to +`FlowStepKind.Unknown` **nodes** (the projector renders unknown kinds rather than dropping them) — the +existing rule *upgrade central first* covers it. An old site sends no lines; the page is unchanged. + +## 5. Site runtime + +- `ScriptLogger` (`SiteRuntime/Scripts/`), created by `ScriptRuntimeContext` and exposed as `Log`; holds the + context's `EmitTrace` delegate, the `ILogger`, the caps and a per-run counter (`CallShared` shares the + context, so inline shared scripts share the counter and the anchor). +- `EmitTrace(kind, …, eventId: Guid? = null)`; `TraceStepScope.Current` (`AsyncLocal`) read when + building a `ScriptLog` row and no explicit step id is given. +- `TraceCapturingLogger : ILogger` — decorator installed only when `_isTraced`; `IsEnabled` delegates to the + inner logger OR `level >= Information`, so a node whose Serilog minimum is Warning still captures + Information lines into the trace (they are trace data, not node log output). +- `ScriptRunLauncher`: pass `errorDetail: ex.ToString()` on the exception path; before `collector?.Complete()` + ask the context to flush its "dropped lines" closing row. + +## 6. Central + +- `InboundScriptContext.Log` → `InboundScriptLogger` writing through the executor's existing inline sink + (`route.TraceSink`) and `ICentralAuditWriter` when traced (`InboundScriptExecutor.ExecuteAsync(..., + trace)` already knows); ILogger always. +- `InboundScriptResult` `+ string? ErrorDetail` (positional-last, default null). `FlowDebugRunner` inbound row + `errorDetail: result.ErrorDetail`. `EndpointExtensions` (the `executor.ExecuteAsync` call site, `:276`) → `HttpContext.Items[AuditErrorDetailKey]` → + `AuditWriteMiddleware` `errorDetail:` (the HTTP body stays `SCRIPT_ERROR`). +- `FlowGraphBuilder`: partition `ScriptLog` rows out before `Fold`, attach per D2 after the steps list is + final (a folded outbound step's `EventId` is its first row's, so `stepEventId` must be the pre-minted + first-row id — only the sync helpers pre-mint in v1, which is exactly the set D6 scopes). + +## 7. Central UI + +`FlowGraphView`: footer becomes a flex row (subtitle + badge); `NodeClass` unchanged; `data-test="flow-node-logs"`. +`FlowStepDetail`: `Log` tab (`data-test="flow-step-tab-log"`), exception block (`flow-step-exception`), line +table (`flow-step-log-lines`), level filter, copy (JS interop `navigator.clipboard`, loose in bUnit), the +cross-step hint ("n more on " selects that step via a new `OnSelectStep` callback). +`FlowDebugger`: strip counts, `flow-logs-truncated` badge, `flow-unattached-logs` note; `ApplyOutcome` +unchanged (a run with only warning lines is still Completed). + +## 8. Security + +Log messages are script-authored text and pass the same `IAuditRedactor` as every other summary (secrets in +a `Log.Info($"token={t}")` are scrubbed by the body regexes where they match, and the 8 KB cap applies). +Exception detail can carry connection strings in `ex.Message` for DB helpers — the DB helpers already +store `ex.ToString()` in `errorDetail` today, so no new exposure class; the redactor's error-row cap (64 KB) +bounds it. Nothing new crosses the PSK boundary: `ScriptLog` rows ride the existing `AuditEventDto`. + +## 9. Out of scope (recorded) + +Live streaming of lines during a long run (rows still arrive with the reply — D4 of the base design); +a run-wide chronological log list (belongs to the Direction B list, register row 110); capturing what +**other actors** log for a run (instance actor, DCL) — needs `LogContext` enrichment with `ExecutionId` plus +`Enrich.FromLogContext()` and an execution-id column on `site_events`; a `Log` sink to the site event log; capturing `Console.Out` on a site run — `System` is imported so +`Console.WriteLine` compiles today, and the Test Run sandbox captures it (`SandboxConsoleCapture`), but on a +site node `Console.Out` is process-global and shared by every concurrent run, so per-run capture is unsound; +`Log.*` is the leveled, per-run replacement and the sandbox mirror writes to the same console pane. + +## 10. Testing + +Pure: `FlowGraphBuilderTests` (attachment by step id, anchor fallback, first-step fallback, unattached count, +ordering by seq, folded-step attachment), `FlowStepProjectorTests` (`ErrorDetail`, `ScriptLog` never a node), +`FlowTraceCollectorTests` (low-priority reserve, `LogsTruncated`). Site: `ScriptLoggerTests` (levels, caps, +closing row, untraced = ILogger only), `TraceCapturingLoggerTests`, `ScriptRuntimeContext` scope attribution, +`ScriptRunLauncher` `errorDetail`. Central: executor `ErrorDetail`, runner inbound row, middleware item key, +`InboundScriptContext.Log` traced/untraced. Trust: `Log.*` passes the gate and binds on all four surfaces. +UI (bUnit): badge, failed subtitle, Log tab default, filter, cross-step hint, strip counts. Rig: +`verify-delmia-notifier.sh` step **J** — a seeded `FlowLogDemo` script logs three levels and throws; +Live shows the exception on the script node and the lines on their steps; Replay matches. + +## 11. Documents to update + +`Component-CentralUI.md` (Flow Debugger bullet), `Component-SiteRuntime.md` (`Log` global, caps, capturing +logger), `Component-InboundAPI.md` (`Log`, `ErrorDetail`), `Component-AuditLog.md` (`ScriptLog` kind), +`Component-ScriptAnalysis.md` (compile surface), CLI README (`--show-logs`), `CLAUDE.md` Flow Debugger bullet +(v1.4), deferred-work register row 110 (close the console half; keep Direction B), plus a new row for the +node-logger enrichment follow-up (§9). diff --git a/docs/plans/2026-09-09-flow-debugger-step-logs-mockup.html b/docs/plans/2026-09-09-flow-debugger-step-logs-mockup.html new file mode 100644 index 00000000..c77ec882 --- /dev/null +++ b/docs/plans/2026-09-09-flow-debugger-step-logs-mockup.html @@ -0,0 +1,357 @@ +Flow Debugger Step Logs + + + +
+
+
Flow Debugger · v1.4 proposal · 2026-09-09
+

Exceptions and log lines on the step that produced them

+

A traced run carries every Log.* line a script writes, every warning the runtime logs on the script's behalf, and the full exception a failing step threw. Each lands on its own node: a count badge on the node, a Log tab in the detail panel, and run-wide error and warning counts in the status strip. Live and Replay read the same rows.

+
+ + +
+
+
Inbound API methodSite script
+
DelmiaRecipeDownload
+
LiveReplay
+
+ Edit inputsRun +
+ +
+ Failed + Run#7 + Started14:02:11.198Z + Total1 842 ms + Steps8 + Boundaries2 + Writes2 + Log1 error · 2 warnings · 4 info + Refresh outcomes + Root7c1e0f2a-…-9b41 +
+ +
+ + + + + + + + + + + + Centralcentral-a + Sitesite-a · DelmiaSim01 + TagsDelmiaReceiver.* + ExternalMesAudit + + + + + + + + + + + + +
Inbound1 842 ms
DelmiaRecipeDownload
flow-debugger · jdoherty≡ 1
+ +
Route call1 790 ms
DelmiaSim01.ProcessRecipeDownload
Script failed at site
+ +
Script run1 731 ms
ProcessRecipeDownload
InvalidOperationException: Recipe 42 rejected by Galaxy (code 7)✕ 1! 1≡ 2
+ +
Write12 ms
RecipeId, RecipeName
RecipeId = 42, RecipeName = …! 1
+ +
Write9 ms
DownloadRequest
DownloadRequest = true
+ +
Wait1 640 ms
RecipeProcessedFlag
satisfied · true
+ +
Database23 ms
MesAudit
Ok· write · 1 row≡ 1
+ +
Response
Response
500 · SCRIPT_ERROR
+
+
+ + +
+
+
+
Script run · Site
+

ProcessRecipeDownload

+
DelmiaSim01.DelmiaReceiver · call
+
+ Failed +
+
+
+
InputOutputLog5WritesTiming
+
+
+
System.InvalidOperationException
+
Recipe 42 rejected by Galaxy (code 7)
+
   at Submission#0.<<Initialize>>d__0.MoveNext() in ProcessRecipeDownload:line 41
+   at ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptRunLauncher.RunScriptAsync(…)
+Inner exception: none
+
CopyOpen in Audit LogThrown by the script body · site-a / node-a
+
+ +
+
AllWarn +
+ 5 lines on this step · 1 more on Write RecipeId, RecipeName + + Copy lines +
+ + + + + + + + + + +
Time (UTC)LevelSourceMessage
14:02:11.204infoscriptRecipe 42 requested for line L3 by flow-debugger
14:02:11.206debugscriptWriting header attributes then DownloadRequest
14:02:12.861infoscriptGalaxy answered: RecipeProcessedFlag = true, ResultCode = 7
14:02:12.862warnscriptResultCode 7 is outside the accepted set {0, 1}
14:02:12.870errorscriptRejecting recipe 42 (code 7) — see exception above
14:02:12.929warnruntimeLog truncated inline: 0 lines dropped (shown only when the per-run cap is hit)
+
+
+
+
Where this ran
+
+
Site / node
site-a / node-a
+
Instance
DelmiaSim01
+
Target
DelmiaReceiver.ProcessRecipeDownload
+
Trigger
call
+
Log lines
5 here · 1 on other steps
+
+
Open in Debug ViewAudit rows (9)
+
+
+
+
+ + +
+

Node anatomy

+

Nothing moves on a node that has no log lines. A node with lines gains a count badge in the footer, highest severity first; a failed node swaps its subtitle for the exception's type and message.

+
+
+
Inbound1 842 ms
DelmiaRecipeDownload
flow-debugger · jdoherty≡ 1
+
Info only. A faint ≡ n badge. Debug lines count here too; the tab's Warn + filter hides them.
+
+
+
Write12 ms
RecipeId, RecipeName
RecipeId = 42, RecipeName = …! 1
+
Runtime warning on a step. The write helper's own log line ("acked with quality Uncertain") lands on the write node, not on the script run, because the helper was inside its step scope when it logged.
+
+
+
Script run1 731 ms
ProcessRecipeDownload
InvalidOperationException: Recipe 42 rejected …✕ 1! 1≡ 2
+
Failed with an exception. Red stroke and eyebrow as today; the subtitle becomes Type: message. Selecting it opens the Log tab first.
+
+
+
+ + +
+

What the script author writes

+

One new global, Log, on every script surface (site scripts, shared scripts, inbound API methods, the Test Run editor). Always forwards to the node's logger; becomes a trace row only when the run is traced, so untraced production runs add no audit volume.

+
// ProcessRecipeDownload — site Call script (excerpt)
+var recipeId = Parameters.Get<int>("RecipeId");
+Log.Info($"Recipe {recipeId} requested for line {Parameters.Get<string>("Line")} by {Parameters.Get<string>("Requester")}");
+Log.Debug("Writing header attributes then DownloadRequest");
+
+await Children["DelmiaReceiver"].SetAsync("RecipeId", recipeId);
+// … writes, wait …
+
+var code = await Children["DelmiaReceiver"].GetAsync<int>("ResultCode");
+Log.Info($"Galaxy answered: RecipeProcessedFlag = true, ResultCode = {code}");
+if (code is not (0 or 1))
+{
+    Log.Warn($"ResultCode {code} is outside the accepted set {{0, 1}}");
+    Log.Error($"Rejecting recipe {recipeId} (code {code})");
+    throw new InvalidOperationException($"Recipe {recipeId} rejected by Galaxy (code {code})");
+}
+Log.Warn("Recoverable problem", caughtException);   // an exception on a line renders as an expandable chip
+
+ +
+

Where a line lands

+
+ + + + + + + + + + +
Produced bySourceLands onHow
Log.* in a site script (incl. inline shared scripts)scriptThat execution's Script run nodeOne ScriptTrace.ScriptLog row per line, keyed by the run's ExecutionId; no step id
Log.* in an inbound API method scriptscriptThe Inbound node (or the debugger's route-call root for a script entry)Same row, written centrally with the inbound ExecutionId; inline on a Live run through the runner's collector
The runtime's own warnings while a helper step runs (write, wait, sync API call)runtimeThat step's nodeThe helper pre-mints its step's event id and holds it in an ambient scope; the context's logger copies Information + lines into a row stamped stepEventId
Runtime warnings outside any helper stepruntimeThe Script run nodeNo scope active → falls back to the execution anchor
The exception that failed a script run—The Script run node's exception blockNot a log row: ScriptRun.errorDetail now carries ex.ToString() (type, message, stack, inner); the inbound row gets the same for a central method failure
Log lines from other actors on the node (instance actor, DCL)—Not captured in v1No execution-id enrichment exists on the node loggers; recorded as the follow-up, not attempted here
+
+
+ +
+

Decision notes

+
    +
  1. Lines are audit rows, not a side channel. A new ScriptLog kind in the existing ScriptTrace channel rides the collector for Live and the audit store for Replay, with redaction, retention and the site 7-day purge applying unchanged. The graph builder folds them into their owning step; they are never drawn as nodes.
  2. +
  3. Untraced runs pay nothing. Log.* always writes to the node's ILogger at the given level (so it shows in Serilog and the site log as usual) and emits a row only when the run is traced. TraceAllCalls methods therefore gain lines in Replay at ~1 row per line.
  4. +
  5. Bounded by design. A per-run cap (200 lines, configurable) and a 2 000-character message cap sit at the API; past the cap lines are dropped and one closing runtime warning names the count. Inline, log rows are low-priority in the collector: they stop 50 events short of the 500 cap so the step rows emitted at run end are never crowded out, and the page shows log lines truncated inline — Replay is complete when that happens.
  6. +
  7. Attribution is explicit, never inferred from time. A row carries stepEventId or it belongs to the execution's anchor step. Steps whose row is written later by another actor (cached calls, notifications) keep the fallback.
  8. +
  9. Exceptions stay where they are already recorded. The failure row exists on every path today; it only lacked the detail. Adding errorDetail costs one argument on the site and one internal field centrally; the HTTP caller still receives the safe SCRIPT_ERROR body.
  10. +
  11. Selecting a failed node opens the Log tab. Every other node keeps Input as its first tab; the Output tab's red alert stays and links across.
  12. +
  13. Not in this slice: live streaming of lines during a long run (Live rows still arrive with the reply), a run-wide chronological log view (the register's Direction B list would host it), and node-logger enrichment by execution id.
  14. +
+
+
diff --git a/docs/plans/2026-09-09-flow-debugger-step-logs.md b/docs/plans/2026-09-09-flow-debugger-step-logs.md new file mode 100644 index 00000000..1aa9092a --- /dev/null +++ b/docs/plans/2026-09-09-flow-debugger-step-logs.md @@ -0,0 +1,528 @@ +# Flow Debugger — Step Logs and Exceptions Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. + +**Goal:** A traced Flow Debugger run shows, on the node that produced them, the exception a failing step threw (type, message, stack) and every log line the script or the runtime wrote during that step — in Live and in Replay. + +**Architecture:** One new audit kind, `ScriptTrace.ScriptLog`, one row per line, riding the existing collector (Live) and audit store (Replay); `FlowGraphBuilder` folds the rows into their owning step (`stepEventId`, else the execution's anchor step) and never draws them as nodes. Scripts get a `Log` global on all four compile surfaces; the site context wraps its own logger while traced so runtime warnings inside a helper step land on that step. Exception detail is the `errorDetail` field that already exists on `AuditDetails` — populated on the site `ScriptRun` row and the central inbound row, projected onto `FlowStep.ErrorDetail`. + +**Tech Stack:** .NET 10, Akka.NET site runtime, `ZB.MOM.WW.Audit` `AuditEvent`, Roslyn script surfaces, Blazor Server custom SVG (`FlowGraphView`), xunit + NSubstitute + bUnit. + +**Design:** `docs/plans/2026-09-09-flow-debugger-step-logs-design.md` (decisions D1–D9). **Mockup:** `docs/plans/2026-09-09-flow-debugger-step-logs-mockup.html`. + +**Conventions for every task:** work in a dedicated worktree (see memory: `isolation:worktree` agents are not isolated — pre-create one `git worktree` per concurrent task; never `git stash`). Build the touched projects only; run the named test filter; full `dotnet build ZB.MOM.WW.ScadaBridge.slnx` at the end of Task 15. Commit with pathspecs (`git add `), never `-a`, when several tasks run concurrently. + +--- + +## Task 1: Commons — `ScriptLog` kind, trace extra fields, `FlowStep.ErrorDetail` / `Logs` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 2 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Enums/AuditKind.cs:64` (append `ScriptLog` AFTER `AttributeWait` — stored ordinals never move) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/ScriptTraceExtra.cs` (add `LogLevel`, `LogSource`, `StepEventId`, `Seq`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs` (add `FlowLogLine`, `FlowStep.ErrorDetail`, `FlowStep.Logs`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs` (add `LogsTruncated`, `UnattachedLogLines`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs` (project `ErrorDetail`; add `IsLogLine`; `ScriptLog` still resolves to `Unknown` in `ResolveKind` so an old-central mixed-version behaves as the design records) +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorTests.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowStepProjectorLogLineTests.cs` (create) + +**Step 1: Write the failing tests** + +```csharp +[Fact] +public void Project_CarriesErrorDetail() +{ + var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Failed, + target: "S", executionId: Guid.NewGuid(), errorMessage: "boom", errorDetail: "System.InvalidOperationException: boom\n at X"); + var step = FlowStepProjector.Project(evt); + Assert.Equal("boom", step.ErrorMessage); + Assert.StartsWith("System.InvalidOperationException", step.ErrorDetail); +} + +[Fact] +public void IsLogLine_TrueOnlyForScriptTraceScriptLog() +{ + var log = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered, target: "S"); + var run = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, target: "S"); + Assert.True(FlowStepProjector.IsLogLine(log)); + Assert.False(FlowStepProjector.IsLogLine(run)); +} + +[Fact] +public void ToLogLine_ReadsLevelSourceAndException() +{ + var extra = new ScriptTraceExtra { LogLevel = "warn", LogSource = "runtime", StepEventId = Guid.NewGuid(), Seq = 3 }; + var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered, + target: "S", requestSummary: "acked Uncertain", errorMessage: "inner", errorDetail: "System.Exception: inner", extra: extra.ToJson()); + var line = FlowStepProjector.ToLogLine(evt); + Assert.Equal("warn", line.Level); Assert.Equal("runtime", line.Source); + Assert.Equal("acked Uncertain", line.Message); Assert.Equal("inner", line.ExceptionMessage); + Assert.Equal(extra.StepEventId, line.StepEventId); Assert.Equal(3, line.Seq); +} +``` + +**Step 2: Run to verify failure** — `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~FlowStepProjector" -v q` → compile errors (`ScriptLog`, `ErrorDetail`, `IsLogLine`, `ToLogLine` missing). + +**Step 3: Implement** + +`AuditKind.cs` — append after `AttributeWait` (keep the trailing-comma style of the file): +```csharp + /// One log line a traced script (or the runtime on its behalf) wrote; folded into its owning step by the Flow Debugger, never a node (design 2026-09-09 D1). + ScriptLog +``` + +`ScriptTraceExtra.cs` — add after `Source`: +```csharp + /// On a ScriptLog row: debug | info | warn | error. + public string? LogLevel { get; init; } + /// On a ScriptLog row: script (a Log.* call) | runtime (the context's own logger while traced). + public string? LogSource { get; init; } + /// On a ScriptLog row: the pre-minted EventId of the step the line belongs to; null = the execution's anchor step (design D2). + public Guid? StepEventId { get; init; } + /// On a ScriptLog row: per-run emission counter, breaks same-millisecond ties. + public int? Seq { get; init; } +``` + +`FlowStep.cs` — add the record and two trailing optional members on `FlowStep`: +```csharp +/// One log line folded onto a step (; design 2026-09-09 D1/D8). +public sealed record FlowLogLine( + Guid EventId, Guid? ExecutionId, DateTimeOffset OccurredAtUtc, string Level, string Source, string Message, + string? ExceptionMessage, string? ExceptionDetail, Guid? StepEventId, int? Seq); +``` +`FlowStep(... , IReadOnlyList? Lifecycle = null, Guid? CorrelationId = null, string? ErrorDetail = null, IReadOnlyList? Logs = null)`. + +`FlowGraph.cs` — `FlowGraph(..., bool Truncated = false, bool LogsTruncated = false, int UnattachedLogLines = 0)` (keep existing positional order; append). + +`FlowStepProjector.cs` — in `Project`: `ErrorDetail: d.ErrorDetail`. Add: +```csharp + /// True for a ScriptTrace.ScriptLog row — a log line, never a step. + public static bool IsLogLine(AuditEvent evt) + { + var d = AuditDetailsCodec.Deserialize(evt.DetailsJson); + return string.Equals(d.Channel, nameof(AuditChannel.ScriptTrace), StringComparison.Ordinal) + && string.Equals(d.Kind, nameof(AuditKind.ScriptLog), StringComparison.Ordinal); + } + + /// Projects a ScriptLog row onto its line. Level/source default to info/script when the extra is missing. + public static FlowLogLine ToLogLine(AuditEvent evt) + { + var d = AuditDetailsCodec.Deserialize(evt.DetailsJson); + var x = ScriptTraceExtra.TryParse(d.Extra); + return new FlowLogLine(evt.EventId, d.ExecutionId, evt.OccurredAtUtc, x?.LogLevel ?? "info", x?.LogSource ?? "script", + d.RequestSummary ?? string.Empty, d.ErrorMessage, d.ErrorDetail, x?.StepEventId, x?.Seq); + } +``` + +**Step 4: Run** the filter → PASS. Also `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter "FullyQualifiedName~Audit" -v q` — there is an enum-count lock-in test for `AuditKind` (PLAN-07 found one); update its expected count. + +**Step 5: Commit** — `git add src/ZB.MOM.WW.ScadaBridge.Commons tests/ZB.MOM.WW.ScadaBridge.Commons.Tests && git commit -m "feat(commons): ScriptLog audit kind, log-line trace extra, FlowStep.ErrorDetail/Logs (flow-debugger step logs D1/D3)"`. + +--- + +## Task 2: Commons — `FlowTraceCollector.AddLowPriority` + `LogsTruncated` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 1 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowTraceCollector.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowTraceCollectorTests.cs` + +**Step 1: Failing tests** +```csharp +[Fact] +public void AddLowPriority_StopsBeforeTheReserve_AndSetsLogsTruncated() +{ + var c = new FlowTraceCollector(maxEvents: 60, maxBytes: 1_000_000); // reserve = 50 → low-priority stops at 10 + for (var i = 0; i < 12; i++) c.AddLowPriority(Evt()); + Assert.Equal(10, c.Count); Assert.True(c.LogsTruncated); Assert.False(c.Truncated); + c.Add(Evt()); // a step row still fits + Assert.Equal(11, c.Count); +} + +[Fact] +public void AddLowPriority_RespectsByteShare() +{ + var c = new FlowTraceCollector(maxEvents: 500, maxBytes: 1_000); // 75 % = 750 chars + c.AddLowPriority(Evt(details: new string('x', 700))); + c.AddLowPriority(Evt(details: new string('x', 100))); + Assert.Equal(1, c.Count); Assert.True(c.LogsTruncated); +} +``` +(`Evt(details:)` — build via `ScadaBridgeAuditEventFactory.Create(..., requestSummary: details)`.) + +**Step 2: Run** `--filter "FullyQualifiedName~FlowTraceCollectorTests"` → compile failure. + +**Step 3: Implement** — constants `public const int LowPriorityReserveEvents = 50;` and `public const double LowPriorityByteShare = 0.75;`; `public bool LogsTruncated { get; private set; }`; refactor `Add` into `private void AddCore(AuditEvent evt, bool lowPriority)` where the bound check is +```csharp +var maxCount = lowPriority ? Math.Max(1, _max - LowPriorityReserveEvents) : _max; +var maxBytes = lowPriority ? (int)(_maxBytes * LowPriorityByteShare) : _maxBytes; +if (_events.Count >= maxCount || _bytes + size > maxBytes) { if (lowPriority) LogsTruncated = true; else Truncated = true; return; } +``` +`public void Add(AuditEvent evt) => AddCore(evt, false);` `public void AddLowPriority(AuditEvent evt) => AddCore(evt, true);`. Update the type remarks (log rows are low-priority so the run-end step rows are never crowded out — D7). + +**Step 4: Run** → PASS. **Step 5: Commit** — `git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowTraceCollector.cs tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowTraceCollectorTests.cs && git commit -m "feat(commons): FlowTraceCollector low-priority lane for log lines (D7)"`. + +--- + +## Task 3: Commons — `FlowGraphBuilder` attaches log lines to steps + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 4, Task 6, Task 7 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs` (`Build` signature + attachment pass) +- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderLogLineTests.cs` (create) + +**Step 1: Failing tests** (helpers: `Run(exec)` = a `ScriptRun` row, `Write(exec, id)` = an `AttributeWrite` row with a chosen `eventId`, `Line(exec, step?, seq, level)` = a `ScriptLog` row) +```csharp +[Fact] public void LogLine_WithStepEventId_AttachesToThatStep() +// Run(A) + Write(A, w) + Line(A, step: w) → the Write step has Logs.Count == 1, the Run step has none. +[Fact] public void LogLine_WithoutStepId_AttachesToTheExecutionAnchor() +// Run(A) + Write(A) + Line(A, null) → Run.Logs.Count == 1. +[Fact] public void CentralLine_AttachesToInboundRow() +// InboundRequest(exec R) + Line(R, null) → the InboundRequest step carries it (not the synthesized Response). +[Fact] public void LogLine_UnknownStepId_FallsBackToAnchor_ThenFirstStep() +// Line(A, step: unknownGuid) with Run(A) → Run; with only Write(A) (no ScriptRun) → the Write (first step of A). +[Fact] public void LogLine_NoStepInExecution_IsCountedUnattached() +// Line(Z, null) alone with Run(A) → graph.UnattachedLogLines == 1, no Logs anywhere. +[Fact] public void Lines_OrderBySeqWithinSameMillisecond() +// three lines with the same OccurredAtUtc and seq 3,1,2 → Logs[0].Seq == 1. +[Fact] public void LogLines_NeverBecomeSteps() +// Run(A) + 5 lines → graph.Steps has exactly 1 step (no Unknown nodes). +[Fact] public void Build_PassesLogsTruncatedThrough() +``` + +**Step 2: Run** `--filter "FullyQualifiedName~FlowGraphBuilderLogLineTests"` → FAIL (compile / Unknown nodes). + +**Step 3: Implement** — `Build(IReadOnlyList events, bool truncated = false, bool logsTruncated = false)`. At the top, partition: `var lines = events.Where(FlowStepProjector.IsLogLine).Select(FlowStepProjector.ToLogLine).ToList(); events = events.Where(e => !FlowStepProjector.IsLogLine(e)).ToList();` (keep `seq` indexing on the filtered list). After the Response synthesis and before `return`, attach: +```csharp +var byEventId = steps.ToDictionary(s => s.EventId); +var anchorByExecution = steps.Where(s => s.ExecutionId is not null && s.Kind is FlowStepKind.ScriptRun or FlowStepKind.InboundRequest or FlowStepKind.RouteCall) + .GroupBy(s => s.ExecutionId!.Value).ToDictionary(g => g.Key, g => g.OrderBy(s => KindRank(s.Kind)).First()); +var firstByExecution = steps.Where(s => s.ExecutionId is not null && s.Kind != FlowStepKind.Response) + .GroupBy(s => s.ExecutionId!.Value).ToDictionary(g => g.Key, g => g.First()); +var logsByStep = new Dictionary>(); +var unattached = 0; +foreach (var line in lines) +{ + FlowStep? owner = null; + if (line.StepEventId is { } sid && byEventId.TryGetValue(sid, out var s)) owner = s; + else if (LineExecution(line) is { } exec && (anchorByExecution.TryGetValue(exec, out var a) || firstByExecution.TryGetValue(exec, out a))) owner = a; + if (owner is null) { unattached++; continue; } + (logsByStep.TryGetValue(owner.EventId, out var l) ? l : logsByStep[owner.EventId] = []).Add(line); +} +for (var i = 0; i < steps.Count; i++) + if (logsByStep.TryGetValue(steps[i].EventId, out var l)) + steps[i] = steps[i] with { Logs = l.OrderBy(x => x.OccurredAtUtc).ThenBy(x => x.Seq ?? int.MaxValue).ToList() }; +``` +`LineExecution(line)` is simply `line.ExecutionId` (Task 1's record carries it). The RouteCall anchor covers the debugger's script-entry root (design D2). Return `new FlowGraph(steps, edges, Predicted: false, Truncated: truncated, LogsTruncated: logsTruncated, UnattachedLogLines: unattached)`. Update the class remarks with the attachment rule. + +**Step 4: Run** the new tests + `--filter "FullyQualifiedName~FlowGraphBuilder"` → all PASS. **Step 5: Commit** — `git add src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging && git commit -m "feat(commons): FlowGraphBuilder folds ScriptLog rows onto their owning step (D2)"`. + +--- + +## Task 4: SiteRuntime — `ScriptLogger`, `TraceStepScope`, `TraceCapturingLogger` (new files) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 3, Task 6, Task 7 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptLogger.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/TraceStepScope.cs` +- Create: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/TraceCapturingLogger.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptLoggerTests.cs` (create) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TraceCapturingLoggerTests.cs` (create) + +These types take a **delegate** for emission so they are testable without a `ScriptRuntimeContext`: + +```csharp +/// The row-emitting callback a uses: level, source, message, exception, step id. +public delegate void ScriptLogEmitter(string level, string source, string message, Exception? exception, Guid? stepEventId); +``` + +`TraceStepScope.cs`: +```csharp +/// Ambient "which step is running" marker for runtime log attribution (design 2026-09-09 D6). AsyncLocal so it flows through the helper's awaits and nowhere else. +public static class TraceStepScope +{ + private static readonly AsyncLocal _current = new(); + public static Guid? Current => _current.Value; + /// Sets the current step for the scope's lifetime; restores the previous value on dispose. + public static IDisposable Enter(Guid stepEventId) { var prev = _current.Value; _current.Value = stepEventId; return new Restore(prev); } + private sealed class Restore(Guid? prev) : IDisposable { public void Dispose() => _current.Value = prev; } +} +``` + +`ScriptLogger.cs` — the `Log` global: +```csharp +/// +/// The Log global (design 2026-09-09 D4/D5/D7). Always forwards to the node's ILogger; emits a +/// ScriptLog trace row only while the run is traced ( non-null). Bounded by +/// per run (shared with inline shared scripts) and ; past +/// the line cap lines are dropped and writes one closing runtime warning. Never throws into the script. +/// +public sealed class ScriptLogger +{ + public const int MaxMessageChars = 2_000; + private readonly ILogger _logger; private readonly ScriptLogEmitter? _emit; private readonly int _maxLines; + private readonly string _instance, _script; private readonly Guid? _executionId; + private int _emitted, _dropped; + + public ScriptLogger(ILogger logger, ScriptLogEmitter? emit, int maxLines, string instance, string script, Guid? executionId) { … } + + public void Debug(string message, Exception? exception = null) => Write(LogLevel.Debug, "debug", message, exception); + public void Info(string message, Exception? exception = null) => Write(LogLevel.Information, "info", message, exception); + public void Warn(string message, Exception? exception = null) => Write(LogLevel.Warning, "warn", message, exception); + public void Error(string message, Exception? exception = null) => Write(LogLevel.Error, "error", message, exception); + + /// Runtime-sourced line (the capturing logger); same caps, source "runtime". + internal void Runtime(string level, string message, Exception? exception) => WriteRow(level, "runtime", message, exception); + + private void Write(LogLevel level, string name, string message, Exception? exception) + { + message = Clip(message); + try { _logger.Log(level, exception, "[script] {Instance}/{Script} {ExecutionId}: {Message}", _instance, _script, _executionId, message); } catch { /* never into the script */ } + WriteRow(name, "script", message, exception); + } + + private void WriteRow(string level, string source, string message, Exception? exception) + { + if (_emit is null) return; + if (Interlocked.Increment(ref _emitted) > _maxLines) { Interlocked.Increment(ref _dropped); return; } + try { _emit(level, source, Clip(message), exception, TraceStepScope.Current); } catch { } + } + + /// Run end: one closing warning naming the dropped count (nothing when none were dropped). + public void FlushDropped() + { + var n = Volatile.Read(ref _dropped); + if (n == 0 || _emit is null) return; + try { _emit("warn", "runtime", $"Log truncated: {n} line(s) dropped past the per-run cap of {_maxLines}", null, null); } catch { } + } + + private static string Clip(string? m) => m is null ? string.Empty : m.Length <= MaxMessageChars ? m : m[..MaxMessageChars] + "…"; +} +``` +(Note the closing row bypasses the counter on purpose — it must be the one row that always lands.) + +`TraceCapturingLogger.cs`: +```csharp +/// Decorates the context's ILogger while traced: forwards everything, and copies Information+ into runtime log rows (design D6). +public sealed class TraceCapturingLogger(ILogger inner, ScriptLogger sink) : ILogger +{ + public IDisposable? BeginScope(TState state) where TState : notnull => inner.BeginScope(state); + public bool IsEnabled(LogLevel level) => level >= LogLevel.Information || inner.IsEnabled(level); + public void Log(LogLevel level, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (inner.IsEnabled(level)) inner.Log(level, eventId, state, exception, formatter); + if (level < LogLevel.Information) return; + var name = level switch { LogLevel.Information => "info", LogLevel.Warning => "warn", _ => "error" }; + sink.Runtime(name, formatter(state, exception), exception); + } +} +``` + +**Tests** (`ScriptLoggerTests`): untraced (`emit: null`) → ILogger receives the entry, nothing emitted; traced → emitter gets `("info","script",msg,null,null)`; inside `TraceStepScope.Enter(id)` → `stepEventId == id`; cap 3 + 5 calls → 3 emits, `FlushDropped` emits one `warn` naming 2; message > 2 000 chars is clipped with `…`; an emitter that throws does not propagate. (`TraceCapturingLoggerTests`): Debug forwarded but not captured; Warning captured as `"warn"`/`"runtime"` with the exception; `IsEnabled(Information)` true even when inner says false. + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~ScriptLoggerTests|FullyQualifiedName~TraceCapturingLoggerTests" -v q` → PASS. Commit — `git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptLogger.cs src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/TraceStepScope.cs src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/TraceCapturingLogger.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptLoggerTests.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/TraceCapturingLoggerTests.cs && git commit -m "feat(site-runtime): ScriptLogger (Log global), TraceStepScope, TraceCapturingLogger (D4-D7)"`. + +--- + +## Task 5: SiteRuntime — wire `Log` into `ScriptRuntimeContext` + `ScriptGlobals` + +**Classification:** high-risk (script execution path, actor-adjacent) +**Estimated implement time:** ~5 min +**Parallelizable with:** none (after Tasks 1, 4) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs` (ctor `:265-300`; `EmitTrace` `:414-456`; `SetAttribute` `:~700-730`, `WriteBatchAndWait` `:~760-790`, `WaitInternal` `:~620-665`, sync `ExternalSystem.Call` emit `:~1651`; new `Log` property + `EmitLog`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs:269-350` (`ScriptGlobals.Log => Instance.Log`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs` (extend; it already builds a traced context with a fake `IAuditWriter` + collector) + +**Steps:** +1. **Failing tests** (extend the existing fixture): (a) traced `Log.Info("x")` → the collector holds one `ScriptLog` row with `RequestSummary == "x"`, `Extra.logLevel == "info"`, `logSource == "script"`, `stepEventId == null`, and the writer received the same instance; (b) untraced context: `Log.Info` emits nothing to writer or collector; (c) traced `SetAttribute` whose Ask fault path logs a warning → the collector holds an `AttributeWrite` row **and** a `ScriptLog` row whose `stepEventId` equals that write row's `EventId` (`logSource == "runtime"`); (d) `Log.Warn("m", new Exception("e"))` → row `ErrorMessage == "e"`, `ErrorDetail` starts with `System.Exception`; (e) `ScriptLog` rows go through `AddLowPriority` — use a tiny collector (`maxEvents: 51`) and check the row is dropped with `LogsTruncated` while `EmitTrace` rows still land. +2. **Implement:** + - ctor: new optional parameter `int maxLogLinesPerRun = 200` (last, defaulted — the launcher passes the option in Task 6); build `Log = new ScriptLogger(_logger, isTraced ? EmitLog : null, maxLogLinesPerRun, instanceName, sourceScript, _executionId)`; then, when traced, `_logger = new TraceCapturingLogger(_logger, Log)` (make the field non-readonly or assign in one expression — keep the untraced path byte-identical). + - `public ScriptLogger Log { get; }` beside `Alarms`. + - `EmitTrace(..., Guid? eventId = null)` → pass `eventId:` to the factory. `Collect` unchanged. + - `private void EmitLog(string level, string source, string message, Exception? exception, Guid? stepEventId)` — builds `AuditChannel.ScriptTrace / AuditKind.ScriptLog / Delivered`, `target: _sourceScript`-derived script name (same as the `ScriptRun` target), `requestSummary: message`, `errorMessage: exception?.Message`, `errorDetail: exception?.ToString()`, `extra: new ScriptTraceExtra { LogLevel, LogSource, StepEventId = stepEventId, Seq = Interlocked.Increment(ref _logSeq) }.ToJson()`; then `_traceCollector?.AddLowPriority(evt)` (guarded like `Collect`) and the same best-effort `_auditWriter.WriteAsync` continuation pattern as `EmitTrace`. + - Step scopes: in `SetAttribute`, `WriteBatchAndWait`, `WaitInternal` and the sync `ExternalSystem.Call` path — `var stepId = Guid.NewGuid(); using var _ = TraceStepScope.Enter(stepId);` around the body, and pass `eventId: stepId` to the step's `EmitTrace` (for the ES sync call, to the `ScadaBridgeAuditEventFactory.Create` at `:~1651`). Only when `_isTraced` (skip the allocation otherwise). + - `ScriptGlobals`: `/// Leveled per-run logging (design 2026-09-09). Usage: Log.Info("…"), Log.Warn("…", ex). public ScriptLogger Log => Instance.Log;` +3. **Run** `--filter "FullyQualifiedName~ScriptRuntimeContextTraceEmissionTests|FullyQualifiedName~CompileSurfaceParityTests"` → the trace tests PASS; the parity test will FAIL until Task 7 adds the mirror — that is expected and noted in the commit. +4. **Commit** — `git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptCompilationService.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs && git commit -m "feat(site-runtime): Log global on ScriptRuntimeContext/ScriptGlobals; runtime lines attributed by TraceStepScope (D4-D6)"`. + +--- + +## Task 6: SiteRuntime — launcher `errorDetail`, dropped-lines flush, `MaxLogLinesPerRun` option + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 7, Task 8 (after Task 5) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRunLauncher.cs:255-260` (collector creation), `:400-405` (context ctor call — pass the option), `:430-506` (call `context?.Log.FlushDropped()` before every `collector?.Complete()` / `EmitScriptRun`; exception path passes `ex.ToString()`), `:568-608` (`EmitScriptRun` gains `string? errorDetail = null` → `errorDetail:`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs:245` (add `MaxLogLinesPerRun { get; set; } = 200;` + the existing options validator: `> 0`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRunLauncherTraceTests.cs` (create, modelled on how `ScriptRuntimeContextTraceEmissionTests` builds a launcher/collector — reuse its helpers if they are shared; otherwise the smallest real `ScriptRunLauncher.RunScriptAsync` invocation with a script body that throws) + +**Steps:** failing tests — (a) a throwing traced script → the `ScriptRun` row has `Status == Failed`, `ErrorMessage` contains the message, `ErrorDetail` starts with the exception type name and contains `at `; (b) a timeout → `ErrorDetail == null`; (c) a script that logs 5 lines under `MaxLogLinesPerRun = 2` → exactly 2 `ScriptLog` rows + 1 closing `warn` runtime row, all present in the reply's `TraceEvents` **before** the `ScriptRun` row; (d) `SiteRuntimeOptions` validation rejects `0`. Implement; run `--filter "FullyQualifiedName~ScriptRunLauncherTraceTests|FullyQualifiedName~SiteRuntimeOptions"` → PASS. Commit — `git commit -m "feat(site-runtime): ScriptRun rows carry exception detail; per-run log line cap (D3/D7)"` (pathspec the four files). + +--- + +## Task 7: Script surfaces — `ScriptCompileSurface.Log`, sandbox mirrors, trust pin + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 6, Task 8 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs:67-70` (add `public CompileLog Log => throw new NotSupportedException(CompileOnly);` + nested `CompileLog` with the four `void` methods `(string message, Exception? exception = null)`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxScriptHost.cs:85-90` (add `public SandboxLog Log { get; } = new();`) and create the `SandboxLog` class in the same file: each method writes `"[level] message"` (+ `exception` on a following line) through `Console.Out`, which the sandbox already captures into `SandboxRunResult.ConsoleOutput` (`SandboxConsoleCapture`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/SandboxInboundScriptHost.cs:27` (same `Log` property) +- Test: `tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs` (a script calling `Log.Info("a"); Log.Warn("b", new Exception()); Log.Error("c"); Log.Debug("d");` compiles) +- Test: `tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs` (`Log.Info("x")` passes the gate — pins that `Log` is not on the deny list) +- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs` (make it green again: `Log` on both sides) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs` (a Test Run script with `Log.Info("hello")` returns `ConsoleOutput` containing `[info] hello`; an inbound-sandbox script likewise) + +Run the three projects' filters → PASS. Commit — `git commit -m "feat(scripts): Log global on the compile surface and both Test Run sandboxes; trust-gate pin (D4)"`. + +--- + +## Task 8: InboundAPI — `InboundScriptContext.Log` + `MaxLogLinesPerRun` + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 6, Task 7 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptLogger.cs` (same shape as the site `ScriptLogger` — copy the class, do **not** reference SiteRuntime; emitter builds the central `ScriptLog` row via `ScadaBridgeAuditEventFactory.Create(..., sourceNode: nodeIdentity.NodeName, executionId: inboundExecutionId)`, pushes it to the inline `traceSink` and, when traced, to `ICentralAuditWriter`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:338-440` (`ExecuteAsync`: build the logger with `trace`, `route.TraceSink`, the execution id; pass it to the context), `:585-625` (`InboundScriptContext` `+ public InboundScriptLogger Log { get; }`, ctor parameter with a null-safe default so existing constructions compile), and the script-globals declaration so `Log` binds at top level exactly like `Route`/`Database` +- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptions.cs` (`MaxLogLinesPerRun = 200`, validated `> 0` in the existing validator) +- Test: `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs` (traced execute of a method whose body calls `Log.Info("central")` → the trace sink received a `ScriptLog` row with the inbound execution id and `stepEventId == null`; untraced → sink empty, ILogger got the entry) + +Steps as Task 4/5. Commit — `git commit -m "feat(inbound-api): Log global for method scripts, central ScriptLog rows when traced (D4/D5)"`. + +--- + +## Task 9: InboundAPI — exception detail on the inbound row + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none (after Task 8 — same executor file) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:548-552` (`return new InboundScriptResult(false, null, "Internal script error", "SCRIPT_ERROR", ErrorDetail: ex.ToString());`), `:640-644` (`InboundScriptResult` `+ string? ErrorDetail = null`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs:276-290` (after `scriptResult`: `if (scriptResult.ErrorDetail is { } detail) httpContext.Items[AuditWriteMiddleware.ErrorDetailItemKey] = detail;`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/Middleware/AuditWriteMiddleware.cs:264-350` (`public const string ErrorDetailItemKey = "ScadaBridge.Audit.ErrorDetail";` and `errorDetail: thrown?.ToString() ?? httpContext.Items[ErrorDetailItemKey] as string`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs` (a throwing method → `ErrorMessage == "Internal script error"`, `ErrorDetail` contains the real message — and the HTTP body test still shows only `SCRIPT_ERROR`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs` (an item-key detail lands on the row's `ErrorDetail`; absent → null) + +Commit — `git commit -m "feat(inbound-api): method-script exception detail on the inbound audit row, never on the HTTP body (D3)"`. + +--- + +## Task 10: ManagementService — runner inbound row carries `ErrorDetail`; results pass `LogsTruncated` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** Task 11, Task 12 (after Task 9) + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs:180-192` (`errorDetail: result.ErrorDetail`), `:145` / `:307` and the result construction lines (`FlowDebugRunResult.LogsTruncated = collector.LogsTruncated`) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/FlowDebugCommands.cs:64-72` (`FlowDebugRunResult` `+ bool LogsTruncated = false`, trailing) +- Test: `tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs` (a `SCRIPT_ERROR` executor result with detail → the written inbound row's `AuditDetails.ErrorDetail` equals it; `LogsTruncated` round-trips) + +Commit — `git commit -m "feat(management): Flow Debugger inbound row carries exception detail; LogsTruncated on the run result"`. + +--- + +## Task 11: CentralUI — `FlowGraphView` log badge + failed-node exception subtitle + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 10, Task 12 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor:88-101` (footer: wrap subtitle/pill and the new badge in `
`; badge `` with up to three `` counts, highest severity first) +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs` (`internal static (int Errors, int Warnings, int Info) LogCounts(FlowStep? s)` — `error`→Errors, `warn`→Warnings, everything else→Info; `internal static string? ExceptionSubtitle(FlowStep s)` → `"{TypeNameWithoutNamespace}: {message}"` parsed from the first line of `ErrorDetail`, else `ErrorMessage`, only when `Outcome is Failed or TimedOut`; `DisplaySubtitle` uses it for failed real steps) +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css` (`.fg-foot` flex row; `.fg-logs i.e/.w/.i` colours from the kit tokens exactly as the pills; `.fg-node.failed .fg-subtitle { color: var(--bad, var(--bs-danger)); font-family: var(--mono); }`) +- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs` (no lines → no `flow-node-logs`; 1 error + 2 warn + 3 info → badge text `✕ 1 ! 2 ≡ 3` in that order; failed step with `ErrorDetail` `"System.InvalidOperationException: Recipe 42 rejected\n at X"` → subtitle `InvalidOperationException: Recipe 42 rejected`; failed step without detail → the `ErrorMessage`) + +Commit — `git commit -m "feat(central-ui): FlowGraphView log-count badge and exception subtitle on failed nodes (D8)"`. + +--- + +## Task 12: CentralUI — `FlowStepDetail` Log tab + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 10, Task 11 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor` (tabs: insert `"Log"` after `"Output"` in both tab arrays, label `Log (n)` where n = `Logs.Count`, hidden when the step has neither lines nor `ErrorDetail`; pane: exception block `data-test="flow-step-exception"` — `
` with type line, message, `
` detail, `Copy` button (JS `navigator.clipboard.writeText`, `IJSRuntime`, swallow failures); level filter `All | Warn +` (`data-test="flow-step-log-filter"`); table `data-test="flow-step-log-lines"` columns Time (UTC, `HH:mm:ss.fff`) · Level pill · Source · Message, an attached exception rendered as a second line in the Message cell; a hint `n more on ` when other steps of the same execution carry lines, rendered as a button that invokes the new `[Parameter] EventCallback OnSelectStep`; the Output tab's alert gains `see exception` when `ErrorDetail` is set; `OnParametersSet`: when the **selected step changes** (track `_lastStepId`) and the new step has `ErrorDetail`, `_tab = "Log"`, else `"Input"` if the current tab is not visible)
+- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor.css` (if the component has one; otherwise the styles live in the page's `.razor.css` — check which exists and follow it: `.fsd-exc` red left stripe on `--bad-bg`, `.fsd-lvl.info/.warn/.error/.debug`)
+- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs` (tab absent without lines/detail; present with count; failed-with-detail step opens on Log and shows type + message + pre; `Warn +` hides info/debug rows; the cross-step hint invokes `OnSelectStep` with the other step; the Output alert's link switches to Log)
+
+Commit — `git commit -m "feat(central-ui): FlowStepDetail Log tab — exception block, level-filtered lines, cross-step hint (D8)"`.
+
+---
+
+## Task 13: CentralUI — Flow Debugger page: strip counts, truncation notes, selection hookup
+
+**Classification:** standard
+**Estimated implement time:** ~4 min
+**Parallelizable with:** none (after Tasks 11, 12)
+
+**Files:**
+- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor:153-194` (after `Writes`: `Log@LogSummary` when any line exists; `log lines truncated inline — Replay is complete` when `_graph?.LogsTruncated == true`; `@n log line(s) could not be placed on a step` when `UnattachedLogLines > 0`), `:283-287` (``)
+- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs` (`LogSummary` = `"{e} errors · {w} warnings · {i} info"` summed over `Steps.SelectMany(s => s.Logs ?? [])`; every `FlowGraphBuilder.Build(...)` call passes `logsTruncated: _raw?.LogsTruncated ?? false`; `MergeRefreshedGraph` keeps it)
+- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/FlowDebugService.cs` + `IFlowDebugService.cs:18-25` (`FlowDebugOutcome` carries `LogsTruncated` from `FlowDebugRunResult`)
+- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs` (counts render; truncation badge on `LogsTruncated`; unattached note; the state chip stays `Completed` for a run whose only errors are `Log.Error` lines on Ok steps — the log level never changes the run state)
+
+Commit — `git commit -m "feat(central-ui): Flow Debugger status strip log counts + inline-truncation notes"`.
+
+---
+
+## Task 14: CLI — `logs` column and `--show-logs`
+
+**Classification:** small
+**Estimated implement time:** ~4 min
+**Parallelizable with:** Task 13
+
+**Files:**
+- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs:85-115` (add a `logs` column = `step.Logs?.Count ?? 0`; a `showLogs` flag prints each line indented under its step as `  HH:mm:ss.fff LEVEL source message` and, for a failed step with `ErrorDetail`, the first three lines of the detail indented)
+- Modify: the `flow-debug` command definitions (`run-method`, `run-script`, `run-value-change`, `replay`) — add `--show-logs` (find them next to the projection: `grep -rn "flow-debug" src/ZB.MOM.WW.ScadaBridge.CLI/Commands/*.cs`)
+- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (`### flow-debug` table columns + the flag)
+- Test: `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs` (a result with two lines on the script step renders `logs 2` and, with `--show-logs`, the two indented lines)
+
+Commit — `git commit -m "feat(cli): flow-debug logs column and --show-logs"`.
+
+---
+
+## Task 15: Specs, register, rig demo + verification step J, full build
+
+**Classification:** standard
+**Estimated implement time:** ~5 min
+**Parallelizable with:** none
+
+**Files:**
+- Modify: `docs/requirements/Component-CentralUI.md:218-220` (Flow Debugger bullet: log badge, Log tab, strip counts), `docs/requirements/Component-SiteRuntime.md` (`Log` global beside `Alarms`; `TraceCapturingLogger`; `MaxLogLinesPerRun`; the process-global `Console.Out` note from design §9), `docs/requirements/Component-InboundAPI.md` (`Log`; `InboundScriptResult.ErrorDetail` never on the body), `docs/requirements/Component-AuditLog.md:58-62` (`ScriptLog` kind row; per-channel retention covers it), `docs/requirements/Component-ScriptAnalysis.md` (compile surface `Log`), `docs/plans/2026-09-05-flow-debugger-design.md` (status line: v1.4 pointer), `CLAUDE.md` (Flow Debugger bullet: **v1.4** one sentence — `Log` global, `ScriptLog` rows folded onto steps, exception detail, caps, "never await audit writes" still holds because emission is fire-and-continue), `docs/plans/2026-07-08-deferred-work-register.md` (row 110: strike the console half, keep Direction B; new row: node-logger execution-id enrichment for other actors' lines — design §9)
+- Create: `docker/seed-flow-log-demo.sh` (a `Call` script `FlowLogDemo` on `SimMachine` at site-a: `Log.Info`, `Log.Debug`, a static write, `Log.Warn`, then `throw new InvalidOperationException("demo failure")`; idempotent like `seed-flow-outbound-demo.sh`)
+- Modify: `docker/verify-delmia-notifier.sh` (step **J**: `flow-debug run-script … FlowLogDemo --show-logs --json`; assert the `ScriptRun` step has `outcome Failed`, `errorDetail` containing `InvalidOperationException`, and ≥ 3 log lines with levels ⊇ {info, debug, warn}; then `flow-debug replay ` asserts the same counts; prints `J   step logs live+replay -> YES`), `docker/README.md` (one line)
+
+**Steps:** write the docs; `dotnet build ZB.MOM.WW.ScadaBridge.slnx -nologo -v q` (0 errors); `dotnet test` per touched project (Commons, SiteRuntime, ScriptAnalysis, InboundAPI, ManagementService, CentralUI, CLI — Playwright excluded); `bash docker/deploy.sh`; `bash docker/seed-flow-log-demo.sh`; `DELMIA_API_KEY=… bash docker/verify-delmia-notifier.sh` → H, I and J all `YES`; open `/deployment/flow-debugger?executionId=` and confirm the script node shows the red exception subtitle and a `✕ 1 ! 1 ≡ 2` badge, the Log tab opens first with the exception block, and the write node carries no badge (no runtime warning on a clean write). Paste the J line into the commit — `git commit -m "docs+rig(flow-debugger): step logs v1.4 specs, register rows, FlowLogDemo seed + verification step J"`.
+
+---
+
+## Verification (whole plan)
+
+1. Solution build clean; all seven test projects green (the pre-existing Communication gRPC gauge flake and env-only Playwright E2E excepted, as recorded in memory).
+2. Rig steps H, I, J `YES`.
+3. Browser: a Live run of `FlowLogDemo` renders the exception on the script node and its lines under Log; `Replay` of the same root shows identical badges and lines; an untraced real call to a method whose script calls `Log.*` writes no `ScriptLog` rows (`scadabridge audit query --channel ScriptTrace --kind ScriptLog` returns nothing for it) but the line appears in the central container log.
diff --git a/docs/plans/2026-09-09-flow-debugger-step-logs.md.tasks.json b/docs/plans/2026-09-09-flow-debugger-step-logs.md.tasks.json
new file mode 100644
index 00000000..5bd4c333
--- /dev/null
+++ b/docs/plans/2026-09-09-flow-debugger-step-logs.md.tasks.json
@@ -0,0 +1,128 @@
+{
+  "planPath": "docs/plans/2026-09-09-flow-debugger-step-logs.md",
+  "tasks": [
+    {
+      "id": 1,
+      "subject": "Task 1: Commons \u2014 ScriptLog kind, trace extra fields, FlowStep.ErrorDetail/Logs",
+      "status": "pending"
+    },
+    {
+      "id": 2,
+      "subject": "Task 2: Commons \u2014 FlowTraceCollector.AddLowPriority + LogsTruncated",
+      "status": "pending"
+    },
+    {
+      "id": 3,
+      "subject": "Task 3: Commons \u2014 FlowGraphBuilder attaches log lines to steps",
+      "status": "pending",
+      "blockedBy": [
+        1
+      ]
+    },
+    {
+      "id": 4,
+      "subject": "Task 4: SiteRuntime \u2014 ScriptLogger, TraceStepScope, TraceCapturingLogger",
+      "status": "pending",
+      "blockedBy": [
+        1
+      ]
+    },
+    {
+      "id": 5,
+      "subject": "Task 5: SiteRuntime \u2014 wire Log into ScriptRuntimeContext + ScriptGlobals",
+      "status": "pending",
+      "blockedBy": [
+        1,
+        2,
+        4
+      ]
+    },
+    {
+      "id": 6,
+      "subject": "Task 6: SiteRuntime \u2014 launcher errorDetail, dropped-lines flush, MaxLogLinesPerRun",
+      "status": "pending",
+      "blockedBy": [
+        5
+      ]
+    },
+    {
+      "id": 7,
+      "subject": "Task 7: Script surfaces \u2014 ScriptCompileSurface.Log, sandbox mirrors, trust pin",
+      "status": "pending",
+      "blockedBy": [
+        5
+      ]
+    },
+    {
+      "id": 8,
+      "subject": "Task 8: InboundAPI \u2014 InboundScriptContext.Log + MaxLogLinesPerRun",
+      "status": "pending",
+      "blockedBy": [
+        1
+      ]
+    },
+    {
+      "id": 9,
+      "subject": "Task 9: InboundAPI \u2014 exception detail on the inbound row",
+      "status": "pending",
+      "blockedBy": [
+        8
+      ]
+    },
+    {
+      "id": 10,
+      "subject": "Task 10: ManagementService \u2014 runner inbound row ErrorDetail; LogsTruncated on results",
+      "status": "pending",
+      "blockedBy": [
+        9
+      ]
+    },
+    {
+      "id": 11,
+      "subject": "Task 11: CentralUI \u2014 FlowGraphView log badge + failed-node exception subtitle",
+      "status": "pending",
+      "blockedBy": [
+        1
+      ]
+    },
+    {
+      "id": 12,
+      "subject": "Task 12: CentralUI \u2014 FlowStepDetail Log tab",
+      "status": "pending",
+      "blockedBy": [
+        1
+      ]
+    },
+    {
+      "id": 13,
+      "subject": "Task 13: CentralUI \u2014 Flow Debugger page strip counts, truncation notes, selection hookup",
+      "status": "pending",
+      "blockedBy": [
+        3,
+        10,
+        11,
+        12
+      ]
+    },
+    {
+      "id": 14,
+      "subject": "Task 14: CLI \u2014 logs column and --show-logs",
+      "status": "pending",
+      "blockedBy": [
+        3
+      ]
+    },
+    {
+      "id": 15,
+      "subject": "Task 15: Specs, register, rig demo + verification step J, full build",
+      "status": "pending",
+      "blockedBy": [
+        6,
+        7,
+        13,
+        14
+      ]
+    }
+  ],
+  "lastUpdated": "2026-09-09T15:20:12.803167+00:00"
+}
\ No newline at end of file