Merge branch 'feature/audit-parent-executionid': ParentExecutionId cross-execution audit correlation

This commit is contained in:
Joseph Doherty
2026-05-21 20:14:44 -04:00
109 changed files with 8953 additions and 190 deletions

View File

@@ -133,6 +133,7 @@ This project contains design documentation for a distributed SCADA system built
- Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded.
- One row per lifecycle event; cached calls produce 4+ rows per operation (`Submitted`, `Forwarded`, `Attempted`, `Delivered`/`Parked`/`Discarded`).
- `ExecutionId` (`uniqueidentifier NULL`) is the universal per-run correlation value — every audit row emitted by one script execution / inbound request shares it; `CorrelationId` remains the per-operation lifecycle id (NULL for sync one-shots).
- `ParentExecutionId` (`uniqueidentifier NULL`) is the cross-execution spawn pointer — every row of a spawned run carries the spawner's `ExecutionId`; first cut bridges the inbound API → routed-site-script case (the routed run records the inbound request's `ExecutionId`; the inbound row stays top-level / NULL); `IX_AuditLog_ParentExecution` backs the filter + the recursive execution-tree walk; tag cascade deferred.
- Site SQLite hot-path first, then gRPC telemetry to central; ingest is idempotent on `EventId`; periodic reconciliation pull as fallback when telemetry is lost.
- Cached operations: site emits a single additively-extended `CachedCallTelemetry` packet carrying both audit events and operational state; central writes `AuditLog` + `SiteCalls` in one transaction.
- Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in.

View File

@@ -0,0 +1,222 @@
# Audit Log — Cross-Execution Correlation (`ParentExecutionId`) Design
**Date:** 2026-05-21
**Status:** Validated — ready for implementation planning.
## Problem
The Audit Log carries `ExecutionId` (`Guid?`) — a universal per-run correlation
value stamped on every audit row, identifying the originating script execution
or inbound API request. It is **per-execution and flat**: `WHERE ExecutionId = X`
returns everything *one* run did, but nothing links an execution to the
execution that *spawned* it. A call chain cannot be traced across the execution
boundary.
Two cross-execution cases exist:
1. **Inbound API request → routed site script.** An inbound HTTP request runs an
inbound method script (`InboundScriptExecutor`, central) which calls
`Route.Call(scriptName, params)`; that sends a `RouteToCallRequest` to a site
instance, which runs `scriptName` as a fresh site-side execution. The inbound
request and the routed site script get two unrelated `ExecutionId`s.
2. **Tag cascade.** Script A writes an attribute; the attribute change triggers
script B as a separate execution. A and B are unrelated.
## Decision
Add a dedicated, nullable **`ParentExecutionId`** (`Guid?`) column to the audit
row. Every execution still gets its own fresh `ExecutionId` (unchanged). An
execution *spawned by* another carries the spawner's `ExecutionId` in its
`ParentExecutionId`; a top-level (tag/timer/inbound/un-bridged) execution leaves
it null. Walking `ParentExecutionId → ExecutionId` recursively reconstructs the
chain as a tree.
**First cut — in scope:** case 1 only, the **inbound → routed-site-script
bridge**. It is the most concrete case and the spawn point is an explicit,
threadable RPC (`RouteToCallRequest`).
**Out of scope:** case 2 (tag cascade) — the trigger is data-driven and
decoupled; "which execution wrote the tag that triggered me" is not tracked
anywhere today. Deferred as a follow-up. The `ParentExecutionId` model
generalises to it with no schema change if that data is ever threaded.
### Considered and rejected
- **Reuse `ExecutionId`** — the routed script *adopts* the inbound request's
`ExecutionId` instead of generating its own. Cheaper (no new column) but
conflates two genuinely separate executions on two clusters, breaks the
invariant "one `ExecutionId` = one `ScriptRuntimeContext` run", and does not
generalise to tag cascade.
- **Point `ParentExecutionId` at the root** (flatten the chain to two levels)
instead of the immediate spawner — simpler queries but loses intermediate
hops, needs a separately threaded root id, and does not generalise. Rejected
in favour of the immediate-spawner tree.
## Architecture & data flow
The id propagated is the **inbound API request's `ExecutionId`**. The chain:
1. **Mint the inbound request id once, early.** Today `AuditWriteMiddleware`
mints a `Guid.NewGuid()` late, only for the inbound row's `ExecutionId`. Move
the mint to the HTTP entry and stash it on `HttpContext.Items`, so both the
middleware (writes the `InboundRequest` row at request end) and
`InboundScriptExecutor` (needs it *before* the script runs) read the same id.
2. **Carry it on the routing RPC.** `RouteHelper.Call` builds a
`RouteToCallRequest`; an additive `ParentExecutionId` field is set from the
stashed inbound id. (`RouteHelper`'s own per-op GUID is a separate concern —
left alone.)
3. **Site side: thread it into the routed script's context.** The site handler
for `RouteToCallRequest` passes it to a new optional `parentExecutionId` ctor
param on `ScriptRuntimeContext` (sibling to the existing `executionId`
param). The routed script still generates its **own** fresh `ExecutionId`.
4. **Every emitter stamps `ParentExecutionId`** alongside `ExecutionId`.
**Recursion (immediate-spawner tree).** A routed script that itself calls
`Route.Call` threads its own `ExecutionId` onward, so a grandchild's
`ParentExecutionId` points at its immediate spawner, not the root. Walk the tree
recursively to reconstruct any depth.
**The inbound request's own row** (`InboundRequest` / `InboundAuthFailure`) is
top-level → `ParentExecutionId = NULL`. Only the routed site script and every
row it produces carry the pointer.
## Schema changes (all additive, nullable — no backfill; pre-existing rows stay `NULL`)
| Where | Change |
|---|---|
| `ScadaLink.Commons` | `AuditEvent.ParentExecutionId` (`Guid?`); `RouteToCallRequest.ParentExecutionId` (`Guid?`); `Notification.OriginParentExecutionId` (`Guid?`); `NotificationSubmit.OriginParentExecutionId` (`Guid?`). |
| Central MS SQL `AuditLog` | `ParentExecutionId uniqueidentifier NULL` column + partition-aligned index `IX_AuditLog_ParentExecution (ParentExecutionId)` (mirror `AddAuditLogExecutionId`). EF migration — additive nullable column is a metadata-only `ALTER`. |
| Central MS SQL `Notifications` | `OriginParentExecutionId uniqueidentifier NULL` column + EF migration (mirror `AddNotificationOriginExecutionId`). |
| Site SQLite `auditlog.db` `AuditLog` | `ParentExecutionId TEXT NULL` — added **via the idempotent `ALTER`-if-missing upgrade path** (per commit `5198b11`), never relying on `CREATE TABLE IF NOT EXISTS`. |
| gRPC `AuditEventDto` (`sitestream.proto`) | additive `parent_execution_id` field (next free number); `AuditEventDtoMapper` maps it both directions (Guid ↔ string; empty string ↔ null). |
| `ScriptRuntimeContext` | optional `parentExecutionId` ctor param + stored `_parentExecutionId` field. |
`IX_AuditLog_ParentExecution` is load-bearing: the tree view's downward
recursive join seeks on it, and it backs the `parentExecutionId` filter.
`SiteCalls` needs no new column — the cached telemetry packet carries the audit
half, which now has `ParentExecutionId` directly.
## Emitter coverage — full (mirrors the `ExecutionId` rollout)
Every audit row a routed-script run produces carries `ParentExecutionId`, so
`WHERE ParentExecutionId = X` returns the routed run's complete trust-boundary
footprint.
| Emitter | `ParentExecutionId` source |
|---|---|
| Sync `ApiCall`, sync `DbWrite` | `ScriptRuntimeContext._parentExecutionId` (in scope) |
| Cached call script-side rows (`CachedSubmit`, immediate `Attempted`/`CachedResolve`) | `ScriptRuntimeContext._parentExecutionId` |
| Cached call **S&F retry-loop** rows (`CachedCallLifecycleBridge`) | threaded through the S&F buffered message → `CachedCallAttemptContext` → the bridge, as a sibling to the `ExecutionId` already threaded there |
| `NotifySend` (site, script-side) | `ScriptRuntimeContext._parentExecutionId` |
| `NotifyDeliver` (central dispatch) | `Notifications.OriginParentExecutionId` — rides on `NotificationSubmit`, persisted on the `Notifications` row, dispatcher stamps every `NotifyDeliver` row |
| Inbound `InboundRequest` / `InboundAuthFailure` | `NULL` — inbound is top-level |
The threading reuses the carry points the `ExecutionId` rollout already opened
(S&F buffer, `NotificationSubmit``Notifications`); `ParentExecutionId` is a
sibling field at each, not a new boundary.
## Recursive chain/tree view
A new repository method `GetExecutionTreeAsync(Guid executionId)`:
- **Walk up** to the root: iterative single-parent follow
(`SELECT TOP 1 ParentExecutionId WHERE ExecutionId = current AND
ParentExecutionId IS NOT NULL`) until null. Cheap — each execution has exactly
one parent.
- **Walk down** from the root: recursive CTE joining
`ParentExecutionId = ancestor.ExecutionId`, seeking on
`IX_AuditLog_ParentExecution`. `MAXRECURSION` capped (e.g. 32) — chains are
shallow; the cap guards against corrupt/pathological data.
- Returns a flat list of execution nodes: `ExecutionId`, `ParentExecutionId`,
row count, channels/statuses present, `SourceSiteId`/`SourceInstanceId`,
first/last `OccurredAtUtc`. The UI assembles the tree from the flat list.
**UI.** New route `/audit/execution-tree?executionId=<guid>`, reached via a
"View execution chain" drill-in from any audit row and from the `ExecutionId`
column. Renders an expandable custom Blazor tree (no component frameworks); each
node shows the execution summary; clicking a node filters the Audit Log grid to
`?executionId=<node>`. The tree is always rooted at the topmost ancestor, so the
reader sees the full chain regardless of which row they entered from.
Plus the cheaper navigation affordances: `ParentExecutionId` grid column (short
form / monospace), a `ParentExecutionId` paste-filter, a `?parentExecutionId=`
query param, and a "View parent execution" drill-in (links
`?executionId=<parentId>`).
### Edge cases
- **Parent with no rows of its own.** An execution that performed no
trust-boundary action emits no audit rows, yet a child still references it via
`ParentExecutionId`. The upward walk resolves the GUID but finds no rows for
that node → render it as a stub node ("execution with no audited actions").
- **Purged parent.** A parent execution older than the 365-day central
retention has no rows → the upward walk stops there; the chain renders as far
as it resolves.
- **Cycle guard.** The `ParentExecutionId` graph is acyclic by construction
(each execution is minted fresh and its parent always pre-exists), but
`MAXRECURSION` bounds the downward CTE against corrupt data.
## CLI / ManagementService
- CLI: `scadalink audit query --parent-execution-id <guid>`;
`AuditLogQueryFilter` gains a `ParentExecutionId` single-value filter
dimension (mirror `ExecutionId`).
- ManagementService `/api/audit/query` + export endpoint and the CentralUI
export endpoints parse a `parentExecutionId` query param (lax-parse —
unparseable dropped).
- The tree view's data path: `GetExecutionTreeAsync` is exposed however the
existing Audit Log page sources its grid data — mirror that path; add a
ManagementService endpoint only if the page goes through it.
- **No CLI `audit tree` command in the first cut** — the tree is a UI forensic
affordance; the `--parent-execution-id` filter covers scripted use. Noted as a
possible follow-up.
## Compatibility
- Additive nullable columns; additive proto field; additive message-contract
fields — all version-compatible. No backfill; historical rows keep
`ParentExecutionId = NULL`.
- `ExecutionId` and `CorrelationId` semantics unchanged — every existing
drill-in keeps working.
## Failure handling
- Audit-write failure NEVER aborts the user-facing action — unchanged invariant;
`ParentExecutionId` is just another field on the row.
- Site `auditlog.db` schema change MUST use the idempotent `ALTER`-if-missing
path (commit `5198b11`); do not repeat the original `CREATE TABLE IF NOT
EXISTS` mistake.
## Testing
- Repository: query-by-`ParentExecutionId`; `GetExecutionTreeAsync` (multi-level
tree, stub-parent node, `MAXRECURSION` cap); migration smoke test.
- Emitter unit tests: each emitter stamps `ParentExecutionId`; the cached-call
lifecycle rows from one routed run share it; `NotifyDeliver` echoes
`Notifications.OriginParentExecutionId`.
- **Headline integration test:** an inbound API request that calls `Route.Call`
→ the routed site script does a sync `ExternalSystem.Call`, a cached call, and
a `Notify.Send` → every resulting audit row (site + central) carries
`ParentExecutionId` = the inbound request's `ExecutionId`, while each has its
own distinct `ExecutionId`.
- Central UI: bUnit (column renders, filter maps, query param parsed, tree
assembled from the flat list) + Playwright (drill-in → tree → node click
filters the grid).
## Out of scope / follow-ups
- **Tag cascade (case 2)** — deferred. If the attribute-write path ever carries
the writing execution's id into the triggered script's `ScriptRuntimeContext`,
the same `ParentExecutionId` column and tree view cover it with no schema
change.
- CLI `audit tree` command — possible follow-up.
- Backfilling `ParentExecutionId` on historical audit rows — not done.
## Constraints
- Additive everywhere — nullable columns, additive proto/message fields, no
backfill.
- Never touch `infra/*`; `alog.md` is the locked v1 spec — do not modify it.
- Site `auditlog.db` schema change MUST use the idempotent `ALTER`-if-missing
path (commit `5198b11`).

View File

