Merge feat/flow-debugger-step-logs: Flow Debugger v1.4 — Log global on all script surfaces, ScriptLog rows folded onto steps, exception detail on failed nodes

This commit is contained in:
Joseph Doherty
2026-09-09 13:37:29 -04:00
82 changed files with 6423 additions and 121 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -100,7 +100,8 @@ docker/
├── seed-delmia-sim.sh # Seed the in-memory Delmia machine stand-in (DelmiaSim01) on site-a
├── seed-delmia-api.sh # Seed the DelmiaRecipeDownload inbound method + a scoped API key (token printed once)
├── seed-flow-outbound-demo.sh # Seed the Flow Debugger outbound-calls demo: RigRest external system, RigDb connection, FlowDemo list, OutboundDemo script + FlowOutboundDemo method/key
├── verify-delmia-notifier.sh # DelmiaNotifier end-to-end vs the rig: standby relay, active-central crash/takeover, rejoin; step H = Flow Debugger live run + replay (CLI flow-debug); step I = outbound API/DB/notify nodes live + replay
├── seed-flow-log-demo.sh # Seed the Flow Debugger STEP-LOGS demo: FlowLogDemo Call script on SimMachine (logs info/debug/warn, writes an attribute, then throws) — idempotent
├── verify-delmia-notifier.sh # DelmiaNotifier end-to-end vs the rig: standby relay, active-central crash/takeover, rejoin; step H = Flow Debugger live run + replay (CLI flow-debug); step I = outbound API/DB/notify nodes live + replay; step J = step logs + exception detail live + replay
├── teardown.sh # Stop and remove containers
├── central-node-a/
│ ├── appsettings.Central.json # Central node A configuration
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
set -euo pipefail
# Seed the Flow Debugger STEP-LOGS demo on the docker rig (plan
# docs/plans/2026-09-09-flow-debugger-step-logs.md, Task 15): one site Call script that writes a
# line at three levels, writes a static attribute, warns, and then throws — so a traced run shows
# BOTH halves of the step-logs feature on one graph:
#
# * the ScriptRun node ends Failed and carries the whole exception (errorDetail = ex.ToString()),
# rendered as a red `InvalidOperationException: demo failure` subtitle plus the Log tab's
# exception block;
# * the info / debug / warn lines fold onto their owning step as ScriptTrace/ScriptLog rows —
# the badge reads `✕ 0 ! 1 ≡ 2`-style counts, never separate nodes.
#
# What it creates:
# - Script FlowLogDemo (Call, no parameters) on template SimMachine (docker/seed-delmia-sim.sh),
# then redeploys DelmiaSim01 so the site actually has it.
#
# IDEMPOTENT: re-running UPDATES the script in place (template script update) instead of failing on
# the unique name, so this is the script to re-run after a rig rebuild.
#
# Prerequisites: bash docker/deploy.sh; docker/seed-sites.sh; docker/seed-delmia-sim.sh.
#
# Try:
# scadabridge --url http://localhost:9000 --username multi-role --password password \
# flow-debug run-script --instance-id <id> --script FlowLogDemo --show-logs
# then open /deployment/flow-debugger?executionId=<rootExecutionId>
# Covered by docker/verify-delmia-notifier.sh step J.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
INSTANCE_NAME="${INSTANCE_NAME:-DelmiaSim01}"
TEMPLATE_NAME="${TEMPLATE_NAME:-SimMachine}"
SCRIPT_NAME="${SCRIPT_NAME:-FlowLogDemo}"
# A static attribute that exists on SimMachine through the composed DelmiaReceiver child, and that
# nothing triggers on (RecipeDownloadFlag would fire the simulator script and muddy the graph).
WRITE_ATTRIBUTE="${WRITE_ATTRIBUTE:-RecipeProcessResultText}"
find_id() { # stdin = JSON array, $1 = key, $2 = value
python3 -c 'import json,sys; k,v=sys.argv[1],sys.argv[2]; print(next(x["id"] for x in json.load(sys.stdin) if x.get(k)==v))' "$1" "$2"; }
# The demo body. Log.* is the leveled per-run logging global (design 2026-09-09 D4): every line
# always reaches the node ILogger, and becomes a ScriptLog trace row only when the run is traced.
read -r -d '' DEMO_CODE <<'CS' || true
// Flow Debugger step-logs demo. Three levels, one real static write between them, then a throw:
// the lines fold onto the steps that produced them, and the throw gives the ScriptRun node its
// exception block. Nothing here is debugger-specific — Log.* is an ordinary script API.
Log.Info("FlowLogDemo starting");
Log.Debug("about to write");
await Children["DelmiaReceiver"].Attributes.SetAsync("RecipeProcessResultText", "42");
Log.Warn("demo warning after the write");
throw new System.InvalidOperationException("demo failure");
CS
echo "1/3 Locating template $TEMPLATE_NAME..."
TEMPLATE_ID=$("$CLI" template list "${A[@]}" | find_id name "$TEMPLATE_NAME")
echo " template id=$TEMPLATE_ID"
echo "2/3 Adding or updating script $SCRIPT_NAME (Call)..."
EXISTING_ID=$("$CLI" template get "${A[@]}" --id "$TEMPLATE_ID" | python3 -c '
import json, sys
t = json.load(sys.stdin)
name = sys.argv[1]
print(next((s["id"] for s in (t.get("scripts") or []) if s.get("name") == name), ""))' "$SCRIPT_NAME")
if [ -n "$EXISTING_ID" ]; then
"$CLI" template script update "${A[@]}" --id "$EXISTING_ID" --name "$SCRIPT_NAME" --trigger-type Call --code "$DEMO_CODE" > /dev/null
echo " updated script id=$EXISTING_ID"
else
EXISTING_ID=$("$CLI" template script add "${A[@]}" --template-id "$TEMPLATE_ID" --name "$SCRIPT_NAME" --trigger-type Call --code "$DEMO_CODE" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("id",""))')
echo " added script id=$EXISTING_ID"
fi
echo "3/3 Redeploying $INSTANCE_NAME so the site has $SCRIPT_NAME..."
INSTANCE_ID=$("$CLI" instance list "${A[@]}" | find_id uniqueName "$INSTANCE_NAME")
"$CLI" instance deploy "${A[@]}" --id "$INSTANCE_ID" > /dev/null
echo " instance id=$INSTANCE_ID"
echo
echo "FLOW_LOG_DEMO_TEMPLATE_ID=$TEMPLATE_ID FLOW_LOG_DEMO_INSTANCE_ID=$INSTANCE_ID SCRIPT=$SCRIPT_NAME (writes $WRITE_ATTRIBUTE)"
echo "Try: $CLI --url http://localhost:9000 --username multi-role --password password flow-debug run-script --instance-id $INSTANCE_ID --script $SCRIPT_NAME --show-logs"
+72 -2
View File
@@ -5,7 +5,7 @@ set -euo pipefail
# crash (docker kill = SIGKILL, so auto-down failover is exercised, not CoordinatedShutdown's
# graceful handover) + rejoin. Requires: seed-delmia-sim.sh + seed-delmia-api.sh already run, and
# DELMIA_API_KEY exported (the token printed by seed-delmia-api.sh). FLOW_ONLY=1 skips A-G (no
# notifier build, no docker kill) and runs the Flow Debugger steps H, H2 and I alone.
# docker kill) and runs the Flow Debugger steps H, H2, I and J alone.
#
# HISTORY (2026-09-02): the first run of this script found that inbound API keys were a PER-NODE
# SQLite store, so the key minted through the active node did not exist on the standby and every
@@ -41,6 +41,11 @@ set -euo pipefail
# DB write Submitted or — DB reachable, immediate-completion path — already Delivered, the
# notification Submitted; replay is retried <= 90 s until the write is Delivered and the
# notification has a terminal NotifyDeliver row). Skipped with a note when the demo is not seeded.
# J Flow Debugger STEP LOGS (2026-09-09) -> YES (FlowLogDemo — seed-flow-log-demo.sh — logs
# info/debug/warn, writes a static attribute, then throws: the ScriptRun step comes back Failed with
# errorDetail naming InvalidOperationException, and >= 3 ScriptTrace/ScriptLog rows carry levels
# {info,debug,warn}. Replay asserts the same from the central audit store. Skipped with a note when
# the demo is not seeded.)
: "${DELMIA_API_KEY:?export DELMIA_API_KEY=sbk_... first}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"; PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -81,7 +86,7 @@ for n in a b; do
done
if [ "${FLOW_ONLY:-}" = 1 ]; then
echo "== FLOW_ONLY=1: skipping A-G (notifier + failover drill); running the Flow Debugger steps H, H2 and I only"
echo "== FLOW_ONLY=1: skipping A-G (notifier + failover drill); running the Flow Debugger steps H, H2, I and J only"
else
echo "== A baseline (active first)"; write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "$DELMIA_API_KEY"; notify A YES
echo "== B standby first (relay)"; write_cfg "$(url_of "$STB"),$(url_of "$ACT")" "$DELMIA_API_KEY"; notify B YES
@@ -232,4 +237,69 @@ sys.exit(0 if ok else 1)' "$1"
echo " replay: $I_REPLAY"
echo "I outbound live+replay -> YES"
fi
# J — Flow Debugger step logs + exception detail (design 2026-09-09). FlowLogDemo logs at three
# levels, writes a static attribute, then throws. Live: the site returns its ScriptRun row (Failed,
# errorDetail = the exception's ToString()) and the ScriptLog rows inline on the same reply. Replay:
# the same rows are read back from the central AuditLog once the 5 s telemetry push has landed.
echo "== J Flow Debugger step logs (live + replay)"
J_INSTANCE_ID=$("$CLI" instance list "${H_ARGS[@]}" | python3 -c 'import json,sys; xs=json.load(sys.stdin); print(next((i["id"] for i in xs if i.get("uniqueName")==sys.argv[1]), ""))' "$MACHINE")
if [ -z "$J_INSTANCE_ID" ]; then
echo "J step logs live+replay -> SKIPPED ($MACHINE not seeded — run docker/seed-delmia-sim.sh)"
else
j_check() { # $1 = live|replay; stdin = FlowDebugRunResult JSON
python3 -c '
import json, sys
mode = sys.argv[1]
r = json.load(sys.stdin)
rows = []
for e in r.get("events", []):
d = json.loads(e["detailsJson"])
d["_extra"] = json.loads(d["extra"]) if d.get("extra") else {}
rows.append(d)
runs = [d for d in rows if d.get("kind") == "ScriptRun"]
logs = [d for d in rows if d.get("kind") == "ScriptLog"]
levels = sorted({(d["_extra"].get("logLevel") or "?") for d in logs})
problems = []
if not runs:
problems.append("no ScriptRun row")
else:
failed = [d for d in runs if d.get("status") == "Failed"]
if not failed:
problems.append("ScriptRun not Failed (" + ",".join(str(d.get("status")) for d in runs) + ")")
elif not any("InvalidOperationException" in (d.get("errorDetail") or "") for d in failed):
problems.append("no InvalidOperationException in errorDetail")
if len(logs) < 3:
problems.append("only " + str(len(logs)) + " ScriptLog row(s)")
missing = [lv for lv in ("info", "debug", "warn") if lv not in levels]
if missing:
problems.append("levels missing " + ",".join(missing))
ok = not problems
print(("YES" if ok else "NO") + " root=" + str(r.get("rootExecutionId")) + " logs=" + str(len(logs))
+ " levels=" + ",".join(levels) + " scriptRun=" + ",".join(str(d.get("status")) for d in runs)
+ (" problems=" + ";".join(problems) if problems else "") + (" error=" + str(r.get("errorCode")) if r.get("errorCode") else ""))
sys.exit(0 if ok else 1)' "$1"
}
# The script throws by design, so the run reports Completed=false with CALL_FAILED — that is the
# expected outcome here, and the CLI still prints the full result. Do not gate on the exit code.
set +e; J_RUN=$("$CLI" flow-debug run-script --instance-id "$J_INSTANCE_ID" --script FlowLogDemo "${H_ARGS[@]}"); set -e
case "$J_RUN" in
*rootExecutionId*) ;;
*) echo "J step logs live+replay -> SKIPPED (FlowLogDemo not seeded — run docker/seed-flow-log-demo.sh): $(echo "$J_RUN" | head -2)"; J_RUN=""; ;;
esac
if [ -n "$J_RUN" ]; then
J_LIVE=$(echo "$J_RUN" | j_check live) || { echo "FAIL: live run: $J_LIVE"; exit 1; }
echo " live: $J_LIVE"
J_ROOT=$(echo "$J_RUN" | python3 -c 'import json,sys; print(json.load(sys.stdin)["rootExecutionId"])')
J_REPLAY=""
for i in $(seq 1 20); do
set +e; J_REPLAY=$("$CLI" flow-debug replay --execution-id "$J_ROOT" "${H_ARGS[@]}" | j_check replay); J_RRC=$?; set -e
[ "$J_RRC" -eq 0 ] && break
sleep 2
done
[ "${J_RRC:-1}" -eq 0 ] || { echo "FAIL: replay never showed the logs + exception after 40 s: $J_REPLAY"; exit 1; }
echo " replay: $J_REPLAY"
echo " root: $J_ROOT (open /deployment/flow-debugger?executionId=$J_ROOT)"
echo "J step logs live+replay -> YES"
fi
fi
echo "== done. Notifier log: $RUN_DIR/delmia-notifier.log"
@@ -102,10 +102,15 @@ All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round
| 107 | **Prod `CvdReactor.ProcessRecipeDownload` still addresses `Children["DelmiaRecv"]` after the 2026-06-27 `DelmiaRecv` → `DelmiaReceiver` composition rename** — inferred from the deployed script (`docs/api-specs/Delmia-Recipe-Download-API.md` §7 item 1): `ChildrenAccessor` concatenates the name into a path, an unknown path yields null, so every call answers `Receiver ready flag not set to true` regardless of the real flag. No call has hit prod since the rename, so this is inferred, not observed. The docker rig's stand-in (`docker/seed-delmia-sim.sh`) uses `Children["DelmiaReceiver"]` and the same handshake answers correctly, which is the working reference. | Found 2026-09-02 while capturing the as-deployed spec; QA copy unread (credentials) | Separate change with its own deployment record: `scadabridge template script update` on the `CvdReactor` template + redeploy Z28061/Z28062/Z28061Sim, then a live `WWNotifier` call. | Before the first production Delmia recipe download. |
| 108 | ~~**Inbound API keys are a PER-NODE SQLite store — a central failover invalidates every inbound client's key**~~ **RESOLVED 2026-09-02 — `ZB.MOM.WW.Auth` 0.3.0 (new `ZB.MOM.WW.Auth.ApiKeys.SqlServer` package) + ScadaBridge `ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer`.** A central pair now shares one row set: both nodes' key stores, admin stores, and audit stores point at the same `zbauth` schema in the configuration database (connection string defaults to `ScadaBridge:Database:ConfigurationDb` — never a `${secret:}` reference, to avoid a circular dependency on the SQL-backed secrets store), so create/revoke/rotate/scope changes are cluster-wide immediately instead of per-node. Each node's existing SQLite file is imported once at boot (insert-where-absent, so both nodes importing is safe and the result is the union) and renamed `*.imported`. `Provider` defaults to `Sqlite` (unchanged behavior for single-node installs, MxAccessGateway, HistorianGateway); a clustered Central left on `Sqlite` now gets a boot-time `StartupValidator` Warning naming this design instead of silently keeping the gap. Both docker rigs (`docker/`, `docker-env2/`) run `Provider=SqlServer` on both centrals; `docker/sync-inbound-api-keys.sh` is deleted, superseded by the shared store. Rig proof 2026-09-03 (`docker/`, both centrals redeployed on `Provider=SqlServer`): both nodes imported their SQLite file at the same second with no PK race — central-a `1 key / 264 audit rows inserted`, central-b `0 / 26 inserted, 1 / 264 already present` (the union), files renamed `*.imported`; a key minted via `:9001` answered `200` via `:9002`, `--enabled false` via `:9001` made both nodes answer `401` and `:9002`'s own list showed `enabled=False`, re-enable via `:9002` restored `200` on `:9001`, delete via `:9002` made `:9001` answer `401`; `verify-delmia-notifier.sh` with a freshly minted key and NO file copy: A/B/E/F `YES`, C/G `NO`, active-central `docker kill` takeover **28 s**, rejoin as standby in 2 s. `docker-env2` (second pair, `ScadaBridgeConfig2`): both centrals booted, `zbauth` v1 created, empty files imported. QA (`vq03`/`vq04`) and prod (`vd03`) still run `Provider=Sqlite` — their move is a planned deployment record (`docs/deployment/deployment-records/2026-09-XX-zb-servers-inbound-key-store.md`), not yet applied. Design: `docs/plans/2026-09-02-inbound-api-key-shared-store-design.md`. Original: | `docs/api-specs/Delmia-Recipe-Download-API.md` §8; `docker/sync-inbound-api-keys.sh` header | Fix is an architecture decision, not a patch: move the store to the shared SQL Server (as Secrets 0.5.0 did for its central store) or replicate the file; either way revocation must become cluster-wide. Needs the `ZB.MOM.WW.Auth.ApiKeys` library (scadaproj), not just this repo. | Before any 2-node central goes live with inbound API clients; before the next arch-review wave. |
| 109 | **`ZB.MOM.WW.Secrets.Replicator.SqlServer`'s store migrator retries its DDL with a flat `100 * attempt` backoff and no jitter — a latent concurrent-boot flake, open in Secrets** *(Low)*. Porting that migrator's retry loop verbatim for the Auth 0.3.0 SQL Server API-key store (register row 108) reproduced an intermittent exhaustion — about once in sixty 8-way concurrent-boot trials the five flat-backoff retries ran out before a DDL/lock conflict cleared; adding jitter to the Auth port's backoff took it to 0 failures in 180 trials. Secrets' own migrator has no concurrent-migrate test, so the same defect is presumed still open there — it just hasn't been exercised at 8-way concurrency, and a ScadaBridge central pair is only ever 2-way, so this is unlikely to bite in practice. | Found 2026-09-02 while implementing `docs/plans/2026-09-02-inbound-api-key-shared-store.md` (Auth 0.3.0 `SqlServerApiKeyStoreMigrator`) | Add jitter to `SqlServerSecretsStoreMigrator`'s backoff in `~/Desktop/scadaproj/ZB.MOM.WW.Secrets` (mirror the Auth fix) and add a `Migrate_Concurrently_FromEightTasks_AllSucceed`-shaped live test to catch a regression. Lives entirely in scadaproj, not this repo. | Next time Secrets' SQL Server migrator is touched, or if a wider-than-2-way concurrent boot of a Secrets-backed cluster is ever planned. |
| 110 | **Flow Debugger follow-ups: the *Vertical trace* list toggle (mockup Direction B) and console capture on `ScriptRun`** *(Low)*. v1 ships the Direction A swimlane only; the mockup's Direction B — the same steps as a vertical, filterable list with the lane as a column — was kept as an alternate for long flows and screen readers, and the detail panel's *Console* tab is absent because script `Console`/log output is not captured per run (`ScriptRun` rows carry parameters, return value, error and timing, not stdout). Both are additive: a list view over the existing `FlowGraph`, and an extra `Extra.console` field on the `ScriptRun` row's `ScriptTraceExtra` (payload cap applies). | Design `docs/plans/2026-09-05-flow-debugger-design.md` §11; plan `2026-09-05-flow-debugger.md` Task 23 | Neither was asked for by an operator; the swimlane + Timing/Writes tabs cover the acceptance flow (Delmia recipe download, rig step H), and console capture needs a per-run writer seam in `ScriptRuntimeContext` plus a redaction decision. | First operator request for a list view or for script log lines in the step detail. |
| 110 | **Flow Debugger follow-up: the *Vertical trace* list toggle (mockup Direction B)** *(Low)*. v1 ships the Direction A swimlane only; the mockup's Direction B — the same steps as a vertical, filterable list with the lane as a column — was kept as an alternate for long flows and screen readers. Additive: a list view over the existing `FlowGraph`. ~~and console capture on `ScriptRun`~~ **The console-capture half is RESOLVED 2026-09-09 (plan `docs/plans/2026-09-09-flow-debugger-step-logs.md`)** — and deliberately not as this row sketched it. `Extra.console` on the `ScriptRun` row was rejected (the 8 KB payload cap would silently drop lines, and helper-step attribution would need every helper to buffer); shipped instead is a leveled `Log` global on all four script surfaces plus one `ScriptTrace.ScriptLog` audit row per line, folded onto the step that produced it, with the failing step's full exception on `errorDetail`. `Console.Out` capture on a site node is recorded as permanently out of scope in the design's §9: `Console.Out` is process-global and shared by every concurrent run, so per-run capture is unsound. | Design `docs/plans/2026-09-05-flow-debugger-design.md` §11; plan `2026-09-05-flow-debugger.md` Task 23 | The list view was not asked for by an operator; the swimlane + Timing/Writes/Log tabs cover the acceptance flow (Delmia recipe download, rig steps H and J). | First operator request for a list view. |
| 111 | ~~**Flow Debugger outbound nodes: no Retry/Discard on a parked node, and no `?correlationId=` deep link from the node to the Site Calls / Notification report pages** *(Low)*~~ **RESOLVED 2026-09-06 (plan `docs/plans/2026-09-06-flow-debugger-followups.md`).** `FlowStep` now carries the row's `CorrelationId`; the detail panel deep-links to `/site-calls/report?correlationId=` / `/notifications/report?correlationId=` (both pages accept it through additive per-id query filters — `SiteCallQueryRequest.TrackedOperationIdFilter`, `NotificationOutboxQueryRequest.NotificationIdFilter` — pre-filter to the row and auto-open its detail), and a `Parked` node offers Retry / Discard in place through `IFlowDebugService.RetryOutboundAsync` / `DiscardOutboundAsync`, which reuse the report pages' relay, site-scope re-check and confirm wording, after which the page re-polls Replay until the node leaves Parked. Per-attempt request/response diff is still not shown (the lifecycle rows carry the error summary only). Found on the way: the prediction circuit deadlock (`4769e192`). | Plan 2026-09-06 "Out of scope"; `FlowStepDetail.razor` parked hint | The Site Calls page owns the retry semantics and its audit rows; duplicating the action on the debugger node would need the same Deployer gate + confirm flow. | First operator who reaches a parked node from the debugger and asks for the button. |
| 112 | **Flow Debugger: a SITE-side inline trace truncation is invisible to the operator** *(Low)*. `ScriptCallResult` / `RouteToCallResponse` carry `TraceEvents` but no `Truncated` flag, so the page's *event list truncated* badge reflects central's `FlowTraceCollector` only — a site run whose own collector dropped rows returns a silently short list. The new 1 MB byte budget (arch-review F1) makes site truncation reachable sooner than the old 500-event count cap did. The fix is additive: a trailing `bool Truncated = false` on both records, a new field on `RouteToCallResponseDto` (regen via `docker/regen-proto.sh sitecommand`), and `FlowDebugRunner` ORing it into `FlowDebugRunResult.Truncated`. | Arch-review remediation code review 2026-09-06 | Needs a proto regeneration plus a rig rebuild to prove end-to-end, and nothing is lost meanwhile: the site's audit rows are written in full, so a Replay of the same execution is never truncated. | The first Live run that shows fewer site steps than its Replay of the same execution. |
| 112 | **Flow Debugger: a SITE-side inline trace truncation is invisible to the operator** *(Low)*. `ScriptCallResult` / `RouteToCallResponse` carry `TraceEvents` but no `Truncated` flag, so the page's *event list truncated* badge reflects central's `FlowTraceCollector` only — a site run whose own collector dropped rows returns a silently short list. The new 1 MB byte budget (arch-review F1) makes site truncation reachable sooner than the old 500-event count cap did. The fix is additive: a trailing `bool Truncated = false` **and a `LogsTruncated` flag** on both records (step logs 2026-09-09 gave the site collector a second, low-priority lane whose overflow sets `LogsTruncated` rather than `Truncated`, so a site run that shed only log lines is invisible in exactly the same way — and the page already has the badge for it), a new field on `RouteToCallResponseDto` (regen via `docker/regen-proto.sh sitecommand`), and `FlowDebugRunner` ORing both into `FlowDebugRunResult.Truncated` / `FlowDebugRunResult.LogsTruncated`. | Arch-review remediation code review 2026-09-06 | Needs a proto regeneration plus a rig rebuild to prove end-to-end, and nothing is lost meanwhile: the site's audit rows are written in full, so a Replay of the same execution is never truncated. | The first Live run that shows fewer site steps than its Replay of the same execution. |
| 113 | **Flow Debugger: the method-entry site scope (F2) gates ROUTED site traffic only** *(Low)*. A site-scoped Deployer who Live-runs a method belonging to another plant is still refused at `RouteTarget.ResolveSiteAsync`, but the method's central-side side effects — its `Database.*` calls, `ExternalSystem` calls, notifications — execute before or regardless of that refusal. Only routing is scoped. | Arch-review remediation code review 2026-09-06 | F2's stated property ("no site traffic outside the operator's scope") holds as written. Going further needs a per-method site affinity, which does not exist in the model — ApiMethods are central objects with no site column — so there is nothing to scope a method against without inventing that concept. | A decision to give `ApiMethod` a site affinity, or an operator report of a scoped user reaching another plant's central-side effects. |
| 114 | *Never issued.* The 2026-09-09 step-logs close-out minted two rows numbered **113** — this one and the "other actors' log lines" follow-up. The duplicates were renumbered **115** / **116** at final review (I2) and 114 was left unused rather than reshuffling every number after it; register numbers are permanent identifiers, cited from code comments and `CLAUDE.md`. | — | — | — |
| 115 | **Other actors' log lines are not captured for an execution — only the script's own `Log.*` and the runtime lines inside a scoped helper** *(Low)*. The Flow Debugger's step logs (2026-09-09) capture what the script logs and what `ScriptRuntimeContext`'s own logger emits while a run is traced, because those two go through one seam. What the *Instance Actor*, the *DCL*, the alarm actors or the S&F engine log while working on the same execution never reaches the graph: there are **zero** `LogContext.PushProperty` calls in the repo, `Enrich.FromLogContext()` is not configured on the Serilog pipeline (`TraceContextEnricher` stamps W3C ids only), and `site_events` has no execution-id column. Capturing them is a separate enrichment initiative: push `ExecutionId` (and instance/script) onto the ambient `LogContext` at run start, enable `Enrich.FromLogContext()`, add an execution-id column + index to `site_events`, then either sink matching entries into `ScriptLog` rows or query the site event log by execution id. | Step-logs design `docs/plans/2026-09-09-flow-debugger-step-logs-design.md` §2 + §9 ("Out of scope") | Out of proportion to the ask: it changes the logging pipeline of every actor on a site node, adds a replicated-table column (`site_events` is one of LocalDb's ten `ReplicatedTables`, so the change is a both-nodes-together schema edit), and risks a volume increase on untraced runs unless the enrichment is itself gated. The script-authored lines cover the debugging case the Flow Debugger exists for. | First investigation where the answer sits in what the Instance Actor or the DCL logged during a traced run and the operator cannot correlate it by hand. |
| 116 | **`CompileSurfaceParityTests.AccessorMirrorPairs` cannot guard the `(ScriptLogger, CompileLog)` pair — the theory has no per-member exclusion mechanism** *(Low)*. The arity theory asserts the compile mirror exposes at least as many overloads as the runtime accessor for **every** public instance method, and `ScriptLogger` has one the mirror must NOT have: `FlushDropped()`, the run-end closing-row flush, which is called by `ScriptRunLauncher`, not by a script. Adding the pair today would fail the theory for a member that is correctly absent, so the pair is left out and `Log`'s four methods are guarded only by the top-level name-superset check plus the hand-written surface tests. The fix is an exclusion seam on the theory — an optional `IReadOnlySet<string> ignoredRuntimeMethods` per row (or an `[NotScriptReachable]` marker attribute on `FlushDropped`, which also documents the intent at the declaration) — after which the pair joins `AccessorMirrorPairs`. | Found while wiring Task 15 of `docs/plans/2026-09-09-flow-debugger-step-logs.md`; test `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs` | No drift is possible without a code change to `ScriptLogger`, and a missing `Log` method on the mirror surfaces immediately as a `CS1061` at the design-time gate for any script that calls it. The seam is a test-infrastructure change that touches every existing row's signature. | The next public method added to `ScriptLogger` (or to any runtime accessor whose mirror must deliberately omit a member) — at that point build the exclusion seam and add the pair. |
| 117 | **Flow Debugger site trace pull: `FlowDebugRunner.WriteAndPollAsync` fetches ONE bounded page with a fixed `since`** *(Low)*. The write-and-poll loop asks the site for `FlowDebugRunner.PullTraceEventsMax` rows per poll, always `since` the run start, and the site serves `ORDER BY OccurredAtUtc ASC LIMIT` — so the page is the OLDEST rows in the window, never a continuation of the previous one. Step logs made that reachable: one traced run can now emit up to 201 `ScriptLog` rows, so a handful of chatty runs inside the window can fill the page and push a later spawned run's `ScriptRun` row past the end of it, at which point the loop never sees the awaited run and reports a false `NO_TRIGGER_WITHIN_WINDOW` for a run that did happen. Final review I1 raised the cap 500 -> 2 000 (the site's own ceiling, `SiteStreamGrpcServer.MaxTraceEventsPerPull`), which makes it ~10x harder but does not remove it. The real fix is to PAGE — advance `since` to the last-seen `OccurredAtUtc` between polls (keeping the already-known ids so nothing is double-counted) — or to exclude `ScriptLog` from the awaited-run poll entirely and pull the log rows once, after the run has settled. | Final review of `docs/plans/2026-09-09-flow-debugger-step-logs.md` (I1); `src/ZB.MOM.WW.ScadaBridge.ManagementService/FlowDebug/FlowDebugRunner.cs` `WriteAndPollAsync` | 2 000 is the site's hard ceiling, so the headroom cannot be bought again with a bigger number; paging needs the poll loop to track a cursor and the awaited-run predicate to survive a partial view, which is more than the raise was worth at review time. | The first `NO_TRIGGER_WITHIN_WINDOW` reported for a run whose Replay of the same root DOES show the spawned `ScriptRun`. |
| 118 | **Pre-existing bUnit flake: `CentralUI.Tests.Pages.NotificationReportDetailModalTests.CloseButton_DismissesModal` (~10 %)** *(Low)*. Fails intermittently with `UnknownEventHandlerIdException` — the test resolves the close button once and clicks it after a re-render has already retired that element's handler id. Not caused by the step-logs work: the file is untouched since `40944a0d`. The fix is a one-liner — re-`Find` the button immediately before the click (the pattern the other modal tests already use) rather than reusing an element captured earlier in the test. | Observed during the step-logs full-suite runs, 2026-09-09 | It is a test-only race with a known cause and a known fix; it was out of scope for a remediation pass whose rule was to touch only the files under review. | Next time `CentralUI.Tests` is worked on, or the first time the flake blocks a gate run. |
| 68 | ~~**`deploy.sh` run from a git worktree silently rehomes every node's bind-mount data dir**~~ **RESOLVED 2026-08-15 (guard added in the same close-out).** The compose files' bind mounts are relative (`./site-a-node-a/data`), so a deploy from a worktree checkout mounts the worktree's copies — the rig keeps running and looks healthy, but its SQLite stores split from the main checkout's and everything written there is orphaned by the next deploy from main. Bit the rows-46–53 close-out live: the seeding agent's `rig-probe-1` deploy persisted into worktree-homed dirs and "vanished" after the orchestrator's redeploy (no tombstone, no product bug — LWW/tombstone forensics ruled out deletion; the row sat in `.claude/worktrees/…/docker/site-a-node-a/data/site-localdb.db` the whole time). Both `docker/deploy.sh` and `docker-env2/deploy.sh` now refuse to run from a `.claude/worktrees/` path (override: `SCADABRIDGE_ALLOW_WORKTREE_DEPLOY=1`). | Found during the rows-46–53 close-out | Closed 2026-08-15. | — |
## Resolved (verified against the code 2026-07-10)
@@ -1,6 +1,6 @@
# Flow Debugger — design
**Status:** implemented 2026-09-06 (branch `feat/flow-debugger`); plan `2026-09-05-flow-debugger.md` (24 tasks, all complete). Residual follow-ups: deferred-work register row 110. **v1.1 outbound calls** (a fourth *External* lane for API / DB / notification nodes, lifecycle folding, async/spawn edges, pending refresh) — see the addendum plan `2026-09-06-flow-debugger-outbound-calls.md`; §4's "three lanes fixed" is amended by its D2. **v1.2 follow-ups** (node Retry/Discard, `?correlationId=` deep links to the report pages, the prediction circuit-deadlock fix, Playwright coverage) — plan `2026-09-06-flow-debugger-followups.md`; register row 111 closed.
**Status:** implemented 2026-09-06 (branch `feat/flow-debugger`); plan `2026-09-05-flow-debugger.md` (24 tasks, all complete). Residual follow-ups: deferred-work register row 110. **v1.1 outbound calls** (a fourth *External* lane for API / DB / notification nodes, lifecycle folding, async/spawn edges, pending refresh) — see the addendum plan `2026-09-06-flow-debugger-outbound-calls.md`; §4's "three lanes fixed" is amended by its D2. **v1.2 follow-ups** (node Retry/Discard, `?correlationId=` deep links to the report pages, the prediction circuit-deadlock fix, Playwright coverage) — plan `2026-09-06-flow-debugger-followups.md`; register row 111 closed. **v1.3 script entry** (site → instance → script as a second entry point) — design `2026-09-08-flow-debugger-script-entry-design.md`. **v1.4 step logs and exceptions** (a `Log` global on every script surface, `ScriptTrace.ScriptLog` rows folded onto the step that produced them, the failing step's full exception on the node, per-run caps) — design `2026-09-09-flow-debugger-step-logs-design.md`, plan `2026-09-09-flow-debugger-step-logs.md`; register row 110's console-capture half closed by it.
**Mockup:** Claude Design canvas *Flow Debugger Mockup* — <https://claude.ai/code/artifact/3f3e81e5-8e5e-4333-bdf6-59256108494d> (Direction A swimlane boards + two low-fi alternates + decision notes).
**Owner components:** Central UI (#page), Inbound API, Site Runtime, Audit Log, Communication, Script Analysis, Configuration Database.
@@ -0,0 +1,156 @@
# Flow Debugger — Step Logs and Exceptions (design)
**Date:** 2026-09-09
**Status:** implemented 2026-09-09 (plan `2026-09-09-flow-debugger-step-logs.md`, 15 tasks). Deltas between this design and what shipped are marked *as built* in place.
**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<Guid?>`): 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 ride the collector's low-priority lane — as built that is an optional parameter on the existing method, `FlowTraceCollector.Add(evt, lowPriority: true)`, not a fourth public `AddLowPriority` method (Commons keeps its public method count tight, and every caller already had the event in hand): 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` (see §7 for the shipped wording). | 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>?`);
`FlowLogLine(EventId, ExecutionId, OccurredAtUtc, Level, Source, Message, ExceptionMessage,
ExceptionDetail, StepEventId, Seq)` — as built, the line keeps its own `ExecutionId` (so a folded
step can say which execution a line came from, and the cross-step hint can name it) plus the
attribution inputs `StepEventId` and `Seq` that `FlowGraphBuilder` sorted by, rather than discarding
them at projection time. `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<Guid?>`) 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 <step>" selects that step via a new `OnSelectStep` callback).
`FlowDebugger`: strip counts (`flow-log-counts`, `Log: n errors · n warnings · n info`, counting
LINES not steps), the `flow-logs-truncated` badge and the `flow-unattached-logs` note; `ApplyOutcome`
unchanged (a run with only warning lines is still Completed).
**Truncation badge wording and its clearing rule.** The badge reads **"inline log lines capped —
refresh outcomes pulls the rest"**: it is a *this-copy-is-short* notice with the remedy in it, not a
data-loss warning — the rows exist in full in the audit store, and Replay reads them all. It is
driven by `FlowGraph.LogsTruncated`, which the page recomputes from the rows it currently holds, so
a *Refresh outcomes* that merges in more log rows **clears the badge on its own**; it must never be
latched on the run. (Contrast `Truncated`, the event-list cap, which is a property of the Live reply
itself.)
## 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).
@@ -0,0 +1,357 @@
<title>Flow Debugger Step Logs</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
:root {
--ground: #f4f3ee; --surface: #fbfaf7; --surface-2: #f0efe9;
--ink: #1f231f; --ink-soft: #5d625d; --ink-faint: #8a8f89;
--rule: #dcdbd3; --rule-strong: #c7c6bd;
--accent: #2f5fd0; --active-bg: #eef2fc;
--ok: #2e7d4a; --ok-bg: #e4f2e8; --warn: #b8741a; --warn-bg: #fbf0dc; --danger: #b3362d; --danger-bg: #f9e3e0;
--sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #1b1d1b; --surface: #242724; --surface-2: #2c2f2c;
--ink: #e8e8e1; --ink-soft: #b3b6b0; --ink-faint: #7f837e;
--rule: #383b37; --rule-strong: #4a4e49;
--accent: #7f9ff0; --active-bg: #23304d;
--ok: #7dcb95; --ok-bg: #1f3a2a; --warn: #e2b264; --warn-bg: #3f3117; --danger: #ee8b82; --danger-bg: #46231f;
}
}
:root[data-theme="dark"] {
--ground: #1b1d1b; --surface: #242724; --surface-2: #2c2f2c;
--ink: #e8e8e1; --ink-soft: #b3b6b0; --ink-faint: #7f837e;
--rule: #383b37; --rule-strong: #4a4e49;
--accent: #7f9ff0; --active-bg: #23304d;
--ok: #7dcb95; --ok-bg: #1f3a2a; --warn: #e2b264; --warn-bg: #3f3117; --danger: #ee8b82; --danger-bg: #46231f;
}
body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
.page { max-width: 1180px; margin: 0 auto; padding: 28px 24px 56px; display: flex; flex-direction: column; gap: 26px; }
h1 { font-size: 1.45rem; font-weight: 600; margin: 0; letter-spacing: -0.01em; text-wrap: balance; }
h2 { font-size: 0.95rem; font-weight: 600; margin: 0 0 8px; }
p { margin: 0; max-width: 68ch; }
.lede { color: var(--ink-soft); }
.eyebrow { font-size: 0.68rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint); }
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
a { color: var(--accent); }
code { font-family: var(--mono); font-size: 0.86em; background: var(--surface-2); padding: 0 4px; border-radius: 3px; }
/* ── the mocked screen ─────────────────────────────────────────────── */
.screen { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 12px; }
.toolbar { display: flex; flex-wrap: wrap; gap: 14px 22px; align-items: flex-end; }
.field label { display: block; font-size: 0.72rem; color: var(--ink-soft); margin-bottom: 3px; }
.seg { display: inline-flex; border: 1px solid var(--rule-strong); border-radius: 4px; overflow: hidden; }
.seg span { padding: 4px 10px; font-size: 0.8rem; color: var(--ink-soft); background: var(--surface); }
.seg span.on { background: var(--accent); color: #fff; }
.select { border: 1px solid var(--rule-strong); border-radius: 4px; padding: 4px 28px 4px 8px; font-size: 0.8rem; background: var(--surface) linear-gradient(45deg, transparent 50%, var(--ink-faint) 50%) no-repeat right 12px top 55% / 6px 6px; min-width: 190px; }
.btn { border: 1px solid var(--rule-strong); border-radius: 4px; padding: 4px 12px; font-size: 0.8rem; background: var(--surface); color: var(--ink); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.sm { padding: 1px 8px; font-size: 0.74rem; }
.spacer { flex: 1; }
.status { display: flex; flex-wrap: wrap; gap: 6px 18px; align-items: center; padding: 8px 12px; border: 1px solid var(--rule); border-radius: 4px; background: var(--surface-2); font-size: 0.8rem; }
.status .k { color: var(--ink-faint); font-size: 0.68rem; letter-spacing: 0.06em; text-transform: uppercase; margin-right: 6px; }
.chip { border: 1px solid var(--ok); color: var(--ok); border-radius: 999px; padding: 1px 10px; font-size: 0.76rem; font-weight: 500; }
.chip.failed { border-color: var(--danger); color: var(--danger); }
.new { outline: 2px dashed var(--accent); outline-offset: 3px; border-radius: 3px; }
.cnt.err { color: var(--danger); font-weight: 600; }
.cnt.warn { color: var(--warn); font-weight: 600; }
.flow-wrap { border: 1px solid var(--rule); border-radius: 4px; background: var(--surface); overflow-x: auto; }
svg.fg { display: block; width: 1500px; height: 456px; font-family: var(--sans); }
.lane-bg.alt { fill: var(--rule); fill-opacity: .25; } .lane-bg { fill: transparent; }
.lane-rule { stroke: var(--rule); stroke-width: 1; }
.lane-label { fill: var(--ink-soft); font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; }
.lane-sub { fill: var(--ink-faint); font-size: 10.5px; font-family: var(--mono); }
.fg-node { cursor: pointer; }
.fg-node rect { fill: var(--surface); stroke: var(--rule-strong); stroke-width: 1; }
.fg-node.selected rect { stroke: var(--accent); stroke-width: 2; fill: var(--active-bg); }
.fg-node.failed rect { stroke: var(--danger); }
.fg-node.failed .eb { color: var(--danger); }
.body { box-sizing: border-box; width: 100%; height: 100%; padding: 6px 8px 5px; display: flex; flex-direction: column; gap: 1px; overflow: hidden; line-height: 1.2; color: var(--ink); font-family: var(--sans); }
.head { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; }
.eb { color: var(--ink-faint); font-size: 9.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dur { color: var(--ink-soft); font-size: 9.5px; font-family: var(--mono); white-space: nowrap; }
.title { font-size: 12px; font-weight: 600; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.sub { color: var(--ink-soft); font-size: 10.5px; margin-top: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sub.exc { color: var(--danger); font-family: var(--mono); }
.foot { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 4px; min-width: 0; }
.foot .sub { margin-top: 0; flex: 1; }
/* the new log badge: highest severity first, counts per level, tabular */
.lg { display: inline-flex; gap: 3px; flex-shrink: 0; font-family: var(--mono); font-size: 9.5px; font-variant-numeric: tabular-nums; }
.lg i { font-style: normal; display: inline-flex; align-items: center; gap: 2px; padding: 0 4px; border-radius: 3px; line-height: 1.35; }
.lg .e { color: var(--danger); background: var(--danger-bg); }
.lg .w { color: var(--warn); background: var(--warn-bg); }
.lg .i { color: var(--ink-faint); background: var(--surface-2); }
.pill { align-self: flex-start; display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-weight: 500; padding: 1px 7px; border-radius: 999px; white-space: nowrap; }
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.pill.ok { color: var(--ok); background: var(--ok-bg); }
.mode { color: var(--ink-soft); font-size: 10.5px; }
.edge { fill: none; stroke: var(--ink-faint); stroke-width: 1.5; }
.edge.accent { stroke: var(--accent); }
.edge.dashed { stroke-dasharray: 4 3; }
.arrow { fill: var(--ink-faint); } .arrow.accent { fill: var(--accent); }
/* detail panel */
.detail { border: 1px solid var(--rule); border-radius: 4px; background: var(--surface); padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 10px; }
.dhead { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
.dhead h3 { margin: 2px 0 0; font-size: 1rem; font-weight: 600; }
.dhead .subl { font-size: 0.78rem; color: var(--ink-soft); }
.badge { font-size: 0.72rem; font-weight: 600; padding: 2px 8px; border-radius: 3px; background: var(--danger); color: #fff; }
.cols { display: grid; grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr); gap: 18px; }
@media (max-width: 760px) { .cols { grid-template-columns: 1fr; } }
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--rule); }
.tabs span { padding: 5px 11px; font-size: 0.8rem; color: var(--ink-soft); border: 1px solid transparent; border-bottom: none; border-radius: 4px 4px 0 0; margin-bottom: -1px; }
.tabs span.on { color: var(--ink); background: var(--surface); border-color: var(--rule); border-bottom-color: var(--surface); font-weight: 500; }
.tabs .n { color: var(--ink-faint); margin-left: 4px; font-family: var(--mono); font-size: 0.72rem; }
.pane { padding: 12px 2px 0; display: flex; flex-direction: column; gap: 10px; }
.exc { border: 1px solid var(--danger); border-left-width: 4px; border-radius: 4px; background: var(--danger-bg); padding: 8px 12px; display: flex; flex-direction: column; gap: 4px; }
.exc .t { font-family: var(--mono); font-size: 0.8rem; font-weight: 600; color: var(--danger); }
.exc .m { font-size: 0.86rem; }
.exc pre { margin: 6px 0 0; font-family: var(--mono); font-size: 0.72rem; line-height: 1.45; color: var(--ink-soft); white-space: pre; overflow-x: auto; }
.exc .row { display: flex; gap: 10px; align-items: center; margin-top: 4px; }
.exc .row .btn { background: var(--surface); }
.logctl { display: flex; align-items: center; gap: 12px; font-size: 0.76rem; color: var(--ink-soft); }
.logctl .seg span { padding: 2px 8px; font-size: 0.74rem; }
table.log { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
table.log th { text-align: left; font-size: 0.66rem; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-faint); font-weight: 600; padding: 4px 8px 6px; border-bottom: 1px solid var(--rule); }
table.log td { padding: 5px 8px; border-bottom: 1px solid var(--rule); vertical-align: top; }
table.log tr:last-child td { border-bottom: none; }
td.t { font-family: var(--mono); font-size: 0.74rem; color: var(--ink-soft); white-space: nowrap; }
td.src { color: var(--ink-faint); font-size: 0.74rem; white-space: nowrap; }
.lvl { display: inline-block; min-width: 46px; text-align: center; font-size: 0.66rem; font-weight: 600; letter-spacing: .04em; padding: 1px 6px; border-radius: 3px; }
.lvl.info { color: var(--ink-soft); background: var(--surface-2); }
.lvl.warn { color: var(--warn); background: var(--warn-bg); }
.lvl.error { color: var(--danger); background: var(--danger-bg); }
.lvl.debug { color: var(--ink-faint); background: transparent; border: 1px dashed var(--rule-strong); }
tr.dim td { color: var(--ink-faint); font-style: italic; }
.where dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0; font-size: 0.8rem; }
.where dt { color: var(--ink-soft); } .where dd { margin: 0; font-family: var(--mono); font-size: 0.76rem; overflow-wrap: anywhere; }
.where .acts { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
/* anatomy + author sections */
.grid3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px; }
.cap { font-size: 0.78rem; color: var(--ink-soft); margin-top: 8px; max-width: none; }
.nodebox { width: 150px; height: 84px; border: 1px solid var(--rule-strong); border-radius: 4px; background: var(--surface); }
.nodebox.failed { border-color: var(--danger); }
.nodebox.failed .eb { color: var(--danger); }
pre.code { margin: 0; background: var(--surface); border: 1px solid var(--rule); border-radius: 4px; padding: 12px 14px; font-family: var(--mono); font-size: 0.76rem; line-height: 1.5; overflow-x: auto; }
pre.code .c { color: var(--ink-faint); } pre.code .k { color: var(--accent); } pre.code .s { color: var(--ok); }
table.plain { border-collapse: collapse; font-size: 0.8rem; width: 100%; }
table.plain th, table.plain td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--rule); vertical-align: top; }
table.plain th { font-size: 0.66rem; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-faint); }
.tbl-wrap { overflow-x: auto; border: 1px solid var(--rule); border-radius: 4px; background: var(--surface); }
ol.notes { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 8px; max-width: 78ch; }
ol.notes li { padding-left: 4px; }
ol.notes b { font-weight: 600; }
</style>
<div class="page">
<header>
<div class="eyebrow">Flow Debugger · v1.4 proposal · 2026-09-09</div>
<h1>Exceptions and log lines on the step that produced them</h1>
<p class="lede" style="margin-top:6px">A traced run carries every <code>Log.*</code> 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 <b>Log</b> tab in the detail panel, and run-wide error and warning counts in the status strip. Live and Replay read the same rows.</p>
</header>
<!-- ───────────────────────────── the screen ───────────────────────────── -->
<section class="screen" aria-label="Flow Debugger after a failed Live run">
<div class="toolbar">
<div class="field"><label>Entry point</label><div class="seg"><span class="on">Inbound API method</span><span>Site script</span></div></div>
<div class="field"><label>Method</label><div class="select">DelmiaRecipeDownload</div></div>
<div class="field"><label>Mode</label><div class="seg"><span class="on">Live</span><span>Replay</span></div></div>
<div class="spacer"></div>
<span class="btn">Edit inputs</span><span class="btn primary">Run</span>
</div>
<div class="status">
<span class="chip failed">Failed</span>
<span><span class="k">Run</span><span class="mono">#7</span></span>
<span><span class="k">Started</span><span class="mono">14:02:11.198Z</span></span>
<span><span class="k">Total</span><span class="mono">1 842 ms</span></span>
<span><span class="k">Steps</span><span class="mono">8</span></span>
<span><span class="k">Boundaries</span><span class="mono">2</span></span>
<span><span class="k">Writes</span><span class="mono">2</span></span>
<span class="new"><span class="k">Log</span><span class="mono"><span class="cnt err">1 error</span> · <span class="cnt warn">2 warnings</span> · 4 info</span></span>
<span class="btn sm">Refresh outcomes</span>
<span><span class="k">Root</span><a class="mono" href="#">7c1e0f2a-…-9b41</a></span>
</div>
<div class="flow-wrap">
<svg class="fg" viewBox="0 0 1500 456" role="img" aria-label="Swimlane: request, failed route call, failed site script with log badge, two writes, a satisfied wait, a database call, and a 500 response">
<defs>
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" class="arrow"/></marker>
<marker id="ar-a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" class="arrow accent"/></marker>
</defs>
<!-- lanes -->
<rect x="0" y="0" width="1500" height="110" class="lane-bg"/>
<rect x="0" y="110" width="1500" height="110" class="lane-bg alt"/>
<rect x="0" y="220" width="1500" height="110" class="lane-bg"/>
<rect x="0" y="330" width="1500" height="110" class="lane-bg alt"/>
<line x1="0" y1="110" x2="1500" y2="110" class="lane-rule"/><line x1="0" y1="220" x2="1500" y2="220" class="lane-rule"/><line x1="0" y1="330" x2="1500" y2="330" class="lane-rule"/>
<text x="12" y="24" class="lane-label">Central</text><text x="12" y="40" class="lane-sub">central-a</text>
<text x="12" y="134" class="lane-label">Site</text><text x="12" y="150" class="lane-sub">site-a · DelmiaSim01</text>
<text x="12" y="244" class="lane-label">Tags</text><text x="12" y="260" class="lane-sub">DelmiaReceiver.*</text>
<text x="12" y="354" class="lane-label">External</text><text x="12" y="370" class="lane-sub">MesAudit</text>
<!-- edges (columns at x = 140 + 170·n, node 150×84, lane centre y = lane·110 + 55) -->
<path d="M290 55 H310" class="edge accent" marker-end="url(#ar-a)"/>
<path d="M460 55 H470 V165 H480" class="edge accent" marker-end="url(#ar-a)"/>
<path d="M630 165 H640 V275 H650" class="edge" marker-end="url(#ar)"/>
<path d="M800 275 H820" class="edge" marker-end="url(#ar)"/>
<path d="M970 275 H980 V165 H990" class="edge" marker-end="url(#ar)"/>
<path d="M895 233 V190 H990" class="edge dashed" marker-end="url(#ar)"/>
<path d="M1140 165 H1150 V385 H1160" class="edge" marker-end="url(#ar)"/>
<path d="M460 55 H1330" class="edge accent" marker-end="url(#ar-a)"/>
<!-- nodes -->
<g class="fg-node" transform="translate(140 13)"><rect width="150" height="84" rx="4"/><foreignObject width="150" height="84"><div xmlns="http://www.w3.org/1999/xhtml" class="body"><div class="head"><span class="eb">Inbound</span><span class="dur">1 842 ms</span></div><div class="title">DelmiaRecipeDownload</div><div class="foot"><span class="sub">flow-debugger · jdoherty</span><span class="lg"><i class="i">≡ 1</i></span></div></div></foreignObject></g>
<g class="fg-node failed" transform="translate(310 13)"><rect width="150" height="84" rx="4"/><foreignObject width="150" height="84"><div xmlns="http://www.w3.org/1999/xhtml" class="body"><div class="head"><span class="eb">Route call</span><span class="dur">1 790 ms</span></div><div class="title">DelmiaSim01.ProcessRecipeDownload</div><div class="foot"><span class="sub exc">Script failed at site</span></div></div></foreignObject></g>
<g class="fg-node failed selected" transform="translate(480 123)"><rect width="150" height="84" rx="4"/><foreignObject width="150" height="84"><div xmlns="http://www.w3.org/1999/xhtml" class="body"><div class="head"><span class="eb">Script run</span><span class="dur">1 731 ms</span></div><div class="title">ProcessRecipeDownload</div><div class="foot"><span class="sub exc">InvalidOperationException: Recipe 42 rejected by Galaxy (code 7)</span><span class="lg"><i class="e">✕ 1</i><i class="w">! 1</i><i class="i">≡ 2</i></span></div></div></foreignObject></g>
<g class="fg-node" transform="translate(650 233)"><rect width="150" height="84" rx="4"/><foreignObject width="150" height="84"><div xmlns="http://www.w3.org/1999/xhtml" class="body"><div class="head"><span class="eb">Write</span><span class="dur">12 ms</span></div><div class="title">RecipeId, RecipeName</div><div class="foot"><span class="sub">RecipeId = 42, RecipeName = …</span><span class="lg"><i class="w">! 1</i></span></div></div></foreignObject></g>
<g class="fg-node" transform="translate(820 233)"><rect width="150" height="84" rx="4"/><foreignObject width="150" height="84"><div xmlns="http://www.w3.org/1999/xhtml" class="body"><div class="head"><span class="eb">Write</span><span class="dur">9 ms</span></div><div class="title">DownloadRequest</div><div class="foot"><span class="sub">DownloadRequest = true</span></div></div></foreignObject></g>
<g class="fg-node" transform="translate(990 123)"><rect width="150" height="84" rx="4"/><foreignObject width="150" height="84"><div xmlns="http://www.w3.org/1999/xhtml" class="body"><div class="head"><span class="eb">Wait</span><span class="dur">1 640 ms</span></div><div class="title">RecipeProcessedFlag</div><div class="foot"><span class="sub">satisfied · true</span></div></div></foreignObject></g>
<g class="fg-node" transform="translate(1160 343)"><rect width="150" height="84" rx="4"/><foreignObject width="150" height="84"><div xmlns="http://www.w3.org/1999/xhtml" class="body"><div class="head"><span class="eb">Database</span><span class="dur">23 ms</span></div><div class="title">MesAudit</div><div class="foot"><span class="pill ok">Ok</span><span class="mode">· write · 1 row</span><span class="lg"><i class="i">≡ 1</i></span></div></div></foreignObject></g>
<g class="fg-node failed" transform="translate(1330 13)"><rect width="150" height="84" rx="4"/><foreignObject width="150" height="84"><div xmlns="http://www.w3.org/1999/xhtml" class="body"><div class="head"><span class="eb">Response</span><span class="dur"></span></div><div class="title">Response</div><div class="foot"><span class="sub exc">500 · SCRIPT_ERROR</span></div></div></foreignObject></g>
</svg>
</div>
<!-- detail panel for the selected (failed) script run -->
<div class="detail" aria-label="Step detail: ProcessRecipeDownload, Log tab">
<div class="dhead">
<div>
<div class="eyebrow">Script run · Site</div>
<h3>ProcessRecipeDownload</h3>
<div class="subl">DelmiaSim01.DelmiaReceiver · call</div>
</div>
<span class="badge">Failed</span>
</div>
<div class="cols">
<div>
<div class="tabs"><span>Input</span><span>Output</span><span class="on new">Log<span class="n">5</span></span><span>Writes</span><span>Timing</span></div>
<div class="pane">
<div class="exc">
<div class="t">System.InvalidOperationException</div>
<div class="m">Recipe 42 rejected by Galaxy (code 7)</div>
<pre> at Submission#0.&lt;&lt;Initialize&gt;&gt;d__0.MoveNext() in ProcessRecipeDownload:line 41
at ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptRunLauncher.RunScriptAsync(…)
Inner exception: none</pre>
<div class="row"><span class="btn sm">Copy</span><span class="btn sm">Open in Audit Log</span><span style="font-size:.74rem;color:var(--ink-soft)">Thrown by the script body · site-a / node-a</span></div>
</div>
<div class="logctl">
<div class="seg"><span class="on">All</span><span>Warn +</span></div>
<span>5 lines on this step · <a href="#">1 more on Write RecipeId, RecipeName</a></span>
<span class="spacer"></span>
<span class="btn sm">Copy lines</span>
</div>
<table class="log">
<thead><tr><th>Time (UTC)</th><th>Level</th><th>Source</th><th>Message</th></tr></thead>
<tbody>
<tr><td class="t">14:02:11.204</td><td><span class="lvl info">info</span></td><td class="src">script</td><td>Recipe 42 requested for line L3 by flow-debugger</td></tr>
<tr><td class="t">14:02:11.206</td><td><span class="lvl debug">debug</span></td><td class="src">script</td><td>Writing header attributes then DownloadRequest</td></tr>
<tr><td class="t">14:02:12.861</td><td><span class="lvl info">info</span></td><td class="src">script</td><td>Galaxy answered: RecipeProcessedFlag = true, ResultCode = 7</td></tr>
<tr><td class="t">14:02:12.862</td><td><span class="lvl warn">warn</span></td><td class="src">script</td><td>ResultCode 7 is outside the accepted set {0, 1}</td></tr>
<tr><td class="t">14:02:12.870</td><td><span class="lvl error">error</span></td><td class="src">script</td><td>Rejecting recipe 42 (code 7) — see exception above</td></tr>
<tr class="dim"><td class="t">14:02:12.929</td><td><span class="lvl warn">warn</span></td><td class="src">runtime</td><td>Log truncated inline: 0 lines dropped (shown only when the per-run cap is hit)</td></tr>
</tbody>
</table>
</div>
</div>
<div class="where">
<div class="eyebrow" style="margin-bottom:6px">Where this ran</div>
<dl>
<dt>Site / node</dt><dd>site-a / node-a</dd>
<dt>Instance</dt><dd>DelmiaSim01</dd>
<dt>Target</dt><dd>DelmiaReceiver.ProcessRecipeDownload</dd>
<dt>Trigger</dt><dd>call</dd>
<dt>Log lines</dt><dd>5 here · 1 on other steps</dd>
</dl>
<div class="acts"><span class="btn sm">Open in Debug View</span><span class="btn sm">Audit rows (9)</span></div>
</div>
</div>
</div>
</section>
<!-- ───────────────────────────── node anatomy ─────────────────────────── -->
<section>
<h2>Node anatomy</h2>
<p class="lede" style="margin-bottom:12px">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.</p>
<div class="grid3">
<div>
<div class="nodebox"><div class="body"><div class="head"><span class="eb">Inbound</span><span class="dur">1 842 ms</span></div><div class="title">DelmiaRecipeDownload</div><div class="foot"><span class="sub">flow-debugger · jdoherty</span><span class="lg"><i class="i">≡ 1</i></span></div></div></div>
<div class="cap"><b>Info only.</b> A faint <span class="mono">≡ n</span> badge. Debug lines count here too; the tab's <i>Warn +</i> filter hides them.</div>
</div>
<div>
<div class="nodebox"><div class="body"><div class="head"><span class="eb">Write</span><span class="dur">12 ms</span></div><div class="title">RecipeId, RecipeName</div><div class="foot"><span class="sub">RecipeId = 42, RecipeName = …</span><span class="lg"><i class="w">! 1</i></span></div></div></div>
<div class="cap"><b>Runtime warning on a step.</b> 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.</div>
</div>
<div>
<div class="nodebox failed"><div class="body"><div class="head"><span class="eb">Script run</span><span class="dur">1 731 ms</span></div><div class="title">ProcessRecipeDownload</div><div class="foot"><span class="sub exc">InvalidOperationException: Recipe 42 rejected …</span><span class="lg"><i class="e">✕ 1</i><i class="w">! 1</i><i class="i">≡ 2</i></span></div></div></div>
<div class="cap"><b>Failed with an exception.</b> Red stroke and eyebrow as today; the subtitle becomes <span class="mono">Type: message</span>. Selecting it opens the Log tab first.</div>
</div>
</div>
</section>
<!-- ───────────────────────────── the author's side ────────────────────── -->
<section>
<h2>What the script author writes</h2>
<p class="lede" style="margin-bottom:12px">One new global, <code>Log</code>, 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.</p>
<pre class="code"><span class="c">// ProcessRecipeDownload — site Call script (excerpt)</span>
<span class="k">var</span> recipeId = Parameters.Get&lt;<span class="k">int</span>&gt;(<span class="s">"RecipeId"</span>);
Log.Info(<span class="s">$"Recipe {recipeId} requested for line {Parameters.Get&lt;string&gt;("Line")} by {Parameters.Get&lt;string&gt;("Requester")}"</span>);
Log.Debug(<span class="s">"Writing header attributes then DownloadRequest"</span>);
<span class="k">await</span> Children[<span class="s">"DelmiaReceiver"</span>].SetAsync(<span class="s">"RecipeId"</span>, recipeId);
<span class="c">// … writes, wait …</span>
<span class="k">var</span> code = <span class="k">await</span> Children[<span class="s">"DelmiaReceiver"</span>].GetAsync&lt;<span class="k">int</span>&gt;(<span class="s">"ResultCode"</span>);
Log.Info(<span class="s">$"Galaxy answered: RecipeProcessedFlag = true, ResultCode = {code}"</span>);
<span class="k">if</span> (code <span class="k">is not</span> (0 <span class="k">or</span> 1))
{
Log.Warn(<span class="s">$"ResultCode {code} is outside the accepted set {{0, 1}}"</span>);
Log.Error(<span class="s">$"Rejecting recipe {recipeId} (code {code})"</span>);
<span class="k">throw new</span> InvalidOperationException(<span class="s">$"Recipe {recipeId} rejected by Galaxy (code {code})"</span>);
}
Log.Warn(<span class="s">"Recoverable problem"</span>, caughtException); <span class="c">// an exception on a line renders as an expandable chip</span></pre>
</section>
<section>
<h2>Where a line lands</h2>
<div class="tbl-wrap">
<table class="plain">
<thead><tr><th>Produced by</th><th>Source</th><th>Lands on</th><th>How</th></tr></thead>
<tbody>
<tr><td><code>Log.*</code> in a site script (incl. inline shared scripts)</td><td>script</td><td>That execution's <b>Script run</b> node</td><td>One <code>ScriptTrace.ScriptLog</code> row per line, keyed by the run's <code>ExecutionId</code>; no step id</td></tr>
<tr><td><code>Log.*</code> in an inbound API method script</td><td>script</td><td>The <b>Inbound</b> node (or the debugger's route-call root for a script entry)</td><td>Same row, written centrally with the inbound <code>ExecutionId</code>; inline on a Live run through the runner's collector</td></tr>
<tr><td>The runtime's own warnings while a helper step runs (write, wait, sync API call)</td><td>runtime</td><td>That <b>step's</b> node</td><td>The 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 <code>stepEventId</code></td></tr>
<tr><td>Runtime warnings outside any helper step</td><td>runtime</td><td>The <b>Script run</b> node</td><td>No scope active → falls back to the execution anchor</td></tr>
<tr><td>The exception that failed a script run</td><td>—</td><td>The <b>Script run</b> node's exception block</td><td>Not a log row: <code>ScriptRun.errorDetail</code> now carries <code>ex.ToString()</code> (type, message, stack, inner); the inbound row gets the same for a central method failure</td></tr>
<tr><td>Log lines from other actors on the node (instance actor, DCL)</td><td>—</td><td>Not captured in v1</td><td>No execution-id enrichment exists on the node loggers; recorded as the follow-up, not attempted here</td></tr>
</tbody>
</table>
</div>
</section>
<section>
<h2>Decision notes</h2>
<ol class="notes">
<li><b>Lines are audit rows, not a side channel.</b> A new <code>ScriptLog</code> kind in the existing <code>ScriptTrace</code> 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.</li>
<li><b>Untraced runs pay nothing.</b> <code>Log.*</code> 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. <code>TraceAllCalls</code> methods therefore gain lines in Replay at ~1 row per line.</li>
<li><b>Bounded by design.</b> 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 <i>log lines truncated inline — Replay is complete</i> when that happens.</li>
<li><b>Attribution is explicit, never inferred from time.</b> A row carries <code>stepEventId</code> or it belongs to the execution's anchor step. Steps whose row is written later by another actor (cached calls, notifications) keep the fallback.</li>
<li><b>Exceptions stay where they are already recorded.</b> The failure row exists on every path today; it only lacked the detail. Adding <code>errorDetail</code> costs one argument on the site and one internal field centrally; the HTTP caller still receives the safe <code>SCRIPT_ERROR</code> body.</li>
<li><b>Selecting a failed node opens the Log tab.</b> Every other node keeps Input as its first tab; the Output tab's red alert stays and links across.</li>
<li><b>Not in this slice:</b> 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.</li>
</ol>
</section>
</div>
@@ -0,0 +1,530 @@
# 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.
**Status:** all 15 tasks complete 2026-09-09 (branch `feat/flow-debugger-step-logs`); every task spec- and code-reviewed; 7 test projects green (4 499 tests); rig rebuilt via `docker/deploy.sh`, `verify-delmia-notifier.sh` H/H2/I/**J** all YES (J root `5793ddc2-5273-4e62-86c2-ccfc8de0e080`); browser-verified. Deviations from the plan text: `FlowTraceCollector.Add(evt, lowPriority:)` instead of `AddLowPriority` (Commons 3-public-method constraint); inline shared scripts share the caller's `ScriptLogger` (design §5, not a per-child logger); `LogsTruncated` clears once a refresh merges in more log rows; the inbound editor host `InboundScriptHost` also mirrors `Log`.
**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 <files>`), 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
/// <summary>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).</summary>
ScriptLog
```
`ScriptTraceExtra.cs` — add after `Source`:
```csharp
/// <summary>On a <c>ScriptLog</c> row: <c>debug</c> | <c>info</c> | <c>warn</c> | <c>error</c>.</summary>
public string? LogLevel { get; init; }
/// <summary>On a <c>ScriptLog</c> row: <c>script</c> (a <c>Log.*</c> call) | <c>runtime</c> (the context's own logger while traced).</summary>
public string? LogSource { get; init; }
/// <summary>On a <c>ScriptLog</c> row: the pre-minted <c>EventId</c> of the step the line belongs to; null = the execution's anchor step (design D2).</summary>
public Guid? StepEventId { get; init; }
/// <summary>On a <c>ScriptLog</c> row: per-run emission counter, breaks same-millisecond ties.</summary>
public int? Seq { get; init; }
```
`FlowStep.cs` — add the record and two trailing optional members on `FlowStep`:
```csharp
/// <summary>One log line folded onto a step (<see cref="FlowStep.Logs"/>; design 2026-09-09 D1/D8).</summary>
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<FlowLifecycleRow>? Lifecycle = null, Guid? CorrelationId = null, string? ErrorDetail = null, IReadOnlyList<FlowLogLine>? 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
/// <summary>True for a <c>ScriptTrace.ScriptLog</c> row — a log line, never a step.</summary>
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);
}
/// <summary>Projects a <c>ScriptLog</c> row onto its line. Level/source default to <c>info</c>/<c>script</c> when the extra is missing.</summary>
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<AuditEvent> 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<Guid, List<FlowLogLine>>();
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
/// <summary>The row-emitting callback a <see cref="ScriptLogger"/> uses: level, source, message, exception, step id.</summary>
public delegate void ScriptLogEmitter(string level, string source, string message, Exception? exception, Guid? stepEventId);
```
`TraceStepScope.cs`:
```csharp
/// <summary>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.</summary>
public static class TraceStepScope
{
private static readonly AsyncLocal<Guid?> _current = new();
public static Guid? Current => _current.Value;
/// <summary>Sets the current step for the scope's lifetime; restores the previous value on dispose.</summary>
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
/// <summary>
/// The <c>Log</c> global (design 2026-09-09 D4/D5/D7). Always forwards to the node's ILogger; emits a
/// <c>ScriptLog</c> trace row only while the run is traced (<paramref name="emit"/> non-null). Bounded by
/// <see cref="MaxLines"/> per run (shared with inline shared scripts) and <see cref="MaxMessageChars"/>; past
/// the line cap lines are dropped and <see cref="FlushDropped"/> writes one closing runtime warning. Never throws into the script.
/// </summary>
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);
/// <summary>Runtime-sourced line (the capturing logger); same caps, source "runtime".</summary>
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 { }
}
/// <summary>Run end: one closing warning naming the dropped count (nothing when none were dropped).</summary>
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
/// <summary>Decorates the context's ILogger while traced: forwards everything, and copies Information+ into runtime log rows (design D6).</summary>
public sealed class TraceCapturingLogger(ILogger inner, ScriptLogger sink) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => inner.BeginScope(state);
public bool IsEnabled(LogLevel level) => level >= LogLevel.Information || inner.IsEnabled(level);
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> 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`: `/// <summary>Leveled per-run logging (design 2026-09-09). Usage: <c>Log.Info("…")</c>, <c>Log.Warn("…", ex)</c>.</summary> 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 `<div class="fg-foot">`; badge `<span class="fg-logs" data-test="flow-node-logs" title="@LogTitle(node)">` with up to three `<i>` 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"` — `<div class="fsd-exc">` with type line, message, `<pre>` 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 <step title>` when other steps of the same execution carry lines, rendered as a button that invokes the new `[Parameter] EventCallback<FlowStep> OnSelectStep`; the Output tab's alert gains `<a @onclick="() => _tab = \"Log\"">see exception</a>` 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`: `<span class="new"><span class="fd-label">Log</span><span class="fd-mono" data-test="flow-log-counts">@LogSummary</span></span>` when any line exists; `<span class="badge bg-warning text-dark" data-test="flow-logs-truncated">log lines truncated inline — Replay is complete</span>` when `_graph?.LogsTruncated == true`; `<span class="text-muted small" data-test="flow-unattached-logs">@n log line(s) could not be placed on a step</span>` when `UnattachedLogLines > 0`), `:283-287` (`<FlowStepDetail … OnSelectStep="OnSelectStep" />`)
- 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 <root>` 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=<J root>` 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.
@@ -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": "completed"
},
{
"id": 2,
"subject": "Task 2: Commons \u2014 FlowTraceCollector.AddLowPriority + LogsTruncated",
"status": "completed"
},
{
"id": 3,
"subject": "Task 3: Commons \u2014 FlowGraphBuilder attaches log lines to steps",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 4,
"subject": "Task 4: SiteRuntime \u2014 ScriptLogger, TraceStepScope, TraceCapturingLogger",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 5,
"subject": "Task 5: SiteRuntime \u2014 wire Log into ScriptRuntimeContext + ScriptGlobals",
"status": "completed",
"blockedBy": [
1,
2,
4
]
},
{
"id": 6,
"subject": "Task 6: SiteRuntime \u2014 launcher errorDetail, dropped-lines flush, MaxLogLinesPerRun",
"status": "completed",
"blockedBy": [
5
]
},
{
"id": 7,
"subject": "Task 7: Script surfaces \u2014 ScriptCompileSurface.Log, sandbox mirrors, trust pin",
"status": "completed",
"blockedBy": [
5
]
},
{
"id": 8,
"subject": "Task 8: InboundAPI \u2014 InboundScriptContext.Log + MaxLogLinesPerRun",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 9,
"subject": "Task 9: InboundAPI \u2014 exception detail on the inbound row",
"status": "completed",
"blockedBy": [
8
]
},
{
"id": 10,
"subject": "Task 10: ManagementService \u2014 runner inbound row ErrorDetail; LogsTruncated on results",
"status": "completed",
"blockedBy": [
9
]
},
{
"id": 11,
"subject": "Task 11: CentralUI \u2014 FlowGraphView log badge + failed-node exception subtitle",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 12,
"subject": "Task 12: CentralUI \u2014 FlowStepDetail Log tab",
"status": "completed",
"blockedBy": [
1
]
},
{
"id": 13,
"subject": "Task 13: CentralUI \u2014 Flow Debugger page strip counts, truncation notes, selection hookup",
"status": "completed",
"blockedBy": [
3,
10,
11,
12
]
},
{
"id": 14,
"subject": "Task 14: CLI \u2014 logs column and --show-logs",
"status": "completed",
"blockedBy": [
3
]
},
{
"id": 15,
"subject": "Task 15: Specs, register, rig demo + verification step J, full build",
"status": "completed",
"blockedBy": [
6,
7,
13,
14
]
}
],
"lastUpdated": "2026-09-09T17:03:37.474386+00:00"
}
+2 -1
View File
@@ -57,9 +57,10 @@ boundary:
| `POST /api/{method}` (Inbound API) | External | Inbound (invokes a script) | No (gap) |
| `Route.To(x).Call(y)` (Inbound API → site) | Script | Central → site | Yes — `ScriptTrace.RouteCall`, **always-on** (2026-09-05) |
| Site script run / attribute write / attribute wait under a **traced** run | Script | Site-local | Yes — `ScriptTrace.ScriptRun` / `AttributeWrite` / `AttributeWait`, **gated** by the `Trace` flag (Flow Debugger Live runs, `ApiMethod.TraceAllCalls`) |
| Script `Log.Debug/Info/Warn/Error(...)` — and the runtime's own Information+ entries during a **traced** run | Script / runtime | Site-local or central | Yes — `ScriptTrace.ScriptLog`, one row per line, **gated** by the same `Trace` flag (2026-09-09) |
| Central method-script `Database.QuerySingle/Query/Execute` (`InboundDatabaseHelper`) | Script | Outbound | Yes — `DbOutbound.DbWrite` via `ICentralAuditWriter` (2026-09-06; was an unaudited gap) — `Target` = connection name, `Actor` = `api:{method}`, the request's `ExecutionId`, `Extra` = `{op, rowsAffected\|rowsReturned}` |
**`AuditChannel.ScriptTrace` (Flow Debugger, design `docs/plans/2026-09-05-flow-debugger-design.md` §5.1).** Trace steps ARE audit events — no parallel store. One kind is always-on (`RouteCall`, a central direct write per `Route.To().Call`, carrying the child run's id in `Extra.childExecutionId`); the three site kinds are emitted only when the run's `Trace` flag is set, ride the ordinary site `IAuditWriter` → SQLite → telemetry → central path, share the run's `ExecutionId` / `ParentExecutionId`, and are additionally returned inline on the routed reply so a Live run does not wait on the 5 s telemetry cadence. The Flow Debugger's graph (`FlowStep` / `FlowGraphBuilder`, Commons) is a central-side projection of these rows — **and, since 2026-09-06, of the run's `ApiOutbound` / `DbOutbound` / `Notification` rows too** (plan `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`): the outbound rows already sit in the execution tree under the run's `ExecutionId`, so the replay query (`GetTraceEventsAsync`) simply widens its channel filter to the four channels plus the inbound row, and a traced site run also passes the outbound rows it emits through the same inline trace collector as its `ScriptTrace` rows (same `AuditEvent` instance the writer receives — no duplicate construction; a collector that throws can neither fail the call nor skip the durable write). Rows written after the run returned (the S&amp;F forwarder's `Forwarded` / `Attempted` / `CachedResolve`, the Notification Outbox's `NotifyDeliver`) are not inline — the page re-reads the tree for them.
**`AuditChannel.ScriptTrace` (Flow Debugger, design `docs/plans/2026-09-05-flow-debugger-design.md` §5.1).** Trace steps ARE audit events — no parallel store. One kind is always-on (`RouteCall`, a central direct write per `Route.To().Call`, carrying the child run's id in `Extra.childExecutionId`); the three site kinds are emitted only when the run's `Trace` flag is set, ride the ordinary site `IAuditWriter` → SQLite → telemetry → central path, share the run's `ExecutionId` / `ParentExecutionId`, and are additionally returned inline on the routed reply so a Live run does not wait on the 5 s telemetry cadence. The Flow Debugger's graph (`FlowStep` / `FlowGraphBuilder`, Commons) is a central-side projection of these rows — **and, since 2026-09-06, of the run's `ApiOutbound` / `DbOutbound` / `Notification` rows too** (plan `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`): the outbound rows already sit in the execution tree under the run's `ExecutionId`, so the replay query (`GetTraceEventsAsync`) simply widens its channel filter to the four channels plus the inbound row, and a traced site run also passes the outbound rows it emits through the same inline trace collector as its `ScriptTrace` rows (same `AuditEvent` instance the writer receives — no duplicate construction; a collector that throws can neither fail the call nor skip the durable write). Rows written after the run returned (the S&amp;F forwarder's `Forwarded` / `Attempted` / `CachedResolve`, the Notification Outbox's `NotifyDeliver`) are not inline — the page re-reads the tree for them. **Since 2026-09-09 the channel also carries `ScriptLog`** (design `docs/plans/2026-09-09-flow-debugger-step-logs-design.md` D1) — one row per line a traced script or the runtime logged, with `RequestSummary` = the message, `ErrorMessage` / `ErrorDetail` = the attached exception's message / `ToString()`, and `Extra` (`ScriptTraceExtra`) carrying `logLevel` (`debug|info|warn|error`), `logSource` (`script|runtime`), the owning `stepEventId` and a per-run `seq`. It is **never a step**: `FlowGraphBuilder` folds it into its owning step's `Logs` list, and the same rows carry the exception the failing step threw via the `errorDetail` field that already existed on `AuditDetails` (site `ScriptRun` rows and the central inbound row now populate it). Redaction, the 8 KB / 64 KB-on-error payload caps and `AuditLog:PerChannelRetentionDays:ScriptTrace` all cover the new kind unchanged — a log line is an ordinary audit row, not a side channel.
Out of scope — framework traffic is not audited:
+1
View File
@@ -219,6 +219,7 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms
- **`/deployment/flow-debugger`** (nav: Deployment → Flow Debugger; design `docs/plans/2026-09-05-flow-debugger-design.md`, plan `docs/plans/2026-09-05-flow-debugger.md`). Runs one entry point with operator-supplied inputs and renders every step the run passes through as a **four-lane swimlane** (Central / Site / Tags / **External**) — request, route call, site script run, attribute writes, attribute waits, **outbound API / database / notification calls**, response — each node carrying its input, output, duration and outcome; a tabbed detail panel (Input · Output · Writes *or* Lifecycle · Timing, plus a *Where this ran* column) opens on the selected node with `Open in Debug View` (script entries, where the instance is known) and `Audit rows (n)` (→ `/audit/log?executionId=`) drill-ins.
- **Two modes.** *Live* is a real run — the method script executes on the active central node with tracing on, or a real attribute write goes through the routed-write path and the site's triggered run is polled back — audited like any other call. *Replay* executes nothing: it rebuilds the flow from an existing execution's `ScriptTrace` audit rows plus its `ApiOutbound` / `DbOutbound` / `Notification` rows (`?executionId=` drill-in from the Audit Log drawer's and the Execution Chain page's **Open in Flow Debugger** actions).
- **External lane (v1.1, plan `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`).** Every `ExternalSystem.Call/CachedCall`, `Database.*` and `Notify.Send` a traced run makes is a node on the fourth lane — kinds `ApiCall` / `DbCall` / `Notify` (eyebrows `API CALL` / `DATABASE` / `NOTIFY`), title = the system.method / connection / list, a status pill (`Ok` / `Pending` / `Parked` / `Failed`) plus `sync` | `cached` | `notify`. **One node per operation:** a cached call's or notification's later lifecycle rows (Forwarded → Attempted → Delivered / Parked, written minutes later by the S&amp;F forwarder or the Notification Outbox) fold into the node (`FlowStep.Lifecycle`, a Lifecycle tab in the detail panel with *Delivered by*) instead of drawing four boxes. A sync call sits in the execution chain; a cached call or notification hangs off it on a dashed **async** connector, and a trigger cascade's spawn edge carries a **bolt badge on the connector's riser** — the edge itself keeps an ordinary arrowhead (the bolt was the arrowhead until 2026-09-08, and on the upward Tags→Site hop it read as a broken arrow). The spawn edge starts at the parent's **last write of the attribute the child's `triggerAttribute` names** (`FlowGraphBuilder`); only when the child names no attribute, or the parent never wrote it, does the timestamp rule (last non-async parent step at or before the child) decide — a burst of writes landing in one millisecond used to draw the cascade from the last of them rather than the one that fired it. A Live run returns only the rows the run itself emitted, so a graph left with `Pending` nodes **refreshes itself** through the Replay command every 5 s for up to 2 min (page constants), and a *Refresh outcomes* button re-reads on demand in either mode; the state chip stays *Completed* — pending outbound work is not a failed run. A refresh **merges** the re-read rows into the run's known rows by `EventId` (fresh copy wins) and rebuilds the graph from the union, so it only ever adds nodes: the site's outbound rows reach central's audit store by telemetry seconds after the run returned them inline, and a Replay read taken in between would otherwise erase an already-drawn node (observed on the rig 2026-09-06 with a resolved cached DB write). The union is exact because the inline rows are the very `AuditEvent` objects the site writer persists. **v1.2 (follow-ups plan `docs/plans/2026-09-06-flow-debugger-followups.md`, register row 111 closed):** a folded node carries its `CorrelationId` (`FlowStep.CorrelationId` — the `TrackedOperationId` / `NotificationId`), so the detail panel deep-links to **`/site-calls/report?correlationId=<id>`** / **`/notifications/report?correlationId=<id>`** (*Open in Site Calls* / *Open in Notification report*; both pages accept that query, pre-filter to the one row through an additive per-id query filter — `SiteCallQueryRequest.TrackedOperationIdFilter` / `NotificationOutboxQueryRequest.NotificationIdFilter` — show a removable *Operation* / *Notification* pill and auto-open the row's detail modal; note `NotificationId` is minted as a Guid in **`N` form** — no dashes — so the Notify link passes `N` and the report page normalises any Guid-shaped value to `N`, while `TrackedOperationId` is a `varchar(36)` `D` string and the Site Calls filter binds a `varchar` parameter for the PK seek), and a **`Parked` node offers Retry / Discard in place**: the same site-scope re-check, confirm dialog (Discard = danger) and central→site / Notification Outbox relay the report pages use (`IFlowDebugService.RetryOutboundAsync` / `DiscardOutboundAsync` → `CommunicationService`), then the page re-polls Replay (5 s / 2 min) until the node leaves `Parked`. **Prediction is async-safe:** `FlowDebugService.PredictMethodAsync` resolves route targets by a resolve-and-rewalk loop — its original blocking resolver deadlocked the whole Blazor circuit on the renderer sync context the moment a method routing to a site script was chosen (found 2026-09-06; pinned by a single-thread-pump unit test and the Playwright `FlowDebuggerTests`, which log in through the fixture and run a Live run of an ephemeral method in a real browser).
- **Step logs and exceptions (v1.4, 2026-09-09, design `docs/plans/2026-09-09-flow-debugger-step-logs-design.md`).** A traced run shows, **on the node that produced them**, the exception a failing step threw and every line the script (or the runtime on its behalf) logged. A log line is an ordinary audit row — `ScriptTrace` / `ScriptLog`, one per line — that `FlowGraphBuilder` folds into its owning step's `FlowStep.Logs` and **never draws as a node**; attribution is `Extra.stepEventId` when the emitting helper pre-minted its step id, else the execution's anchor row (`ScriptRun` on a site, `InboundRequest` / the debugger's synthesized `RouteCall` root centrally), else that execution's first step, else `FlowGraph.UnattachedLogLines`. Rendering: a node footer badge (`✕ n` errors, `! n` warnings, `≡ n` info, highest severity first, `data-test="flow-node-logs"`, hidden with no lines); a failed node's subtitle becomes `ExceptionType: message` (namespace stripped, innermost exception, generic-safe) fed by the new `FlowStep.ErrorDetail`; a **Log (n)** tab in the detail panel (`flow-step-tab-log`) carrying the full exception block first, then the lines under an `All | Warn +` filter with a copy button and a *"n more on &lt;step&gt;"* cross-step hint that selects the named step; and a `Log: n errors · n warnings · n info` count in the status strip (`flow-log-counts`) that counts LINES, not steps — a `Log.Error` line is not a failed step and never moves the state chip. Two truncation notes sit beside it: `flow-logs-truncated` (*inline log lines capped — refresh outcomes pulls the rest*, from `FlowDebugRunResult.LogsTruncated`; it clears once a refresh merges in more log rows, because Replay reads the complete set) and `flow-unattached-logs`. Live and Replay read the same rows, so the two modes render identically once telemetry has landed.
- **Authorization (arch-review F3).** The page is `RequireFlowDebugger` — `AuthorizationPolicies.FlowDebuggerAccessRoles` = Deployer, Administrator, Viewer — so an auditor can open it and Replay a past execution without the ability to run anything, and `FlowDebugReplayCommand` accepts the same set. **Everything that executes stays Deployer-only:** the three run commands (`FlowDebugRunMethodCommand` / `FlowDebugRunScriptCommand` / `FlowDebugRunValueChangeCommand`), the Live entry-point controls, the parked-node Retry/Discard actions and the `?methodId=` / `?instanceId=` / `?script=` drill-ins; for a non-Deployer the page pins itself to Replay mode.
- **Two entry points** (script entry redesigned 2026-09-08, `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`). An inbound API method (inputs form generated from the method's `ParameterDefinitions`, values coerced by declared type) or a **site script** (Site → Instance → Script pickers, site-scope filtered): a script whose trigger is ValueChange or Conditional shows one *New value for `<attribute>`* field, with the attribute derived from the script's own trigger configuration rather than picked; every other script kind (Call, Interval, Expression, or no trigger) shows a `ParameterValueForm` generated from the script's `ParameterDefinitions`, or a "this script takes no parameters" note when there are none. A method-entry Live run is additionally site-scoped **at route time** on the server (`RouteHelper.WithSiteScope` → `SITE_SCOPE_DENIED`, arch-review F2), so an operator cannot reach a site outside their scope by choosing a method that routes there. Both entry points pick the target instance through the shared **`InstancePicker`** combobox (2026-09-08, `docs/plans/2026-09-08-instance-picker-design.md`): a text field whose dropdown is the site's area tree (`InstanceTreeBuilder` — all areas collapsed on open, un-areaed instances at the root, orphaned rows surface at the root), typed text filters by instance name and expands to the matches, Enter picks the first visible match, Escape restores the selection.
- **Predicted graph first.** Choosing an entry point renders the statically predicted shape (`FlowShapePredictor`, Script Analysis REQ-SA-7) before anything runs — nodes whose target depends on inputs render dashed; after a run, predicted nodes the run never produced render greyed.
+39 -1
View File
@@ -152,7 +152,7 @@ The `sbk_<keyId>_<secret>` design already supports zero-downtime rotation — a
| `TIMEOUT` | 500 | The method script exceeded its execution timeout. |
| `SITE_UNREACHABLE` | 500 | The target site did not respond to the routed request within the routing timeout (no contact / down / partitioned) — typed from the routed Ask expiring (`AskTimeoutException`), never inferred from an error-message substring. A `Success=false` response means the site answered and is classified `SCRIPT_ERROR`. |
| `SCRIPT_COMPILE_FAILED` | 500 | The method's script failed to compile (returned consistently on every node until a compiling version is saved). |
| `SCRIPT_ERROR` | 500 | Catch-all for any other script-execution failure (details are logged centrally, never leaked to the caller). |
| `SCRIPT_ERROR` | 500 | Catch-all for any other script-execution failure (details are logged centrally, never leaked to the caller — the exception's `ToString()` is captured on the audit row as `ErrorDetail`, never in the response body). |
### Extended Type System
- API method parameter and return type definitions support an **extended type system** beyond the four template attribute types (Boolean, Integer, Float, String):
@@ -295,6 +295,44 @@ Containment rules (enforced, not advisory):
For everything else, scripts interact with the system through the curated `Route` and
`Parameters` surfaces above.
#### Logging (`Log`)
Method scripts get the same leveled `Log` global site scripts do (design
`docs/plans/2026-09-09-flow-debugger-step-logs-design.md` D4/D5) — `InboundScriptContext.Log`,
backed by `InboundScriptLogger`:
- `Log.Debug(message, exception?)` / `Log.Info` / `Log.Warn` / `Log.Error` — synchronous, `void`,
never throws into the script.
- **Always** forwards to the central node's `ILogger`. A `ScriptTrace` / `ScriptLog` audit row is
written **only when the request is traced** (a Flow Debugger Live run, or a real call to a method
with `TraceAllCalls`), through the executor's inline trace sink and `ICentralAuditWriter`; an
ordinary call pays a log entry and nothing more.
- Lines with no explicit step id are attributed to the request's anchor row (the `InboundRequest`
row, or the debugger's synthesized `RouteCall` root), so they render on the inbound node.
- **Bounded:** `ScadaBridge:InboundApi:MaxLogLinesPerRun` (default 200, validated `> 0`) caps the
rows one request may emit, messages are clipped at 2 000 characters, and one closing `warn` row
names the dropped count.
#### Script exception detail
`InboundScriptResult` carries an internal `ErrorDetail` (`ex.ToString()` of the fault the method
script threw). It is written to the request's audit row — by `FlowDebugRunner` for a Live run, and
on the ordinary HTTP path via `HttpContext.Items[AuditWriteMiddleware.ErrorDetailItemKey]` — and is
projected onto `FlowStep.ErrorDetail` for the Flow Debugger's exception block. **It is never
returned to the HTTP caller:** the response body stays the generic `SCRIPT_ERROR` envelope,
unchanged.
**Note the asymmetry with the site half, which is easy to get wrong:** the inbound row's
`errorDetail` is written on **every failing method request**, traced or not — the middleware stamps
it whenever the script faulted, so it is not gated on `TraceAllCalls` or on a Live run (those only
decide whether the row is later *readable as a Flow Debugger step*). Its size is bounded by the
redactor's truncation stage, which for an `ApiInbound` row selects `AuditLog:InboundMaxBytes`
(1 MiB default — `SelectCap` gives this channel its own ceiling instead of the 64 KB
`ErrorCapBytes` other channels' error rows get), so a deep stack is truncated there rather than
dropped. The **site** `ScriptRun` row's `errorDetail`, by contrast, is **traced-only** — an
untraced site script run emits no `ScriptTrace` row at all, so there is nothing to carry it; that
row, being a `ScriptTrace` error row, is the one bounded at the 64 KB `ErrorCapBytes`.
### Routing Behavior
- The `Route.To()` helper resolves the instance's site assignment from the configuration database and routes the request to the correct site cluster via the Communication Layer.
- The call is **synchronous from the API caller's perspective** — the API method blocks until the site responds or the **method-level timeout** is reached.
@@ -148,6 +148,8 @@ var errors = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
```
This allows the compile to bind `Attributes["name"]`, `Notify.To("x").Send(...)`, `ExternalSystem.Call(...)`, `Alarms.CurrentAsync()`, and similar API calls against real types, catching undefined-symbol and type-mismatch errors before deployment.
The surface also mirrors the **`Log` global** (step logs design `docs/plans/2026-09-09-flow-debugger-step-logs-design.md` D4) as the compile-only `ScriptCompileSurface.CompileLog` — `Debug` / `Info` / `Warn` / `Error(string message, Exception? exception = null)`, including the optional exception parameter, so `Log.Warn("…", ex)` binds at the design-time gate exactly as it does at the site. `Log` needs no `ScriptTrustPolicy` change for the same reason `Alarms` did not: the trust boundary is a deny-list over API roots, not an allow-list of context members.
Where an accessor returns a domain type rather than a primitive, the stub returns the **same** type the runtime returns (not a compile-only stand-in) — `Alarms.CurrentAsync()` yields `IReadOnlyList<ScriptAlarm>` on both surfaces, so a script reading `a.Severity` / `a.AckTime` binds identically at the gate and at the site. This works because those types live in Commons, which is already in `DefaultAssemblies`.
**Trust-model note:** the script trust boundary is a **deny-list over API roots**, not an allow-list of context members, so adding a globals member such as `Alarms` requires no `ScriptTrustPolicy` change. What must hold is that the member's return type resolves in a *permitted* namespace — pinned by a test asserting no entry in `ForbiddenScopes` prefixes the Commons script-surface namespace.
+10 -1
View File
@@ -516,6 +516,15 @@ Available to all script runs and alarm on-trigger runs:
- The type lives in Commons so the runtime accessor and the Script Analysis compile-only surface (`ScriptCompileSurface.CompileAlarmsAccessor`) project to the **same** type — a script binding at the design-time gate binds identically at the site. Parity is pinned by `CompileSurfaceParityTests`.
- Design record: `docs/plans/2026-06-30-mes-alarm-status-api.md` §5.2.
### Logging (`Log`)
- `Log.Debug(message, exception?)` / `Log.Info` / `Log.Warn` / `Log.Error` — leveled, per-run logging available to **every** script, alongside the `Alarms` accessor above (design `docs/plans/2026-09-09-flow-debugger-step-logs-design.md` D4/D5). Synchronous, `void`, and it **never throws into the script**: a failing sink is swallowed, because a debug observer must not fail a production run.
- **Every line always reaches the node's `ILogger`** (structured `Instance` / `Script` / `ExecutionId` / message), traced or not — an untraced run is an ordinary logging surface and pays nothing else. A `ScriptTrace` / `ScriptLog` **audit row** is emitted **only when the run is traced** (a Flow Debugger Live run, or a real call to a method with `TraceAllCalls`), so a timer or value-change script logging at high rate costs no audit volume. There is no site-event-log sink for `Log.*`.
- The implementation is `ScriptLogger` (`SiteRuntime/Scripts/ScriptLogger.cs`), created by `ScriptRuntimeContext` and exposed as `ScriptGlobals.Log`. **One logger instance is one run's counter** — `CallShared` shares the calling context, so an inline shared script shares the caller's counter, execution id and anchor row rather than starting its own.
- **Runtime lines land on the step that produced them.** While a run is traced, `ScriptRuntimeContext` wraps its own `ILogger` in a `TraceCapturingLogger`, which forwards everything to the inner logger unchanged and additionally copies **Information and above** into `ScriptLog` rows with `logSource = "runtime"` (Debug/Trace are deliberately not captured — internal chatter). `IsEnabled` returns true for Information and above regardless of the node's minimum level, because those entries are trace data rather than node log output. Attribution rides an ambient `TraceStepScope` (`AsyncLocal<Guid?>`): `SetAttribute`, `WriteBatchAndWait`, `WaitInternal` and the synchronous `ExternalSystem.Call` pre-mint their step row's `EventId`, open the scope around their body and pass the id to `EmitTrace`, so a warning the runtime logs inside a write shows on that write's node. Everything else falls back to the run's anchor `ScriptRun` row. **Build `ScriptLogger` over the UNDECORATED logger** — `TraceCapturingLogger`'s constructor rejects an inner `TraceCapturingLogger`, since double wrapping would emit two rows per line.
- **Bounded (D7).** `ScadaBridge:SiteRuntime:MaxLogLinesPerRun` (default 200, validated `> 0`) caps the rows one run may emit; messages are clipped at 2 000 characters. Lines past the cap are counted and dropped, and a single closing `warn` / `runtime` row naming the dropped count is flushed at run end (it bypasses the counter — it is the one row that must always land).
- **Exception detail.** A failed run's `ScriptRun` row now carries `errorDetail = ex.ToString()` alongside the existing one-line `errorMessage`, so the Flow Debugger can show type, message, stack and inner exception. A timeout emits no detail — an `OperationCanceledException` stack is noise.
- **`Console.WriteLine` is not the logging surface on a site.** `System` is imported, so it compiles, and the Central UI Test Run sandbox does capture it; but on a site node `Console.Out` is process-global and shared by every concurrent script run, so per-run capture is unsound. `Log.*` is the leveled, per-run replacement, and the sandbox's mirror writes to the same console pane.
### Parameter Access
- `Parameters["key"]` — Raw dictionary access (returns `object?`, requires manual casting).
- `Parameters.Get<T>("key")` — Typed access with descriptive error messages. Throws `ScriptParameterException` if parameter is missing, null, or cannot be converted to `T`.
@@ -535,7 +544,7 @@ Available to all script runs and alarm on-trigger runs:
Scripts execute **in-process** with constrained access. The following restrictions are enforced at compilation and runtime:
- **Allowed**: Access to the Script Runtime API (GetAttribute, SetAttribute, CallScript, CallShared, ExternalSystem, Notify, Database, Tracking, Alarms), standard C# language features, basic .NET types (collections, string manipulation, math, date/time). `System.Diagnostics.Stopwatch`, `Debug`, and `Activity` are permitted.
- **Allowed**: Access to the Script Runtime API (GetAttribute, SetAttribute, CallScript, CallShared, ExternalSystem, Notify, Database, Tracking, Alarms, Log), standard C# language features, basic .NET types (collections, string manipulation, math, date/time). `System.Diagnostics.Stopwatch`, `Debug`, and `Activity` are permitted.
- **Forbidden**: File system access (`System.IO`), process spawning (`System.Diagnostics.Process`), threading (`System.Threading` — except `Tasks`, `CancellationToken`, and `CancellationTokenSource`), reflection (`System.Reflection`), all raw network access (`System.Net` — must use `ExternalSystem.Call`), native interop (`System.Runtime.InteropServices`, `Microsoft.Win32`), assembly loading, unsafe code, `dynamic`, `Activator`.
- **Execution timeout**: Configurable per-script maximum execution time, **measured from enqueue** (WP3.1) — a run that spends its whole budget queued behind blocked scripts is shed at dequeue without executing, rather than being granted a fresh full budget for work whose triggering condition is already stale. Exceeding the timeout cancels the script **cooperatively** (S2/UA5): a script blocked in synchronous I/O or a tight CPU loop does not observe cancellation and continues to occupy its dedicated script-execution thread. A **watchdog names the script** once its thread has not returned within a grace period after the timeout — and, as of WP3.1, **detaches and replaces that worker thread**, so a wedged script costs the pool a thread only until the watchdog fires rather than for the rest of the process. The detached worker exits (instead of pulling more work) when its body finally returns, so capacity never silently doubles. Live detached workers are capped at the pool size — at the cap no replacement is started and an Error site event says so, because bounded starvation is preferable to unbounded thread growth when scripts wedge en masse. The scheduler's **queue depth / busy-thread / oldest-busy-age / detached-thread gauges** and the **per-interval shed count** are reported through Health Monitoring so a saturated, stuck, or shedding scheduler is visible.
- **Memory**: Scripts share the host process memory. No per-script memory limit, but the execution timeout prevents runaway allocations.
@@ -255,6 +255,20 @@ internal static class CommandHelpers
// element, in first-seen order. Using only items[0] would silently drop
// columns for any later element with a different shape.
var objectItems = items.Where(i => i.ValueKind == JsonValueKind.Object).ToList();
// A "note" property that appears ONLY on single-property rows (a trailing
// run-level note, or — Task 14 review, 2026-09-09 — a step's folded log/exception
// line) is not a real column: every other row's cell there would render blank,
// and column padding would swallow the raw line's own leading indentation. Such
// rows print as a raw unpadded line instead, at their position in the sequence,
// and "note" is excluded from the header union so nothing renders a blank column
// for it.
var noteOnlyRows = objectItems
.Where(i => i.EnumerateObject().Any(p => p.Name == "note"))
.ToList();
var noteIsRawLine = noteOnlyRows.Count > 0
&& noteOnlyRows.All(i => i.EnumerateObject().Count() == 1);
string[] headers;
if (objectItems.Count > 0)
{
@@ -262,8 +276,12 @@ internal static class CommandHelpers
var known = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in objectItems)
foreach (var prop in item.EnumerateObject())
{
if (noteIsRawLine && prop.Name == "note")
continue;
if (known.Add(prop.Name))
seen.Add(prop.Name);
}
headers = seen.ToArray();
}
else
@@ -275,12 +293,20 @@ internal static class CommandHelpers
{
if (item.ValueKind == JsonValueKind.Object)
{
return headers.Select(h =>
if (noteIsRawLine
&& item.EnumerateObject().Count() == 1
&& item.TryGetProperty("note", out var noteValue))
{
return OutputFormatter.TableRow.Raw(
noteValue.ValueKind == JsonValueKind.Null ? "" : noteValue.ToString());
}
return OutputFormatter.TableRow.Columns(headers.Select(h =>
item.TryGetProperty(h, out var val)
? val.ValueKind == JsonValueKind.Null ? "" : val.ToString()
: "").ToArray();
: "").ToArray());
}
return new[] { item.ToString() };
return OutputFormatter.TableRow.Columns(new[] { item.ToString() });
});
OutputFormatter.WriteTable(rows, headers);
@@ -51,13 +51,16 @@ public static class FlowDebugCommands
+ "(default 5 min), so a larger value still 504s at that ceiling",
DefaultValueFactory = _ => 120,
};
var showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
var cmd = new Command("run-method") { Description = "Run an inbound API method on the active central node with full tracing and print the flow's steps" };
cmd.Add(methodIdOption);
cmd.Add(paramsOption);
cmd.Add(timeoutOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) =>
{
var timeoutSeconds = result.GetValue(timeoutOption);
var showLogs = result.GetValue(showLogsOption);
if (timeoutSeconds <= 0)
{
OutputFormatter.WriteError("--timeout-seconds must be greater than zero.", "INVALID_ARGUMENT");
@@ -79,7 +82,7 @@ public static class FlowDebugCommands
result, urlOption, formatOption, usernameOption, passwordOption,
new FlowDebugRunMethodCommand(result.GetValue(methodIdOption), parameters),
timeout: TimeSpan.FromSeconds(timeoutSeconds),
tableProjector: FlowDebugTableProjection.ProjectEvents);
tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
});
return cmd;
}
@@ -102,6 +105,7 @@ public static class FlowDebugCommands
+ "(default 5 min), so a larger value still 504s at that ceiling",
DefaultValueFactory = _ => 120,
};
var showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
var cmd = new Command("run-script") { Description = "Run one site script with tracing — a ValueChange/Conditional script is fired by writing its trigger attribute (--value); any other script is called directly (--params)" };
cmd.Add(instanceIdOption);
cmd.Add(scriptOption);
@@ -109,10 +113,12 @@ public static class FlowDebugCommands
cmd.Add(valueOption);
cmd.Add(waitOption);
cmd.Add(timeoutOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) =>
{
var waitSeconds = result.GetValue(waitOption);
var timeoutSeconds = result.GetValue(timeoutOption);
var showLogs = result.GetValue(showLogsOption);
if (waitSeconds <= 0)
{
OutputFormatter.WriteError("--wait-seconds must be greater than zero.", "INVALID_ARGUMENT");
@@ -145,7 +151,7 @@ public static class FlowDebugCommands
result.GetValue(valueOption),
waitSeconds),
timeout: TimeSpan.FromSeconds(Math.Max(timeoutSeconds, waitSeconds + ClientSlackSeconds)),
tableProjector: FlowDebugTableProjection.ProjectEvents);
tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
});
return cmd;
}
@@ -156,14 +162,17 @@ public static class FlowDebugCommands
var attributeOption = new Option<string>("--attribute") { Description = "Canonical attribute name to write", Required = true };
var valueOption = new Option<string>("--value") { Description = "Value to write (canonical string form)", Required = true };
var waitOption = new Option<int>("--wait-seconds") { Description = "How long to wait for a triggered script run before reporting NO_TRIGGER_WITHIN_WINDOW", DefaultValueFactory = _ => 45 };
var showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
var cmd = new Command("run-value-change") { Description = "Write an attribute through the traced path and capture the script run(s) the change triggers — the attribute-level path (also fires Expression triggers); prefer run-script to run a named script" };
cmd.Add(instanceIdOption);
cmd.Add(attributeOption);
cmd.Add(valueOption);
cmd.Add(waitOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) =>
{
var waitSeconds = result.GetValue(waitOption);
var showLogs = result.GetValue(showLogsOption);
if (waitSeconds <= 0)
{
OutputFormatter.WriteError("--wait-seconds must be greater than zero.", "INVALID_ARGUMENT");
@@ -178,7 +187,7 @@ public static class FlowDebugCommands
result.GetValue(valueOption)!,
waitSeconds),
timeout: TimeSpan.FromSeconds(waitSeconds + ClientSlackSeconds),
tableProjector: FlowDebugTableProjection.ProjectEvents);
tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
});
return cmd;
}
@@ -186,14 +195,17 @@ public static class FlowDebugCommands
private static Command BuildReplay(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var executionIdOption = new Option<Guid>("--execution-id") { Description = "Any execution ID in the chain (from an audit row)", Required = true };
var showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
var cmd = new Command("replay") { Description = "Replay a past flow from the central audit store" };
cmd.Add(executionIdOption);
cmd.Add(showLogsOption);
cmd.SetAction(async (ParseResult result) =>
{
var showLogs = result.GetValue(showLogsOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new FlowDebugReplayCommand(result.GetValue(executionIdOption)),
tableProjector: FlowDebugTableProjection.ProjectEvents);
tableProjector: json => FlowDebugTableProjection.ProjectEvents(json, showLogs));
});
return cmd;
}
@@ -18,13 +18,18 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// to the generic renderer unprojected, the object takes the Property/Value branch and the
/// whole event array lands in one cell as raw JSON. This projects the events through the same
/// <see cref="FlowGraphBuilder"/> the Flow Debugger page uses and prints one row per STEP —
/// <c>occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error</c> — in the
/// order the page draws them, so a terminal user can read the swimlane top-to-bottom. An
/// <c>occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error | logs</c> —
/// in the order the page draws them, so a terminal user can read the swimlane top-to-bottom. An
/// outbound operation's rows (a cached call's Submit → Forwarded → Attempted → Resolve, a
/// notification's Send → Deliver) fold into ONE row whose <c>lifecycle</c> column counts them
/// and whose <c>status</c> is the latest row's (outbound-calls plan 2026-09-06, D3). A trailing
/// <c>note</c> row reports a failed / incomplete run (with the error code verbatim, e.g.
/// <c>STANDBY_NODE</c>) or a truncated event list.
/// and whose <c>status</c> is the latest row's (outbound-calls plan 2026-09-06, D3). <c>logs</c>
/// counts the step's attached <c>ScriptLog</c> lines (step-logs design 2026-09-09); passing
/// <c>showLogs</c> additionally prints those lines, and a failed step's exception detail, as
/// raw indented lines immediately under that step's row — <see cref="CommandHelpers"/>'s table
/// renderer special-cases a single-property <c>{ "note": … }</c> row to print it that way
/// instead of padding it into a table cell. A trailing <c>note</c> row (the same special case)
/// reports a failed / incomplete run (with the error code verbatim, e.g. <c>STANDBY_NODE</c>)
/// or a truncated event list — and, separately, an inline log-line cap (<c>logsTruncated</c>).
/// </para>
///
/// <para>JSON output is never touched — <c>--format json</c> gets the full raw result.</para>
@@ -37,8 +42,14 @@ internal static class FlowDebugTableProjection
/// renderer's own fallbacks still apply.
/// </summary>
/// <param name="json">The raw success JSON body from the management API.</param>
/// <param name="showLogs">
/// When true, prints each step's <c>ScriptLog</c> lines and (on a failed step) the first
/// three lines of its exception detail as additional indented <c>note</c> rows immediately
/// following that step's row (step-logs design 2026-09-09, CLI Task 14). The <c>logs</c>
/// column (a count of attached log lines) is always present, independent of this flag.
/// </param>
/// <returns>Compact JSON suitable for table rendering.</returns>
internal static string ProjectEvents(string json)
internal static string ProjectEvents(string json, bool showLogs = false)
{
JsonDocument doc;
try
@@ -61,8 +72,13 @@ internal static class FlowDebugTableProjection
}
var truncated = TryGetPropertyCI(root, "truncated", out var t) && t.ValueKind == JsonValueKind.True;
// Step logs (design 2026-09-09 D7): the inline copy sheds low-priority ScriptLog rows
// BEFORE it sheds step rows, so `logsTruncated` is a separate signal from `truncated`
// and needs its own note — the audit rows are complete, so a replay shows every line.
var logsTruncated = TryGetPropertyCI(root, "logsTruncated", out var lt)
&& lt.ValueKind == JsonValueKind.True;
var rows = ProjectSteps(events, truncated);
var rows = ProjectSteps(events, truncated, showLogs);
if (rows == null)
{
// The event array did not deserialize as canonical audit events (a future wire
@@ -87,6 +103,10 @@ internal static class FlowDebugTableProjection
if (truncated)
notes.Add("event list truncated — the audit rows remain complete (audit tree --execution-id <root>)");
// Final review M8: the page shows this; the CLI silently dropped it.
if (logsTruncated)
notes.Add("inline log lines capped — replay pulls the rest");
if (notes.Count > 0)
rows.Add(new JsonObject { ["note"] = JsonValue.Create(string.Join("; ", notes)) });
@@ -103,7 +123,7 @@ internal static class FlowDebugTableProjection
/// Builds the flow graph from the canonical events and projects one row per step. Returns
/// null when the array is not a list of canonical audit events.
/// </summary>
private static JsonArray? ProjectSteps(JsonElement events, bool truncated)
private static JsonArray? ProjectSteps(JsonElement events, bool truncated, bool showLogs)
{
List<AuditEvent>? list;
try
@@ -148,12 +168,47 @@ internal static class FlowDebugTableProjection
["lifecycle"] = lifecycle is { Count: > 0 } ? $"{lifecycle.Count} {(lifecycle.Count == 1 ? "row" : "rows")}" : null,
["correlation"] = step.CorrelationId?.ToString("D"),
["error"] = step.ErrorMessage,
["logs"] = step.Logs?.Count ?? 0,
});
if (!showLogs)
continue;
if (step.Logs is { Count: > 0 } logs)
{
foreach (var line in logs)
rows.Add(new JsonObject { ["note"] = JsonValue.Create(FormatLogLine(line)) });
}
if (!string.IsNullOrWhiteSpace(step.ErrorDetail))
{
foreach (var line in FormatErrorDetailLines(step.ErrorDetail))
rows.Add(new JsonObject { ["note"] = JsonValue.Create(line) });
}
}
return rows;
}
/// <summary>Formats one <see cref="FlowLogLine"/> as <c> HH:mm:ss.fff LEVEL source message</c> (UTC, level upper-cased and padded to 5 chars).</summary>
private static string FormatLogLine(FlowLogLine line)
{
var time = line.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
var level = (line.Level ?? "info").ToUpperInvariant().PadRight(5);
return $" {time} {level} {line.Source} {line.Message}";
}
/// <summary>
/// Formats the first three lines of an exception's <c>ToString()</c> detail — the first
/// prefixed <c> exception: </c>, the rest indented four spaces.
/// </summary>
private static IEnumerable<string> FormatErrorDetailLines(string errorDetail)
{
var lines = errorDetail.Replace("\r\n", "\n").Split('\n');
for (var i = 0; i < Math.Min(3, lines.Length); i++)
yield return i == 0 ? $" exception: {lines[i]}" : $" {lines[i]}";
}
/// <summary>Projects one canonical audit event (camelCase JSON) to its compact row (fallback path).</summary>
private static JsonNode ProjectEvent(JsonElement evt)
{
@@ -31,19 +31,74 @@ public static class OutputFormatter
/// <param name="rows">Data rows; each inner array corresponds to a column in the same order as <paramref name="headers"/>.</param>
/// <param name="headers">Column header labels.</param>
public static void WriteTable(IEnumerable<string[]> rows, string[] headers)
=> WriteTable(rows.Select(TableRow.Columns), headers);
/// <summary>
/// One row for the <see cref="TableRow"/> overload of <c>WriteTable</c>: either padded
/// column cells like the plain <see cref="string[]"/> overload, or an already-formatted
/// line printed verbatim with no column padding/alignment at all (Task 14 review,
/// 2026-09-09) — used for a note/log line folded onto a step, which must render as a raw
/// indented line under that step's row rather than as a cell in a table column (every
/// other row's cell there would otherwise render blank, and the line's own leading
/// whitespace would be swallowed by column padding).
/// </summary>
internal readonly struct TableRow
{
var allRows = new List<string[]> { headers };
allRows.AddRange(rows);
private TableRow(string[]? cells, string? rawLine)
{
Cells = cells;
RawLine = rawLine;
}
internal string[]? Cells { get; }
internal string? RawLine { get; }
/// <summary>A normal row, rendered as padded columns aligned with the header set.</summary>
internal static TableRow Columns(string[] cells) => new(cells, null);
/// <summary>An already-formatted line, printed exactly as given with no column alignment.</summary>
internal static TableRow Raw(string line) => new(null, line);
}
/// <summary>
/// Writes a plain-text padded table like <see cref="WriteTable(IEnumerable{string[]}, string[])"/>,
/// except a <see cref="TableRow.Raw"/> row is printed verbatim — unpadded, uninvolved in the
/// column-width computation — at its position in the row sequence, immediately after
/// whatever row precedes it.
/// </summary>
/// <param name="rows">Data rows, each either padded columns or a raw line.</param>
/// <param name="headers">Column header labels.</param>
internal static void WriteTable(IEnumerable<TableRow> rows, string[] headers)
{
var rowList = rows.ToList();
var widths = new int[headers.Length];
foreach (var row in allRows)
void Consider(string[] row)
{
for (int i = 0; i < Math.Min(row.Length, widths.Length); i++)
widths[i] = Math.Max(widths[i], (row[i] ?? "").Length);
}
foreach (var row in allRows)
Consider(headers);
foreach (var row in rowList)
if (row.Cells != null)
Consider(row.Cells);
void PrintColumns(string[] row)
{
for (int i = 0; i < headers.Length; i++)
Console.Write((i < row.Length ? row[i] ?? "" : "").PadRight(widths[i] + 2));
Console.WriteLine();
}
PrintColumns(headers);
foreach (var row in rowList)
{
if (row.RawLine != null)
Console.WriteLine(row.RawLine);
else
PrintColumns(row.Cells!);
}
}
}
+18 -6
View File
@@ -2293,23 +2293,31 @@ Trace steps are ordinary audit rows: a run leaves them in the central `AuditLog`
chain after the fact.
Table output prints one row per step in chronological order —
`occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error` — the same
`occurred | kind | lane | target | durationMs | status | lifecycle | correlation | error | logs` — the same
projection the Flow Debugger page draws (`FlowGraphBuilder`): an outbound `ExternalSystem.*`,
`Database.*` or `Notify` call is ONE row on the `External` lane (kinds `ApiCall` / `DbCall` /
`Notify`), and a cached call's or notification's later rows (Forwarded → Attempted → Delivered /
Parked) fold into it — `lifecycle` counts them and `status` is the latest row's (`Submitted`
while the operation is still in flight; re-run `replay` to see it settle). A trailing `note`
while the operation is still in flight; re-run `replay` to see it settle). `logs` is the count
of `ScriptLog` lines a step's script emitted (step-logs design 2026-09-09). A trailing `note`
row reports a run that did not complete (the error code verbatim) or a truncated inline event
list. `--format json` returns the raw `FlowDebugRunResult` (`rootExecutionId`,
`completed`, `error`, `errorCode`, `events`, `truncated`, `startedAtUtc`, `finishedAtUtc`).
Pass `--show-logs` (on `run-method`, `run-script`, `run-value-change` and `replay`) to print
each step's log lines immediately under its row, as additional indented `note` rows —
` HH:mm:ss.fff LEVEL source message` (UTC, level upper-cased) — and, for a failed step whose
error carries an exception detail, the first three lines of it (` exception: <first line>`,
then two more lines indented four spaces). Off by default; the `logs` count column is always
shown regardless of this flag.
#### `flow-debug run-method`
Run an inbound API method with the given parameters, exactly as an external caller would,
and print the traced flow.
```sh
scadabridge --url <url> flow-debug run-method --method-id <int> [--params <json>] [--timeout-seconds 120]
scadabridge --url <url> flow-debug run-method --method-id <int> [--params <json>] [--timeout-seconds 120] [--show-logs]
```
| Option | Required | Description |
@@ -2317,6 +2325,7 @@ scadabridge --url <url> flow-debug run-method --method-id <int> [--params <json>
| `--method-id` | yes | Inbound API method ID (`api-method list`) |
| `--params` | no | Flat JSON object of parameters, validated against the method's parameter schema. Omit for a parameterless method. |
| `--timeout-seconds` | no | Client-side wait for the run (default 120). Raise this above the method's own `TimeoutSeconds + 25` for a long-running method, or the client gives up before the server's own run grace does. **Server ceiling:** the management endpoint applies `ScadaBridge:ManagementService:LongRunningCommandTimeout` (default 5 min) to `flow-debug run-method`, so a value above that still returns `504` at the server's ceiling — raise the server setting too if a method legitimately runs longer. |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row (table output only; default off) |
Parameters that fail schema validation are reported as `VALIDATION_FAILED` without running
the script.
@@ -2331,7 +2340,7 @@ method. The runner resolves the script from the instance's flattened configurati
the branch itself — the client does not need to know the script's trigger kind ahead of time.
```sh
scadabridge --url <url> flow-debug run-script --instance-id <int> --script <name> [--params <json>] [--value <string>] [--wait-seconds 45] [--timeout-seconds 120]
scadabridge --url <url> flow-debug run-script --instance-id <int> --script <name> [--params <json>] [--value <string>] [--wait-seconds 45] [--timeout-seconds 120] [--show-logs]
```
| Option | Required | Description |
@@ -2342,6 +2351,7 @@ scadabridge --url <url> flow-debug run-script --instance-id <int> --script <name
| `--value` | no | Value to write to the trigger attribute of a ValueChange/Conditional script, in canonical string form |
| `--wait-seconds` | no | Write kinds: how long to wait for the triggered run before reporting `NO_TRIGGER_WITHIN_WINDOW` (default 45) |
| `--timeout-seconds` | no | Client-side wait for the run (default 120). Raise this above the script's own `ExecutionTimeoutSeconds + 25` for a long-running script. **Server ceiling:** the management endpoint applies `ScadaBridge:ManagementService:LongRunningCommandTimeout` (default 5 min) to `flow-debug run-script`, so a value above that still returns `504` at the server's ceiling — raise the server setting too if a script legitimately runs longer. |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row (table output only; default off) |
A missing `--value` on a write-kind script, or `--params` that fail schema validation on a
call-kind script, are reported as `VALIDATION_FAILED` without running the script. An unknown
@@ -2355,7 +2365,7 @@ attribute-level path (it is also the way to fire an `Expression` trigger, which
attribute of its own); prefer `run-script` to run a script by name.
```sh
scadabridge --url <url> flow-debug run-value-change --instance-id <int> --attribute <name> --value <string> [--wait-seconds 45]
scadabridge --url <url> flow-debug run-value-change --instance-id <int> --attribute <name> --value <string> [--wait-seconds 45] [--show-logs]
```
| Option | Required | Description |
@@ -2364,6 +2374,7 @@ scadabridge --url <url> flow-debug run-value-change --instance-id <int> --attrib
| `--attribute` | yes | Canonical attribute name to write |
| `--value` | yes | Value to write, in canonical string form |
| `--wait-seconds` | no | How long to wait for a triggered run before reporting `NO_TRIGGER_WITHIN_WINDOW` (default 45) |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row (table output only; default off) |
The client waits `--wait-seconds` + 25 (no separate option — the client's HTTP wait always
derives from `--wait-seconds`). The write itself is real — the attribute changes — and is the root step of the flow.
@@ -2376,12 +2387,13 @@ default 30) correlates back to this run.
Rebuild a past flow from the central audit store.
```sh
scadabridge --url <url> flow-debug replay --execution-id <guid>
scadabridge --url <url> flow-debug replay --execution-id <guid> [--show-logs]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--execution-id` | yes | Any execution ID in the chain — the root is resolved server-side |
| `--show-logs` | no | Print each step's `ScriptLog` lines and exception detail as indented lines under its row (table output only; default off) |
Only flows whose site legs were traced (a Live run, or a method with `--trace-all-calls`
set) have site steps to replay; an untraced call shows its central `RouteCall` step plus the
@@ -3,6 +3,7 @@
@using System.Text.Json
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging
@inject NavigationManager Navigation
@inject IJSRuntime Js
@* Flow Debugger step detail (design 2026-09-05 §4): tabs Input · Output ·
Attribute writes · Timing plus a "Where this ran" column with drill-ins.
@@ -11,7 +12,9 @@
System / Connection / List + "Delivered by" facts.
Follow-ups (plan 2026-09-06 D2/D5, Task 6): an "Open in …" deep link to the report page
for a correlated outbound step, and Retry / Discard buttons on a Parked one when the owner
supplies the callbacks. *@
supplies the callbacks.
Step logs (plan 2026-09-09 D8, Task 12): a Log tab after Output carrying the step's thrown exception
and the log lines folded onto it, level-filtered, with a hint to sibling steps of the same execution. *@
<div class="card" data-test="flow-step-detail">
<div class="card-body">
@@ -55,10 +58,98 @@
case "Output":
@if (!string.IsNullOrEmpty(Step.ErrorMessage))
{
<div class="alert alert-danger small py-1 mb-2" data-test="flow-step-error">@Step.ErrorMessage</div>
<div class="alert alert-danger small py-1 mb-2" data-test="flow-step-error">
@Step.ErrorMessage
@if (HasException)
{
<text> </text>
<a href="#" @onclick="ShowLogTab" @onclick:preventDefault
data-test="flow-step-error-see-log">see exception</a>
}
</div>
}
<pre class="fsd-json" data-test="flow-step-output">@Pretty(Step.OutputJson)</pre>
break;
case "Log":
@if (HasException)
{
<div class="fsd-exc" data-test="flow-step-exception">
<div class="fsd-exc-type">@ExceptionTypeName</div>
<div class="fsd-exc-message">@ExceptionMessageText</div>
<pre class="fsd-exc-detail">@Step.ErrorDetail</pre>
<button type="button" class="btn btn-outline-secondary btn-sm mt-1"
data-test="flow-step-exception-copy"
@onclick="() => CopyAsync(Step!.ErrorDetail)">Copy</button>
</div>
}
@if (LogLineCount > 0)
{
<div class="fsd-logctl">
<div class="btn-group btn-group-sm" role="group" aria-label="Log level filter">
<button type="button" class="btn @(_warnOnly ? "btn-outline-secondary" : "btn-secondary")"
data-test="flow-step-log-filter-all" @onclick="() => _warnOnly = false">All</button>
<button type="button" class="btn @(_warnOnly ? "btn-secondary" : "btn-outline-secondary")"
data-test="flow-step-log-filter-warn" @onclick="() => _warnOnly = true">Warn +</button>
</div>
<span class="text-muted">@LogLineCount line@(LogLineCount == 1 ? "" : "s") on this step</span>
@if (SiblingStepsWithLogs is { Count: > 0 } siblings)
{
<span class="text-muted" data-test="flow-step-log-hint">
@for (var i = 0; i < siblings.Count; i++)
{
var other = siblings[i];
@(i > 0 ? " · " : "")
<text>@other.Logs!.Count more on </text>
@if (OnSelectStep.HasDelegate)
{
<button type="button" class="btn btn-link btn-sm p-0 align-baseline"
@onclick="() => OnSelectStep.InvokeAsync(other)">@other.Title</button>
}
else
{
<text>@other.Title</text>
}
}
</span>
}
<button type="button" class="btn btn-outline-secondary btn-sm ms-auto"
data-test="flow-step-log-copy" @onclick="CopyLinesAsync">Copy lines</button>
</div>
<table class="table table-sm small mb-0" data-test="flow-step-log-lines">
<thead><tr><th>Time (UTC)</th><th>Level</th><th>Source</th><th>Message</th></tr></thead>
<tbody>
@foreach (var line in VisibleLogLines)
{
<tr data-level="@line.Level">
<td class="font-monospace text-nowrap">@line.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture)</td>
<td><span class="fsd-lvl fsd-lvl-@line.Level.ToLowerInvariant()">@line.Level</span></td>
<td class="text-muted text-nowrap">@line.Source</td>
<td class="text-break">
@line.Message
@if (!string.IsNullOrEmpty(line.ExceptionMessage))
{
<div class="fsd-line-exc font-monospace small">
@line.ExceptionMessage
@if (!string.IsNullOrEmpty(line.ExceptionDetail))
{
<details>
<summary>detail</summary>
<pre class="fsd-exc-detail mb-0">@line.ExceptionDetail</pre>
</details>
}
</div>
}
</td>
</tr>
}
</tbody>
</table>
}
else if (!HasException)
{
<div class="text-muted small">No log lines on this step.</div>
}
break;
case "Writes":
@if (Step.Extra?.Writes is { Count: > 0 } writes)
{
@@ -187,8 +278,8 @@
</div>
@code {
private static readonly string[] Tabs = ["Input", "Output", "Writes", "Timing"];
private static readonly string[] TabsWithLifecycle = ["Input", "Output", "Lifecycle", "Timing"];
private static readonly string[] Tabs = ["Input", "Output", "Log", "Writes", "Timing"];
private static readonly string[] TabsWithLifecycle = ["Input", "Output", "Log", "Lifecycle", "Timing"];
/// <summary>The selected step; null renders the placeholder.</summary>
[Parameter] public FlowStep? Step { get; set; }
@@ -211,8 +302,20 @@
/// <summary>True while the owner is relaying a Retry / Discard — disables both buttons.</summary>
[Parameter] public bool ActionInProgress { get; set; }
/// <summary>
/// Select another step: the Log tab's cross-step hint names the sibling steps of the same execution
/// that carry their own lines. No delegate = the hint renders their titles as plain text.
/// </summary>
[Parameter] public EventCallback<FlowStep> OnSelectStep { get; set; }
private string _tab = "Input";
/// <summary>Log level filter: false = All, true = Warn + (hides <c>debug</c> and <c>info</c>).</summary>
private bool _warnOnly;
/// <summary>Step the tab state belongs to — a different step re-picks the default tab and resets the filter.</summary>
private Guid? _tabStepId;
private bool IsOutbound => Step is { } s && FlowStepProjector.IsOutbound(s.Kind);
/// <summary>
@@ -234,19 +337,89 @@
private bool ShowActions
=> Step is { Outcome: FlowStepOutcome.Parked, CorrelationId: not null } && IsOutbound;
/// <summary>Outbound steps swap the Writes tab (never populated for them) for the Lifecycle tab.</summary>
/// <summary>
/// Outbound steps swap the Writes tab (never populated for them) for the Lifecycle tab; the Log tab
/// appears only for a step that actually has something to show there (D8).
/// </summary>
private IEnumerable<string> VisibleTabs
=> Step?.Lifecycle is { Count: > 0 } ? TabsWithLifecycle : Tabs;
{
get
{
var tabs = Step?.Lifecycle is { Count: > 0 } ? TabsWithLifecycle : Tabs;
return HasLogTab ? tabs : tabs.Where(t => t != "Log");
}
}
/// <summary>Kind-specific label for the Input tab: what the payload actually is.</summary>
/// <summary>Number of log lines folded onto this step.</summary>
private int LogLineCount => Step?.Logs?.Count ?? 0;
/// <summary>True when the step carries a thrown exception's full detail.</summary>
private bool HasException => !string.IsNullOrWhiteSpace(Step?.ErrorDetail);
private bool HasLogTab => LogLineCount > 0 || HasException;
/// <summary>Kind-specific label for the Input tab, plus the Log tab's line count.</summary>
private string TabLabel(string tab)
=> tab != "Input" ? tab : Step?.Kind switch
{
if (tab == "Log") return LogLineCount > 0 ? $"Log ({LogLineCount})" : "Log";
if (tab != "Input") return tab;
return Step?.Kind switch
{
FlowStepKind.ApiCall => "Request",
FlowStepKind.DbCall => "SQL + parameters",
FlowStepKind.Notify => "Subject + body",
_ => "Input",
};
}
/// <summary>
/// The parsed header of the captured exception detail (final review M1: the shared
/// <see cref="ExceptionHeader"/>, so this panel and the graph node name the SAME exception —
/// the innermost of a <c>" ---&gt; "</c> chain, where this panel used to show the outermost).
/// The type is NOT shortened here: the node badge is the place that strips the namespace.
/// </summary>
private (string Type, string Message)? ExceptionHead
=> ExceptionHeader.Parse(Step?.ErrorDetail, shortenType: false);
/// <summary>The exception's type, shown in full here.</summary>
private string ExceptionTypeName => ExceptionHead?.Type ?? string.Empty;
/// <summary>The exception's message; falls back to the step's own error summary if the detail is not the usual shape.</summary>
private string ExceptionMessageText
=> ExceptionHead is { Message.Length: > 0 } head ? head.Message : (Step?.ErrorMessage ?? string.Empty);
/// <summary>Lines shown under the current filter — <c>Warn +</c> hides <c>debug</c> and <c>info</c>, keeping anything unrecognised.</summary>
private IEnumerable<FlowLogLine> VisibleLogLines
=> (Step?.Logs ?? []).Where(l => !_warnOnly
|| !(string.Equals(l.Level, "debug", StringComparison.OrdinalIgnoreCase)
|| string.Equals(l.Level, "info", StringComparison.OrdinalIgnoreCase)));
/// <summary>Other steps of the same execution that carry lines of their own (the cross-step hint, D8).</summary>
private IReadOnlyList<FlowStep> SiblingStepsWithLogs
=> Step is { ExecutionId: { } exec } s && Graph is not null
? Graph.Steps.Where(o => o.ExecutionId == exec && o.EventId != s.EventId && o.Logs is { Count: > 0 }).ToList()
: [];
private void ShowLogTab() => _tab = "Log";
/// <summary>Copies the whole exception detail / the shown lines to the clipboard.</summary>
private Task CopyLinesAsync()
=> CopyAsync(string.Join('\n', VisibleLogLines.Select(l =>
$"{l.OccurredAtUtc.ToUniversalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture)} {l.Level} {l.Source} {l.Message}")));
private async Task CopyAsync(string? text)
{
if (string.IsNullOrEmpty(text)) return;
try
{
await Js.InvokeVoidAsync("navigator.clipboard.writeText", text);
}
catch (Exception)
{
// Copying is a convenience: the clipboard API is absent outside a secure context and the
// interop call throws on a disconnected / prerendering circuit. Never surface that to the operator.
}
}
private string TargetLabel => Step?.Kind switch
{
@@ -264,7 +437,18 @@
protected override void OnParametersSet()
{
if (!VisibleTabs.Contains(_tab)) _tab = "Input";
if (Step?.EventId != _tabStepId)
{
// A different step: open on Log when it threw (D8), otherwise keep the operator's current
// tab when the new step still has it, and start the level filter over on All.
_tabStepId = Step?.EventId;
_tab = HasException ? "Log" : (VisibleTabs.Contains(_tab) ? _tab : "Input");
_warnOnly = false;
}
else if (!VisibleTabs.Contains(_tab))
{
_tab = "Input";
}
}
private int AuditRowCount
@@ -166,6 +166,12 @@
<span><span class="fd-label">Steps</span><span class="fd-mono" data-test="flow-step-count">@_graph.Steps.Count</span></span>
<span><span class="fd-label">Boundaries</span><span class="fd-mono">@_graph.Edges.Count(e => e.CrossesBoundary)</span></span>
<span><span class="fd-label">Writes</span><span class="fd-mono">@_graph.Steps.Count(s => s.Kind == FlowStepKind.AttributeWrite)</span></span>
@* Step logs (design 2026-09-09 D5): the run's line counts by level — a Log.Error line is
NOT a failed step, so it never moves the state chip. *@
@if (LogCounts is { Total: > 0 })
{
<span><span class="fd-label">Log</span><span class="fd-mono" data-test="flow-log-counts">@LogSummary</span></span>
}
@if (PendingCount > 0)
{
<span><span class="fd-label">Pending</span><span class="fd-mono" data-test="flow-pending-count">@PendingCount</span></span>
@@ -191,6 +197,14 @@
{
<span class="badge bg-warning text-dark">event list truncated</span>
}
@if (_graph?.LogsTruncated == true)
{
<span class="badge bg-warning text-dark" data-test="flow-logs-truncated">inline log lines capped — refresh outcomes pulls the rest</span>
}
@if (_graph?.UnattachedLogLines > 0)
{
<span class="text-muted small" data-test="flow-unattached-logs">@_graph.UnattachedLogLines log line(s) could not be placed on a step</span>
}
</div>
<div class="row g-3">
@@ -284,7 +298,7 @@
SiteDbId="@(_entry == EntryKind.Script && _selectedSiteId > 0 ? _selectedSiteId : null)"
InstanceDbId="@(_entry == EntryKind.Script && _selectedInstanceId > 0 ? _selectedInstanceId : null)"
OnRetry="RetryCallback" OnDiscard="DiscardCallback"
ActionInProgress="_actionInProgress" />
ActionInProgress="_actionInProgress" OnSelectStep="OnSelectStep" />
</div>
</div>
</div>
@@ -183,6 +183,44 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private int PendingCount => _graph?.Steps.Count(s => s.Outcome == FlowStepOutcome.Pending) ?? 0;
/// <summary>
/// Step logs (design 2026-09-09 D5): the run's log lines summed by level over every step that
/// carries any. <c>error</c> and <c>warn</c> are counted by name (ordinal, case-insensitive —
/// the level rides the audit row as free text); everything else, <c>debug</c> included, is
/// "info", so the three counts always add up to <c>Total</c>. Lines the builder could not place
/// on a step are NOT here — they are reported separately as
/// <see cref="FlowGraph.UnattachedLogLines"/>.
/// </summary>
internal static (int Errors, int Warnings, int Info, int Total) CountLogs(FlowGraph? graph)
{
if (graph is null)
return (0, 0, 0, 0);
var errors = 0;
var warnings = 0;
var info = 0;
foreach (var line in graph.Steps.SelectMany(s => s.Logs ?? []))
{
if (string.Equals(line.Level, "error", StringComparison.OrdinalIgnoreCase)) errors++;
else if (string.Equals(line.Level, "warn", StringComparison.OrdinalIgnoreCase)) warnings++;
else info++;
}
return (errors, warnings, info, errors + warnings + info);
}
private (int Errors, int Warnings, int Info, int Total) LogCounts => CountLogs(_graph);
/// <summary>Status-strip text for <see cref="LogCounts"/>.</summary>
private string LogSummary
{
get
{
var counts = LogCounts;
return $"{counts.Errors} errors · {counts.Warnings} warnings · {counts.Info} info";
}
}
/// <summary>Pending outbound work, or an acted-on node that is still Parked, keeps the refresh loop alive.</summary>
private bool ShouldKeepPolling =>
PendingCount > 0
@@ -647,11 +685,25 @@ public partial class FlowDebugger : ComponentBase, IDisposable
var selectedId = _selected?.EventId;
var merged = MergeEvents(_raw.Events, outcome.Raw?.Events);
var truncated = _raw.Truncated || outcome.Raw?.Truncated == true;
_raw = _raw with { Events = merged, Truncated = truncated };
_graph = FlowGraphBuilder.Build(merged, truncated);
// D7: the inline copy's cap is only worth reporting while the page still holds that
// incomplete copy. A refresh MERGES rows, so the moment it brings back even one ScriptLog
// row the run did not already have, the page is no longer short of lines and the flag
// clears. Until then it stays: the site's rows reach central's audit store by telemetry
// 5-30 s later, so a refresh that adds nothing proves nothing.
// Final review M7: read the OUTCOME's own flag — FlowDebugService copies the raw result's
// onto it precisely so a caller need not unwrap Raw, and reading Raw here left the record
// member with no consumer.
var logsTruncated = (_raw.LogsTruncated || outcome.LogsTruncated)
&& CountLogRows(merged) <= CountLogRows(_raw.Events);
_raw = _raw with { Events = merged, Truncated = truncated, LogsTruncated = logsTruncated };
_graph = FlowGraphBuilder.Build(merged, truncated, logsTruncated);
_selected = selectedId is { } id ? _graph.Steps.FirstOrDefault(s => s.EventId == id) ?? _selected : _selected;
}
/// <summary>Number of <c>ScriptTrace.ScriptLog</c> rows in <paramref name="events"/> — the log lines a refresh can add to.</summary>
private static int CountLogRows(IReadOnlyList<AuditEvent>? events)
=> events?.Count(FlowStepProjector.IsLogLine) ?? 0;
/// <summary>Union of two audit-row lists by <see cref="AuditEvent.EventId"/>; a row present in both takes the fresh copy.</summary>
internal static IReadOnlyList<AuditEvent> MergeEvents(IReadOnlyList<AuditEvent>? known, IReadOnlyList<AuditEvent>? fresh)
{
@@ -69,3 +69,57 @@
max-height: 18rem;
overflow: auto;
}
/* Step detail Log tab (step-logs plan 2026-09-09 D8): the thrown exception, then the folded lines. */
::deep .fsd-exc {
border: 1px solid var(--bad, var(--bs-danger, #e03131));
border-left-width: 4px;
border-radius: 4px;
background: var(--bad-bg, rgba(var(--bs-danger-rgb, 220, 53, 69), 0.08));
padding: 8px 12px;
margin-bottom: 10px;
}
::deep .fsd-exc-type {
font-family: var(--mono, var(--bs-font-monospace));
font-weight: 600;
color: var(--bad, var(--bs-danger, #e03131));
}
::deep .fsd-exc-detail {
margin: 6px 0 0;
font-family: var(--mono, var(--bs-font-monospace));
font-size: 0.75rem;
white-space: pre;
overflow-x: auto;
color: var(--ink-soft, var(--bs-secondary-color));
}
::deep .fsd-line-exc {
color: var(--ink-soft, var(--bs-secondary-color));
margin-top: 2px;
}
::deep .fsd-logctl {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
font-size: 0.8rem;
}
::deep .fsd-lvl {
display: inline-block;
min-width: 46px;
text-align: center;
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 1px 6px;
border-radius: 3px;
}
::deep .fsd-lvl-info { color: var(--ink-soft, var(--bs-secondary-color)); background: var(--rule, rgba(0, 0, 0, 0.06)); }
::deep .fsd-lvl-warn { color: var(--warn, var(--bs-warning, #e8920c)); background: var(--warn-bg, rgba(var(--bs-warning-rgb, 255, 193, 7), 0.15)); }
::deep .fsd-lvl-error { color: var(--bad, var(--bs-danger, #e03131)); background: var(--bad-bg, rgba(var(--bs-danger-rgb, 220, 53, 69), 0.12)); }
::deep .fsd-lvl-debug { color: var(--ink-faint, var(--bs-tertiary-color)); background: transparent; border: 1px dashed var(--rule-strong, var(--bs-border-color)); }
@@ -70,6 +70,7 @@
@foreach (var n in _layout.Nodes)
{
var node = n;
var logs = LogCounts(node.Step);
<g class="@NodeClass(node)" transform="translate(@F(node.X) @F(node.Y))"
role="button" tabindex="0"
aria-label="@(Eyebrow(node) + ": " + node.Title)"
@@ -86,6 +87,9 @@
<span class="fg-duration">@Duration(node)</span>
</div>
<div class="fg-title" data-test="flow-node-title">@WrapHints(node.DisplayTitle)</div>
@* Footer row: the pill-or-subtitle on the left, the log-count badge on the right
(step-logs design 2026-09-09 D8). A node with no lines shows no badge at all. *@
<div class="fg-foot">
@if (HasPill(node))
{
@* Outbound kinds: the folded outcome as a pill, then the call mode (node anatomy: "● Ok · sync"). *@
@@ -98,6 +102,24 @@
{
<div class="fg-subtitle" data-test="flow-node-subtitle">@node.DisplaySubtitle</div>
}
@if (logs.Errors + logs.Warnings + logs.Info > 0)
{
<span class="fg-logs" data-test="flow-node-logs" title="@LogTitle(logs)">
@if (logs.Errors > 0)
{
<i class="e">✕ @logs.Errors</i>
}
@if (logs.Warnings > 0)
{
<i class="w">! @logs.Warnings</i>
}
@if (logs.Info > 0)
{
<i class="i">≡ @logs.Info</i>
}
</span>
}
</div>
</div>
</foreignObject>
</g>
@@ -40,8 +40,14 @@ public sealed record FlowLayoutNode(
/// <summary>Title with <see cref="LanePrefix"/> removed — the discriminating tail of a dotted path.</summary>
public string DisplayTitle => FlowGraphView.StripPrefix(Title, LanePrefix) ?? Title;
/// <summary>Subtitle with <see cref="LanePrefix"/> removed from each comma-separated item.</summary>
public string? DisplaySubtitle => FlowGraphView.StripPrefix(Subtitle, LanePrefix);
/// <summary>
/// Subtitle with <see cref="LanePrefix"/> removed from each comma-separated item — except on a
/// failed/timed-out real step, where the exception's type and message take the line instead
/// (step-logs design 2026-09-09 D8). A lane prefix is a dotted attribute head, never part of an
/// exception line, so the exception text is shown verbatim.
/// </summary>
public string? DisplaySubtitle => (Step != null ? FlowGraphView.ExceptionSubtitle(Step) : null)
?? FlowGraphView.StripPrefix(Subtitle, LanePrefix);
/// <summary>Full, unclipped text for the hover tooltip.</summary>
public string FullText => string.IsNullOrEmpty(Subtitle) ? Title : Title + " — " + Subtitle;
@@ -231,6 +237,55 @@ public partial class FlowGraphView : ComponentBase
return detail.Length == 0 ? "sync" : detail + " · sync";
}
/// <summary>
/// Log lines on a step folded to per-severity counts for the footer badge (step-logs design
/// 2026-09-09 D8). <c>error</c> and <c>warn</c> are counted by name (ordinal-ignore-case);
/// everything else — <c>info</c>, <c>debug</c>, an unknown level — counts as info, so the badge
/// never silently drops a line.
/// </summary>
internal static (int Errors, int Warnings, int Info) LogCounts(FlowStep? s)
{
if (s?.Logs is not { Count: > 0 } lines) return (0, 0, 0);
var errors = 0;
var warnings = 0;
var info = 0;
foreach (var line in lines)
{
if (string.Equals(line.Level, "error", StringComparison.OrdinalIgnoreCase)) errors++;
else if (string.Equals(line.Level, "warn", StringComparison.OrdinalIgnoreCase)) warnings++;
else info++;
}
return (errors, warnings, info);
}
/// <summary>Hover text for the log badge — the non-zero levels only ("1 error, 2 warnings, 4 info").</summary>
internal static string LogTitle((int Errors, int Warnings, int Info) counts)
{
var (errors, warnings, info) = counts;
var parts = new List<string>(3);
if (errors > 0) parts.Add($"{errors} error{(errors == 1 ? "" : "s")}");
if (warnings > 0) parts.Add($"{warnings} warning{(warnings == 1 ? "" : "s")}");
if (info > 0) parts.Add($"{info} info");
return string.Join(", ", parts);
}
/// <summary>
/// The subtitle a failed or timed-out step shows instead of its own: <c>{Type}: {message}</c>, where
/// the type is the exception's name without its namespace. Parsing is
/// <see cref="ExceptionHeader.Parse"/> (final review M1 — the same parser the step detail panel
/// uses), which takes the first line of the captured <see cref="FlowStep.ErrorDetail"/> and follows
/// its <c>" ---&gt; "</c> chain to the innermost exception. Falls back to the line itself when it
/// carries no <c>": "</c>, and to <see cref="FlowStep.ErrorMessage"/> when there is no usable detail.
/// Null for every non-failed step.
/// </summary>
internal static string? ExceptionSubtitle(FlowStep s)
{
if (s.Outcome is not (FlowStepOutcome.Failed or FlowStepOutcome.TimedOut)) return null;
if (ExceptionHeader.Parse(s.ErrorDetail, shortenType: true) is not { } head) return s.ErrorMessage;
return head.Message.Length == 0 ? head.Type : head.Type + ": " + head.Message;
}
private static string Eyebrow(FlowLayoutNode n) => n.Kind switch
{
FlowStepKind.InboundRequest => "REQUEST",
@@ -74,13 +74,23 @@
-webkit-box-orient: vertical;
overflow: hidden;
}
.fg-subtitle { color: var(--ink-soft); font-size: 0.66rem; margin-top: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Footer row: the pill-or-subtitle, then the log-count badge (step-logs design 2026-09-09 D8).
The foot owns the push to the bottom of the body; neither child does. */
.fg-foot { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 4px; min-width: 0; }
.fg-subtitle { color: var(--ink-soft); font-size: 0.66rem; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fg-node.failed .fg-subtitle,
.fg-node.timed-out .fg-subtitle { color: var(--bad, var(--bs-danger, #e03131)); font-family: var(--mono); }
.fg-logs { display: inline-flex; gap: 3px; flex-shrink: 0; font-family: var(--mono); font-size: 0.6rem; font-variant-numeric: tabular-nums; }
.fg-logs i { font-style: normal; padding: 0 4px; border-radius: 3px; line-height: 1.35; }
.fg-logs i.e { color: var(--bad, var(--bs-danger, #e03131)); background: var(--bad-bg, rgba(var(--bs-danger-rgb, 220, 53, 69), 0.12)); }
.fg-logs i.w { color: var(--warn, var(--bs-warning, #e8920c)); background: var(--warn-bg, rgba(var(--bs-warning-rgb, 255, 193, 7), 0.15)); }
.fg-logs i.i { color: var(--ink-faint); background: var(--rule); }
.fg-node.failed .fg-eyebrow,
.fg-node.timed-out .fg-eyebrow { color: var(--bs-danger, #c0392b); }
/* Outbound nodes (API call / database / notify): the folded outcome as a status pill, then the
call mode. Kit status tokens first (dark mode follows via site.css), Bootstrap's as fallback. */
.fg-status { margin-top: auto; display: flex; align-items: center; gap: 4px; min-width: 0; }
.fg-status { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; }
.fg-pill {
display: inline-flex;
align-items: center;
@@ -28,6 +28,14 @@ public class InboundScriptHost
/// </summary>
public DatabaseAccessor Database { get; } = new();
/// <summary>
/// Leveled per-run logging. Editor mirror of
/// ZB.MOM.WW.ScadaBridge.InboundAPI.InboundScriptLogger (step logs design 2026-09-09 D4);
/// shares <see cref="SandboxLog"/> with the Test Run sandbox because only the signatures
/// matter here — analysis never invokes it.
/// </summary>
public SandboxLog Log { get; } = new();
/// <summary>
/// The cancellation token for the operation.
/// </summary>
@@ -26,6 +26,13 @@ public class SandboxInboundScriptHost
/// <summary>Gets the database accessor; every call throws <see cref="ScriptSandboxException"/> in a test run.</summary>
public DatabaseAccessor Database { get; } = new();
/// <summary>
/// Sandbox mirror of the inbound runtime's <c>Log</c> global (step logs design 2026-09-09 D4).
/// Present so a method script that logs BINDS in the Test Run editor; lines go to the Test Run
/// console pane and are never audited.
/// </summary>
public SandboxLog Log { get; } = new();
/// <summary>
/// Mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.InboundDatabaseHelper — signatures match
/// the runtime helper so the same user code compiles, but every call throws because a
@@ -87,6 +87,58 @@ public class SandboxScriptHost
/// would show a spurious CS1061 on a script that deploys and runs perfectly.
/// </summary>
public SandboxAlarmsAccessor Alarms { get; } = new();
/// <summary>
/// Sandbox mirror of the site runtime's <c>Log</c> global (step logs design 2026-09-09 D4).
/// Present so a script that logs BINDS in the Test Run editor; lines go to the Test Run
/// console pane and are never audited. Delegates to <see cref="SandboxInstanceContext.Log"/>
/// exactly as the runtime's <c>ScriptGlobals.Log =&gt; Instance.Log</c> does, so
/// <c>Log</c> and <c>Instance.Log</c> are the same object here too.
/// </summary>
public SandboxLog Log => Instance.Log;
}
/// <summary>
/// Sandbox mirror of <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptLogger</c> (step logs
/// design 2026-09-09 D4). Signature-faithful so the same script body compiles here and at a site,
/// but a Test Run has no execution to trace and no audit trail: every line is written to
/// <see cref="Console.Out"/>, which the sandbox captures into
/// <c>SandboxRunResult.ConsoleOutput</c> (see <see cref="SandboxConsoleCapture"/>) and shows in
/// the editor's console pane. Nothing here reaches the node log or the audit log.
/// </summary>
public class SandboxLog
{
/// <summary>Writes a debug-level line to the Test Run console pane.</summary>
/// <param name="message">The message text.</param>
/// <param name="exception">An optional exception, rendered on a following line.</param>
public void Debug(string message, Exception? exception = null) => Write("debug", message, exception);
/// <summary>Writes an information-level line to the Test Run console pane.</summary>
/// <param name="message">The message text.</param>
/// <param name="exception">An optional exception, rendered on a following line.</param>
public void Info(string message, Exception? exception = null) => Write("info", message, exception);
/// <summary>Writes a warning-level line to the Test Run console pane.</summary>
/// <param name="message">The message text.</param>
/// <param name="exception">An optional exception, rendered on a following line.</param>
public void Warn(string message, Exception? exception = null) => Write("warn", message, exception);
/// <summary>
/// Writes an error-level line to the Test Run console pane. Like the runtime logger, this
/// does NOT fail the run — it is a message, not a throw.
/// </summary>
/// <param name="message">The message text.</param>
/// <param name="exception">An optional exception, rendered on a following line.</param>
public void Error(string message, Exception? exception = null) => Write("error", message, exception);
private static void Write(string level, string message, Exception? exception)
{
Console.Out.WriteLine($"[{level}] {message}");
if (exception != null)
{
Console.Out.WriteLine(exception.ToString());
}
}
}
/// <summary>
@@ -193,6 +245,12 @@ public class SandboxInstanceContext
/// </summary>
public SandboxScriptCallHelper Scripts { get; }
/// <summary>
/// Sandbox mirror of <c>ScriptRuntimeContext.Log</c> (step logs design 2026-09-09 D4), so
/// <c>Instance.Log.Info("…")</c> binds and prints in the Test Run console pane.
/// </summary>
public SandboxLog Log { get; } = new();
/// <summary>
/// Initializes a new instance of the SandboxInstanceContext.
/// </summary>
@@ -537,8 +537,8 @@ public sealed class FlowDebugService : IFlowDebugService
if (result == null)
return FlowDebugOutcome.Fail("The server returned no result.", "BAD_RESULT");
var graph = FlowGraphBuilder.Build(result.Events ?? [], result.Truncated);
return new FlowDebugOutcome(graph, result, null, null);
var graph = FlowGraphBuilder.Build(result.Events ?? [], result.Truncated, result.LogsTruncated);
return new FlowDebugOutcome(graph, result, null, null, result.LogsTruncated);
}
case ManagementUnauthorized unauthorized:
return FlowDebugOutcome.Fail(unauthorized.Message, "UNAUTHORIZED");
@@ -15,7 +15,14 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <param name="Raw">The server's <see cref="FlowDebugRunResult"/>; null when no result came back.</param>
/// <param name="Error">Human-readable failure, null on success.</param>
/// <param name="ErrorCode">Machine-readable failure code (<c>STANDBY_NODE</c>, <c>NO_TRIGGER_WITHIN_WINDOW</c>, <c>UNAUTHORIZED</c>, <c>SERVICE_UNAVAILABLE</c>, …), null on success.</param>
public sealed record FlowDebugOutcome(FlowGraph? Graph, FlowDebugRunResult? Raw, string? Error, string? ErrorCode)
/// <param name="LogsTruncated">
/// Step logs (design 2026-09-09 D7): true when the inline trace collector dropped low-priority
/// <c>ScriptLog</c> rows to protect room for the step rows — the run's lines are incomplete inline,
/// while Replay (which reads the never-truncated audit store) is complete. Mirrors
/// <see cref="FlowDebugRunResult.LogsTruncated"/> so the page can note it without unwrapping the raw result.
/// </param>
public sealed record FlowDebugOutcome(
FlowGraph? Graph, FlowDebugRunResult? Raw, string? Error, string? ErrorCode, bool LogsTruncated = false)
{
/// <summary>True when a graph was built (the run may still have finished incomplete — see <see cref="Raw"/>).</summary>
public bool Success => Graph != null && Error == null;
@@ -61,6 +61,11 @@ public record FlowDebugReplayCommand(Guid ExecutionId);
/// <param name="Truncated">True when the inline event list was capped (the audit rows remain complete).</param>
/// <param name="StartedAtUtc">When the run started.</param>
/// <param name="FinishedAtUtc">When the run finished (or the replay was served).</param>
/// <param name="LogsTruncated">
/// Step-logs (D7, 2026-09-09): true when the inline <c>FlowTraceCollector</c> dropped one
/// or more low-priority <c>ScriptLog</c> rows to protect room for step rows — the run's log lines
/// are incomplete inline, but Replay (which reads the audit store, never truncated) is complete.
/// </param>
public record FlowDebugRunResult(
Guid RootExecutionId,
bool Completed,
@@ -69,4 +74,5 @@ public record FlowDebugRunResult(
IReadOnlyList<AuditEvent> Events,
bool Truncated,
DateTimeOffset StartedAtUtc,
DateTimeOffset FinishedAtUtc);
DateTimeOffset FinishedAtUtc,
bool LogsTruncated = false);
@@ -0,0 +1,62 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <summary>
/// Flow Debugger (final review M1): the ONE parser for the header line of a captured
/// exception detail. A .NET <c>Exception.ToString()</c> opens with
/// <c>Namespace.Type: message</c>, and a wrapper (an <c>AggregateException</c>, a rethrow)
/// chains its inner exceptions on that same first line with <c>" ---&gt; "</c>.
///
/// <para>
/// Two surfaces render that header — the graph node's subtitle (namespace stripped, so the
/// badge stays narrow) and the step detail panel's exception block (type in full) — and they
/// had drifted: the panel showed the OUTERMOST exception while the node showed the innermost.
/// Both now call this, so they always agree on which exception is being named.
/// </para>
/// </summary>
public static class ExceptionHeader
{
private const string InnerMarker = " ---> ";
/// <summary>
/// Parses the innermost exception's type and message out of <paramref name="detail"/>.
/// Takes the first line, follows the <c>" ---&gt; "</c> chain to its LAST segment (the
/// innermost exception, which is the one a script author has to act on) and splits it on
/// the first <c>": "</c>.
/// </summary>
/// <param name="detail">The captured detail (a full <c>ToString()</c>), or null.</param>
/// <param name="shortenType">
/// When true the namespace is stripped from the head of the type name — only the head,
/// so a generic arity/argument suffix (<c>`1[System.String]</c>) is kept verbatim.
/// </param>
/// <returns>
/// The type and message; the whole line as the type with an EMPTY message when the line
/// carries no <c>": "</c>; null when there is no detail, or nothing left after the chain
/// is followed (callers fall back to their own error summary in both those cases).
/// </returns>
public static (string Type, string Message)? Parse(string? detail, bool shortenType)
{
if (string.IsNullOrWhiteSpace(detail)) return null;
var first = detail.Split('\n', 2)[0].TrimEnd('\r').Trim();
var inner = first.LastIndexOf(InnerMarker, StringComparison.Ordinal);
if (inner >= 0) first = first[(inner + InnerMarker.Length)..].Trim();
if (first.Length == 0) return null;
var split = first.IndexOf(": ", StringComparison.Ordinal);
if (split < 0) return (first, string.Empty);
var type = first[..split];
var message = first[(split + 2)..];
if (!shortenType) return (type, message);
// Shorten only the leading namespace: everything from a generic marker on is part of the name.
var generic = type.IndexOfAny(['`', '[']);
var head = generic < 0 ? type : type[..generic];
var dot = head.LastIndexOf('.');
if (dot >= 0 && dot < head.Length - 1)
{
type = head[(dot + 1)..] + (generic < 0 ? string.Empty : type[generic..]);
}
return (type, message);
}
}
@@ -14,11 +14,15 @@ public sealed record FlowEdge(Guid FromEventId, Guid ToEventId, bool CrossesBoun
/// <param name="Edges">Edges between steps.</param>
/// <param name="Predicted">True when the graph came from static analysis rather than real events.</param>
/// <param name="Truncated">True when the inline event list was capped (the audit rows remain complete — Replay shows everything).</param>
/// <param name="LogsTruncated">True when the inline trace collector dropped low-priority log rows to keep room for the step rows — the audit rows remain complete, so Replay shows every line (design 2026-09-09 D7).</param>
/// <param name="UnattachedLogLines">Count of log lines the builder could not place on any step (design 2026-09-09 D2).</param>
public sealed record FlowGraph(
IReadOnlyList<FlowStep> Steps,
IReadOnlyList<FlowEdge> Edges,
bool Predicted,
bool Truncated)
bool Truncated,
bool LogsTruncated = false,
int UnattachedLogLines = 0)
{
/// <summary>An empty, non-predicted graph.</summary>
public static FlowGraph Empty { get; } = new(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), false, false);
@@ -43,16 +43,43 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <item>When an <c>InboundRequest</c> row carries a response summary, a <c>Response</c> step is
/// synthesized as the final Central node, linked from the last non-async step of the <i>inbound</i>
/// execution (the route call, or the inbound row itself), never from a cascaded site step.</item>
/// <item><b>Log lines are never steps</b> (step-logs design 2026-09-09 D2). <c>ScriptLog</c> rows are
/// partitioned out before anything else — before the input-order index — and folded onto a step's
/// <see cref="FlowStep.Logs"/>: the step its <c>StepEventId</c> names, else the <i>anchor</i> of its
/// execution (the <c>InboundRequest</c>, <c>RouteCall</c> or <c>ScriptRun</c> step, in that order),
/// else that execution's first non-<c>Response</c> step. A line whose execution has no step at all is
/// dropped and counted in <see cref="FlowGraph.UnattachedLogLines"/>. Within a step, lines are ordered
/// by <c>OccurredAtUtc</c> then <c>Seq</c>, with a null <c>Seq</c> sorting last, so same-millisecond
/// lines keep their emission order.</item>
/// </list>
/// </remarks>
public static class FlowGraphBuilder
{
/// <summary>Builds the graph. <paramref name="truncated"/> is passed through to <see cref="FlowGraph.Truncated"/>.</summary>
public static FlowGraph Build(IReadOnlyList<AuditEvent> events, bool truncated = false)
/// <summary>
/// Builds the graph. <paramref name="truncated"/> and <paramref name="logsTruncated"/> are passed
/// through to <see cref="FlowGraph.Truncated"/> / <see cref="FlowGraph.LogsTruncated"/>.
/// </summary>
public static FlowGraph Build(IReadOnlyList<AuditEvent> events, bool truncated = false, bool logsTruncated = false)
{
ArgumentNullException.ThrowIfNull(events);
// D2: a ScriptLog row is a line, never a node. Partition BEFORE the input-order index below, so
// `seq` indexes only real step rows and a line can never shift an execution's display position.
var lines = new List<FlowLogLine>();
var stepEvents = new List<AuditEvent>(events.Count);
foreach (var e in events)
{
if (FlowStepProjector.IsLogLine(e))
lines.Add(FlowStepProjector.ToLogLine(e));
else
stepEvents.Add(e);
}
events = stepEvents;
if (events.Count == 0)
return new FlowGraph(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), Predicted: false, Truncated: truncated);
return new FlowGraph(Array.Empty<FlowStep>(), Array.Empty<FlowEdge>(), Predicted: false, Truncated: truncated,
LogsTruncated: logsTruncated, UnattachedLogLines: lines.Count);
// Arch-review F5: a step's position in the input is the only cross-node ordering we can trust —
// central rows carry central's clock, site rows the site's, and a 1-2 s skew used to sort a whole
@@ -230,7 +257,65 @@ public static class FlowGraphBuilder
edges.Add(new FlowEdge(origin.EventId, response.EventId, CrossesBoundary(origin, response), External: false));
}
return new FlowGraph(steps, edges, Predicted: false, Truncated: truncated);
// 5. Fold the log lines onto their owning steps (D2) — after the Response node joined `steps`,
// so a line naming it (none should) still resolves rather than counting as unattached.
AttachLogLines(steps, lines, out var unattached);
return new FlowGraph(steps, edges, Predicted: false, Truncated: truncated,
LogsTruncated: logsTruncated, UnattachedLogLines: unattached);
}
/// <summary>
/// Attaches each log line to its step (D2): the step named by <see cref="FlowLogLine.StepEventId"/>,
/// else the anchor of the line's execution (<c>InboundRequest</c> before <c>RouteCall</c> before
/// <c>ScriptRun</c> — an inbound row and its route call share one central execution), else that
/// execution's first non-<c>Response</c> step. Lines that resolve to nothing are counted in
/// <paramref name="unattached"/>. Rewrites <paramref name="steps"/> in place.
/// </summary>
private static void AttachLogLines(List<FlowStep> steps, List<FlowLogLine> lines, out int unattached)
{
unattached = 0;
if (lines.Count == 0) return;
var byEventId = new Dictionary<Guid, FlowStep>(steps.Count);
foreach (var step in steps)
byEventId[step.EventId] = step;
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<Guid, List<FlowLogLine>>();
foreach (var line in lines)
{
FlowStep? owner = null;
if (line.StepEventId is { } stepId && byEventId.TryGetValue(stepId, out var named))
owner = named;
else if (line.ExecutionId is { } execution
&& (anchorByExecution.TryGetValue(execution, out var fallback)
|| firstByExecution.TryGetValue(execution, out fallback)))
owner = fallback;
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() };
}
}
/// <summary>
@@ -74,6 +74,32 @@ public sealed record FlowLifecycleRow(
string? ErrorMessage,
int? DurationMs);
/// <summary>
/// One log line folded onto a step (<see cref="FlowStep.Logs"/>; design 2026-09-09 D1/D8) — the
/// projection of a <c>ScriptTrace.ScriptLog</c> audit row, which is never drawn as a node of its own.
/// </summary>
/// <param name="EventId">The audit row's id.</param>
/// <param name="ExecutionId">Execution the line was written in.</param>
/// <param name="OccurredAtUtc">When the line was written.</param>
/// <param name="Level"><c>debug</c> | <c>info</c> | <c>warn</c> | <c>error</c>.</param>
/// <param name="Source"><c>script</c> (a <c>Log.*</c> call) | <c>runtime</c> (the context's own logger while traced).</param>
/// <param name="Message">The logged message.</param>
/// <param name="ExceptionMessage">Message of an attached exception, when the line carried one.</param>
/// <param name="ExceptionDetail">Full <c>ToString()</c> of that exception.</param>
/// <param name="StepEventId">Step the line belongs to; null = the execution's anchor step (design D2).</param>
/// <param name="Seq">Per-run emission counter, breaking same-millisecond ties in display order.</param>
public sealed record FlowLogLine(
Guid EventId,
Guid? ExecutionId,
DateTimeOffset OccurredAtUtc,
string Level,
string Source,
string Message,
string? ExceptionMessage,
string? ExceptionDetail,
Guid? StepEventId,
int? Seq);
/// <summary>
/// One node of the flow graph — a central-side projection of a <c>ScriptTrace</c> /
/// <c>ApiInbound.InboundRequest</c> <see cref="ZB.MOM.WW.Audit.AuditEvent"/>, or of one outbound
@@ -106,4 +132,6 @@ public sealed record FlowStep(
string? Target,
ScriptTraceExtra? Extra,
IReadOnlyList<FlowLifecycleRow>? Lifecycle = null,
Guid? CorrelationId = null);
Guid? CorrelationId = null,
string? ErrorDetail = null,
IReadOnlyList<FlowLogLine>? Logs = null);
@@ -43,7 +43,36 @@ public static class FlowStepProjector
Instance: d.SourceInstanceId,
Target: evt.Target,
Extra: extra,
CorrelationId: evt.CorrelationId);
CorrelationId: evt.CorrelationId,
ErrorDetail: d.ErrorDetail);
}
/// <summary>True for a <c>ScriptTrace.ScriptLog</c> row — a log line, never a step.</summary>
public static bool IsLogLine(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(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);
}
/// <summary>Projects a <c>ScriptLog</c> row onto its line. Level/source default to <c>info</c>/<c>script</c> when the extra is missing.</summary>
public static FlowLogLine ToLogLine(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
var d = AuditDetailsCodec.Deserialize(evt.DetailsJson);
var x = ScriptTraceExtra.TryParse(d.Extra);
return new FlowLogLine(
EventId: evt.EventId,
ExecutionId: d.ExecutionId,
OccurredAtUtc: evt.OccurredAtUtc,
Level: x?.LogLevel ?? "info",
Source: x?.LogSource ?? "script",
Message: d.RequestSummary ?? string.Empty,
ExceptionMessage: d.ErrorMessage,
ExceptionDetail: d.ErrorDetail,
StepEventId: x?.StepEventId,
Seq: x?.Seq);
}
/// <summary>Projects one outbound audit row onto its lifecycle entry (<see cref="FlowStep.Lifecycle"/>).</summary>
@@ -27,12 +27,33 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// protobuf framing, and 1 048 576 chars is 1 MB of UTF-8 for ASCII payloads but up to ~3 MB for
/// non-ASCII ones. That worst case still fits the 4 MB limit with the rest of the reply, which is
/// what the budget exists to guarantee — raise it only with a real serialized-size measurement.
/// Step-logs addendum (D7, 2026-09-09): a <c>ScriptLog</c> row per script log line rides the same
/// sink via <see cref="Add"/>'s <c>lowPriority</c> parameter — a lower-priority lane that shares
/// the same event list/byte accounting but stops <see cref="LowPriorityReserveEvents"/> events
/// short of the count cap and at <see cref="LowPriorityByteShare"/> of the byte budget — so the
/// step rows a run emits at the very end (the <c>ScriptRun</c> anchor is written last) always
/// have room, even on a chatty run whose log lines alone would otherwise fill the collector. A
/// dropped low-priority row sets <see cref="LogsTruncated"/> instead of <see cref="Truncated"/>,
/// because losing inline log lines does not affect step-row fidelity the way losing a step row
/// would — the page reports it separately ("log lines truncated inline — Replay is complete").
/// </summary>
public sealed class FlowTraceCollector
{
/// <summary>Default inline cap per run (design §5.4 <c>MaxTraceEventsPerRun</c>).</summary>
public const int DefaultMaxEvents = 500;
/// <summary>
/// Events reserved for full-priority step rows — a low-priority (log line) <see cref="Add"/>
/// stops this many events short of the event cap (D7, 2026-09-09).
/// </summary>
public const int LowPriorityReserveEvents = 50;
/// <summary>
/// Share of the byte budget a low-priority (log line) <see cref="Add"/> may consume — the
/// remainder is reserved for full-priority step rows (D7, 2026-09-09).
/// </summary>
public const double LowPriorityByteShare = 0.75;
/// <summary>
/// Default inline byte budget per run — the summed redacted <see cref="AuditEvent.DetailsJson"/>
/// length in UTF-16 chars (see the type remarks: ~1 MB of UTF-8 for ASCII payloads, ~3 MB worst
@@ -53,6 +74,13 @@ public sealed class FlowTraceCollector
/// </summary>
public bool Truncated { get; private set; }
/// <summary>
/// True once a low-priority (log line) <see cref="Add"/> was dropped because the low-priority
/// event or byte share was reached — reported separately from <see cref="Truncated"/> because
/// step-row fidelity is unaffected (D7, 2026-09-09).
/// </summary>
public bool LogsTruncated { get; private set; }
/// <summary>
/// True once <see cref="Complete"/> ran — the run's reply snapshot has been taken and
/// later <see cref="Add"/> calls are ignored (they never count as <see cref="Truncated"/>).
@@ -80,7 +108,13 @@ public sealed class FlowTraceCollector
/// Appends the REDACTED copy of an event, or marks the collector truncated once the event
/// cap or the byte budget is reached. A no-op after <see cref="Complete"/>.
/// </summary>
public void Add(AuditEvent evt)
/// <param name="evt">The event to append.</param>
/// <param name="lowPriority">
/// True for a log-line row — stops at the reserve/byte share and sets
/// <see cref="LogsTruncated"/> instead of <see cref="Truncated"/>, so the step rows emitted
/// at run end are never crowded out (D7). False (default) for a full-priority step row.
/// </param>
public void Add(AuditEvent evt, bool lowPriority = false)
{
ArgumentNullException.ThrowIfNull(evt);
// Redaction runs OUTSIDE the lock (arch-review F1): IAuditRedactor is a pure function
@@ -91,7 +125,13 @@ public sealed class FlowTraceCollector
lock (_gate)
{
if (Completed) return;
if (_events.Count >= _max || _bytes + size > _maxBytes) { Truncated = true; return; }
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;
}
_events.Add(filtered);
_bytes += size;
}
@@ -49,6 +49,18 @@ public sealed record ScriptTraceExtra
/// <summary><c>flow-debugger</c> on rows the debugger synthesizes (the Live-run inbound row, the value-change root).</summary>
public string? Source { get; init; }
/// <summary>On a <c>ScriptLog</c> row: <c>debug</c> | <c>info</c> | <c>warn</c> | <c>error</c>.</summary>
public string? LogLevel { get; init; }
/// <summary>On a <c>ScriptLog</c> row: <c>script</c> (a <c>Log.*</c> call) | <c>runtime</c> (the context's own logger while traced).</summary>
public string? LogSource { get; init; }
/// <summary>On a <c>ScriptLog</c> row: the pre-minted <c>EventId</c> of the step the line belongs to; null = the execution's anchor step (design D2).</summary>
public Guid? StepEventId { get; init; }
/// <summary>On a <c>ScriptLog</c> row: per-run emission counter, breaks same-millisecond ties.</summary>
public int? Seq { get; init; }
/// <summary>Serializer options shared by every emitter and reader: camelCase, nulls omitted.</summary>
public static JsonSerializerOptions JsonOptions { get; } = new(JsonSerializerDefaults.Web)
{
@@ -61,5 +61,8 @@ public enum AuditKind
AttributeWrite,
/// <summary>One <c>WaitAsync</c>/<c>WaitForAsync</c> outcome in a traced script (traced executions only; <see cref="AuditChannel.ScriptTrace"/>).</summary>
AttributeWait
AttributeWait,
/// <summary>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).</summary>
ScriptLog
}
@@ -280,6 +280,16 @@ public static class EndpointExtensions
// production call so Replay of a real integration call is complete.
trace: method.TraceAllCalls);
// Flow Debugger step logs (design 2026-09-09 D3): the executor sanitises the
// caller-facing message, so this stash is the ONLY place a method script's real
// exception survives for the inbound audit row. AuditWriteMiddleware consumes the
// slot once when it builds that row; the HTTP body below still carries only the
// safe ErrorMessage.
if (scriptResult.ErrorDetail is { } scriptErrorDetail)
{
httpContext.Items[AuditWriteMiddleware.ErrorDetailItemKey] = scriptErrorDetail;
}
if (!scriptResult.Success)
{
// A client-aborted request is not a script failure.
@@ -69,4 +69,17 @@ public class InboundApiOptions
/// is still legitimately working and moves on to the next one. Default 60 s.
/// </summary>
public TimeSpan StandbyForwardTimeout { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>Default value of <see cref="MaxLogLinesPerRun"/>, also used when no options instance is available.</summary>
public const int DefaultMaxLogLinesPerRun = 200;
/// <summary>
/// Flow Debugger step logs (design 2026-09-09 D7): how many <c>Log.*</c> lines ONE traced
/// method execution may turn into <c>ScriptTrace/ScriptLog</c> audit rows. Past the cap the
/// lines are dropped from the trace — they still reach the node's <c>ILogger</c> — and the
/// run ends with a single runtime-sourced warning row naming the dropped count, so a
/// truncated trace is never silently short. Untraced executions emit no rows at all and are
/// therefore unaffected by this value. Must be greater than zero.
/// </summary>
public int MaxLogLinesPerRun { get; set; } = DefaultMaxLogLinesPerRun;
}
@@ -36,6 +36,10 @@ public sealed class InboundApiOptionsValidator : OptionsValidatorBase<InboundApi
$"ScadaBridge:InboundApi:StandbyForwardTimeout must be a positive duration " +
$"(was {options.StandbyForwardTimeout}); it bounds each standby→active relay attempt.");
builder.RequireThat(options.MaxLogLinesPerRun > 0,
$"ScadaBridge:InboundApi:MaxLogLinesPerRun must be greater than zero " +
$"(was {options.MaxLogLinesPerRun}); it caps the Log.* lines one traced method execution audits.");
foreach (var peer in options.StandbyForwardPeerEndpoints)
{
var ok = Uri.TryCreate(peer, UriKind.Absolute, out var uri)
@@ -4,7 +4,9 @@ using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
@@ -363,6 +365,9 @@ public class InboundScriptExecutor
// client abort), disposing the scope underneath it would fault the still-running
// task with ObjectDisposedException — so the finally defers disposal instead.
Task<object?>? handlerTask = null;
// Flow Debugger step logs (design 2026-09-09 D7): hoisted so the finally can flush the
// "lines dropped" closing row on EVERY exit path — success, timeout, or a thrown script.
InboundScriptLogger? scriptLogger = null;
try
{
scope = _serviceProvider.CreateScope();
@@ -392,10 +397,14 @@ public class InboundScriptExecutor
// come from the same scope as the gateway (both null-tolerant: an unregistered
// writer means no row, never a failed script), and the trace sink is the one the
// caller attached to the RouteHelper so a Live run collects both row kinds.
// Shared by the Database helper's outbound rows and the script's Log.* lines, so both
// land in the same store and carry the same SourceNode.
var auditWriter = scope?.ServiceProvider.GetService<ICentralAuditWriter>();
var nodeIdentity = scope?.ServiceProvider.GetService<INodeIdentityProvider>();
var dbHelper = new InboundDatabaseHelper(
gateway, cts.Token, timeout,
auditWriter: scope?.ServiceProvider.GetService<ICentralAuditWriter>(),
nodeIdentity: scope?.ServiceProvider.GetService<INodeIdentityProvider>(),
auditWriter: auditWriter,
nodeIdentity: nodeIdentity,
executionId: parentExecutionId,
parentExecutionId: null,
methodName: method.Name,
@@ -424,6 +433,47 @@ public class InboundScriptExecutor
&& auditOptions.PerTargetOverrides.TryGetValue(method.Name, out var perTargetOverride)
&& perTargetOverride.SkipBodyCapture;
// Flow Debugger step logs (design 2026-09-09 D4/D5/D7): the script's Log global.
// Lines ALWAYS reach this node's logger; only a TRACED execution also builds
// ScriptTrace/ScriptLog rows, which are written best-effort to the central store for
// Replay and then handed to the Live run's inline sink — exactly as
// RouteHelper.EmitRouteCall orders its RouteCall row. An untraced request builds no
// delegate and resolves no extra service.
var methodName = method.Name;
var maxLogLines = InboundApiOptions.DefaultMaxLogLinesPerRun;
Action<AuditEvent>? logEmit = null;
if (trace)
{
// The row cap comes from options when they are available (a sandbox/test provider
// that registers none falls back to the documented default).
maxLogLines = scope?.ServiceProvider
.GetService<IOptionsMonitor<InboundApiOptions>>()?.CurrentValue.MaxLogLinesPerRun
?? InboundApiOptions.DefaultMaxLogLinesPerRun;
var logTraceSink = route.TraceSink;
logEmit = evt =>
{
// Durable first: the Replay row must never depend on the Live sink succeeding.
WriteScriptLogBestEffort(auditWriter, evt, methodName);
try
{
logTraceSink?.Invoke(evt);
}
catch (Exception)
{
// A debug observer must never fail the request: a misbehaving Live sink is
// swallowed here so it cannot mask the durable write that already happened.
}
};
}
scriptLogger = new InboundScriptLogger(
_logger,
emit: logEmit,
maxLines: maxLogLines,
methodName: methodName,
executionId: parentExecutionId,
sourceNode: nodeIdentity?.NodeName);
var context = new InboundScriptContext(
parameters,
route.WithDeadline(cts.Token)
@@ -432,7 +482,8 @@ public class InboundScriptExecutor
.WithTrace(trace)
.WithBodyCapture(!skipBodyCapture),
dbHelper,
cts.Token);
cts.Token,
scriptLogger);
_scriptHandlers.TryGetValue(method.Name, out var cached);
// Revision check: a cached handler compiled from different script text is stale
@@ -548,11 +599,26 @@ public class InboundScriptExecutor
catch (Exception ex)
{
_logger.LogError(ex, "Script execution failed for method {Method}", method.Name);
// Safe error message, no internal details
return new InboundScriptResult(false, null, "Internal script error", "SCRIPT_ERROR");
// Safe error message, no internal details — the caller only ever sees this.
// Flow Debugger step logs (design 2026-09-09 D3): the exception itself rides the
// additive ErrorDetail so the endpoint can put it on the inbound AUDIT row, where
// the Flow Debugger's inbound node reads it. It must never reach the HTTP body.
return new InboundScriptResult(
false, null, "Internal script error", "SCRIPT_ERROR", ErrorDetail: ex.ToString());
}
finally
{
// The script body has finished (or been abandoned): close out its log lines before the
// caller completes its trace collector. Idempotent and a no-op when nothing was dropped.
try
{
scriptLogger?.FlushDropped();
}
catch (Exception)
{
// A debug observer must never fail the request.
}
if (handlerTask is null || handlerTask.IsCompleted)
{
scope?.Dispose();
@@ -577,6 +643,33 @@ public class InboundScriptExecutor
}
}
}
/// <summary>
/// Writes one <c>ScriptLog</c> row to the central audit store without ever blocking or failing
/// the script (design 2026-09-09 D5) — the same fire-and-observe pattern
/// <c>RouteHelper.EmitRouteCall</c> uses for its always-on <c>RouteCall</c> row. Never awaited on
/// a script path: an audit write completes on the writer's own flush cadence.
/// </summary>
private void WriteScriptLogBestEffort(ICentralAuditWriter? writer, AuditEvent evt, string methodName)
{
if (writer is null) return;
try
{
var t = writer.WriteAsync(evt, CancellationToken.None);
if (!t.IsCompletedSuccessfully)
{
_ = t.ContinueWith(
x => _logger.LogWarning(x.Exception, "Flow trace ScriptLog write failed for method {Method}", methodName),
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
}
catch (Exception)
{
// A debug observer must never fail the request: an audit failure is not a script failure.
}
}
}
/// <summary>
@@ -605,6 +698,14 @@ public class InboundScriptContext
/// </summary>
public CancellationToken CancellationToken { get; }
/// <summary>
/// Flow Debugger step logs (design 2026-09-09 D4): the script's leveled logger —
/// <c>Log.Debug/Info/Warn/Error(message, exception?)</c>. Always reaches the node's log; a
/// traced execution additionally records each line as a <c>ScriptTrace/ScriptLog</c> audit row
/// the Flow Debugger folds onto the step that produced it.
/// </summary>
public InboundScriptLogger Log { get; }
/// <summary>
/// Initializes a new instance of the InboundScriptContext.
/// </summary>
@@ -612,16 +713,25 @@ public class InboundScriptContext
/// <param name="route">The route helper for cross-site routing.</param>
/// <param name="database">The scoped, parameterized database helper for the script.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <param name="log">
/// The script's <c>Log</c> global. Optional so existing constructions (and the Central UI
/// sandbox host) keep compiling: when omitted the context gets an inert logger that emits no
/// audit rows and writes nowhere.
/// </param>
public InboundScriptContext(
IReadOnlyDictionary<string, object?> parameters,
RouteHelper route,
InboundDatabaseHelper database,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
InboundScriptLogger? log = null)
{
Parameters = new ScriptParameters(parameters);
Route = route;
Database = database;
CancellationToken = cancellationToken;
Log = log ?? new InboundScriptLogger(
NullLogger.Instance, emit: null, InboundApiOptions.DefaultMaxLogLinesPerRun,
methodName: string.Empty, executionId: null);
}
}
@@ -637,8 +747,18 @@ public class InboundScriptContext
/// <c>SCRIPT_COMPILE_FAILED</c>, <c>SCRIPT_ERROR</c>). Null when the executor does not
/// classify the failure; the endpoint then falls back to <c>SCRIPT_ERROR</c>.
/// </param>
/// <param name="ErrorDetail">
/// Flow Debugger step logs (design 2026-09-09 D3): verbose, operator-only failure detail —
/// the method script's exception <c>ToString()</c>. <b>Audit-row only.</b> The endpoint stashes
/// it on <c>HttpContext.Items[AuditWriteMiddleware.ErrorDetailItemKey]</c> so it lands on the
/// inbound audit row (and therefore on the Flow Debugger's inbound node); it is NEVER written
/// to the HTTP response, which keeps carrying the sanitised <see cref="ErrorMessage"/>. Null
/// for failures whose message already says everything (timeouts, client aborts, site-scope
/// denials, compile failures).
/// </param>
public record InboundScriptResult(
bool Success,
string? ResultJson,
string? ErrorMessage,
string? ErrorCode = null);
string? ErrorCode = null,
string? ErrorDetail = null);
@@ -0,0 +1,228 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// The <c>Log</c> global an inbound API method script writes through (Flow Debugger step logs,
/// design 2026-09-09 D4/D5/D7). Central counterpart of the site runtime's script logger; the two
/// deliberately share a shape but no code (the InboundAPI component never references SiteRuntime).
///
/// <para>
/// Every call ALWAYS reaches the node's <see cref="ILogger"/>, so an author's message is never
/// lost on an ordinary request. A <c>ScriptTrace</c>/<c>ScriptLog</c> audit row is emitted ONLY
/// when the execution is traced — the executor supplies a non-null <c>emit</c> delegate in that
/// case and <c>null</c> otherwise, so an untraced run pays nothing beyond the log write (D5).
/// </para>
///
/// <para>
/// The logger knows nothing about <c>ICentralAuditWriter</c> or the Live-run trace sink: the
/// executor composes both into the single <c>emit</c> delegate it passes in.
/// </para>
///
/// <para>
/// Bounded per run (D7): at most <c>maxLines</c> rows, each message truncated to
/// <see cref="MaxMessageChars"/>; further lines are counted and <see cref="FlushDropped"/> emits
/// one closing runtime-sourced <c>warn</c> row naming the dropped count at the end of the run.
/// </para>
///
/// <para>
/// A method script may log from several concurrent tasks, so the counters are guarded. Nothing in
/// this class is allowed to throw into the script: a debug observer must never fail the request.
/// </para>
/// </summary>
public sealed class InboundScriptLogger
{
/// <summary>Per-line message cap (design D7); longer messages are truncated with an ellipsis.</summary>
public const int MaxMessageChars = 2_000;
private const string SourceScript = "script";
private const string SourceRuntime = "runtime";
private readonly ILogger _logger;
private readonly Action<AuditEvent>? _emit;
private readonly int _maxLines;
private readonly string _methodName;
private readonly string? _actor;
private readonly Guid? _executionId;
private readonly string? _sourceNode;
private readonly object _gate = new();
private int _emitted;
private int _dropped;
private int _seq;
/// <summary>
/// Initializes the per-execution logger.
/// </summary>
/// <param name="logger">The node logger every line is forwarded to, traced or not.</param>
/// <param name="emit">
/// Receives each built <c>ScriptLog</c> row (the executor fans it out to the Live trace sink and
/// the central audit writer). <c>null</c> for an untraced execution — no rows are built at all.
/// </param>
/// <param name="maxLines">Row cap for this run (<c>ScadaBridge:InboundApi:MaxLogLinesPerRun</c>).</param>
/// <param name="methodName">The inbound method being executed; the row's <c>Target</c>.</param>
/// <param name="executionId">The inbound request's execution id, tying the lines to its other rows.</param>
/// <param name="sourceNode">Emitting central node (<c>central-a</c>/<c>central-b</c>); null = unstamped.</param>
public InboundScriptLogger(
ILogger logger,
Action<AuditEvent>? emit,
int maxLines,
string methodName,
Guid? executionId,
string? sourceNode = null)
{
_logger = logger;
_emit = emit;
// A non-positive cap is rejected by the options validator; clamp defensively so a
// hand-constructed logger can never silently audit nothing.
_maxLines = maxLines > 0 ? maxLines : InboundApiOptions.DefaultMaxLogLinesPerRun;
_methodName = methodName;
_actor = string.IsNullOrEmpty(methodName) ? null : "api:" + methodName;
_executionId = executionId;
_sourceNode = sourceNode;
}
/// <summary>Writes a debug-level line.</summary>
/// <param name="message">The message; truncated to <see cref="MaxMessageChars"/>.</param>
/// <param name="exception">Optional exception whose message/stack ride the row.</param>
public void Debug(string message, Exception? exception = null) =>
Write(LogLevel.Debug, "debug", message, exception);
/// <summary>Writes an information-level line.</summary>
/// <param name="message">The message; truncated to <see cref="MaxMessageChars"/>.</param>
/// <param name="exception">Optional exception whose message/stack ride the row.</param>
public void Info(string message, Exception? exception = null) =>
Write(LogLevel.Information, "info", message, exception);
/// <summary>Writes a warning-level line.</summary>
/// <param name="message">The message; truncated to <see cref="MaxMessageChars"/>.</param>
/// <param name="exception">Optional exception whose message/stack ride the row.</param>
public void Warn(string message, Exception? exception = null) =>
Write(LogLevel.Warning, "warn", message, exception);
/// <summary>Writes an error-level line. Does NOT fail the script — it is a message, not a throw.</summary>
/// <param name="message">The message; truncated to <see cref="MaxMessageChars"/>.</param>
/// <param name="exception">Optional exception whose message/stack ride the row.</param>
public void Error(string message, Exception? exception = null) =>
Write(LogLevel.Error, "error", message, exception);
/// <summary>
/// Emits the single closing <c>warn</c>/<c>runtime</c> row naming how many lines the per-run
/// cap dropped, then resets the count — so it is idempotent and a second call after a flushed
/// run emits nothing. No-op when nothing was dropped or the run is untraced. Called by
/// <see cref="InboundScriptExecutor"/> once the script body has finished, on every path.
/// </summary>
public void FlushDropped()
{
if (_emit is null) return;
int dropped;
int seq;
lock (_gate)
{
if (_dropped == 0) return;
dropped = _dropped;
_dropped = 0;
seq = ++_seq;
}
Emit("warn", SourceRuntime, seq,
$"log line cap reached - {dropped} further log line(s) were not captured " +
$"(ScadaBridge:InboundApi:MaxLogLinesPerRun = {_maxLines})",
exception: null);
}
private void Write(LogLevel level, string levelName, string message, Exception? exception)
{
var text = Truncate(message);
try
{
_logger.Log(level, exception,
"Script log [{LogLevel}] for inbound method {Method} (execution {ExecutionId}): {Message}",
levelName, _methodName, _executionId, text);
}
catch (Exception)
{
// A debug observer must never fail the request: a logging sink that throws is swallowed.
}
if (_emit is null) return; // untraced run: the ILogger write above is the whole cost (D5)
int seq;
lock (_gate)
{
if (_emitted >= _maxLines)
{
// Past the cap the LINE is still logged above; only the audit row is dropped,
// and FlushDropped reports the total at the end of the run.
_dropped++;
return;
}
_emitted++;
seq = ++_seq;
}
Emit(levelName, SourceScript, seq, text, exception);
}
private void Emit(string level, string source, int seq, string message, Exception? exception)
{
AuditEvent evt;
try
{
evt = ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace,
AuditKind.ScriptLog,
AuditStatus.Delivered,
actor: _actor,
target: _methodName,
sourceNode: _sourceNode,
executionId: _executionId,
// An inbound request is a root: its lines have no spawning execution, and
// StepEventId stays null so the graph builder attaches them to the execution's
// anchor step (design D2).
parentExecutionId: null,
errorMessage: exception?.Message,
errorDetail: exception?.ToString(),
requestSummary: message,
extra: new ScriptTraceExtra { LogLevel = level, LogSource = source, Seq = seq }.ToJson());
}
catch (Exception)
{
// A debug observer must never fail the request: an unserializable row is dropped.
return;
}
try
{
_emit!(evt);
}
catch (Exception)
{
// A debug observer must never fail the request: a misbehaving sink/writer is swallowed.
}
}
/// <summary>
/// Applies the per-message cap, marking a truncated message with a trailing ellipsis.
///
/// <para>Final review M2 — mirrors the site runtime's <c>ScriptLogger.Clip</c>: cuts one char
/// short when the cap would land between the two halves of a surrogate pair, so an emoji
/// straddling the boundary is dropped whole rather than leaving a lone surrogate that breaks
/// JSON serialization downstream.</para>
/// </summary>
private static string Truncate(string? message)
{
if (string.IsNullOrEmpty(message)) return string.Empty;
if (message.Length <= MaxMessageChars) return message;
var keep = char.IsHighSurrogate(message[MaxMessageChars - 1]) ? MaxMessageChars - 1 : MaxMessageChars;
return string.Concat(message.AsSpan(0, keep), "…");
}
}
@@ -79,6 +79,16 @@ public sealed class AuditWriteMiddleware
/// </summary>
public const string AuditActorItemKey = "ZB.MOM.WW.ScadaBridge.InboundAPI.AuditActor";
/// <summary>
/// <see cref="HttpContext.Items"/> key used by the endpoint handler to publish the
/// method script's exception detail when the script threw (Flow Debugger step logs,
/// design 2026-09-09 D3). The executor deliberately sanitises the caller-facing
/// message, so this slot is the only place the real cause survives; it is consumed
/// once here, onto the inbound audit row's <c>ErrorDetail</c>, and never written to
/// the HTTP response. An exception that ESCAPED the pipeline takes precedence.
/// </summary>
public const string ErrorDetailItemKey = "ScadaBridge.Audit.ErrorDetail";
/// <summary>
/// ParentExecutionId: <see cref="HttpContext.Items"/> key under
/// which this middleware stashes the inbound request's per-request
@@ -318,6 +328,17 @@ public sealed class AuditWriteMiddleware
headerDict[header.Key] = value;
}
// Flow Debugger step logs (design 2026-09-09 D3): an exception that ESCAPED the
// pipeline is the authoritative failure, so it wins; otherwise the endpoint may
// have stashed the method script's exception (which the executor keeps out of the
// HTTP body). Absent both, the row carries no detail.
var errorDetail = thrown?.ToString();
if (errorDetail is null
&& ctx.Items.TryGetValue(ErrorDetailItemKey, out var stashedErrorDetail))
{
errorDetail = stashedErrorDetail as string;
}
var extra = JsonSerializer.Serialize(new
{
remoteIp = ctx.Connection.RemoteIpAddress?.ToString(),
@@ -347,6 +368,7 @@ public sealed class AuditWriteMiddleware
httpStatus: statusCode,
durationMs: (int)Math.Min(durationMs, int.MaxValue),
errorMessage: thrown?.Message,
errorDetail: errorDetail,
requestSummary: requestBody,
responseSummary: responseBody,
payloadTruncated: payloadTruncated,
@@ -82,6 +82,26 @@ public sealed class FlowDebugRunner
/// <summary>Quiet period after the last new row before a value-change run is declared complete.</summary>
public static readonly TimeSpan SettleWindow = TimeSpan.FromSeconds(2);
/// <summary>
/// Row cap asked of the site on each <see cref="IPullTraceEventsClient.PullAsync"/> poll — the
/// site's own ceiling (<c>SiteStreamGrpcServer.MaxTraceEventsPerPull</c> = 2000; a larger
/// <c>max_events</c> is rejected <c>InvalidArgument</c>), so this asks for everything the site
/// will serve.
///
/// <para>
/// Final review I1: this was 500, which was ample before step logs but is not any more. The
/// site query is <c>ORDER BY OccurredAtUtc ASC LIMIT</c>, so the page is the OLDEST rows in
/// the window — and a single traced run now emits up to 201 <c>ScriptLog</c> rows (design
/// §3, <c>MaxLogLinesPerRun</c> + the closing dropped-count row). Two chatty runs could
/// therefore fill a 500-row page and push a later spawned run's <c>ScriptRun</c> row past the
/// end of it, making <see cref="WriteAndPollAsync"/> report a false
/// <see cref="NoTriggerCode"/> for a run that did happen. 2000 makes that ~10x harder; the
/// real fix (paging by last-seen timestamp, or excluding <c>ScriptLog</c> from the awaited-run
/// poll) is deferred-work register row 117.
/// </para>
/// </summary>
public const int PullTraceEventsMax = 2000;
private readonly ILogger<FlowDebugRunner> _logger;
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
private readonly Func<DateTimeOffset> _now;
@@ -152,7 +172,9 @@ public sealed class FlowDebugRunner
// only known once the script runs. So the operator's scope is pushed into the
// RouteHelper and enforced at route time, before any site traffic.
var scope = await ResolvePermittedSiteIdentifiersAsync(sp, user, ct).ConfigureAwait(false);
var route = sp.GetRequiredService<RouteHelper>().WithTraceSink(collector.Add).WithSiteScope(scope);
var route = sp.GetRequiredService<RouteHelper>().WithTraceSink(evt => collector.Add(evt, lowPriority: FlowStepProjector.IsLogLine(evt))).WithSiteScope(scope);
// Step-logs D7: a ScriptLog row rides the collector's low-priority lane so the step rows
// emitted at run end are never crowded out of the inline copy (Replay stays complete).
var executor = sp.GetRequiredService<InboundScriptExecutor>();
InboundScriptResult result;
@@ -186,6 +208,7 @@ public sealed class FlowDebugRunner
executionId: root,
durationMs: (int)Math.Min(sw.ElapsedMilliseconds, int.MaxValue),
errorMessage: result.ErrorMessage,
errorDetail: result.ErrorDetail,
requestSummary: requestJson,
responseSummary: result.ResultJson,
extra: new ScriptTraceExtra { Source = SourceTag }.ToJson());
@@ -210,7 +233,7 @@ public sealed class FlowDebugRunner
return new FlowDebugRunResult(
root, result.Success, result.ErrorMessage, result.Success ? null : result.ErrorCode,
ordered, Truncated: collector.Truncated, startedAt, finishedAt);
ordered, Truncated: collector.Truncated, startedAt, finishedAt, LogsTruncated: collector.LogsTruncated);
}
/// <summary>
@@ -358,7 +381,7 @@ public sealed class FlowDebugRunner
{
try
{
collector.Add(e);
collector.Add(e, lowPriority: FlowStepProjector.IsLogLine(e));
}
catch (Exception ex)
{
@@ -395,7 +418,8 @@ public sealed class FlowDebugRunner
return new FlowDebugRunResult(
root, response.Success, response.ErrorMessage,
response.Success ? null : faultCode ?? CallFailedCode,
ordered, Truncated: collector.Truncated, startedAt, DateTimeOffset.UtcNow);
ordered, Truncated: collector.Truncated, startedAt, DateTimeOffset.UtcNow,
LogsTruncated: collector.LogsTruncated);
}
/// <summary>
@@ -483,7 +507,7 @@ public sealed class FlowDebugRunner
var d = AuditDetailsCodec.Deserialize(e.DetailsJson);
if (d.ExecutionId is { } x) ids.Add(x);
}
var pulled = await puller.PullAsync(site.SiteIdentifier, ids.Take(64).ToList(), since, 500, ct).ConfigureAwait(false);
var pulled = await puller.PullAsync(site.SiteIdentifier, ids.Take(64).ToList(), since, PullTraceEventsMax, ct).ConfigureAwait(false);
var added = false;
foreach (var e in pulled)
{
@@ -67,6 +67,9 @@ public sealed class ScriptCompileSurface
/// <summary>Mirrors <c>ScriptGlobals.Alarms</c>.</summary>
public CompileAlarmsAccessor Alarms => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>ScriptGlobals.Log</c>.</summary>
public CompileLog Log => throw new NotSupportedException(CompileOnly);
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext</c> (the <c>Instance</c> global).</summary>
public sealed class CompileInstance
{
@@ -104,6 +107,38 @@ public sealed class ScriptCompileSurface
/// <summary>Mirrors <c>ScriptRuntimeContext.Alarms</c>.</summary>
public CompileAlarmsAccessor Alarms => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>ScriptRuntimeContext.Log</c>.</summary>
public CompileLog Log => throw new NotSupportedException(CompileOnly);
}
/// <summary>
/// Compile-only mirror of <c>ScriptLogger</c> (step logs design 2026-09-09 D4) — the
/// <c>Log</c> global a script writes leveled lines through. The optional
/// <c>exception</c> parameter is mirrored so <c>Log.Warn("…", ex)</c> binds at design
/// time exactly as it does at the site.
/// </summary>
public sealed class CompileLog
{
/// <summary>Mirrors <c>ScriptLogger.Debug</c>.</summary>
/// <param name="message">The message text.</param>
/// <param name="exception">An optional exception to attach to the line.</param>
public void Debug(string message, Exception? exception = null) => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>ScriptLogger.Info</c>.</summary>
/// <param name="message">The message text.</param>
/// <param name="exception">An optional exception to attach to the line.</param>
public void Info(string message, Exception? exception = null) => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>ScriptLogger.Warn</c>.</summary>
/// <param name="message">The message text.</param>
/// <param name="exception">An optional exception to attach to the line.</param>
public void Warn(string message, Exception? exception = null) => throw new NotSupportedException(CompileOnly);
/// <summary>Mirrors <c>ScriptLogger.Error</c>.</summary>
/// <param name="message">The message text.</param>
/// <param name="exception">An optional exception to attach to the line.</param>
public void Error(string message, Exception? exception = null) => throw new NotSupportedException(CompileOnly);
}
/// <summary>
@@ -344,6 +344,9 @@ public class ScriptGlobals
/// </summary>
public AlarmsAccessor Alarms => Instance.Alarms;
/// <summary>Leveled per-run logging (design 2026-09-09). Usage: <c>Log.Info("…")</c>, <c>Log.Warn("…", ex)</c>. Always reaches the node log; becomes Flow Debugger trace rows only for a traced run.</summary>
public ScriptLogger Log => Instance.Log;
/// <summary>
/// Parent composition (null when this script is on a root-level template).
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
@@ -0,0 +1,196 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// The row-emitting callback a <see cref="ScriptLogger"/> uses: level name, source, message,
/// optional exception, owning step id (design 2026-09-09 D1/D2).
/// </summary>
/// <param name="level">Level name as it appears on the wire: <c>debug|info|warn|error</c>.</param>
/// <param name="source">Line origin: <c>script</c> (the author's own <c>Log.*</c> call) or <c>runtime</c>.</param>
/// <param name="message">The already-clipped message text.</param>
/// <param name="exception">The exception attached to the line, if any.</param>
/// <param name="stepEventId">The step row the line belongs to, or <see langword="null"/> to fall back to the run's anchor row.</param>
public delegate void ScriptLogEmitter(string level, string source, string message, Exception? exception, Guid? stepEventId);
/// <summary>
/// Flow Debugger step logs (design 2026-09-09 D4/D5/D7): the <c>Log</c> global site scripts call.
///
/// <para>Every line ALWAYS forwards to the node's <see cref="ILogger"/> — an untraced run is a
/// normal logging surface and pays nothing else. A <c>ScriptLog</c> audit row is emitted only
/// when the run is traced, i.e. only when the owning <c>ScriptRuntimeContext</c> supplied an
/// emitter; untraced runs construct the logger with a <see langword="null"/> emitter and the row
/// path is a single null check.</para>
///
/// <para><b>Bounded (D7).</b> At most <c>maxLines</c> rows per run — one <see cref="ScriptLogger"/>
/// instance is one run's counter, and inline shared scripts share the context, hence the counter —
/// plus a hard <see cref="MaxMessageChars"/> cap per message. Lines past the cap are counted and
/// dropped; <see cref="FlushDropped"/> emits the single closing warning that names the count.</para>
///
/// <para><b>Never throws into the script.</b> A logging or emitting failure is swallowed: this is a
/// debug observer, and a broken observer must not fail a production script run.</para>
/// </summary>
public sealed class ScriptLogger
{
/// <summary>Per-message character cap (design D7); longer messages are truncated and suffixed with an ellipsis.</summary>
public const int MaxMessageChars = 2_000;
private readonly ILogger _logger;
private readonly ScriptLogEmitter? _emit;
private readonly int _maxLines;
private readonly string _instance;
private readonly string _script;
private readonly Guid? _executionId;
private int _emitted;
private int _dropped;
/// <summary>Creates the <c>Log</c> global for one script run.</summary>
/// <param name="logger">The node logger every line forwards to, traced or not.</param>
/// <param name="emit">Row emitter; <see langword="null"/> when the run is not traced (no rows are produced).</param>
/// <param name="maxLines">Per-run row cap (design D7); must be greater than zero.</param>
/// <param name="instance">Instance unique name, for the structured log entry.</param>
/// <param name="script">Script name, for the structured log entry.</param>
/// <param name="executionId">The run's execution id, for the structured log entry.</param>
/// <exception cref="ArgumentNullException"><paramref name="logger"/> is null.</exception>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="maxLines"/> is not greater than zero.</exception>
public ScriptLogger(ILogger logger, ScriptLogEmitter? emit, int maxLines, string instance, string script, Guid? executionId)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentOutOfRangeException.ThrowIfLessThan(maxLines, 1);
_logger = logger;
_emit = emit;
_maxLines = maxLines;
_instance = instance;
_script = script;
_executionId = executionId;
}
/// <summary>Logs a diagnostic line at Debug level.</summary>
/// <param name="message">The message text (clipped at <see cref="MaxMessageChars"/>).</param>
/// <param name="exception">An optional exception to attach to the line.</param>
public void Debug(string message, Exception? exception = null) => Write(LogLevel.Debug, "debug", message, exception);
/// <summary>Logs an informational line.</summary>
/// <param name="message">The message text (clipped at <see cref="MaxMessageChars"/>).</param>
/// <param name="exception">An optional exception to attach to the line.</param>
public void Info(string message, Exception? exception = null) => Write(LogLevel.Information, "info", message, exception);
/// <summary>Logs a warning line.</summary>
/// <param name="message">The message text (clipped at <see cref="MaxMessageChars"/>).</param>
/// <param name="exception">An optional exception to attach to the line.</param>
public void Warn(string message, Exception? exception = null) => Write(LogLevel.Warning, "warn", message, exception);
/// <summary>Logs an error line. Does not fail the run — the script decides what to do next.</summary>
/// <param name="message">The message text (clipped at <see cref="MaxMessageChars"/>).</param>
/// <param name="exception">An optional exception to attach to the line.</param>
public void Error(string message, Exception? exception = null) => Write(LogLevel.Error, "error", message, exception);
/// <summary>
/// Runtime-sourced line (fed by <see cref="TraceCapturingLogger"/>); same caps, source
/// <c>runtime</c>; does NOT forward to the <see cref="ILogger"/> — the caller already logged it,
/// and re-logging would loop back through the decorator.
/// </summary>
/// <param name="level">Level name (<c>info|warn|error</c>).</param>
/// <param name="message">The already-formatted message text.</param>
/// <param name="exception">The exception attached to the entry, if any.</param>
internal void Runtime(string level, string message, Exception? exception) => WriteRow(level, "runtime", message, exception);
/// <summary>Forwards to the node logger, then records the trace row (when traced).</summary>
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
{
// Deliberate swallow: a failing logger sink must never propagate into the script body.
}
WriteRow(name, "script", message, exception);
}
/// <summary>Emits one trace row, subject to the per-run line cap.</summary>
private void WriteRow(string level, string source, string message, Exception? exception)
{
if (_emit is null)
{
return; // Untraced run: no rows, no counting.
}
if (Interlocked.Increment(ref _emitted) > _maxLines)
{
Interlocked.Increment(ref _dropped);
return;
}
try
{
_emit(level, source, Clip(message), exception, TraceStepScope.Current);
}
catch
{
// Deliberate swallow: the trace collector/audit writer is a debug observer, never a run failure.
}
}
/// <summary>
/// Run end: emits one closing warning naming the dropped count (nothing when none were
/// dropped). Bypasses the counter on purpose — it is the one row that must always land.
/// Idempotent: the count is cleared as it is reported, so a second call emits nothing.
/// </summary>
public void FlushDropped()
{
if (_emit is null)
{
return;
}
var n = Interlocked.Exchange(ref _dropped, 0);
if (n == 0)
{
return;
}
try
{
// Final review M3: worded identically to the central InboundScriptLogger's closing row,
// so the two surfaces read the same in the step-log panel (only the option key differs).
_emit("warn", "runtime",
$"log line cap reached - {n} further log line(s) were not captured " +
$"(ScadaBridge:SiteRuntime:MaxLogLinesPerRun = {_maxLines})", null, null);
}
catch
{
// Deliberate swallow: same reason as WriteRow — the observer must not fail the run.
}
}
/// <summary>
/// Applies the per-message cap, marking a truncated message with a trailing ellipsis.
///
/// <para>Cuts one char short when the cap would land between the two halves of a surrogate
/// pair, so an emoji straddling the boundary is dropped whole rather than leaving a lone
/// surrogate that breaks JSON serialization downstream. Re-clipping an already-clipped message
/// is a no-op, which matters because the script path clips once before logging and again
/// before emitting.</para>
/// </summary>
private static string Clip(string? m)
{
if (m is null)
{
return string.Empty;
}
if (m.Length <= MaxMessageChars)
{
return m;
}
var keep = char.IsHighSurrogate(m[MaxMessageChars - 1]) ? MaxMessageChars - 1 : MaxMessageChars;
return m[..keep] + "…";
}
}
@@ -400,7 +400,12 @@ internal static class ScriptRunLauncher
scriptTimeoutToken: cts.Token,
// Flow Debugger (design §5.2/§5.4): the trace flag and the inline sink.
isTraced: trace,
traceCollector: collector);
traceCollector: collector,
// Step logs (design 2026-09-09 D7): the per-run ScriptLog row cap. Read off
// effectiveOptions (the IOptionsMonitor snapshot taken at the top of this run),
// so an operator's live edit applies from the NEXT run, like the event-policy
// flags above.
maxLogLinesPerRun: effectiveOptions.MaxLogLinesPerRun);
var globals = new ScriptGlobals
{
@@ -426,13 +431,18 @@ internal static class ScriptRunLauncher
var state = await compiledScript.RunAsync(globals, cts.Token);
// Step logs (design D7): close the run's log BEFORE the anchor row, so the closing
// "log line cap reached - N further log line(s)…" row precedes the ScriptRun in
// emission order on every path.
context.Log.FlushDropped();
// Flow Debugger: the ScriptRun row goes into the collector BEFORE the reply so the
// inline list the caller receives includes the run itself. Complete() (not Snapshot)
// closes the sink at the same instant — outbound rows finishing after this point
// (plan 2026-09-06 D5) go to the audit writer only.
EmitScriptRun(trace, collector, auditWriter, logger, scriptName, instanceName, siteId, sourceNode,
context.ExecutionId, parentExecutionId, startedUtc, runStopwatch.ElapsedMilliseconds,
parameters, state.ReturnValue, error: null, triggerExtra);
parameters, state.ReturnValue, error: null, errorDetail: null, triggerExtra);
// Send result to requester if this was an Ask-based call
if (!replyTo.IsNobody())
@@ -467,9 +477,14 @@ internal static class ScriptRunLauncher
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg);
// Step logs (design D7): see the success path — the closing row comes first.
context?.Log.FlushDropped();
// A timeout carries NO errorDetail (design D3): the OperationCanceledException's stack
// is the launcher's cancellation plumbing, not the script's, so it would mislead.
EmitScriptRun(trace, collector, auditWriter, logger, scriptName, instanceName, siteId, sourceNode,
context?.ExecutionId, parentExecutionId, startedUtc, runStopwatch.ElapsedMilliseconds,
parameters, null, errorMsg, triggerExtra);
parameters, null, errorMsg, errorDetail: null, triggerExtra);
if (!replyTo.IsNobody())
{
@@ -492,9 +507,14 @@ internal static class ScriptRunLauncher
_ = siteEventLogger?.LogEventAsync(
"script", "Error", instanceName, $"ScriptActor:{scriptName}", errorMsg, ex.ToString());
// Step logs (design D7): see the success path — the closing row comes first.
context?.Log.FlushDropped();
// Design D3: the failing run's full exception — type, message and stack — so the Flow
// Debugger's red node can show the stack, not just the one-line message.
EmitScriptRun(trace, collector, auditWriter, logger, scriptName, instanceName, siteId, sourceNode,
context?.ExecutionId, parentExecutionId, startedUtc, runStopwatch.ElapsedMilliseconds,
parameters, null, errorMsg, triggerExtra);
parameters, null, errorMsg, errorDetail: ex.ToString(), triggerExtra);
if (!replyTo.IsNobody())
{
@@ -551,7 +571,7 @@ internal static class ScriptRunLauncher
EmitScriptRun(
trace: true, collector: null, writer, logger, scriptName, instanceName, siteId, sourceNode,
executionId: Guid.NewGuid(), parentExecutionId, DateTime.UtcNow, elapsedMs: 0,
parameters: null, returnValue: null, error: reason, triggerExtra);
parameters: null, returnValue: null, error: reason, errorDetail: null, triggerExtra);
}
catch (Exception ex)
{
@@ -564,13 +584,19 @@ internal static class ScriptRunLauncher
/// traced run — collector first (so the inline reply carries it), then the best-effort
/// audit writer. <c>OccurredAtUtc</c> is the run's START so the row sorts before the
/// writes/waits the run made. No-op when untraced; never throws.
///
/// <para>Step logs (design 2026-09-09 D3): <paramref name="errorDetail"/> carries the failing
/// run's full <c>Exception.ToString()</c> — type, message and stack — so the Flow Debugger can
/// show the stack behind a red node instead of only the one-line message. A TIMED-OUT run
/// passes null on purpose: its <c>OperationCanceledException</c> stack is the launcher's own
/// cancellation plumbing, not the script's, and would mislead.</para>
/// </summary>
private static void EmitScriptRun(
bool trace, FlowTraceCollector? collector, IAuditWriter? auditWriter, ILogger logger,
string scriptName, string instanceName, string siteId, string? sourceNode,
Guid? executionId, Guid? parentExecutionId, DateTime startedUtc, long elapsedMs,
IReadOnlyDictionary<string, object?>? parameters, object? returnValue, string? error,
ScriptTraceExtra? triggerExtra)
string? errorDetail, ScriptTraceExtra? triggerExtra)
{
if (!trace) return;
try
@@ -586,6 +612,7 @@ internal static class ScriptRunLauncher
sourceInstanceId: instanceName, sourceScript: sourceScript,
durationMs: (int)Math.Min(elapsedMs, int.MaxValue),
errorMessage: error,
errorDetail: errorDetail,
requestSummary: TraceJson(parameters),
responseSummary: TraceJson(returnValue),
extra: (triggerExtra ?? new ScriptTraceExtra { TriggerKind = "call" }).ToJson());
@@ -679,6 +706,12 @@ internal static class ScriptRunLauncher
timeout,
instanceName,
logger,
// Final review M6: name the source so ScriptRuntimeContext's `_logTarget` derives
// the alarm this on-trigger run belongs to ("AlarmActor:Foo" -> "Foo") — without
// it every Log.* line from an alarm script was attributed to the INSTANCE, not the
// alarm. `scriptName` above is already the "AlarmActor:{alarmName}" source key, so
// it is passed as-is (re-prefixing it would leave "AlarmActor:" in the target).
sourceScript: scriptName,
// ParentExecutionId tag-cascade: the
// alarm on-trigger run mints its own fresh ExecutionId (the
// ctor's `?? NewGuid()` fallback) and records the firing
@@ -45,7 +45,24 @@ public class ScriptRuntimeContext
private readonly int _currentCallDepth;
private readonly int _maxCallDepth;
private readonly TimeSpan _askTimeout;
private readonly ILogger _logger;
/// <summary>
/// The logger every runtime diagnostic in this context goes through. NOT readonly because a
/// TRACED run swaps in a <see cref="TraceCapturingLogger"/> in the constructor (step-logs
/// design 2026-09-09 D6), so the warnings the runtime logs on the script's behalf become
/// <c>ScriptLog</c> rows attributed to the step that was running. An untraced run keeps the
/// injected logger untouched — no decorator, no allocation.
/// </summary>
private ILogger _logger;
/// <summary>
/// The UNDECORATED injected logger. Every diagnostic emitted from inside the log path itself
/// (<see cref="EmitLog"/>) must use this one: routing it through <see cref="_logger"/> would be
/// captured by the decorator and re-enter <see cref="EmitLog"/> in a loop. Also the logger a
/// child (inline shared-script) context is built with, since
/// <see cref="TraceCapturingLogger"/> refuses to wrap itself.
/// </summary>
private readonly ILogger _innerLogger;
private readonly string _instanceName;
/// <summary>
@@ -189,6 +206,48 @@ public class ScriptRuntimeContext
/// </summary>
private readonly Action<AuditEvent>? _traceSink;
/// <summary>
/// Step logs (design 2026-09-09 D7): the per-run <c>ScriptLog</c> row cap this context's
/// <see cref="Log"/> enforces, carried over verbatim into an inline shared-script child.
/// </summary>
private readonly int _maxLogLinesPerRun;
/// <summary>
/// Step logs (design D4): the bare script name a <c>ScriptLog</c> row targets — the SAME value
/// the run's <c>ScriptRun</c> anchor row uses, so the Flow Debugger's anchor fallback lines up.
/// Derived from <c>_sourceScript</c> by dropping its <c>ScriptActor:</c> / <c>AlarmActor:</c>
/// prefix; the instance name when no script owns the context.
/// </summary>
private readonly string _logTarget;
/// <summary>
/// Step logs (design D4): per-run emission counter stamped on every <c>ScriptLog</c> row as
/// <c>ScriptTraceExtra.Seq</c>, so lines produced inside the same millisecond still sort.
/// </summary>
private int _logSeq;
/// <summary>
/// Step logs (design 2026-09-09 D4/D5): the <c>Log</c> global site scripts call —
/// <c>Log.Debug/Info/Warn/Error(message, exception?)</c>.
///
/// <para>Every line ALWAYS reaches the node's <see cref="ILogger"/>, traced or not. A
/// <c>ScriptLog</c> trace row is additionally produced only when this run is traced: an
/// untraced context builds the logger with a null emitter, so the row path is one null check
/// and the untraced run is byte-identical to the pre-feature behaviour.</para>
///
/// <para>Bounded per run (D7) and never throws into the script — see
/// <see cref="ScriptLogger"/>.</para>
///
/// <para><b>An inline shared script SHARES its caller's logger</b> (design §5): a context built
/// by <see cref="CreateChildContextForSharedScript"/> is handed this same instance rather than
/// making its own, so the caller and every shared script it calls share ONE per-run line cap and
/// ONE closing "dropped lines" row. The consequence to know: a shared script's lines carry the
/// PARENT run's <c>ExecutionId</c> and therefore land on the parent's <c>ScriptRun</c> node in
/// the Flow Debugger — not on the shared script's own execution node, even though its
/// <c>ApiCall</c>/<c>DbWrite</c>/step rows still chain under their own execution.</para>
/// </summary>
public ScriptLogger Log { get; }
/// <summary>
/// Initializes a new script runtime context with all dependencies for script
/// execution, external system calls, database access, and notification delivery.
@@ -240,6 +299,17 @@ public class ScriptRuntimeContext
/// </param>
/// <param name="isTraced">Flow Debugger trace flag — see design 2026-09-05 §5.2. Defaults to untraced.</param>
/// <param name="traceCollector">Flow Debugger per-run inline trace sink (design §5.4); null when untraced.</param>
/// <param name="maxLogLinesPerRun">
/// Step logs (design 2026-09-09 D7): the per-run <c>ScriptLog</c> row cap for <see cref="Log"/>
/// — mirrors <c>SiteRuntimeOptions.MaxLogLinesPerRun</c>, which the script run launcher passes
/// in. Values below 1 are clamped to 1: a mis-set option must not make every script run throw.
/// </param>
/// <param name="sharedLog">
/// Step logs (design §5): an existing <see cref="ScriptLogger"/> to adopt as <see cref="Log"/>
/// instead of creating one — supplied only by
/// <see cref="CreateChildContextForSharedScript"/>, so an inline shared script shares its
/// caller's line cap and anchor. Null (the default) creates this run's own logger.
/// </param>
public ScriptRuntimeContext(
IActorRef instanceActor,
SharedScriptLibrary sharedScriptLibrary,
@@ -263,7 +333,9 @@ public class ScriptRuntimeContext
ISiteEventLogger? siteEventLogger = null,
CancellationToken scriptTimeoutToken = default,
bool isTraced = false,
FlowTraceCollector? traceCollector = null)
FlowTraceCollector? traceCollector = null,
int maxLogLinesPerRun = 200,
ScriptLogger? sharedLog = null)
{
_instanceActor = instanceActor;
_sharedScriptLibrary = sharedScriptLibrary;
@@ -271,6 +343,7 @@ public class ScriptRuntimeContext
_maxCallDepth = maxCallDepth;
_askTimeout = askTimeout;
_instanceName = instanceName;
_innerLogger = logger;
_logger = logger;
_externalSystemClient = externalSystemClient;
_databaseGateway = databaseGateway;
@@ -298,6 +371,28 @@ public class ScriptRuntimeContext
_isTraced = isTraced;
_traceCollector = traceCollector;
_traceSink = traceCollector is null ? null : Collect;
// Step logs (design 2026-09-09 D4-D6). ORDER MATTERS: the Log global is built over the
// UNDECORATED logger first, and only then — when traced — does the context's own logger
// become the capturing decorator over that same sink. Building them the other way round
// would hand ScriptLogger the decorator (every Log.* line logged, captured and re-emitted
// as a second row) and TraceCapturingLogger refuses to wrap itself for exactly that reason.
_maxLogLinesPerRun = Math.Max(1, maxLogLinesPerRun);
_logTarget = string.IsNullOrEmpty(_sourceScript)
// No single script owns the context — the instance is the closest honest target.
? _instanceName
// "ScriptActor:Foo" / "AlarmActor:Foo" -> "Foo"; a bare name is unchanged (IndexOf -1).
: _sourceScript[(_sourceScript.IndexOf(':') + 1)..];
// An inline shared script adopts its caller's logger (design §5) — one counter, one anchor,
// one closing row for the whole inline chain. Everyone else builds their own.
Log = sharedLog ?? new ScriptLogger(
logger, isTraced ? EmitLog : null, _maxLogLinesPerRun, _instanceName, _logTarget, _executionId);
if (isTraced)
{
// The child's OWN undecorated logger, decorated over the SHARED sink: the capturing
// decorator is per-context (it must not be handed on), the sink behind it is not.
_logger = new TraceCapturingLogger(logger, Log);
}
}
/// <summary>
@@ -378,7 +473,9 @@ public class ScriptRuntimeContext
_maxCallDepth,
_askTimeout,
_instanceName,
_logger,
// Step logs (D6): the UNDECORATED logger — the child installs its own capturing
// decorator when traced, and TraceCapturingLogger refuses to wrap another one.
_innerLogger,
_externalSystemClient,
_databaseGateway,
_storeAndForward,
@@ -401,7 +498,12 @@ public class ScriptRuntimeContext
// Flow Debugger (design §5.2): a traced root traces its whole tree — the inline
// shared script shares the same flag AND the same inline sink.
isTraced: _isTraced,
traceCollector: _traceCollector);
traceCollector: _traceCollector,
maxLogLinesPerRun: _maxLogLinesPerRun,
// Step logs (design §5): the inline shared script is its own execution node for step
// rows, but it SHARES this run's Log — one line cap and one anchor for the whole
// inline chain, so its lines carry this run's ExecutionId.
sharedLog: Log);
}
/// <summary>
@@ -411,9 +513,22 @@ public class ScriptRuntimeContext
/// throws into the script, and hands raw payloads to the writer whose redactor applies
/// the configured size cap (§9) exactly as the other channels do.
/// </summary>
/// <param name="kind">The trace row's kind.</param>
/// <param name="status">The row's lifecycle status.</param>
/// <param name="target">The row's target (attribute name, script name, …).</param>
/// <param name="startedUtc">When the traced action started — the row's <c>OccurredAtUtc</c>.</param>
/// <param name="durationMs">How long the action took.</param>
/// <param name="input">Request summary.</param>
/// <param name="output">Response summary.</param>
/// <param name="error">Error message on a failed row.</param>
/// <param name="extra">Kind-specific extra detail.</param>
/// <param name="eventId">
/// Step logs (design 2026-09-09 D6): the step's PRE-MINTED <c>EventId</c>, so the log lines the
/// runtime produced inside the step's <see cref="TraceStepScope"/> can name it. Null mints one.
/// </param>
private void EmitTrace(
AuditKind kind, AuditStatus status, string target, DateTime startedUtc, int durationMs,
string? input, string? output, string? error, ScriptTraceExtra? extra)
string? input, string? output, string? error, ScriptTraceExtra? extra, Guid? eventId = null)
{
if (!_isTraced) return;
AuditEvent evt;
@@ -421,6 +536,7 @@ public class ScriptRuntimeContext
{
evt = ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, kind, status,
eventId: eventId,
occurredAtUtc: DateTime.SpecifyKind(startedUtc, DateTimeKind.Utc),
actor: _sourceScript, target: target, sourceNode: _sourceNode,
executionId: _executionId, parentExecutionId: _parentExecutionId,
@@ -456,6 +572,79 @@ public class ScriptRuntimeContext
}
}
/// <summary>
/// Step logs (design 2026-09-09 D4/D7): emits one <c>ScriptTrace</c>/<c>ScriptLog</c> row for a
/// single log line — the collector's LOW-PRIORITY lane first (log lines must never crowd out
/// the step rows a run emits at its end), then the best-effort <see cref="IAuditWriter"/>.
///
/// <para>This is the emitter handed to <see cref="Log"/>, and it is wired only when the run is
/// traced. Every diagnostic it logs itself goes to <see cref="_innerLogger"/>, NEVER to
/// <see cref="_logger"/>: on a traced run the latter is the capturing decorator, which would
/// feed the warning straight back into this method.</para>
/// </summary>
/// <param name="level">Level name as it appears on the wire: <c>debug|info|warn|error</c>.</param>
/// <param name="source">Line origin: <c>script</c> (a <c>Log.*</c> call) or <c>runtime</c>.</param>
/// <param name="message">The already-clipped message text.</param>
/// <param name="exception">The exception attached to the line, if any.</param>
/// <param name="stepEventId">The owning step's <c>EventId</c>; null falls back to the run's anchor row (D2).</param>
private void EmitLog(string level, string source, string message, Exception? exception, Guid? stepEventId)
{
if (!_isTraced) return;
AuditEvent evt;
try
{
evt = ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
occurredAtUtc: DateTime.UtcNow,
actor: _sourceScript, target: _logTarget, sourceNode: _sourceNode,
executionId: _executionId, parentExecutionId: _parentExecutionId,
sourceSiteId: string.IsNullOrEmpty(_siteId) ? null : _siteId,
sourceInstanceId: _instanceName, sourceScript: _sourceScript,
errorMessage: exception?.Message, errorDetail: exception?.ToString(),
requestSummary: message,
extra: new ScriptTraceExtra
{
LogLevel = level,
LogSource = source,
StepEventId = stepEventId,
Seq = Interlocked.Increment(ref _logSeq),
}.ToJson());
}
catch (Exception ex)
{
_innerLogger.LogWarning(ex, "Flow trace log-line build failed at {Level}", level);
return;
}
try
{
// Low-priority lane (D7): dropped lines set LogsTruncated, not Truncated.
_traceCollector?.Add(evt, lowPriority: true);
}
catch (Exception ex)
{
_innerLogger.LogWarning(ex, "Flow trace collector rejected log line ({EventId})", evt.EventId);
}
if (_auditWriter is null) return;
try
{
var t = _auditWriter.WriteAsync(evt, CancellationToken.None);
if (!t.IsCompletedSuccessfully)
{
_ = t.ContinueWith(
x => _innerLogger.LogWarning(x.Exception, "Flow trace log-line write failed ({EventId})", evt.EventId),
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
}
catch (Exception ex)
{
_innerLogger.LogWarning(ex, "Flow trace log-line write failed ({EventId})", evt.EventId);
}
}
/// <summary>Serialises a small trace payload; null on failure (best-effort, like the audit helpers).</summary>
private static string? TraceJson(object? value)
{
@@ -618,6 +807,11 @@ public class ScriptRuntimeContext
var startedUtc = DateTime.UtcNow;
var sw = _isTraced ? Stopwatch.StartNew() : null;
// Step logs (design D6): pre-mint this step's row id and make it the ambient step for the
// body, so a warning the runtime logs while the wait is in flight names THIS row. Nothing
// is minted and no scope is opened on an untraced run.
Guid? stepId = _isTraced ? Guid.NewGuid() : null;
using var step = stepId is { } waitStepId ? TraceStepScope.Enter(waitStepId) : null;
WaitForAttributeResponse response;
try
{
@@ -648,7 +842,8 @@ public class ScriptRuntimeContext
{
WaitAttribute = name, WaitTarget = targetValueEncoded ?? (predicate is null ? null : "<predicate>"),
WaitTimeoutMs = (int)timeout.TotalMilliseconds, WaitOutcome = "cancelled",
});
},
eventId: stepId);
}
throw;
}
@@ -670,7 +865,8 @@ public class ScriptRuntimeContext
WaitTimeoutMs = (int)timeout.TotalMilliseconds,
WaitOutcome = outcome,
ObservedValue = observed,
});
},
eventId: stepId);
}
return response;
@@ -706,6 +902,10 @@ public class ScriptRuntimeContext
var startedUtc = DateTime.UtcNow;
var sw = _isTraced ? Stopwatch.StartNew() : null;
// Step logs (design D6): one pre-minted row id shared by BOTH emit points below, opened as
// the ambient step so runtime warnings logged during the write are attributed to it.
Guid? stepId = _isTraced ? Guid.NewGuid() : null;
using var step = stepId is { } writeStepId ? TraceStepScope.Enter(writeStepId) : null;
SetStaticAttributeResponse response;
try
{
@@ -717,7 +917,8 @@ public class ScriptRuntimeContext
{
EmitTrace(AuditKind.AttributeWrite, AuditStatus.Failed, attributeName, startedUtc,
(int)sw.ElapsedMilliseconds, TraceJson(new { attribute = attributeName, value }), null, ex.Message,
new ScriptTraceExtra { Writes = [new AttributeWriteDetail(attributeName, value, false, ex.Message)] });
new ScriptTraceExtra { Writes = [new AttributeWriteDetail(attributeName, value, false, ex.Message)] },
eventId: stepId);
throw;
}
@@ -732,7 +933,8 @@ public class ScriptRuntimeContext
extra: new ScriptTraceExtra
{
Writes = [new AttributeWriteDetail(attributeName, value, response.Success, response.ErrorMessage)],
});
},
eventId: stepId);
}
if (!response.Success)
@@ -772,6 +974,10 @@ public class ScriptRuntimeContext
// than an AskTimeoutException here. Honors the script execution-timeout token.
var startedUtc = DateTime.UtcNow;
var sw = _isTraced ? Stopwatch.StartNew() : null;
// Step logs (design D6): the batch's own step. The trailing WaitAttribute opens its own
// nested scope and restores this one on the way out, so each leg owns its lines.
Guid? stepId = _isTraced ? Guid.NewGuid() : null;
using var step = stepId is { } batchStepId ? TraceStepScope.Enter(batchStepId) : null;
var batchResp = await _instanceActor.Ask<WriteAttributeBatchResponse>(
batchReq, TimeSpan.FromSeconds(35), _scriptTimeoutToken);
@@ -789,7 +995,8 @@ public class ScriptRuntimeContext
input: TraceJson(new { values = encodedValues, trigger = triggerAttr, triggerValue = triggerEncoded }),
output: null,
error: batchResp.ErrorMessage,
extra: new ScriptTraceExtra { TriggerAttribute = triggerAttr, TriggerValue = triggerEncoded, Writes = writes });
extra: new ScriptTraceExtra { TriggerAttribute = triggerAttr, TriggerValue = triggerEncoded, Writes = writes },
eventId: stepId);
}
if (!batchResp.Success)
@@ -873,7 +1080,9 @@ public class ScriptRuntimeContext
// it onto SiteCallOperational.SourceNode.
_sourceNode,
// Flow Debugger: the outbound rows ride the inline collector (null when untraced).
traceSink: _traceSink);
traceSink: _traceSink,
// Step logs (D6): the run's trace flag — the authority for the sync Call's step scope.
isTraced: _isTraced);
/// <summary>
/// Provides access to database operations.
@@ -1131,6 +1340,15 @@ public class ScriptRuntimeContext
/// </summary>
private readonly Action<AuditEvent>? _traceSink;
/// <summary>
/// Step logs (design 2026-09-09 D6): whether the owning run is traced. The authority for
/// opening a <see cref="TraceStepScope"/> around <see cref="Call"/>. Deliberately NOT
/// <see cref="_traceSink"/>: the sink is null whenever the run has no inline collector,
/// which is a different question from "is this run traced" — a traced run without a
/// collector still writes durable <c>ScriptLog</c> rows that must name their step.
/// </summary>
private readonly bool _isTraced;
/// <summary>
/// Initializes a new external system helper for script API calls.
/// </summary>
@@ -1145,6 +1363,7 @@ public class ScriptRuntimeContext
/// <param name="parentExecutionId">Optional identifier of the parent execution (for routed calls).</param>
/// <param name="sourceNode">Optional cluster node identifier (node-a/node-b) for audit stamping.</param>
/// <param name="traceSink">Flow Debugger inline collector — receives the same <see cref="AuditEvent"/> each emit hands to the writer/forwarder; null when untraced.</param>
/// <param name="isTraced">Step logs (design D6): true when the owning run is traced, so <see cref="Call"/> pre-mints its row id and publishes it as the ambient step. Defaults to false for direct (test) construction.</param>
// Internal constructor for tests living in ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests
// (via InternalsVisibleTo). Production sites resolve the helper through
// ScriptRuntimeContext.ExternalSystem.
@@ -1168,7 +1387,8 @@ public class ScriptRuntimeContext
ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? parentExecutionId = null,
string? sourceNode = null,
Action<AuditEvent>? traceSink = null)
Action<AuditEvent>? traceSink = null,
bool isTraced = false)
{
_client = client;
_instanceName = instanceName;
@@ -1181,6 +1401,7 @@ public class ScriptRuntimeContext
_parentExecutionId = parentExecutionId;
_sourceNode = sourceNode;
_traceSink = GuardTraceSink(traceSink, logger);
_isTraced = isTraced;
}
/// <summary>
@@ -1207,6 +1428,13 @@ public class ScriptRuntimeContext
// audit emission is best-effort.
var occurredAtUtc = DateTime.UtcNow;
var startTicks = Stopwatch.GetTimestamp();
// Step logs (design D6): pre-mint this call's audit row id and make it the ambient step
// so the helper's own warnings — and anything the client logs through this context —
// are attributed to the API-call node. Gated on the run's trace flag, NOT on the
// presence of an inline collector: a traced run without one still writes durable
// ScriptLog rows that need the step id. Untraced mints nothing and opens no scope.
Guid? stepId = _isTraced ? Guid.NewGuid() : null;
using var step = stepId is { } callStepId ? TraceStepScope.Enter(callStepId) : null;
ExternalCallResult? result = null;
Exception? thrown = null;
try
@@ -1223,7 +1451,7 @@ public class ScriptRuntimeContext
{
var elapsedMs = (int)((Stopwatch.GetTimestamp() - startTicks)
* 1000d / Stopwatch.Frequency);
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown, parameters);
EmitCallAudit(systemName, methodName, occurredAtUtc, elapsedMs, result, thrown, parameters, stepId);
}
}
@@ -1610,6 +1838,18 @@ public class ScriptRuntimeContext
/// row. Any exception thrown by the writer is logged and swallowed —
/// audit-write failures must never abort the user-facing action.
/// </summary>
/// <param name="systemName">External system name.</param>
/// <param name="methodName">Method invoked on it.</param>
/// <param name="occurredAtUtc">When the call started.</param>
/// <param name="durationMs">How long it took.</param>
/// <param name="result">The call result, when one was produced.</param>
/// <param name="thrown">The exception the call threw, if any.</param>
/// <param name="parameters">The call arguments.</param>
/// <param name="eventId">
/// Step logs (design 2026-09-09 D6): the PRE-MINTED row id <see cref="Call"/> also opened as
/// the ambient <see cref="TraceStepScope"/>, so the log lines produced during the call name
/// this row. Null (untraced) mints a fresh id as before.
/// </param>
private void EmitCallAudit(
string systemName,
string methodName,
@@ -1617,7 +1857,8 @@ public class ScriptRuntimeContext
int durationMs,
ExternalCallResult? result,
Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
IReadOnlyDictionary<string, object?>? parameters,
Guid? eventId = null)
{
if (_auditWriter == null)
{
@@ -1628,7 +1869,7 @@ public class ScriptRuntimeContext
try
{
evt = BuildCallAuditEvent(
systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters);
systemName, methodName, occurredAtUtc, durationMs, result, thrown, parameters, eventId);
}
catch (Exception buildEx)
{
@@ -1687,7 +1928,8 @@ public class ScriptRuntimeContext
int durationMs,
ExternalCallResult? result,
Exception? thrown,
IReadOnlyDictionary<string, object?>? parameters)
IReadOnlyDictionary<string, object?>? parameters,
Guid? eventId = null)
{
// Status: Delivered on a Success result; Failed otherwise (the
// ExternalSystemClient already maps HTTP non-2xx + transient
@@ -1728,6 +1970,8 @@ public class ScriptRuntimeContext
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: status,
// Step logs (D6): the id the caller already published as the ambient step.
eventId: eventId,
occurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
// Outbound channel: per the Audit Log Actor-column spec the actor
// is the calling script. Null when no single script owns the call
@@ -0,0 +1,95 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// Flow Debugger step logs (design 2026-09-09 D6): decorates the script context's
/// <see cref="ILogger"/> while a run is traced.
///
/// <para>Forwards EVERYTHING to the inner logger unchanged (the node's log file is unaffected by
/// tracing), and additionally copies <see cref="LogLevel.Information"/> and above into
/// <c>ScriptLog</c> rows with source <c>runtime</c>, attributed to the ambient
/// <see cref="TraceStepScope"/>. This is how the warnings the runtime logs on the script's behalf —
/// inside <c>SetAttribute</c>, <c>WriteBatchAndWait</c>, <c>WaitInternal</c>, the sync
/// <c>ExternalSystem.Call</c> — reach the Flow Debugger without editing each call site.</para>
///
/// <para>Debug and Trace are deliberately NOT captured (internal chatter, design D6), and the
/// per-run line cap of the sink applies to what is.</para>
///
/// <para>Installed only when the run is traced, so an untraced run keeps the bare inner logger.</para>
/// </summary>
public sealed class TraceCapturingLogger : ILogger
{
private readonly ILogger _inner;
private readonly ScriptLogger _sink;
/// <summary>Wraps <paramref name="inner"/> so that Information and above also reach <paramref name="sink"/>.</summary>
/// <param name="inner">The node logger every entry is forwarded to.</param>
/// <param name="sink">The run's <see cref="ScriptLogger"/>, which owns the caps and the row emitter.</param>
/// <exception cref="ArgumentNullException"><paramref name="inner"/> or <paramref name="sink"/> is null.</exception>
/// <exception cref="ArgumentException"><paramref name="inner"/> is itself a <see cref="TraceCapturingLogger"/>.</exception>
public TraceCapturingLogger(ILogger inner, ScriptLogger sink)
{
ArgumentNullException.ThrowIfNull(inner);
ArgumentNullException.ThrowIfNull(sink);
if (inner is TraceCapturingLogger)
{
throw new ArgumentException("inner is already a TraceCapturingLogger — wrapping twice would emit two rows per script line", nameof(inner));
}
_inner = inner;
_sink = sink;
}
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => _inner.BeginScope(state);
/// <summary>
/// Information and above are always enabled — they are trace data even when the node's minimum
/// level is Warning. Below Information the inner logger decides.
/// </summary>
/// <param name="level">The level being tested.</param>
/// <returns><see langword="true"/> when the entry should be produced.</returns>
public bool IsEnabled(LogLevel level) => level >= LogLevel.Information || _inner.IsEnabled(level);
/// <inheritdoc />
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
// Forward first, and only if the inner logger actually wants it: IsEnabled above lies
// upward for trace capture, so the node's own minimum level must be re-checked here.
if (_inner.IsEnabled(level))
{
_inner.Log(level, eventId, state, exception, formatter);
}
if (level < LogLevel.Information || level == LogLevel.None)
{
return;
}
var name = level switch
{
LogLevel.Information => "info",
LogLevel.Warning => "warn",
_ => "error",
};
string message;
try
{
message = formatter(state, exception);
}
catch
{
// Deliberate swallow: formatting is the CALLER's code, and a great many of the entries
// captured here are the runtime's own best-effort "audit write failed" warnings logged
// from inside a catch block. A throwing formatter escaping this decorator would replace
// the original failure with a formatting exception on a path whose whole contract is
// that it never throws. The line is dropped; the inner logger already saw (or refused)
// it above, unchanged.
return;
}
_sink.Runtime(name, message, exception);
}
}
@@ -0,0 +1,44 @@
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// Flow Debugger step logs (design 2026-09-09 D6): the ambient "which step is running right now"
/// marker used to attribute runtime-sourced log lines to a step node.
///
/// <para>A <c>ScriptLog</c> row names its owning step explicitly (D2), but the runtime lines the
/// node logs on the script's behalf are produced deep inside a helper — the helper knows its
/// pre-minted step <c>EventId</c>, the logging call site does not. Rather than thread that id
/// through every internal call, the helpers open a scope around their body and
/// <see cref="ScriptLogger"/> reads <see cref="Current"/> when no explicit id is supplied.</para>
///
/// <para><see cref="AsyncLocal{T}"/> so the value flows through the helper's own awaits and
/// nowhere else: it is captured by continuations started inside the scope and is invisible to
/// concurrent runs on other threads of the script execution pool. Lines logged outside any scope
/// see <see langword="null"/> and fall back to the execution's anchor row (D2).</para>
/// </summary>
public static class TraceStepScope
{
private static readonly AsyncLocal<Guid?> _current = new();
/// <summary>The step <c>EventId</c> owning the currently executing helper body, or <see langword="null"/> outside any scope.</summary>
public static Guid? Current => _current.Value;
/// <summary>
/// Sets the current step for the scope's lifetime; restores the previous value on dispose,
/// so nested scopes (a helper calling another helper) unwind correctly.
/// </summary>
/// <param name="stepEventId">The pre-minted <c>EventId</c> of the step row this body belongs to.</param>
/// <returns>A handle that restores the previous ambient value when disposed.</returns>
public static IDisposable Enter(Guid stepEventId)
{
var prev = _current.Value;
_current.Value = stepEventId;
return new Restore(prev);
}
/// <summary>Restores the ambient step id captured at <see cref="Enter"/> time.</summary>
private sealed class Restore(Guid? prev) : IDisposable
{
/// <inheritdoc />
public void Dispose() => _current.Value = prev;
}
}
@@ -243,4 +243,18 @@ public class SiteRuntimeOptions
/// Default: 30.
/// </summary>
public int TraceArmWindowSeconds { get; set; } = 30;
/// <summary>
/// Flow Debugger step logs (design 2026-09-09 D7): the maximum number of <c>ScriptLog</c>
/// trace rows ONE script run may produce — the author's own <c>Log.*</c> lines plus the
/// runtime lines captured while the run is traced. Lines past the cap are counted and
/// dropped, and the run emits one closing <c>warn</c> row naming the dropped count, so a
/// chatty script degrades to a truncated log instead of an unbounded row stream.
///
/// <para>The cap is per RUN, not per script: an inline shared script shares its caller's
/// logger and therefore its counter. It only bounds trace rows — every line still reaches
/// the node's <c>ILogger</c>, traced or not — and an untraced run produces no rows at all.
/// Must be greater than 0. Default: 200.</para>
/// </summary>
public int MaxLogLinesPerRun { get; set; } = 200;
}
@@ -113,5 +113,11 @@ public sealed class SiteRuntimeOptionsValidator : OptionsValidatorBase<SiteRunti
$"ScadaBridge:SiteRuntime:TraceArmWindowSeconds must be greater than 0 " +
$"(was {options.TraceArmWindowSeconds}); it is how long the Instance Actor waits for the " +
"device echo of a Flow Debugger traced write before the arm expires (design 2026-09-05 §5.3).");
builder.RequireThat(options.MaxLogLinesPerRun > 0,
$"ScadaBridge:SiteRuntime:MaxLogLinesPerRun must be greater than 0 " +
$"(was {options.MaxLogLinesPerRun}); it is the per-run cap on Flow Debugger ScriptLog " +
"rows a script's Log.* calls may produce (design 2026-09-09 D7), and a run that may " +
"emit no lines at all cannot report its own truncation.");
}
}
@@ -1,6 +1,7 @@
using System.CommandLine;
using System.Text.Json;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
@@ -14,6 +15,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// the command tree shape, <c>--params</c> parsing, and the table projection that turns a
/// <see cref="FlowDebugRunResult"/> into one row per step.
/// </summary>
[Collection("Console")]
public class FlowDebugCommandsTests
{
private static readonly Option<string> Url = new("--url") { Recursive = true };
@@ -157,7 +159,8 @@ public class FlowDebugCommandsTests
/// and canonical <see cref="AuditEvent"/> types with the server's camelCase policy, so
/// a rename on either fails this test instead of silently blanking a column.
/// </summary>
private static string RealPayload(bool completed, string? errorCode, string? error, bool truncated)
private static string RealPayload(
bool completed, string? errorCode, string? error, bool truncated, bool logsTruncated = false)
{
var root = Guid.NewGuid();
var child = Guid.NewGuid();
@@ -178,7 +181,7 @@ public class FlowDebugCommandsTests
};
var result = new FlowDebugRunResult(root, completed, error, errorCode, events, truncated,
new DateTimeOffset(t0), new DateTimeOffset(t0.AddSeconds(1)));
new DateTimeOffset(t0), new DateTimeOffset(t0.AddSeconds(1)), logsTruncated);
return JsonSerializer.Serialize(result, CamelCase);
}
@@ -239,6 +242,33 @@ public class FlowDebugCommandsTests
Assert.Contains("audit tree", last.GetProperty("note").GetString());
}
[Fact]
public void Projection_LogsTruncatedRun_NotesThatReplayPullsTheRest()
{
// Final review M8: `logsTruncated` is a SEPARATE signal from `truncated` (the inline
// collector sheds low-priority ScriptLog rows before it sheds step rows). The page shows
// it; the CLI used to drop it silently.
var projected = FlowDebugTableProjection.ProjectEvents(
RealPayload(completed: true, null, null, truncated: false, logsTruncated: true));
using var doc = JsonDocument.Parse(projected);
var rows = doc.RootElement.EnumerateArray().ToList();
Assert.Equal(5, rows.Count); // the four steps plus the note
Assert.Equal("inline log lines capped — replay pulls the rest", rows[^1].GetProperty("note").GetString());
}
[Fact]
public void Projection_BothTruncationFlags_AreReportedTogether()
{
var projected = FlowDebugTableProjection.ProjectEvents(
RealPayload(completed: true, null, null, truncated: true, logsTruncated: true));
using var doc = JsonDocument.Parse(projected);
var note = doc.RootElement.EnumerateArray().Last().GetProperty("note").GetString();
Assert.Contains("event list truncated", note);
Assert.Contains("inline log lines capped — replay pulls the rest", note);
}
[Fact]
public void Projection_LeavesNonMatchingBodiesUntouched()
{
@@ -302,4 +332,134 @@ public class FlowDebugCommandsTests
Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("lifecycle").ValueKind);
Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("correlation").ValueKind);
}
/// <summary>Builds a run whose script step carries two <c>ScriptLog</c> rows and fails with an exception detail (step-logs plan 2026-09-09, Task 14).</summary>
private static string StepLogsPayload(out DateTime log1Time, out DateTime log2Time)
{
var root = Guid.NewGuid();
var child = Guid.NewGuid();
var t0 = new DateTime(2026, 9, 9, 10, 0, 0, 0, DateTimeKind.Utc);
log1Time = t0.AddMilliseconds(30);
log2Time = t0.AddMilliseconds(31);
var events = new List<AuditEvent>
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: t0, target: "DelmiaSim01.ProcessRecipeDownload", executionId: root, durationMs: 100,
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Failed,
occurredAtUtc: t0.AddMilliseconds(10), target: "ProcessRecipeDownload", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "DelmiaSim01", sourceScript: "ProcessRecipeDownload", durationMs: 50,
errorMessage: "boom", errorDetail: "System.InvalidOperationException: boom\n at X\n at Y\n at Z"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
occurredAtUtc: log1Time, target: "ProcessRecipeDownload", executionId: child,
requestSummary: "hello", extra: new ScriptTraceExtra { LogLevel = "warn", LogSource = "script", Seq = 1 }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
occurredAtUtc: log2Time, target: "ProcessRecipeDownload", executionId: child,
requestSummary: "world", extra: new ScriptTraceExtra { LogLevel = "warn", LogSource = "script", Seq = 2 }.ToJson()),
};
var result = new FlowDebugRunResult(root, true, null, null, events, false, new DateTimeOffset(t0), new DateTimeOffset(t0.AddSeconds(1)));
return JsonSerializer.Serialize(result, CamelCase);
}
[Fact]
public void Projection_LogsColumn_CountsAttachedLogLines()
{
var projected = FlowDebugTableProjection.ProjectEvents(StepLogsPayload(out _, out _));
using var doc = JsonDocument.Parse(projected);
var rows = doc.RootElement.EnumerateArray().ToList();
Assert.Equal(2, rows.Count); // RouteCall + ScriptRun — no note rows without --show-logs
Assert.Equal(0, rows[0].GetProperty("logs").GetInt32());
Assert.Equal(2, rows[1].GetProperty("logs").GetInt32());
Assert.All(rows, r => Assert.False(r.TryGetProperty("note", out _)));
}
[Fact]
public void Projection_ShowLogsTrue_PrintsIndentedLogLinesAndExceptionDetail_UnderTheStepRow()
{
var json = StepLogsPayload(out var log1Time, out var log2Time);
var projected = FlowDebugTableProjection.ProjectEvents(json, showLogs: true);
using var doc = JsonDocument.Parse(projected);
var rows = doc.RootElement.EnumerateArray().ToList();
// RouteCall, ScriptRun, 2 log lines, 3 exception-detail lines (capped at 3 of the 4 available).
Assert.Equal(7, rows.Count);
var expectedLog1 = $" {log1Time:HH:mm:ss.fff} WARN script hello";
var expectedLog2 = $" {log2Time:HH:mm:ss.fff} WARN script world";
Assert.Equal(expectedLog1, rows[2].GetProperty("note").GetString());
Assert.Equal(expectedLog2, rows[3].GetProperty("note").GetString());
Assert.Equal(" exception: System.InvalidOperationException: boom", rows[4].GetProperty("note").GetString());
Assert.Equal(" " + " at X", rows[5].GetProperty("note").GetString());
Assert.Equal(" " + " at Y", rows[6].GetProperty("note").GetString());
Assert.DoesNotContain("at Z", projected);
}
[Fact]
public void Projection_ShowLogsFalse_OmitsLogAndExceptionDetailLines()
{
var projected = FlowDebugTableProjection.ProjectEvents(StepLogsPayload(out _, out _), showLogs: false);
Assert.DoesNotContain("WARN", projected);
Assert.DoesNotContain("exception:", projected);
}
/// <summary>
/// Drives the real table renderer (<see cref="CommandHelpers.HandleResponse"/>'s table
/// branch, which is what every <c>flow-debug</c> command actually prints to the terminal)
/// rather than only inspecting the intermediate JSON — a code review of Task 14 found the
/// generic column-union renderer padded the log/exception <c>note</c> rows into a far-right
/// "note" column instead of printing them as raw indented lines under their step, which the
/// JSON-level assertions above could not have caught (Task 14 review, 2026-09-09).
/// </summary>
[Fact]
public void HandleResponse_TableFormat_ShowLogsTrue_PrintsLogLineRawUnderScriptRunRow_NoNoteHeader()
{
var json = StepLogsPayload(out var log1Time, out var log2Time);
var projected = FlowDebugTableProjection.ProjectEvents(json, showLogs: true);
var response = new ManagementResponse(200, projected, null, null);
var writer = new StringWriter();
Console.SetOut(writer);
try
{
var exitCode = CommandHelpers.HandleResponse(response, "table");
Assert.Equal(0, exitCode);
}
finally
{
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
}
var lines = writer.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries);
// The header row must not gain a "note" column — a raw log/exception line has nowhere
// sensible to sit in a column table, so it must never become one.
Assert.DoesNotContain("note", lines[0]);
var scriptRunLineIndex = Array.FindIndex(lines, l => l.Contains("ScriptRun"));
Assert.True(scriptRunLineIndex >= 0, "expected a ScriptRun row in the rendered table");
var expectedLog1 = $" {log1Time:HH:mm:ss.fff} WARN script hello";
var expectedLog2 = $" {log2Time:HH:mm:ss.fff} WARN script world";
// The two log lines print as raw, unpadded lines immediately under the ScriptRun row —
// not aligned into any table column.
var log1Line = lines[scriptRunLineIndex + 1];
Assert.StartsWith(" ", log1Line);
Assert.Contains("WARN", log1Line);
Assert.Equal(expectedLog1, log1Line);
Assert.Equal(expectedLog2, lines[scriptRunLineIndex + 2]);
// The exception detail follows, also raw and capped at 3 of the 4 available lines.
Assert.Equal(" exception: System.InvalidOperationException: boom", lines[scriptRunLineIndex + 3]);
Assert.Equal(" at X", lines[scriptRunLineIndex + 4]);
Assert.Equal(" at Y", lines[scriptRunLineIndex + 5]);
Assert.DoesNotContain("at Z", writer.ToString());
}
}
@@ -903,4 +903,259 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal(string.Empty, cut.Find("[data-test='flow-instance-input']").GetAttribute("value"));
Assert.Equal(["— choose —"], cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList());
}
// ---- Step logs (plan 2026-09-09 D5/D7, Task 13): status-strip counts, truncation notes, cross-step hint ----
/// <summary>One <c>ScriptTrace.ScriptLog</c> row — a line the builder folds onto a step, never a node of its own.</summary>
private static AuditEvent LogLine(Guid execution, string level, int ms, Guid? stepEventId = null, string message = "hello") =>
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
occurredAtUtc: DateTime.UtcNow.AddMilliseconds(ms), actor: "Process", target: "Process", sourceNode: "node-a",
executionId: execution, sourceSiteId: "site-a", sourceInstanceId: "Sim01", requestSummary: message,
extra: new ScriptTraceExtra { LogLevel = level, LogSource = "script", StepEventId = stepEventId }.ToJson());
/// <summary>
/// The <see cref="GraphOutcome"/> flow (inbound → route → site run) whose site run carries the asked-for
/// log lines, optionally with <paramref name="unattached"/> lines belonging to an execution with no steps
/// (the builder drops and counts those) and the inline log-truncation flag set.
/// </summary>
private static FlowDebugOutcome LoggedOutcome(int errors = 0, int warnings = 0, int infos = 0,
bool logsTruncated = false, int unattached = 0)
{
var root = Guid.NewGuid();
var child = Guid.NewGuid();
var t0 = DateTime.UtcNow;
var events = new List<AuditEvent>
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100,
requestSummary: "{}", responseSummary: "{}"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90,
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
};
var ms = 3;
for (var i = 0; i < errors; i++) events.Add(LogLine(child, "error", ms++));
for (var i = 0; i < warnings; i++) events.Add(LogLine(child, "warn", ms++));
for (var i = 0; i < infos; i++) events.Add(LogLine(child, "info", ms++));
for (var i = 0; i < unattached; i++) events.Add(LogLine(Guid.NewGuid(), "info", ms++));
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1), logsTruncated);
return new FlowDebugOutcome(FlowGraphBuilder.Build(events, false, logsTruncated), raw, null, null, logsTruncated);
}
/// <summary>Picks the method, runs it against <paramref name="outcome"/> and waits for the status strip.</summary>
private IRenderedComponent<FlowDebuggerPage> RunWith(FlowDebugOutcome outcome)
{
_flow.RunMethodAsync(4, Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>()).Returns(outcome);
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("input[data-test='flow-input-MachineCode']");
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForElement("[data-test='flow-step-count']");
return cut;
}
[Fact]
public void StatusStrip_ShowsLogCountsByLevel()
{
RegisterServices();
var cut = RunWith(LoggedOutcome(errors: 1, warnings: 2, infos: 3));
Assert.Equal("1 errors · 2 warnings · 3 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
[Fact]
public void StatusStrip_OmitsLogCounts_WhenTheRunLoggedNothing()
{
RegisterServices();
var cut = RunWith(LoggedOutcome());
Assert.Empty(cut.FindAll("[data-test='flow-log-counts']"));
Assert.Empty(cut.FindAll("[data-test='flow-logs-truncated']"));
Assert.Empty(cut.FindAll("[data-test='flow-unattached-logs']"));
}
[Fact]
public void StatusStrip_ShowsTheInlineLogTruncationBadge()
{
// D7: the inline copy dropped low-priority rows; the audit store — and so Replay — is complete.
RegisterServices();
var cut = RunWith(LoggedOutcome(infos: 1, logsTruncated: true));
Assert.Contains("inline log lines capped", cut.Find("[data-test='flow-logs-truncated']").TextContent);
}
[Fact]
public void StatusStrip_NotesLinesThatCouldNotBePlacedOnAStep()
{
RegisterServices();
var cut = RunWith(LoggedOutcome(infos: 1, unattached: 2));
Assert.Contains("2 log line(s) could not be placed", cut.Find("[data-test='flow-unattached-logs']").TextContent);
// The dropped lines are not counted on the strip — it counts only what landed on a step.
Assert.Equal("0 errors · 0 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
[Fact]
public void StateChip_StaysCompleted_WhenTheOnlyErrorsAreLogLines()
{
// A Log.Error line is not a failed step: the run state is decided by step outcomes alone.
RegisterServices();
var cut = RunWith(LoggedOutcome(errors: 2));
Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim());
Assert.Equal("2 errors · 0 warnings · 0 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
[Fact]
public void StepDetail_LogHint_SelectsTheSiblingStepCarryingTheOtherLines()
{
RegisterServices();
var root = Guid.NewGuid();
var child = Guid.NewGuid();
var writeId = Guid.NewGuid();
var t0 = DateTime.UtcNow;
var events = new List<AuditEvent>
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100,
requestSummary: "{}", responseSummary: "{}"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90,
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
eventId: writeId, occurredAtUtc: t0.AddMilliseconds(3), target: "Sim01.Flag", executionId: child,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 2,
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("Sim01.Flag", "true", true, null)] }.ToJson()),
LogLine(child, "info", 4), // → the ScriptRun anchor
LogLine(child, "warn", 5, stepEventId: writeId), // → the write step
};
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1));
var cut = RunWith(new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null));
cut.Find("g[data-test='flow-node'][data-kind='ScriptRun']").Click();
cut.Find("[data-test='flow-step-tab-log']").Click();
var before = cut.Find("[data-test='flow-step-title']").TextContent.Trim();
var hint = cut.Find("[data-test='flow-step-log-hint'] button");
var otherTitle = hint.TextContent.Trim();
hint.Click();
// The page owns the selection: clicking the hint moves the detail panel AND the graph's
// highlight to the sibling step.
Assert.NotEqual(before, otherTitle);
Assert.Equal(otherTitle, cut.Find("[data-test='flow-step-title']").TextContent.Trim());
var selected = cut.Find("[data-test='flow-node'].selected");
Assert.Equal("e:" + writeId.ToString("N"), selected.GetAttribute("data-key"));
}
// ---- D7: the inline cap flag is only true while the page is still short of lines --------------
/// <summary>
/// A Live run that reported inline log truncation, plus the Replay a refresh reads back — sharing the
/// run's own event objects (stable ids, as real audit rows are) and, when asked, one more log row than
/// the run held.
/// </summary>
private static (FlowDebugOutcome Run, FlowDebugOutcome Refreshed, Guid Root) TruncatedThenRefreshed(bool refreshAddsALine)
{
var root = Guid.NewGuid();
var child = Guid.NewGuid();
var t0 = DateTime.UtcNow.AddSeconds(-5);
var events = new List<AuditEvent>
{
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: t0, target: "POST /api/DelmiaRecipeDownload", executionId: root, durationMs: 100,
requestSummary: "{}", responseSummary: "{}"),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(1), target: "Sim01.Process", executionId: root, durationMs: 90,
extra: new ScriptTraceExtra { ChildExecutionId = child }.ToJson()),
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: t0.AddMilliseconds(2), target: "Process", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
LogLine(child, "info", 3),
};
var raw = new FlowDebugRunResult(root, true, null, null, events, false, t0, t0.AddSeconds(1), true);
var run = new FlowDebugOutcome(FlowGraphBuilder.Build(events, false, true), raw, null, null, true);
// The audit store is never truncated, so the re-read's own flag is false whatever it holds.
var reread = new List<AuditEvent>(events);
if (refreshAddsALine)
reread.Add(LogLine(child, "warn", 4));
var rereadRaw = new FlowDebugRunResult(root, true, null, null, reread, false, t0, t0.AddSeconds(1));
var refreshed = new FlowDebugOutcome(FlowGraphBuilder.Build(reread), rereadRaw, null, null);
return (run, refreshed, root);
}
[Fact]
public void RefreshOutcomes_ClearsTheInlineCapBadge_OnceItMergesInMoreLogRows()
{
RegisterServices();
var (run, refreshed, root) = TruncatedThenRefreshed(refreshAddsALine: true);
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(refreshed);
var cut = RunWith(run);
cut.Instance.Delay = (_, _) => Task.CompletedTask;
Assert.NotNull(cut.Find("[data-test='flow-logs-truncated']"));
cut.Find("button[data-test='flow-refresh']").Click();
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='flow-logs-truncated']")));
Assert.Equal("0 errors · 1 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
[Fact]
public void RefreshOutcomes_KeepsTheInlineCapBadge_WhenTheReReadBringsBackNoNewLines()
{
// The site's rows reach central by telemetry seconds later — a refresh that adds nothing
// has not proven the page holds every line.
RegisterServices();
var (run, refreshed, root) = TruncatedThenRefreshed(refreshAddsALine: false);
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(refreshed);
var cut = RunWith(run);
cut.Instance.Delay = (_, _) => Task.CompletedTask;
cut.Find("button[data-test='flow-refresh']").Click();
cut.WaitForAssertion(() => _flow.Received(1).ReplayAsync(root, Arg.Any<CancellationToken>()));
Assert.NotNull(cut.Find("[data-test='flow-logs-truncated']"));
Assert.Equal("0 errors · 0 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
// ---- CountLogs: the level buckets, straight ---------------------------------------------------
[Fact]
public void CountLogs_BucketsByLevel_CaseInsensitively_WithEverythingElseAsInfo()
{
var execution = Guid.NewGuid();
var t0 = DateTime.UtcNow;
var graph = FlowGraphBuilder.Build([
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: t0, target: "Process", executionId: execution,
sourceSiteId: "site-a", sourceInstanceId: "Sim01", durationMs: 80),
LogLine(execution, "ERROR", 1),
LogLine(execution, "Warn", 2),
LogLine(execution, "info", 3),
LogLine(execution, "debug", 4),
LogLine(execution, "trace", 5), // an unknown level is still a line — it lands in info
]);
Assert.Equal((1, 1, 3, 5), FlowDebuggerPage.CountLogs(graph));
}
[Fact]
public void CountLogs_NullGraph_IsAllZeroes()
{
Assert.Equal((0, 0, 0, 0), FlowDebuggerPage.CountLogs(null));
}
}
@@ -8,11 +8,46 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// Flow Debugger outbound-calls plan (2026-09-06) Task 8: the step detail panel's Lifecycle tab,
/// outbound "Where this ran" fields and kind-specific Input tab labels. Follow-ups plan (2026-09-06)
/// Task 6: report deep links from a correlated outbound step and Retry/Discard on a parked one (D2/D5).
/// Step-logs plan (2026-09-09) Task 12: the Log tab — exception block, level-filtered lines and the
/// cross-step hint (design D8).
/// </summary>
public class FlowStepDetailTests : BunitContext
{
private static readonly DateTimeOffset T0 = new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero);
public FlowStepDetailTests()
{
// The Log tab's Copy buttons call navigator.clipboard through IJSRuntime.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private const string ExceptionDetail =
"System.InvalidOperationException: Recipe 42 rejected by Galaxy (code 7)\n" +
" at Submission#0.<<Initialize>>d__0.MoveNext() in ProcessRecipeDownload:line 41\n" +
" at ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptRunLauncher.RunScriptAsync()";
/// <summary>A site script-run step, optionally carrying folded log lines and a thrown exception's detail.</summary>
private static FlowStep ScriptStep(
FlowStepOutcome outcome,
string title,
Guid executionId,
IReadOnlyList<FlowLogLine>? logs = null,
string? errorDetail = null)
=> new(Guid.NewGuid(), executionId, null, FlowStepKind.ScriptRun, FlowLane.Site, title,
"DelmiaSim01.DelmiaReceiver \u00b7 call", T0, 1731, outcome, "{\"RecipeId\":42}", "{\"ok\":false}",
outcome == FlowStepOutcome.Failed ? "Recipe 42 rejected by Galaxy (code 7)" : null,
"site-a", "node-a", "DelmiaSim01", title, null, null, null, errorDetail, logs);
private static FlowLogLine Line(
string level,
string message,
string source = "script",
int seq = 0,
string? exceptionMessage = null,
string? exceptionDetail = null)
=> new(Guid.NewGuid(), null, T0.AddMilliseconds(seq), level, source, message,
exceptionMessage, exceptionDetail, null, seq);
private static FlowStep Step(FlowStepKind kind, FlowStepOutcome outcome, string target, IReadOnlyList<FlowLifecycleRow>? lifecycle = null, Guid? correlationId = null)
=> new(Guid.NewGuid(), Guid.NewGuid(), null, kind, FlowLane.External, target, "Delivered · cached", T0, 4100, outcome,
"{\"id\":1}", "{\"ok\":true}", outcome == FlowStepOutcome.Failed ? "boom" : null, "site-a", "node-a", "M1", target, null, lifecycle, correlationId);
@@ -182,4 +217,224 @@ public class FlowStepDetailTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=flow-step-retry]"));
Assert.Empty(cut.FindAll("[data-test=flow-step-discard]"));
}
[Fact]
public void NoLinesAndNoDetail_HidesTheLogTab()
{
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid())));
Assert.Empty(cut.FindAll("[data-test=flow-step-tab-log]"));
}
[Fact]
public void LogTab_CountsItsLines()
{
var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(),
[
Line("info", "Recipe 42 requested", seq: 1),
Line("debug", "Writing header attributes", seq: 2),
Line("warn", "ResultCode 7 is outside the accepted set", seq: 3),
]);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
Assert.Equal("Log (3)", cut.Find("[data-test=flow-step-tab-log]").TextContent.Trim());
}
[Fact]
public void FailedStepWithDetail_OpensOnLog_AndRendersTheExceptionBlock()
{
var step = ScriptStep(FlowStepOutcome.Failed, "ProcessRecipeDownload", Guid.NewGuid(),
[Line("error", "Rejecting recipe 42 (code 7)", seq: 1)], ExceptionDetail);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-log]").ClassName);
var exc = cut.Find("[data-test=flow-step-exception]");
Assert.Equal("System.InvalidOperationException", exc.QuerySelector(".fsd-exc-type")!.TextContent.Trim());
Assert.Equal("Recipe 42 rejected by Galaxy (code 7)", exc.QuerySelector(".fsd-exc-message")!.TextContent.Trim());
Assert.Contains("ScriptRunLauncher.RunScriptAsync", exc.QuerySelector("pre")!.TextContent);
Assert.NotNull(cut.Find("[data-test=flow-step-exception-copy]"));
}
[Fact]
public void WrappedException_TheBlockHeaderNamesTheINNERMOSTType_InFull()
{
// Final review M1: the panel used to parse the OUTERMOST exception of a " ---> " chain
// while the graph node's subtitle showed the innermost — the two now share
// ExceptionHeader, which always follows the chain to its end. The panel still shows the
// type with its namespace (only the node badge shortens it), and the raw detail is
// untouched, so the wrapper is still visible in the <pre>.
const string wrapped =
"System.AggregateException: One or more errors occurred. (Recipe 42 rejected by Galaxy (code 7)) " +
"---> ZB.Scada.RecipeRejectedException: Recipe 42 rejected by Galaxy (code 7)\n" +
" at Submission#0.<<Initialize>>d__0.MoveNext()";
var step = ScriptStep(FlowStepOutcome.Failed, "ProcessRecipeDownload", Guid.NewGuid(), [], wrapped);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
var exc = cut.Find("[data-test=flow-step-exception]");
Assert.Equal("ZB.Scada.RecipeRejectedException", exc.QuerySelector(".fsd-exc-type")!.TextContent.Trim());
Assert.Equal("Recipe 42 rejected by Galaxy (code 7)", exc.QuerySelector(".fsd-exc-message")!.TextContent.Trim());
Assert.Contains("System.AggregateException", exc.QuerySelector("pre")!.TextContent);
}
[Fact]
public void OkStepWithLines_OpensOnInput()
{
var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(), [Line("info", "hello", seq: 1)]);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-input]").ClassName);
Assert.DoesNotContain("active", cut.Find("[data-test=flow-step-tab-log]").ClassName);
Assert.Empty(cut.FindAll("[data-test=flow-step-exception]"));
}
[Fact]
public void WarnFilter_LeavesOnlyWarnAndErrorRows()
{
var step = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid(),
[
Line("info", "Recipe 42 requested", seq: 1),
Line("debug", "Writing header attributes", seq: 2),
Line("warn", "ResultCode 7 is outside the accepted set", seq: 3),
Line("error", "Rejecting recipe 42", seq: 4),
]);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
cut.Find("[data-test=flow-step-tab-log]").Click();
Assert.Equal(4, cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]").Count);
cut.Find("[data-test=flow-step-log-filter-warn]").Click();
var rows = cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]");
Assert.Equal(2, rows.Count);
Assert.Equal("warn", rows[0].GetAttribute("data-level"));
Assert.Equal("error", rows[1].GetAttribute("data-level"));
cut.Find("[data-test=flow-step-log-filter-all]").Click();
Assert.Equal(4, cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]").Count);
}
[Fact]
public void Hint_NamesAnotherStepOfTheSameExecution_AndSelectsIt()
{
var exec = Guid.NewGuid();
var selected = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", exec, [Line("info", "a", seq: 1)]);
var other = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId, RecipeName", exec, [Line("warn", "acked Uncertain", seq: 2)]);
var elsewhere = ScriptStep(FlowStepOutcome.Ok, "Another execution", Guid.NewGuid(), [Line("info", "c", seq: 3)]);
var graph = new FlowGraph([selected, other, elsewhere], [], false, false);
FlowStep? picked = null;
var cut = Render<FlowStepDetail>(p => p
.Add(x => x.Step, selected)
.Add(x => x.Graph, graph)
.Add(x => x.OnSelectStep, s => picked = s));
cut.Find("[data-test=flow-step-tab-log]").Click();
var hint = cut.Find("[data-test=flow-step-log-hint]");
Assert.Contains("1 more on", hint.TextContent);
Assert.Contains("Write RecipeId, RecipeName", hint.TextContent);
Assert.DoesNotContain("Another execution", hint.TextContent);
hint.QuerySelector("button")!.Click();
Assert.Same(other, picked);
}
[Fact]
public void Hint_WithoutASelectCallback_IsPlainText()
{
var exec = Guid.NewGuid();
var selected = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", exec, [Line("info", "a", seq: 1)]);
var other = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId, RecipeName", exec, [Line("warn", "b", seq: 2)]);
var graph = new FlowGraph([selected, other], [], false, false);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, selected).Add(x => x.Graph, graph));
cut.Find("[data-test=flow-step-tab-log]").Click();
var hint = cut.Find("[data-test=flow-step-log-hint]");
Assert.Contains("Write RecipeId, RecipeName", hint.TextContent);
Assert.Null(hint.QuerySelector("button"));
}
[Fact]
public void OutputAlert_SeeExceptionLink_SwitchesToTheLogTab()
{
var step = ScriptStep(FlowStepOutcome.Failed, "ProcessRecipeDownload", Guid.NewGuid(),
[Line("error", "Rejecting recipe 42", seq: 1)], ExceptionDetail);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
cut.Find("[data-test=flow-step-tab-output]").Click();
Assert.Contains("Recipe 42 rejected", cut.Find("[data-test=flow-step-error]").TextContent);
cut.Find("[data-test=flow-step-error-see-log]").Click();
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-log]").ClassName);
Assert.NotNull(cut.Find("[data-test=flow-step-exception]"));
}
[Fact]
public void RuntimeSourcedLine_ShowsItsSource_AndAnAttachedException()
{
var step = ScriptStep(FlowStepOutcome.Ok, "Write RecipeId", Guid.NewGuid(),
[
Line("warn", "acked with quality Uncertain", source: "runtime", seq: 1,
exceptionMessage: "Tag write was not confirmed", exceptionDetail: "System.TimeoutException: Tag write was not confirmed\n at Dcl"),
]);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, step));
cut.Find("[data-test=flow-step-tab-log]").Click();
var row = cut.Find("[data-test=flow-step-log-lines] tr[data-level=warn]");
Assert.Contains("runtime", row.TextContent);
Assert.Contains("Tag write was not confirmed", row.QuerySelector(".fsd-line-exc")!.TextContent);
Assert.Contains("System.TimeoutException", row.QuerySelector("details pre")!.TextContent);
}
[Fact]
public void SwitchingSteps_KeepsAStillValidTab()
{
var a = ScriptStep(FlowStepOutcome.Ok, "ProcessRecipeDownload", Guid.NewGuid());
var b = ScriptStep(FlowStepOutcome.Ok, "AckRecipe", Guid.NewGuid());
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, a));
cut.Find("[data-test=flow-step-tab-timing]").Click();
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-timing]").ClassName);
cut.Render(p => p.Add(x => x.Step, b));
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-timing]").ClassName);
Assert.DoesNotContain("active", cut.Find("[data-test=flow-step-tab-input]").ClassName);
}
[Fact]
public void RefreshingTheSameStep_KeepsTheTabAndTheLevelFilter()
{
// The page re-reads outcomes after a Live run and hands back a NEW FlowStep instance carrying
// the same EventId plus whatever rows have landed since — that must not disturb the panel.
var eventId = Guid.NewGuid();
var exec = Guid.NewGuid();
FlowStep Refreshed(IReadOnlyList<FlowLogLine> logs)
=> new(eventId, exec, null, FlowStepKind.ScriptRun, FlowLane.Site, "ProcessRecipeDownload",
"sub", T0, 1731, FlowStepOutcome.Failed, "{}", "{}", "Recipe 42 rejected by Galaxy (code 7)",
"site-a", "node-a", "DelmiaSim01", "ProcessRecipeDownload", null, null, null, ExceptionDetail, logs);
var first = Refreshed([Line("info", "Recipe 42 requested", seq: 1), Line("warn", "ResultCode 7", seq: 2)]);
var cut = Render<FlowStepDetail>(p => p.Add(x => x.Step, first));
Assert.Equal("Log (2)", cut.Find("[data-test=flow-step-tab-log]").TextContent.Trim());
cut.Find("[data-test=flow-step-log-filter-warn]").Click();
Assert.Single(cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]"));
cut.Find("[data-test=flow-step-tab-timing]").Click();
cut.Render(p => p.Add(x => x.Step, Refreshed(
[
Line("info", "Recipe 42 requested", seq: 1),
Line("warn", "ResultCode 7", seq: 2),
Line("error", "Rejecting recipe 42", seq: 3),
])));
Assert.Contains("active", cut.Find("[data-test=flow-step-tab-timing]").ClassName);
Assert.Equal("Log (3)", cut.Find("[data-test=flow-step-tab-log]").TextContent.Trim());
cut.Find("[data-test=flow-step-tab-log]").Click();
Assert.Contains("btn-secondary", cut.Find("[data-test=flow-step-log-filter-warn]").ClassName);
var rows = cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]");
Assert.Equal(2, rows.Count);
Assert.Equal("warn", rows[0].GetAttribute("data-level"));
Assert.Equal("error", rows[1].GetAttribute("data-level"));
}
}
@@ -574,6 +574,40 @@ public class ScriptAnalysisServiceTests
Assert.Contains("hello-sandbox", result.ConsoleOutput);
}
[Fact]
public async Task RunInSandbox_LogGlobal_WritesToConsoleOutput()
{
// Flow Debugger step logs (design 2026-09-09 D4): the sandbox `Log` mirror exists so
// a script that logs BINDS in the Test Run editor, and its lines land in the console
// pane (the sandbox has no audit trail — nothing here is written to AuditLog).
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest(
"Log.Info(\"hello\"); return 1;",
Parameters: null,
TimeoutSeconds: null),
CancellationToken.None);
Assert.True(result.Success, result.Error);
Assert.Contains("[info] hello", result.ConsoleOutput);
}
[Fact]
public async Task RunInSandbox_InboundScript_LogGlobal_WritesToConsoleOutput()
{
// Same mirror on the inbound Test Run host (SandboxInboundScriptHost) — an API
// method script's Log.* calls must bind and surface in the console pane too.
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest(
"Log.Info(\"hello\"); return 1;",
Parameters: null,
TimeoutSeconds: null,
Kind: ScriptKind.InboundApi),
CancellationToken.None);
Assert.True(result.Success, result.Error);
Assert.Contains("[info] hello", result.ConsoleOutput);
}
[Fact]
public async Task RunInSandbox_ConcurrentRuns_DoNotCrossContaminateConsoleOutput()
{
@@ -661,6 +695,40 @@ public class ScriptAnalysisServiceTests
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
}
[Fact]
public void InboundScript_Log_DiagnosesClean()
{
// Flow Debugger step logs (design 2026-09-09 D4): `Log` is a shipped inbound-script
// global (InboundScriptContext.Log). The EDITOR host is a third mirror alongside the
// compile surface and the Test Run sandbox — without `Log` on InboundScriptHost a
// logging method script shows a spurious CS0103 while deploying and running fine.
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "Log.Info(\"x\");\n"
+ "Log.Debug(\"d\");\n"
+ "Log.Warn(\"w\", new System.Exception(\"boom\"));\n"
+ "Log.Error(\"e\");",
Kind: ScriptKind.InboundApi));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
}
[Fact]
public void SiteScript_Log_DiagnosesClean()
{
// The other half of GlobalsTypeFor: every non-inbound kind analyses against
// SandboxScriptHost, so the same `Log` global must resolve there too — both at
// the top level and via `Instance.Log`.
var resp = _svc.Diagnose(new DiagnoseRequest(
Code: "Log.Info(\"x\");\n"
+ "Log.Warn(\"w\", new System.Exception(\"boom\"));\n"
+ "Instance.Log.Info(\"via instance\");",
Kind: ScriptKind.Template));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("SCADA"));
}
[Fact]
public void InboundScript_Database_DiagnosesClean()
{
@@ -253,7 +253,7 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.IsType<DiscardSiteCallRequest>(Assert.Single(_siteCallRequests));
}
private static string SuccessPayload(int events, bool completed = true, bool truncated = false)
private static string SuccessPayload(int events, bool completed = true, bool truncated = false, bool logsTruncated = false)
{
var root = Guid.NewGuid();
var child = Guid.NewGuid();
@@ -273,10 +273,36 @@ public sealed class FlowDebugServiceTests : IDisposable
occurredAtUtc: t0.AddMilliseconds(2), target: "S", executionId: child, parentExecutionId: root,
sourceSiteId: "site-a", sourceInstanceId: "I", durationMs: 80));
var result = new FlowDebugRunResult(root, completed, null, null, list, truncated, t0, t0.AddSeconds(1));
var result = new FlowDebugRunResult(root, completed, null, null, list, truncated, t0, t0.AddSeconds(1), logsTruncated);
return JsonSerializer.Serialize(result, ServerJson);
}
[Fact]
public async Task RunMethod_LogsTruncated_RidesOntoTheOutcomeAndTheGraph()
{
// Step logs D7: the run result's inline log-truncation flag is the page's only way to
// know the lines it holds are incomplete — carry it onto the outcome AND into the graph.
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 3, logsTruncated: true)));
var outcome = await svc.RunMethodAsync(4, null);
Assert.True(outcome.Success);
Assert.True(outcome.LogsTruncated);
Assert.True(outcome.Graph!.LogsTruncated);
Assert.True(outcome.Raw!.LogsTruncated);
}
[Fact]
public async Task RunMethod_NoLogTruncation_LeavesTheFlagFalse()
{
var svc = CreateService(new ManagementSuccess("c", SuccessPayload(events: 3)));
var outcome = await svc.RunMethodAsync(4, null);
Assert.False(outcome.LogsTruncated);
Assert.False(outcome.Graph!.LogsTruncated);
}
[Fact]
public async Task RunMethod_HolderNull_ReportsServiceUnavailable()
{
@@ -566,4 +566,153 @@ public class FlowGraphViewTests : BunitContext
var edge = Assert.Single(layout.Edges);
Assert.True(edge.Async);
}
// ── Step logs + exception subtitle (step-logs plan 2026-09-09, D8) ──
private static FlowLogLine Line(string level, string message = "m", int seq = 0)
=> new(Guid.NewGuid(), Root, T0.AddMilliseconds(seq), level, "script", message, null, null, null, seq);
/// <summary>A one-node Site-lane graph whose single step carries the given logs / error shape.</summary>
private static FlowGraph LogGraph(
FlowStepOutcome outcome = FlowStepOutcome.Ok,
string? errorMessage = null,
string? errorDetail = null,
IReadOnlyList<FlowLogLine>? logs = null)
{
var step = new FlowStep(Guid.NewGuid(), Root, null, FlowStepKind.ScriptRun, FlowLane.Site, "DoWork", "trigger: manual",
T0, 5, outcome, null, null, errorMessage, "site-a", "node-a", "M1", "DoWork", null, null, null, errorDetail, logs);
return new FlowGraph([step], [], false, false);
}
[Fact]
public void Render_StepWithNoLogLines_HasNoLogBadge()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph()));
Assert.Empty(cut.FindAll("[data-test='flow-node-logs']"));
// The footer still exists and holds the subtitle.
Assert.NotNull(cut.Find("g[data-test='flow-node'] .fg-foot [data-test='flow-node-subtitle']"));
}
[Fact]
public void Render_StepWithMixedLevels_ShowsCountsHighestSeverityFirst_DebugCountedAsInfo()
{
var logs = new List<FlowLogLine>
{
Line("error"), Line("warn"), Line("WARN"), Line("info"), Line("info"), Line("info"), Line("debug"),
};
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph(logs: logs)));
var badge = cut.Find("[data-test='flow-node-logs']");
Assert.Equal(["✕ 1", "! 2", "≡ 4"], badge.QuerySelectorAll("i").Select(i => i.TextContent.Trim()));
Assert.Equal(["e", "w", "i"], badge.QuerySelectorAll("i").Select(i => i.GetAttribute("class")));
Assert.Equal("1 error, 2 warnings, 4 info", badge.GetAttribute("title"));
}
[Fact]
public void Render_StepWithOnlyInfoLines_ShowsASingleFaintCount()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph(logs: [Line("info"), Line("debug")])));
var badge = cut.Find("[data-test='flow-node-logs']");
var item = Assert.Single(badge.QuerySelectorAll("i"));
Assert.Equal("≡ 2", item.TextContent.Trim());
Assert.Equal("i", item.GetAttribute("class"));
Assert.Equal("2 info", badge.GetAttribute("title"));
}
[Fact]
public void Render_FailedStepWithErrorDetail_SubtitleIsTheExceptionTypeAndMessage()
{
var graph = LogGraph(FlowStepOutcome.Failed, errorMessage: "Recipe 42 rejected",
errorDetail: "System.InvalidOperationException: Recipe 42 rejected\n at X");
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, graph));
Assert.Equal("InvalidOperationException: Recipe 42 rejected",
cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim());
}
[Fact]
public void Render_FailedStepWithoutDetail_FallsBackToTheErrorMessage()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph(FlowStepOutcome.Failed, errorMessage: "timed out talking to MesDb")));
Assert.Equal("timed out talking to MesDb", cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim());
}
[Fact]
public void Render_OkStepWithAnErrorDetail_KeepsItsNormalSubtitle()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, LogGraph(errorDetail: "System.InvalidOperationException: stale")));
Assert.Equal("trigger: manual", cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim());
}
[Fact]
public void LogCounts_FoldsLevelsCaseInsensitively_AndTreatsEverythingElseAsInfo()
{
Assert.Equal((0, 0, 0), FlowGraphView.LogCounts(null));
Assert.Equal((0, 0, 0), FlowGraphView.LogCounts(LogGraph().Steps[0]));
var step = LogGraph(logs: [Line("Error"), Line("error"), Line("Warn"), Line("info"), Line("debug"), Line("trace")]).Steps[0];
Assert.Equal((2, 1, 3), FlowGraphView.LogCounts(step));
}
[Theory]
[InlineData(FlowStepOutcome.Failed, "System.InvalidOperationException: Recipe 42 rejected\n at X", "boom", "InvalidOperationException: Recipe 42 rejected")]
[InlineData(FlowStepOutcome.TimedOut, "System.TimeoutException: took too long", "boom", "TimeoutException: took too long")]
[InlineData(FlowStepOutcome.Failed, "SomethingWentWrong", "boom", "SomethingWentWrong")]
[InlineData(FlowStepOutcome.Failed, " ", "boom", "boom")]
[InlineData(FlowStepOutcome.Failed, null, "boom", "boom")]
[InlineData(FlowStepOutcome.Ok, "System.InvalidOperationException: nope", "boom", null)]
// A wrapper chains its inner exceptions on the first line — the innermost is the one to act on.
[InlineData(FlowStepOutcome.Failed,
"System.AggregateException: One or more errors occurred. (Recipe 42 rejected) ---> System.InvalidOperationException: Recipe 42 rejected\n at X",
"boom", "InvalidOperationException: Recipe 42 rejected")]
[InlineData(FlowStepOutcome.Failed,
"A.Outer: outer ---> B.Middle: middle ---> C.Innermost: the real one",
"boom", "Innermost: the real one")]
// The namespace is stripped only from the head of the name; a generic suffix stays verbatim.
[InlineData(FlowStepOutcome.Failed, "MyApp.RecipeException`1[System.String]: msg", "boom", "RecipeException`1[System.String]: msg")]
[InlineData(FlowStepOutcome.Failed, "RecipeException`1[System.String]: msg", "boom", "RecipeException`1[System.String]: msg")]
public void ExceptionSubtitle_ParsesTheFirstLineOfTheDetail_OnlyForAFailedStep(
FlowStepOutcome outcome, string? detail, string? message, string? expected)
{
var step = LogGraph(outcome, errorMessage: message, errorDetail: detail).Steps[0];
Assert.Equal(expected, FlowGraphView.ExceptionSubtitle(step));
}
[Fact]
public void LogTitle_NamesOnlyTheNonZeroLevels_WithSingularAndPlural()
{
Assert.Equal("1 error", FlowGraphView.LogTitle((1, 0, 0)));
Assert.Equal("2 errors, 1 warning", FlowGraphView.LogTitle((2, 1, 0)));
Assert.Equal("1 warning, 2 info", FlowGraphView.LogTitle((0, 1, 2)));
Assert.Equal("3 info", FlowGraphView.LogTitle((0, 0, 3)));
Assert.Equal(string.Empty, FlowGraphView.LogTitle((0, 0, 0)));
}
[Fact]
public void Render_OutboundNodeWithLogLines_ShowsBothThePillAndTheBadgeInTheFooter()
{
var correlation = Guid.NewGuid();
var step = new FlowStep(Guid.NewGuid(), Root, null, FlowStepKind.ApiCall, FlowLane.External, "Delmia.AckRecipe",
"Delivered · sync", T0, 212, FlowStepOutcome.Ok, null, null, null, "site-a", "node-a", "M1", "Delmia.AckRecipe", null,
[new FlowLifecycleRow(Guid.NewGuid(), "ApiCall", "Delivered", T0, "node-a", null, 212)],
correlation, null, [Line("warn"), Line("info")]);
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, new FlowGraph([step], [], false, false)));
var foot = cut.Find("g[data-test='flow-node'][data-kind='ApiCall'] .fg-foot");
Assert.Equal("Ok", foot.QuerySelector("[data-test='flow-node-pill']")!.TextContent.Trim());
Assert.Equal("· sync", foot.QuerySelector(".fg-mode")!.TextContent.Trim());
var badge = foot.QuerySelector("[data-test='flow-node-logs']")!;
Assert.Equal(["! 1", "≡ 1"], badge.QuerySelectorAll("i").Select(i => i.TextContent.Trim()));
Assert.Equal("1 warning, 1 info", badge.GetAttribute("title"));
// A pill node has no subtitle line — the pill occupies the footer's left half.
Assert.Null(foot.QuerySelector("[data-test='flow-node-subtitle']"));
}
}
@@ -0,0 +1,71 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
/// <summary>
/// Final review M1: the ONE parser behind the graph node's exception subtitle and the step
/// detail panel's exception block. Before it, the panel showed the OUTERMOST exception of a
/// wrapped chain while the node showed the innermost — the two now agree by construction.
/// </summary>
public class ExceptionHeaderTests
{
[Fact]
public void Parse_PlainDetail_SplitsTypeFromMessage_AndShortensOnlyWhenAsked()
{
const string detail = "System.InvalidOperationException: Recipe 42 rejected\n at X.Y()";
Assert.Equal(("InvalidOperationException", "Recipe 42 rejected"), ExceptionHeader.Parse(detail, shortenType: true));
Assert.Equal(("System.InvalidOperationException", "Recipe 42 rejected"), ExceptionHeader.Parse(detail, shortenType: false));
}
[Fact]
public void Parse_WrappedChain_TakesTheINNERMOSTException()
{
const string detail = "A.Outer: outer ---> B.Middle: middle ---> C.Innermost: the real one\n at X";
Assert.Equal(("Innermost", "the real one"), ExceptionHeader.Parse(detail, shortenType: true));
// The panel keeps the namespace but still names the innermost exception, not the wrapper.
Assert.Equal(("C.Innermost", "the real one"), ExceptionHeader.Parse(detail, shortenType: false));
}
[Fact]
public void Parse_GenericType_KeepsTheArityAndArgumentsVerbatim_ShorteningOnlyTheHead()
{
Assert.Equal(("RecipeException`1[System.String]", "msg"),
ExceptionHeader.Parse("MyApp.RecipeException`1[System.String]: msg", shortenType: true));
Assert.Equal(("RecipeException`1[System.String]", "msg"),
ExceptionHeader.Parse("RecipeException`1[System.String]: msg", shortenType: true));
}
[Fact]
public void Parse_ChainMarkerIsMatchedAfterTrimming_SoATrailingMarkerIsNotAChain()
{
// The first line is trimmed BEFORE the " ---> " chain is walked (behaviour carried over
// verbatim from the FlowGraphView original), so a detail ending in the marker keeps it as
// part of the message rather than resolving to an empty innermost segment.
Assert.Equal(("Outer", "outer --->"), ExceptionHeader.Parse("A.Outer: outer ---> ", shortenType: true));
}
[Fact]
public void Parse_LineWithoutAColon_IsAllTypeAndNoMessage()
{
Assert.Equal(("SomethingWentWrong", string.Empty), ExceptionHeader.Parse("SomethingWentWrong", shortenType: true));
}
[Fact]
public void Parse_CrLfDetail_TrimsTheCarriageReturnOffTheFirstLine()
{
Assert.Equal(("TimeoutException", "took too long"),
ExceptionHeader.Parse("System.TimeoutException: took too long\r\n at X\r\n", shortenType: true));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Parse_NothingUsable_ReturnsNull(string? detail)
{
Assert.Null(ExceptionHeader.Parse(detail, shortenType: true));
Assert.Null(ExceptionHeader.Parse(detail, shortenType: false));
}
}
@@ -0,0 +1,204 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
/// <summary>
/// Step-logs design 2026-09-09 D2: a <c>ScriptTrace.ScriptLog</c> row is never a node — it folds onto the
/// step its <c>StepEventId</c> names, else onto its execution's anchor (<c>ScriptRun</c> /
/// <c>InboundRequest</c> / <c>RouteCall</c>), else its execution's first non-<c>Response</c> step, else it is
/// dropped and counted in <see cref="FlowGraph.UnattachedLogLines"/>.
/// </summary>
public class FlowGraphBuilderLogLineTests
{
private static readonly DateTime T0 = new(2026, 9, 9, 12, 0, 0, DateTimeKind.Utc);
private static AuditEvent Run(Guid execution, Guid? parent = null, int ms = 0) =>
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(ms), actor: "SomeScript", target: "SomeScript", sourceNode: "node-a",
executionId: execution, parentExecutionId: parent, sourceSiteId: "site-a", sourceInstanceId: "M1",
durationMs: 100, extra: new ScriptTraceExtra { TriggerKind = "call" }.ToJson());
private static AuditEvent Write(Guid execution, Guid? eventId = null, int ms = 10) =>
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.AttributeWrite, AuditStatus.Delivered,
eventId: eventId, occurredAtUtc: T0.AddMilliseconds(ms), actor: "SomeScript", target: "M1.Flag",
sourceNode: "node-a", executionId: execution, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 2,
extra: new ScriptTraceExtra { Writes = [new AttributeWriteDetail("M1.Flag", "true", true, null)] }.ToJson());
private static AuditEvent Inbound(Guid execution, int ms = 0) =>
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(ms), actor: "some-key", target: "SomeMethod", sourceNode: "central-a",
executionId: execution, durationMs: 50, requestSummary: "{}", responseSummary: "{\"ok\":true}");
private static AuditEvent RouteCallRoot(Guid execution, int ms = 0) =>
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.RouteCall, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(ms), actor: "flow-debugger", target: "M1.SomeScript", sourceNode: "central-a",
executionId: execution, durationMs: 120);
private static AuditEvent ApiOut(Guid execution, Guid eventId, int ms = 20) =>
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered,
eventId: eventId, occurredAtUtc: T0.AddMilliseconds(ms), actor: "SomeScript", target: "ERP/orders",
sourceNode: "node-a", executionId: execution, sourceSiteId: "site-a", durationMs: 30);
private static AuditEvent Line(Guid execution, Guid? step = null, int? seq = null, string level = "info",
int ms = 5, string message = "hello") =>
ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(ms), actor: "SomeScript", target: "SomeScript", sourceNode: "node-a",
executionId: execution, sourceSiteId: "site-a", sourceInstanceId: "M1", requestSummary: message,
extra: new ScriptTraceExtra { LogLevel = level, LogSource = "script", StepEventId = step, Seq = seq }.ToJson());
private static FlowStep StepOf(FlowGraph graph, FlowStepKind kind) => Assert.Single(graph.Steps, s => s.Kind == kind);
[Fact]
public void LogLine_WithStepEventId_AttachesToThatStep()
{
var execution = Guid.NewGuid();
var writeId = Guid.NewGuid();
var graph = FlowGraphBuilder.Build([Run(execution), Write(execution, writeId), Line(execution, step: writeId)]);
var write = StepOf(graph, FlowStepKind.AttributeWrite);
var line = Assert.Single(write.Logs!);
Assert.Equal("hello", line.Message);
Assert.Null(StepOf(graph, FlowStepKind.ScriptRun).Logs);
}
[Fact]
public void LogLine_WithoutStepId_AttachesToTheExecutionAnchor()
{
var execution = Guid.NewGuid();
var graph = FlowGraphBuilder.Build([Run(execution), Write(execution), Line(execution)]);
Assert.Single(StepOf(graph, FlowStepKind.ScriptRun).Logs!);
Assert.Null(StepOf(graph, FlowStepKind.AttributeWrite).Logs);
}
[Fact]
public void CentralLine_AttachesToInboundRow_NotResponse()
{
var execution = Guid.NewGuid();
var graph = FlowGraphBuilder.Build([Inbound(execution), Line(execution)]);
Assert.Single(StepOf(graph, FlowStepKind.InboundRequest).Logs!);
Assert.Null(StepOf(graph, FlowStepKind.Response).Logs);
}
[Fact]
public void ScriptEntryLine_AttachesToRouteCallRoot()
{
var execution = Guid.NewGuid();
var graph = FlowGraphBuilder.Build([RouteCallRoot(execution), Line(execution)]);
Assert.Single(StepOf(graph, FlowStepKind.RouteCall).Logs!);
}
[Fact]
public void LogLine_UnknownStepId_FallsBackToAnchor()
{
var execution = Guid.NewGuid();
var graph = FlowGraphBuilder.Build([Run(execution), Line(execution, step: Guid.NewGuid())]);
Assert.Single(StepOf(graph, FlowStepKind.ScriptRun).Logs!);
Assert.Equal(0, graph.UnattachedLogLines);
}
[Fact]
public void LogLine_NoAnchor_FallsBackToFirstStep()
{
var execution = Guid.NewGuid();
var graph = FlowGraphBuilder.Build([Write(execution), Line(execution)]);
Assert.Single(StepOf(graph, FlowStepKind.AttributeWrite).Logs!);
Assert.Equal(0, graph.UnattachedLogLines);
}
[Fact]
public void LogLine_NoStepInExecution_IsCountedUnattached()
{
var execution = Guid.NewGuid();
var graph = FlowGraphBuilder.Build([Run(execution), Line(Guid.NewGuid())]);
Assert.Equal(1, graph.UnattachedLogLines);
Assert.All(graph.Steps, s => Assert.True(s.Logs is null or { Count: 0 }));
}
[Fact]
public void Build_LinesOnly_ReturnsEmptyGraphWithUnattachedCount()
{
var graph = FlowGraphBuilder.Build([Line(Guid.NewGuid()), Line(Guid.NewGuid())], logsTruncated: true);
Assert.Empty(graph.Steps);
Assert.Empty(graph.Edges);
Assert.Equal(2, graph.UnattachedLogLines);
Assert.True(graph.LogsTruncated);
}
[Fact]
public void Lines_OrderBySeqWithinSameMillisecond_NullSeqLast()
{
var execution = Guid.NewGuid();
var graph = FlowGraphBuilder.Build(
[
Run(execution),
Line(execution, seq: 3, ms: 5, message: "three"),
Line(execution, seq: 1, ms: 5, message: "one"),
Line(execution, seq: null, ms: 5, message: "null"),
Line(execution, seq: 2, ms: 5, message: "two"),
]);
var logs = StepOf(graph, FlowStepKind.ScriptRun).Logs!;
Assert.Equal(new int?[] { 1, 2, 3, null }, logs.Select(l => l.Seq).ToArray());
Assert.Equal(new[] { "one", "two", "three", "null" }, logs.Select(l => l.Message).ToArray());
}
[Fact]
public void LogLines_NeverBecomeSteps()
{
var execution = Guid.NewGuid();
var events = new List<AuditEvent> { Run(execution) };
for (var i = 0; i < 5; i++)
events.Add(Line(execution, seq: i, ms: 1 + i, message: $"line {i}"));
var lineIds = events.Skip(1).Select(e => e.EventId).ToHashSet();
var graph = FlowGraphBuilder.Build(events);
var step = Assert.Single(graph.Steps);
Assert.Equal(FlowStepKind.ScriptRun, step.Kind);
Assert.Equal(5, step.Logs!.Count);
Assert.DoesNotContain(graph.Edges, e => lineIds.Contains(e.FromEventId) || lineIds.Contains(e.ToEventId));
}
[Fact]
public void Build_PassesLogsTruncatedThrough()
{
var execution = Guid.NewGuid();
var graph = FlowGraphBuilder.Build([Run(execution)], truncated: false, logsTruncated: true);
Assert.True(graph.LogsTruncated);
Assert.False(graph.Truncated);
}
[Fact]
public void LogLine_OnFoldedOutboundStep_AttachesByFirstRowId()
{
var execution = Guid.NewGuid();
var callId = Guid.NewGuid();
var graph = FlowGraphBuilder.Build([Run(execution), ApiOut(execution, callId), Line(execution, step: callId)]);
var call = StepOf(graph, FlowStepKind.ApiCall);
Assert.Equal(callId, call.EventId);
Assert.Single(call.Logs!);
Assert.Null(StepOf(graph, FlowStepKind.ScriptRun).Logs);
}
}
@@ -0,0 +1,137 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
/// <summary>
/// Flow Debugger step-logs plan (2026-09-09) Task 1: the Commons log-line vocabulary —
/// exception detail on a step (D3), and the <c>ScriptTrace.ScriptLog</c> row projecting
/// onto a <see cref="FlowLogLine"/> rather than a node (D1).
/// </summary>
public class FlowStepProjectorLogLineTests
{
[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 Project_ErrorDetail_IsNullWhenAbsent()
{
var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered, target: "S");
Assert.Null(FlowStepProjector.Project(evt).ErrorDetail);
}
[Fact]
public void ScriptLog_ProjectsToUnknownKind_NeverItsOwnStepKind()
{
// Mixed-version rule (design §4): an old central renders a ScriptLog row as an Unknown node
// rather than dropping it. No FlowStepKind is added for it.
Assert.Equal(FlowStepKind.Unknown, FlowStepProjector.ResolveKind(nameof(AuditChannel.ScriptTrace), nameof(AuditKind.ScriptLog)));
Assert.False(Enum.GetNames<FlowStepKind>().Contains("ScriptLog"));
}
[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 IsLogLine_FalseForOtherChannels()
{
var outbound = ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiOutbound, AuditKind.ApiCall, AuditStatus.Delivered, target: "S");
Assert.False(FlowStepProjector.IsLogLine(outbound));
}
[Fact]
public void ToLogLine_ReadsLevelSourceAndException()
{
var exec = Guid.NewGuid();
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", executionId: exec, requestSummary: "acked Uncertain", errorMessage: "inner",
errorDetail: "System.Exception: inner", extra: extra.ToJson());
var line = FlowStepProjector.ToLogLine(evt);
Assert.Equal(evt.EventId, line.EventId);
Assert.Equal(exec, line.ExecutionId);
Assert.Equal(evt.OccurredAtUtc, line.OccurredAtUtc);
Assert.Equal("warn", line.Level);
Assert.Equal("runtime", line.Source);
Assert.Equal("acked Uncertain", line.Message);
Assert.Equal("inner", line.ExceptionMessage);
Assert.Equal("System.Exception: inner", line.ExceptionDetail);
Assert.Equal(extra.StepEventId, line.StepEventId);
Assert.Equal(3, line.Seq);
}
[Fact]
public void ToLogLine_DefaultsLevelAndSourceWhenExtraMissing()
{
var evt = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered, target: "S");
var line = FlowStepProjector.ToLogLine(evt);
Assert.Equal("info", line.Level);
Assert.Equal("script", line.Source);
Assert.Equal(string.Empty, line.Message);
Assert.Null(line.ExceptionMessage);
Assert.Null(line.ExceptionDetail);
Assert.Null(line.StepEventId);
Assert.Null(line.Seq);
}
[Fact]
public void ScriptTraceExtra_LogMembers_RoundTripThroughJson()
{
var extra = new ScriptTraceExtra { LogLevel = "error", LogSource = "script", StepEventId = Guid.NewGuid(), Seq = 12 };
var parsed = ScriptTraceExtra.TryParse(extra.ToJson());
Assert.NotNull(parsed);
Assert.Equal("error", parsed!.LogLevel);
Assert.Equal("script", parsed.LogSource);
Assert.Equal(extra.StepEventId, parsed.StepEventId);
Assert.Equal(12, parsed.Seq);
}
[Fact]
public void FlowStep_LogsAndErrorDetail_DefaultToNull()
{
var step = new FlowStep(Guid.NewGuid(), null, null, FlowStepKind.ScriptRun, FlowLane.Site, "S", null,
DateTimeOffset.UnixEpoch, null, FlowStepOutcome.Ok, null, null, null, null, null, null, null, null);
Assert.Null(step.ErrorDetail);
Assert.Null(step.Logs);
var line = new FlowLogLine(Guid.NewGuid(), null, DateTimeOffset.UnixEpoch, "info", "script", "m", null, null, null, null);
Assert.Single((step with { Logs = [line] }).Logs!);
}
[Fact]
public void FlowGraph_LogTruncationMembers_DefaultToOff()
{
var graph = new FlowGraph([], [], false, false);
Assert.False(graph.LogsTruncated);
Assert.Equal(0, graph.UnattachedLogLines);
Assert.False(FlowGraph.Empty.LogsTruncated);
Assert.Equal(0, FlowGraph.Empty.UnattachedLogLines);
}
}
@@ -1,5 +1,7 @@
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Debugging;
@@ -16,6 +18,12 @@ public class FlowTraceCollectorTests
Target = i.ToString(),
};
/// <summary>Low-priority-lane helper (D7, 2026-09-09): a canonical row built through the
/// factory, so <c>DetailsJson</c> carries a real envelope around <paramref name="details"/>.</summary>
private static AuditEvent Evt(string? details = null) => ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
target: "S", requestSummary: details);
[Fact]
public void Add_BeyondCap_KeepsFirstN_AndFlagsTruncated()
{
@@ -128,6 +136,65 @@ public class FlowTraceCollectorTests
Assert.Throws<ArgumentOutOfRangeException>(() => new FlowTraceCollector(maxBytes: 0));
}
/// <summary>D7 (2026-09-09): the low-priority lane stops short of the event cap so the
/// step rows a run emits at the end always have room.</summary>
[Fact]
public void Add_LowPriority_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.Add(Evt(), lowPriority: true);
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);
}
/// <summary>D7 (2026-09-09): the low-priority lane also stops short of the byte budget.</summary>
[Fact]
public void Add_LowPriority_RespectsByteShare()
{
var c = new FlowTraceCollector(maxEvents: 500, maxBytes: 1_000); // 75% = 750 chars
c.Add(Evt(details: new string('x', 600)), lowPriority: true);
c.Add(Evt(details: new string('x', 200)), lowPriority: true);
Assert.Equal(1, c.Count);
Assert.True(c.LogsTruncated);
}
/// <summary>D7 (2026-09-09): the reserve floors at 1, so a tiny cap still admits one log line.</summary>
[Fact]
public void Add_LowPriority_WithTinyCap_StillAllowsOne()
{
var c = new FlowTraceCollector(maxEvents: 20, maxBytes: 1_000_000); // max(1, 20-50) = 1
c.Add(Evt(), lowPriority: true);
Assert.Equal(1, c.Count);
Assert.False(c.LogsTruncated);
c.Add(Evt(), lowPriority: true);
Assert.Equal(1, c.Count);
Assert.True(c.LogsTruncated);
}
/// <summary>D7 (2026-09-09): <see cref="FlowTraceCollector.Complete"/> closes the low-priority
/// lane too — a late log line is dropped silently, not counted as truncated.</summary>
[Fact]
public void Add_LowPriority_IsNoOpAfterComplete()
{
var c = new FlowTraceCollector();
c.Add(Evt(), lowPriority: true);
c.Complete();
c.Add(Evt(), lowPriority: true);
Assert.Equal(1, c.Count);
Assert.False(c.LogsTruncated);
}
/// <summary>Upper-cases <c>DetailsJson</c> so a test can see the filtered copy land.</summary>
private sealed class UpperRedactor : IAuditRedactor
{
@@ -23,7 +23,7 @@ public class AuditEnumTests
}
[Fact]
public void AuditKind_HasExactlyTwentyOneExpectedMembers()
public void AuditKind_HasExactlyTwentyTwoExpectedMembers()
{
var expected = new[]
{
@@ -34,14 +34,14 @@ public class AuditEnumTests
"SecuredWriteExpire",
"ReconciliationAbandoned",
"ManualFailover",
"RouteCall", "ScriptRun", "AttributeWrite", "AttributeWait",
"RouteCall", "ScriptRun", "AttributeWrite", "AttributeWait", "ScriptLog",
};
var actual = Enum.GetValues(typeof(AuditKind))
.Cast<AuditKind>()
.Select(x => x.ToString())
.ToArray();
Assert.Equal(21, actual.Length);
Assert.Equal(22, actual.Length);
Assert.Equal(expected, actual);
}
@@ -432,7 +432,23 @@ public sealed class EndpointExtensionsTests : IDisposable
var method = SeedMethod(1, "boom",
"""throw new System.InvalidOperationException("boom-msg");""");
using var host = await BuildHostAsync(method);
// Flow Debugger step logs (design 2026-09-09 D3): the detail the body must NOT carry
// is nonetheless stashed for AuditWriteMiddleware, so the inbound audit row (and the
// Flow Debugger's inbound node) can say why the run failed. A capture middleware reads
// the slot once the endpoint has finished, proving both halves in one request.
string? capturedDetail = null;
using var host = await BuildHostAsync(method, customize: builder =>
{
builder.Use(async (ctx, next) =>
{
await next();
if (ctx.Items.TryGetValue(
AuditWriteMiddleware.ErrorDetailItemKey, out var stashed))
{
capturedDetail = stashed as string;
}
});
});
var token = await SeedKeyAsync(host, "key1", "caller", new[] { "boom" });
var client = host.GetTestClient();
@@ -445,6 +461,10 @@ public sealed class EndpointExtensionsTests : IDisposable
// message is not leaked (the executor sanitises before this point).
Assert.Contains("error", body);
Assert.DoesNotContain("boom-msg", body);
// …while the audit-only slot carries the real cause.
Assert.NotNull(capturedDetail);
Assert.Contains("boom-msg", capturedDetail);
}
[Fact]
@@ -117,4 +117,19 @@ public class InboundApiOptionsValidatorTests
Assert.True(result.Failed);
Assert.Contains("StandbyForwardTimeout", result.FailureMessage);
}
// --- Flow Debugger step logs (design 2026-09-09 D7) ---
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void NonPositiveMaxLogLinesPerRun_IsRejected(int max)
{
// A zero/negative cap would audit no line of a traced run at all while still
// paying for the closing "dropped" row - the option must bound, not disable.
var result = Validate(new InboundApiOptions { MaxLogLinesPerRun = max });
Assert.True(result.Failed);
Assert.Contains("MaxLogLinesPerRun", result.FailureMessage);
}
}
@@ -11,6 +11,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
@@ -200,6 +202,56 @@ public class InboundScriptExecutorTests
Assert.Equal("Internal script error", result.ErrorMessage);
}
[Fact]
public async Task HandlerThrows_ErrorDetailCarriesTheException_WhileTheMessageStaysSafe()
{
// Flow Debugger step logs (design 2026-09-09 D3): the generic SCRIPT_ERROR catch keeps
// the caller-facing message sanitised, but the exception itself must survive on the
// additive ErrorDetail so the endpoint can put it on the inbound audit row (and the
// Flow Debugger's inbound node can show why the run failed).
var method = new ApiMethod("failing-detail", "throw new InvalidOperationException(\"real reason\");")
{ Id = 1, TimeoutSeconds = 10 };
_executor.RegisterHandler("failing-detail", _ => throw new InvalidOperationException("real reason"));
var result = await _executor.ExecuteAsync(
method,
new Dictionary<string, object?>(),
_route,
TimeSpan.FromSeconds(10));
Assert.False(result.Success);
Assert.Equal("Internal script error", result.ErrorMessage);
Assert.Equal("SCRIPT_ERROR", result.ErrorCode);
Assert.NotNull(result.ErrorDetail);
Assert.Contains("InvalidOperationException", result.ErrorDetail);
Assert.Contains("real reason", result.ErrorDetail);
// The caller-safe fields must never carry it.
Assert.DoesNotContain("real reason", result.ErrorMessage!);
}
[Fact]
public async Task HandlerTimesOut_CarriesNoErrorDetail()
{
// A timeout is fully described by its message + TIMEOUT code — the
// OperationCanceledException adds nothing, so the row stays detail-less.
var method = new ApiMethod("slow-detail", "Thread.Sleep(60000);") { Id = 1, TimeoutSeconds = 1 };
_executor.RegisterHandler("slow-detail", async ctx =>
{
await Task.Delay(TimeSpan.FromSeconds(60), ctx.CancellationToken);
return "never";
});
var result = await _executor.ExecuteAsync(
method,
new Dictionary<string, object?>(),
_route,
TimeSpan.FromMilliseconds(100));
Assert.False(result.Success);
Assert.Equal("TIMEOUT", result.ErrorCode);
Assert.Null(result.ErrorDetail);
}
[Fact]
public async Task RoutedAskTimeout_Returns500WithSiteUnreachableCode()
{
@@ -902,6 +954,173 @@ public class InboundScriptExecutorTests
Assert.Equal(0, _executor.AbandonedExecutionCount); // ...and DECREMENTED
}
// --- Flow Debugger step logs (design 2026-09-09 D4/D5/D7): the Log global ---
[Fact]
public async Task Log_TracedRun_EmitsOneScriptLogRowOnTheTraceSink()
{
// D4/D5: a method script's Log.Info(...) binds as a top-level global and, because the
// execution is traced, produces exactly one ScriptTrace/ScriptLog row carrying the
// inbound request's own ExecutionId. stepEventId stays null on a central line — the
// graph builder attaches it to the execution's anchor step (design D2).
var sink = new List<AuditEvent>();
var route = NewRoute().WithTraceSink(sink.Add);
var method = new ApiMethod("logs", "Log.Info(\"central\"); return 1;") { Id = 7, TimeoutSeconds = 10 };
var executionId = Guid.NewGuid();
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
parentExecutionId: executionId, trace: true);
Assert.True(result.Success, result.ErrorMessage);
var row = Assert.Single(ScriptLogRows(sink));
var details = AuditDetailsCodec.Deserialize(row.DetailsJson);
Assert.Equal(nameof(AuditKind.ScriptLog), details.Kind);
Assert.Equal(nameof(AuditChannel.ScriptTrace), details.Channel);
Assert.Equal(executionId, details.ExecutionId);
Assert.Equal("logs", row.Target);
Assert.Equal("central", details.RequestSummary);
var extra = ScriptTraceExtra.TryParse(details.Extra);
Assert.NotNull(extra);
Assert.Equal("info", extra!.LogLevel);
Assert.Equal("script", extra.LogSource);
Assert.Null(extra.StepEventId);
}
[Fact]
public async Task Log_TracedRun_WritesTheScriptLogRowToTheCentralAuditStore()
{
// D5: the row must be DURABLE, not just inline — Replay reads it back from the central
// audit store, so the write happens even when no Live trace sink is attached, and it
// carries the emitting node exactly as the RouteCall row does.
var writer = Substitute.For<ICentralAuditWriter>();
writer.WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
var nodeIdentity = Substitute.For<INodeIdentityProvider>();
nodeIdentity.NodeName.Returns("central-a");
var services = new ServiceCollection();
services.AddSingleton(writer);
services.AddSingleton(nodeIdentity);
using var provider = services.BuildServiceProvider();
var executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance, provider);
var method = new ApiMethod("persisted", "Log.Info(\"persist\"); return 1;") { Id = 7, TimeoutSeconds = 10 };
var result = await executor.ExecuteAsync(
method, new Dictionary<string, object?>(), NewRoute(), TimeSpan.FromSeconds(10),
parentExecutionId: Guid.NewGuid(), trace: true);
Assert.True(result.Success, result.ErrorMessage);
await writer.Received(1).WriteAsync(
Arg.Is<AuditEvent>(e =>
AuditDetailsCodec.Deserialize(e.DetailsJson).Kind == nameof(AuditKind.ScriptLog)
&& e.SourceNode == "central-a"),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task Log_UntracedRun_WritesOnlyToTheNodeLogger()
{
// D5: untraced runs pay nothing — no trace-sink row, no central audit write — but the
// author's message still reaches the node's ILogger so it is never silently lost.
var sink = new List<AuditEvent>();
var route = NewRoute().WithTraceSink(sink.Add);
var writer = Substitute.For<ICentralAuditWriter>();
writer.WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
var services = new ServiceCollection();
services.AddSingleton(writer);
using var provider = services.BuildServiceProvider();
var recorded = new List<string>();
var executor = new InboundScriptExecutor(new RecordingLogger<InboundScriptExecutor>(recorded), provider);
var method = new ApiMethod("quiet", "Log.Warn(\"unwatched\"); return 1;") { Id = 7, TimeoutSeconds = 10 };
var result = await executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
parentExecutionId: Guid.NewGuid());
Assert.True(result.Success, result.ErrorMessage);
Assert.Empty(ScriptLogRows(sink));
await writer.DidNotReceive().WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>());
Assert.Contains(recorded, m => m.Contains("unwatched", StringComparison.Ordinal));
}
[Fact]
public async Task Log_TracedWithException_CarriesMessageAndDetail()
{
// §4: the attached exception rides errorMessage/errorDetail, exactly as the failure
// rows do, so the Log tab can render the type + stack.
var sink = new List<AuditEvent>();
var route = NewRoute().WithTraceSink(sink.Add);
var method = new ApiMethod(
"boom", "Log.Warn(\"x\", new InvalidOperationException(\"e\")); return 1;")
{ Id = 7, TimeoutSeconds = 10 };
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
parentExecutionId: Guid.NewGuid(), trace: true);
Assert.True(result.Success, result.ErrorMessage);
var details = AuditDetailsCodec.Deserialize(Assert.Single(ScriptLogRows(sink)).DetailsJson);
Assert.Equal("x", details.RequestSummary);
Assert.Equal("e", details.ErrorMessage);
Assert.StartsWith("System.InvalidOperationException", details.ErrorDetail);
Assert.Equal("warn", ScriptTraceExtra.TryParse(details.Extra)!.LogLevel);
}
[Fact]
public async Task Log_PastMaxLogLinesPerRun_DropsAndEmitsOneClosingRuntimeWarning()
{
// D7: the per-run cap bounds the ROWS (the ILogger still sees every line); the run
// ends with one runtime-sourced warn row naming how many lines were dropped.
var sink = new List<AuditEvent>();
var route = NewRoute().WithTraceSink(sink.Add);
var services = new ServiceCollection();
services.Configure<InboundApiOptions>(o => o.MaxLogLinesPerRun = 2);
using var provider = services.BuildServiceProvider();
var executor = new InboundScriptExecutor(NullLogger<InboundScriptExecutor>.Instance, provider);
var method = new ApiMethod(
"chatty", "Log.Info(\"a\"); Log.Info(\"b\"); Log.Info(\"c\"); Log.Info(\"d\"); return 1;")
{ Id = 7, TimeoutSeconds = 10 };
var result = await executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
parentExecutionId: Guid.NewGuid(), trace: true);
Assert.True(result.Success, result.ErrorMessage);
var rows = ScriptLogRows(sink)
.Select(e => AuditDetailsCodec.Deserialize(e.DetailsJson))
.Select(d => (Details: d, Extra: ScriptTraceExtra.TryParse(d.Extra)!))
.ToList();
Assert.Equal(3, rows.Count);
Assert.Equal(new[] { "a", "b" }, rows.Where(r => r.Extra.LogSource == "script").Select(r => r.Details.RequestSummary));
var closing = Assert.Single(rows, r => r.Extra.LogSource == "runtime");
Assert.Equal("warn", closing.Extra.LogLevel);
Assert.Contains("2", closing.Details.RequestSummary);
}
private RouteHelper NewRoute() =>
new(Substitute.For<IInstanceLocator>(), Substitute.For<IInstanceRouter>());
private static List<AuditEvent> ScriptLogRows(IEnumerable<AuditEvent> events) =>
events.Where(e => AuditDetailsCodec.Deserialize(e.DetailsJson).Kind == nameof(AuditKind.ScriptLog)).ToList();
// Minimal ILogger that keeps every formatted message so a test can prove the node
// logger saw a line even when nothing was audited.
private sealed class RecordingLogger<T>(List<string> messages) : ILogger<T>
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (messages) messages.Add(formatter(state, exception));
}
}
private sealed class CompileLogCounter
{
public int CompilationFailures;
@@ -0,0 +1,60 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
/// <summary>
/// Flow Debugger step logs (design 2026-09-09 D7): the central <c>Log</c> global's per-message cap.
/// Final review M2 — the site runtime's <c>ScriptLogger.Clip</c> already backed off a char when the
/// cap landed between the halves of a surrogate pair; this side did not, so an emoji straddling the
/// boundary left a LONE surrogate on the row (which breaks JSON serialization further down the pipe).
/// </summary>
public class InboundScriptLoggerTests
{
private static (InboundScriptLogger Logger, List<AuditEvent> Rows) MakeTraced(int maxLines = 200)
{
var rows = new List<AuditEvent>();
var logger = new InboundScriptLogger(
NullLogger.Instance, rows.Add, maxLines, "DelmiaRecipeDownload", Guid.NewGuid(), "central-a");
return (logger, rows);
}
[Fact]
public void ASurrogatePairStraddlingTheCap_IsDroppedWhole()
{
var (logger, rows) = MakeTraced();
var straddling = new string('x', InboundScriptLogger.MaxMessageChars - 1) + "🚀" + new string('y', 10);
logger.Info(straddling);
var message = Assert.Single(rows).AsRow().RequestSummary!;
Assert.Equal(InboundScriptLogger.MaxMessageChars, message.Length);
Assert.EndsWith("…", message);
Assert.DoesNotContain(message, static c => char.IsSurrogate(c));
}
[Fact]
public void AnOverlongMessageWithNoSurrogateAtTheBoundary_KeepsTheFullCap()
{
var (logger, rows) = MakeTraced();
logger.Info(new string('x', InboundScriptLogger.MaxMessageChars + 500));
var message = Assert.Single(rows).AsRow().RequestSummary!;
Assert.Equal(InboundScriptLogger.MaxMessageChars + 1, message.Length); // cap + the ellipsis
Assert.Equal(new string('x', InboundScriptLogger.MaxMessageChars) + "…", message);
}
[Fact]
public void AMessageExactlyAtTheCap_PassesThroughUnclipped()
{
var (logger, rows) = MakeTraced();
var exact = new string('x', InboundScriptLogger.MaxMessageChars);
logger.Info(exact);
Assert.Equal(exact, Assert.Single(rows).AsRow().RequestSummary);
}
}
@@ -265,6 +265,74 @@ public class AuditWriteMiddlewareTests
Assert.Equal("kaboom", evt.ErrorMessage);
}
// ---------------------------------------------------------------------
// 4b. Error detail — the endpoint stashes a method script's exception on
// HttpContext.Items (the HTTP body only ever carries the sanitised
// message), and the middleware lifts it onto the row.
// ---------------------------------------------------------------------
[Fact]
public async Task ScriptErrorDetail_StashedOnItems_LandsOnTheRow()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
const string detail = "System.InvalidOperationException: real reason\n at Script.<Main>";
var mw = CreateMiddleware(hc =>
{
hc.Items[AuditWriteMiddleware.ErrorDetailItemKey] = detail;
hc.Response.StatusCode = 500;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Equal(AuditStatus.Failed, evt.Status);
Assert.Equal(detail, evt.ErrorDetail);
}
[Fact]
public async Task NoErrorDetailStashed_LeavesErrorDetailNull()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(hc =>
{
hc.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.Null(evt.ErrorDetail);
}
[Fact]
public async Task ThrownException_ErrorDetail_WinsOverTheStashedItem()
{
// An exception that ESCAPED the pipeline is the richer, more authoritative
// source: it describes the failure the framework error path is about to report.
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var boom = new InvalidOperationException("kaboom");
var mw = CreateMiddleware(hc =>
{
hc.Items[AuditWriteMiddleware.ErrorDetailItemKey] = "stashed detail";
throw boom;
}, writer);
await Assert.ThrowsAsync<InvalidOperationException>(() => mw.InvokeAsync(ctx));
var evt = Assert.Single(writer.Events);
// Compared by content, not by an exact ToString(): the re-throw keeps appending
// stack frames after the row was built, so the captured text is a prefix of what
// the exception reads as by the time the assertion runs.
Assert.NotNull(evt.ErrorDetail);
Assert.StartsWith(boom.GetType().FullName + ": kaboom", evt.ErrorDetail);
Assert.DoesNotContain("stashed detail", evt.ErrorDetail);
}
// ---------------------------------------------------------------------
// 5. Actor resolution — the endpoint handler stashes the API key name
// AFTER successful auth so the middleware can pick it up from
@@ -152,6 +152,30 @@ public class FlowDebugRunnerTests
Assert.Same(siteRun, result.Events[2]);
}
[Fact]
public async Task RunMethod_ScriptThrows_InboundRowCarriesErrorDetail()
{
// Step-logs D3: InboundScriptExecutor turns an unhandled script exception into a
// SCRIPT_ERROR InboundScriptResult whose ErrorDetail is the exception's own ToString() —
// never surfaced to the HTTP caller, but the Flow Debugger's inbound node reads it off
// the written audit row.
var method = new ApiMethod("M", "x") { Id = 21, TimeoutSeconds = 5 };
_apiRepo.GetApiMethodByIdAsync(21, Arg.Any<CancellationToken>()).Returns(method);
_executor.RegisterHandler("M", _ => throw new InvalidOperationException("boom"));
var result = await Runner().RunMethodAsync(_services.BuildServiceProvider(),
new FlowDebugRunMethodCommand(21, null), Deployer, CancellationToken.None);
Assert.False(result.Completed);
Assert.Equal("SCRIPT_ERROR", result.ErrorCode);
var inbound = Assert.Single(_central, e => e.AsRow().Kind == AuditKind.InboundRequest).AsRow();
Assert.NotNull(inbound.ErrorDetail);
Assert.StartsWith("System.InvalidOperationException: boom", inbound.ErrorDetail);
// The returned copy carries the same detail (redacted, same as every other field).
var returnedInbound = result.Events[0].AsRow();
Assert.StartsWith("System.InvalidOperationException: boom", returnedInbound.ErrorDetail);
}
[Fact]
public async Task RunMethod_UnknownMethod_IsNotFound()
{
@@ -226,6 +250,27 @@ public class FlowDebugRunnerTests
Assert.Single(result.Events); // just the synthesized write row
}
[Fact]
public async Task RunValueChange_AsksTheSiteForItsFullPage_NotTheOldFiveHundredRowCap()
{
// Final review I1: the site serves ORDER BY OccurredAtUtc ASC LIMIT, so the page is the
// OLDEST rows in the window. With step logs a single traced run can emit up to 201
// ScriptLog rows, so a 500-row request could push a later spawned run's ScriptRun row off
// the end of the page and make this loop report a false NO_TRIGGER_WITHIN_WINDOW. Ask for
// the site's own ceiling (SiteStreamGrpcServer.MaxTraceEventsPerPull) instead.
var (_, _, puller) = WireValueChange();
var maxima = new List<int>();
puller.PullAsync("site-a", Arg.Any<IReadOnlyCollection<Guid>>(), Arg.Any<DateTime>(), Arg.Do<int>(maxima.Add), Arg.Any<CancellationToken>())
.Returns(Array.Empty<AuditEvent>());
await Runner().RunValueChangeAsync(_services.BuildServiceProvider(),
new FlowDebugRunValueChangeCommand(5, "Status", "Running", WaitSeconds: 3), Deployer, CancellationToken.None);
Assert.NotEmpty(maxima);
Assert.All(maxima, m => Assert.Equal(2000, m));
Assert.Equal(2000, FlowDebugRunner.PullTraceEventsMax);
}
[Fact]
public async Task RunValueChange_OutOfScopeSite_IsRefused()
{
@@ -389,6 +434,12 @@ public class FlowDebugRunnerTests
executionId: Guid.NewGuid(), target: "work") with
{ DetailsJson = detailsJson };
/// <summary>A <c>ScriptLog</c> row — the low-priority lane <see cref="FlowStepProjector.IsLogLine"/> recognizes.</summary>
private static AuditEvent LogRow() =>
ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, AuditKind.ScriptLog, AuditStatus.Delivered,
executionId: Guid.NewGuid(), target: "work");
[Fact]
public async Task RunMethod_SinkEvents_AreRedacted_WhenARedactorIsRegistered()
{
@@ -454,6 +505,37 @@ public class FlowDebugRunnerTests
Assert.True(result.Truncated);
Assert.Equal(501, result.Events.Count); // inbound row + the 500 kept
Assert.False(result.LogsTruncated); // these are full-priority step rows, not log lines
}
[Fact]
public async Task RunMethod_NoLogRows_LogsTruncatedIsFalse()
{
WireSinkPushingMethod(33, [TraceRow("{}")]);
var result = await Runner().RunMethodAsync(_services.BuildServiceProvider(),
new FlowDebugRunMethodCommand(33, null), Deployer, CancellationToken.None);
Assert.True(result.Completed, result.Error);
Assert.False(result.LogsTruncated);
Assert.False(result.Truncated);
}
[Fact]
public async Task RunMethod_OverLowPriorityReserve_ReportsLogsTruncated_ButNotTruncated()
{
// FlowTraceCollector reserves the last LowPriorityReserveEvents (50) of DefaultMaxEvents
// (500) for full-priority step rows — a low-priority (ScriptLog) Add stops at 450 and
// sets LogsTruncated, leaving Truncated (the step-row fidelity signal) untouched.
WireSinkPushingMethod(34, Enumerable.Range(0, 451).Select(_ => LogRow()).ToList());
var result = await Runner().RunMethodAsync(_services.BuildServiceProvider(),
new FlowDebugRunMethodCommand(34, null), Deployer, CancellationToken.None);
Assert.True(result.Completed, result.Error);
Assert.True(result.LogsTruncated);
Assert.False(result.Truncated);
Assert.Equal(451, result.Events.Count); // inbound row + the 450 log rows kept
}
// --- Flow Debugger script entry (design 2026-09-08) ---
@@ -139,6 +139,25 @@ public class RoslynScriptCompilerTests
Assert.Empty(diagnostics);
}
[Fact]
public void CompilesCleanly_ForTheLogGlobal()
{
// Flow Debugger step logs (design 2026-09-09 D4): `Log` is a script-reachable
// global on the runtime ScriptGlobals AND on ScriptRuntimeContext, so both the
// top-level `Log` and `Instance.Log` must bind at design time — otherwise a
// script that logs would pass the editor but fail the deploy gate.
const string code = """
Log.Info("a");
Log.Warn("b", new System.Exception("x"));
Log.Error("c");
Log.Debug("d");
Instance.Log.Info("e");
""";
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
Assert.Empty(diagnostics);
}
[Fact]
public void Compile_TwiceWithSharedResolver_StillCompilesCleanly()
{
@@ -212,6 +212,24 @@ public class ScriptTrustValidatorTests
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
[Fact]
public void Allows_TheLogGlobal()
{
// Flow Debugger step logs (design 2026-09-09 D4). The trust model is a DENY-list
// over API roots, not an allow-list of context members, so the `Log` global needs
// no policy entry — including the overload that attaches a System.Exception, which
// is a permitted BCL type. This pins that a logging script is not blocked.
const string code = """
Log.Info("x");
Log.Debug("d");
Log.Warn("w", new System.Exception("boom"));
Log.Error("e");
Instance.Log.Info("via instance");
""";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
[Fact]
public void ForbiddenScopes_DoNotCover_TheCommonsScriptSurface()
{
@@ -0,0 +1,89 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
/// <summary>
/// Final review M6: an alarm on-trigger run's <c>ScriptRuntimeContext</c> was built without a
/// <c>sourceScript</c>, so the context's <c>_logTarget</c> — which names the script on every
/// <c>Log.*</c> line and on the run's trace rows — fell back to the INSTANCE name. The launcher now
/// passes the same <c>AlarmActor:{alarmName}</c> source key it already uses for this alarm's site
/// events, from which the context derives the alarm's own name.
/// </summary>
public class AlarmScriptLogTargetTests : TestKit, IDisposable
{
private sealed record Entry(LogLevel Level, string Message);
/// <summary>A minimal recording logger — the alarm path hands its own ILogger down to the script logger.</summary>
private sealed class RecordingLogger<T> : ILogger<T>
{
public List<Entry> Entries { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add(new Entry(level, formatter(state, exception)));
}
}
private readonly SharedScriptLibrary _sharedLibrary = new(
new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
NullLogger<SharedScriptLibrary>.Instance);
void IDisposable.Dispose() => Shutdown();
[Fact]
public void AlarmOnTriggerRun_AttributesItsLogLinesToTheALARM_NotJustTheInstance()
{
var options = new SiteRuntimeOptions
{
ScriptExecutionTimeoutSeconds = 10,
MaxScriptCallDepth = 10,
};
var scriptOptions = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly, typeof(ScriptGlobals).Assembly)
.WithImports("System", "System.Threading.Tasks");
var compiled = CSharpScript.Create<object?>("Log.Info(\"alarm handler ran\"); return null;", scriptOptions, typeof(ScriptGlobals));
compiled.Compile();
var logger = new RecordingLogger<AlarmActor>();
using var scheduler = new ScriptExecutionScheduler(1);
var config = new ResolvedAlarm
{
CanonicalName = "HighTemp",
TriggerType = "ValueMatch",
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
PriorityLevel = 100,
};
var alarm = ActorOfAsTestActorRef<AlarmActor>(
Props.Create(() => new AlarmActor(
"HighTemp", "Inst1", CreateTestProbe().Ref, config, compiled, _sharedLibrary, options,
logger, null, null, null, null, null, scheduler, null)),
"alarm-logtarget-" + Guid.NewGuid().ToString("N"));
alarm.Tell(new AttributeValueChanged("Inst1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
AwaitAssert(
() =>
{
Entry[] snapshot;
lock (logger.Entries) snapshot = [.. logger.Entries];
// "[script] {Instance}/{Script} {ExecutionId}: {Message}" — the script half is the
// alarm's name because the launcher passed "AlarmActor:HighTemp" as the source.
Assert.Contains(snapshot, e => e.Message.StartsWith("[script] Inst1/HighTemp ", StringComparison.Ordinal)
&& e.Message.EndsWith(": alarm handler ran", StringComparison.Ordinal));
},
TimeSpan.FromSeconds(15));
}
}
@@ -0,0 +1,322 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Flow Debugger step logs (design 2026-09-09 D4/D5/D7): the <c>Log</c> global's two obligations —
/// ALWAYS forward to the node logger, emit a trace row ONLY when traced — plus the per-run line cap,
/// the per-message character cap, step attribution through <see cref="TraceStepScope"/>, and the
/// "never throws into the script" rule.
///
/// <para>Deliberately built without a <c>ScriptRuntimeContext</c>: the row emitter is a delegate
/// precisely so this surface is testable on its own.</para>
/// </summary>
public class ScriptLoggerTests
{
/// <summary>One entry the logger under test forwarded to the node logger.</summary>
private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception);
/// <summary>A minimal recording <see cref="ILogger"/> — clearer here than a mocking framework.</summary>
private sealed class RecordingLogger(bool enabled = true) : ILogger
{
public List<LogEntry> Entries { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => enabled;
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
Entries.Add(new LogEntry(level, formatter(state, exception), exception));
}
/// <summary>One row the logger under test handed to its emitter.</summary>
private sealed record Row(string Level, string Source, string Message, Exception? Exception, Guid? StepEventId);
private static (ScriptLogger Logger, RecordingLogger Node, List<Row> Rows) MakeTraced(int maxLines = 200)
{
var node = new RecordingLogger();
var rows = new List<Row>();
var logger = new ScriptLogger(
node,
(level, source, message, exception, stepEventId) => rows.Add(new Row(level, source, message, exception, stepEventId)),
maxLines,
instance: "CvdReactor01",
script: "OnHeartbeat",
executionId: Guid.NewGuid());
return (logger, node, rows);
}
[Fact]
public void UntracedRun_ForwardsToTheNodeLogger_AndEmitsNoRow()
{
// D5: an untraced run is an ordinary logging surface — the author's messages are never lost,
// but nothing is audited.
var node = new RecordingLogger();
var logger = new ScriptLogger(node, emit: null, maxLines: 200, "CvdReactor01", "OnHeartbeat", Guid.NewGuid());
logger.Info("hello");
logger.Warn("careful");
Assert.Equal(2, node.Entries.Count);
Assert.Equal(LogLevel.Information, node.Entries[0].Level);
Assert.Contains("hello", node.Entries[0].Message);
Assert.Equal(LogLevel.Warning, node.Entries[1].Level);
}
[Fact]
public void TracedRun_EmitsOneScriptSourcedRowPerLine()
{
var (logger, node, rows) = MakeTraced();
logger.Info("msg");
Assert.Single(node.Entries);
var row = Assert.Single(rows);
Assert.Equal(("info", "script", "msg", (Exception?)null, (Guid?)null), (row.Level, row.Source, row.Message, row.Exception, row.StepEventId));
}
[Fact]
public void EveryLevel_MapsToItsWireName()
{
var (logger, node, rows) = MakeTraced();
logger.Debug("d");
logger.Info("i");
logger.Warn("w");
logger.Error("e");
Assert.Equal(["debug", "info", "warn", "error"], rows.Select(r => r.Level));
Assert.Equal(
[LogLevel.Debug, LogLevel.Information, LogLevel.Warning, LogLevel.Error],
node.Entries.Select(e => e.Level));
}
[Fact]
public void LineInsideAStepScope_IsAttributedToThatStep_AndTheScopeUnwinds()
{
// D6: the ambient scope is how a line written inside a helper body names its step node.
var (logger, _, rows) = MakeTraced();
var stepId = Guid.NewGuid();
using (TraceStepScope.Enter(stepId))
{
Assert.Equal(stepId, TraceStepScope.Current);
logger.Info("inside");
}
Assert.Null(TraceStepScope.Current);
logger.Info("outside");
Assert.Equal(stepId, rows[0].StepEventId);
Assert.Null(rows[1].StepEventId);
}
[Fact]
public void NestedStepScopes_Unwind_ToTheEnclosingStep()
{
// A helper calling another helper: the inner step owns its own lines, and the outer step
// gets its attribution back the moment the inner scope closes.
var (logger, _, rows) = MakeTraced();
var outer = Guid.NewGuid();
var inner = Guid.NewGuid();
using (TraceStepScope.Enter(outer))
{
using (TraceStepScope.Enter(inner))
{
Assert.Equal(inner, TraceStepScope.Current);
logger.Info("inner");
}
Assert.Equal(outer, TraceStepScope.Current);
logger.Info("outer");
}
Assert.Null(TraceStepScope.Current);
Assert.Equal(inner, rows[0].StepEventId);
Assert.Equal(outer, rows[1].StepEventId);
}
[Fact]
public void PastThePerRunCap_LinesAreDropped_AndFlushDroppedNamesTheCount_Once()
{
// D7: bounded per run; the closing row bypasses the cap because it is the only signal that
// anything was lost — and it is idempotent so a double flush cannot duplicate it.
var (logger, _, rows) = MakeTraced(maxLines: 3);
for (var i = 0; i < 5; i++)
{
logger.Info($"line {i}");
}
Assert.Equal(3, rows.Count);
Assert.Equal(["line 0", "line 1", "line 2"], rows.Select(r => r.Message));
logger.FlushDropped();
Assert.Equal(4, rows.Count);
var closing = rows[^1];
Assert.Equal("warn", closing.Level);
Assert.Equal("runtime", closing.Source);
// M3: worded identically to the central InboundScriptLogger closing row.
Assert.Contains("2 further log line(s) were not captured", closing.Message);
Assert.Contains("ScadaBridge:SiteRuntime:MaxLogLinesPerRun = 3", closing.Message);
Assert.Null(closing.StepEventId);
logger.FlushDropped();
Assert.Equal(4, rows.Count);
}
[Fact]
public void FlushDropped_EmitsNothing_WhenNoLineWasDropped()
{
var (logger, _, rows) = MakeTraced(maxLines: 3);
logger.Info("only one");
logger.FlushDropped();
Assert.Single(rows);
}
[Fact]
public void MessagesLongerThanTheCap_AreClippedWithAnEllipsis()
{
var (logger, _, rows) = MakeTraced();
logger.Info(new string('x', 2_500));
var message = Assert.Single(rows).Message;
Assert.Equal(ScriptLogger.MaxMessageChars + 1, message.Length);
Assert.EndsWith("…", message);
}
[Fact]
public void AMessageExactlyAtTheCap_PassesThroughUnclipped()
{
var (logger, _, rows) = MakeTraced();
var exact = new string('x', ScriptLogger.MaxMessageChars);
logger.Info(exact);
Assert.Equal(exact, Assert.Single(rows).Message);
}
[Fact]
public void ASurrogatePairStraddlingTheCap_IsDroppedWhole()
{
// Cutting between the halves would leave a lone surrogate, which breaks JSON serialization
// of the audit row further down the pipe.
var (logger, _, rows) = MakeTraced();
var straddling = new string('x', ScriptLogger.MaxMessageChars - 1) + "🚀" + new string('y', 10);
logger.Info(straddling);
var message = Assert.Single(rows).Message;
Assert.Equal(ScriptLogger.MaxMessageChars, message.Length);
Assert.EndsWith("…", message);
Assert.DoesNotContain(message, static c => char.IsSurrogate(c));
}
[Fact]
public void AThrowingEmitter_NeverReachesTheScript_AndTheNodeLoggerStillGotTheLine()
{
// The whole feature is a debug observer: a broken observer must not fail a production run.
var node = new RecordingLogger();
var logger = new ScriptLogger(
node,
(_, _, _, _, _) => throw new InvalidOperationException("collector is broken"),
maxLines: 200,
"CvdReactor01",
"OnHeartbeat",
Guid.NewGuid());
logger.Info("still logged");
Assert.Single(node.Entries);
Assert.Contains("still logged", node.Entries[0].Message);
}
[Fact]
public void AThrowingNodeLogger_NeverReachesTheScript_AndTheRowIsStillEmitted()
{
var rows = new List<Row>();
var logger = new ScriptLogger(
new ThrowingLogger(),
(level, source, message, exception, stepEventId) => rows.Add(new Row(level, source, message, exception, stepEventId)),
maxLines: 200,
"CvdReactor01",
"OnHeartbeat",
Guid.NewGuid());
logger.Warn("sink is down");
Assert.Single(rows);
}
/// <summary>A node logger whose sink fails, standing in for a wedged Serilog sink.</summary>
private sealed class ThrowingLogger : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
throw new InvalidOperationException("sink is down");
}
[Fact]
public void AnAttachedException_ReachesBothTheNodeLoggerAndTheRow()
{
var (logger, node, rows) = MakeTraced();
var boom = new InvalidOperationException("boom");
logger.Error("x", boom);
Assert.Same(boom, node.Entries[0].Exception);
Assert.Same(boom, Assert.Single(rows).Exception);
}
[Fact]
public void RuntimeLines_AreRowOnly_AndCarryTheRuntimeSource()
{
// The capturing logger has already written the entry to the node logger; re-logging it here
// would double it (and loop straight back through the decorator).
var (logger, node, rows) = MakeTraced();
logger.Runtime("warn", "tag write timed out", null);
Assert.Empty(node.Entries);
var row = Assert.Single(rows);
Assert.Equal("warn", row.Level);
Assert.Equal("runtime", row.Source);
}
[Fact]
public void RuntimeLines_ShareThePerRunCapWithScriptLines()
{
var (logger, _, rows) = MakeTraced(maxLines: 2);
logger.Info("script line");
logger.Runtime("info", "runtime line", null);
logger.Runtime("info", "dropped", null);
Assert.Equal(2, rows.Count);
logger.FlushDropped();
Assert.Contains("1 further log line(s) were not captured", rows[^1].Message);
}
[Fact]
public void MaxLinesMustBePositive()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ScriptLogger(new RecordingLogger(), emit: null, maxLines: 0, "i", "s", null));
}
[Fact]
public void TheNodeLoggerIsRequired()
{
Assert.Throws<ArgumentNullException>(() => new ScriptLogger(null!, emit: null, maxLines: 1, "i", "s", null));
}
}
@@ -0,0 +1,159 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Flow Debugger step logs (design 2026-09-09 D3/D7) at the launcher boundary: the
/// <c>ScriptRun</c> anchor row a traced run returns inline carries the failing run's full
/// exception detail (and deliberately none for a timeout), and the run's log lines are bounded
/// by <c>SiteRuntimeOptions.MaxLogLinesPerRun</c> with one closing row naming the drops — all
/// of it emitted BEFORE the anchor, so the Flow Debugger reads a run's lines in order.
/// </summary>
public class ScriptRunLauncherTraceTests : TestKit, IDisposable
{
/// <summary>Own pool per class (#18 seam), so a wedged body cannot strand the process-wide one.</summary>
private readonly ScriptExecutionScheduler _scheduler = new(4);
private readonly SharedScriptLibrary _sharedLibrary;
public ScriptRunLauncherTraceTests()
=> _sharedLibrary = new SharedScriptLibrary(
new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance),
NullLogger<SharedScriptLibrary>.Instance);
void IDisposable.Dispose()
{
Shutdown();
_scheduler.Dispose();
}
// ── helpers ──────────────────────────────────────────────────────────────────
private static Script<object?> CompileScript(string code)
{
var scriptOptions = ScriptOptions.Default
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly,
typeof(ScriptGlobals).Assembly)
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
script.Compile();
return script;
}
private static AuditRowProjection.AuditRowValues Row(AuditEvent evt) => evt.AsRow();
private static ScriptTraceExtra Extra(AuditEvent evt) => ScriptTraceExtra.TryParse(Row(evt).Extra)!;
/// <summary>Runs one traced script to completion and returns the reply's inline trace rows.</summary>
private ScriptCallResult RunTraced(string body, SiteRuntimeOptions options)
{
var caller = CreateTestProbe();
ScriptRunLauncher.LaunchScript(
scriptName: "Runner",
instanceName: "Inst1",
compiledScript: CompileScript(body),
parameters: null,
callDepth: 0,
instanceActor: CreateTestProbe().Ref,
sharedScriptLibrary: _sharedLibrary,
options: options,
replyTo: caller.Ref,
correlationId: "corr-1",
completionTarget: CreateTestProbe().Ref,
siteCommunicationActor: null,
logger: NullLogger.Instance,
scope: ScriptScope.Root,
runId: 1,
healthCollector: null,
serviceProvider: null,
parentExecutionId: null,
executionTimeoutSeconds: null,
scheduler: _scheduler,
trace: true,
triggerExtra: null);
return caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(20));
}
private static SiteRuntimeOptions Options(int timeoutSeconds = 30, int maxLogLines = 200) => new()
{
MaxScriptCallDepth = 10,
ScriptExecutionTimeoutSeconds = timeoutSeconds,
StuckScriptGraceMs = 30000,
MaxLogLinesPerRun = maxLogLines,
};
private static AuditEvent ScriptRunRow(ScriptCallResult result)
=> Assert.Single(result.TraceEvents!, e => Row(e).Kind == AuditKind.ScriptRun);
// ── (a) a throwing run carries the exception's full detail ───────────────────
[Fact]
public void ThrowingTracedRun_ScriptRunRow_CarriesTheFullExceptionDetail()
{
var result = RunTraced("throw new InvalidOperationException(\"boom\");", Options());
Assert.False(result.Success);
var row = Row(ScriptRunRow(result));
Assert.Equal(AuditStatus.Failed, row.Status);
Assert.Contains("boom", row.ErrorMessage);
Assert.NotNull(row.ErrorDetail);
// Type name first, then the stack — the whole point of D3 over the one-line message.
Assert.StartsWith("System.InvalidOperationException", row.ErrorDetail);
Assert.Contains(" at ", row.ErrorDetail);
}
// ── (b) a timeout carries NO detail (the stack would be the launcher's) ──────
[Fact]
public void TimedOutTracedRun_ScriptRunRow_CarriesNoExceptionDetail()
{
var result = RunTraced(
"while (true) { await Task.Delay(25, CancellationToken); }",
Options(timeoutSeconds: 1));
Assert.False(result.Success);
var row = Row(ScriptRunRow(result));
Assert.Equal(AuditStatus.Failed, row.Status);
Assert.Contains("timed out", row.ErrorMessage);
Assert.Null(row.ErrorDetail);
}
// ── (c) the per-run line cap, its closing row, and emission order ────────────
[Fact]
public void TracedRun_CapsLogLines_AndEmitsTheClosingRowBeforeTheAnchor()
{
var result = RunTraced(
"Log.Info(\"a\"); Log.Info(\"b\"); Log.Info(\"c\"); Log.Info(\"d\"); Log.Info(\"e\"); return null;",
Options(maxLogLines: 2));
Assert.True(result.Success);
var rows = result.TraceEvents!;
var logs = rows.Where(e => Row(e).Kind == AuditKind.ScriptLog).ToList();
var scriptLines = logs.Where(e => Extra(e).LogSource == "script").ToList();
Assert.Equal(2, scriptLines.Count);
Assert.Equal(["a", "b"], scriptLines.Select(e => Row(e).RequestSummary));
// One closing runtime warning naming the three dropped lines.
var closing = Assert.Single(logs, e => Extra(e).LogSource == "runtime");
Assert.Equal("warn", Extra(closing).LogLevel);
Assert.Contains("3 further log line(s) were not captured", Row(closing).RequestSummary);
// Every log row — the closing one included — precedes the run's anchor row.
var anchorIndex = rows.ToList().FindIndex(e => Row(e).Kind == AuditKind.ScriptRun);
Assert.All(logs, e => Assert.True(rows.ToList().IndexOf(e) < anchorIndex));
Assert.Equal(logs.Count, anchorIndex);
}
}
@@ -145,7 +145,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
IExternalSystemClient? externalSystemClient = null,
IDatabaseGateway? databaseGateway = null,
ICachedCallTelemetryForwarder? cachedForwarder = null,
StoreAndForwardService? storeAndForward = null)
StoreAndForwardService? storeAndForward = null,
int maxLogLinesPerRun = 200)
=> new(
instanceActor, NewLibrary(),
currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5),
@@ -155,7 +156,8 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
siteId: "site-a", sourceScript: "ScriptActor:A", auditWriter: writer,
cachedForwarder: cachedForwarder,
executionId: executionId, parentExecutionId: parentExecutionId, sourceNode: "node-a",
isTraced: traced, traceCollector: collector);
isTraced: traced, traceCollector: collector,
maxLogLinesPerRun: maxLogLinesPerRun);
[Fact]
public async Task SetAttribute_WhenTraced_EmitsAttributeWriteRow_AndPassesTraceOnCommand()
@@ -556,4 +558,268 @@ public class ScriptRuntimeContextTraceEmissionTests : TestKit
var written = Assert.Single(writer.Events);
Assert.Equal(AuditKind.ApiCall, Row(written).Kind);
}
// ---- Step logs: the Log global and runtime-line attribution (design 2026-09-09 D4-D7) ----
/// <summary>Fails every write so the context's own best-effort "write failed" warning fires —
/// the runtime line test (c) needs a genuine <c>_logger.LogWarning</c> inside a step body.</summary>
private sealed class ThrowingAuditWriter : IAuditWriter
{
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
=> throw new InvalidOperationException("writer down");
}
[Fact]
public void Log_WhenTraced_EmitsOneScriptLogRow_ToTheCollectorAndTheWriter()
{
var writer = new CapturingAuditWriter();
var collector = new FlowTraceCollector();
var ctx = CreateContext(CreateTestProbe().Ref, writer, traced: true, collector);
ctx.Log.Info("x");
var evt = Assert.Single(writer.Events);
Assert.Same(evt, Assert.Single(collector.Snapshot()));
var row = Row(evt);
Assert.Equal(AuditChannel.ScriptTrace, row.Channel);
Assert.Equal(AuditKind.ScriptLog, row.Kind);
Assert.Equal(AuditStatus.Delivered, row.Status);
Assert.Equal("x", row.RequestSummary);
Assert.Equal(ctx.ExecutionId, row.ExecutionId);
Assert.Equal("site-a", row.SourceSiteId);
Assert.Equal("node-a", evt.SourceNode);
Assert.Equal(InstanceName, row.SourceInstanceId);
// Same bare script name the ScriptRun anchor row carries, so the builder's fallback matches.
Assert.Equal("A", evt.Target);
var extra = ScriptTraceExtra.TryParse(row.Extra)!;
Assert.Equal("info", extra.LogLevel);
Assert.Equal("script", extra.LogSource);
Assert.Null(extra.StepEventId);
Assert.Equal(1, extra.Seq);
}
[Fact]
public void Log_WhenNotTraced_ReachesNeitherTheWriterNorTheCollector()
{
var writer = new CapturingAuditWriter();
var collector = new FlowTraceCollector();
var ctx = CreateContext(CreateTestProbe().Ref, writer, traced: false, collector);
ctx.Log.Info("x");
ctx.Log.Error("boom", new InvalidOperationException("e"));
ctx.Log.FlushDropped();
Assert.Empty(writer.Events);
Assert.Equal(0, collector.Count);
Assert.False(collector.LogsTruncated);
}
[Fact]
public async Task RuntimeWarning_InsideATracedWrite_IsAttributedToThatWriteStep()
{
var probe = CreateTestProbe();
var collector = new FlowTraceCollector();
// The throwing writer makes EmitTrace log its best-effort "write failed" warning while the
// AttributeWrite step scope is open — exactly the runtime line D6 exists to attribute.
//
// It also PINS THE ANTI-RECURSION PROPERTY: EmitLog's own write fails against this writer
// too, so its "log-line write failed" warning must go to the UNDECORATED logger. Routed
// through the capturing decorator instead, each warning would emit another row whose write
// fails again — an unbounded loop, and this test would hang or blow the row count rather
// than see exactly one ScriptLog row.
var ctx = CreateContext(probe.Ref, new ThrowingAuditWriter(), traced: true, collector);
var call = ctx.SetAttribute("Setpoint", "42");
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow));
await call;
var rows = collector.Snapshot();
var write = rows.Single(e => Row(e).Kind == AuditKind.AttributeWrite);
var log = rows.Single(e => Row(e).Kind == AuditKind.ScriptLog);
var extra = ScriptTraceExtra.TryParse(Row(log).Extra)!;
Assert.Equal("runtime", extra.LogSource);
Assert.Equal("warn", extra.LogLevel);
Assert.Equal(write.EventId, extra.StepEventId);
}
[Fact]
public void Log_WithException_CarriesTheMessageAndTheFullDetail()
{
var writer = new CapturingAuditWriter();
var ctx = CreateContext(CreateTestProbe().Ref, writer, traced: true, new FlowTraceCollector());
ctx.Log.Warn("m", new InvalidOperationException("e"));
var row = Row(Assert.Single(writer.Events));
Assert.Equal("m", row.RequestSummary);
Assert.Equal("e", row.ErrorMessage);
Assert.StartsWith("System.InvalidOperationException", row.ErrorDetail);
Assert.Equal("warn", ScriptTraceExtra.TryParse(row.Extra)!.LogLevel);
}
[Fact]
public async Task LogRows_RideTheLowPriorityLane_SoStepRowsKeepTheirReserve()
{
var probe = CreateTestProbe();
var writer = new CapturingAuditWriter();
// Reserve (50) + 1 => exactly one low-priority slot, while step rows keep the full cap.
var collector = new FlowTraceCollector(maxEvents: FlowTraceCollector.LowPriorityReserveEvents + 1);
var ctx = CreateContext(probe.Ref, writer, traced: true, collector);
ctx.Log.Info("first");
ctx.Log.Info("second");
Assert.Single(collector.Snapshot());
Assert.True(collector.LogsTruncated);
Assert.False(collector.Truncated);
var call = ctx.SetAttribute("Setpoint", "42");
var cmd = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
probe.Reply(new SetStaticAttributeResponse(cmd.CorrelationId, InstanceName, "Setpoint", true, null, DateTimeOffset.UtcNow));
await call;
Assert.Equal(2, collector.Count);
Assert.Contains(collector.Snapshot(), e => Row(e).Kind == AuditKind.AttributeWrite);
}
[Fact]
public void FlushDropped_EmitsTheClosingRow_OnceThePerRunCapIsExceeded()
{
var writer = new CapturingAuditWriter();
var collector = new FlowTraceCollector();
var ctx = CreateContext(CreateTestProbe().Ref, writer, traced: true, collector, maxLogLinesPerRun: 2);
ctx.Log.Info("1");
ctx.Log.Info("2");
ctx.Log.Info("3");
ctx.Log.FlushDropped();
var rows = collector.Snapshot();
Assert.Equal(3, rows.Count);
var closing = Row(rows[^1]);
var extra = ScriptTraceExtra.TryParse(closing.Extra)!;
Assert.Equal("warn", extra.LogLevel);
Assert.Equal("runtime", extra.LogSource);
Assert.Contains("1 further log line(s) were not captured", closing.RequestSummary);
}
/// <summary>
/// Review I1: the sync <c>ExternalSystem.Call</c> pre-mints its <c>ApiCall</c> row id and
/// publishes it as the ambient step, so a warning logged while the call is in flight names
/// that node. Provoked the same way as the write case — a writer that throws makes the
/// helper's own best-effort "audit write threw" warning fire inside the scope.
/// </summary>
[Fact]
public async Task RuntimeWarning_InsideATracedExternalCall_IsAttributedToTheApiCallStep()
{
var collector = new FlowTraceCollector();
var ctx = CreateContext(CreateTestProbe().Ref, new ThrowingAuditWriter(), traced: true, collector,
externalSystemClient: SyncClient(new ExternalCallResult(true, "{}", null)).Object);
var result = await ctx.ExternalSystem.Call("ERP", "GetOrder");
Assert.True(result.Success);
var rows = collector.Snapshot();
var call = rows.Single(e => Row(e).Kind == AuditKind.ApiCall);
var log = rows.Single(e => Row(e).Kind == AuditKind.ScriptLog);
var extra = ScriptTraceExtra.TryParse(Row(log).Extra)!;
Assert.Equal("runtime", extra.LogSource);
Assert.Equal(call.EventId, extra.StepEventId);
}
/// <summary>
/// Review I1: the step scope is gated on the run's TRACE FLAG, not on the presence of an
/// inline collector — a traced run without one still writes durable <c>ScriptLog</c> rows that
/// have to name their step.
/// </summary>
[Fact]
public async Task TracedExternalCall_WithoutACollector_StillNamesTheApiCallStep()
{
var writer = new CapturingAuditWriter();
var ctx = CreateContext(CreateTestProbe().Ref, writer, traced: true, collector: null,
externalSystemClient: SyncClient(new ExternalCallResult(true, "{}", null)).Object);
await ctx.ExternalSystem.Call("ERP", "GetOrder");
ctx.Log.Info("after the call, outside any scope");
var call = writer.Events.Single(e => Row(e).Kind == AuditKind.ApiCall);
// The line above is outside the scope, so it falls back to the anchor; what this pins is
// that the call's row id was minted and published at all on a collector-less traced run.
Assert.NotEqual(Guid.Empty, call.EventId);
var log = writer.Events.Single(e => Row(e).Kind == AuditKind.ScriptLog);
Assert.Null(ScriptTraceExtra.TryParse(Row(log).Extra)!.StepEventId);
}
/// <summary>
/// Review I2 (design §5): an inline shared script SHARES its caller's <see cref="ScriptLogger"/>
/// — one counter, one anchor. Its lines therefore carry the PARENT run's <c>ExecutionId</c> and
/// land on the parent's <c>ScriptRun</c> node, even though its own step rows chain under its own
/// execution.
/// </summary>
[Fact]
public void SharedScriptChild_SharesTheCallersLogger_ExecutionIdAndCap()
{
var writer = new CapturingAuditWriter();
var collector = new FlowTraceCollector();
var parent = CreateContext(CreateTestProbe().Ref, writer, traced: true, collector, maxLogLinesPerRun: 2);
var child = parent.CreateChildContextForSharedScript(1);
Assert.Same(parent.Log, child.Log);
Assert.NotEqual(parent.ExecutionId, child.ExecutionId);
parent.Log.Info("from the caller");
child.Log.Info("shared");
// Third line from either side is over the SHARED cap of 2.
child.Log.Info("dropped");
parent.Log.Info("also dropped");
var rows = collector.Snapshot();
Assert.Equal(2, rows.Count);
Assert.All(rows, e => Assert.Equal(parent.ExecutionId, Row(e).ExecutionId));
Assert.Equal(["from the caller", "shared"], rows.Select(e => Row(e).RequestSummary));
parent.Log.FlushDropped();
Assert.Contains("2 further log line(s) were not captured", Row(collector.Snapshot()[^1]).RequestSummary);
}
/// <summary>
/// Review item 4: step scopes NEST — the batch leg's lines name the batch row, the wait leg's
/// name the wait row, and both unwind so a later line falls back to the run anchor. The
/// throwing writer is again what provokes a runtime line inside each leg.
/// </summary>
[Fact]
public async Task NestedStepScopes_AttributeEachLegSeparately_AndUnwind()
{
var probe = CreateTestProbe();
var collector = new FlowTraceCollector();
var ctx = CreateContext(probe.Ref, new ThrowingAuditWriter(), traced: true, collector);
var call = ctx.WriteBatchAndWait(
new Dictionary<string, string?> { ["Recipe"] = "R-17" },
"Trigger", "1", "Ack", "1", TimeSpan.FromSeconds(3));
var batchReq = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WriteAttributeBatchResponse(batchReq.CorrelationId, true, null));
var waitReq = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
probe.Reply(new WaitForAttributeResponse(waitReq.CorrelationId, Matched: true, "1", "Good", TimedOut: false));
Assert.True(await call);
// Both scopes have unwound by the time the run continues.
ctx.Log.Info("after both legs");
var rows = collector.Snapshot();
var write = rows.Single(e => Row(e).Kind == AuditKind.AttributeWrite);
var wait = rows.Single(e => Row(e).Kind == AuditKind.AttributeWait);
Assert.NotEqual(write.EventId, wait.EventId);
var logs = rows.Where(e => Row(e).Kind == AuditKind.ScriptLog)
.Select(e => ScriptTraceExtra.TryParse(Row(e).Extra)!)
.ToList();
Assert.Equal(3, logs.Count);
Assert.Equal(write.EventId, logs[0].StepEventId);
Assert.Equal(wait.EventId, logs[1].StepEventId);
Assert.Null(logs[2].StepEventId);
Assert.Equal(["runtime", "runtime", "script"], logs.Select(l => l.LogSource));
}
}
@@ -0,0 +1,189 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
/// <summary>
/// Flow Debugger step logs (design 2026-09-09 D6): the decorator installed over the script
/// context's logger while a run is traced. It must forward everything the node would have logged
/// anyway, capture Information and above as <c>runtime</c> rows — including levels the node's own
/// minimum level suppresses, because they are trace data, not node log output — and never capture
/// Debug/Trace chatter.
/// </summary>
public class TraceCapturingLoggerTests
{
/// <summary>One row the sink's emitter received.</summary>
private sealed record Row(string Level, string Source, string Message, Exception? Exception, Guid? StepEventId);
/// <summary>An inner logger that records what it was asked to write, at a configurable minimum level.</summary>
private sealed class RecordingLogger(LogLevel minimum = LogLevel.Trace) : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public List<object?> Scopes { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
Scopes.Add(state);
return null;
}
public bool IsEnabled(LogLevel logLevel) => logLevel >= minimum;
public void Log<TState>(LogLevel level, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
Entries.Add((level, formatter(state, exception)));
}
private static (TraceCapturingLogger Logger, RecordingLogger Inner, List<Row> Rows) Make(LogLevel innerMinimum = LogLevel.Trace)
{
var inner = new RecordingLogger(innerMinimum);
var rows = new List<Row>();
var sink = new ScriptLogger(
new RecordingLogger(),
(level, source, message, exception, stepEventId) => rows.Add(new Row(level, source, message, exception, stepEventId)),
maxLines: 200,
instance: "CvdReactor01",
script: "OnHeartbeat",
executionId: Guid.NewGuid());
return (new TraceCapturingLogger(inner, sink), inner, rows);
}
[Fact]
public void Debug_IsForwarded_ButNotCaptured()
{
var (logger, inner, rows) = Make();
logger.LogDebug("internal chatter");
Assert.Single(inner.Entries);
Assert.Empty(rows);
}
[Fact]
public void Warning_IsCapturedAsARuntimeRow_WithItsException()
{
var (logger, inner, rows) = Make();
var boom = new TimeoutException("no reply");
logger.LogWarning(boom, "tag write timed out after {Ms} ms", 5_000);
Assert.Single(inner.Entries);
var row = Assert.Single(rows);
Assert.Equal("warn", row.Level);
Assert.Equal("runtime", row.Source);
Assert.Equal("tag write timed out after 5000 ms", row.Message);
Assert.Same(boom, row.Exception);
}
[Fact]
public void ErrorAndCritical_AreBothCapturedAsError()
{
var (logger, _, rows) = Make();
logger.LogError("bad");
logger.LogCritical("worse");
Assert.Equal(["error", "error"], rows.Select(r => r.Level));
}
[Fact]
public void InformationIsEnabled_EvenWhenTheInnerLoggerSaysNo()
{
// A node whose Serilog minimum is Warning must still contribute Information lines to a trace.
var (logger, inner, rows) = Make(innerMinimum: LogLevel.Warning);
Assert.False(inner.IsEnabled(LogLevel.Information));
Assert.True(logger.IsEnabled(LogLevel.Information));
logger.LogInformation("captured anyway");
Assert.Empty(inner.Entries);
var row = Assert.Single(rows);
Assert.Equal("info", row.Level);
Assert.Equal("captured anyway", row.Message);
}
[Fact]
public void BelowInformation_TheInnerLoggerDecidesEnablement()
{
var (logger, _, _) = Make(innerMinimum: LogLevel.Warning);
Assert.False(logger.IsEnabled(LogLevel.Debug));
}
[Fact]
public void ADisabledInnerLogger_IsNotWrittenTo_ButTheRowIsStillCaptured()
{
var (logger, inner, rows) = Make(innerMinimum: LogLevel.Critical);
logger.LogWarning("suppressed on the node");
Assert.Empty(inner.Entries);
Assert.Single(rows);
}
[Fact]
public void CapturedLines_CarryTheAmbientStepId()
{
var (logger, _, rows) = Make();
var stepId = Guid.NewGuid();
using (TraceStepScope.Enter(stepId))
{
logger.LogInformation("inside the helper");
}
Assert.Equal(stepId, Assert.Single(rows).StepEventId);
}
[Fact]
public void Ctor_RejectsAnAlreadyDecoratedLogger()
{
// Wrapping twice would forward each script line through two capture stages and emit two
// runtime rows per line — and would burn the run's line cap at double rate.
var (already, _, _) = Make();
var sink = new ScriptLogger(new RecordingLogger(), emit: null, maxLines: 200, "i", "s", null);
var ex = Assert.Throws<ArgumentException>(() => new TraceCapturingLogger(already, sink));
Assert.Equal("inner", ex.ParamName);
}
[Fact]
public void Ctor_RequiresBothCollaborators()
{
var sink = new ScriptLogger(new RecordingLogger(), emit: null, maxLines: 200, "i", "s", null);
Assert.Throws<ArgumentNullException>(() => new TraceCapturingLogger(null!, sink));
Assert.Throws<ArgumentNullException>(() => new TraceCapturingLogger(new RecordingLogger(), null!));
}
[Fact]
public void AThrowingFormatter_IsSwallowed_AndDropsOnlyThatLine()
{
// Review M3. Most of what this decorator captures is the runtime's own best-effort
// "audit write failed" warnings, logged from INSIDE a catch block: a formatter exception
// escaping here would replace the original failure with a formatting one on a path whose
// contract is that it never throws. The inner logger is disabled for this level so the
// decorator's OWN formatter call is the only one under test.
var (logger, inner, rows) = Make(innerMinimum: LogLevel.Critical);
logger.Log(LogLevel.Warning, new EventId(0), "state", null,
(_, _) => throw new InvalidOperationException("formatter broke"));
Assert.Empty(rows);
Assert.Empty(inner.Entries);
// …and the decorator keeps working for the next line.
logger.LogWarning("still alive");
Assert.Equal("still alive", Assert.Single(rows).Message);
}
[Fact]
public void BeginScope_DelegatesToTheInnerLogger()
{
var (logger, inner, _) = Make();
logger.BeginScope("state");
Assert.Equal(["state"], inner.Scopes);
}
}
@@ -147,6 +147,18 @@ public class SiteRuntimeOptionsValidatorTests
Assert.Equal(30, new SiteRuntimeOptions().TraceArmWindowSeconds);
}
/// <summary>A zero cap would drop every log line AND the closing row that reports the drops.</summary>
[Fact]
public void ZeroMaxLogLinesPerRun_IsRejected()
{
// Flow Debugger step logs (design 2026-09-09 D7): the per-run cap must be positive.
var result = Validate(new SiteRuntimeOptions { MaxLogLinesPerRun = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxLogLinesPerRun", result.FailureMessage);
Assert.Equal(200, new SiteRuntimeOptions().MaxLogLinesPerRun);
}
[Fact]
public void ZeroMaxConcurrentRunsPerScript_IsRejected()
{