@@ -0,0 +1,220 @@
# Audit Log ParentExecutionId — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to execute this plan task-by-task (fresh implementer per task + spec review + code-quality review).
**Goal:** Add a `ParentExecutionId` column to the Audit Log so an execution spawned by another (first cut: an inbound API request that routes to a site script) records a pointer back to its spawner, making audit call chains traceable across the execution boundary.
**Architecture:** Additive nullable `ParentExecutionId` (`Guid`) on the audit row (Commons `AuditEvent`, central MS SQL `AuditLog`, site SQLite `auditlog.db`, gRPC `AuditEventDto`). The inbound API request's `ExecutionId` is minted once at the HTTP entry, threaded onto `RouteToCallRequest``ScriptCallRequest` → the routed script's `ScriptRuntimeContext` as a new `parentExecutionId`; the routed script still mints its own fresh `ExecutionId`. Every emitter stamps `ParentExecutionId` as a sibling to `ExecutionId` — through the S&F buffer for retry-loop cached rows and through `NotificationSubmit``Notifications.OriginParentExecutionId` for central `NotifyDeliver` rows. A recursive repository query plus a Central UI tree view reconstruct the chain. Validated design: `docs/plans/2026-05-21-audit-parent-executionid-design.md`.
**Tech Stack:** .NET 10, EF Core 10 (MS SQL + SQLite), Akka.NET, gRPC, Blazor Server + Bootstrap, System.CommandLine, xUnit + Akka.TestKit.Xunit2 + bUnit + NSubstitute/Moq, Playwright.
**Ground rules (every task):** branch is `feature/audit-parent-executionid` (already created) — never commit to `main`. TDD — write the failing test first, then the minimal implementation. Edit in place; never touch `infra/*` or `alog.md`; `docker/*` only if a task says so (none do). Stage with explicit `git add <path>` — never `git add .` / `commit -am`. Full solution stays green (`dotnet build ScadaLink.slnx` 0 warnings — `TreatWarningsAsErrors` is on; `dotnet test ScadaLink.slnx` for touched suites). Additive contract evolution only. Do not push.
---
## Task 0: Prep — verify branch + baseline
**Files:** none.
**Steps:** confirm `git branch --show-current` is `feature/audit-parent-executionid`; run `dotnet build ScadaLink.slnx` and confirm it succeeds with 0 warnings.
**Acceptance:** on the branch, solution builds clean.
---
## Task 1: Foundation — `AuditEvent.ParentExecutionId`, central `AuditLog` column, repository query
**Files:**
- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` — add `Guid? ParentExecutionId` (sibling to `ExecutionId`, same XML-doc style).
- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — add `Guid? ParentExecutionId` single-value filter dimension (mirror `ExecutionId`).
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` — map the column; add index `IX_AuditLog_ParentExecution (ParentExecutionId)`.
- Create: EF migration under `src/ScadaLink.ConfigurationDatabase/Migrations/``AddAuditLogParentExecutionId``ParentExecutionId uniqueidentifier NULL` + the index. Mirror `20260521184044_AddAuditLogExecutionId` exactly (partition-aligned index, metadata-only `ALTER`).
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs``QueryAsync` translates `filter.ParentExecutionId` to `e.ParentExecutionId == value` (mirror the `ExecutionId` clause). Keyset paging untouched.
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs``QueryAsync_FilterByParentExecutionId`; migration smoke if the suite has that pattern.
**Approach:** purely additive; `ParentExecutionId` is `Guid?` everywhere. Generate the migration the same way `AddAuditLogExecutionId` was produced (match the repo's migration workflow).
**Commit:** `feat(auditlog): ParentExecutionId column on AuditEvent + central AuditLog`
---
## Task 2: Foundation — site SQLite + gRPC DTO
**Files:**
- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — add `ParentExecutionId TEXT NULL` to the `auditlog.db` `AuditLog` table; the insert command binds it; `MapRow` reads it back. **Add the column via the idempotent `ALTER TABLE ... ADD COLUMN`-if-missing upgrade path** (the same path commit `5198b11` introduced for `ExecutionId` — locate it and extend it; do NOT rely on `CREATE TABLE IF NOT EXISTS` for the new column on an existing site DB).
- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` — add `string parent_execution_id` to `AuditEventDto` (next free field number; additive). Rebuild regenerates the C# stubs.
- Modify: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs``ToDto`/`FromDto` map `ParentExecutionId``parent_execution_id` (Guid ↔ string; empty string ↔ null, mirroring the existing `ExecutionId` handling).
- Test: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs` — column present, round-trips, and the `ALTER`-if-missing path adds it to a pre-existing DB lacking the column; `tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs``ParentExecutionId` round-trip incl. null.
**Commit:** `feat(auditlog): ParentExecutionId on site SQLite schema + gRPC AuditEventDto`
---
## Task 3: Inbound request id minting + `RouteToCallRequest.ParentExecutionId`
**What:** The id propagated as `ParentExecutionId` is the inbound API request's `ExecutionId`. Today `AuditWriteMiddleware` mints it late, only for the inbound audit row. Mint it once early and stash it so `InboundScriptExecutor` can carry it onto the routing RPC.
**Files:**
- Modify: `src/ScadaLink.Commons/Messages/InboundApi/RouteToInstanceRequest.cs` — add `Guid? ParentExecutionId` to the `RouteToCallRequest` record (additive — append as the last positional param with a default, or make it a settable init property; match how the codebase evolves records).
- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs` (+ `AuditWriteMiddlewareExtensions.cs` if the pipeline order needs it) — mint the request `ExecutionId` (`Guid.NewGuid()`) at the start of the request, stash it on `HttpContext.Items` under a well-known key (add a small constant, e.g. `InboundExecutionContext.HttpItemKey`); `EmitInboundAudit` reads that same id for the inbound row's `ExecutionId` instead of minting its own.
- Modify: `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs` — read the stashed inbound `ExecutionId` from `HttpContext.Items` (or accept it as a parameter from the endpoint that has the `HttpContext`).
- Modify: `src/ScadaLink.InboundAPI/RouteHelper.cs` (~line where `RouteToCallRequest` is built) — set `ParentExecutionId` on the `RouteToCallRequest` from the inbound `ExecutionId`. Leave `RouteHelper`'s own per-op `CorrelationId` GUID alone — separate concern.
- Modify: `src/ScadaLink.InboundAPI/EndpointExtensions.cs` if the inbound `ExecutionId` must be plumbed from the endpoint into `InboundScriptExecutor`.
- Test: `tests/ScadaLink.InboundAPI.Tests/``AuditWriteMiddlewareTests` (inbound row uses the early-minted id; distinct per request); a `RouteHelper`/`InboundScriptExecutor` test that a routed `RouteToCallRequest` carries `ParentExecutionId` = the inbound request's `ExecutionId`.
**Approach:** the inbound request's own audit row stays top-level — `ParentExecutionId` is NOT set on it (it remains `NULL`). Only the spawn id flows outward on `RouteToCallRequest`. If the early mint cannot cleanly be shared between middleware and executor, STOP and report before guessing the pipeline shape.
**Commit:** `feat(inboundapi): mint inbound ExecutionId early, carry it as RouteToCallRequest.ParentExecutionId`
---
## Task 4: Thread `ParentExecutionId` into the routed script's `ScriptRuntimeContext`
**What:** Carry the `RouteToCallRequest.ParentExecutionId` site-side down to the routed script's `ScriptRuntimeContext`. The routed script still generates its own fresh `ExecutionId`.
**Files:**
- Modify: `src/ScadaLink.Commons/Messages/ScriptExecution/ScriptCallRequest.cs` — add `Guid? ParentExecutionId` (additive). This is the message `RouteInboundApiCall` builds.
- Modify: `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs` `RouteInboundApiCall` (~line 734) — set `ParentExecutionId = request.ParentExecutionId` on the `ScriptCallRequest` it builds from the `RouteToCallRequest`.
- Modify: `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs` `HandleScriptCallRequest` (~line 319) — forward `request.ParentExecutionId` onward.
- Modify: `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs` `HandleScriptCallRequest` (~line 175) — pass `ParentExecutionId` into the `ScriptExecutionActor` it spawns.
- Modify: `src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs` — add an optional `Guid? parentExecutionId = null` ctor param; thread it through `ExecuteScript` into `new ScriptRuntimeContext(...)`.
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs` — add an optional `Guid? parentExecutionId = null` ctor param (sibling to the existing `executionId` param ~line 144); store `_parentExecutionId`; XML-doc it. Thread it to the helper sub-context types alongside `_executionId` (the inner `ExternalSystem`/`Database`/`Notify` helper structs at ~lines 386, 406, 1003 carry `_executionId` — give them `_parentExecutionId` too).
- Test: `tests/ScadaLink.SiteRuntime.Tests/` — a test that a `ScriptCallRequest` carrying `ParentExecutionId` produces a `ScriptRuntimeContext` whose `_parentExecutionId` is that value AND whose `ExecutionId` is freshly generated (distinct); a `RouteToCallRequest``ScriptCallRequest` mapping test on `DeploymentManagerActor`.
**Note for implementer:** this task only threads the value — no emitter stamps it yet (Task 5). A normal (tag/timer) script run passes no `ParentExecutionId`, so `_parentExecutionId` stays `null`. Verify the helper sub-context plumbing matches exactly how `_executionId` is already threaded; if the ctor param ordering is awkward, mirror the `executionId` decision documented at `ScriptRuntimeContext.cs:396`.
**Commit:** `feat(siteruntime): thread ParentExecutionId into the routed script's ScriptRuntimeContext`
---
## Task 5: Site script-side emitters stamp `ParentExecutionId`
**What:** Every audit row a `ScriptRuntimeContext` emits gets `ParentExecutionId = _parentExecutionId` alongside `ExecutionId = _executionId`.
**Files:**
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs`:
- Sync `ApiCall` (`BuildCallAuditEvent` / the sync emission ~line 932): set `ParentExecutionId = _parentExecutionId`.
- Cached script-side rows (`CachedSubmit`, immediate `ApiCallCached`/`CachedResolve` ~lines 582, 693, 759): set `ParentExecutionId = _parentExecutionId`.
- `NotifySend` emission: set `ParentExecutionId = _parentExecutionId`.
- Modify: `src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs` + `AuditingDbCommand.cs` — thread `_parentExecutionId` (sibling to the audit `_executionId` already threaded); sync `DbWrite` and cached DB-write rows set `ParentExecutionId = _parentExecutionId`.
- Test: extend `tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs`, `DatabaseSyncEmissionTests.cs`, `ExternalSystemCachedCallEmissionTests.cs`, `DatabaseCachedWriteEmissionTests.cs`, `NotifySendAuditEmissionTests.cs`, `ExecutionCorrelationContextTests.cs` — assert `ParentExecutionId` is the context's `_parentExecutionId` on every emitted row; assert it is `null` when the context was constructed without one.
**Commit:** `feat(auditlog): site script-side emitters stamp ParentExecutionId`
---
## Task 6: Cached S&F retry-loop rows carry `ParentExecutionId`
**What:** Thread `ParentExecutionId` through the store-and-forward buffer so the retry-loop cached audit rows (`CachedCallLifecycleBridge`) carry it — a sibling to the `ExecutionId` the `ExecutionId` rollout already threaded through this exact path.
**Files:**
- Modify: the S&F buffered cached-call message / payload in `src/ScadaLink.StoreAndForward/` (`StoreAndForwardService.cs` and the buffered message type — find where `ExecutionId` was added in the `ExecutionId` rollout's Task 4) — carry `ParentExecutionId` alongside.
- Modify: `CachedCallAttemptContext` (in `src/ScadaLink.StoreAndForward/` / referenced by `src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs`) — add a `ParentExecutionId` field beside `ExecutionId`.
- Modify: `src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs` `BuildPacket` — set `ParentExecutionId` from the context, beside the existing `ExecutionId`.
- Modify: the enqueue path (`ExternalSystem.CachedCall` / `Database.CachedWrite` in `ScriptRuntimeContext.cs` ~line 520, where `executionId: _executionId` is already passed into the buffered message) — also write `_parentExecutionId` into the buffered message.
- Test: `tests/ScadaLink.AuditLog.Tests/` cached-telemetry tests + `tests/ScadaLink.StoreAndForward.Tests/` — retry-loop rows carry the originating `ParentExecutionId` (incl. `null` for a non-routed run).
**Note for implementer:** the threading boundary is already open from the `ExecutionId` rollout — this is one more field at each existing carry point, not a new boundary. If the buffered message cannot cleanly carry it, STOP and report.
**Commit:** `feat(auditlog): thread ParentExecutionId through S&F for retry-loop cached rows`
---
## Task 7: Central `NotifyDeliver` rows carry `ParentExecutionId`
**Files:**
- Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs` — add `Guid? OriginParentExecutionId` (sibling to `OriginExecutionId`).
- Modify: `src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs``NotificationSubmit` carries `Guid? OriginParentExecutionId` (additive).
- Modify: `src/ScadaLink.ConfigurationDatabase/` — EF config for `Notifications` + a new migration `AddNotificationOriginParentExecutionId` (`Notifications.OriginParentExecutionId uniqueidentifier NULL`). Mirror `20260521193048_AddNotificationOriginExecutionId`.
- Modify: the site `NotifySend` forward path — the routed run's `_parentExecutionId` (on the `NotifySend` audit row from Task 5) also rides on the `NotificationSubmit` (set it where the submit is built — `ScriptRuntimeContext` `Notify.Send` / the S&F notification forwarder, beside `OriginExecutionId`).
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` — persist `OriginParentExecutionId` on insert; `BuildNotifyDeliverEvent` sets `ParentExecutionId = notification.OriginParentExecutionId`.
- Test: `tests/ScadaLink.NotificationOutbox.Tests/``NotifyDeliver` rows echo `OriginParentExecutionId`; `tests/ScadaLink.Commons.Tests/` contract shape.
**Commit:** `feat(auditlog): NotifyDeliver rows carry the originating ParentExecutionId`
---
## Task 8: Repository — `GetExecutionTreeAsync`
**What:** A repository method that, given any `ExecutionId`, returns the whole execution chain rooted at the topmost ancestor — for the Central UI tree view.
**Files:**
- Create: `src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs` — a record: `ExecutionId`, `ParentExecutionId`, `RowCount`, channels present, statuses present, `SourceSiteId`, `SourceInstanceId`, `FirstOccurredAtUtc`, `LastOccurredAtUtc`.
- Modify: `src/ScadaLink.Commons/Interfaces/` — the Audit Log repository interface gains `Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(Guid executionId, CancellationToken ct)`.
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — implement it:
1. **Walk up** to the root — iterative `SELECT TOP 1 ParentExecutionId FROM AuditLog WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL` until none; the last `ExecutionId` with no parent is the root. Cap the loop (e.g. 32) against corrupt data.
2. **Walk down** — a recursive CTE seeded at the root, joining `child.ParentExecutionId = parent.ExecutionId`; `OPTION (MAXRECURSION 32)`. Project each distinct `ExecutionId` with the summary aggregates (`GROUP BY`).
Use `FromSqlInterpolated`/raw SQL for the recursive CTE (EF Core cannot express it in LINQ); keep the SQL append-only-safe (SELECT only).
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs``GetExecutionTree_MultiLevelChain` (3-level chain returns all nodes rooted at the ancestor regardless of the entry node); `GetExecutionTree_StubParentNode` (a `ParentExecutionId` referencing an execution with no rows of its own yields a node with `RowCount = 0` / is surfaced as referenced); `GetExecutionTree_RespectsMaxRecursion`.
**Note for implementer:** chains are shallow (12 levels typical). The `ParentExecutionId` graph is acyclic by construction; `MAXRECURSION` is a guard, not a routine limit. A purged parent simply ends the upward walk.
**Commit:** `feat(auditlog): GetExecutionTreeAsync recursive execution-chain query`
---
## Task 9: Central UI — `ParentExecutionId` column, filter, parent drill-in
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — add `ParentExecutionId` to the column set (short form / monospace, like `ExecutionId`); it participates in the existing resize/reorder + `ColumnOrder`.
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (+ `.razor.cs`) + `AuditQueryModel.cs` — a `ParentExecutionId` paste text-filter; `ToFilter` maps it to `AuditLogQueryFilter.ParentExecutionId`.
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs``ApplyQueryStringFilters` accepts `?parentExecutionId=<guid>`; `BuildExportUrl` emits it.
- Add a "View parent execution" row/drilldown action (in `AuditDrilldownDrawer` and/or a grid row action) linking `/audit/log?executionId=<ParentExecutionId>`, shown only when the row has a non-null `ParentExecutionId`. Mirror the existing `?executionId=` drill-in.
- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (column renders, filter maps, query-param parsed, drill-in hidden when `ParentExecutionId` null); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (parent drill-in filters the grid).
Use the `frontend-design` skill for the column/filter/drill-in styling. Custom Blazor + Bootstrap only — no component frameworks.
**Commit:** `feat(centralui): ParentExecutionId column, filter and parent drill-in on the Audit Log page`
---
## Task 10: Central UI — execution-chain tree view
**What:** A page that renders the full execution chain (rooted at the topmost ancestor) as an expandable tree, reached via a "View execution chain" drill-in.
**Files:**
- Create: `src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor` (+ `.razor.cs`) — route `/audit/execution-tree`, accepts `?executionId=<guid>`; calls `GetExecutionTreeAsync` via the same data path the Audit Log page uses for its grid (mirror that — repository service in-process, or a ManagementService endpoint if the grid goes through one; if the latter, add the endpoint in Task 11).
- Create: `src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor` (+ `.razor.cs` + `.razor.css`) — a custom recursive Blazor tree component: assembles the tree from the flat `ExecutionTreeNode` list, renders expandable nodes each showing the execution summary (id short form, row count, channels/statuses, site/instance, time span); a node referenced as a parent but with `RowCount = 0` renders as a stub ("execution with no audited actions"); clicking a node navigates to `/audit/log?executionId=<node>`.
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor` (+ `.razor.cs`) and/or `AuditResultsGrid` — add a "View execution chain" action linking `/audit/execution-tree?executionId=<ExecutionId of the row>`.
- Modify: the Central UI `Audit` nav group if the page should be linkable (decide — it is primarily a drill-in target; a nav entry is optional).
- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (tree assembled correctly from a flat list incl. multi-level + stub node; node click navigates); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (drill-in → tree renders → node click filters the Audit Log grid).
Use the `frontend-design` skill for the tree component. Clean, corporate, internal-use aesthetic; custom component, no frameworks.
**Commit:** `feat(centralui): execution-chain tree view on the Audit Log page`
---
## Task 11: CLI + ManagementService — `ParentExecutionId` filter
**Files:**
- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` + `AuditQueryHelpers.cs``audit query --parent-execution-id <guid>`; `AuditQueryArgs` + `BuildQueryString` emit `parentExecutionId`.
- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` `ParseFilter` — parse `parentExecutionId` query param into `AuditLogQueryFilter.ParentExecutionId` (lax-parse — unparseable dropped).
- Modify: `src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs` `ParseFilter` — same.
- If Task 10's tree page goes through ManagementService rather than the repository in-process: add `GET /api/audit/execution-tree?executionId=<guid>` to `AuditEndpoints.cs` returning the `ExecutionTreeNode` list. Otherwise skip this bullet. No CLI `audit tree` command in the first cut.
- Test: `tests/ScadaLink.CLI.Tests/`, `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`.
**Commit:** `feat(audit): ParentExecutionId filter in the CLI and ManagementService`
---
## Task 12: End-to-end integration test + docs
**Files:**
- Create: `tests/ScadaLink.IntegrationTests/AuditLog/ParentExecutionIdCorrelationTests.cs` — boot a site+central pair; issue an inbound API request whose method script calls `Route.Call` into a site instance; the routed site script does a sync `ExternalSystem.Call`, a cached call, and a `Notify.Send`. Assert: every audit row the routed run produced (site + central, sync + cached lifecycle + `NotifySend`/`NotifyDeliver`) carries `ParentExecutionId` = the inbound request's `ExecutionId`; each routed-run row has its own distinct `ExecutionId`; the inbound `InboundRequest` row has `ParentExecutionId = NULL`. Assert `GetExecutionTreeAsync` returns both executions in one chain.
- Modify: `docs/requirements/Component-AuditLog.md` — add `ParentExecutionId` to the `AuditLog` schema table and the index list (`IX_AuditLog_ParentExecution`); extend the `ExecutionId vs CorrelationId` section with a paragraph on `ParentExecutionId` (cross-execution correlation; inbound→routed bridge; immediate-spawner tree; tag cascade deferred). (Do NOT modify `alog.md`.)
- Modify: `CLAUDE.md` — under the Centralized Audit Log decisions, one line noting `ParentExecutionId` as the cross-execution spawn pointer (inbound→routed-site-script bridge; tag cascade deferred).
- Modify: component #23 summary in `CLAUDE.md`'s Current Component List if it enumerates correlation columns (keep it in sync).
**Commit:** `test(auditlog): end-to-end ParentExecutionId correlation + docs`
---
## Final review
Dispatch a final cross-cutting review of the whole branch; full `dotnet build ScadaLink.slnx` (0 warnings) + `dotnet test ScadaLink.slnx`; hand back to the user for the push/merge/redeploy decision (do not push).
## Dependency summary
0 blocks all. 1 ← 0. 2 ← 1. 3 ← 0. 4 ← 3. 5 ← 4, 2. 6 ← 5. 7 ← 5, 1. 8 ← 1. 9 ← 1. 10 ← 8, 9. 11 ← 1. 12 ← 5, 6, 7, 10, 11.
Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → final review.

View File

@@ -0,0 +1,19 @@
{
"planPath": "docs/plans/2026-05-21-audit-parent-executionid.md",
"tasks": [
{"id": 0, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"},
{"id": 1, "subject": "Task 1: Foundation — AuditEvent.ParentExecutionId + central AuditLog column", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Inbound request id minting + RouteToCallRequest.ParentExecutionId", "status": "pending", "blockedBy": [0]},
{"id": 4, "subject": "Task 4: Thread ParentExecutionId into routed script ScriptRuntimeContext", "status": "pending", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: Site script-side emitters stamp ParentExecutionId", "status": "pending", "blockedBy": [4, 2]},
{"id": 6, "subject": "Task 6: Cached S&F retry-loop rows carry ParentExecutionId", "status": "pending", "blockedBy": [5]},
{"id": 7, "subject": "Task 7: Central NotifyDeliver rows carry ParentExecutionId", "status": "pending", "blockedBy": [5, 1]},
{"id": 8, "subject": "Task 8: Repository — GetExecutionTreeAsync", "status": "pending", "blockedBy": [1]},
{"id": 9, "subject": "Task 9: Central UI — ParentExecutionId column, filter, parent drill-in", "status": "pending", "blockedBy": [1]},
{"id": 10, "subject": "Task 10: Central UI — execution-chain tree view", "status": "pending", "blockedBy": [8, 9]},
{"id": 11, "subject": "Task 11: CLI + ManagementService — ParentExecutionId filter", "status": "pending", "blockedBy": [1]},
{"id": 12, "subject": "Task 12: End-to-end integration test + docs", "status": "pending", "blockedBy": [5, 6, 7, 10, 11]}
],
"lastUpdated": "2026-05-21"
}

View File

@@ -84,6 +84,7 @@ row per lifecycle event across all channels.
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
| `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. |
| `ParentExecutionId` | `uniqueidentifier` NULL | The `ExecutionId` of the execution that *spawned* this run — the cross-execution correlation pointer. Set on every row of an inbound-API-routed site script run (= the inbound request's `ExecutionId`); NULL for a top-level run (inbound, tag-change / timer-triggered, un-bridged). |
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
@@ -105,6 +106,7 @@ row per lifecycle event across all channels.
- `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters.
- `IX_AuditLog_CorrelationId (CorrelationId)` — drilldown from a single operation.
- `IX_AuditLog_Execution (ExecutionId)` — drilldown to every action of one script execution / inbound request.
- `IX_AuditLog_ParentExecution (ParentExecutionId)` — cross-execution drilldown: the downward leg of the execution-tree walk seeks on it (`ParentExecutionId = ancestor.ExecutionId`), and it backs the `parentExecutionId` filter.
- `IX_AuditLog_Channel_Status_Occurred (Channel, Status, OccurredAtUtc)` — KPI / dashboard tiles.
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X".
- Monthly partitioning on `OccurredAtUtc` from day one; purge is a partition switch (see Retention & Purge).
@@ -149,6 +151,22 @@ The table carries two correlation columns at different granularities:
The two are orthogonal: one execution may touch several operations (each with
its own `CorrelationId`) yet every resulting row shares the one `ExecutionId`.
**`ParentExecutionId`** adds *cross-execution* correlation on top. `ExecutionId`
is per-run and flat — `WHERE ExecutionId = X` returns everything one run did, but
nothing links a run to the run that *spawned* it. `ParentExecutionId` carries the
spawning execution's `ExecutionId`: a spawned run still gets its own fresh
`ExecutionId`, and every audit row it emits also carries the spawner's id in
`ParentExecutionId`. The first cut bridges the **inbound API → routed-site-script**
case: an inbound request runs a method script that calls `Route.Call`, routing to
a site instance; the routed site script records the inbound request's
`ExecutionId` as its `ParentExecutionId`, while the inbound `InboundRequest` row
itself is top-level (`ParentExecutionId` NULL). The pointer always references the
*immediate* spawner, so a routed run that itself routes onward threads its own
`ExecutionId` — walking `ParentExecutionId → ExecutionId` recursively
reconstructs the call chain as a tree of arbitrary depth. The tag-cascade case
(an attribute write triggering another script) is **deferred** — the model
generalises to it with no schema change once that spawn point is threaded.
## The Site-Local `AuditLog` (SQLite)
A SQLite database file on each site node, alongside the Store-and-Forward

View File

@@ -115,6 +115,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
ExecutionId TEXT NULL,
ParentExecutionId TEXT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
@@ -135,11 +136,20 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// nullable with no default, so any row written before this migration
// reads back ExecutionId = null (back-compat).
AddColumnIfMissing("ExecutionId", "TEXT NULL");
// Audit Log #23 (ParentExecutionId): same idempotent upgrade path as
// ExecutionId above. A deployment that already ran the ExecutionId
// branch has an auditlog.db with the 21-column schema and no
// ParentExecutionId column; CREATE TABLE IF NOT EXISTS cannot add it,
// so it is ALTER-ed in here. Nullable with no default — rows written
// before this migration read back ParentExecutionId = null.
AddColumnIfMissing("ParentExecutionId", "TEXT NULL");
}
/// <summary>
/// Audit Log #23 (ExecutionId): adds a column to <c>AuditLog</c> only when
/// it is not already present. SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>,
/// Audit Log #23: additively adds a column to <c>AuditLog</c> only when
/// it is not already present (used for <c>ExecutionId</c> and
/// <c>ParentExecutionId</c>). SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>,
/// so the schema is probed via <c>PRAGMA table_info</c> first. Idempotent —
/// safe to run on every <see cref="InitializeSchema"/>. Mirrors
/// <c>StoreAndForwardStorage.AddColumnIfMissingAsync</c>; kept synchronous
@@ -263,13 +273,13 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
ExecutionId, ParentExecutionId
) VALUES (
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId,
$SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target,
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState,
$ExecutionId
$ExecutionId, $ParentExecutionId
);
""";
@@ -294,6 +304,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text);
var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text);
var pExecutionId = cmd.Parameters.Add("$ExecutionId", SqliteType.Text);
var pParentExecutionId = cmd.Parameters.Add("$ParentExecutionId", SqliteType.Text);
foreach (var pending in batch)
{
@@ -319,6 +330,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pExtra.Value = (object?)e.Extra ?? DBNull.Value;
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value;
pParentExecutionId.Value = (object?)e.ParentExecutionId?.ToString() ?? DBNull.Value;
try
{
@@ -377,7 +389,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
ExecutionId, ParentExecutionId
FROM AuditLog
WHERE ForwardState = $pending
ORDER BY OccurredAtUtc ASC, EventId ASC
@@ -426,7 +438,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
ExecutionId, ParentExecutionId
FROM AuditLog
WHERE ForwardState = $forwarded
ORDER BY OccurredAtUtc ASC, EventId ASC
@@ -513,7 +525,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
ExecutionId, ParentExecutionId
FROM AuditLog
WHERE ForwardState IN ($pending, $forwarded)
AND OccurredAtUtc >= $since
@@ -691,6 +703,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
Extra = reader.IsDBNull(18) ? null : reader.GetString(18),
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(19)),
ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)),
ParentExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
};
}

View File

@@ -137,6 +137,12 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
// execution's per-run correlation id, threaded through the S&F
// buffer; null on rows buffered before Task 4 (back-compat).
ExecutionId = context.ExecutionId,
// Audit Log #23 (ParentExecutionId Task 6): the spawning
// inbound-API request's ExecutionId, threaded through the S&F
// buffer alongside ExecutionId so the retry-loop cached rows
// correlate back to the cross-execution chain. Null for a
// non-routed run and on rows buffered before Task 6.
ParentExecutionId = context.ParentExecutionId,
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
SourceInstanceId = context.SourceInstanceId,
// Audit Log #23 (ExecutionId Task 4): SourceScript is now

View File

@@ -60,6 +60,7 @@ public static class AuditCommands
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
var executionIdOption = new Option<string?>("--execution-id") { Description = "Filter by execution ID" };
var parentExecutionIdOption = new Option<string?>("--parent-execution-id") { Description = "Filter by parent execution ID" };
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
pageSizeOption.DefaultValueFactory = _ => 100;
@@ -76,6 +77,7 @@ public static class AuditCommands
cmd.Add(actorOption);
cmd.Add(correlationIdOption);
cmd.Add(executionIdOption);
cmd.Add(parentExecutionIdOption);
cmd.Add(errorsOnlyOption);
cmd.Add(pageSizeOption);
cmd.Add(allOption);
@@ -104,6 +106,7 @@ public static class AuditCommands
Actor = result.GetValue(actorOption),
CorrelationId = result.GetValue(correlationIdOption),
ExecutionId = result.GetValue(executionIdOption),
ParentExecutionId = result.GetValue(parentExecutionIdOption),
ErrorsOnly = result.GetValue(errorsOnlyOption),
PageSize = result.GetValue(pageSizeOption),
};

View File

@@ -25,6 +25,7 @@ public sealed class AuditQueryArgs
public string? Actor { get; set; }
public string? CorrelationId { get; set; }
public string? ExecutionId { get; set; }
public string? ParentExecutionId { get; set; }
public bool ErrorsOnly { get; set; }
public int PageSize { get; set; } = 100;
}
@@ -127,6 +128,7 @@ public static class AuditQueryHelpers
Add("actor", args.Actor);
Add("correlationId", args.CorrelationId);
Add("executionId", args.ExecutionId);
Add("parentExecutionId", args.ParentExecutionId);
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
if (afterOccurredAtUtc.HasValue)

View File

@@ -112,6 +112,13 @@ public static class AuditExportEndpoints
executionId = parsedExec;
}
Guid? parentExecutionId = null;
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
{
parentExecutionId = parsedParentExec;
}
DateTime? fromUtc = ParseUtcDate(query, "from");
DateTime? toUtc = ParseUtcDate(query, "to");
@@ -124,6 +131,7 @@ public static class AuditExportEndpoints
Actor: actor,
CorrelationId: correlationId,
ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: fromUtc,
ToUtc: toUtc);
}

View File

@@ -58,6 +58,9 @@
<dt class="col-4 text-muted fw-normal">ExecutionId</dt>
<dd class="col-8 font-monospace" data-test="field-ExecutionId">@(Event.ExecutionId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">ParentExecutionId</dt>
<dd class="col-8 font-monospace" data-test="field-ParentExecutionId">@(Event.ParentExecutionId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
@@ -162,6 +165,22 @@
View this execution
</button>
}
@if (Event.ParentExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-parent-execution"
@onclick="ViewParentExecution">
View parent execution
</button>
}
@if (Event.ExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-execution-chain"
@onclick="ViewExecutionChain">
View execution chain
</button>
}
<button class="btn btn-primary btn-sm ms-auto"
data-test="drawer-close-footer"
@onclick="HandleClose">

View File

@@ -49,7 +49,10 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// the "Show all events" button navigates to
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
/// button navigates to <c>/audit/log?executionId={id}</c>. Both are deep
/// button navigates to <c>/audit/log?executionId={id}</c>. Likewise, when
/// <see cref="AuditEvent.ParentExecutionId"/> is set the "View parent
/// execution" button navigates to <c>/audit/log?executionId={parentId}</c>
/// — the spawner's id used as the per-run drill-in target. All are deep
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
/// </para>
/// </summary>
@@ -291,6 +294,37 @@ public partial class AuditDrilldownDrawer
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to the spawner execution: a routed (child) row carries a non-null
/// <see cref="AuditEvent.ParentExecutionId"/>. Navigates to
/// <c>/audit/log?executionId={ParentExecutionId}</c> so the user sees the
/// spawner execution's own rows — the parent's id becomes the <c>?executionId=</c>
/// drill-in target. The button is only rendered when
/// <see cref="AuditEvent.ParentExecutionId"/> is non-null, so this is total.
/// </summary>
private void ViewParentExecution()
{
if (Event?.ParentExecutionId is not { } parentExec) return;
var uri = $"/audit/log?executionId={parentExec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to the execution-chain TREE view (Audit Log ParentExecutionId
/// feature, Task 10). Navigates to
/// <c>/audit/execution-tree?executionId={ExecutionId}</c> — the tree page
/// resolves the whole chain rooted at the topmost ancestor and renders it
/// expandably, with this row's execution highlighted. The button is only
/// rendered when <see cref="AuditEvent.ExecutionId"/> is non-null, so this
/// is total.
/// </summary>
private void ViewExecutionChain()
{
if (Event?.ExecutionId is not { } exec) return;
var uri = $"/audit/execution-tree?executionId={exec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Build a cURL command from an audit event. The URL comes from
/// <c>Target</c>; when the RequestSummary parses as

View File

@@ -127,6 +127,16 @@
placeholder="paste GUID…" @bind="_model.ExecutionId" />
</div>
@* ParentExecutionId is an exact-match Guid filter — the operator pastes
the spawner execution's id to find every run it spawned. Lax-parsed
in ToFilter, exactly like ExecutionId above. *@
<div class="col-auto" data-test="filter-parent-execution-id">
<label class="form-label small mb-1" for="audit-parent-execution-id">Parent execution ID</label>
<input id="audit-parent-execution-id" type="text"
class="form-control form-control-sm font-monospace"
placeholder="paste GUID…" @bind="_model.ParentExecutionId" />
</div>
<div class="col-auto" data-test="filter-errors-only">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="audit-errors-only"

View File

@@ -136,6 +136,7 @@ public partial class AuditFilterBar
_model.TargetSearch = string.Empty;
_model.ActorSearch = string.Empty;
_model.ExecutionId = string.Empty;
_model.ParentExecutionId = string.Empty;
_model.ErrorsOnly = false;
}

View File

@@ -55,6 +55,15 @@ public sealed class AuditQueryModel
/// </summary>
public string ExecutionId { get; set; } = string.Empty;
/// <summary>
/// Paste-in ParentExecutionId filter — the operator pastes the spawner
/// execution's Guid to find every run it spawned. Stored as free text;
/// <see cref="ToFilter"/> lax-parses it through
/// <see cref="Guid.TryParse(string?, out Guid)"/> so a blank or unparseable
/// value simply yields no constraint, mirroring <see cref="ExecutionId"/>.
/// </summary>
public string ParentExecutionId { get; set; } = string.Empty;
public bool ErrorsOnly { get; set; }
/// <summary>
@@ -128,6 +137,11 @@ public sealed class AuditQueryModel
? parsedExecutionId
: null;
// Same lax-parse contract for the pasted ParentExecutionId.
Guid? parentExecutionId = Guid.TryParse(ParentExecutionId, out var parsedParentExecutionId)
? parsedParentExecutionId
: null;
return new AuditLogQueryFilter(
Channels: Channels.Count > 0 ? Channels.ToArray() : null,
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
@@ -137,6 +151,7 @@ public sealed class AuditQueryModel
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
CorrelationId: null,
ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: fromUtc,
ToUtc: toUtc);
}

View File

@@ -132,6 +132,18 @@
<span class="small text-muted">—</span>
}
break;
case "ParentExecutionId":
@if (row.ParentExecutionId is { } parentExecutionId)
{
<span class="small font-monospace"
data-test="parent-execution-id-@row.EventId"
title="@parentExecutionId">@ShortGuid(parentExecutionId)</span>
}
else
{
<span class="small text-muted">—</span>
}
break;
case "DurationMs":
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
break;

View File

@@ -11,7 +11,8 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3).
/// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc,
/// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus,
/// ErrorMessage — plus the ExecutionId per-run correlation column. Talks to
/// ErrorMessage — plus the ExecutionId per-run correlation column and the
/// ParentExecutionId spawner-correlation column. Talks to
/// <see cref="Services.IAuditLogQueryService"/>
/// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data
/// source without standing up EF Core.
@@ -123,6 +124,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
("Target", "Target"),
("Actor", "Actor"),
("ExecutionId", "ExecutionId"),
("ParentExecutionId", "ParentExecutionId"),
("DurationMs", "DurationMs"),
("HttpStatus", "HttpStatus"),
("ErrorMessage", "ErrorMessage"),

View File

@@ -0,0 +1,123 @@
@using ScadaLink.Commons.Types.Audit
@* Execution-chain tree (Audit Log ParentExecutionId feature, Task 10).
A custom recursive Blazor tree: the host hands in the FLAT ExecutionTreeNode
list the repository returns; this component assembles it into a tree (joining
ParentExecutionId → a parent's ExecutionId), then renders depth-first.
Recursion is expressed by the component rendering <ExecutionTree> for each
child subtree. To keep that recursion finite even on corrupt/cyclic input,
the assembled subtree is computed ONCE at the root (Depth == 0) and threaded
downward via the PreBuiltRoots parameter — child instances never re-run the
flat-list assembly, and the assembly itself tracks visited ExecutionIds so a
cycle is broken on first revisit. *@
@if (_rootsToRender.Count == 0)
{
return;
}
<ul class="execution-tree @(Depth == 0 ? "execution-tree--root" : "")"
data-test="execution-tree@(Depth == 0 ? "" : "-subtree")">
@foreach (var subtree in _rootsToRender)
{
var node = subtree.Node;
var isCurrent = node.ExecutionId == ArrivedFromExecutionId;
var isStub = node.RowCount == 0;
<li class="execution-tree-item" @key="node.ExecutionId">
<div class="execution-tree-node @(isCurrent ? "execution-tree-node--current" : "") @(isStub ? "execution-tree-node--stub" : "")"
data-test="tree-node-@node.ExecutionId">
@if (subtree.Children.Count > 0)
{
<button type="button"
class="execution-tree-toggle"
data-test="tree-toggle-@node.ExecutionId"
aria-expanded="@(IsExpanded(node.ExecutionId) ? "true" : "false")"
aria-label="@(IsExpanded(node.ExecutionId) ? "Collapse" : "Expand") child executions"
@onclick="() => ToggleExpand(node.ExecutionId)">
<span class="execution-tree-toggle-glyph" aria-hidden="true">
@(IsExpanded(node.ExecutionId) ? "" : "+")
</span>
</button>
}
else
{
<span class="execution-tree-toggle execution-tree-toggle--leaf" aria-hidden="true"></span>
}
<div class="execution-tree-body">
<div class="execution-tree-headline">
<a class="execution-tree-link font-monospace"
data-test="tree-node-link-@node.ExecutionId"
href="@AuditLogUrl(node.ExecutionId)"
title="Open the Audit Log filtered to execution @node.ExecutionId">
@ShortId(node.ExecutionId)
</a>
@if (isCurrent)
{
<span class="badge text-bg-primary execution-tree-tag"
data-test="tree-current-tag-@node.ExecutionId">Arrived from</span>
}
@if (isStub)
{
<span class="badge text-bg-secondary execution-tree-tag"
data-test="stub-node-@node.ExecutionId">No audited actions</span>
}
else
{
<span class="execution-tree-rowcount text-muted small"
data-test="tree-rowcount-@node.ExecutionId">
@node.RowCount audit @(node.RowCount == 1 ? "row" : "rows")
</span>
}
</div>
@if (isStub)
{
<div class="execution-tree-meta text-muted small">
Execution with no audited actions — referenced as a parent, but it
emitted no audit rows of its own (or its rows have been purged).
</div>
}
else
{
<div class="execution-tree-meta small">
<span class="execution-tree-meta-item">
<span class="text-muted">Source</span>
@(node.SourceSiteId ?? "—")@(node.SourceInstanceId is null ? "" : " / " + node.SourceInstanceId)
</span>
@if (node.Channels.Count > 0)
{
<span class="execution-tree-meta-item">
<span class="text-muted">Channels</span>
@string.Join(", ", node.Channels)
</span>
}
@if (node.Statuses.Count > 0)
{
<span class="execution-tree-meta-item">
<span class="text-muted">Statuses</span>
@string.Join(", ", node.Statuses)
</span>
}
<span class="execution-tree-meta-item">
<span class="text-muted">Time span</span>
@FormatSpan(node.FirstOccurredAtUtc, node.LastOccurredAtUtc)
</span>
</div>
}
</div>
</div>
@if (subtree.Children.Count > 0 && IsExpanded(node.ExecutionId))
{
@* Recurse: each child subtree is already assembled, so the
nested instance renders directly from PreBuiltRoots and skips
the flat-list assembly entirely. *@
<ExecutionTree PreBuiltRoots="subtree.Children"
ArrivedFromExecutionId="ArrivedFromExecutionId"
Depth="Depth + 1" />
}
</li>
}
</ul>

View File

@@ -0,0 +1,266 @@
using System.Globalization;
using Microsoft.AspNetCore.Components;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.CentralUI.Components.Audit;
/// <summary>
/// Recursive Blazor tree component for the execution-chain view (Audit Log
/// ParentExecutionId feature, Task 10).
///
/// <para>
/// <b>Flat list → tree.</b> The repository / query service returns the chain as
/// a FLAT <see cref="ExecutionTreeNode"/> list (one per distinct execution). The
/// root instance (<see cref="Depth"/> == 0) assembles it once in
/// <see cref="OnParametersSet"/>: it groups by <see cref="ExecutionTreeNode.ExecutionId"/>,
/// links each node to its parent via <see cref="ExecutionTreeNode.ParentExecutionId"/>,
/// and identifies the roots (nodes whose parent is null or not present in the
/// list — a purged/ghost parent). Nested instances skip assembly: the parent
/// hands each child subtree down pre-built via <see cref="PreBuiltRoots"/>.
/// </para>
///
/// <para>
/// <b>Cycle safety.</b> The <c>ParentExecutionId</c> graph is acyclic by
/// construction, but the UI must not infinite-loop on corrupt data. Assembly
/// tracks visited <see cref="ExecutionTreeNode.ExecutionId"/> values while
/// walking children, so a node is attached to the tree at most once — a cycle
/// (A→B, B→A) is broken at the first revisit and every execution still renders
/// exactly once.
/// </para>
///
/// <para>
/// <b>Presentation.</b> Each node shows the short execution id (a link to
/// <c>/audit/log?executionId={id}</c>), row count, channels/statuses, source
/// site/instance, and time span. A stub node (<see cref="ExecutionTreeNode.RowCount"/>
/// == 0) is marked "No audited actions". The node the user arrived from
/// (<see cref="ArrivedFromExecutionId"/>) is highlighted. Nodes with children
/// are expandable; all nodes start expanded so the whole chain is visible.
/// </para>
/// </summary>
public partial class ExecutionTree
{
/// <summary>
/// One assembled subtree: a node plus its already-linked child subtrees.
/// Recursive — children are themselves <see cref="Subtree"/> values.
/// </summary>
/// <param name="Node">The execution this subtree is rooted at.</param>
/// <param name="Children">
/// Child subtrees, ordered by <c>(FirstOccurredAtUtc ?? DateTime.MaxValue,
/// ExecutionId)</c> — earliest first-occurrence time first, stub nodes
/// (null timestamp) last, with <c>ExecutionId</c> breaking ties.
/// </param>
public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList<Subtree> Children);
/// <summary>
/// The flat node list to assemble into a tree. Supplied on the ROOT
/// instance only (<see cref="Depth"/> == 0); nested instances receive
/// <see cref="PreBuiltRoots"/> instead.
/// </summary>
[Parameter] public IReadOnlyList<ExecutionTreeNode>? Nodes { get; set; }
/// <summary>
/// Pre-assembled child subtrees, threaded down from a parent
/// <see cref="ExecutionTree"/> so nested instances render without
/// re-running the flat-list assembly. Null / unused on the root instance.
/// </summary>
[Parameter] public IReadOnlyList<Subtree>? PreBuiltRoots { get; set; }
/// <summary>
/// The execution the user drilled in from — its node is visually
/// highlighted so the user keeps their bearings within the chain.
/// </summary>
[Parameter] public Guid ArrivedFromExecutionId { get; set; }
/// <summary>
/// Nesting depth. 0 on the root instance (which owns flat-list assembly);
/// each recursive child increments it. Used purely to pick the assembly
/// path and to tag the root <c>&lt;ul&gt;</c> for styling.
/// </summary>
[Parameter] public int Depth { get; set; }
// The subtrees this instance renders: assembled from Nodes on the root,
// or taken straight from PreBuiltRoots on a nested instance.
private IReadOnlyList<Subtree> _rootsToRender = Array.Empty<Subtree>();
// The Nodes reference the current _rootsToRender was assembled from. Used
// to skip a redundant re-assembly when OnParametersSet fires for an
// unrelated parameter change (the flat list itself is unchanged).
private IReadOnlyList<ExecutionTreeNode>? _assembledFrom;
// Per-execution expand/collapse state. Absent => expanded (the default):
// the whole chain is shown on arrival so the user sees the full picture.
private readonly HashSet<Guid> _collapsed = new();
protected override void OnParametersSet()
{
// Nested instance: the parent already assembled our subtrees.
if (Depth > 0)
{
_rootsToRender = PreBuiltRoots ?? Array.Empty<Subtree>();
return;
}
// Root instance: assemble the flat list into a tree. Re-assemble only
// when the Nodes reference itself changes — OnParametersSet also fires
// for unrelated parameter changes (e.g. ArrivedFromExecutionId), and
// re-running assembly then would needlessly rebuild an identical tree.
if (!ReferenceEquals(Nodes, _assembledFrom))
{
_assembledFrom = Nodes;
_rootsToRender = BuildForest(Nodes ?? Array.Empty<ExecutionTreeNode>());
}
}
/// <summary>
/// Assembles the flat <see cref="ExecutionTreeNode"/> list into a forest of
/// <see cref="Subtree"/> values. There is normally exactly one root (the
/// chain's topmost ancestor); the method returns a list to stay total if
/// the input ever contains disjoint fragments. A fully-cyclic feed has no
/// real root, so each remaining cyclic component is seeded with a fallback
/// root after the main pass — every execution in <paramref name="nodes"/>
/// is therefore placed in the forest exactly once.
/// </summary>
private static IReadOnlyList<Subtree> BuildForest(IReadOnlyList<ExecutionTreeNode> nodes)
{
if (nodes.Count == 0)
{
return Array.Empty<Subtree>();
}
// De-dupe defensively: the repository emits one node per execution, but
// a corrupt feed could repeat an id. First write wins.
var byId = new Dictionary<Guid, ExecutionTreeNode>();
foreach (var node in nodes)
{
byId.TryAdd(node.ExecutionId, node);
}
// Children grouped by parent id. A node whose parent is null or absent
// from the list (a purged/ghost parent) is a root.
var childrenByParent = new Dictionary<Guid, List<ExecutionTreeNode>>();
var roots = new List<ExecutionTreeNode>();
foreach (var node in byId.Values)
{
if (node.ParentExecutionId is { } parentId && byId.ContainsKey(parentId))
{
if (!childrenByParent.TryGetValue(parentId, out var bucket))
{
bucket = new List<ExecutionTreeNode>();
childrenByParent[parentId] = bucket;
}
bucket.Add(node);
}
else
{
roots.Add(node);
}
}
var visited = new HashSet<Guid>();
var forest = roots
.OrderBy(SortKey)
.Select(root => BuildSubtree(root, childrenByParent, visited))
.ToList();
// Cycle guard: if the input is fully cyclic every node has a present
// parent, so a cyclic component contributes no entry to `roots`. Any
// execution still missing from `visited` after the pass above belongs
// to such a component (a corrupt feed may contain several independent
// cycles, e.g. A↔B and C↔D). Seed the lowest-ordered unvisited id of
// each remaining component as an extra root and assemble it, looping
// until every node has been placed — so every execution renders.
while (visited.Count < byId.Count)
{
var fallbackRoot = byId.Values
.Where(n => !visited.Contains(n.ExecutionId))
.OrderBy(SortKey)
.First();
forest.Add(BuildSubtree(fallbackRoot, childrenByParent, visited));
}
return forest;
}
/// <summary>
/// Recursively builds one <see cref="Subtree"/>, tracking
/// <paramref name="visited"/> so a cyclic flat list cannot drive unbounded
/// recursion — a node already attached is never descended into again.
/// </summary>
private static Subtree BuildSubtree(
ExecutionTreeNode node,
IReadOnlyDictionary<Guid, List<ExecutionTreeNode>> childrenByParent,
HashSet<Guid> visited)
{
visited.Add(node.ExecutionId);
var children = new List<Subtree>();
if (childrenByParent.TryGetValue(node.ExecutionId, out var directChildren))
{
foreach (var child in directChildren.OrderBy(SortKey))
{
// Cycle / DAG guard: skip any execution already placed in the
// tree so each renders exactly once and recursion terminates.
if (visited.Contains(child.ExecutionId))
{
continue;
}
children.Add(BuildSubtree(child, childrenByParent, visited));
}
}
return new Subtree(node, children);
}
// Stable child ordering: earliest activity first; stub nodes (null
// timestamp) sort last; ExecutionId breaks ties so rendering is
// deterministic across requests.
private static (DateTime, Guid) SortKey(ExecutionTreeNode node)
=> (node.FirstOccurredAtUtc ?? DateTime.MaxValue, node.ExecutionId);
private bool IsExpanded(Guid executionId) => !_collapsed.Contains(executionId);
private void ToggleExpand(Guid executionId)
{
if (!_collapsed.Remove(executionId))
{
_collapsed.Add(executionId);
}
}
/// <summary>Audit Log deep link filtered to one execution's rows.</summary>
private static string AuditLogUrl(Guid executionId)
=> $"/audit/log?executionId={executionId}";
/// <summary>First 8 hex digits — the short-id presentation used across the Audit UI.</summary>
private static string ShortId(Guid value)
{
var n = value.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
/// <summary>
/// Renders the [first, last] occurrence span. Both null on a stub node
/// (handled by the caller); a single-row execution shows one timestamp.
/// </summary>
private static string FormatSpan(DateTime? firstUtc, DateTime? lastUtc)
{
if (firstUtc is null && lastUtc is null)
{
return "—";
}
var first = firstUtc ?? lastUtc!.Value;
var last = lastUtc ?? firstUtc!.Value;
var firstText = Iso(first);
if (first == last)
{
return firstText;
}
return $"{firstText} → {Iso(last)}";
}
// Audit timestamps are UTC by system convention, so the value is formatted
// with a literal 'Z' suffix without re-tagging its DateTimeKind.
private static string Iso(DateTime utc)
=> utc.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,137 @@
/* Execution-chain tree (Audit Log ParentExecutionId feature, Task 10).
Clean, corporate, internal-tool aesthetic — consistent with the Audit Log
grid / drilldown drawer. Bootstrap CSS variables drive every colour so the
tree tracks the active theme. No component framework, no JS for layout. */
.execution-tree {
list-style: none;
margin: 0;
padding: 0;
}
/* Nested lists indent and carry a vertical guide rule that ties children to
their parent — the classic file-tree connector, kept subtle. */
.execution-tree--root {
padding-left: 0;
}
.execution-tree .execution-tree {
margin-left: 0.75rem;
padding-left: 1rem;
border-left: 1px solid var(--bs-border-color);
}
.execution-tree-item {
position: relative;
}
/* The node card: a flex row of [toggle][body]. */
.execution-tree-node {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
margin: 0.25rem 0;
border: 1px solid var(--bs-border-color);
border-radius: 0.375rem;
background-color: var(--bs-body-bg);
}
/* The execution the user drilled in from — a left accent rule + tinted
background so it stands out without shouting. */
.execution-tree-node--current {
border-color: var(--bs-primary-border-subtle);
background-color: var(--bs-primary-bg-subtle);
box-shadow: inset 3px 0 0 0 var(--bs-primary);
}
/* Stub node — an execution with no audited actions. Muted + dashed border so
it reads as a placeholder rather than a real audited execution. */
.execution-tree-node--stub {
border-style: dashed;
background-color: var(--bs-tertiary-bg);
}
/* Expand / collapse control. A small square that mirrors the table-light
header tone used elsewhere on the Audit pages. */
.execution-tree-toggle {
flex: 0 0 auto;
width: 1.25rem;
height: 1.25rem;
margin-top: 0.0625rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--bs-border-color);
border-radius: 0.25rem;
background-color: var(--bs-tertiary-bg);
color: var(--bs-secondary-color);
line-height: 1;
cursor: pointer;
}
.execution-tree-toggle:hover {
background-color: var(--bs-secondary-bg);
color: var(--bs-body-color);
}
.execution-tree-toggle--leaf {
border-color: transparent;
background-color: transparent;
cursor: default;
}
.execution-tree-toggle-glyph {
font-size: 0.875rem;
font-weight: 600;
}
.execution-tree-body {
flex: 1 1 auto;
min-width: 0;
}
/* Headline row: short id link, tags, row count. */
.execution-tree-headline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.execution-tree-link {
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
}
.execution-tree-link:hover {
text-decoration: underline;
}
.execution-tree-tag {
font-weight: 500;
font-size: 0.6875rem;
}
.execution-tree-rowcount {
margin-left: auto;
}
/* Meta row: source / channels / statuses / time span, pipe-separated visually
via spacing rather than literal separators. */
.execution-tree-meta {
margin-top: 0.25rem;
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
color: var(--bs-body-color);
}
.execution-tree-meta-item .text-muted {
margin-right: 0.25rem;
text-transform: uppercase;
font-size: 0.6875rem;
letter-spacing: 0.02em;
}

View File

@@ -23,7 +23,9 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
/// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds
/// <c>?executionId=</c> for the "View this execution" drill-in. When any param is present we allocate a
/// <c>?executionId=</c> for the "View this execution" drill-in, and the
/// ParentExecutionId follow-up adds <c>?parentExecutionId=</c> for the
/// "View parent execution" drill-in. When any param is present we allocate a
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
@@ -71,6 +73,15 @@ public partial class AuditLogPage
executionId = parsedExec;
}
// ?parentExecutionId= constrains to runs spawned by a given execution.
// Lax-parsed like ?executionId=: an unparseable value is silently dropped.
Guid? parentExecutionId = null;
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
{
parentExecutionId = parsedParentExec;
}
string? target = null;
if (query.TryGetValue("target", out var targetValues))
{
@@ -128,7 +139,8 @@ public partial class AuditLogPage
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load
// because the filter contract has no instance column — the user still needs
// to refine + Apply for those.
if (correlationId is null && executionId is null && target is null && actor is null
if (correlationId is null && executionId is null && parentExecutionId is null
&& target is null && actor is null
&& sites is null && channels is null && kinds is null && statuses is null)
{
return;
@@ -142,7 +154,8 @@ public partial class AuditLogPage
Target: target,
Actor: actor,
CorrelationId: correlationId,
ExecutionId: executionId);
ExecutionId: executionId,
ParentExecutionId: parentExecutionId);
}
/// <summary>
@@ -252,6 +265,10 @@ public partial class AuditLogPage
{
parts.Add(new("executionId", exec.ToString()));
}
if (filter.ParentExecutionId is { } parentExec)
{
parts.Add(new("parentExecutionId", parentExec.ToString()));
}
if (filter.FromUtc is { } from)
{
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));

View File

@@ -0,0 +1,63 @@
@page "/audit/execution-tree"
@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)]
@using ScadaLink.CentralUI.Components.Audit
@using ScadaLink.CentralUI.Services
@using ScadaLink.Commons.Types.Audit
@using ScadaLink.Security
@inject IAuditLogQueryService AuditLogQueryService
<PageTitle>Execution Chain</PageTitle>
@* Execution-chain tree view (Audit Log ParentExecutionId feature, Task 10).
A drill-in target reached from the Audit Log drawer's "View execution chain"
action: /audit/execution-tree?executionId={guid}. The page parses the id,
asks the query service for the whole chain (flat ExecutionTreeNode list), and
hands it to the recursive ExecutionTree component. There is deliberately NO
nav-menu entry — this page is only meaningful in the context of a specific
execution, so it is reachable only via drill-in (the Audit nav group keeps
just the Audit Log + Configuration Audit Log pages). *@
<div class="container-fluid mt-3">
<h1 class="h4 mb-1">Execution Chain</h1>
<p class="text-muted small mb-3">
The full chain of script / inbound-request executions linked by
<span class="font-monospace">ParentExecutionId</span>, rooted at the
topmost ancestor. Select an execution to open the Audit Log filtered to
its rows.
</p>
@if (_executionId is null)
{
@* No (or unparseable) ?executionId= — render guidance rather than an
empty tree. Mirrors the Audit Log page's silently-drop contract. *@
<div class="alert alert-secondary small" data-test="execution-tree-no-id">
No execution selected. Open this view from an audit row's
<strong>View execution chain</strong> action.
</div>
}
else if (_loading)
{
<div class="text-muted small" data-test="execution-tree-loading">Loading execution chain…</div>
}
else if (_error is not null)
{
<div class="alert alert-danger small" data-test="execution-tree-error">@_error</div>
}
else if (_nodes is { Count: > 0 })
{
<div class="mb-2">
<a class="btn btn-outline-secondary btn-sm"
data-test="execution-tree-back-to-log"
href="@($"/audit/log?executionId={_executionId}")">
View this execution in the Audit Log
</a>
</div>
<ExecutionTree Nodes="_nodes" ArrivedFromExecutionId="_executionId.Value" />
}
else
{
<div class="alert alert-secondary small" data-test="execution-tree-empty">
No execution chain found for this id.
</div>
}
</div>

View File

@@ -0,0 +1,93 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.CentralUI.Components.Pages.Audit;
/// <summary>
/// Code-behind for the execution-chain tree page (Audit Log ParentExecutionId
/// feature, Task 10). Route <c>/audit/execution-tree</c>, reached via the Audit
/// Log drilldown drawer's "View execution chain" action with
/// <c>?executionId={guid}</c>.
///
/// <para>
/// On initialization the page parses <c>?executionId=</c> (lax-parsed, matching
/// the Audit Log page's drill-in contract — an absent or unparseable value
/// leaves the page in a guidance state and issues NO service call), then asks
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService.GetExecutionTreeAsync"/>
/// for the whole chain. The flat <see cref="ExecutionTreeNode"/> list is handed
/// to the recursive <c>ExecutionTree</c> component, which assembles + renders
/// the tree.
/// </para>
///
/// <para>
/// The data path mirrors the Audit Log results grid: the page talks ONLY to the
/// CentralUI <c>IAuditLogQueryService</c> facade, never <c>IAuditLogRepository</c>
/// directly, so the page can be unit-tested with a substituted service.
/// </para>
/// </summary>
public partial class ExecutionTreePage
{
[Inject] private NavigationManager Navigation { get; set; } = null!;
// The parsed ?executionId= value, or null when absent / unparseable.
private Guid? _executionId;
// The flat chain returned by the query service; null until the load
// completes (or when no id was supplied).
private IReadOnlyList<ExecutionTreeNode>? _nodes;
private bool _loading;
private string? _error;
protected override async Task OnInitializedAsync()
{
_executionId = ParseExecutionId();
if (_executionId is null)
{
// No id — render guidance, do not touch the service.
return;
}
await LoadChainAsync(_executionId.Value);
}
/// <summary>
/// Lax-parses <c>?executionId=</c>. Returns null when the param is absent or
/// is not a valid <see cref="Guid"/> — the page then shows guidance instead
/// of an error, consistent with the Audit Log page's drill-in handling.
/// </summary>
private Guid? ParseExecutionId()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = QueryHelpers.ParseQuery(uri.Query);
if (query.TryGetValue("executionId", out var values)
&& Guid.TryParse(values.ToString(), out var parsed))
{
return parsed;
}
return null;
}
private async Task LoadChainAsync(Guid executionId)
{
_loading = true;
_error = null;
try
{
_nodes = await AuditLogQueryService.GetExecutionTreeAsync(executionId);
}
catch (Exception ex)
{
// A transient DB outage degrades this page to an error banner
// rather than killing the circuit — the same defensive posture the
// Audit Log grid takes around its query.
_error = $"Could not load the execution chain: {ex.Message}";
_nodes = null;
}
finally
{
_loading = false;
}
}
}

View File

@@ -132,4 +132,23 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
return repoSnapshot with { BacklogTotal = backlog };
}
/// <inheritdoc/>
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default)
{
// Test-seam ctor: use the injected repository directly.
if (_injectedRepository is not null)
{
return await _injectedRepository.GetExecutionTreeAsync(executionId, ct);
}
// Production: a fresh scope (and thus a fresh DbContext) per call — the
// same context-isolation contract QueryAsync upholds, so the tree
// page's auto-load never shares the circuit-scoped context.
await using var scope = _scopeFactory!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
return await repository.GetExecutionTreeAsync(executionId, ct);
}
}

View File

@@ -50,4 +50,23 @@ public interface IAuditLogQueryService
/// dashboard.
/// </remarks>
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default);
/// <summary>
/// Audit Log ParentExecutionId feature (Task 10) — returns the full
/// execution chain containing <paramref name="executionId"/> as a flat list
/// of <see cref="ExecutionTreeNode"/>, delegating to
/// <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.GetExecutionTreeAsync"/>.
/// The execution-chain tree view (<c>/audit/execution-tree</c>) assembles the
/// returned flat list into a tree by joining
/// <see cref="ExecutionTreeNode.ParentExecutionId"/> to a parent node's
/// <see cref="ExecutionTreeNode.ExecutionId"/>.
/// </summary>
/// <remarks>
/// A pure pass-through, mirroring <see cref="QueryAsync"/> — the production
/// implementation opens its own DI scope per call so the tree page's
/// auto-load never contends with the circuit-scoped <c>ScadaLinkDbContext</c>.
/// </remarks>
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default);
}

View File

@@ -33,6 +33,13 @@ public sealed record AuditEvent
/// </summary>
public Guid? ExecutionId { get; init; }
/// <summary>
/// <see cref="ExecutionId"/> of the execution that spawned this run, when this
/// run was spawned by another; null for top-level runs. Lets a spawned
/// execution point back at its spawner for cross-run correlation.
/// </summary>
public Guid? ParentExecutionId { get; init; }
/// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; }

View File

@@ -36,6 +36,15 @@ public class Notification
/// submitted before the column existed, or raised outside a script-execution context.
/// </summary>
public Guid? OriginExecutionId { get; set; }
/// <summary>
/// The originating routed script execution's <c>ParentExecutionId</c> (Audit Log #23).
/// Carried from the site on the <see cref="Commons.Messages.Notification.NotificationSubmit"/>
/// so the central dispatcher can stamp the same parent id onto its <c>NotifyDeliver</c>
/// audit rows, correlating them with the site-emitted <c>NotifySend</c> row. Null for
/// non-routed runs, or for notifications submitted before the column existed.
/// </summary>
public Guid? OriginParentExecutionId { get; set; }
public DateTimeOffset SiteEnqueuedAt { get; set; }
/// <summary>Central ingest time.</summary>

View File

@@ -134,4 +134,45 @@ public interface IAuditLogRepository
TimeSpan window,
DateTime? nowUtc = null,
CancellationToken ct = default);
/// <summary>
/// Audit Log ParentExecutionId feature (Task 8) — given any
/// <paramref name="executionId"/> in an execution chain, returns the whole
/// chain rooted at the topmost ancestor: one <see cref="ExecutionTreeNode"/>
/// per distinct execution, summarising its <c>AuditLog</c> rows. The Central
/// UI renders the result as a tree.
/// </summary>
/// <remarks>
/// <para>
/// The input id may be any node in the chain — a leaf, the root, or a middle
/// node. The implementation first walks <em>up</em> via
/// <c>ParentExecutionId</c> to find the root, then walks <em>down</em> from
/// the root via a recursive CTE, so the full chain is returned regardless of
/// entry point.
/// </para>
/// <para>
/// The <c>ParentExecutionId</c> graph is a tree (acyclic by construction —
/// each execution is minted fresh and its parent always pre-exists). Both
/// the upward walk and the downward CTE are nonetheless bounded at 32 levels
/// as a guard against corrupt/pathological data: a depth that exceeds the
/// guard raises an error rather than hanging the server. Chains are shallow
/// (1-2 levels typical) so the guard is never reached in practice.
/// </para>
/// <para>
/// A "stub" node — an execution that emitted no rows of its own yet is
/// referenced by a child via <c>ParentExecutionId</c>, or whose rows have
/// been purged — still appears, with <see cref="ExecutionTreeNode.RowCount"/>
/// = 0. A purged/missing parent simply ends the upward walk.
/// </para>
/// <para>
/// When no <c>AuditLog</c> row carries <paramref name="executionId"/> in
/// either <c>ExecutionId</c> or <c>ParentExecutionId</c>, the result is a
/// single stub node for <paramref name="executionId"/> itself
/// (<see cref="ExecutionTreeNode.RowCount"/> = 0) — consistent with the
/// stub-node treatment of any other row-less execution.
/// </para>
/// </remarks>
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default);
}

View File

@@ -71,6 +71,16 @@ public interface ICachedCallLifecycleObserver
/// rows carry the same <c>SourceScript</c> provenance the script-side cached
/// rows already do. <c>null</c> when not known.
/// </param>
/// <param name="ParentExecutionId">
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
/// inbound-API request that spawned the originating script execution,
/// threaded through the store-and-forward buffer alongside
/// <paramref name="ExecutionId"/>. The audit bridge stamps it onto the
/// retry-loop <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted and
/// <c>CachedResolve</c> rows so they correlate back to the spawning run.
/// <c>null</c> for a non-routed run and for rows buffered before Task 6
/// (back-compat).
/// </param>
public sealed record CachedCallAttemptContext(
TrackedOperationId TrackedOperationId,
string Channel,
@@ -85,7 +95,8 @@ public sealed record CachedCallAttemptContext(
int? DurationMs,
string? SourceInstanceId,
Guid? ExecutionId = null,
string? SourceScript = null);
string? SourceScript = null,
Guid? ParentExecutionId = null);
/// <summary>
/// Coarse outcome of one cached-call delivery attempt, observed from inside

View File

@@ -40,6 +40,14 @@ public interface IDatabaseGateway
/// threaded onto the buffered S&amp;F message alongside
/// <paramref name="executionId"/>. <c>null</c> when not known.
/// </param>
/// <param name="parentExecutionId">
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
/// inbound-API request that spawned the originating script execution.
/// When the write is buffered on a transient failure this is threaded onto
/// the S&amp;F message alongside <paramref name="executionId"/> so the
/// retry-loop cached-write audit rows carry it. <c>null</c> for a
/// non-routed run.
/// </param>
Task CachedWriteAsync(
string connectionName,
string sql,
@@ -48,5 +56,6 @@ public interface IDatabaseGateway
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null);
string? sourceScript = null,
Guid? parentExecutionId = null);
}

View File

@@ -41,6 +41,14 @@ public interface IExternalSystemClient
/// threaded onto the buffered S&amp;F message alongside
/// <paramref name="executionId"/>. <c>null</c> when not known.
/// </param>
/// <param name="parentExecutionId">
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
/// inbound-API request that spawned the originating script execution.
/// When the call is buffered on a transient failure this is threaded onto
/// the S&amp;F message alongside <paramref name="executionId"/> so the
/// retry-loop cached-call audit rows carry it. <c>null</c> for a non-routed
/// run.
/// </param>
Task<ExternalCallResult> CachedCallAsync(
string systemName,
string methodName,
@@ -49,7 +57,8 @@ public interface IExternalSystemClient
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null);
string? sourceScript = null,
Guid? parentExecutionId = null);
}
/// <summary>

View File

@@ -4,12 +4,21 @@ namespace ScadaLink.Commons.Messages.InboundApi;
/// Request routed from Inbound API to a site to invoke a script on an instance.
/// Used by Route.To("instanceCode").Call("scriptName", params).
/// </summary>
/// <param name="ParentExecutionId">
/// Audit Log #23 (ParentExecutionId): the spawning execution's <c>ExecutionId</c>
/// — for an inbound-API-routed call this is the inbound request's per-request
/// execution id. The site records it as the routed script execution's
/// <c>ParentExecutionId</c> so a spawned execution points back at its spawner.
/// Additive trailing member — null for requests built before the field existed
/// or for routed calls with no spawning execution (e.g. the Central UI sandbox).
/// </param>
public record RouteToCallRequest(
string CorrelationId,
string InstanceUniqueName,
string ScriptName,
IReadOnlyDictionary<string, object?>? Parameters,
DateTimeOffset Timestamp);
DateTimeOffset Timestamp,
Guid? ParentExecutionId = null);
/// <summary>
/// Response from a Route.To() call.

View File

@@ -11,6 +11,13 @@ namespace ScadaLink.Commons.Messages.Notification;
/// <c>NotifyDeliver</c> audit rows. Additive trailing member — null for messages built
/// before the field existed, or for notifications raised outside a script execution.
/// </param>
/// <param name="OriginParentExecutionId">
/// The originating routed script execution's <c>ParentExecutionId</c> (Audit Log #23).
/// Stamped at <c>Notify.Send</c> time and carried, inside the serialized payload, through
/// the site store-and-forward buffer so the central dispatcher can echo it onto the
/// <c>NotifyDeliver</c> audit rows. Additive trailing member — null for messages built
/// before the field existed, or for non-routed runs.
/// </param>
public record NotificationSubmit(
string NotificationId,
string ListName,
@@ -20,7 +27,8 @@ public record NotificationSubmit(
string? SourceInstanceId,
string? SourceScript,
DateTimeOffset SiteEnqueuedAt,
Guid? OriginExecutionId = null);
Guid? OriginExecutionId = null,
Guid? OriginParentExecutionId = null);
/// <summary>
/// Central -> Site: ack sent after the notification row is persisted.

View File

@@ -1,7 +1,17 @@
namespace ScadaLink.Commons.Messages.ScriptExecution;
/// <param name="ParentExecutionId">
/// Audit Log #23 (ParentExecutionId): the spawning execution's <c>ExecutionId</c>.
/// For an inbound-API-routed call this is the inbound request's per-request
/// execution id (carried in from <c>RouteToCallRequest.ParentExecutionId</c>);
/// the routed script execution records it as its <c>ParentExecutionId</c> so a
/// spawned execution points back at its spawner. Additive trailing member —
/// null for normal (tag-change / timer-triggered) runs, nested <c>Script.Call</c>
/// invocations, and any request built before the field existed.
/// </param>
public record ScriptCallRequest(
string ScriptName,
IReadOnlyDictionary<string, object?>? Parameters,
int CurrentCallDepth,
string CorrelationId);
string CorrelationId,
Guid? ParentExecutionId = null);

View File

@@ -12,8 +12,8 @@ namespace ScadaLink.Commons.Types.Audit;
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
/// inclusive of the upper bound; the repository SQL uses <c>&gt;=</c> / <c>&lt;=</c>
/// respectively. All filter dimensions are AND-combined with one another. The
/// single-value <see cref="CorrelationId"/> and <see cref="ExecutionId"/>
/// dimensions constrain on equality when set.
/// single-value <see cref="CorrelationId"/>, <see cref="ExecutionId"/> and
/// <see cref="ParentExecutionId"/> dimensions constrain on equality when set.
/// </summary>
public sealed record AuditLogQueryFilter(
IReadOnlyList<AuditChannel>? Channels = null,
@@ -24,5 +24,6 @@ public sealed record AuditLogQueryFilter(
string? Actor = null,
Guid? CorrelationId = null,
Guid? ExecutionId = null,
Guid? ParentExecutionId = null,
DateTime? FromUtc = null,
DateTime? ToUtc = null);

View File

@@ -0,0 +1,71 @@
namespace ScadaLink.Commons.Types.Audit;
/// <summary>
/// One execution within an execution chain returned by
/// <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.GetExecutionTreeAsync"/>.
/// Each node summarises the <c>AuditLog</c> rows sharing a single
/// <see cref="ExecutionId"/>; the Central UI renders the set as a tree by
/// joining <see cref="ParentExecutionId"/> to a parent node's
/// <see cref="ExecutionId"/>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Stub nodes.</b> An execution that performed a trust-boundary action but
/// crossed it without emitting any audit row — or whose own rows have been
/// purged — still appears as a node when a child references it via
/// <see cref="ParentExecutionId"/>. Such a stub node has <see cref="RowCount"/>
/// = 0, empty <see cref="Channels"/>/<see cref="Statuses"/>, null
/// <see cref="SourceSiteId"/>/<see cref="SourceInstanceId"/>, null timestamps,
/// and a null <see cref="ParentExecutionId"/> (a purged/ghost parent leaves no
/// row from which its own parent could be read — the upward walk ends there).
/// </para>
/// <para>
/// <see cref="Channels"/> and <see cref="Statuses"/> are the distinct sets of
/// the corresponding enum names present across the execution's rows, modelled
/// as <see cref="IReadOnlyList{T}"/> of string to mirror how the repository's
/// query filters already pass small bounded sets around.
/// </para>
/// </remarks>
/// <param name="ExecutionId">The execution this node summarises.</param>
/// <param name="ParentExecutionId">
/// The <see cref="ExecutionId"/> of the spawning execution, or null for the
/// root (and for stub nodes, whose own parent is unknowable).
/// </param>
/// <param name="RowCount">
/// Number of <c>AuditLog</c> rows carrying this <see cref="ExecutionId"/>; 0 for
/// a stub node.
/// </param>
/// <param name="Channels">
/// Distinct <see cref="ScadaLink.Commons.Types.Enums.AuditChannel"/> names
/// present across this execution's rows; empty for a stub node.
/// </param>
/// <param name="Statuses">
/// Distinct <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/> names
/// present across this execution's rows; empty for a stub node.
/// </param>
/// <param name="SourceSiteId">
/// Source site of the execution's rows when consistent; null for a stub node
/// (or when the rows carry no site).
/// </param>
/// <param name="SourceInstanceId">
/// Source instance of the execution's rows when consistent; null for a stub
/// node (or when the rows carry no instance).
/// </param>
/// <param name="FirstOccurredAtUtc">
/// Earliest <c>OccurredAtUtc</c> across this execution's rows; null for a stub
/// node.
/// </param>
/// <param name="LastOccurredAtUtc">
/// Latest <c>OccurredAtUtc</c> across this execution's rows; null for a stub
/// node.
/// </param>
public sealed record ExecutionTreeNode(
Guid ExecutionId,
Guid? ParentExecutionId,
int RowCount,
IReadOnlyList<string> Channels,
IReadOnlyList<string> Statuses,
string? SourceSiteId,
string? SourceInstanceId,
DateTime? FirstOccurredAtUtc,
DateTime? LastOccurredAtUtc);

View File

@@ -48,6 +48,7 @@ public static class AuditEventDtoMapper
Kind = evt.Kind.ToString(),
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty,
ParentExecutionId = evt.ParentExecutionId?.ToString() ?? string.Empty,
SourceSiteId = evt.SourceSiteId ?? string.Empty,
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
SourceScript = evt.SourceScript ?? string.Empty,
@@ -94,6 +95,7 @@ public static class AuditEventDtoMapper
Kind = Enum.Parse<AuditKind>(dto.Kind),
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
ParentExecutionId = NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null,
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
SourceScript = NullIfEmpty(dto.SourceScript),

View File

@@ -92,6 +92,7 @@ message AuditEventDto {
bool payload_truncated = 18;
string extra = 19;
string execution_id = 20; // empty string represents null
string parent_execution_id = 21; // empty string represents null
}
message AuditEventBatch { repeated AuditEventDto events = 1; }

View File

@@ -41,7 +41,7 @@ namespace ScadaLink.Communication.Grpc {
"c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy",
"aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90",
"b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs",
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiiwQKDUF1ZGl0RXZlbnRE",
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiqAQKDUF1ZGl0RXZlbnRE",
"dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL",
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ",
"EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291",
@@ -53,42 +53,43 @@ namespace ScadaLink.Communication.Grpc {
"bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz",
"dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR",
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhl",
"Y3V0aW9uX2lkGBQgASgJIjwKD0F1ZGl0RXZlbnRCYXRjaBIpCgZldmVudHMY",
"ASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZlbnREdG8iJwoJSW5nZXN0QWNr",
"EhoKEmFjY2VwdGVkX2V2ZW50X2lkcxgBIAMoCSL0AgoWU2l0ZUNhbGxPcGVy",
"YXRpb25hbER0bxIcChR0cmFja2VkX29wZXJhdGlvbl9pZBgBIAEoCRIPCgdj",
"aGFubmVsGAIgASgJEg4KBnRhcmdldBgDIAEoCRITCgtzb3VyY2Vfc2l0ZRgE",
"IAEoCRIOCgZzdGF0dXMYBSABKAkSEwoLcmV0cnlfY291bnQYBiABKAUSEgoK",
"bGFzdF9lcnJvchgHIAEoCRIwCgtodHRwX3N0YXR1cxgIIAEoCzIbLmdvb2ds",
"ZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjIKDmNyZWF0ZWRfYXRfdXRjGAkgASgL",
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIyCg51cGRhdGVkX2F0X3V0",
"YxgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoPdGVybWlu",
"YWxfYXRfdXRjGAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKA",
"AQoVQ2FjaGVkVGVsZW1ldHJ5UGFja2V0Ei4KC2F1ZGl0X2V2ZW50GAEgASgL",
"Mhkuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50RHRvEjcKC29wZXJhdGlvbmFsGAIg",
"ASgLMiIuc2l0ZXN0cmVhbS5TaXRlQ2FsbE9wZXJhdGlvbmFsRHRvIkoKFENh",
"Y2hlZFRlbGVtZXRyeUJhdGNoEjIKB3BhY2tldHMYASADKAsyIS5zaXRlc3Ry",
"ZWFtLkNhY2hlZFRlbGVtZXRyeVBhY2tldCJbChZQdWxsQXVkaXRFdmVudHNS",
"ZXF1ZXN0Ei0KCXNpbmNlX3V0YxgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5U",
"aW1lc3RhbXASEgoKYmF0Y2hfc2l6ZRgCIAEoBSJcChdQdWxsQXVkaXRFdmVu",
"dHNSZXNwb25zZRIpCgZldmVudHMYASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0",
"RXZlbnREdG8SFgoObW9yZV9hdmFpbGFibGUYAiABKAgqXAoHUXVhbGl0eRIX",
"ChNRVUFMSVRZX1VOU1BFQ0lGSUVEEAASEAoMUVVBTElUWV9HT09EEAESFQoR",
"UVVBTElUWV9VTkNFUlRBSU4QAhIPCgtRVUFMSVRZX0JBRBADKl0KDkFsYXJt",
"U3RhdGVFbnVtEhsKF0FMQVJNX1NUQVRFX1VOU1BFQ0lGSUVEEAASFgoSQUxB",
"Uk1fU1RBVEVfTk9STUFMEAESFgoSQUxBUk1fU1RBVEVfQUNUSVZFEAIqhQEK",
"DkFsYXJtTGV2ZWxFbnVtEhQKEEFMQVJNX0xFVkVMX05PTkUQABITCg9BTEFS",
"TV9MRVZFTF9MT1cQARIXChNBTEFSTV9MRVZFTF9MT1dfTE9XEAISFAoQQUxB",
"Uk1fTEVWRUxfSElHSBADEhkKFUFMQVJNX0xFVkVMX0hJR0hfSElHSBAEMuEC",
"ChFTaXRlU3RyZWFtU2VydmljZRJVChFTdWJzY3JpYmVJbnN0YW5jZRIhLnNp",
"dGVzdHJlYW0uSW5zdGFuY2VTdHJlYW1SZXF1ZXN0Ghsuc2l0ZXN0cmVhbS5T",
"aXRlU3RyZWFtRXZlbnQwARJHChFJbmdlc3RBdWRpdEV2ZW50cxIbLnNpdGVz",
"dHJlYW0uQXVkaXRFdmVudEJhdGNoGhUuc2l0ZXN0cmVhbS5Jbmdlc3RBY2sS",
"UAoVSW5nZXN0Q2FjaGVkVGVsZW1ldHJ5EiAuc2l0ZXN0cmVhbS5DYWNoZWRU",
"ZWxlbWV0cnlCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrEloKD1B1bGxB",
"dWRpdEV2ZW50cxIiLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVxdWVz",
"dBojLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVzcG9uc2VCH6oCHFNj",
"YWRhTGluay5Db21tdW5pY2F0aW9uLkdycGNiBnByb3RvMw=="));
"Y3V0aW9uX2lkGBQgASgJEhsKE3BhcmVudF9leGVjdXRpb25faWQYFSABKAki",
"PAoPQXVkaXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJl",
"YW0uQXVkaXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZl",
"bnRfaWRzGAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRy",
"YWNrZWRfb3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoG",
"dGFyZ2V0GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgF",
"IAEoCRITCgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJ",
"EjAKC2h0dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMy",
"VmFsdWUSMgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9i",
"dWYuVGltZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xl",
"LnByb3RvYnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsy",
"Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0",
"cnlQYWNrZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1",
"ZGl0RXZlbnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFt",
"LlNpdGVDYWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0",
"Y2gSMgoHcGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1l",
"dHJ5UGFja2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2Vf",
"dXRjGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRj",
"aF9zaXplGAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2",
"ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3Jl",
"X2F2YWlsYWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVD",
"SUZJRUQQABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJ",
"ThACEg8KC1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxB",
"Uk1fU1RBVEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQ",
"ARIWChJBTEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0S",
"FAoQQUxBUk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcK",
"E0FMQVJNX0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMS",
"GQoVQUxBUk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2",
"aWNlElUKEVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5j",
"ZVN0cmVhbVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDAB",
"EkcKEUluZ2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50",
"QmF0Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRU",
"ZWxlbWV0cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUu",
"c2l0ZXN0cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0",
"ZXN0cmVhbS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5Q",
"dWxsQXVkaXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmlj",
"YXRpb24uR3JwY2IGcHJvdG8z"));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), typeof(global::ScadaLink.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
@@ -96,7 +97,7 @@ namespace ScadaLink.Communication.Grpc {
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteStreamEvent), global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AttributeValueUpdate), global::ScadaLink.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId", "ParentExecutionId" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventBatch), global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.IngestAck), global::ScadaLink.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteCallOperationalDto), global::ScadaLink.Communication.Grpc.SiteCallOperationalDto.Parser, new[]{ "TrackedOperationId", "Channel", "Target", "SourceSite", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc" }, null, null, null, null),
@@ -1592,6 +1593,7 @@ namespace ScadaLink.Communication.Grpc {
payloadTruncated_ = other.payloadTruncated_;
extra_ = other.extra_;
executionId_ = other.executionId_;
parentExecutionId_ = other.parentExecutionId_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
@@ -1854,6 +1856,21 @@ namespace ScadaLink.Communication.Grpc {
}
}
/// <summary>Field number for the "parent_execution_id" field.</summary>
public const int ParentExecutionIdFieldNumber = 21;
private string parentExecutionId_ = "";
/// <summary>
/// empty string represents null
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string ParentExecutionId {
get { return parentExecutionId_; }
set {
parentExecutionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
@@ -1889,6 +1906,7 @@ namespace ScadaLink.Communication.Grpc {
if (PayloadTruncated != other.PayloadTruncated) return false;
if (Extra != other.Extra) return false;
if (ExecutionId != other.ExecutionId) return false;
if (ParentExecutionId != other.ParentExecutionId) return false;
return Equals(_unknownFields, other._unknownFields);
}
@@ -1916,6 +1934,7 @@ namespace ScadaLink.Communication.Grpc {
if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode();
if (Extra.Length != 0) hash ^= Extra.GetHashCode();
if (ExecutionId.Length != 0) hash ^= ExecutionId.GetHashCode();
if (ParentExecutionId.Length != 0) hash ^= ParentExecutionId.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
@@ -2012,6 +2031,10 @@ namespace ScadaLink.Communication.Grpc {
output.WriteRawTag(162, 1);
output.WriteString(ExecutionId);
}
if (ParentExecutionId.Length != 0) {
output.WriteRawTag(170, 1);
output.WriteString(ParentExecutionId);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
@@ -2100,6 +2123,10 @@ namespace ScadaLink.Communication.Grpc {
output.WriteRawTag(162, 1);
output.WriteString(ExecutionId);
}
if (ParentExecutionId.Length != 0) {
output.WriteRawTag(170, 1);
output.WriteString(ParentExecutionId);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
@@ -2170,6 +2197,9 @@ namespace ScadaLink.Communication.Grpc {
if (ExecutionId.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(ExecutionId);
}
if (ParentExecutionId.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(ParentExecutionId);
}
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
@@ -2249,6 +2279,9 @@ namespace ScadaLink.Communication.Grpc {
if (other.ExecutionId.Length != 0) {
ExecutionId = other.ExecutionId;
}
if (other.ParentExecutionId.Length != 0) {
ParentExecutionId = other.ParentExecutionId;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
@@ -2357,6 +2390,10 @@ namespace ScadaLink.Communication.Grpc {
ExecutionId = input.ReadString();
break;
}
case 170: {
ParentExecutionId = input.ReadString();
break;
}
}
}
#endif
@@ -2465,6 +2502,10 @@ namespace ScadaLink.Communication.Grpc {
ExecutionId = input.ReadString();
break;
}
case 170: {
ParentExecutionId = input.ReadString();
break;
}
}
}
}

View File

@@ -93,6 +93,10 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
.HasFilter("[ExecutionId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_Execution");
builder.HasIndex(e => e.ParentExecutionId)
.HasFilter("[ParentExecutionId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_ParentExecution");
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
.IsDescending(false, false, true)
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");

View File

@@ -51,6 +51,10 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
// site so the dispatcher can echo it onto NotifyDeliver audit rows. No index —
// it is never a query predicate on this table, only copied onto audit events.
// OriginParentExecutionId (Audit Log #23): nullable uniqueidentifier carried from
// the site — the routed run's parent ExecutionId — so the dispatcher can echo it
// onto NotifyDeliver audit rows. No index — same rationale as OriginExecutionId.
builder.HasIndex(n => new { n.Status, n.NextAttemptAt });
builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt });

View File

@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Adds the <c>ParentExecutionId</c> correlation column to the centralized
/// <c>AuditLog</c> table (#23). <c>ParentExecutionId</c> carries the
/// <c>ExecutionId</c> of the execution that spawned this run, letting a
/// spawned execution point back at its spawner — a sibling to the universal
/// per-run <c>ExecutionId</c>.
///
/// The change is purely additive:
/// 1. <c>ParentExecutionId uniqueidentifier NULL</c> is added with no default,
/// so the operation is a metadata-only <c>ALTER TABLE … ADD</c> — it does
/// NOT rewrite the monthly-partitioned <c>AuditLog</c> table, and
/// historical rows stay <c>NULL</c> (no backfill).
/// 2. <c>IX_AuditLog_ParentExecution</c> is created via raw SQL so it lands on
/// the <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme, matching
/// every other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned
/// preserves the partition-switch purge path (see
/// AuditLogRepository.SwitchOutPartitionAsync).
/// </summary>
public partial class AddAuditLogParentExecutionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ParentExecutionId",
table: "AuditLog",
type: "uniqueidentifier",
nullable: true);
// Raw SQL so the index is created on the partition scheme — EF's
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
// clause. Mirrors IX_AuditLog_Execution (filtered, aligned).
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution
ON dbo.AuditLog (ParentExecutionId)
WHERE ParentExecutionId IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_ParentExecution' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_ParentExecution ON dbo.AuditLog;");
migrationBuilder.DropColumn(
name: "ParentExecutionId",
table: "AuditLog");
}
}
}

View File

@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Adds the <c>OriginParentExecutionId</c> correlation column to the central
/// <c>Notifications</c> table (#21). It carries the originating routed script
/// execution's <c>ParentExecutionId</c> from the site so the dispatcher can echo it
/// onto the <c>NotifyDeliver</c> audit rows (#23), linking them to the routed run's
/// parent. Sibling of <c>OriginExecutionId</c>.
///
/// The change is purely additive: <c>OriginParentExecutionId uniqueidentifier NULL</c>
/// is added with no default, so the operation is a metadata-only
/// <c>ALTER TABLE … ADD</c>. Unlike <c>AuditLog</c>, the <c>Notifications</c> table is
/// NOT partitioned, so a plain <c>ADD</c> is fine. No index is created — the column is
/// never a query predicate, only copied onto audit events. Historical rows stay
/// <c>NULL</c>.
/// </summary>
public partial class AddNotificationOriginParentExecutionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "OriginParentExecutionId",
table: "Notifications",
type: "uniqueidentifier",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OriginParentExecutionId",
table: "Notifications");
}
}
}

View File

@@ -96,6 +96,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsUnicode(false)
.HasColumnType("varchar(32)");
b.Property<Guid?>("ParentExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("PayloadTruncated")
.HasColumnType("bit");
@@ -149,6 +152,10 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsDescending()
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
b.HasIndex("ParentExecutionId")
.HasDatabaseName("IX_AuditLog_ParentExecution")
.HasFilter("[ParentExecutionId] IS NOT NULL");
b.HasIndex("SourceSiteId", "OccurredAtUtc")
.IsDescending(false, true)
.HasDatabaseName("IX_AuditLog_Site_Occurred");
@@ -790,6 +797,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.Property<Guid?>("OriginExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("OriginParentExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ResolvedTargets")
.HasColumnType("nvarchar(max)");

View File

@@ -64,12 +64,12 @@ public class AuditLogRepository : IAuditLogRepository
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId,
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState)
VALUES
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId},
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId}, {evt.ParentExecutionId},
{evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
@@ -162,6 +162,11 @@ VALUES
query = query.Where(e => e.ExecutionId == executionId);
}
if (filter.ParentExecutionId is { } parentExecutionId)
{
query = query.Where(e => e.ParentExecutionId == parentExecutionId);
}
if (filter.FromUtc is { } fromUtc)
{
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
@@ -268,10 +273,13 @@ VALUES
PayloadTruncated bit NOT NULL,
Extra nvarchar(max) NULL,
ForwardState varchar(32) NULL,
-- ExecutionId is last because it was added to the live AuditLog table by a later
-- ALTER TABLE ADD migration; the staging table must match the live table column
-- shape ordinal-for-ordinal or ALTER TABLE ... SWITCH PARTITION fails.
-- ExecutionId and ParentExecutionId are last (in this ordinal order)
-- because each was added to the live AuditLog table by a later
-- ALTER TABLE ADD migration; the staging table must match the live
-- table column shape ordinal-for-ordinal or
-- ALTER TABLE ... SWITCH PARTITION fails.
ExecutionId uniqueidentifier NULL,
ParentExecutionId uniqueidentifier NULL,
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
) ON [PRIMARY];
@@ -547,4 +555,227 @@ VALUES
BacklogTotal: 0L,
AsOfUtc: anchorUtc);
}
// Hard ceiling on chain depth for both the upward walk and the downward
// recursive CTE. The ParentExecutionId graph is a tree (acyclic by
// construction — each execution is minted fresh, its parent always
// pre-exists), so this is purely a guard against corrupt/pathological data:
// a cycle must surface as a bounded error rather than hang the server.
// Chains are shallow (1-2 levels typical) so the guard is never reached in
// practice.
private const int ExecutionChainMaxDepth = 32;
/// <summary>
/// Audit Log ParentExecutionId (Task 8) — returns the whole execution chain
/// containing <paramref name="executionId"/>, regardless of entry point.
/// </summary>
/// <remarks>
/// <para>
/// Two phases. <b>Walk up:</b> an iterative
/// <c>SELECT TOP 1 ParentExecutionId … WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL</c>
/// climbs from the supplied node to the root — the last execution id with no
/// parent. The loop is capped at <see cref="ExecutionChainMaxDepth"/>
/// iterations; a purged/missing parent simply ends the climb early. <b>Walk
/// down:</b> a recursive CTE over a DISTINCT
/// <c>(ExecutionId, ParentExecutionId)</c> edge set, seeded at the root edge
/// and joining <c>edge.ParentExecutionId = chain.ExecutionId</c> to
/// enumerate every descendant. Recursing over edges rather than raw rows
/// keeps the recursion one path wide per execution. It is bounded by
/// <c>OPTION (MAXRECURSION ...)</c> at <see cref="ExecutionChainMaxDepth"/>
/// — corrupt cyclic data raises a <see cref="SqlException"/> (msg 530)
/// rather than spinning.
/// </para>
/// <para>
/// The chain's full execution-id set is every edge's <c>ExecutionId</c>
/// unioned with its non-null <c>ParentExecutionId</c>, so an execution
/// referenced only as a parent — a "stub" that emitted no rows of its own,
/// and therefore owns no edge of its own — is still included. The final
/// projection LEFT JOINs that id set back to <c>AuditLog</c> and
/// <c>GROUP BY</c>s, so a stub yields a node with <c>RowCount = 0</c> and
/// empty/null aggregates. The query is SELECT-only
/// (the audit writer role grants no UPDATE/DELETE — reads are unrestricted).
/// </para>
/// </remarks>
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default)
{
var conn = _context.Database.GetDbConnection();
var openedHere = false;
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct).ConfigureAwait(false);
openedHere = true;
}
try
{
// --- Phase 1: walk up to the root ---------------------------------
// Climb ParentExecutionId until a node has no parent (root) or the
// parent execution has no rows of its own (purged/stub — the climb
// cannot continue past a row-less node). The depth cap guards
// against a cycle in corrupt data; a tree never reaches it.
var rootExecutionId = executionId;
for (var depth = 0; depth < ExecutionChainMaxDepth; depth++)
{
Guid? parent;
await using (var upCmd = conn.CreateCommand())
{
upCmd.CommandText =
"SELECT TOP 1 ParentExecutionId FROM dbo.AuditLog " +
"WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL;";
var pCur = upCmd.CreateParameter();
pCur.ParameterName = "@cur";
pCur.Value = rootExecutionId;
upCmd.Parameters.Add(pCur);
var result = await upCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
parent = result is null or DBNull ? null : (Guid)result;
}
if (parent is null)
{
// No parent row for the current node — it is the root (or a
// row-less stub at the top of what survives). Stop climbing.
break;
}
rootExecutionId = parent.Value;
}
// --- Phase 2: walk down from the root via a recursive CTE ---------
// Edges : a non-recursive, DISTINCT (ExecutionId, ParentExecutionId)
// edge set distilled from AuditLog. Recursing over edges
// instead of raw rows means an execution with N audit rows
// contributes ONE recursion path, not N — MAXRECURSION
// bounds depth, not per-level width, so the raw-row form
// could fan out badly. One edge per execution because all
// rows of an execution share a single ParentExecutionId
// (see the MIN(...) note on the final projection).
// Chain : seeded at the root edge, recursively joins each edge whose
// ParentExecutionId is an ExecutionId already in the chain.
// Each edge carries its own ParentExecutionId, so the chain
// of edges already surfaces every execution id in the tree
// — including a row-less stub parent, which appears as the
// ParentExecutionId of its child's edge. No separate
// union-back CTE is needed.
// Final : collect every distinct execution id reachable from the
// chain (each edge's ExecutionId plus its non-null
// ParentExecutionId), LEFT JOIN back to AuditLog and
// GROUP BY so a stub parent — which owns no edge of its own
// because it emitted no rows — still surfaces as a node with
// RowCount 0 and NULL aggregates.
var nodes = new List<ExecutionTreeNode>();
await using (var downCmd = conn.CreateCommand())
{
downCmd.CommandText = $@"
WITH Edges AS (
SELECT DISTINCT ExecutionId, ParentExecutionId
FROM dbo.AuditLog
WHERE ExecutionId IS NOT NULL
),
Chain AS (
-- Anchor: the root execution id, seeded as a literal so
-- it is present even when the root is a row-less stub
-- (a purged/no-action parent owns no edge of its own).
-- The root is parentless by construction — the upward
-- walk stopped there — so its ParentExecutionId is NULL.
SELECT CAST(@root AS uniqueidentifier) AS ExecutionId,
CAST(NULL AS uniqueidentifier) AS ParentExecutionId
UNION ALL
SELECT e.ExecutionId, e.ParentExecutionId
FROM Edges e
INNER JOIN Chain c ON e.ParentExecutionId = c.ExecutionId
),
ChainIds AS (
SELECT ExecutionId FROM Chain
UNION
SELECT ParentExecutionId FROM Chain
WHERE ParentExecutionId IS NOT NULL
)
-- ParentExecutionId / SourceSiteId / SourceInstanceId are
-- derived via MIN: every audit row of one execution carries
-- the SAME ParentExecutionId (and source identity) — it is
-- stamped once per script run / inbound request — so MIN
-- simply picks that one shared value, it is not collapsing a
-- genuine disagreement across rows.
SELECT
ids.ExecutionId AS [ExecutionId],
MIN(a.ParentExecutionId) AS [ParentExecutionId],
COUNT(a.EventId) AS [RowCount],
(SELECT STRING_AGG(d.Channel, ',')
FROM (SELECT DISTINCT a2.Channel FROM dbo.AuditLog a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Channels],
(SELECT STRING_AGG(d.Status, ',')
FROM (SELECT DISTINCT a2.Status FROM dbo.AuditLog a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Statuses],
MIN(a.SourceSiteId) AS [SourceSiteId],
MIN(a.SourceInstanceId) AS [SourceInstanceId],
MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc],
MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc]
FROM ChainIds ids
LEFT JOIN dbo.AuditLog a ON a.ExecutionId = ids.ExecutionId
GROUP BY ids.ExecutionId
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
var pRoot = downCmd.CreateParameter();
pRoot.ParameterName = "@root";
pRoot.Value = rootExecutionId;
downCmd.Parameters.Add(pRoot);
await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
var nodeExecutionId = reader.GetGuid(0);
Guid? parentExecutionId = reader.IsDBNull(1) ? null : reader.GetGuid(1);
var rowCount = reader.GetInt32(2);
var channels = SplitAggregate(reader.IsDBNull(3) ? null : reader.GetString(3));
var statuses = SplitAggregate(reader.IsDBNull(4) ? null : reader.GetString(4));
var sourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5);
var sourceInstanceId = reader.IsDBNull(6) ? null : reader.GetString(6);
DateTime? firstOccurred = reader.IsDBNull(7) ? null : reader.GetDateTime(7);
DateTime? lastOccurred = reader.IsDBNull(8) ? null : reader.GetDateTime(8);
nodes.Add(new ExecutionTreeNode(
ExecutionId: nodeExecutionId,
ParentExecutionId: parentExecutionId,
RowCount: rowCount,
Channels: channels,
Statuses: statuses,
SourceSiteId: sourceSiteId,
SourceInstanceId: sourceInstanceId,
FirstOccurredAtUtc: firstOccurred,
LastOccurredAtUtc: lastOccurred));
}
}
return nodes;
}
finally
{
if (openedHere)
{
await conn.CloseAsync().ConfigureAwait(false);
}
}
}
/// <summary>
/// Splits a <c>STRING_AGG</c> comma-joined value into a distinct, ordered
/// list. A null/empty aggregate (a stub node with no rows) yields an empty
/// list rather than a single empty string.
/// </summary>
private static IReadOnlyList<string> SplitAggregate(string? aggregate)
{
if (string.IsNullOrEmpty(aggregate))
{
return Array.Empty<string>();
}
return aggregate
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.Ordinal)
.OrderBy(v => v, StringComparer.Ordinal)
.ToArray();
}
}

View File

@@ -86,7 +86,8 @@ public class DatabaseGateway : IDatabaseGateway
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null)
string? sourceScript = null,
Guid? parentExecutionId = null)
{
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
if (definition == null)
@@ -132,7 +133,12 @@ public class DatabaseGateway : IDatabaseGateway
// the retry-loop cached-write audit rows carry the same provenance
// the script-side cached rows do.
executionId: executionId,
sourceScript: sourceScript);
sourceScript: sourceScript,
// Audit Log #23 (ParentExecutionId Task 6): thread the spawning
// inbound-API request's ExecutionId onto the buffered row so the
// retry-loop cached-write audit rows correlate back to the
// cross-execution chain. Null for a non-routed run.
parentExecutionId: parentExecutionId);
}
/// <summary>

View File

@@ -88,7 +88,8 @@ public class ExternalSystemClient : IExternalSystemClient
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null)
string? sourceScript = null,
Guid? parentExecutionId = null)
{
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
if (system == null || method == null)
@@ -152,7 +153,12 @@ public class ExternalSystemClient : IExternalSystemClient
// buffered row so the retry-loop cached-call audit rows carry
// the same provenance the script-side cached rows do.
executionId: executionId,
sourceScript: sourceScript);
sourceScript: sourceScript,
// Audit Log #23 (ParentExecutionId Task 6): thread the spawning
// inbound-API request's ExecutionId onto the buffered row so
// the retry-loop cached-call audit rows correlate back to the
// cross-execution chain. Null for a non-routed run.
parentExecutionId: parentExecutionId);
return new ExternalCallResult(true, null, null, WasBuffered: true);
}

View File

@@ -92,8 +92,21 @@ public static class EndpointExtensions
? TimeSpan.FromSeconds(method.TimeoutSeconds)
: options.DefaultMethodTimeout;
// Audit Log #23 (ParentExecutionId): the inbound request's per-request
// ExecutionId was minted early by AuditWriteMiddleware and stashed on
// HttpContext.Items. Thread it into the executor so a routed
// Route.To(...).Call(...) carries it as RouteToCallRequest.ParentExecutionId
// — the spawned site script execution points back at this inbound request.
var parentExecutionId =
httpContext.Items.TryGetValue(
AuditWriteMiddleware.InboundExecutionIdItemKey, out var stashedExecutionId)
&& stashedExecutionId is Guid inboundExecutionId
? inboundExecutionId
: (Guid?)null;
var scriptResult = await executor.ExecuteAsync(
method, paramResult.Parameters, routeHelper, timeout, httpContext.RequestAborted);
method, paramResult.Parameters, routeHelper, timeout,
httpContext.RequestAborted, parentExecutionId);
if (!scriptResult.Success)
{

View File

@@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Messages.InboundApi;
using ScadaLink.Commons.Types;
namespace ScadaLink.InboundAPI;
@@ -156,12 +157,25 @@ public class InboundScriptExecutor
/// <summary>
/// Executes the script for the given method with the provided context.
/// </summary>
/// <param name="parentExecutionId">
/// Audit Log #23 (ParentExecutionId): the inbound API request's per-request
/// <c>ExecutionId</c> (minted early by <c>AuditWriteMiddleware</c> and stashed
/// on <c>HttpContext.Items</c>). When supplied, a routed
/// <c>Route.To(...).Call(...)</c> inside the script carries it as
/// <see cref="RouteToCallRequest.ParentExecutionId"/> so the spawned site
/// script execution points back at this inbound request. Null when the script
/// runs outside an inbound API request flow.
/// </param>
public async Task<InboundScriptResult> ExecuteAsync(
ApiMethod method,
IReadOnlyDictionary<string, object?> parameters,
RouteHelper route,
TimeSpan timeout,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
// Deliberate ordering: this optional parameter trails the CancellationToken
// because it was appended additively for non-breaking contract evolution.
// Every call site passes it by named argument (parentExecutionId:).
Guid? parentExecutionId = null)
{
// InboundAPI-004: keep the timeout source and the request-abort source
// separable. A single linked CTS makes a genuine client disconnect
@@ -177,7 +191,14 @@ public class InboundScriptExecutor
// InboundAPI-016: bind the route helper to the method deadline so a
// routed Route.To(...).Call(...) inherits the method-level timeout
// without the script having to thread the context token by hand.
var context = new InboundScriptContext(parameters, route.WithDeadline(cts.Token), cts.Token);
//
// Audit Log #23 (ParentExecutionId): also bind the inbound request's
// ExecutionId so a routed call carries it as ParentExecutionId — the
// spawned site script execution points back at this inbound request.
var context = new InboundScriptContext(
parameters,
route.WithDeadline(cts.Token).WithParentExecutionId(parentExecutionId),
cts.Token);
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
{

View File

@@ -59,6 +59,18 @@ public sealed class AuditWriteMiddleware
/// </summary>
public const string AuditActorItemKey = "ScadaLink.InboundAPI.AuditActor";
/// <summary>
/// Audit Log #23 (ParentExecutionId): <see cref="HttpContext.Items"/> key under
/// which this middleware stashes the inbound request's per-request
/// <c>ExecutionId</c> (a <see cref="Guid"/>) at the very start of the request.
/// The id is minted ONCE and shared: the endpoint handler reads it to thread it
/// onto a routed <c>RouteToCallRequest.ParentExecutionId</c>, and the
/// middleware's own inbound audit row uses the same id for its
/// <see cref="AuditEvent.ExecutionId"/>. Exposed as a constant so the handler
/// and middleware share a single source of truth (no stringly-typed coupling).
/// </summary>
public const string InboundExecutionIdItemKey = "ScadaLink.InboundAPI.InboundExecutionId";
private readonly RequestDelegate _next;
private readonly ICentralAuditWriter _auditWriter;
private readonly ILogger<AuditWriteMiddleware> _logger;
@@ -77,6 +89,17 @@ public sealed class AuditWriteMiddleware
{
var sw = Stopwatch.StartNew();
// Audit Log #23 (ParentExecutionId): mint the inbound request's per-request
// ExecutionId ONCE, here at the start of the request, and stash it on
// HttpContext.Items. Two consumers share this single id:
// (a) the endpoint handler reads it to thread onto a routed
// RouteToCallRequest.ParentExecutionId, so a spawned site script
// execution points back at this inbound request;
// (b) the inbound audit row this middleware emits uses it as its own
// ExecutionId (the row stays top-level — its ParentExecutionId is
// never set).
ctx.Items[InboundExecutionIdItemKey] = Guid.NewGuid();
// Buffer the request body up front so we can both audit it and let the
// downstream handler still parse it. EnableBuffering swaps the request
// stream for a seekable wrapper that the framework rewinds at the end
@@ -145,17 +168,14 @@ public sealed class AuditWriteMiddleware
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiInbound,
Kind = kind,
// Audit Log #23: a fresh per-request execution id so the
// inbound row carries a request identifier (closes the design
// gap that inbound rows should be correlatable).
//
// This id is intentionally request-local: it is NOT bridged to
// RouteHelper's routed-call correlation id or to
// HttpContext.TraceIdentifier. Threading an inbound request's
// execution id through to the routed script execution (so an
// inbound call and the outbound API/DB rows it triggers share
// one id) is a deliberate future follow-up, out of scope here.
ExecutionId = Guid.NewGuid(),
// Audit Log #23: the per-request execution id minted ONCE at the
// start of the request (InvokeAsync) and stashed on
// HttpContext.Items. The same id is threaded onto a routed
// RouteToCallRequest.ParentExecutionId by the endpoint handler,
// so an inbound request and the site script it routes to share
// one correlation point. This inbound row stays top-level — its
// own ParentExecutionId is never set (see below).
ExecutionId = ResolveInboundExecutionId(ctx),
// CorrelationId is purely the per-operation-lifecycle id; an
// inbound request is a one-shot from the audit row's
// perspective with no multi-row operation to correlate.
@@ -225,6 +245,29 @@ public sealed class AuditWriteMiddleware
}
}
/// <summary>
/// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request
/// <c>ExecutionId</c> that <see cref="InvokeAsync"/> minted and stashed on
/// <see cref="HttpContext.Items"/> under <see cref="InboundExecutionIdItemKey"/>.
/// Throws <see cref="InvalidOperationException"/> if the slot is absent — for a
/// correlation feature a silently-divergent id is the worst failure mode, so we
/// fail fast rather than mint a fresh one. <see cref="EmitInboundAudit"/>'s
/// try/catch degrades the throw to a dropped best-effort audit row, never a
/// failed request.
/// </summary>
private static Guid ResolveInboundExecutionId(HttpContext ctx)
{
if (ctx.Items.TryGetValue(InboundExecutionIdItemKey, out var stashed)
&& stashed is Guid id)
{
return id;
}
throw new InvalidOperationException(
"Inbound ExecutionId invariant violated: the inbound ExecutionId must be "
+ "stashed by AuditWriteMiddleware.InvokeAsync before the audit row is emitted.");
}
/// <summary>
/// Reads the API key name the endpoint handler stashed on
/// <see cref="HttpContext.Items"/> after successful auth. Falls back to

View File

@@ -19,22 +19,25 @@ public class RouteHelper
private readonly IInstanceLocator _instanceLocator;
private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
private readonly Guid? _parentExecutionId;
public RouteHelper(
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter)
: this(instanceLocator, instanceRouter, CancellationToken.None)
: this(instanceLocator, instanceRouter, CancellationToken.None, parentExecutionId: null)
{
}
private RouteHelper(
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
CancellationToken deadlineToken)
CancellationToken deadlineToken,
Guid? parentExecutionId)
{
_instanceLocator = instanceLocator;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
_parentExecutionId = parentExecutionId;
}
/// <summary>
@@ -45,14 +48,27 @@ public class RouteHelper
/// requires.
/// </summary>
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
new(_instanceLocator, _instanceRouter, deadlineToken);
new(_instanceLocator, _instanceRouter, deadlineToken, _parentExecutionId);
/// <summary>
/// Audit Log #23 (ParentExecutionId): returns a <see cref="RouteHelper"/> whose
/// routed <see cref="RouteTarget.Call"/> requests carry
/// <paramref name="parentExecutionId"/> as <see cref="RouteToCallRequest.ParentExecutionId"/>.
/// For an inbound API request this is the inbound request's own per-request
/// execution id, so the routed site script records the inbound request as its
/// parent. <see cref="InboundScriptExecutor"/> calls this when it builds the
/// script context.
/// </summary>
public RouteHelper WithParentExecutionId(Guid? parentExecutionId) =>
new(_instanceLocator, _instanceRouter, _deadlineToken, parentExecutionId);
/// <summary>
/// Creates a route target for the specified instance.
/// </summary>
public RouteTarget To(string instanceCode)
{
return new RouteTarget(instanceCode, _instanceLocator, _instanceRouter, _deadlineToken);
return new RouteTarget(
instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _parentExecutionId);
}
}
@@ -65,17 +81,20 @@ public class RouteTarget
private readonly IInstanceLocator _instanceLocator;
private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken;
private readonly Guid? _parentExecutionId;
internal RouteTarget(
string instanceCode,
IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter,
CancellationToken deadlineToken)
CancellationToken deadlineToken,
Guid? parentExecutionId)
{
_instanceCode = instanceCode;
_instanceLocator = instanceLocator;
_instanceRouter = instanceRouter;
_deadlineToken = deadlineToken;
_parentExecutionId = parentExecutionId;
}
/// <summary>
@@ -96,8 +115,13 @@ public class RouteTarget
var siteId = await ResolveSiteAsync(token);
var correlationId = Guid.NewGuid().ToString();
// Audit Log #23 (ParentExecutionId): stamp the spawning execution's id
// (the inbound API request's ExecutionId) so the routed site script
// records this call's parent. CorrelationId above is a separate concern
// — the per-operation lifecycle id, freshly minted per routed call.
var request = new RouteToCallRequest(
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), DateTimeOffset.UtcNow);
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters),
DateTimeOffset.UtcNow, _parentExecutionId);
var response = await _instanceRouter.RouteToCallAsync(siteId, request, token);

View File

@@ -402,6 +402,13 @@ public static class AuditEndpoints
executionId = parsedExec;
}
Guid? parentExecutionId = null;
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
{
parentExecutionId = parsedParentExec;
}
return new AuditLogQueryFilter(
Channels: channels,
Kinds: kinds,
@@ -411,6 +418,7 @@ public static class AuditEndpoints
Actor: TrimToNullable(query, "actor"),
CorrelationId: correlationId,
ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: ParseUtcDate(query, "fromUtc"),
ToUtc: ParseUtcDate(query, "toUtc"));
}

View File

@@ -492,7 +492,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
/// <see cref="AuditEvent.ExecutionId"/> is copied straight from
/// <see cref="Notification.OriginExecutionId"/> so the dispatcher's
/// <c>NotifyDeliver</c> rows carry the same per-run id as the site's
/// <c>NotifySend</c> row (Audit Log #23).
/// <c>NotifySend</c> row (Audit Log #23); <see cref="AuditEvent.ParentExecutionId"/>
/// is likewise copied from <see cref="Notification.OriginParentExecutionId"/>.
/// </summary>
private static AuditEvent BuildNotifyDeliverEvent(
Notification notification,
@@ -525,6 +526,11 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
// rows to the site-emitted NotifySend row for the same run. Null when
// the notification was raised outside a script execution.
ExecutionId = notification.OriginExecutionId,
// ParentExecutionId (Audit Log #23): the originating routed run's
// parent ExecutionId, carried from the site on NotificationSubmit and
// persisted on the Notification row. Echoing it here links the central
// NotifyDeliver rows to the routed run's parent. Null for non-routed runs.
ParentExecutionId = notification.OriginParentExecutionId,
Target = notification.ListName,
Status = status,
ErrorMessage = errorMessage,
@@ -954,6 +960,10 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
// OriginExecutionId (Audit Log #23): the originating script execution's id,
// carried from the site so the dispatcher can echo it onto NotifyDeliver rows.
OriginExecutionId = msg.OriginExecutionId,
// OriginParentExecutionId (Audit Log #23): the originating routed run's parent
// ExecutionId, carried from the site so the dispatcher can echo it onto
// NotifyDeliver rows.
OriginParentExecutionId = msg.OriginParentExecutionId,
SiteEnqueuedAt = msg.SiteEnqueuedAt,
CreatedAt = DateTimeOffset.UtcNow,
// Status stays at its Pending default for the dispatch sweep to claim.

View File

@@ -735,9 +735,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
{
// Convert to ScriptCallRequest and Ask the Instance Actor
// Convert to ScriptCallRequest and Ask the Instance Actor.
// Audit Log #23 (ParentExecutionId): carry the inbound request's
// ExecutionId down as ParentExecutionId so the routed script
// execution can record its spawner.
var scriptCall = new ScriptCallRequest(
request.ScriptName, request.Parameters, 0, request.CorrelationId);
request.ScriptName, request.Parameters, 0, request.CorrelationId,
ParentExecutionId: request.ParentExecutionId);
var sender = Sender;
instanceActor.Ask<ScriptCallResult>(scriptCall, TimeSpan.FromSeconds(30))
.ContinueWith(t =>

View File

@@ -320,7 +320,10 @@ public class InstanceActor : ReceiveActor
{
if (_scriptActors.TryGetValue(request.ScriptName, out var scriptActor))
{
// Forward the request to the Script Actor, preserving the original sender
// Forward the request to the Script Actor, preserving the original
// sender. The whole record is forwarded unchanged, so any
// ParentExecutionId (Audit Log #23) set by an inbound-API-routed
// call is carried through to the Script Actor verbatim.
scriptActor.Forward(request);
}
else

View File

@@ -184,7 +184,13 @@ public class ScriptActor : ReceiveActor, IWithTimers
return;
}
SpawnExecution(request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId);
// Audit Log #23 (ParentExecutionId): carry any inbound-routed
// ParentExecutionId through to the ScriptExecutionActor so the routed
// script's ScriptRuntimeContext can record its spawner. Null for normal
// (tag-change / timer) runs and nested Script.Call invocations.
SpawnExecution(
request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId,
request.ParentExecutionId);
}
/// <summary>
@@ -379,7 +385,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
IReadOnlyDictionary<string, object?>? parameters,
int callDepth,
IActorRef replyTo,
string correlationId)
string correlationId,
Guid? parentExecutionId = null)
{
var executionId = $"{_scriptName}-exec-{_executionCounter++}";
@@ -401,7 +408,10 @@ public class ScriptActor : ReceiveActor, IWithTimers
_logger,
_scope,
_healthCollector,
_serviceProvider));
_serviceProvider,
// Audit Log #23 (ParentExecutionId): null for trigger-driven runs;
// an inbound-API-routed call supplies the inbound request's id.
parentExecutionId));
Context.ActorOf(props, executionId);
}

View File

@@ -43,7 +43,11 @@ public class ScriptExecutionActor : ReceiveActor
ILogger logger,
Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector = null,
IServiceProvider? serviceProvider = null)
IServiceProvider? serviceProvider = null,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// ExecutionId for an inbound-API-routed call. Null for normal
// (tag-change / timer) runs and nested Script.Call invocations.
Guid? parentExecutionId = null)
{
// Immediately begin execution
var self = Self;
@@ -52,7 +56,8 @@ public class ScriptExecutionActor : ReceiveActor
ExecuteScript(
scriptName, instanceName, compiledScript, parameters, callDepth,
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
self, parent, logger, scope, healthCollector, serviceProvider);
self, parent, logger, scope, healthCollector, serviceProvider,
parentExecutionId);
}
private static void ExecuteScript(
@@ -71,7 +76,8 @@ public class ScriptExecutionActor : ReceiveActor
ILogger logger,
Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector,
IServiceProvider? serviceProvider)
IServiceProvider? serviceProvider,
Guid? parentExecutionId)
{
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
@@ -164,7 +170,12 @@ public class ScriptExecutionActor : ReceiveActor
// emission. Best-effort: null degrades the helpers to a
// no-emission path; the S&F handoff and TrackedOperationId
// return are unaffected.
cachedForwarder: cachedForwarder);
cachedForwarder: cachedForwarder,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// id for an inbound-API-routed call. The routed script still
// mints its own fresh ExecutionId — this records the spawner.
// Null for normal (tag-change / timer) runs.
parentExecutionId: parentExecutionId);
var globals = new ScriptGlobals
{

View File

@@ -38,12 +38,22 @@ internal sealed class AuditingDbCommand : DbCommand
private readonly string _instanceName;
private readonly string? _sourceScript;
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when this
/// run was inbound-API-routed; <c>null</c> for non-routed runs. Threaded
/// alongside <see cref="_executionId"/> and stamped onto the <c>DbWrite</c>
/// audit row.
/// </summary>
private readonly Guid? _parentExecutionId;
private readonly ILogger _logger;
private DbConnection? _wrappingConnection;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbConnection).
// DatabaseHelper, AuditingDbConnection). parentExecutionId is a trailing
// optional param so existing positional callers stay source-compatible.
public AuditingDbCommand(
DbCommand inner,
IAuditWriter auditWriter,
@@ -52,7 +62,8 @@ internal sealed class AuditingDbCommand : DbCommand
string instanceName,
string? sourceScript,
ILogger logger,
Guid executionId)
Guid executionId,
Guid? parentExecutionId = null)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -62,6 +73,7 @@ internal sealed class AuditingDbCommand : DbCommand
_sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_executionId = executionId;
_parentExecutionId = parentExecutionId;
}
// -- Forwarded surface ------------------------------------------------
@@ -438,6 +450,9 @@ internal sealed class AuditingDbCommand : DbCommand
// trust-boundary rows from the same script run.
CorrelationId = null,
ExecutionId = _executionId,
// Audit Log #23 (ParentExecutionId): the spawning execution's id;
// null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,

View File

@@ -37,11 +37,21 @@ internal sealed class AuditingDbConnection : DbConnection
private readonly string _instanceName;
private readonly string? _sourceScript;
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when this
/// run was inbound-API-routed; <c>null</c> for non-routed runs. Threaded
/// alongside <see cref="_executionId"/> into the
/// <see cref="AuditingDbCommand"/> so its <c>DbWrite</c> row stamps it.
/// </summary>
private readonly Guid? _parentExecutionId;
private readonly ILogger _logger;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbCommand).
// DatabaseHelper, AuditingDbCommand). parentExecutionId is a trailing
// optional param so existing positional callers stay source-compatible.
public AuditingDbConnection(
DbConnection inner,
IAuditWriter auditWriter,
@@ -50,7 +60,8 @@ internal sealed class AuditingDbConnection : DbConnection
string instanceName,
string? sourceScript,
ILogger logger,
Guid executionId)
Guid executionId,
Guid? parentExecutionId = null)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -60,6 +71,7 @@ internal sealed class AuditingDbConnection : DbConnection
_sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_executionId = executionId;
_parentExecutionId = parentExecutionId;
}
// ConnectionString is settable on DbConnection — forward both halves.
@@ -99,7 +111,10 @@ internal sealed class AuditingDbConnection : DbConnection
_instanceName,
_sourceScript,
_logger,
_executionId);
_executionId,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId);
}
protected override void Dispose(bool disposing)

View File

@@ -116,6 +116,19 @@ public class ScriptRuntimeContext
/// </summary>
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's
/// <see cref="_executionId"/> when this script run was spawned by another
/// execution — for an inbound-API-routed call this is the inbound request's
/// per-request execution id. <c>null</c> for normal (tag-change /
/// timer-triggered) runs and nested <c>CallScript</c> invocations. The
/// routed script still mints its OWN fresh <see cref="_executionId"/>; this
/// field records the spawner so a spawned execution's audit rows can point
/// back at the execution that spawned it. (Task 5 wires the emitter that
/// stamps this onto <c>AuditEvent.ParentExecutionId</c>.)
/// </summary>
private readonly Guid? _parentExecutionId;
/// <param name="executionId">
/// Audit Log #23: the per-execution id for this script run. When omitted
/// (tag-change / timer-triggered executions) a fresh id is generated; an
@@ -123,6 +136,13 @@ public class ScriptRuntimeContext
/// request. Stamped into <c>AuditEvent.ExecutionId</c> on every
/// trust-boundary audit row this execution emits.
/// </param>
/// <param name="parentExecutionId">
/// Audit Log #23 (ParentExecutionId): the spawning execution's
/// <c>ExecutionId</c> — supplied for an inbound-API-routed call (the
/// inbound request's per-request id), <c>null</c> for normal (tag-change /
/// timer-triggered) runs. The routed script still generates its own fresh
/// <paramref name="executionId"/>; this only records the spawner.
/// </param>
public ScriptRuntimeContext(
IActorRef instanceActor,
IActorRef self,
@@ -141,7 +161,8 @@ public class ScriptRuntimeContext
IAuditWriter? auditWriter = null,
IOperationTrackingStore? operationTrackingStore = null,
ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? executionId = null)
Guid? executionId = null,
Guid? parentExecutionId = null)
{
_instanceActor = instanceActor;
_self = self;
@@ -161,6 +182,9 @@ public class ScriptRuntimeContext
_operationTrackingStore = operationTrackingStore;
_cachedForwarder = cachedForwarder;
_executionId = executionId ?? Guid.NewGuid();
// Audit Log #23 (ParentExecutionId): stored verbatim — no `?? NewGuid()`
// fallback. A non-routed run legitimately has no parent and stays null.
_parentExecutionId = parentExecutionId;
}
/// <summary>
@@ -264,7 +288,10 @@ public class ScriptRuntimeContext
_externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript,
// Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry
// on every ExternalSystem.CachedCall enqueue.
_cachedForwarder);
_cachedForwarder,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId);
/// <summary>
/// WP-13: Provides access to database operations.
@@ -285,7 +312,10 @@ public class ScriptRuntimeContext
_sourceScript,
// Audit Log #23 (M3 Bundle E — Task E6): emit CachedSubmit telemetry on
// every Database.CachedWrite enqueue.
_cachedForwarder);
_cachedForwarder,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId);
/// <summary>
/// Provides access to the Notification Outbox API.
@@ -302,7 +332,10 @@ public class ScriptRuntimeContext
/// </remarks>
public NotifyHelper Notify => new(
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
_executionId, _auditWriter);
_executionId, _auditWriter,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId);
/// <summary>
/// Audit Log #23 (M3): site-local tracking-status API for cached operations.
@@ -384,6 +417,15 @@ public class ScriptRuntimeContext
private readonly string _instanceName;
private readonly ILogger _logger;
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
/// emitter — no audit row carries it yet.
/// </summary>
private readonly Guid? _parentExecutionId;
private readonly IAuditWriter? _auditWriter;
private readonly string _siteId;
private readonly string? _sourceScript;
@@ -398,7 +440,9 @@ public class ScriptRuntimeContext
// DatabaseHelper, AuditingDbConnection, AuditingDbCommand) — a required
// Guid cannot follow the optional provenance params without a
// required-after-optional compile error, so the post-logger slot is the
// one consistent position that compiles cleanly everywhere.
// one consistent position that compiles cleanly everywhere. The nullable
// parentExecutionId is a trailing optional param so existing positional
// callers stay source-compatible.
internal ExternalSystemHelper(
IExternalSystemClient? client,
string instanceName,
@@ -407,7 +451,8 @@ public class ScriptRuntimeContext
IAuditWriter? auditWriter = null,
string siteId = "",
string? sourceScript = null,
ICachedCallTelemetryForwarder? cachedForwarder = null)
ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? parentExecutionId = null)
{
_client = client;
_instanceName = instanceName;
@@ -417,6 +462,7 @@ public class ScriptRuntimeContext
_siteId = siteId;
_sourceScript = sourceScript;
_cachedForwarder = cachedForwarder;
_parentExecutionId = parentExecutionId;
}
public async Task<ExternalCallResult> Call(
@@ -518,7 +564,12 @@ public class ScriptRuntimeContext
// execution's ExecutionId + SourceScript so a buffered
// cached call's retry-loop audit rows carry them.
executionId: _executionId,
sourceScript: _sourceScript).ConfigureAwait(false);
sourceScript: _sourceScript,
// Audit Log #23 (ParentExecutionId Task 6): thread the
// spawning inbound-API request's ExecutionId so a buffered
// cached call's retry-loop audit rows carry it too. Null
// for a non-routed run.
parentExecutionId: _parentExecutionId).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -580,6 +631,9 @@ public class ScriptRuntimeContext
// per-execution id shared across this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -693,6 +747,9 @@ public class ScriptRuntimeContext
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -757,6 +814,9 @@ public class ScriptRuntimeContext
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -934,6 +994,9 @@ public class ScriptRuntimeContext
// one script run can be correlated together.
CorrelationId = null,
ExecutionId = _executionId,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// id; null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -1001,6 +1064,15 @@ public class ScriptRuntimeContext
private readonly string _instanceName;
private readonly ILogger _logger;
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
/// emitter — no audit row carries it yet.
/// </summary>
private readonly Guid? _parentExecutionId;
private readonly string _siteId;
private readonly string? _sourceScript;
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
@@ -1020,7 +1092,7 @@ public class ScriptRuntimeContext
// Parameter ordering: executionId sits immediately after the
// ILogger — see the note on ExternalSystemHelper's ctor for why the
// post-logger slot is the one consistent position across all four
// audit-threaded ctors.
// audit-threaded ctors. parentExecutionId is a trailing optional param.
internal DatabaseHelper(
IDatabaseGateway? gateway,
string instanceName,
@@ -1029,7 +1101,8 @@ public class ScriptRuntimeContext
IAuditWriter? auditWriter = null,
string siteId = "",
string? sourceScript = null,
ICachedCallTelemetryForwarder? cachedForwarder = null)
ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? parentExecutionId = null)
{
_gateway = gateway;
_instanceName = instanceName;
@@ -1039,6 +1112,7 @@ public class ScriptRuntimeContext
_siteId = siteId;
_sourceScript = sourceScript;
_cachedForwarder = cachedForwarder;
_parentExecutionId = parentExecutionId;
}
public async Task<System.Data.Common.DbConnection> Connection(
@@ -1070,7 +1144,10 @@ public class ScriptRuntimeContext
instanceName: _instanceName,
sourceScript: _sourceScript,
logger: _logger,
executionId: _executionId);
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// id, threaded alongside _executionId. Null for non-routed runs.
parentExecutionId: _parentExecutionId);
}
/// <summary>
@@ -1106,7 +1183,12 @@ public class ScriptRuntimeContext
// execution's ExecutionId + SourceScript so a buffered
// cached write's retry-loop audit rows carry them.
executionId: _executionId,
sourceScript: _sourceScript)
sourceScript: _sourceScript,
// Audit Log #23 (ParentExecutionId Task 6): thread the
// spawning inbound-API request's ExecutionId so a buffered
// cached write's retry-loop audit rows carry it too. Null
// for a non-routed run.
parentExecutionId: _parentExecutionId)
.ConfigureAwait(false);
}
catch (Exception ex)
@@ -1146,6 +1228,9 @@ public class ScriptRuntimeContext
// (TrackedOperationId); ExecutionId = per-execution id.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -1213,6 +1298,14 @@ public class ScriptRuntimeContext
/// </summary>
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
/// emitter — no audit row carries it yet.
/// </summary>
private readonly Guid? _parentExecutionId;
/// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row produced when the script
@@ -1224,7 +1317,8 @@ public class ScriptRuntimeContext
private readonly IAuditWriter? _auditWriter;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other audit-threaded ctors.
// consistent with the other audit-threaded ctors. parentExecutionId is
// a trailing optional param.
internal NotifyHelper(
StoreAndForwardService? storeAndForward,
ICanTell? siteCommunicationActor,
@@ -1234,7 +1328,8 @@ public class ScriptRuntimeContext
TimeSpan askTimeout,
ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null)
IAuditWriter? auditWriter = null,
Guid? parentExecutionId = null)
{
_storeAndForward = storeAndForward;
_siteCommunicationActor = siteCommunicationActor;
@@ -1245,6 +1340,7 @@ public class ScriptRuntimeContext
_logger = logger;
_executionId = executionId;
_auditWriter = auditWriter;
_parentExecutionId = parentExecutionId;
}
/// <summary>
@@ -1259,7 +1355,10 @@ public class ScriptRuntimeContext
_executionId,
// Audit Log #23 (M4 Bundle C): forward the writer so Send()
// can emit one NotifySend(Submitted) row per accepted submission.
_auditWriter);
_auditWriter,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// id, threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId);
}
/// <summary>
@@ -1340,6 +1439,14 @@ public class ScriptRuntimeContext
/// </summary>
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
/// emitter — no audit row carries it yet.
/// </summary>
private readonly Guid? _parentExecutionId;
/// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row written immediately after
@@ -1356,7 +1463,8 @@ public class ScriptRuntimeContext
string? sourceScript,
ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null)
IAuditWriter? auditWriter = null,
Guid? parentExecutionId = null)
{
_listName = listName;
_storeAndForward = storeAndForward;
@@ -1366,6 +1474,7 @@ public class ScriptRuntimeContext
_logger = logger;
_executionId = executionId;
_auditWriter = auditWriter;
_parentExecutionId = parentExecutionId;
}
/// <summary>
@@ -1412,7 +1521,13 @@ public class ScriptRuntimeContext
// onto this run's NotifySend audit row. It rides inside the serialized
// payload through the S&F buffer to central, where the dispatcher echoes
// it onto the NotifyDeliver rows so all rows for one run share an id.
OriginExecutionId: _executionId);
OriginExecutionId: _executionId,
// OriginParentExecutionId (Audit Log #23): the SAME parent-execution id
// stamped onto this run's NotifySend audit row — the spawning run's id
// for an inbound-API-routed execution, null otherwise. It rides through
// the S&F buffer to central, where the dispatcher echoes it onto the
// NotifyDeliver rows so the central rows carry the routed run's parent id.
OriginParentExecutionId: _parentExecutionId);
var payloadJson = JsonSerializer.Serialize(payload);
@@ -1490,6 +1605,9 @@ public class ScriptRuntimeContext
// lifecycle id; ExecutionId carries the per-execution id.
CorrelationId = correlationId,
ExecutionId = _executionId,
// Audit Log #23 (ParentExecutionId): the spawning
// execution's id; null for non-routed runs.
ParentExecutionId = _parentExecutionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,

View File

@@ -76,4 +76,19 @@ public class StoreAndForwardMessage
/// known (non-cached categories, pre-migration rows).
/// </summary>
public string? SourceScript { get; set; }
/// <summary>
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
/// inbound-API request that spawned the originating script execution,
/// threaded alongside <see cref="ExecutionId"/> from the cached-call enqueue
/// path. Carried so the store-and-forward retry loop can stamp it onto the
/// per-attempt / terminal cached-call audit rows
/// (<c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted, <c>CachedResolve</c>),
/// keeping them correlated with the cross-execution chain. <c>null</c> for a
/// non-routed run, for non-cached-call categories (notifications), and for
/// rows buffered before this field existed — back-compat with old persisted
/// rows (the column is added by an additive migration and read as null when
/// absent).
/// </summary>
public Guid? ParentExecutionId { get; set; }
}

View File

@@ -187,6 +187,14 @@ public class StoreAndForwardService
/// so the retry-loop audit rows carry the same provenance the script-side
/// cached rows do. <c>null</c> when not known.
/// </param>
/// <param name="parentExecutionId">
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
/// inbound-API request that spawned the originating script execution.
/// Threaded onto the buffered row alongside <paramref name="executionId"/>
/// so the retry-loop cached-call audit rows carry it. <c>null</c> for a
/// non-routed run and for callers (notifications, pre-Task-6 callers) that
/// do not supply one.
/// </param>
public async Task<StoreAndForwardResult> EnqueueAsync(
StoreAndForwardCategory category,
string target,
@@ -197,7 +205,8 @@ public class StoreAndForwardService
bool attemptImmediateDelivery = true,
string? messageId = null,
Guid? executionId = null,
string? sourceScript = null)
string? sourceScript = null,
Guid? parentExecutionId = null)
{
var message = new StoreAndForwardMessage
{
@@ -212,7 +221,8 @@ public class StoreAndForwardService
Status = StoreAndForwardMessageStatus.Pending,
OriginInstanceName = originInstanceName,
ExecutionId = executionId,
SourceScript = sourceScript
SourceScript = sourceScript,
ParentExecutionId = parentExecutionId
};
// Attempt immediate delivery — unless the caller has already made a
@@ -515,7 +525,13 @@ public class StoreAndForwardService
// stamp the retry-loop cached audit rows. Null on rows buffered
// before Task 4 (back-compat).
ExecutionId: message.ExecutionId,
SourceScript: message.SourceScript);
SourceScript: message.SourceScript,
// Audit Log #23 (ParentExecutionId Task 6): the buffered
// message also carries the spawning inbound-API request's
// ExecutionId; surface it so the bridge stamps it onto the
// retry-loop cached rows. Null for a non-routed run and on
// rows buffered before Task 6 (back-compat).
ParentExecutionId: message.ParentExecutionId);
}
catch (Exception buildEx)
{

View File

@@ -76,6 +76,12 @@ public class StoreAndForwardStorage
await AddColumnIfMissingAsync(connection, "execution_id", "TEXT");
await AddColumnIfMissingAsync(connection, "source_script", "TEXT");
// Audit Log #23 (ParentExecutionId Task 6): additively add the
// parent_execution_id column the same way — a sibling to execution_id.
// Nullable with no default, so any row buffered before this migration
// reads back ParentExecutionId = null (back-compat).
await AddColumnIfMissingAsync(connection, "parent_execution_id", "TEXT");
_logger.LogInformation("Store-and-forward SQLite storage initialized");
}
@@ -142,10 +148,10 @@ public class StoreAndForwardStorage
cmd.CommandText = @"
INSERT INTO sf_messages (id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error,
origin_instance, execution_id, source_script)
origin_instance, execution_id, source_script, parent_execution_id)
VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries,
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError,
@origin, @executionId, @sourceScript)";
@origin, @executionId, @sourceScript, @parentExecutionId)";
cmd.Parameters.AddWithValue("@id", message.Id);
cmd.Parameters.AddWithValue("@category", (int)message.Category);
@@ -166,6 +172,11 @@ public class StoreAndForwardStorage
cmd.Parameters.AddWithValue("@executionId",
message.ExecutionId.HasValue ? message.ExecutionId.Value.ToString("D") : DBNull.Value);
cmd.Parameters.AddWithValue("@sourceScript", (object?)message.SourceScript ?? DBNull.Value);
// Audit Log #23 (ParentExecutionId Task 6): the parent execution id is
// stored as its canonical string form ("D") so it round-trips cleanly
// through the TEXT column; null when not a routed cached call.
cmd.Parameters.AddWithValue("@parentExecutionId",
message.ParentExecutionId.HasValue ? message.ParentExecutionId.Value.ToString("D") : DBNull.Value);
await cmd.ExecuteNonQueryAsync();
}
@@ -182,7 +193,7 @@ public class StoreAndForwardStorage
cmd.CommandText = @"
SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script
execution_id, source_script, parent_execution_id
FROM sf_messages
WHERE status = @pending
AND (last_attempt_at IS NULL
@@ -314,7 +325,7 @@ public class StoreAndForwardStorage
pageCmd.CommandText = $@"
SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script
execution_id, source_script, parent_execution_id
FROM sf_messages
WHERE status = @parked{categoryFilter}
ORDER BY created_at ASC
@@ -436,7 +447,7 @@ public class StoreAndForwardStorage
cmd.CommandText = @"
SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script
execution_id, source_script, parent_execution_id
FROM sf_messages
WHERE id = @id";
cmd.Parameters.AddWithValue("@id", messageId);
@@ -500,28 +511,35 @@ public class StoreAndForwardStorage
// Guid.TryParse (not Parse) guards the retry sweep: a corrupt
// non-null execution_id is treated as "no execution id" rather
// than throwing FormatException and aborting the whole sweep.
ExecutionId = ParseExecutionId(reader, 12),
SourceScript = reader.IsDBNull(13) ? null : reader.GetString(13)
ExecutionId = ParseGuidColumn(reader, 12),
SourceScript = reader.IsDBNull(13) ? null : reader.GetString(13),
// Audit Log #23 (ParentExecutionId Task 6): rows persisted
// before the additive migration have no parent_execution_id
// value; the IsDBNull guard inside ParseGuidColumn keeps those
// reading back as null (back-compat). Guid.TryParse (not Parse)
// guards the retry sweep against a corrupt non-null value.
ParentExecutionId = ParseGuidColumn(reader, 14)
});
}
return results;
}
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): defensively reads the
/// <c>execution_id</c> column. A <c>null</c> value (legacy pre-migration
/// Audit Log #23 (ExecutionId Task 4 / ParentExecutionId Task 6):
/// defensively reads a nullable GUID column (<c>execution_id</c> or
/// <c>parent_execution_id</c>). A <c>null</c> value (legacy pre-migration
/// rows) and a malformed non-null value both yield <c>null</c> — a corrupt
/// id must not throw and abort the retry sweep, which reads many rows.
/// </summary>
private static Guid? ParseExecutionId(System.Data.Common.DbDataReader reader, int ordinal)
private static Guid? ParseGuidColumn(System.Data.Common.DbDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return null;
}
return Guid.TryParse(reader.GetString(ordinal), out var executionId)
? executionId
return Guid.TryParse(reader.GetString(ordinal), out var value)
? value
: null;
}
}

View File

@@ -224,5 +224,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
_inner.GetExecutionTreeAsync(executionId, ct);
}
}

View File

@@ -82,6 +82,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
}
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)

View File

@@ -51,6 +51,10 @@ public class CentralAuditWriteFailuresTests : TestKit
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
}
/// <summary>

View File

@@ -97,6 +97,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
}
/// <summary>

View File

@@ -0,0 +1,618 @@
using System.Text;
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.InboundApi;
using ScadaLink.Commons.Messages.Notification;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
using ScadaLink.InboundAPI;
using ScadaLink.InboundAPI.Middleware;
using ScadaLink.NotificationOutbox;
using ScadaLink.NotificationOutbox.Delivery;
using ScadaLink.NotificationOutbox.Messages;
using ScadaLink.SiteRuntime.Scripts;
using ScadaLink.StoreAndForward;
namespace ScadaLink.AuditLog.Tests.Integration;
/// <summary>
/// Audit Log #23 — <b>ParentExecutionId cross-execution correlation</b> headline
/// end-to-end suite. Verifies the inbound-API → routed-site-script bridge: an
/// inbound HTTP request runs an inbound method script that calls
/// <c>Route.Call</c> into a site instance; the routed site script does a sync
/// <c>ExternalSystem.Call</c>, a cached call and a <c>Notify.Send</c>. Every
/// audit row the routed run produces — site + central, sync + cached lifecycle
/// + <c>NotifySend</c>/<c>NotifyDeliver</c> — must carry
/// <see cref="AuditEvent.ParentExecutionId"/> equal to the inbound request's
/// <see cref="AuditEvent.ExecutionId"/>, while the routed run has its own
/// distinct <see cref="AuditEvent.ExecutionId"/> and the inbound
/// <see cref="AuditKind.InboundRequest"/> row is top-level
/// (<c>ParentExecutionId = NULL</c>).
/// </summary>
/// <remarks>
/// <para>
/// This is the integration-level counterpart to <see cref="ExecutionIdCorrelationTests"/>:
/// where that suite drives a single <see cref="ScriptRuntimeContext"/> run and
/// asserts the shared per-run <c>ExecutionId</c>, this suite spans <b>two</b>
/// executions on opposite sides of the inbound→routed bridge and asserts the
/// cross-execution <c>ParentExecutionId</c> linkage plus
/// <see cref="IAuditLogRepository.GetExecutionTreeAsync"/>.
/// </para>
/// <para>
/// The bridge is exercised through the genuine production glue:
/// <list type="bullet">
/// <item><description>the real <see cref="AuditWriteMiddleware"/> in a
/// Microsoft.AspNetCore.TestHost pipeline — mints the inbound request's
/// per-request <c>ExecutionId</c> once, stashes it on
/// <see cref="HttpContext.Items"/>, and emits the top-level
/// <see cref="AuditKind.InboundRequest"/> row via the real
/// <see cref="CentralAuditWriter"/>;</description></item>
/// <item><description>the real <see cref="InboundScriptExecutor"/> +
/// <see cref="RouteHelper"/> — the executor binds the stashed inbound
/// <c>ExecutionId</c> via <see cref="RouteHelper.WithParentExecutionId"/>, so a
/// <c>Route.To(...).Call(...)</c> inside the inbound script builds a
/// <see cref="RouteToCallRequest"/> carrying
/// <see cref="RouteToCallRequest.ParentExecutionId"/>.</description></item>
/// </list>
/// Only the cross-cluster routing transport is substituted: the test
/// <see cref="BridgingInstanceRouter"/> stands in for
/// <c>CommunicationServiceInstanceRouter</c> exactly as the production site
/// (<c>DeploymentManagerActor</c> → <c>ScriptActor</c> → <c>ScriptExecutionActor</c>)
/// would — it reads <see cref="RouteToCallRequest.ParentExecutionId"/> off the
/// wire request and threads it into the routed <see cref="ScriptRuntimeContext"/>
/// as <c>parentExecutionId</c>. A multi-node cluster is out of scope for an
/// in-process test (mirroring <c>SiteAuditPushFlowTests</c>'s relay).
/// </para>
/// <para>
/// The central audit store is the real <see cref="AuditLogRepository"/> over the
/// per-class <see cref="MsSqlMigrationFixture"/> MSSQL database; the routed run's
/// site rows reach it through the real <see cref="SqliteAuditWriter"/> hot-path +
/// <see cref="SiteAuditTelemetryActor"/> drain, the cached lifecycle rows through
/// the production <see cref="CachedCallTelemetryForwarder"/>, and the
/// <c>NotifyDeliver</c> rows through the real central
/// <see cref="NotificationOutboxActor"/> dispatcher.
/// </para>
/// </remarks>
public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public ParentExecutionIdCorrelationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private const string RoutedInstanceCode = "Plant.Pump42";
private const string RoutedScriptName = "OnInboundRouted";
private const string ExternalSystemName = "ERP";
private const string ExternalMethodName = "GetOrder";
private const string NotifyListName = "ops-team";
/// <summary>Per-run site id (Guid suffix) so concurrent tests sharing the MSSQL fixture stay isolated.</summary>
private static string NewSiteId() =>
"test-parentexec-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private ScadaLinkDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
.Options;
return new ScadaLinkDbContext(options);
}
[SkippableFact]
public async Task InboundRoutedRun_AllRoutedRows_CarryInboundExecutionId_AsParentExecutionId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
// ── Central — repository + ingest actor + audit writer over the MSSQL fixture ──
var centralServices = new ServiceCollection();
centralServices.AddDbContext<ScadaLinkDbContext>(opts =>
opts.UseSqlServer(_fixture.ConnectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
centralServices.AddScoped<IAuditLogRepository>(sp =>
new AuditLogRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
centralServices.AddScoped<ISiteCallAuditRepository>(sp =>
new SiteCallAuditRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
centralServices.AddScoped<INotificationOutboxRepository>(sp =>
new NotificationOutboxRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
centralServices.AddScoped<INotificationRepository>(sp =>
new NotificationRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
// The NotifyDeliver dispatch path runs through this same long-lived
// provider — a stub adapter that always reports a successful delivery.
centralServices.AddScoped<INotificationDeliveryAdapter>(_ => new AlwaysDeliversAdapter());
await using var centralProvider = centralServices.BuildServiceProvider();
var ingestActor = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
(IServiceProvider)centralProvider,
NullLogger<AuditLogIngestActor>.Instance)));
var centralAuditWriter = new CentralAuditWriter(
centralProvider, NullLogger<CentralAuditWriter>.Instance);
// ── Site — SQLite audit writer (hot-path) drained to central by the
// real SiteAuditTelemetryActor through the stub gRPC client. The sync
// ApiCall row and the NotifySend row flow through this chain. ──
await using var sqliteWriter = new SqliteAuditWriter(
Options.Create(new SqliteAuditWriterOptions
{
DatabasePath = "ignored",
BatchSize = 64,
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
connectionStringOverride:
$"Data Source=file:auditlog-parentexec-{Guid.NewGuid():N}?mode=memory&cache=shared");
var ring = new RingBufferFallback();
var siteAuditWriter = new FallbackAuditWriter(
sqliteWriter, ring, new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance);
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor);
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
(ISiteAuditQueue)sqliteWriter,
stubClient,
Options.Create(new SiteAuditTelemetryOptions
{
BatchSize = 256,
BusyIntervalSeconds = 1,
IdleIntervalSeconds = 1,
}),
NullLogger<SiteAuditTelemetryActor>.Instance)));
// Cached-call telemetry: production forwarder + dispatcher that also
// pushes each combined packet through the stub client into the central
// dual-write transaction (same wiring CombinedTelemetryHarness uses).
var cachedForwarder = new CombinedTelemetryDispatcher(
new CachedCallTelemetryForwarder(
siteAuditWriter, trackingStore: null,
NullLogger<CachedCallTelemetryForwarder>.Instance),
stubClient);
// Site Store-and-Forward — Notify.Send buffers a NotificationSubmit here.
using var safKeepAlive = new Microsoft.Data.Sqlite.SqliteConnection(
$"Data Source=parentexec-saf-{Guid.NewGuid():N};Mode=Memory;Cache=Shared");
safKeepAlive.Open();
var safStorage = new StoreAndForwardStorage(
safKeepAlive.ConnectionString, NullLogger<StoreAndForwardStorage>.Instance);
await safStorage.InitializeAsync();
var storeAndForward = new StoreAndForwardService(
safStorage,
new StoreAndForwardOptions
{
DefaultRetryInterval = TimeSpan.Zero,
DefaultMaxRetries = 3,
RetryTimerInterval = TimeSpan.FromMinutes(10),
},
NullLogger<StoreAndForwardService>.Instance);
// ── Outbound external-system client (routed run): sync Call succeeds,
// CachedCall completes immediately (WasBuffered=false) so the script
// helper emits the Submit + Attempted + CachedResolve lifecycle. ──
var externalClient = Substitute.For<IExternalSystemClient>();
externalClient
.CallAsync(ExternalSystemName, ExternalMethodName,
Arg.Any<IReadOnlyDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(new ExternalCallResult(true, "{\"ok\":true}", null));
externalClient
.CachedCallAsync(ExternalSystemName, ExternalMethodName,
Arg.Any<IReadOnlyDictionary<string, object?>?>(),
Arg.Any<string?>(), Arg.Any<CancellationToken>(),
Arg.Any<ScadaLink.Commons.Types.TrackedOperationId?>(),
Arg.Any<Guid?>(), Arg.Any<string?>(), Arg.Any<Guid?>())
.Returns(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
// ── The routing transport stand-in: builds the routed ScriptRuntimeContext
// carrying RouteToCallRequest.ParentExecutionId — exactly what the
// production site handler (DeploymentManagerActor) does. ──
var router = new BridgingInstanceRouter(
siteId,
externalClient,
siteAuditWriter,
cachedForwarder,
storeAndForward);
// ── The inbound API method script: it calls Route.Call into the site
// instance. The real InboundScriptExecutor binds the inbound request's
// ExecutionId onto the RouteHelper, so the routed call carries it as
// ParentExecutionId. ──
var inboundMethod = new ScadaLink.Commons.Entities.InboundApi.ApiMethod(
"submitOrder",
$"return await Route.To(\"{RoutedInstanceCode}\").Call(\"{RoutedScriptName}\", new {{ order = 7 }});");
var locator = Substitute.For<IInstanceLocator>();
locator.GetSiteIdForInstanceAsync(RoutedInstanceCode, Arg.Any<CancellationToken>())
.Returns(siteId);
var scriptExecutor = new InboundScriptExecutor(
NullLogger<InboundScriptExecutor>.Instance,
new ServiceCollection().BuildServiceProvider());
Assert.True(scriptExecutor.CompileAndRegister(inboundMethod));
// ── Act — issue the inbound HTTP request through a TestHost pipeline
// fronted by the real AuditWriteMiddleware. The endpoint handler reads
// the middleware-stashed inbound ExecutionId and runs the inbound
// method script with it as parentExecutionId. ──
using var host = await BuildInboundHostAsync(centralAuditWriter, async ctx =>
{
var inboundExecutionId = (Guid)ctx.Items[AuditWriteMiddleware.InboundExecutionIdItemKey]!;
var route = new RouteHelper(locator, router);
var result = await scriptExecutor.ExecuteAsync(
inboundMethod,
new Dictionary<string, object?>(),
route,
TimeSpan.FromSeconds(30),
ctx.RequestAborted,
parentExecutionId: inboundExecutionId);
ctx.Response.StatusCode = result.Success ? 200 : 500;
await ctx.Response.WriteAsync(result.Success ? "ok" : "fail");
});
var client = host.GetTestClient();
var response = await client.PostAsync(
"/api/submitOrder",
new StringContent("{}", Encoding.UTF8, "application/json"));
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
// The routed run emits its sync-ApiCall and NotifySend audit rows on a
// deliberately fire-and-forget path (alog.md §7 — an audit write must
// never block the user-facing script call). `Notify.Send` therefore
// returns — and the routed `RouteToCallAsync` completes — BEFORE the
// SqliteAuditWriter background loop has flushed the NotifySend row into
// the site hot-path. Wait for all five site rows to be durably present
// in SQLite before the central assertion: this is the production
// durability point (the row IS in SQLite before it is considered
// audited), and pinning it removes the emit-vs-drain race that
// otherwise let the SiteAuditTelemetryADrain forward only four rows on
// its first tick and leave NotifySend stranded for a full drain
// interval under heavy parallel load.
await WaitForSiteRowsPersistedAsync(sqliteWriter);
// The routed run produced a NotifySend that buffered a NotificationSubmit
// into S&F. Drain that genuine site-produced submission to the central
// NotificationOutboxActor so the NotifyDeliver dispatch rows materialise.
await ForwardBufferedNotificationToCentralAsync(
storeAndForward, router.NotificationId!, centralProvider, centralAuditWriter);
// ── Assert ──────────────────────────────────────────────────────────
await AwaitAssertAsync(async () =>
{
await using var readContext = CreateContext();
var repo = new AuditLogRepository(readContext);
// Every audit row this site produced (sync ApiCall + cached lifecycle
// + NotifySend) plus the central NotifyDeliver rows.
var siteRows = await repo.QueryAsync(
new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }),
new AuditLogPaging(PageSize: 100));
// sync ApiCall (1) + cached Submit/Attempted/Resolve (3) + NotifySend (1)
// + NotifyDeliver Attempted/Delivered (2) = 7 rows for the routed run.
Assert.True(siteRows.Count == 7,
"Expected 7 routed-run audit rows; saw: "
+ string.Join(", ", siteRows.Select(r => $"{r.Channel}/{r.Kind}/{r.Status}")));
Assert.Single(siteRows, r => r.Channel == AuditChannel.ApiOutbound && r.Kind == AuditKind.ApiCall);
Assert.Single(siteRows, r => r.Kind == AuditKind.CachedSubmit);
Assert.Single(siteRows, r => r.Kind == AuditKind.CachedResolve);
Assert.Single(siteRows, r => r.Kind == AuditKind.NotifySend);
Assert.Equal(2, siteRows.Count(r => r.Kind == AuditKind.NotifyDeliver));
// CORE PROMISE: every routed-run row carries the SAME non-null
// ParentExecutionId — the inbound request's ExecutionId.
var parentIds = siteRows.Select(r => r.ParentExecutionId).Distinct().ToList();
Assert.Single(parentIds);
Assert.NotNull(parentIds[0]);
var inboundExecutionId = parentIds[0]!.Value;
// The routed run has its OWN distinct ExecutionId — not the parent's.
var routedExecutionIds = siteRows
.Select(r => r.ExecutionId)
.Distinct()
.ToList();
Assert.Single(routedExecutionIds);
Assert.NotNull(routedExecutionIds[0]);
var routedExecutionId = routedExecutionIds[0]!.Value;
Assert.NotEqual(inboundExecutionId, routedExecutionId);
// The inbound request's own InboundRequest row is TOP-LEVEL —
// ExecutionId = the propagated id, ParentExecutionId = NULL.
var inboundRows = await repo.QueryAsync(
new AuditLogQueryFilter(ExecutionId: inboundExecutionId),
new AuditLogPaging(PageSize: 10));
var inboundRow = Assert.Single(inboundRows,
r => r.Channel == AuditChannel.ApiInbound && r.Kind == AuditKind.InboundRequest);
Assert.Equal(AuditStatus.Delivered, inboundRow.Status);
Assert.Null(inboundRow.ParentExecutionId);
// The parentExecutionId filter pulls the routed run's complete
// trust-boundary footprint (all 7 routed rows, none of the inbound).
var byParent = await repo.QueryAsync(
new AuditLogQueryFilter(ParentExecutionId: inboundExecutionId),
new AuditLogPaging(PageSize: 100));
Assert.Equal(7, byParent.Count);
Assert.All(byParent, r => Assert.Equal(routedExecutionId, r.ExecutionId));
// GetExecutionTreeAsync returns BOTH executions in one chain —
// inbound (root) and routed (child), regardless of entry point.
var treeFromChild = await repo.GetExecutionTreeAsync(routedExecutionId);
AssertChain(treeFromChild, inboundExecutionId, routedExecutionId);
var treeFromRoot = await repo.GetExecutionTreeAsync(inboundExecutionId);
AssertChain(treeFromRoot, inboundExecutionId, routedExecutionId);
}, TimeSpan.FromSeconds(90));
}
/// <summary>
/// Asserts the execution tree is the expected two-node inbound→routed chain:
/// the inbound execution is the root (<c>ParentExecutionId = NULL</c>) and the
/// routed execution's <c>ParentExecutionId</c> points back at it.
/// </summary>
private static void AssertChain(
IReadOnlyList<ExecutionTreeNode> tree,
Guid inboundExecutionId,
Guid routedExecutionId)
{
Assert.Equal(2, tree.Count);
var root = Assert.Single(tree, n => n.ExecutionId == inboundExecutionId);
Assert.Null(root.ParentExecutionId);
var child = Assert.Single(tree, n => n.ExecutionId == routedExecutionId);
Assert.Equal(inboundExecutionId, child.ParentExecutionId);
}
/// <summary>
/// Spins up a minimal in-memory ASP.NET host whose pipeline mirrors the
/// production inbound-API arrangement: routing → the real
/// <see cref="AuditWriteMiddleware"/> → the <c>POST /api/{methodName}</c>
/// endpoint. The middleware mints + stashes the inbound request's
/// <c>ExecutionId</c> and emits the top-level <see cref="AuditKind.InboundRequest"/>
/// row via the supplied <see cref="ICentralAuditWriter"/>.
/// </summary>
private static async Task<IHost> BuildInboundHostAsync(
ICentralAuditWriter centralAuditWriter,
RequestDelegate endpointHandler)
{
var hostBuilder = new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder
.UseTestServer()
.ConfigureServices(services =>
{
services.AddSingleton(centralAuditWriter);
services.AddRouting();
})
.Configure(app =>
{
app.UseRouting();
app.UseAuditWriteMiddleware();
app.UseEndpoints(endpoints =>
{
endpoints.MapPost("/api/{methodName}", endpointHandler);
});
});
});
return await hostBuilder.StartAsync();
}
/// <summary>
/// Reads the genuine site-produced <see cref="NotificationSubmit"/> the routed
/// <c>Notify.Send</c> buffered into Store-and-Forward, then drives it through
/// a real central <see cref="NotificationOutboxActor"/> so the
/// <see cref="AuditKind.NotifyDeliver"/> dispatch rows materialise. The
/// dispatcher echoes <c>OriginParentExecutionId</c> off the
/// <c>NotificationSubmit</c> onto every <c>NotifyDeliver</c> row — the
/// cross-execution linkage under test on the central side.
/// </summary>
private async Task ForwardBufferedNotificationToCentralAsync(
StoreAndForwardService storeAndForward,
string notificationId,
IServiceProvider centralProvider,
ICentralAuditWriter centralAuditWriter)
{
var buffered = await storeAndForward.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var submit = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(submit);
// The routed Notify.Send stamped the inbound request's ExecutionId as the
// submission's OriginParentExecutionId — proven separately on the
// NotifyDeliver rows, but asserted here too as the central handoff input.
Assert.NotNull(submit!.OriginParentExecutionId);
// The outbox actor runs over the long-lived central provider (which
// carries the AlwaysDeliversAdapter) so the dispatch sweep — launched
// asynchronously by the DispatchTick — still has a live IServiceProvider
// to resolve its per-sweep scope from.
var outboxActor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
centralProvider,
new NotificationOutboxOptions
{
// Long timers so PreStart's scheduled ticks never fire — the
// test drives ingest + dispatch explicitly.
DispatchInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromDays(1),
},
centralAuditWriter,
NullLogger<NotificationOutboxActor>.Instance)));
// Ingest the genuine site submission, then run one dispatch sweep.
var ack = await outboxActor.Ask<NotificationSubmitAck>(
submit, TimeSpan.FromSeconds(15));
Assert.True(ack.Accepted, ack.Error);
outboxActor.Tell(InternalMessages.DispatchTick.Instance);
}
/// <summary>
/// Polls the site SQLite hot-path until every audit <see cref="AuditKind"/>
/// the routed run is expected to emit — sync <c>ApiCall</c>, the cached
/// <c>CachedSubmit</c>/<c>ApiCallCached</c>/<c>CachedResolve</c> lifecycle,
/// and <c>NotifySend</c> — is durably present (Pending or Forwarded).
/// </summary>
/// <remarks>
/// The routed run's sync-<c>ApiCall</c> and <c>NotifySend</c> audit rows are
/// written fire-and-forget (the script call must not block on the audit
/// writer — alog.md §7), so the routed <c>RouteToCallAsync</c> returns
/// before the background writer loop has committed those rows.
/// <c>NotifySend</c> is emitted last and therefore settles last. This wait
/// asserts the specific <b>Kinds</b> are present, not merely a row count: a
/// bare count could be satisfied while the last-emitted <c>NotifySend</c>
/// row was still in flight, letting the <c>SiteAuditTelemetryActor</c> drain
/// only a partial snapshot and leave <c>NotifySend</c> stranded for a later
/// tick — the emit-vs-drain race that failed this test under full-suite load.
/// </remarks>
private async Task WaitForSiteRowsPersistedAsync(SqliteAuditWriter sqliteWriter)
{
var expectedKinds = new[]
{
AuditKind.ApiCall, AuditKind.CachedSubmit, AuditKind.ApiCallCached,
AuditKind.CachedResolve, AuditKind.NotifySend,
};
await AwaitAssertAsync(
async () =>
{
var pending = await sqliteWriter.ReadPendingAsync(256);
var forwarded = await sqliteWriter.ReadForwardedAsync(256);
var kinds = pending.Concat(forwarded).Select(r => r.Kind).ToHashSet();
var missing = expectedKinds.Where(k => !kinds.Contains(k)).ToList();
Assert.True(
missing.Count == 0,
"Expected every routed-run audit Kind durably in SQLite; missing: "
+ string.Join(", ", missing)
+ $" (saw {pending.Count} Pending + {forwarded.Count} Forwarded).");
},
TimeSpan.FromSeconds(30),
TimeSpan.FromMilliseconds(50));
}
/// <summary>
/// Stub <see cref="INotificationDeliveryAdapter"/> that always reports a
/// successful delivery — a single dispatch sweep then yields one
/// <see cref="AuditStatus.Attempted"/> + one <see cref="AuditStatus.Delivered"/>
/// <see cref="AuditKind.NotifyDeliver"/> row.
/// </summary>
private sealed class AlwaysDeliversAdapter : INotificationDeliveryAdapter
{
public NotificationType Type => NotificationType.Email;
public Task<DeliveryOutcome> DeliverAsync(
ScadaLink.Commons.Entities.Notifications.Notification notification,
CancellationToken cancellationToken = default)
=> Task.FromResult(DeliveryOutcome.Success("ops@example.com"));
}
/// <summary>
/// In-process stand-in for the cross-cluster routing transport
/// (<c>CommunicationServiceInstanceRouter</c> →
/// <c>CommunicationService</c> → site <c>DeploymentManagerActor</c>). On a
/// routed <c>Call</c> it does exactly what the production site handler does:
/// it reads <see cref="RouteToCallRequest.ParentExecutionId"/> off the wire
/// request and threads it into a fresh routed <see cref="ScriptRuntimeContext"/>
/// as <c>parentExecutionId</c>, then runs the routed script's three
/// trust-boundary actions (sync <c>ExternalSystem.Call</c>, a cached call and
/// a <c>Notify.Send</c>). The routed context still mints its OWN fresh
/// <c>ExecutionId</c> — only the parent pointer is inherited.
/// </summary>
private sealed class BridgingInstanceRouter : IInstanceRouter
{
private readonly string _siteId;
private readonly IExternalSystemClient _externalClient;
private readonly IAuditWriter _auditWriter;
private readonly ICachedCallTelemetryForwarder _cachedForwarder;
private readonly StoreAndForwardService _storeAndForward;
/// <summary>
/// The <c>NotificationId</c> the routed <c>Notify.Send</c> minted, captured
/// so the test can drain the buffered <see cref="NotificationSubmit"/>.
/// </summary>
public string? NotificationId { get; private set; }
public BridgingInstanceRouter(
string siteId,
IExternalSystemClient externalClient,
IAuditWriter auditWriter,
ICachedCallTelemetryForwarder cachedForwarder,
StoreAndForwardService storeAndForward)
{
_siteId = siteId;
_externalClient = externalClient;
_auditWriter = auditWriter;
_cachedForwarder = cachedForwarder;
_storeAndForward = storeAndForward;
}
public async Task<RouteToCallResponse> RouteToCallAsync(
string siteId, RouteToCallRequest request, CancellationToken cancellationToken)
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
var sharedScriptLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.Instance);
// Mirror DeploymentManagerActor → ScriptActor → ScriptExecutionActor:
// the routed script execution gets its OWN fresh ExecutionId, and the
// inbound request's ExecutionId arrives as ParentExecutionId.
var routedContext = new ScriptRuntimeContext(
ActorRefs.Nobody,
ActorRefs.Nobody,
sharedScriptLibrary,
currentCallDepth: 0,
maxCallDepth: 10,
askTimeout: TimeSpan.FromSeconds(5),
instanceName: request.InstanceUniqueName,
logger: NullLogger.Instance,
externalSystemClient: _externalClient,
databaseGateway: null,
storeAndForward: _storeAndForward,
siteCommunicationActor: null,
siteId: _siteId,
sourceScript: $"ScriptActor:{request.ScriptName}",
auditWriter: _auditWriter,
operationTrackingStore: null,
cachedForwarder: _cachedForwarder,
executionId: null,
parentExecutionId: request.ParentExecutionId);
// The routed site script's body: a sync ExternalSystem.Call, a cached
// call, and a Notify.Send — three distinct trust-boundary actions of
// the one routed execution.
await routedContext.ExternalSystem.Call(ExternalSystemName, ExternalMethodName);
await routedContext.ExternalSystem.CachedCall(ExternalSystemName, ExternalMethodName);
NotificationId = await routedContext.Notify
.To(NotifyListName)
.Send("Routed run alert", "inbound-routed script fired");
return new RouteToCallResponse(
request.CorrelationId, true, "routed-ok", null, DateTimeOffset.UtcNow);
}
public Task<RouteToGetAttributesResponse> RouteToGetAttributesAsync(
string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken)
=> throw new NotSupportedException();
}
}

View File

@@ -43,9 +43,9 @@ public class SqliteAuditWriterSchemaTests
}
[Fact]
public void Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId()
public void Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId()
{
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId));
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
@@ -59,7 +59,7 @@ public class SqliteAuditWriterSchemaTests
columns.Add((reader.GetString(1), reader.GetInt32(5)));
}
Assert.Equal(21, columns.Count);
Assert.Equal(22, columns.Count);
var expected = new[]
{
@@ -67,7 +67,7 @@ public class SqliteAuditWriterSchemaTests
"SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target",
"Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail",
"RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra",
"ForwardState", "ExecutionId",
"ForwardState", "ExecutionId", "ParentExecutionId",
};
Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n));
@@ -245,4 +245,136 @@ public class SqliteAuditWriterSchemaTests
Assert.True(ColumnExists(seedConnection, "ExecutionId"));
}
}
// ----- ParentExecutionId schema-upgrade regression (persistent auditlog.db) ----- //
/// <summary>
/// The pre-ParentExecutionId-branch <c>AuditLog</c> schema — the 21-column
/// CREATE TABLE that HAS <c>ExecutionId</c> but is WITHOUT
/// <c>ParentExecutionId</c>. A deployment that ran the ExecutionId branch
/// already has an on-disk <c>auditlog.db</c> in exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreParentExecutionIdSchema = """
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Channel TEXT NOT NULL,
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
ExecutionId TEXT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
/// <summary>
/// Seeds a shared-cache in-memory database with the pre-ParentExecutionId
/// 21-column schema and returns the open connection. The connection MUST
/// stay open for the lifetime of the test — a shared-cache in-memory
/// database is dropped once its last connection closes.
/// </summary>
private static SqliteConnection SeedPreParentExecutionIdSchemaDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreParentExecutionIdSchema;
cmd.ExecuteNonQuery();
return connection;
}
[Fact]
public async Task Opening_Over_PreExisting_PreParentExecutionId_Db_Adds_ParentExecutionId_Column_And_WriteAsync_RoundTrips()
{
var dataSource = $"file:{nameof(Opening_Over_PreExisting_PreParentExecutionId_Db_Adds_ParentExecutionId_Column_And_WriteAsync_RoundTrips)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A deployment that ran the ExecutionId branch: auditlog.db already
// exists with the 21-column schema and NO ParentExecutionId column.
using var seedConnection = SeedPreParentExecutionIdSchemaDatabase(dataSource);
Assert.True(ColumnExists(seedConnection, "ExecutionId"));
Assert.False(ColumnExists(seedConnection, "ParentExecutionId"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing ParentExecutionId column in —
// the CREATE TABLE IF NOT EXISTS alone is a no-op against the existing
// table.
var executionId = Guid.NewGuid();
var parentExecutionId = Guid.NewGuid();
await using (var writer = CreateWriterOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "ParentExecutionId"),
"SqliteAuditWriter must ALTER the ParentExecutionId column into a pre-existing AuditLog table.");
// A WriteAsync binding $ParentExecutionId must now succeed and
// round-trip; without the ALTER it would fail with "no such column:
// ParentExecutionId" and — because audit writes are best-effort —
// silently drop the row.
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
PayloadTruncated = false,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.ExecutionId);
Assert.Equal(parentExecutionId, row.ParentExecutionId);
}
// Idempotency: a second writer over the now-upgraded DB must not error
// (the probe sees ParentExecutionId already present and skips the ALTER).
await using (var writerAgain = CreateWriterOver(dataSource))
{
Assert.True(ColumnExists(seedConnection, "ParentExecutionId"));
}
}
[Fact]
public async Task WriteAsync_NullParentExecutionId_RoundTripsAsNull()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_NullParentExecutionId_RoundTripsAsNull));
await using (writer)
{
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend,
Status = AuditStatus.Submitted,
PayloadTruncated = false,
// ParentExecutionId left null
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Null(row.ParentExecutionId);
}
}
}

View File

@@ -33,7 +33,8 @@ public class CachedCallLifecycleBridgeTests
string? lastError = null,
int? httpStatus = null,
Guid? executionId = null,
string? sourceScript = null) =>
string? sourceScript = null,
Guid? parentExecutionId = null) =>
new(
TrackedOperationId: _id,
Channel: channel,
@@ -48,7 +49,8 @@ public class CachedCallLifecycleBridgeTests
DurationMs: 42,
SourceInstanceId: "Plant.Pump42",
ExecutionId: executionId,
SourceScript: sourceScript);
SourceScript: sourceScript,
ParentExecutionId: parentExecutionId);
[Fact]
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
@@ -259,4 +261,70 @@ public class CachedCallLifecycleBridgeTests
Assert.Null(captured!.Audit.ExecutionId);
Assert.Null(captured.Audit.SourceScript);
}
// ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ──
[Fact]
public async Task RetryLoopAttemptedRow_CarriesParentExecutionId_FromContext()
{
// Task 6: the ParentExecutionId threaded through the S&F buffer (the
// inbound-API run that spawned the originating script) arrives on the
// CachedCallAttemptContext; the bridge must stamp it onto the
// per-attempt ApiCallCached row beside ExecutionId.
var parentExecutionId = Guid.NewGuid();
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.TransientFailure,
parentExecutionId: parentExecutionId));
var packet = Assert.Single(captured);
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
Assert.Equal(parentExecutionId, packet.Audit.ParentExecutionId);
}
[Fact]
public async Task RetryLoopCachedResolveRow_CarriesParentExecutionId_FromContext()
{
// The terminal CachedResolve row must also carry the threaded
// ParentExecutionId so the whole retry-loop lifecycle correlates back
// to the spawning inbound-API execution.
var parentExecutionId = Guid.NewGuid();
var captured = new List<CachedCallTelemetry>();
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(
CachedCallAttemptOutcome.Delivered,
channel: "DbOutbound",
parentExecutionId: parentExecutionId));
Assert.Equal(2, captured.Count);
var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve);
Assert.Equal(parentExecutionId, resolve.Audit.ParentExecutionId);
var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached);
Assert.Equal(parentExecutionId, attempted.Audit.ParentExecutionId);
}
[Fact]
public async Task RetryLoopRow_NullParentExecutionId_RemainsNull()
{
// Back-compat / non-routed run: the originating script was not spawned
// by an inbound-API request, so ParentExecutionId is null; the bridge
// must leave the audit row's ParentExecutionId null rather than throwing.
CachedCallTelemetry? captured = null;
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured = t), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var sut = CreateSut();
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
Assert.NotNull(captured);
Assert.Null(captured!.Audit.ParentExecutionId);
}
}

View File

@@ -66,6 +66,7 @@ public class AuditQueryCommandTests
Actor = "multi-role",
CorrelationId = "abc-123",
ExecutionId = "def-456",
ParentExecutionId = "ghi-789",
ErrorsOnly = false,
PageSize = 250,
};
@@ -83,6 +84,7 @@ public class AuditQueryCommandTests
Assert.Equal("multi-role", parsed["actor"]);
Assert.Equal("abc-123", parsed["correlationId"]);
Assert.Equal("def-456", parsed["executionId"]);
Assert.Equal("ghi-789", parsed["parentExecutionId"]);
Assert.Equal("250", parsed["pageSize"]);
Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]);
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
@@ -159,6 +161,7 @@ public class AuditQueryCommandTests
Assert.Null(parsed["fromUtc"]);
Assert.Null(parsed["correlationId"]);
Assert.Null(parsed["executionId"]);
Assert.Null(parsed["parentExecutionId"]);
Assert.Equal("100", parsed["pageSize"]);
}
@@ -173,6 +176,17 @@ public class AuditQueryCommandTests
Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]);
}
[Fact]
public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter()
{
// --parent-execution-id is a single-value Guid filter — mirrors --execution-id.
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs { ParentExecutionId = "22222222-2222-2222-2222-222222222222" };
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("22222222-2222-2222-2222-222222222222", parsed["parentExecutionId"]);
}
// ---- HTTP execution / paging ------------------------------------------
private sealed class RecordingHandler : HttpMessageHandler
@@ -308,6 +322,18 @@ public class AuditQueryCommandTests
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_ParentExecutionIdOption_IsAccepted()
{
// --parent-execution-id is a single-value option — mirrors --execution-id.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "query", "--parent-execution-id", "22222222-2222-2222-2222-222222222222",
});
Assert.Empty(parse.Errors);
}
// ---- Enum-name validation (fast-fail) ----------------------------------
[Fact]

View File

@@ -66,6 +66,7 @@ internal static class AuditDataSeeder
string? actor = null,
Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
int? httpStatus = null,
int? durationMs = null,
string? errorMessage = null,
@@ -77,12 +78,12 @@ internal static class AuditDataSeeder
const string sql = @"
INSERT INTO [AuditLog]
([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId],
[ExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target],
[ExecutionId], [ParentExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target],
[Status], [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
[ResponseSummary], [PayloadTruncated], [Extra], [ForwardState])
VALUES
(@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId,
@executionId, @sourceSiteId, NULL, NULL, @actor, @target,
@executionId, @parentExecutionId, @sourceSiteId, NULL, NULL, @actor, @target,
@status, @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
@responseSummary, 0, @extra, NULL);";
@@ -96,6 +97,7 @@ VALUES
cmd.Parameters.AddWithValue("@kind", kind);
cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@executionId", (object?)executionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@parentExecutionId", (object?)parentExecutionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@sourceSiteId", (object?)sourceSiteId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value);
cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value);

View File

@@ -27,6 +27,10 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
/// <item><c>DrillInFromExecutionId_LandsOnAuditLogWithFilterContext</c> — the
/// <c>?executionId=</c> drill-in (the drawer's "View this execution" action)
/// auto-loads the grid filtered by ExecutionId.</item>
/// <item><c>DrillInFromParentExecution_FiltersGridToSpawnerExecution</c> — the
/// drawer's "View parent execution" action on a spawned (child) row drills in
/// to <c>?executionId={ParentExecutionId}</c>, auto-loading the spawner's
/// rows.</item>
/// <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
/// the report page wires drill-in links when notifications are present.</item>
/// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on
@@ -350,6 +354,163 @@ public class AuditLogPageTests
}
}
[Fact]
public async Task DrillInFromParentExecution_FiltersGridToSpawnerExecution()
{
// The drawer's "View parent execution" action navigates a routed (child)
// row to /audit/log?executionId={ParentExecutionId}. We seed a spawner row
// (its ExecutionId == the parent id) and a child row (ParentExecutionId
// pointing at the spawner), open the child's drawer, click the action, and
// assert the grid auto-loads filtered to the spawner's own rows.
if (!await AuditDataSeeder.IsAvailableAsync())
{
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
}
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/parent-exec-drill-in/{runId}/";
var parentExecutionId = Guid.NewGuid();
var spawnerEventId = Guid.NewGuid();
var childEventId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
// The spawner execution's own row — carries ExecutionId == parentExecutionId.
await AuditDataSeeder.InsertAuditEventAsync(
eventId: spawnerEventId,
occurredAtUtc: now,
channel: "ApiInbound",
kind: "InboundRequest",
status: "Delivered",
target: targetPrefix + "spawner",
executionId: parentExecutionId,
httpStatus: 200,
durationMs: 7);
// The child (spawned) row — ParentExecutionId points at the spawner.
await AuditDataSeeder.InsertAuditEventAsync(
eventId: childEventId,
occurredAtUtc: now,
channel: "ApiOutbound",
kind: "ApiCall",
status: "Delivered",
target: targetPrefix + "child",
executionId: Guid.NewGuid(),
parentExecutionId: parentExecutionId,
httpStatus: 200,
durationMs: 13);
var page = await _fixture.NewAuthenticatedPageAsync();
// Land on the child row via its ParentExecutionId filter, open the drawer.
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?parentExecutionId={parentExecutionId}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var childRow = page.Locator($"[data-test='grid-row-{childEventId}']");
await Assertions.Expect(childRow).ToBeVisibleAsync();
await childRow.ClickAsync();
// The "View parent execution" action drills in to the spawner.
var viewParent = page.Locator("[data-test='view-parent-execution']");
await Assertions.Expect(viewParent).ToBeVisibleAsync();
await viewParent.ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// The drill-in lands on ?executionId={parentExecutionId} and auto-loads
// the spawner's own row.
Assert.Contains($"executionId={parentExecutionId}", page.Url);
var spawnerRow = page.Locator($"[data-test='grid-row-{spawnerEventId}']");
await Assertions.Expect(spawnerRow).ToBeVisibleAsync();
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task DrillInToExecutionChain_RendersTree_AndNodeClickFiltersGrid()
{
// Audit Log ParentExecutionId feature, Task 10: the drawer's "View
// execution chain" action opens /audit/execution-tree?executionId={id}.
// We seed a spawner row + a child row, open the child's drawer, click
// "View execution chain", assert the tree renders BOTH executions, then
// click the spawner node and assert the Audit Log grid filters to it.
if (!await AuditDataSeeder.IsAvailableAsync())
{
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
}
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/exec-chain-tree/{runId}/";
var parentExecutionId = Guid.NewGuid();
var childExecutionId = Guid.NewGuid();
var spawnerEventId = Guid.NewGuid();
var childEventId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
// Spawner execution's own row.
await AuditDataSeeder.InsertAuditEventAsync(
eventId: spawnerEventId,
occurredAtUtc: now,
channel: "ApiInbound",
kind: "InboundRequest",
status: "Delivered",
target: targetPrefix + "spawner",
executionId: parentExecutionId,
httpStatus: 200,
durationMs: 7);
// Child (spawned) row — links to the spawner via ParentExecutionId.
await AuditDataSeeder.InsertAuditEventAsync(
eventId: childEventId,
occurredAtUtc: now,
channel: "ApiOutbound",
kind: "ApiCall",
status: "Delivered",
target: targetPrefix + "child",
executionId: childExecutionId,
parentExecutionId: parentExecutionId,
httpStatus: 200,
durationMs: 13);
var page = await _fixture.NewAuthenticatedPageAsync();
// Open the child row's drawer via its ExecutionId filter.
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={childExecutionId}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
var childRow = page.Locator($"[data-test='grid-row-{childEventId}']");
await Assertions.Expect(childRow).ToBeVisibleAsync();
await childRow.ClickAsync();
// "View execution chain" opens the tree view.
var viewChain = page.Locator("[data-test='view-execution-chain']");
await Assertions.Expect(viewChain).ToBeVisibleAsync();
await viewChain.ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// The tree page rendered both executions as nodes.
Assert.Contains($"executionId={childExecutionId}", page.Url);
await Assertions.Expect(page.Locator($"[data-test='tree-node-{parentExecutionId}']")).ToBeVisibleAsync();
await Assertions.Expect(page.Locator($"[data-test='tree-node-{childExecutionId}']")).ToBeVisibleAsync();
// Clicking the spawner node's link filters the Audit Log to its rows.
await page.Locator($"[data-test='tree-node-link-{parentExecutionId}']").ClickAsync();
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
Assert.Contains($"executionId={parentExecutionId}", page.Url);
await Assertions.Expect(page.Locator($"[data-test='grid-row-{spawnerEventId}']")).ToBeVisibleAsync();
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
{

View File

@@ -139,6 +139,7 @@ public class AuditExportEndpointsTests
{
var correlationId = Guid.NewGuid().ToString();
var executionId = Guid.NewGuid().ToString();
var parentExecutionId = Guid.NewGuid().ToString();
var url =
"/api/centralui/audit/export?" +
"channel=ApiOutbound&" +
@@ -149,6 +150,7 @@ public class AuditExportEndpointsTests
"actor=apikey-1&" +
$"correlationId={correlationId}&" +
$"executionId={executionId}&" +
$"parentExecutionId={parentExecutionId}&" +
"from=2026-05-20T00:00:00Z&" +
"to=2026-05-20T23:59:59Z";
@@ -170,6 +172,7 @@ public class AuditExportEndpointsTests
f.Actor == "apikey-1" &&
f.CorrelationId == Guid.Parse(correlationId) &&
f.ExecutionId == Guid.Parse(executionId) &&
f.ParentExecutionId == Guid.Parse(parentExecutionId) &&
f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) &&
f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)),
Arg.Any<AuditLogPaging>(),
@@ -199,6 +202,7 @@ public class AuditExportEndpointsTests
f.Actor == null &&
f.CorrelationId == null &&
f.ExecutionId == null &&
f.ParentExecutionId == null &&
f.FromUtc == null &&
f.ToUtc == null),
Arg.Any<AuditLogPaging>(),
@@ -245,6 +249,25 @@ public class AuditExportEndpointsTests
}
}
[Fact]
public async Task ExportEndpoint_UnparseableParentExecutionId_SilentlyDropped()
{
// Lax-parse contract: an unparseable parentExecutionId is dropped (no 400)
// — mirrors the executionId / correlationId parse.
var (client, repo, host) = await BuildHostAsync();
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export?parentExecutionId=not-a-guid");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.ParentExecutionId == null),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
/// <summary>
/// Test-only authentication handler that signs every request in as an Admin.
/// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy

View File

@@ -41,6 +41,7 @@ public class AuditDrilldownDrawerTests : BunitContext
string? extra = null,
Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
string? errorMessage = null,
string? errorDetail = null,
string? target = "demo-target")
@@ -53,6 +54,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Kind = kind,
CorrelationId = correlationId,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
SourceSiteId = "plant-a",
SourceInstanceId = "boiler-3",
SourceScript = "OnAlarm.csx",
@@ -258,6 +260,91 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains($"/audit/log?executionId={exec}", nav.Uri);
}
[Fact]
public void Drawer_NullParentExecutionId_HidesViewParentExecutionButton()
{
var ev = MakeEvent(parentExecutionId: null);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"view-parent-execution\"", cut.Markup);
}
[Fact]
public void Drawer_NonNullParentExecutionId_ShowsViewParentExecutionButton()
{
var ev = MakeEvent(parentExecutionId: Guid.Parse("bbbbbbbb-1111-2222-3333-444444444444"));
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"view-parent-execution\"", cut.Markup);
}
[Fact]
public void ViewParentExecution_Navigates_WithExecutionIdQueryString()
{
// A routed (child) row drills in to its spawner: the "View parent
// execution" action navigates to /audit/log?executionId={ParentExecutionId}
// so the user sees the spawner execution's rows.
var parent = Guid.Parse("eeeeeeee-dddd-cccc-bbbb-aaaaaaaaaaaa");
var ev = MakeEvent(parentExecutionId: parent);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
cut.Find("[data-test=\"view-parent-execution\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/log?executionId={parent}", nav.Uri);
}
[Fact]
public void Drawer_NullExecutionId_HidesViewExecutionChainButton()
{
var ev = MakeEvent(executionId: null);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"view-execution-chain\"", cut.Markup);
}
[Fact]
public void Drawer_NonNullExecutionId_ShowsViewExecutionChainButton()
{
var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-9999-8888-7777-666666666666"));
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup);
}
[Fact]
public void ViewExecutionChain_Navigates_ToExecutionTreePage()
{
// The "View execution chain" action opens the tree view rooted at the
// chain containing this row's ExecutionId.
var exec = Guid.Parse("12345678-aaaa-bbbb-cccc-1234567890ab");
var ev = MakeEvent(executionId: exec);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
cut.Find("[data-test=\"view-execution-chain\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri);
}
[Fact]
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
{

View File

@@ -62,6 +62,7 @@ public class AuditFilterBarTests : BunitContext
"data-test=\"filter-target\"",
"data-test=\"filter-actor\"",
"data-test=\"filter-execution-id\"",
"data-test=\"filter-parent-execution-id\"",
"data-test=\"filter-errors-only\"",
};
foreach (var marker in markers)
@@ -215,6 +216,42 @@ public class AuditFilterBarTests : BunitContext
Assert.Null(captured!.ExecutionId);
}
[Fact]
public void Apply_WithPastedParentExecutionId_MapsThroughToFilter()
{
// The operator pastes a Guid into the Parent execution ID box; Apply must
// map it straight onto AuditLogQueryFilter.ParentExecutionId.
var parentExecutionId = Guid.Parse("11112222-3333-4444-5555-666677778888");
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-parent-execution-id\"] input").Change(parentExecutionId.ToString());
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(parentExecutionId, captured!.ParentExecutionId);
}
[Fact]
public void Apply_WithBlankOrUnparseableParentExecutionId_LeavesFilterParentExecutionIdNull()
{
// Lax parsing: a blank box yields no constraint; garbage text likewise.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Blank — never typed into.
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Null(captured!.ParentExecutionId);
// Unparseable paste — still dropped, no error.
cut.Find("[data-test=\"filter-parent-execution-id\"] input").Change("not-a-guid");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Null(captured!.ParentExecutionId);
}
[Fact]
public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
{

View File

@@ -22,7 +22,7 @@ public class AuditResultsGridTests : BunitContext
private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null)
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null, Guid? parentExecutionId = null)
=> new()
{
EventId = Guid.NewGuid(),
@@ -34,6 +34,7 @@ public class AuditResultsGridTests : BunitContext
Target = "demo-target",
Actor = "tester",
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
DurationMs = 42,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
@@ -165,6 +166,49 @@ public class AuditResultsGridTests : BunitContext
Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]"));
}
[Fact]
public void Render_IncludesParentExecutionIdColumn()
{
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// The ParentExecutionId column header is present alongside the spec columns.
Assert.Contains("data-test=\"col-header-ParentExecutionId\"", cut.Markup);
}
[Fact]
public void ParentExecutionId_NonNullRow_RendersShortMonospaceValue()
{
var parentExecutionId = Guid.Parse("fedcba98-2222-3333-4444-555555555555");
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: parentExecutionId);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
var cell = cut.Find($"[data-test=\"parent-execution-id-{row.EventId}\"]");
// Short form: first 8 hex digits of the "N" form — mirrors ExecutionId.
Assert.Equal("fedcba98", cell.TextContent.Trim());
// Monospace presentation; full value retained in the title attribute.
Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty);
Assert.Equal(parentExecutionId.ToString(), cell.GetAttribute("title"));
}
[Fact]
public void ParentExecutionId_NullRow_RendersBlankPlaceholder_NoParentExecutionIdCell()
{
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: null);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// A null ParentExecutionId renders the em-dash placeholder, not a value cell.
Assert.Empty(cut.FindAll($"[data-test=\"parent-execution-id-{row.EventId}\"]"));
}
[Fact]
public void Status_FailedRow_HasErrorBadgeClass()
{
@@ -193,7 +237,8 @@ public class AuditResultsGridTests : BunitContext
private static readonly string[] DefaultOrder =
{
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
"Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage",
"Target", "Actor", "ExecutionId", "ParentExecutionId",
"DurationMs", "HttpStatus", "ErrorMessage",
};
private static int HeaderIndex(string markup, string key)

View File

@@ -0,0 +1,240 @@
using Bunit;
using ScadaLink.CentralUI.Components.Audit;
using ScadaLink.Commons.Types.Audit;
namespace ScadaLink.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="ExecutionTree"/> (Audit Log ParentExecutionId
/// feature, Task 10). The component takes the FLAT
/// <see cref="ExecutionTreeNode"/> list the repository returns, assembles it
/// into a tree by joining <see cref="ExecutionTreeNode.ParentExecutionId"/> to a
/// parent node's <see cref="ExecutionTreeNode.ExecutionId"/>, and renders it
/// recursively. Tests pin: single-node tree, multi-level assembly, stub-node
/// presentation, the arrived-from highlight, node-click navigation, and
/// cycle-safety (a corrupt flat list must not infinite-loop).
/// </summary>
public class ExecutionTreeTests : BunitContext
{
private static ExecutionTreeNode Node(
Guid executionId,
Guid? parentExecutionId,
int rowCount = 2,
string? site = "plant-a",
string? instance = "boiler-3")
=> new(
executionId,
parentExecutionId,
rowCount,
rowCount == 0 ? Array.Empty<string>() : new[] { "ApiOutbound" },
rowCount == 0 ? Array.Empty<string>() : new[] { "Delivered" },
rowCount == 0 ? null : site,
rowCount == 0 ? null : instance,
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc));
[Fact]
public void SingleNode_RendersOneTreeNode()
{
var id = Guid.Parse("11111111-1111-1111-1111-111111111111");
var nodes = new List<ExecutionTreeNode> { Node(id, null) };
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, id));
Assert.Contains($"data-test=\"tree-node-{id}\"", cut.Markup);
}
[Fact]
public void MultiLevel_AssemblesTree_FromFlatList()
{
// root → child → grandchild — a deliberately shuffled flat list so the
// component must reconstruct parent/child links rather than rely on
// input ordering.
var root = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000");
var child = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000");
var grandchild = Guid.Parse("cccccccc-0000-0000-0000-000000000000");
var nodes = new List<ExecutionTreeNode>
{
Node(grandchild, child),
Node(root, null),
Node(child, root),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, child));
// All three executions render as nodes.
Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
// The root must appear before the child, and the child before the
// grandchild — recursive depth-first rendering preserves ancestry.
var rootIdx = cut.Markup.IndexOf($"tree-node-{root}", StringComparison.Ordinal);
var childIdx = cut.Markup.IndexOf($"tree-node-{child}", StringComparison.Ordinal);
var grandIdx = cut.Markup.IndexOf($"tree-node-{grandchild}", StringComparison.Ordinal);
Assert.True(rootIdx < childIdx, "root must render before child");
Assert.True(childIdx < grandIdx, "child must render before grandchild");
}
[Fact]
public void StubNode_RendersStubMarker()
{
// A stub parent (RowCount = 0) referenced by a real child must still
// render, visibly marked as "no audited actions".
var stubParent = Guid.Parse("dddddddd-0000-0000-0000-000000000000");
var child = Guid.Parse("eeeeeeee-0000-0000-0000-000000000000");
var nodes = new List<ExecutionTreeNode>
{
Node(stubParent, null, rowCount: 0),
Node(child, stubParent),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, child));
Assert.Contains($"data-test=\"tree-node-{stubParent}\"", cut.Markup);
Assert.Contains($"data-test=\"stub-node-{stubParent}\"", cut.Markup);
Assert.Contains("no audited actions", cut.Markup);
}
[Fact]
public void ArrivedFromNode_IsVisuallyHighlighted()
{
var root = Guid.Parse("aaaaaaaa-1111-1111-1111-111111111111");
var child = Guid.Parse("bbbbbbbb-1111-1111-1111-111111111111");
var nodes = new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, child));
// The arrived-from node carries the highlight marker; a non-arrived
// sibling does not.
var arrived = cut.Find($"[data-test=\"tree-node-{child}\"]");
Assert.Contains("execution-tree-node--current", arrived.GetAttribute("class"));
var other = cut.Find($"[data-test=\"tree-node-{root}\"]");
Assert.DoesNotContain("execution-tree-node--current", other.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void NodeLink_PointsTo_AuditLogFilteredByThatExecution()
{
// Each node's id is a real <a href> deep link — clicking it lands on
// the Audit Log filtered to that execution's rows. A genuine anchor
// (rather than an @onclick navigate) keeps the link middle-click /
// open-in-new-tab friendly, matching the rest of the Audit UI.
var root = Guid.Parse("aaaaaaaa-2222-2222-2222-222222222222");
var child = Guid.Parse("bbbbbbbb-2222-2222-2222-222222222222");
var nodes = new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, root));
var childLink = cut.Find($"[data-test=\"tree-node-link-{child}\"]");
Assert.Equal($"/audit/log?executionId={child}", childLink.GetAttribute("href"));
var rootLink = cut.Find($"[data-test=\"tree-node-link-{root}\"]");
Assert.Equal($"/audit/log?executionId={root}", rootLink.GetAttribute("href"));
}
[Fact]
public void EmptyNodeList_RendersNothingWithoutThrowing()
{
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, (IReadOnlyList<ExecutionTreeNode>)Array.Empty<ExecutionTreeNode>())
.Add(c => c.ArrivedFromExecutionId, Guid.NewGuid()));
Assert.DoesNotContain("data-test=\"tree-node-", cut.Markup);
}
[Fact]
public void CyclicFlatList_TerminatesWithoutInfiniteLoop()
{
// Defensive: a corrupt flat list where A→B and B→A must not hang the
// renderer. Each execution is rendered at most once.
var a = Guid.Parse("a0000000-0000-0000-0000-000000000000");
var b = Guid.Parse("b0000000-0000-0000-0000-000000000000");
var nodes = new List<ExecutionTreeNode>
{
Node(a, b),
Node(b, a),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, a));
// Both render exactly once — no runaway recursion.
Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{a}\""));
Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{b}\""));
}
[Fact]
public void ToggleExpand_CollapsesAndReExpandsChildSubtree()
{
// root → child → grandchild. Clicking the root's toggle collapses its
// subtree (the child node disappears); clicking it again re-expands.
var root = Guid.Parse("aaaaaaaa-3333-3333-3333-333333333333");
var child = Guid.Parse("bbbbbbbb-3333-3333-3333-333333333333");
var grandchild = Guid.Parse("cccccccc-3333-3333-3333-333333333333");
var nodes = new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
Node(grandchild, child),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, root));
// All nodes start expanded — the whole chain is visible on arrival.
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
var toggle = cut.Find($"[data-test=\"tree-toggle-{root}\"]");
Assert.Equal("true", toggle.GetAttribute("aria-expanded"));
// Collapse: the child (and its descendants) must disappear.
toggle.Click();
Assert.DoesNotContain($"data-test=\"tree-node-{child}\"", cut.Markup);
Assert.DoesNotContain($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
Assert.Equal(
"false",
cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded"));
// Re-expand: the child subtree reappears.
cut.Find($"[data-test=\"tree-toggle-{root}\"]").Click();
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
Assert.Equal(
"true",
cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded"));
}
private static int CountOccurrences(string haystack, string needle)
{
int count = 0, idx = 0;
while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0)
{
count++;
idx += needle.Length;
}
return count;
}
}

View File

@@ -89,6 +89,20 @@ public class AuditLogPageExportUrlTests
Assert.Equal(exec.ToString(), query["executionId"]);
}
[Fact]
public void BuildExportUrl_ParentExecutionIdSet_EmitsParentExecutionIdParam()
{
var parent = Guid.Parse("34343434-5656-7878-9090-121212121212");
var filter = new AuditLogQueryFilter(ParentExecutionId: parent);
var url = AuditLogPage.BuildExportUrl(filter);
Assert.StartsWith("/api/centralui/audit/export?", url);
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
Assert.Single(query);
Assert.Equal(parent.ToString(), query["parentExecutionId"]);
}
[Fact]
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
{

View File

@@ -214,6 +214,45 @@ public class AuditLogPageScaffoldTests : BunitContext
Arg.Any<CancellationToken>());
}
[Fact]
public void NavigateWithParentExecutionIdParam_AppliesFilter_AndAutoLoads()
{
// The "View parent execution" drill-in (and operator-crafted URLs) land on
// /audit/log?parentExecutionId={id}. The page parses the Guid, builds an
// AuditLogQueryFilter with ParentExecutionId set, and auto-loads the grid.
var parentExecutionId = Guid.Parse("aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb");
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Admin");
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.ParentExecutionId == parentExecutionId),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void NavigateWithUnparseableParentExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
{
_queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderAuditLogPageWithQuery("parentExecutionId=not-a-guid", "Admin");
// An unparseable parentExecutionId leaves ParentExecutionId null. With no
// other filter params present the page renders but does NOT call the query
// service.
cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
_queryService.DidNotReceive().QueryAsync(
Arg.Any<AuditLogQueryFilter>(),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
}
[Fact]
public void NavigateWithTargetParam_AppliesTargetFilter()
{

View File

@@ -0,0 +1,124 @@
using System.Security.Claims;
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Security;
using ExecutionTreePage = ScadaLink.CentralUI.Components.Pages.Audit.ExecutionTreePage;
namespace ScadaLink.CentralUI.Tests.Pages;
/// <summary>
/// bUnit tests for <see cref="ExecutionTreePage"/> (Audit Log ParentExecutionId
/// feature, Task 10). The page is reached via the "View execution chain"
/// drill-in at <c>/audit/execution-tree?executionId={guid}</c>. It parses the
/// query-string id, calls <see cref="IAuditLogQueryService.GetExecutionTreeAsync"/>,
/// and hands the flat node list to the <c>ExecutionTree</c> component.
/// </summary>
public class ExecutionTreePageTests : BunitContext
{
private IAuditLogQueryService _queryService = Substitute.For<IAuditLogQueryService>();
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
{
var claims = new List<Claim> { new("Username", "tester") };
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
private IRenderedComponent<ExecutionTreePage> RenderPage(string? query, params string[] roles)
{
var user = BuildPrincipal(roles);
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
Services.AddAuthorizationCore();
AuthorizationPolicies.AddScadaLinkAuthorization(Services);
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
Services.AddSingleton(_queryService);
if (!string.IsNullOrEmpty(query))
{
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
nav.NavigateTo($"/audit/execution-tree?{query}");
}
var host = Render<CascadingAuthenticationState>(parameters => parameters
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<ExecutionTreePage>(0);
builder.CloseComponent();
})));
return host.FindComponent<ExecutionTreePage>();
}
private static ExecutionTreeNode Node(Guid id, Guid? parent, int rowCount = 2)
=> new(
id, parent, rowCount,
rowCount == 0 ? Array.Empty<string>() : new[] { "ApiOutbound" },
rowCount == 0 ? Array.Empty<string>() : new[] { "Delivered" },
rowCount == 0 ? null : "plant-a",
rowCount == 0 ? null : "boiler-3",
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc));
[Fact]
public void NavigateWithExecutionId_CallsService_AndRendersTree()
{
var root = Guid.Parse("11111111-1111-1111-1111-111111111111");
var child = Guid.Parse("22222222-2222-2222-2222-222222222222");
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.GetExecutionTreeAsync(child, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
}));
var cut = RenderPage($"executionId={child}", "Admin");
cut.WaitForAssertion(() =>
{
_queryService.Received().GetExecutionTreeAsync(child, Arg.Any<CancellationToken>());
Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
});
}
[Fact]
public void NavigateWithoutExecutionId_RendersGuidancePrompt_NoServiceCall()
{
_queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderPage(query: null, "Admin");
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
}
[Fact]
public void NavigateWithUnparseableExecutionId_RendersGuidancePrompt_NoServiceCall()
{
_queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderPage("executionId=not-a-guid", "Admin");
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
}
[Fact]
public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute()
{
var attributes = typeof(ExecutionTreePage)
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
.Cast<AuthorizeAttribute>()
.ToList();
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
}
}

View File

@@ -222,6 +222,66 @@ public class AuditLogQueryServiceTests
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
}
// ─────────────────────────────────────────────────────────────────────────
// Audit Log ParentExecutionId feature (Task 10): GetExecutionTreeAsync —
// a thin pass-through over IAuditLogRepository.GetExecutionTreeAsync, mirroring
// QueryAsync's scope-per-call contract on the production path.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task GetExecutionTreeAsync_ForwardsExecutionId_ToRepository()
{
var repo = Substitute.For<IAuditLogRepository>();
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
var expected = new List<ExecutionTreeNode>
{
new(executionId, null, 3,
new[] { "ApiOutbound" }, new[] { "Delivered" },
"plant-a", "boiler-3",
new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)),
};
repo.GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(expected));
var sut = new AuditLogQueryService(repo, EmptyAggregator());
var result = await sut.GetExecutionTreeAsync(executionId);
Assert.Same(expected, result);
await repo.Received(1).GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetExecutionTreeAsync_OpensFreshScopePerCall_OnProductionCtor()
{
// The production ctor must resolve a fresh repository per call — same
// scope-per-query contract QueryAsync upholds, so the page's auto-load
// never shares the circuit-scoped DbContext.
var resolvedRepos = new List<IAuditLogRepository>();
var services = new ServiceCollection();
services.AddScoped<IAuditLogRepository>(_ =>
{
var repo = Substitute.For<IAuditLogRepository>();
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>()));
resolvedRepos.Add(repo);
return repo;
});
await using var provider = services.BuildServiceProvider();
var sut = new AuditLogQueryService(
provider.GetRequiredService<IServiceScopeFactory>(),
EmptyAggregator());
await sut.GetExecutionTreeAsync(Guid.NewGuid());
await sut.GetExecutionTreeAsync(Guid.NewGuid());
Assert.Equal(2, resolvedRepos.Count);
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
}
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
{
SiteAuditBacklogSnapshot? backlog = pending.HasValue

View File

@@ -36,6 +36,21 @@ public class NotificationEntityTests
Assert.Equal(executionId, n.OriginExecutionId);
}
[Fact]
public void OriginParentExecutionId_DefaultsToNull_AndIsSettable()
{
// Audit Log ParentExecutionId: OriginParentExecutionId carries the
// routed run's parent ExecutionId from the site so the dispatcher can
// echo it onto NotifyDeliver rows. Null for non-routed runs, or for
// notifications submitted before the column existed.
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
Assert.Null(n.OriginParentExecutionId);
var parentExecutionId = Guid.NewGuid();
n.OriginParentExecutionId = parentExecutionId;
Assert.Equal(parentExecutionId, n.OriginParentExecutionId);
}
[Fact]
public void Constructor_NullArguments_Throw()
{

View File

@@ -81,6 +81,51 @@ public class NotificationMessagesTests
Assert.Equal(executionId, roundTripped!.OriginExecutionId);
}
[Fact]
public void NotificationSubmit_OriginParentExecutionId_DefaultsToNull()
{
// Audit Log ParentExecutionId: OriginParentExecutionId is an additive
// trailing member — a submit built without it (old call sites / old
// serialized payloads, or non-routed runs) leaves the id null.
var msg = new NotificationSubmit(
"notif-6", "Operators", "Subject", "Body",
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow);
Assert.Null(msg.OriginParentExecutionId);
}
[Fact]
public void NotificationSubmit_OriginParentExecutionId_RoundTripsWhenSupplied()
{
var executionId = Guid.NewGuid();
var parentExecutionId = Guid.NewGuid();
var msg = new NotificationSubmit(
"notif-7", "Operators", "Subject", "Body",
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow,
executionId, parentExecutionId);
Assert.Equal(parentExecutionId, msg.OriginParentExecutionId);
}
[Fact]
public void NotificationSubmit_OriginParentExecutionId_SurvivesJsonRoundTrip()
{
// The buffered S&F payload IS a serialized NotificationSubmit; the
// forwarder deserializes it, so OriginParentExecutionId must survive JSON.
var executionId = Guid.NewGuid();
var parentExecutionId = Guid.NewGuid();
var msg = new NotificationSubmit(
"notif-8", "Operators", "Subject", "Body",
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow,
executionId, parentExecutionId);
var json = System.Text.Json.JsonSerializer.Serialize(msg);
var roundTripped = System.Text.Json.JsonSerializer.Deserialize<NotificationSubmit>(json);
Assert.NotNull(roundTripped);
Assert.Equal(parentExecutionId, roundTripped!.OriginParentExecutionId);
}
[Fact]
public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
{

View File

@@ -20,6 +20,7 @@ public class AuditEventDtoMapperTests
var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc);
var correlationId = Guid.NewGuid();
var executionId = Guid.NewGuid();
var parentExecutionId = Guid.NewGuid();
var eventId = Guid.NewGuid();
var original = new AuditEvent
@@ -31,6 +32,7 @@ public class AuditEventDtoMapperTests
Kind = AuditKind.ApiCallCached,
CorrelationId = correlationId,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
SourceSiteId = "site-1",
SourceInstanceId = "Pump01",
SourceScript = "OnDemand",
@@ -57,6 +59,7 @@ public class AuditEventDtoMapperTests
Assert.Equal(original.Kind, roundTripped.Kind);
Assert.Equal(original.CorrelationId, roundTripped.CorrelationId);
Assert.Equal(original.ExecutionId, roundTripped.ExecutionId);
Assert.Equal(original.ParentExecutionId, roundTripped.ParentExecutionId);
Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId);
Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId);
Assert.Equal(original.SourceScript, roundTripped.SourceScript);
@@ -94,6 +97,7 @@ public class AuditEventDtoMapperTests
Assert.Equal(string.Empty, dto.CorrelationId);
Assert.Equal(string.Empty, dto.ExecutionId);
Assert.Equal(string.Empty, dto.ParentExecutionId);
Assert.Equal(string.Empty, dto.SourceSiteId);
Assert.Equal(string.Empty, dto.SourceInstanceId);
Assert.Equal(string.Empty, dto.SourceScript);
@@ -118,6 +122,7 @@ public class AuditEventDtoMapperTests
Status = nameof(AuditStatus.Submitted),
CorrelationId = string.Empty,
ExecutionId = string.Empty,
ParentExecutionId = string.Empty,
SourceSiteId = string.Empty,
SourceInstanceId = string.Empty,
SourceScript = string.Empty,
@@ -134,6 +139,7 @@ public class AuditEventDtoMapperTests
Assert.Null(evt.CorrelationId);
Assert.Null(evt.ExecutionId);
Assert.Null(evt.ParentExecutionId);
Assert.Null(evt.SourceSiteId);
Assert.Null(evt.SourceInstanceId);
Assert.Null(evt.SourceScript);

View File

@@ -74,9 +74,10 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
.Where(p => !p.IsShadowProperty())
.ToList();
// AuditEvent record exposes 22 init-only properties (alog.md §4 plus the
// additive ExecutionId universal correlation column).
Assert.Equal(22, properties.Count);
// AuditEvent record exposes 23 init-only properties (alog.md §4 plus the
// additive ExecutionId universal correlation column and its
// ParentExecutionId sibling).
Assert.Equal(23, properties.Count);
}
[Fact]
@@ -92,13 +93,15 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
// Five reconciliation/query indexes from alog.md §4, plus the EventId unique
// index introduced alongside the composite PK (Bundle C), plus the additive
// IX_AuditLog_Execution index supporting ExecutionId lookups.
// IX_AuditLog_Execution index supporting ExecutionId lookups and the
// IX_AuditLog_ParentExecution index supporting ParentExecutionId lookups.
var expected = new[]
{
"IX_AuditLog_Channel_Status_Occurred",
"IX_AuditLog_CorrelationId",
"IX_AuditLog_Execution",
"IX_AuditLog_OccurredAtUtc",
"IX_AuditLog_ParentExecution",
"IX_AuditLog_Site_Occurred",
"IX_AuditLog_Target_Occurred",
"UX_AuditLog_EventId",
@@ -143,5 +146,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
var executionIdx = entity.GetIndexes()
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Execution");
Assert.Equal("[ExecutionId] IS NOT NULL", executionIdx.GetFilter());
var parentExecutionIdx = entity.GetIndexes()
.Single(i => i.GetDatabaseName() == "IX_AuditLog_ParentExecution");
Assert.Equal("[ParentExecutionId] IS NOT NULL", parentExecutionIdx.GetFilter());
}
}

View File

@@ -0,0 +1,71 @@
using Xunit;
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// Audit Log ParentExecutionId integration test for the
/// <c>AddNotificationOriginParentExecutionId</c> migration: applies the EF
/// migrations to a freshly-created MSSQL test database on the running
/// infra/mssql container and asserts that the <c>Notifications</c> table carries
/// the new <c>OriginParentExecutionId</c> column as a nullable
/// <c>uniqueidentifier</c>.
/// </summary>
/// <remarks>
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is not partitioned, so
/// the column is a plain metadata-only <c>ALTER TABLE … ADD</c> with no index.
/// Tests pair <see cref="SkippableFactAttribute"/> with <c>Skip.IfNot(...)</c> so
/// the runner reports them as Skipped (not Passed) when MSSQL is unreachable. The
/// fixture applies the migrations once at construction time.
/// </remarks>
public class AddNotificationOriginParentExecutionIdMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AddNotificationOriginParentExecutionIdMigrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task AppliesMigration_AddsOriginParentExecutionIdColumn_ToNotifications()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var present = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId' " +
"AND TABLE_SCHEMA = 'dbo';");
Assert.Equal(1, present);
}
[SkippableFact]
public async Task OriginParentExecutionIdColumn_IsNullableUniqueIdentifier()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var dataType = await ScalarAsync<string?>(
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId';");
Assert.Equal("uniqueidentifier", dataType);
var isNullable = await ScalarAsync<string?>(
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId';");
Assert.Equal("YES", isNullable);
}
// --- helpers ------------------------------------------------------------
private async Task<T> ScalarAsync<T>(string sql)
{
await using var conn = _fixture.OpenConnection();
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
var result = await cmd.ExecuteScalarAsync();
if (result is null || result is DBNull)
{
return default!;
}
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
}
}

View File

@@ -275,6 +275,35 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Equal(executionId, r.ExecutionId));
}
[SkippableFact]
public async Task QueryAsync_FilterByParentExecutionId_ReturnsMatchingRows()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var parentExecutionId = Guid.NewGuid();
var t0 = new DateTime(2026, 5, 3, 13, 0, 0, DateTimeKind.Utc);
// Two rows share the ParentExecutionId; one carries a different
// ParentExecutionId and one leaves it null — both must be excluded by the
// single-value filter.
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, parentExecutionId: parentExecutionId));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), parentExecutionId: parentExecutionId));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), parentExecutionId: Guid.NewGuid()));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), parentExecutionId: null));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
SourceSiteIds: new[] { siteId },
ParentExecutionId: parentExecutionId),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(parentExecutionId, r.ParentExecutionId));
}
[SkippableFact]
public async Task QueryAsync_FilterByTimeRange()
{
@@ -717,6 +746,184 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.Equal(nowUtc, snapshot.AsOfUtc);
}
// ------------------------------------------------------------------------
// Audit Log ParentExecutionId (Task 8): GetExecutionTreeAsync
// ------------------------------------------------------------------------
//
// GetExecutionTreeAsync walks UP from any node to the chain root, then walks
// DOWN via a recursive CTE, returning one ExecutionTreeNode per distinct
// execution in the chain. These tests verify the observable behaviour:
// * a multi-level chain returns the full set regardless of entry node
// * a parent referenced only via ParentExecutionId (no rows of its own)
// still surfaces, as a RowCount = 0 stub node
// * pathological cyclic data is bounded by the MAXRECURSION guard and
// surfaces a SqlException rather than hanging
[SkippableFact]
public async Task GetExecutionTree_MultiLevelChain()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
// A 3-level chain: root -> mid -> leaf. Each execution emits two rows so
// RowCount aggregation is exercised; the child rows carry the parent's
// ExecutionId as ParentExecutionId. Each execution is given a DISTINCT
// channel, and its two rows carry DISTINCT statuses and timestamps, so
// the per-node Channels/Statuses sets and the FirstOccurred/LastOccurred
// span are meaningfully asserted (not all-defaults).
var rootExec = Guid.NewGuid();
var midExec = Guid.NewGuid();
var leafExec = Guid.NewGuid();
var t0 = new DateTime(2026, 10, 5, 9, 0, 0, DateTimeKind.Utc);
var rootT0 = t0;
var rootT1 = t0.AddMinutes(1);
var midT0 = t0.AddMinutes(2);
var midT1 = t0.AddMinutes(3);
var leafT0 = t0.AddMinutes(4);
var leafT1 = t0.AddMinutes(5);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: rootT0, channel: AuditChannel.ApiOutbound, status: AuditStatus.Submitted, executionId: rootExec));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: rootT1, channel: AuditChannel.ApiOutbound, status: AuditStatus.Delivered, executionId: rootExec));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: midT0, channel: AuditChannel.DbOutbound, status: AuditStatus.Submitted, executionId: midExec, parentExecutionId: rootExec));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: midT1, channel: AuditChannel.DbOutbound, status: AuditStatus.Failed, executionId: midExec, parentExecutionId: rootExec));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: leafT0, channel: AuditChannel.Notification, status: AuditStatus.Submitted, executionId: leafExec, parentExecutionId: midExec));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: leafT1, channel: AuditChannel.Notification, status: AuditStatus.Parked, executionId: leafExec, parentExecutionId: midExec));
var expected = new[] { rootExec, midExec, leafExec };
// Entry point must not matter: leaf, middle node, and root all yield the
// same full chain.
foreach (var entry in new[] { leafExec, midExec, rootExec })
{
var tree = await repo.GetExecutionTreeAsync(entry);
Assert.Equal(3, tree.Count);
Assert.True(
expected.ToHashSet().SetEquals(tree.Select(n => n.ExecutionId)),
$"entry {entry} did not return the full chain");
var root = tree.Single(n => n.ExecutionId == rootExec);
var mid = tree.Single(n => n.ExecutionId == midExec);
var leaf = tree.Single(n => n.ExecutionId == leafExec);
Assert.Null(root.ParentExecutionId);
Assert.Equal(rootExec, mid.ParentExecutionId);
Assert.Equal(midExec, leaf.ParentExecutionId);
Assert.Equal(2, root.RowCount);
Assert.Equal(2, mid.RowCount);
Assert.Equal(2, leaf.RowCount);
// Each populated node aggregates its own rows' channels and
// statuses — distinct per execution, so a regression that mixes
// executions or drops the per-id aggregate would be caught.
Assert.Equal(
new[] { nameof(AuditChannel.ApiOutbound) },
root.Channels);
Assert.Equal(
new[] { nameof(AuditChannel.DbOutbound) },
mid.Channels);
Assert.Equal(
new[] { nameof(AuditChannel.Notification) },
leaf.Channels);
Assert.True(
new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Delivered) }
.ToHashSet().SetEquals(root.Statuses));
Assert.True(
new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Failed) }
.ToHashSet().SetEquals(mid.Statuses));
Assert.True(
new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Parked) }
.ToHashSet().SetEquals(leaf.Statuses));
// Each populated node's timestamp span covers exactly its two rows.
Assert.Equal(rootT0, root.FirstOccurredAtUtc);
Assert.Equal(rootT1, root.LastOccurredAtUtc);
Assert.Equal(midT0, mid.FirstOccurredAtUtc);
Assert.Equal(midT1, mid.LastOccurredAtUtc);
Assert.Equal(leafT0, leaf.FirstOccurredAtUtc);
Assert.Equal(leafT1, leaf.LastOccurredAtUtc);
}
}
[SkippableFact]
public async Task GetExecutionTree_StubParentNode()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
// The parent execution emitted no rows of its own (it performed no
// trust-boundary action, or its rows were purged). Only the child has
// rows, and they reference the parent via ParentExecutionId. The parent
// must still surface as a node — a RowCount = 0 stub.
var stubParentExec = Guid.NewGuid();
var childExec = Guid.NewGuid();
var t0 = new DateTime(2026, 10, 6, 9, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: childExec, parentExecutionId: stubParentExec));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: childExec, parentExecutionId: stubParentExec));
// Entering by the child must surface BOTH the child and the stub parent.
var tree = await repo.GetExecutionTreeAsync(childExec);
Assert.Equal(2, tree.Count);
var stub = tree.Single(n => n.ExecutionId == stubParentExec);
var child = tree.Single(n => n.ExecutionId == childExec);
// The stub node: no rows, empty aggregate sets, null parent and timestamps.
Assert.Equal(0, stub.RowCount);
Assert.Empty(stub.Channels);
Assert.Empty(stub.Statuses);
Assert.Null(stub.ParentExecutionId);
Assert.Null(stub.FirstOccurredAtUtc);
Assert.Null(stub.LastOccurredAtUtc);
// The child node carries its rows and points at the stub parent.
Assert.Equal(2, child.RowCount);
Assert.Equal(stubParentExec, child.ParentExecutionId);
}
[SkippableFact]
public async Task GetExecutionTree_RespectsMaxRecursion()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
// Pathological cyclic data: A's rows point at B as parent, B's rows
// point back at A. The ParentExecutionId graph is acyclic by
// construction in production, but corrupt data must not hang the
// server. The downward recursive CTE's OPTION (MAXRECURSION 32) raises
// a SqlException when the cycle exceeds the guard; the method surfaces
// it rather than spinning forever.
var execA = Guid.NewGuid();
var execB = Guid.NewGuid();
var t0 = new DateTime(2026, 10, 7, 9, 0, 0, DateTimeKind.Utc);
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: execA, parentExecutionId: execB));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: execB, parentExecutionId: execA));
// The call must complete (throw) quickly, not hang. A generous 30s
// ceiling distinguishes "bounded failure" from "infinite loop".
var call = repo.GetExecutionTreeAsync(execA);
var completed = await Task.WhenAny(call, Task.Delay(TimeSpan.FromSeconds(30)));
Assert.Same(call, completed);
// MAXRECURSION exceeded surfaces as a SqlException — bounded, not a hang.
await Assert.ThrowsAsync<SqlException>(() => call);
}
private async Task<T> ScalarAsync<T>(ScadaLinkDbContext context, string sql)
{
var conn = context.Database.GetDbConnection();
@@ -754,7 +961,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
string? errorMessage = null,
Guid? executionId = null) =>
Guid? executionId = null,
Guid? parentExecutionId = null) =>
new()
{
EventId = Guid.NewGuid(),
@@ -765,5 +973,6 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
SourceSiteId = siteId,
ErrorMessage = errorMessage,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
};
}

View File

@@ -269,6 +269,7 @@ public class NotificationOutboxConfigurationTests : IDisposable
var nextAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 2, 0, TimeSpan.Zero);
var deliveredAt = new DateTimeOffset(2026, 5, 19, 8, 3, 0, TimeSpan.Zero);
var originExecutionId = Guid.NewGuid();
var originParentExecutionId = Guid.NewGuid();
var notification = new Notification(id, NotificationType.Email, "Ops List",
"High Tank Level", "Tank 4 exceeded the high level threshold.", "site-north")
@@ -281,6 +282,7 @@ public class NotificationOutboxConfigurationTests : IDisposable
SourceInstanceId = "instance-42",
SourceScript = "TankLevelAlarm",
OriginExecutionId = originExecutionId,
OriginParentExecutionId = originParentExecutionId,
SiteEnqueuedAt = siteEnqueuedAt,
CreatedAt = createdAt,
LastAttemptAt = lastAttemptAt,
@@ -314,6 +316,7 @@ public class NotificationOutboxConfigurationTests : IDisposable
Assert.Equal(nextAttemptAt, loaded.NextAttemptAt);
Assert.Equal(deliveredAt, loaded.DeliveredAt);
Assert.Equal(originExecutionId, loaded.OriginExecutionId);
Assert.Equal(originParentExecutionId, loaded.OriginParentExecutionId);
}
[Fact]
@@ -336,6 +339,26 @@ public class NotificationOutboxConfigurationTests : IDisposable
Assert.Null(loaded!.OriginExecutionId);
}
[Fact]
public async Task Notification_NullOriginParentExecutionId_RoundTripsAsNull()
{
// Audit Log ParentExecutionId: OriginParentExecutionId is an additive
// nullable column — notifications from non-routed runs (or submitted
// before the column existed) persist and reload it as null.
var id = Guid.NewGuid().ToString();
var notification = new Notification(id, NotificationType.Email, "Ops List",
"Subject", "Body", "site-north");
_context.Notifications.Add(notification);
await _context.SaveChangesAsync();
_context.ChangeTracker.Clear();
var loaded = await _context.Notifications.FindAsync(id);
Assert.NotNull(loaded);
Assert.Null(loaded!.OriginParentExecutionId);
}
[Fact]
public async Task Notification_StatusPersistsAsString()
{

View File

@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.InboundApi;
namespace ScadaLink.InboundAPI.Tests;
@@ -427,6 +428,71 @@ public class InboundScriptExecutorTests
Assert.Contains("whatever", result.ResultJson!);
}
// --- Audit Log #23 (ParentExecutionId, T3): the inbound request's
// ExecutionId is threaded through ExecuteAsync onto routed calls ---
[Fact]
public async Task ExecuteAsync_WithParentExecutionId_RoutedCallCarriesItAsParentExecutionId()
{
// The endpoint hands ExecuteAsync the inbound request's ExecutionId; a
// routed Route.To(...).Call(...) inside the script must stamp that id onto
// the RouteToCallRequest as ParentExecutionId.
var inboundExecutionId = Guid.NewGuid();
var locator = Substitute.For<IInstanceLocator>();
locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
var router = Substitute.For<IInstanceRouter>();
RouteToCallRequest? captured = null;
router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
var route = new RouteHelper(locator, router);
var method = new ApiMethod("routes", "return 1;") { Id = 1, TimeoutSeconds = 10 };
_executor.RegisterHandler("routes", async ctx =>
{
await ctx.Route.To("inst-1").Call("doWork");
return 1;
});
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10),
parentExecutionId: inboundExecutionId);
Assert.True(result.Success, result.ErrorMessage);
Assert.NotNull(captured);
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
}
[Fact]
public async Task ExecuteAsync_WithoutParentExecutionId_RoutedCallHasNullParentExecutionId()
{
// ExecuteAsync called without a parent execution id (the default) routes
// calls with ParentExecutionId null.
var locator = Substitute.For<IInstanceLocator>();
locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
var router = Substitute.For<IInstanceRouter>();
RouteToCallRequest? captured = null;
router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
var route = new RouteHelper(locator, router);
var method = new ApiMethod("routes2", "return 1;") { Id = 1, TimeoutSeconds = 10 };
_executor.RegisterHandler("routes2", async ctx =>
{
await ctx.Route.To("inst-1").Call("doWork");
return 1;
});
var result = await _executor.ExecuteAsync(
method, new Dictionary<string, object?>(), route, TimeSpan.FromSeconds(10));
Assert.True(result.Success, result.ErrorMessage);
Assert.NotNull(captured);
Assert.Null(captured!.ParentExecutionId);
}
private sealed class CompileLogCounter
{
public int CompilationFailures;

View File

@@ -395,6 +395,78 @@ public class AuditWriteMiddlewareTests
Assert.NotEqual(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId);
}
// ---------------------------------------------------------------------
// ParentExecutionId — Audit Log #23 (ParentExecutionId feature, T3): the
// inbound request's ExecutionId is minted ONCE, early, and stashed on
// HttpContext.Items so the endpoint handler can carry it onto the routed
// RouteToCallRequest as ParentExecutionId. The inbound row that the
// middleware itself emits stays top-level — its own ParentExecutionId is
// NEVER set.
// ---------------------------------------------------------------------
[Fact]
public async Task InboundExecutionId_IsStashedOnHttpItems_BeforeEndpointRuns()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
object? observedDuringHandler = null;
var mw = CreateMiddleware(hc =>
{
// The endpoint handler must be able to read the early-minted id —
// it is stashed before _next so a downstream reader sees it.
hc.Items.TryGetValue(AuditWriteMiddleware.InboundExecutionIdItemKey, out observedDuringHandler);
hc.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
var stashed = Assert.IsType<Guid>(observedDuringHandler);
Assert.NotEqual(Guid.Empty, stashed);
}
[Fact]
public async Task InboundRow_ExecutionId_Equals_TheEarlyMintedStashedId()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
Guid stashedDuringHandler = Guid.Empty;
var mw = CreateMiddleware(hc =>
{
stashedDuringHandler =
(Guid)hc.Items[AuditWriteMiddleware.InboundExecutionIdItemKey]!;
hc.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
// The inbound audit row's ExecutionId must be the SAME id minted early
// and shared with the endpoint handler — not a second, late mint.
var evt = Assert.Single(writer.Events);
Assert.Equal(stashedDuringHandler, evt.ExecutionId);
}
[Fact]
public async Task InboundRow_OwnParentExecutionId_StaysNull()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
var mw = CreateMiddleware(_ =>
{
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
// The inbound request is itself top-level — only the spawn id flows
// OUT on RouteToCallRequest. The inbound row's own ParentExecutionId
// is never set.
var evt = Assert.Single(writer.Events);
Assert.Null(evt.ParentExecutionId);
}
[Fact]
public async Task DurationMs_IsRecorded()
{

View File

@@ -233,6 +233,71 @@ public class RouteHelperTests
Assert.Equal(explicitCts.Token, seen);
}
// --- Audit Log #23 (ParentExecutionId, T3): a routed call carries the
// inbound request's ExecutionId as RouteToCallRequest.ParentExecutionId ---
[Fact]
public async Task Call_WithoutParentExecutionId_LeavesParentExecutionIdNull()
{
// A RouteHelper not bound to an inbound execution id (e.g. the Central UI
// sandbox path) builds requests with ParentExecutionId null.
SiteResolves("inst-1", "SiteA");
RouteToCallRequest? captured = null;
_router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
await CreateHelper().To("inst-1").Call("doWork");
Assert.NotNull(captured);
Assert.Null(captured!.ParentExecutionId);
}
[Fact]
public async Task Call_WithParentExecutionId_CarriesItOnRouteToCallRequest()
{
// A RouteHelper bound to the inbound request's ExecutionId must stamp that
// id onto the routed RouteToCallRequest so the site script records it as
// its ParentExecutionId.
SiteResolves("inst-1", "SiteA");
var inboundExecutionId = Guid.NewGuid();
RouteToCallRequest? captured = null;
_router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
var bound = CreateHelper().WithParentExecutionId(inboundExecutionId);
await bound.To("inst-1").Call("doWork");
Assert.NotNull(captured);
Assert.Equal(inboundExecutionId, captured!.ParentExecutionId);
// ParentExecutionId is a separate concern from the per-op CorrelationId —
// the helper still mints its own routed-call correlation id.
Assert.True(Guid.TryParse(captured.CorrelationId, out _));
}
[Fact]
public async Task WithParentExecutionId_PreservesDeadlineToken()
{
// The two builder methods compose — binding a parent execution id must
// not drop a previously-bound deadline token.
SiteResolves("inst-1", "SiteA");
using var deadline = new CancellationTokenSource();
CancellationToken seen = default;
RouteToCallRequest? captured = null;
_router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Do<CancellationToken>(t => seen = t))
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow));
var bound = CreateHelper()
.WithDeadline(deadline.Token)
.WithParentExecutionId(Guid.NewGuid());
await bound.To("inst-1").Call("doWork");
Assert.Equal(deadline.Token, seen);
Assert.NotNull(captured!.ParentExecutionId);
}
[Fact]
public async Task GetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken()
{

View File

@@ -96,6 +96,10 @@ public class SiteAuditPushFlowTests : TestKit
public Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default)
=> throw new NotSupportedException();
}
private static AuditEvent NewPendingEvent(Guid id) => new()

View File

@@ -460,6 +460,38 @@ public class AuditEndpointsTests
Assert.Null(filter.ExecutionId);
}
[Fact]
public void ParseFilter_ParentExecutionId_ParsesIntoSingleValueGuid()
{
// parentExecutionId is a single-value Guid? filter — mirrors executionId.
var parentExecutionId = Guid.NewGuid();
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["parentExecutionId"] = parentExecutionId.ToString(),
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Equal(parentExecutionId, filter.ParentExecutionId);
}
[Fact]
public void ParseFilter_UnparseableParentExecutionId_IsDroppedSilently()
{
// Lax-parse contract: an unparseable parentExecutionId is dropped (no 400) —
// mirrors the executionId parse.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["parentExecutionId"] = "not-a-guid",
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Null(filter.ParentExecutionId);
}
[Fact]
public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter()
{

View File

@@ -95,7 +95,8 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
Guid? notificationId = null,
string sourceSite = "site-1",
int retryCount = 0,
Guid? originExecutionId = null)
Guid? originExecutionId = null,
Guid? originParentExecutionId = null)
{
return new Notification(
(notificationId ?? Guid.NewGuid()).ToString("D"),
@@ -110,6 +111,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
SourceInstanceId = "instance-42",
SourceScript = "AlarmScript",
OriginExecutionId = originExecutionId,
OriginParentExecutionId = originParentExecutionId,
};
}
@@ -207,6 +209,50 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
});
}
[Fact]
public void Attempt_CarriesOriginParentExecutionId_AsParentExecutionId()
{
// Audit Log ParentExecutionId: the Attempted NotifyDeliver row must echo
// the notification's OriginParentExecutionId so the central dispatcher's
// rows carry the routed run's parent id.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var parentExecutionId = Guid.NewGuid();
var notification = MakeNotification(originParentExecutionId: parentExecutionId);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var attempted = EventsByStatus(AuditStatus.Attempted);
Assert.Single(attempted);
Assert.Equal(parentExecutionId, attempted[0].ParentExecutionId);
});
}
[Fact]
public void Attempt_NullOriginParentExecutionId_HasNullParentExecutionId()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification(originParentExecutionId: null);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var attempted = EventsByStatus(AuditStatus.Attempted);
Assert.Single(attempted);
Assert.Null(attempted[0].ParentExecutionId);
});
}
[Fact]
public void Attempt_TransientFailure_EmitsEvent_StatusAttempted_ErrorMessageSet()
{

View File

@@ -40,7 +40,9 @@ public class NotificationOutboxActorIngestTests : TestKit
}
private static NotificationSubmit MakeSubmit(
string? notificationId = null, Guid? originExecutionId = null)
string? notificationId = null,
Guid? originExecutionId = null,
Guid? originParentExecutionId = null)
{
return new NotificationSubmit(
NotificationId: notificationId ?? Guid.NewGuid().ToString(),
@@ -51,7 +53,8 @@ public class NotificationOutboxActorIngestTests : TestKit
SourceInstanceId: "instance-42",
SourceScript: "AlarmScript",
SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero),
OriginExecutionId: originExecutionId);
OriginExecutionId: originExecutionId,
OriginParentExecutionId: originParentExecutionId);
}
[Fact]
@@ -121,6 +124,42 @@ public class NotificationOutboxActorIngestTests : TestKit
Arg.Any<CancellationToken>());
}
[Fact]
public void NotificationSubmit_CopiesOriginParentExecutionId_OntoPersistedNotification()
{
// Audit Log ParentExecutionId: the routed run's parent ExecutionId rides
// on the NotificationSubmit and must be persisted on the Notification row
// so the dispatcher can later echo it onto NotifyDeliver audit rows.
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
.Returns(true);
var parentExecutionId = Guid.NewGuid();
var submit = MakeSubmit(originParentExecutionId: parentExecutionId);
var actor = CreateActor();
actor.Tell(submit, TestActor);
ExpectMsg<NotificationSubmitAck>();
_repository.Received(1).InsertIfNotExistsAsync(
Arg.Is<Notification>(n => n.OriginParentExecutionId == parentExecutionId),
Arg.Any<CancellationToken>());
}
[Fact]
public void NotificationSubmit_NullOriginParentExecutionId_PersistsNull()
{
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
.Returns(true);
var submit = MakeSubmit(originParentExecutionId: null);
var actor = CreateActor();
actor.Tell(submit, TestActor);
ExpectMsg<NotificationSubmitAck>();
_repository.Received(1).InsertIfNotExistsAsync(
Arg.Is<Notification>(n => n.OriginParentExecutionId == null),
Arg.Any<CancellationToken>());
}
[Fact]
public void DuplicateSubmit_RepositoryReturnsFalse_StillAcksAccepted()
{

View File

@@ -88,7 +88,8 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
NotificationStatus status = NotificationStatus.Pending,
int retryCount = 0,
Guid? notificationId = null,
Guid? originExecutionId = null)
Guid? originExecutionId = null,
Guid? originParentExecutionId = null)
{
return new Notification(
(notificationId ?? Guid.NewGuid()).ToString("D"),
@@ -102,6 +103,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
RetryCount = retryCount,
CreatedAt = DateTimeOffset.UtcNow,
OriginExecutionId = originExecutionId,
OriginParentExecutionId = originParentExecutionId,
};
}
@@ -191,6 +193,50 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
});
}
[Fact]
public void Terminal_Delivered_CarriesOriginParentExecutionId_AsParentExecutionId()
{
// Audit Log ParentExecutionId: the terminal NotifyDeliver row must echo
// the notification's OriginParentExecutionId so the central dispatcher's
// rows carry the routed run's parent id.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var parentExecutionId = Guid.NewGuid();
var notification = MakeNotification(originParentExecutionId: parentExecutionId);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var delivered = EventsByStatus(AuditStatus.Delivered);
Assert.Single(delivered);
Assert.Equal(parentExecutionId, delivered[0].ParentExecutionId);
});
}
[Fact]
public void Terminal_Delivered_NullOriginParentExecutionId_HasNullParentExecutionId()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification(originParentExecutionId: null);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var delivered = EventsByStatus(AuditStatus.Delivered);
Assert.Single(delivered);
Assert.Null(delivered[0].ParentExecutionId);
});
}
[Fact]
public void Terminal_Parked_OnPermanentFailure_EmitsEvent_StatusParked()
{

View File

@@ -3,6 +3,7 @@ using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Messages.Deployment;
using ScadaLink.Commons.Messages.InboundApi;
using ScadaLink.Commons.Messages.Lifecycle;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Commons.Types.Flattening;
@@ -68,6 +69,28 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
return JsonSerializer.Serialize(config);
}
/// <summary>
/// Builds a config carrying a single callable (no-trigger) script that
/// returns a constant — enough for an inbound <see cref="RouteToCallRequest"/>
/// to be routed end-to-end through the Instance/Script/ScriptExecution actors.
/// </summary>
private static string MakeConfigWithScriptJson(string instanceName, string scriptName)
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = instanceName,
Attributes =
[
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "42", DataType = "Int32" }
],
Scripts =
[
new ResolvedScript { CanonicalName = scriptName, Code = "return 7;" }
]
};
return JsonSerializer.Serialize(config);
}
[Fact]
public async Task DeploymentManager_CreatesInstanceActors_FromStoredConfigs()
{
@@ -240,4 +263,57 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
Assert.Equal(DeploymentStatus.Success, response.Status);
}
// ── Audit Log #23 (ParentExecutionId, Task 4): inbound-API routing ──
[Fact]
public async Task RouteInboundApiCall_WithParentExecutionId_RoutesToScriptSuccessfully()
{
// A RouteToCallRequest carrying a ParentExecutionId (the inbound
// request's ExecutionId) must be mapped to a ScriptCallRequest and
// routed end-to-end through the Instance/Script/ScriptExecution actors.
// The additive ParentExecutionId field must not break that routing.
var actor = CreateDeploymentManager();
await Task.Delay(500); // empty startup
actor.Tell(new DeployInstanceCommand(
"dep-route", "RoutedPump", "sha256:route",
MakeConfigWithScriptJson("RoutedPump", "DoWork"), "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(1000); // let the InstanceActor + ScriptActor spin up
var parentExecutionId = Guid.NewGuid();
actor.Tell(new RouteToCallRequest(
"route-corr-1", "RoutedPump", "DoWork",
Parameters: null, DateTimeOffset.UtcNow, ParentExecutionId: parentExecutionId));
var response = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("route-corr-1", response.CorrelationId);
Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}");
Assert.Equal(7, Convert.ToInt32(response.ReturnValue));
}
[Fact]
public async Task RouteInboundApiCall_WithoutParentExecutionId_StillRoutes()
{
// A routed call with no ParentExecutionId (e.g. the Central UI sandbox)
// is the additive-default path — it must route exactly as before.
var actor = CreateDeploymentManager();
await Task.Delay(500);
actor.Tell(new DeployInstanceCommand(
"dep-route2", "RoutedPump2", "sha256:route2",
MakeConfigWithScriptJson("RoutedPump2", "DoWork"), "admin", DateTimeOffset.UtcNow));
ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(1000);
// No ParentExecutionId argument — exercises the additive `= null` default.
actor.Tell(new RouteToCallRequest(
"route-corr-2", "RoutedPump2", "DoWork",
Parameters: null, DateTimeOffset.UtcNow));
var response = ExpectMsg<RouteToCallResponse>(TimeSpan.FromSeconds(10));
Assert.Equal("route-corr-2", response.CorrelationId);
Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}");
}
}

Some files were not shown because too many files have changed in this diff Show More