42 Commits

Author SHA1 Message Date
Joseph Doherty afd81c32ef fix(centralui): marshal Audit Log LocationChanged handler through InvokeAsync
Code-review follow-ups on the same-page drill-in fix (3f1c0e5):
- Wrap HandleLocationChanged's body in InvokeAsync — LocationChanged can
  fire off the renderer's synchronization context.
- Document that a paramless /audit/log navigation intentionally preserves
  the last applied filter (drill-ins always carry query params).
2026-05-21 20:35:20 -04:00
Joseph Doherty 3f1c0e5018 fix(centralui): re-apply Audit Log query-string filters on same-page drill-in
The drilldown drawer's 'View this/parent execution' actions call
NavigationManager.NavigateTo('/audit/log?executionId=...') while the
user is already on the routed AuditLogPage. Blazor treats this as a
same-component navigation, so OnInitialized does not re-run and
ApplyQueryStringFilters() (which was wired only to OnInitialized) never
re-parsed the new query string: _currentFilter stayed stale and the
results grid never reloaded to the drill-in target.

AuditLogPage now subscribes to NavigationManager.LocationChanged,
re-applies the query-string filters on every location change (closing
the drawer and calling StateHasChanged), and unsubscribes via
IDisposable. The 'View parent execution' drill-in now genuinely lands
on /audit/log?executionId={parentId} with the grid reloaded.

Also corrects the Playwright test wait: a same-page query-string Blazor
navigation pushes history.pushState over the SignalR circuit rather
than triggering a document load, so WaitForLoadState(NetworkIdle)
returned before the URL settled. Switched to WaitForURLAsync, the
correct primitive for SPA/pushState navigations.
2026-05-21 20:30:48 -04:00
Joseph Doherty 16f800b76a Merge branch 'feature/audit-parent-executionid': ParentExecutionId cross-execution audit correlation 2026-05-21 20:14:44 -04:00
Joseph Doherty 9ec83d5070 docs(auditlog): generalize two stale XML-doc comments
- AddColumnIfMissing is now shared by ExecutionId and ParentExecutionId;
  drop the ExecutionId-specific tag.
- AuditLogRepository.GetExecutionTreeAsync doc no longer hardcodes the
  MAXRECURSION literal; reference the ExecutionChainMaxDepth const instead.
2026-05-21 20:14:31 -04:00
Joseph Doherty 933f0484ba test(auditlog): ParentExecutionId e2e waits on audit kinds, not a row count
The headline ParentExecutionIdCorrelationTests intermittently failed under
full-suite parallel load, seeing 6 of 7 routed-run rows (NotifySend missing).
Root cause: WaitForSiteRowsPersistedAsync checked only a row *count*, which a
partial snapshot could satisfy before the last-emitted NotifySend row settled,
letting the SiteAuditTelemetryActor drain a partial batch. Fix is test-only:
wait on the specific audit Kinds (guaranteeing NotifySend is durably in SQLite
before the assertion) and widen the assertion ceiling 30s -> 90s for drain
headroom under load. Also drops leftover // DIAG sampler debug scaffolding.
2026-05-21 20:09:54 -04:00
Joseph Doherty fb1312d0bf test(auditlog): end-to-end ParentExecutionId correlation + docs 2026-05-21 19:12:19 -04:00
Joseph Doherty 592cbd028e feat(audit): ParentExecutionId filter in the CLI and ManagementService 2026-05-21 18:59:06 -04:00
Joseph Doherty 9b1f78638b refactor(centralui): complete cycle fallback + polish in ExecutionTree 2026-05-21 18:56:03 -04:00
Joseph Doherty 34a4356625 feat(centralui): execution-chain tree view on the Audit Log page 2026-05-21 18:49:13 -04:00
Joseph Doherty 0b5723b777 feat(centralui): ParentExecutionId column, filter and parent drill-in on the Audit Log page 2026-05-21 18:38:02 -04:00
Joseph Doherty 252bf0a970 refactor(auditlog): GetExecutionTreeAsync recurses over a distinct edge set 2026-05-21 18:29:48 -04:00
Joseph Doherty 255dd95cd9 feat(auditlog): GetExecutionTreeAsync recursive execution-chain query 2026-05-21 18:22:21 -04:00
Joseph Doherty d35551efc2 feat(auditlog): NotifyDeliver rows carry the originating ParentExecutionId 2026-05-21 18:11:04 -04:00
Joseph Doherty c00603e2a4 feat(auditlog): thread ParentExecutionId through S&F for retry-loop cached rows
The store-and-forward retry loop emits the per-attempt and terminal cached
audit rows (ApiCallCached/DbWriteCached Attempted, CachedResolve) via
CachedCallLifecycleBridge from a CachedCallAttemptContext, not from the
script context. The ExecutionId rollout (Task 4) already threaded ExecutionId
and SourceScript through this path; ParentExecutionId — the spawning
inbound-API request's ExecutionId — was not, so those retry-loop rows had
ParentExecutionId = null even for an inbound-API-routed run.

Thread it additively as a sibling at every carry point ExecutionId passes
through:

- StoreAndForwardMessage gains ParentExecutionId (Guid?).
- StoreAndForwardStorage adds a nullable parent_execution_id column via the
  same idempotent PRAGMA-probed ALTER TABLE migration; rows persisted by an
  older build read back null (back-compat). The defensive Guid.TryParse read
  helper (ParseExecutionId) is renamed ParseGuidColumn and reused for both
  columns so a corrupt value cannot abort the retry sweep.
- StoreAndForwardService.EnqueueAsync gains an optional parentExecutionId
  param, stamped onto the buffered message and surfaced on the
  CachedCallAttemptContext built in the retry loop.
- CachedCallAttemptContext gains ParentExecutionId.
- CachedCallLifecycleBridge.BuildPacket sets AuditEvent.ParentExecutionId
  from the context, beside the existing ExecutionId.
- IExternalSystemClient.CachedCallAsync / IDatabaseGateway.CachedWriteAsync
  gain an optional parentExecutionId param; ScriptRuntimeContext's CachedCall
  / CachedWrite helpers pass _parentExecutionId.

All threading is additive — ParentExecutionId is Guid? everywhere, null for
non-routed runs, and old buffered S&F rows still deserialize with the new
field null.
2026-05-21 17:58:11 -04:00
Joseph Doherty 150ba5e63f feat(auditlog): site script-side emitters stamp ParentExecutionId 2026-05-21 17:45:55 -04:00
Joseph Doherty 6af2607a50 feat(siteruntime): thread ParentExecutionId into the routed script's ScriptRuntimeContext 2026-05-21 17:35:49 -04:00
Joseph Doherty dc2c73b07d refactor(inboundapi): fail-fast on missing inbound ExecutionId stash 2026-05-21 17:26:49 -04:00
Joseph Doherty d8453bfba2 feat(inboundapi): mint inbound ExecutionId early, carry it as RouteToCallRequest.ParentExecutionId 2026-05-21 17:22:13 -04:00
Joseph Doherty 50430b9daa feat(auditlog): ParentExecutionId on site SQLite schema + gRPC AuditEventDto 2026-05-21 17:12:34 -04:00
Joseph Doherty 0a8709e5c5 feat(auditlog): ParentExecutionId column on AuditEvent + central AuditLog 2026-05-21 17:04:39 -04:00
Joseph Doherty e4b37e2798 docs(auditlog): ParentExecutionId implementation plan + task tracking 2026-05-21 16:58:07 -04:00
Joseph Doherty 6be26e2813 docs(auditlog): ParentExecutionId cross-execution correlation design 2026-05-21 16:53:25 -04:00
Joseph Doherty 156e560171 Merge branch 'feature/audit-executionid': ExecutionId universal audit correlation
Adds a dedicated ExecutionId column to the Audit Log — one universal per-run
correlation value stamped on every audit row (sync ApiCall/DbWrite, the cached
call lifecycle incl. the S&F retry-loop path, NotifySend, central NotifyDeliver,
inbound). CorrelationId keeps its per-operation lifecycle meaning. Additive
schema (AuditLog.ExecutionId, Notifications.OriginExecutionId); UI column +
filter + drill-in; CLI + ManagementService filter.
2026-05-21 16:30:41 -04:00
Joseph Doherty 5198b114b4 fix(auditlog): evolve existing site auditlog.db schema for ExecutionId 2026-05-21 16:18:17 -04:00
Joseph Doherty fd76c19007 test(auditlog): end-to-end ExecutionId correlation + docs 2026-05-21 16:06:40 -04:00
Joseph Doherty 24cdfe373c feat(audit): ExecutionId filter in the CLI and ManagementService 2026-05-21 16:00:09 -04:00
Joseph Doherty 1ba62052d6 feat(centralui): ExecutionId column, filter and drill-in on the Audit Log page 2026-05-21 15:52:57 -04:00
Joseph Doherty cfd8f1ecf4 feat(auditlog): inbound audit rows carry ExecutionId 2026-05-21 15:44:17 -04:00
Joseph Doherty 6aac4c8ed7 test(auditlog): pin OriginExecutionId preservation in forwarder + Parked NotifyDeliver 2026-05-21 15:42:45 -04:00
Joseph Doherty 85bb61a1f3 feat(auditlog): NotifyDeliver rows carry the originating ExecutionId 2026-05-21 15:35:40 -04:00
Joseph Doherty 705ae95404 test(auditlog): assert ExecutionId threading hops; defensive Guid parse on S&F read 2026-05-21 15:27:58 -04:00
Joseph Doherty 6f5a35f222 feat(auditlog): thread ExecutionId through S&F for retry-loop cached rows
The store-and-forward retry loop emits the per-attempt and terminal cached
audit rows (ApiCallCached/DbWriteCached Attempted, CachedResolve) via
CachedCallLifecycleBridge from a CachedCallAttemptContext, not from the
script context. ExecutionId (and SourceScript) were not threaded through the
S&F buffer, so those rows had ExecutionId = null and SourceScript = null.

Thread both, additively, from the cached-call enqueue path:

- StoreAndForwardMessage gains ExecutionId (Guid?) / SourceScript (string?).
- StoreAndForwardStorage adds nullable execution_id / source_script columns
  via an idempotent PRAGMA-probed ALTER TABLE migration; rows persisted by
  an older build read back null (back-compat).
- StoreAndForwardService.EnqueueAsync gains optional executionId /
  sourceScript params, stamped onto the buffered message and surfaced on the
  CachedCallAttemptContext built in the retry loop.
- CachedCallAttemptContext gains ExecutionId / SourceScript.
- CachedCallLifecycleBridge.BuildPacket sets AuditEvent.ExecutionId and
  AuditEvent.SourceScript from the context (replacing the hard-coded
  SourceScript = null and its now-stale comment).
- IExternalSystemClient.CachedCallAsync / IDatabaseGateway.CachedWriteAsync
  gain optional executionId / sourceScript params; ScriptRuntimeContext's
  CachedCall / CachedWrite helpers pass _executionId / _sourceScript.

Script-side cached rows (CachedSubmit, immediate Attempted+Resolve) are
unchanged. All threading is additive — old buffered S&F rows still
deserialize and process with the new fields null.
2026-05-21 15:18:35 -04:00
Joseph Doherty 0149ce6180 feat(auditlog): site script-side emitters stamp ExecutionId
Move the per-script-execution Guid on ScriptRuntimeContext from
_auditCorrelationId to _executionId, and stamp it into the dedicated
AuditEvent.ExecutionId column on every script-side audit row:

- Sync ApiCall / DbWrite: ExecutionId set; CorrelationId reverts to
  null (a sync one-shot call has no operation lifecycle).
- Cached-call script-side rows (CachedSubmit, immediate-completion
  ApiCallCached + CachedResolve) and NotifySend: ExecutionId set;
  CorrelationId unchanged (per-operation TrackedOperationId /
  NotificationId).

Renames the threaded ctor param/field across ExternalSystemHelper,
DatabaseHelper, AuditingDbConnection and AuditingDbCommand, and threads
the id through NotifyHelper/NotifyTarget. The S&F retry-loop cached rows
(CachedCallLifecycleBridge) are out of scope here.
2026-05-21 15:05:00 -04:00
Joseph Doherty 6b16a48886 feat(auditlog): ExecutionId on site SQLite schema + gRPC AuditEventDto 2026-05-21 14:53:08 -04:00
Joseph Doherty 990731d12f test(auditlog): cover ExecutionId in AuditEvent round-trip test; clarify staging-table comment 2026-05-21 14:48:39 -04:00
Joseph Doherty fd12021984 feat(auditlog): ExecutionId column on AuditEvent + central AuditLog 2026-05-21 14:43:35 -04:00
Joseph Doherty 4002f4197b docs(plan): Audit Log ExecutionId implementation plan 2026-05-21 14:37:12 -04:00
Joseph Doherty 6ffa47f258 docs(design): Audit Log ExecutionId universal correlation 2026-05-21 14:34:12 -04:00
Joseph Doherty c9229c35fc Merge branch 'feature/audit-execution-correlation': per-execution audit correlation id
Every script execution gets an audit correlation id (generated for tag/timer
runs, request-local for inbound); it is stamped as CorrelationId on the sync
ApiCall and DbWrite audit rows so all sync trust-boundary rows from one run
correlate. Shared scripts inherit it. Cached calls / notifications keep their
existing CorrelationId. No schema change.
2026-05-21 13:58:37 -04:00
Joseph Doherty aadb1fd72a refactor(auditlog): rename audit correlation field, add cross-helper tests 2026-05-21 13:57:17 -04:00
Joseph Doherty 8243f61e96 feat(auditlog): per-script-execution correlation id on sync audit rows 2026-05-21 13:46:34 -04:00
Joseph Doherty 53508c79b2 Merge branch 'feature/audit-apicall-payloads': capture API-call payloads
Outbound API audit rows now carry the request arguments and response body
(sync ApiCall + cached immediate-completion path); the emitter previously
hard-coded both summary fields to null.
2026-05-21 10:17:50 -04:00
125 changed files with 15218 additions and 182 deletions
+2
View File
@@ -132,6 +132,8 @@ This project contains design documentation for a distributed SCADA system built
- Layered design — append-only `AuditLog` (#23) sits alongside operational `Notifications` (#21) and `SiteCalls` (#22), not replacing them. - Layered design — append-only `AuditLog` (#23) sits alongside operational `Notifications` (#21) and `SiteCalls` (#22), not replacing them.
- Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded. - 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`). - 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. - 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. - 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. - 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.
@@ -0,0 +1,115 @@
# Audit Log — ExecutionId Universal Correlation (Design)
**Date:** 2026-05-21
**Status:** Validated — ready for implementation planning.
## Problem
The audit `CorrelationId` column is overloaded with three incompatible meanings —
`TrackedOperationId` for cached calls, `NotificationId` for notifications, the
script-execution id for sync calls (added 2026-05-21), and request-local ids for
inbound. It is `NULL` for sync one-shot calls. There is no single value that ties
together *everything one script run (or inbound request) did*: a run that makes a
sync API call, a cached call and a notification produces three unrelated
correlation ids, and nothing links the cached call's lifecycle rows back to the
run that launched them.
A single `CorrelationId` column cannot serve both scopes — the **operation
lifecycle** (a cached call's `Submit→Attempted→Resolve`; a notification's
`Send→Deliver`, which the Site Calls / Notifications "View audit history"
drill-ins depend on) and the **execution trace** (all operations of one run).
## Decision
Add a dedicated, nullable **`ExecutionId`** column to the audit row. It identifies
the originating **script execution** or **inbound API request**. Every audit row
that execution produces carries the same `ExecutionId`. `CorrelationId` is left
exactly as it is — it keeps the per-operation lifecycle meaning, so the existing
operation drill-ins are unaffected.
Result: `WHERE ExecutionId = X` returns every audit row of one run — sync
`ApiCall`/`DbWrite`, the whole cached-call lifecycle, `NotifySend`,
`NotifyDeliver`, and the inbound row — across both the site and central tables.
`ScriptRuntimeContext` already holds a per-execution id (`_auditCorrelationId`,
added 2026-05-21). That id becomes the `ExecutionId`; this work stamps it into the
new column from every emitter and threads it to the two paths where the script
context is not in scope.
### Considered and rejected
- **Overload `CorrelationId`** with the execution id everywhere — breaks the
cached-call / notification "View audit history" drill-ins (they filter
`CorrelationId` by `TrackedOperationId` / `NotificationId`), or forces them to
show the whole run instead of the one operation.
- **Stash the execution id in `Extra` JSON** — no schema change, but `Extra` is
unindexed; filtering an audit table of this volume by it is unworkable.
## Schema changes (all additive, nullable — no backfill; pre-existing rows stay `NULL`)
| Where | Change |
|---|---|
| `ScadaLink.Commons` | `AuditEvent` record (and the site-local variant) gains `Guid? ExecutionId`. |
| Central MS SQL `AuditLog` | new `ExecutionId uniqueidentifier NULL` column + index `IX_AuditLog_Execution (ExecutionId)`. EF migration — additive nullable column is a metadata-only `ALTER`, fast even on the monthly-partitioned table. |
| Site SQLite `auditlog.db` `AuditLog` | new `ExecutionId TEXT NULL` column (`SqliteAuditWriter` schema + `MapRow`). |
| gRPC `AuditEventDto` (`sitestream.proto`) | additive `execution_id` field; `AuditEventDtoMapper` maps it both directions. |
| Central MS SQL `Notifications` | new `OriginExecutionId uniqueidentifier NULL` column — carries the originating run's id so the dispatcher can echo it onto `NotifyDeliver` audit rows. EF migration. |
`SiteCalls` needs no new column — the cached telemetry packet already carries the
audit half, which now has `ExecutionId` directly.
## Emitter coverage — every audit row carries `ExecutionId`
| Emitter | `ExecutionId` source |
|---|---|
| Sync `ApiCall`, sync `DbWrite` | `ScriptRuntimeContext` execution id (in scope today) |
| Cached call script-side rows (`CachedSubmit`, immediate `Attempted`/`CachedResolve`) | `ScriptRuntimeContext` execution id |
| Cached call **S&F retry-loop** rows (`CachedCallLifecycleBridge`) | threaded through the store-and-forward buffered message → `CachedCallAttemptContext` → the bridge. This same threading also fixes the pre-existing `SourceScript = NULL` gap on those rows (identical boundary). |
| `NotifySend` (site, script-side) | `ScriptRuntimeContext` execution id |
| `NotifyDeliver` (central dispatch) | `Notifications.OriginExecutionId` — the id rides on `NotificationSubmit`, is persisted on the `Notifications` row, and the dispatcher stamps it on every `NotifyDeliver` row |
| Inbound `InboundRequest` / `InboundAuthFailure` | request id minted once in `AuditWriteMiddleware` |
## Data flow
- **Site script run** — `ScriptRuntimeContext` generates the execution id (or is
given one); every emitter it owns stamps `ExecutionId`.
- **Buffered cached call** — the execution id rides on the S&F buffered message;
the retry loop reconstructs it into `CachedCallAttemptContext`;
`CachedCallLifecycleBridge` stamps it on the retry-loop audit rows.
- **Notification** — the `NotifySend` row stamps it site-side; the id travels on
`NotificationSubmit`, is stored as `Notifications.OriginExecutionId`, and the
dispatcher stamps every `NotifyDeliver` row it emits.
- **Inbound API request** — `AuditWriteMiddleware` mints a request id and stamps
the inbound audit row.
## UI / CLI surface
- **Central UI Audit Log page** — `ExecutionId` added as a results-grid column
(the grid already supports resize/reorder); an `ExecutionId` paste-filter in
the filter bar; the page accepts `?executionId=<guid>`; a row drill-in
"View this execution" → `/audit/log?executionId=<guid>`.
- **CLI** — `scadalink audit query --execution-id <guid>`.
- **ManagementService** — `/api/audit/query` and the export endpoint accept an
`executionId` filter parameter.
## Compatibility
- Two additive nullable columns; additive proto field; additive message-contract
fields — all version-compatible. No data backfill; historical rows keep
`ExecutionId = NULL`.
- `CorrelationId` semantics unchanged — every existing drill-in keeps working.
## Testing
- Repository: query-by-`ExecutionId`; migration smoke test.
- Emitter unit tests: each emitter stamps `ExecutionId`; the cached-call lifecycle
rows from one run share it; `NotifyDeliver` echoes `Notifications.OriginExecutionId`.
- Integration: a script run that does a sync call + a cached call + a notification
→ all resulting audit rows share one `ExecutionId` end-to-end.
- Central UI: bUnit (grid column, filter, drill-in) + Playwright.
## Out of scope
- Bridging the inbound request id into the routed site script's execution
(cross-cluster threading) — a separate future change.
- Backfilling `ExecutionId` on historical audit rows.
+155
View File
@@ -0,0 +1,155 @@
# Audit Log ExecutionId — 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 dedicated `ExecutionId` column to the Audit Log — one universal correlation value, stamped on every audit row, identifying the originating script execution or inbound request.
**Architecture:** Additive nullable `ExecutionId` (`Guid`) on the audit row (Commons `AuditEvent`, central MS SQL `AuditLog`, site SQLite `auditlog.db`, gRPC `AuditEventDto`). Every emitter stamps it; the `ScriptRuntimeContext` per-execution id is the source for site script runs, threaded through the S&F buffer for retry-loop cached rows and through `NotificationSubmit``Notifications.OriginExecutionId` for central `NotifyDeliver` rows. `CorrelationId` is left as the per-operation lifecycle id (and reverts to `null` for sync one-shot calls). Validated design: `docs/plans/2026-05-21-audit-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-executionid` (already created) — never commit to `main`. Edit in place; never touch `infra/*`; `docker/*` only if a task says so (none do). Stage with explicit `git add <path>` — never `git add .` / `commit -am`. TDD; full solution stays green (`dotnet build ScadaLink.slnx` 0 warnings — `TreatWarningsAsErrors` is on). Additive contract evolution. Do not push.
---
## Task 0: Prep — verify branch + baseline
**Files:** none.
**Steps:** confirm `git branch --show-current` is `feature/audit-executionid`; `dotnet build ScadaLink.slnx` succeeds.
**Acceptance:** on the branch, solution builds clean.
---
## Task 1: Foundation — `AuditEvent.ExecutionId`, central `AuditLog` column, repository query
**Files:**
- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` — add `Guid? ExecutionId`.
- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — add `Guid? ExecutionId` filter dimension (single-value, like `CorrelationId`).
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` — map the column; add index `IX_AuditLog_Execution (ExecutionId)`.
- Create: a new EF migration under `src/ScadaLink.ConfigurationDatabase/Migrations/``AddAuditLogExecutionId``ExecutionId uniqueidentifier NULL` + the index. Additive nullable column (metadata-only ALTER, safe on the monthly-partitioned table). Mirror the existing `AddNotificationsTable` migration style.
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs``QueryAsync` translates `filter.ExecutionId` to `e.ExecutionId == value` (mirror the `CorrelationId` clause). Keyset paging untouched.
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs``QueryAsync_FilterByExecutionId`; migration smoke if the suite has that pattern.
**Approach:** purely additive. `ExecutionId` is `Guid?` everywhere. Generate the migration with `dotnet ef migrations add` against the ConfigurationDatabase project (or hand-write mirroring an existing one — match how the repo does migrations).
**Commit:** `feat(auditlog): ExecutionId column on AuditEvent + central AuditLog`
---
## Task 2: Foundation — site SQLite + gRPC DTO
**Files:**
- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — add `ExecutionId TEXT NULL` to the `auditlog.db` `AuditLog` table DDL; the insert command binds it; `MapRow` reads it back. (Site SQLite is created fresh by the writer — an additive column in the `CREATE TABLE` is enough; if the writer has any migration/ALTER path, extend it.)
- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` — add `string execution_id` to `AuditEventDto` (next free field number; additive). Rebuild regenerates the C# stubs.
- Modify: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs``ToDto`/`FromDto` map `ExecutionId``execution_id` (Guid ↔ string; empty string ↔ null, mirroring the existing `CorrelationId` handling).
- Test: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs` (column present + round-trips); `tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs` (ExecutionId round-trip incl. null).
**Commit:** `feat(auditlog): ExecutionId on site SQLite schema + gRPC AuditEventDto`
---
## Task 3: Site script-side emitters stamp `ExecutionId`
**What:** Every audit row a `ScriptRuntimeContext` emits gets `ExecutionId` = the context's per-execution id. Revert the interim "execution id in `CorrelationId` for sync rows" change so `CorrelationId` is purely per-operation again.
**Files:**
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs`:
- Rename the field `_auditCorrelationId``_executionId` (and the ctor param `auditCorrelationId``executionId`) for clarity; update XML docs. Thread it to the helpers as today.
- Sync `ApiCall` (`BuildCallAuditEvent`): set `ExecutionId = _executionId`; set `CorrelationId = null` (revert — sync one-shot calls have no operation lifecycle).
- Cached script-side rows (`CachedSubmit`, immediate `ApiCallCached`/`CachedResolve`): set `ExecutionId = _executionId`; `CorrelationId` stays `trackedId.Value`.
- `NotifySend` (`Notify.Send` emission): set `ExecutionId = _executionId`; `CorrelationId` stays the `NotificationId`.
- Modify: `src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs` + `AuditingDbCommand.cs` — thread `_executionId` (rename from the audit-correlation param); sync `DbWrite` event sets `ExecutionId = _executionId` and `CorrelationId = null`. Cached DB write rows: `ExecutionId` set, `CorrelationId` stays `trackedId`.
- Test: extend `tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs`, `DatabaseSyncEmissionTests.cs`, `ExternalSystemCachedCallEmissionTests.cs`, `DatabaseCachedWriteEmissionTests.cs`, `NotifySendAuditEmissionTests.cs`, and `ExecutionCorrelationContextTests.cs` — assert `ExecutionId` is the context's id on every row; assert sync rows now have `CorrelationId == null`; assert cached/notification rows keep their `CorrelationId`.
**Commit:** `feat(auditlog): site script-side emitters stamp ExecutionId`
---
## Task 4: Cached S&F retry-loop rows carry `ExecutionId`
**What:** Thread the execution id through the store-and-forward buffer so the retry-loop cached audit rows (`CachedCallLifecycleBridge`) carry `ExecutionId`. This same threading fixes the pre-existing `SourceScript = null` gap on those rows (identical boundary).
**Files:**
- Modify: the S&F buffered cached-call message / `StoreAndForwardMessage` (or the cached-call payload) in `src/ScadaLink.StoreAndForward/` — carry the originating execution id (and source script) alongside the call.
- Modify: `CachedCallAttemptContext` (find it — `src/ScadaLink.AuditLog/Site/Telemetry/` or StoreAndForward) — add an `ExecutionId` (and `SourceScript`) field.
- Modify: `src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs` `BuildPacket` — set `ExecutionId` from the context (and `SourceScript`, replacing the `SourceScript = null` line).
- Modify the enqueue path (`ExternalSystem.CachedCall` / `Database.CachedWrite` in `ScriptRuntimeContext`) so the execution id is written into the buffered message.
- Test: `tests/ScadaLink.AuditLog.Tests/` cached-telemetry tests + `tests/ScadaLink.StoreAndForward.Tests/` — retry-loop rows carry the originating `ExecutionId`.
**Note for implementer:** this is the deepest task — the threading touches StoreAndForward. If the buffered message can't cleanly carry the id, STOP and report before guessing.
**Commit:** `feat(auditlog): thread ExecutionId through S&F for retry-loop cached rows`
---
## Task 5: Central `NotifyDeliver` rows carry `ExecutionId`
**Files:**
- Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs` — add `Guid? OriginExecutionId`.
- Modify: `src/ScadaLink.Commons/Messages/Notification/``NotificationSubmit` carries `Guid? OriginExecutionId` (additive).
- Modify: `src/ScadaLink.ConfigurationDatabase/` — EF config + a new migration `AddNotificationOriginExecutionId` (`Notifications.OriginExecutionId uniqueidentifier NULL`).
- Modify: the site `NotifySend` forward path — the execution id (already on the `NotifySend` audit row from Task 3) also rides on the `NotificationSubmit` (set it where the submit is built — `ScriptRuntimeContext` `Notify.Send` / the S&F notification forwarder).
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` — persist `OriginExecutionId` on insert; `BuildNotifyDeliverEvent` sets `ExecutionId = notification.OriginExecutionId`.
- Test: `tests/ScadaLink.NotificationOutbox.Tests/``NotifyDeliver` rows echo `OriginExecutionId`; `tests/ScadaLink.Commons.Tests/` contract shape.
**Commit:** `feat(auditlog): NotifyDeliver rows carry the originating ExecutionId`
---
## Task 6: Inbound rows carry `ExecutionId`
**Files:**
- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs``EmitInboundAudit` sets `ExecutionId` to the request id (it already mints a `Guid.NewGuid()` for the inbound `CorrelationId` per the 2026-05-21 change; reuse that one id for `ExecutionId` — and reconsider whether the inbound row's `CorrelationId` should now be `null` to keep `CorrelationId` purely per-operation; align with the Task 3 decision: inbound is a one-shot from the audit row's perspective → `CorrelationId = null`, `ExecutionId = <request id>`).
- Test: `tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs` — inbound row carries a non-null `ExecutionId`; distinct per request.
**Commit:** `feat(auditlog): inbound audit rows carry ExecutionId`
---
## Task 7: Central UI — ExecutionId column, filter, drill-in
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — add `ExecutionId` to the column set (the grid already supports resize/reorder + a `ColumnOrder`); render it (short form / monospace).
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (+ `.razor.cs`) + `AuditQueryModel.cs` — an `ExecutionId` paste text-filter; `ToFilter` maps it to `AuditLogQueryFilter.ExecutionId`.
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs``ApplyQueryStringFilters` accepts `?executionId=<guid>`; `BuildExportUrl` emits it.
- Add a "View this execution" drill-in — a row/drilldown action linking `/audit/log?executionId=<guid>`. Mirror the existing `?correlationId=` drill-in.
- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (column renders, filter maps, query-param parsed); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (drill-in filters the grid).
Use the `frontend-design` skill for the column/filter styling.
**Commit:** `feat(centralui): ExecutionId column, filter and drill-in on the Audit Log page`
---
## Task 8: CLI + ManagementService — ExecutionId filter
**Files:**
- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` + `AuditQueryHelpers.cs``audit query --execution-id <guid>`; `AuditQueryArgs` + `BuildQueryString` emit `executionId`.
- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` `ParseFilter` — parse `executionId` query param into `AuditLogQueryFilter.ExecutionId` (lax-parse — unparseable dropped).
- Modify: `src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs` `ParseFilter` — same.
- Test: `tests/ScadaLink.CLI.Tests/`, `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`.
**Commit:** `feat(audit): ExecutionId filter in the CLI and ManagementService`
---
## Task 9: End-to-end integration test + docs
**Files:**
- Create: `tests/ScadaLink.IntegrationTests/AuditLog/ExecutionIdCorrelationTests.cs` — boot a site+central pair; run a script that does a sync `ExternalSystem.Call`, a cached call, and a `Notify.Send`; assert every resulting audit row (site + central) shares one `ExecutionId`.
- Modify: `docs/requirements/Component-AuditLog.md` — add `ExecutionId` to the schema table and a sentence on its meaning vs `CorrelationId`. (Do NOT modify `alog.md` — it is the locked v1 spec.)
- Modify: `CLAUDE.md` — one line under the Centralized Audit Log decisions noting `ExecutionId` as the universal per-run correlation value.
**Commit:** `test(auditlog): end-to-end ExecutionId correlation + docs`
---
## Final review
Dispatch a final cross-cutting review of the whole branch; full `dotnet build` + `dotnet test ScadaLink.slnx`; hand back to the user for the push/merge/redeploy decision (do not push).
## Dependency summary
0 blocks all. 2 blockedBy 1. 3 blockedBy 2. 4 blockedBy 3. 5 blockedBy 2. 6 blockedBy 2. 7 blockedBy 1. 8 blockedBy 1. 9 blockedBy 3,4,5,6,7,8. Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → final review.
@@ -0,0 +1,16 @@
{
"planPath": "docs/plans/2026-05-21-audit-executionid.md",
"tasks": [
{"id": 50, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"},
{"id": 51, "subject": "Task 1: Foundation — AuditEvent.ExecutionId + central AuditLog column + repo query", "status": "pending", "blockedBy": [50]},
{"id": 52, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [51]},
{"id": 53, "subject": "Task 3: Site script-side emitters stamp ExecutionId", "status": "pending", "blockedBy": [52]},
{"id": 54, "subject": "Task 4: Cached S&F retry-loop rows carry ExecutionId", "status": "pending", "blockedBy": [53]},
{"id": 55, "subject": "Task 5: Central NotifyDeliver rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
{"id": 56, "subject": "Task 6: Inbound audit rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
{"id": 57, "subject": "Task 7: Central UI — ExecutionId column, filter, drill-in", "status": "pending", "blockedBy": [51]},
{"id": 58, "subject": "Task 8: CLI + ManagementService — ExecutionId filter", "status": "pending", "blockedBy": [51]},
{"id": 59, "subject": "Task 9: End-to-end integration test + docs", "status": "pending", "blockedBy": [53, 54, 55, 56, 57, 58]}
],
"lastUpdated": "2026-05-21T00:00:00Z"
}
@@ -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`).
@@ -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.
@@ -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"
}
+42 -1
View File
@@ -83,6 +83,8 @@ row per lifecycle event across all channels.
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. | | `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). | | `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. | | `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. | | `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). | | `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. | | `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
@@ -102,7 +104,9 @@ row per lifecycle event across all channels.
- `IX_AuditLog_OccurredAtUtc` — primary time-range index for global scans. - `IX_AuditLog_OccurredAtUtc` — primary time-range index for global scans.
- `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters. - `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters.
- `IX_AuditLog_Correlation (CorrelationId)` — drilldown from a single operation. - `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_Channel_Status_Occurred (Channel, Status, OccurredAtUtc)` — KPI / dashboard tiles.
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X". - `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). - Monthly partitioning on `OccurredAtUtc` from day one; purge is a partition switch (see Retention & Purge).
@@ -126,6 +130,43 @@ Inbound API is intentionally collapsed to a single `InboundRequest` (or
`InboundAuthFailure` for auth rejections) row per request rather than a `InboundAuthFailure` for auth rejections) row per request rather than a
multi-event lifecycle. multi-event lifecycle.
### `ExecutionId` vs `CorrelationId`
The table carries two correlation columns at different granularities:
- **`ExecutionId`** is the *universal per-run* value: one id per script
execution (tag-change / timer-triggered or otherwise) or per inbound API
request. It is stamped on **every** audit row that run produces — the sync
`ApiCall` and `DbWrite` rows, the full cached-call lifecycle, the
`NotifySend` / `NotifyDeliver` rows, and the inbound row alike. A run that
performs no trust-boundary action emits no rows, but any run that emits
multiple rows ties them all together under one `ExecutionId`. This lets an
audit reader pull the complete trust-boundary footprint of a single script
run with one `ExecutionId` filter.
- **`CorrelationId`** is the *per-operation lifecycle* id — it groups the
multiple events of one long-running operation (`TrackedOperationId` for a
cached call, `NotificationId` for a notification, request-id for inbound
API) and is NULL for sync one-shot calls that have no operation lifecycle.
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) ## The Site-Local `AuditLog` (SQLite)
A SQLite database file on each site node, alongside the Store-and-Forward A SQLite database file on each site node, alongside the Store-and-Forward
@@ -114,12 +114,63 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
PayloadTruncated INTEGER NOT NULL, PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL, Extra TEXT NULL,
ForwardState TEXT NOT NULL, ForwardState TEXT NOT NULL,
ExecutionId TEXT NULL,
ParentExecutionId TEXT NULL,
PRIMARY KEY (EventId) PRIMARY KEY (EventId)
); );
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc); ON AuditLog (ForwardState, OccurredAtUtc);
"""; """;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
// Audit Log #23 (ExecutionId): additively add the ExecutionId column.
// CREATE TABLE IF NOT EXISTS above does NOT add columns to an AuditLog
// table that already exists from a pre-ExecutionId build, so an
// auditlog.db created by an older build needs the column ALTER-ed in.
// The file is durable across restart/failover by design (7-day
// retention), so without this step every WriteAsync on an upgraded
// deployment would bind $ExecutionId against a missing column and the
// best-effort write path would silently drop every site audit row.
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
// probed first and the ALTER skipped when already there. The column is
// 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: 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
/// here to match the rest of this writer's bootstrap DDL.
/// </summary>
private void AddColumnIfMissing(string columnName, string columnDefinition)
{
using var probe = _connection.CreateCommand();
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
probe.Parameters.AddWithValue("$name", columnName);
var exists = Convert.ToInt32(probe.ExecuteScalar()) > 0;
if (exists)
{
return;
}
using var alter = _connection.CreateCommand();
// Column name + definition are caller-controlled constants, never user
// input — safe to interpolate (parameters are not permitted in DDL).
alter.CommandText = $"ALTER TABLE AuditLog ADD COLUMN {columnName} {columnDefinition}";
alter.ExecuteNonQuery();
} }
/// <summary> /// <summary>
@@ -221,12 +272,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
EventId, OccurredAtUtc, Channel, Kind, CorrelationId, EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId, ParentExecutionId
) VALUES ( ) VALUES (
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId, $EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId,
$SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target, $SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target,
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail, $Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState $RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState,
$ExecutionId, $ParentExecutionId
); );
"""; """;
@@ -250,6 +303,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer); var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer);
var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text); var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text);
var pForwardState = cmd.Parameters.Add("$ForwardState", 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) foreach (var pending in batch)
{ {
@@ -274,6 +329,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0; pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
pExtra.Value = (object?)e.Extra ?? DBNull.Value; pExtra.Value = (object?)e.Extra ?? DBNull.Value;
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString(); pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value;
pParentExecutionId.Value = (object?)e.ParentExecutionId?.ToString() ?? DBNull.Value;
try try
{ {
@@ -331,7 +388,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId, ParentExecutionId
FROM AuditLog FROM AuditLog
WHERE ForwardState = $pending WHERE ForwardState = $pending
ORDER BY OccurredAtUtc ASC, EventId ASC ORDER BY OccurredAtUtc ASC, EventId ASC
@@ -379,7 +437,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId, ParentExecutionId
FROM AuditLog FROM AuditLog
WHERE ForwardState = $forwarded WHERE ForwardState = $forwarded
ORDER BY OccurredAtUtc ASC, EventId ASC ORDER BY OccurredAtUtc ASC, EventId ASC
@@ -465,7 +524,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId, SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId, ParentExecutionId
FROM AuditLog FROM AuditLog
WHERE ForwardState IN ($pending, $forwarded) WHERE ForwardState IN ($pending, $forwarded)
AND OccurredAtUtc >= $since AND OccurredAtUtc >= $since
@@ -642,6 +702,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
PayloadTruncated = reader.GetInt32(17) != 0, PayloadTruncated = reader.GetInt32(17) != 0,
Extra = reader.IsDBNull(18) ? null : reader.GetString(18), Extra = reader.IsDBNull(18) ? null : reader.GetString(18),
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(19)), 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)),
}; };
} }
@@ -133,9 +133,23 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
Channel = channel, Channel = channel,
Kind = kind, Kind = kind,
CorrelationId = context.TrackedOperationId.Value, CorrelationId = context.TrackedOperationId.Value,
// Audit Log #23 (ExecutionId Task 4): the originating script
// 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, SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
SourceInstanceId = context.SourceInstanceId, SourceInstanceId = context.SourceInstanceId,
SourceScript = null, // Not threaded through S&F; left null on retry-loop rows. // Audit Log #23 (ExecutionId Task 4): SourceScript is now
// threaded through the S&F buffer alongside ExecutionId — the
// retry-loop cached rows carry the same provenance the
// script-side cached rows do. Null on pre-Task-4 buffered rows.
SourceScript = context.SourceScript,
Target = context.Target, Target = context.Target,
Status = status, Status = status,
HttpStatus = httpStatus, HttpStatus = httpStatus,
@@ -59,6 +59,8 @@ public static class AuditCommands
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" }; var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" }; var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" }; 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 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)" }; var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
pageSizeOption.DefaultValueFactory = _ => 100; pageSizeOption.DefaultValueFactory = _ => 100;
@@ -74,6 +76,8 @@ public static class AuditCommands
cmd.Add(targetOption); cmd.Add(targetOption);
cmd.Add(actorOption); cmd.Add(actorOption);
cmd.Add(correlationIdOption); cmd.Add(correlationIdOption);
cmd.Add(executionIdOption);
cmd.Add(parentExecutionIdOption);
cmd.Add(errorsOnlyOption); cmd.Add(errorsOnlyOption);
cmd.Add(pageSizeOption); cmd.Add(pageSizeOption);
cmd.Add(allOption); cmd.Add(allOption);
@@ -101,6 +105,8 @@ public static class AuditCommands
Target = result.GetValue(targetOption), Target = result.GetValue(targetOption),
Actor = result.GetValue(actorOption), Actor = result.GetValue(actorOption),
CorrelationId = result.GetValue(correlationIdOption), CorrelationId = result.GetValue(correlationIdOption),
ExecutionId = result.GetValue(executionIdOption),
ParentExecutionId = result.GetValue(parentExecutionIdOption),
ErrorsOnly = result.GetValue(errorsOnlyOption), ErrorsOnly = result.GetValue(errorsOnlyOption),
PageSize = result.GetValue(pageSizeOption), PageSize = result.GetValue(pageSizeOption),
}; };
@@ -24,6 +24,8 @@ public sealed class AuditQueryArgs
public string? Target { get; set; } public string? Target { get; set; }
public string? Actor { get; set; } public string? Actor { get; set; }
public string? CorrelationId { get; set; } public string? CorrelationId { get; set; }
public string? ExecutionId { get; set; }
public string? ParentExecutionId { get; set; }
public bool ErrorsOnly { get; set; } public bool ErrorsOnly { get; set; }
public int PageSize { get; set; } = 100; public int PageSize { get; set; } = 100;
} }
@@ -125,6 +127,8 @@ public static class AuditQueryHelpers
Add("target", args.Target); Add("target", args.Target);
Add("actor", args.Actor); Add("actor", args.Actor);
Add("correlationId", args.CorrelationId); Add("correlationId", args.CorrelationId);
Add("executionId", args.ExecutionId);
Add("parentExecutionId", args.ParentExecutionId);
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture)); Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
if (afterOccurredAtUtc.HasValue) if (afterOccurredAtUtc.HasValue)
@@ -105,6 +105,20 @@ public static class AuditExportEndpoints
correlationId = parsedCorr; correlationId = parsedCorr;
} }
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
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? fromUtc = ParseUtcDate(query, "from");
DateTime? toUtc = ParseUtcDate(query, "to"); DateTime? toUtc = ParseUtcDate(query, "to");
@@ -116,6 +130,8 @@ public static class AuditExportEndpoints
Target: target, Target: target,
Actor: actor, Actor: actor,
CorrelationId: correlationId, CorrelationId: correlationId,
ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: fromUtc, FromUtc: fromUtc,
ToUtc: toUtc); ToUtc: toUtc);
} }
@@ -55,6 +55,12 @@
<dt class="col-4 text-muted fw-normal">CorrelationId</dt> <dt class="col-4 text-muted fw-normal">CorrelationId</dt>
<dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd> <dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd>
<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> <dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd> <dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
@@ -151,6 +157,30 @@
Show all events for this operation Show all events for this operation
</button> </button>
} }
@if (Event.ExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-this-execution"
@onclick="ViewThisExecution">
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" <button class="btn btn-primary btn-sm ms-auto"
data-test="drawer-close-footer" data-test="drawer-close-footer"
@onclick="HandleClose"> @onclick="HandleClose">
@@ -47,9 +47,13 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <para> /// <para>
/// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set, /// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set,
/// the "Show all events" button navigates to /// the "Show all events" button navigates to
/// <c>/audit/log?correlationId={id}</c>. The parent page does not /// <c>/audit/log?correlationId={id}</c>. Likewise, when
/// auto-apply that filter today — it is a deep link the page can use /// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
/// when Bundle D wires up query-string deserialization. /// 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> /// </para>
/// </summary> /// </summary>
public partial class AuditDrilldownDrawer public partial class AuditDrilldownDrawer
@@ -276,6 +280,51 @@ public partial class AuditDrilldownDrawer
Navigation.NavigateTo(uri); Navigation.NavigateTo(uri);
} }
/// <summary>
/// Drill-in to every audit row sharing this row's <see cref="AuditEvent.ExecutionId"/>
/// — the universal per-run correlation value, distinct from the per-operation
/// CorrelationId drill-back above. Navigates to <c>/audit/log?executionId={id}</c>,
/// which the page parses on init and auto-loads. The button is only rendered
/// when <see cref="AuditEvent.ExecutionId"/> is non-null, so this is total.
/// </summary>
private void ViewThisExecution()
{
if (Event?.ExecutionId is not { } exec) return;
var uri = $"/audit/log?executionId={exec}";
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> /// <summary>
/// Build a cURL command from an audit event. The URL comes from /// Build a cURL command from an audit event. The URL comes from
/// <c>Target</c>; when the RequestSummary parses as /// <c>Target</c>; when the RequestSummary parses as
@@ -117,6 +117,26 @@
placeholder="contains…" @bind="_model.ActorSearch" /> placeholder="contains…" @bind="_model.ActorSearch" />
</div> </div>
@* ExecutionId is an exact-match Guid filter — the operator pastes the
universal per-run correlation value. Lax-parsed in ToFilter so a
blank/malformed paste simply drops the constraint. *@
<div class="col-auto" data-test="filter-execution-id">
<label class="form-label small mb-1" for="audit-execution-id">Execution ID</label>
<input id="audit-execution-id" type="text"
class="form-control form-control-sm font-monospace"
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="col-auto" data-test="filter-errors-only">
<div class="form-check mb-1"> <div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="audit-errors-only" <input class="form-check-input" type="checkbox" id="audit-errors-only"
@@ -135,6 +135,8 @@ public partial class AuditFilterBar
_model.ScriptSearch = string.Empty; _model.ScriptSearch = string.Empty;
_model.TargetSearch = string.Empty; _model.TargetSearch = string.Empty;
_model.ActorSearch = string.Empty; _model.ActorSearch = string.Empty;
_model.ExecutionId = string.Empty;
_model.ParentExecutionId = string.Empty;
_model.ErrorsOnly = false; _model.ErrorsOnly = false;
} }
@@ -47,6 +47,23 @@ public sealed class AuditQueryModel
public string TargetSearch { get; set; } = string.Empty; public string TargetSearch { get; set; } = string.Empty;
public string ActorSearch { get; set; } = string.Empty; public string ActorSearch { get; set; } = string.Empty;
/// <summary>
/// Paste-in ExecutionId filter — the operator pastes the universal per-run
/// correlation Guid. 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.
/// </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; } public bool ErrorsOnly { get; set; }
/// <summary> /// <summary>
@@ -114,6 +131,17 @@ public sealed class AuditQueryModel
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow); var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
// Lax-parse the pasted ExecutionId — blank or malformed text yields no
// constraint rather than an error, mirroring the optional-filter contract.
Guid? executionId = Guid.TryParse(ExecutionId, out var parsedExecutionId)
? parsedExecutionId
: null;
// Same lax-parse contract for the pasted ParentExecutionId.
Guid? parentExecutionId = Guid.TryParse(ParentExecutionId, out var parsedParentExecutionId)
? parsedParentExecutionId
: null;
return new AuditLogQueryFilter( return new AuditLogQueryFilter(
Channels: Channels.Count > 0 ? Channels.ToArray() : null, Channels: Channels.Count > 0 ? Channels.ToArray() : null,
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null, Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
@@ -122,6 +150,8 @@ public sealed class AuditQueryModel
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(), Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(), Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
CorrelationId: null, CorrelationId: null,
ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: fromUtc, FromUtc: fromUtc,
ToUtc: toUtc); ToUtc: toUtc);
} }
@@ -83,6 +83,15 @@
</div> </div>
@code { @code {
// Compact display for Guid id columns: the first 8 hex digits, mirroring
// the drilldown drawer's ShortEventId presentation. The full value is kept
// in the cell's title attribute so it stays copy-paste accessible.
private static string ShortGuid(Guid value)
{
var n = value.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
private RenderFragment RenderCell(string key, AuditEvent row) => __builder => private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
{ {
switch (key) switch (key)
@@ -111,6 +120,30 @@
case "Actor": case "Actor":
<span class="small">@(row.Actor ?? "—")</span> <span class="small">@(row.Actor ?? "—")</span>
break; break;
case "ExecutionId":
@if (row.ExecutionId is { } executionId)
{
<span class="small font-monospace"
data-test="execution-id-@row.EventId"
title="@executionId">@ShortGuid(executionId)</span>
}
else
{
<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": case "DurationMs":
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span> <span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
break; break;
@@ -9,9 +9,11 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <summary> /// <summary>
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3). /// Keyset-paged results grid for the central Audit Log page (#23 M7-T3).
/// Renders the 10 columns named in Component-AuditLog.md §10: /// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc,
/// OccurredAtUtc, Site, Channel, Kind, Status, Target, Actor, DurationMs, /// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus,
/// HttpStatus, ErrorMessage. Talks to <see cref="Services.IAuditLogQueryService"/> /// 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 /// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data
/// source without standing up EF Core. /// source without standing up EF Core.
/// ///
@@ -121,6 +123,8 @@ public partial class AuditResultsGrid : IAsyncDisposable
("Status", "Status"), ("Status", "Status"),
("Target", "Target"), ("Target", "Target"),
("Actor", "Actor"), ("Actor", "Actor"),
("ExecutionId", "ExecutionId"),
("ParentExecutionId", "ParentExecutionId"),
("DurationMs", "DurationMs"), ("DurationMs", "DurationMs"),
("HttpStatus", "HttpStatus"), ("HttpStatus", "HttpStatus"),
("ErrorMessage", "ErrorMessage"), ("ErrorMessage", "ErrorMessage"),
@@ -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>
@@ -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);
}
@@ -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;
}
@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.WebUtilities; using Microsoft.AspNetCore.WebUtilities;
using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Audit; using ScadaLink.Commons.Types.Audit;
@@ -22,14 +23,29 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only /// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends /// <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 /// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
/// drill in to <c>?status=Failed</c>. When any param is present we allocate a /// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds
/// <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 /// fresh <see cref="AuditLogQueryFilter"/> and assign it to
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load /// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name) /// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
/// are silently dropped — the page still renders, just without that constraint. /// are silently dropped — the page still renders, just without that constraint.
/// </para> /// </para>
///
/// <para>
/// Query-string filters are re-applied on every <see cref="NavigationManager.LocationChanged"/>,
/// not just on init. The drilldown drawer's "View this/parent execution" actions
/// navigate to <c>/audit/log?executionId=…</c> while the user is ALREADY on this
/// routed page — Blazor treats that as a same-component navigation, so
/// <see cref="OnInitialized"/> does not re-run. Without the
/// <see cref="NavigationManager.LocationChanged"/> subscription the URL would
/// change but <see cref="_currentFilter"/> would stay stale and the grid would
/// never reload to the new drill-in. The subscription is disposed via
/// <see cref="IDisposable"/>.
/// </para>
/// </summary> /// </summary>
public partial class AuditLogPage public partial class AuditLogPage : IDisposable
{ {
[Inject] private NavigationManager Navigation { get; set; } = null!; [Inject] private NavigationManager Navigation { get; set; } = null!;
@@ -41,6 +57,33 @@ public partial class AuditLogPage
protected override void OnInitialized() protected override void OnInitialized()
{ {
ApplyQueryStringFilters(); ApplyQueryStringFilters();
Navigation.LocationChanged += HandleLocationChanged;
}
/// <summary>
/// Re-applies the query-string drill-in filters when the URL changes while
/// this page stays routed (e.g. the drawer's "View parent execution" action
/// navigates to <c>/audit/log?executionId=…</c>). Reassigning
/// <see cref="_currentFilter"/> to a fresh instance is what kicks the results
/// grid into reloading; we also close the drawer so the operator sees the
/// newly filtered grid. The body is marshalled through
/// <see cref="ComponentBase.InvokeAsync(Action)"/> because
/// <see cref="NavigationManager.LocationChanged"/> can fire off the renderer's
/// synchronization context.
/// </summary>
private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
{
_ = InvokeAsync(() =>
{
ApplyQueryStringFilters();
_drawerOpen = false;
StateHasChanged();
});
}
public void Dispose()
{
Navigation.LocationChanged -= HandleLocationChanged;
} }
private void ApplyQueryStringFilters() private void ApplyQueryStringFilters()
@@ -48,6 +91,10 @@ public partial class AuditLogPage
var uri = Navigation.ToAbsoluteUri(Navigation.Uri); var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
var query = QueryHelpers.ParseQuery(uri.Query); var query = QueryHelpers.ParseQuery(uri.Query);
// A paramless navigation (e.g. clicking the "Audit Log" nav link while
// already here) intentionally preserves the last applied filter rather
// than clearing the grid: this method is a drill-in mechanism and every
// drill-in carries query params. The operator clears via the filter bar.
if (query.Count == 0) if (query.Count == 0)
{ {
return; return;
@@ -60,6 +107,25 @@ public partial class AuditLogPage
correlationId = parsedCorr; correlationId = parsedCorr;
} }
// ?executionId= is the "View this execution" drill-in target — the
// universal per-run correlation value. Lax-parsed like ?correlationId=:
// an unparseable value is silently dropped (no constraint).
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
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; string? target = null;
if (query.TryGetValue("target", out var targetValues)) if (query.TryGetValue("target", out var targetValues))
{ {
@@ -117,7 +183,8 @@ public partial class AuditLogPage
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load // 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 // because the filter contract has no instance column — the user still needs
// to refine + Apply for those. // to refine + Apply for those.
if (correlationId 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) && sites is null && channels is null && kinds is null && statuses is null)
{ {
return; return;
@@ -130,7 +197,9 @@ public partial class AuditLogPage
SourceSiteIds: sites, SourceSiteIds: sites,
Target: target, Target: target,
Actor: actor, Actor: actor,
CorrelationId: correlationId); CorrelationId: correlationId,
ExecutionId: executionId,
ParentExecutionId: parentExecutionId);
} }
/// <summary> /// <summary>
@@ -236,6 +305,14 @@ public partial class AuditLogPage
{ {
parts.Add(new("correlationId", corr.ToString())); parts.Add(new("correlationId", corr.ToString()));
} }
if (filter.ExecutionId is { } exec)
{
parts.Add(new("executionId", exec.ToString()));
}
if (filter.ParentExecutionId is { } parentExec)
{
parts.Add(new("parentExecutionId", parentExec.ToString()));
}
if (filter.FromUtc is { } from) if (filter.FromUtc is { } from)
{ {
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture))); parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));
@@ -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>
@@ -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;
}
}
}
@@ -132,4 +132,23 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
return repoSnapshot with { BacklogTotal = backlog }; 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);
}
} }
@@ -50,4 +50,23 @@ public interface IAuditLogQueryService
/// dashboard. /// dashboard.
/// </remarks> /// </remarks>
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default); 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);
} }
@@ -26,6 +26,20 @@ public sealed record AuditEvent
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary> /// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
public Guid? CorrelationId { get; init; } public Guid? CorrelationId { get; init; }
/// <summary>
/// Id of the originating script execution / inbound request — the universal
/// per-run correlation value, distinct from <see cref="CorrelationId"/> (which
/// is the per-operation lifecycle id).
/// </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> /// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; } public string? SourceSiteId { get; init; }
@@ -27,6 +27,24 @@ public class Notification
public string SourceSiteId { get; set; } public string SourceSiteId { get; set; }
public string? SourceInstanceId { get; set; } public string? SourceInstanceId { get; set; }
public string? SourceScript { get; set; } public string? SourceScript { get; set; }
/// <summary>
/// The originating script execution's <c>ExecutionId</c> (Audit Log #23). Carried from
/// the site on the <see cref="Commons.Messages.Notification.NotificationSubmit"/> so the
/// central dispatcher can stamp the same id onto its <c>NotifyDeliver</c> audit rows,
/// correlating them with the site-emitted <c>NotifySend</c> row. Null for notifications
/// 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; } public DateTimeOffset SiteEnqueuedAt { get; set; }
/// <summary>Central ingest time.</summary> /// <summary>Central ingest time.</summary>
@@ -134,4 +134,45 @@ public interface IAuditLogRepository
TimeSpan window, TimeSpan window,
DateTime? nowUtc = null, DateTime? nowUtc = null,
CancellationToken ct = default); 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);
} }
@@ -57,6 +57,30 @@ public interface ICachedCallLifecycleObserver
/// <param name="OccurredAtUtc">When this attempt completed.</param> /// <param name="OccurredAtUtc">When this attempt completed.</param>
/// <param name="DurationMs">Duration of the attempt in milliseconds (null when not measured).</param> /// <param name="DurationMs">Duration of the attempt in milliseconds (null when not measured).</param>
/// <param name="SourceInstanceId">Originating instance, when known.</param> /// <param name="SourceInstanceId">Originating instance, when known.</param>
/// <param name="ExecutionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id, threaded through the store-and-forward buffer from
/// the cached-call enqueue path. The audit bridge stamps it onto the
/// retry-loop <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted and
/// <c>CachedResolve</c> rows so they correlate with the rest of the run.
/// <c>null</c> for rows buffered before Task 4 (back-compat).
/// </param>
/// <param name="SourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded alongside <paramref name="ExecutionId"/> so the retry-loop audit
/// 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( public sealed record CachedCallAttemptContext(
TrackedOperationId TrackedOperationId, TrackedOperationId TrackedOperationId,
string Channel, string Channel,
@@ -69,7 +93,10 @@ public sealed record CachedCallAttemptContext(
DateTime CreatedAtUtc, DateTime CreatedAtUtc,
DateTime OccurredAtUtc, DateTime OccurredAtUtc,
int? DurationMs, int? DurationMs,
string? SourceInstanceId); string? SourceInstanceId,
Guid? ExecutionId = null,
string? SourceScript = null,
Guid? ParentExecutionId = null);
/// <summary> /// <summary>
/// Coarse outcome of one cached-call delivery attempt, observed from inside /// Coarse outcome of one cached-call delivery attempt, observed from inside
@@ -29,11 +29,33 @@ public interface IDatabaseGateway
/// <c>null</c> — when omitted the S&amp;F engine mints a fresh GUID and no /// <c>null</c> — when omitted the S&amp;F engine mints a fresh GUID and no
/// M3 telemetry is correlated (pre-M3 caller behaviour). /// M3 telemetry is correlated (pre-M3 caller behaviour).
/// </param> /// </param>
/// <param name="executionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id. When the write is buffered on a transient
/// failure this is threaded onto the S&amp;F message so the retry-loop
/// cached-write audit rows carry it. <c>null</c> when not threaded.
/// </param>
/// <param name="sourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// 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( Task CachedWriteAsync(
string connectionName, string connectionName,
string sql, string sql,
IReadOnlyDictionary<string, object?>? parameters = null, IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null, string? originInstanceName = null,
CancellationToken cancellationToken = default, CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null); TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null,
Guid? parentExecutionId = null);
} }
@@ -30,13 +30,35 @@ public interface IExternalSystemClient
/// M3 telemetry is correlated (the legacy behaviour pre-M3 callers rely /// M3 telemetry is correlated (the legacy behaviour pre-M3 callers rely
/// on). /// on).
/// </param> /// </param>
/// <param name="executionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id. When the call is buffered on a transient
/// failure this is threaded onto the S&amp;F message so the retry-loop
/// cached-call audit rows carry it. <c>null</c> when not threaded.
/// </param>
/// <param name="sourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// 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( Task<ExternalCallResult> CachedCallAsync(
string systemName, string systemName,
string methodName, string methodName,
IReadOnlyDictionary<string, object?>? parameters = null, IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null, string? originInstanceName = null,
CancellationToken cancellationToken = default, CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null); TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null,
Guid? parentExecutionId = null);
} }
/// <summary> /// <summary>
@@ -4,12 +4,21 @@ namespace ScadaLink.Commons.Messages.InboundApi;
/// Request routed from Inbound API to a site to invoke a script on an instance. /// Request routed from Inbound API to a site to invoke a script on an instance.
/// Used by Route.To("instanceCode").Call("scriptName", params). /// Used by Route.To("instanceCode").Call("scriptName", params).
/// </summary> /// </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( public record RouteToCallRequest(
string CorrelationId, string CorrelationId,
string InstanceUniqueName, string InstanceUniqueName,
string ScriptName, string ScriptName,
IReadOnlyDictionary<string, object?>? Parameters, IReadOnlyDictionary<string, object?>? Parameters,
DateTimeOffset Timestamp); DateTimeOffset Timestamp,
Guid? ParentExecutionId = null);
/// <summary> /// <summary>
/// Response from a Route.To() call. /// Response from a Route.To() call.
@@ -4,6 +4,20 @@ namespace ScadaLink.Commons.Messages.Notification;
/// Site -> Central: submit a notification for central delivery. /// Site -> Central: submit a notification for central delivery.
/// Fire-and-forget with ack; the site retries until a <see cref="NotificationSubmitAck"/> is received. /// Fire-and-forget with ack; the site retries until a <see cref="NotificationSubmitAck"/> is received.
/// </summary> /// </summary>
/// <param name="OriginExecutionId">
/// The originating script execution's <c>ExecutionId</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 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( public record NotificationSubmit(
string NotificationId, string NotificationId,
string ListName, string ListName,
@@ -12,7 +26,9 @@ public record NotificationSubmit(
string SourceSiteId, string SourceSiteId,
string? SourceInstanceId, string? SourceInstanceId,
string? SourceScript, string? SourceScript,
DateTimeOffset SiteEnqueuedAt); DateTimeOffset SiteEnqueuedAt,
Guid? OriginExecutionId = null,
Guid? OriginParentExecutionId = null);
/// <summary> /// <summary>
/// Central -> Site: ack sent after the notification row is persisted. /// Central -> Site: ack sent after the notification row is persisted.
@@ -1,7 +1,17 @@
namespace ScadaLink.Commons.Messages.ScriptExecution; 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( public record ScriptCallRequest(
string ScriptName, string ScriptName,
IReadOnlyDictionary<string, object?>? Parameters, IReadOnlyDictionary<string, object?>? Parameters,
int CurrentCallDepth, int CurrentCallDepth,
string CorrelationId); string CorrelationId,
Guid? ParentExecutionId = null);
@@ -11,7 +11,9 @@ namespace ScadaLink.Commons.Types.Audit;
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in /// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is /// 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> /// 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. /// respectively. All filter dimensions are AND-combined with one another. The
/// single-value <see cref="CorrelationId"/>, <see cref="ExecutionId"/> and
/// <see cref="ParentExecutionId"/> dimensions constrain on equality when set.
/// </summary> /// </summary>
public sealed record AuditLogQueryFilter( public sealed record AuditLogQueryFilter(
IReadOnlyList<AuditChannel>? Channels = null, IReadOnlyList<AuditChannel>? Channels = null,
@@ -21,5 +23,7 @@ public sealed record AuditLogQueryFilter(
string? Target = null, string? Target = null,
string? Actor = null, string? Actor = null,
Guid? CorrelationId = null, Guid? CorrelationId = null,
Guid? ExecutionId = null,
Guid? ParentExecutionId = null,
DateTime? FromUtc = null, DateTime? FromUtc = null,
DateTime? ToUtc = null); DateTime? ToUtc = null);
@@ -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);
@@ -47,6 +47,8 @@ public static class AuditEventDtoMapper
Channel = evt.Channel.ToString(), Channel = evt.Channel.ToString(),
Kind = evt.Kind.ToString(), Kind = evt.Kind.ToString(),
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty, CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty,
ParentExecutionId = evt.ParentExecutionId?.ToString() ?? string.Empty,
SourceSiteId = evt.SourceSiteId ?? string.Empty, SourceSiteId = evt.SourceSiteId ?? string.Empty,
SourceInstanceId = evt.SourceInstanceId ?? string.Empty, SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
SourceScript = evt.SourceScript ?? string.Empty, SourceScript = evt.SourceScript ?? string.Empty,
@@ -92,6 +94,8 @@ public static class AuditEventDtoMapper
Channel = Enum.Parse<AuditChannel>(dto.Channel), Channel = Enum.Parse<AuditChannel>(dto.Channel),
Kind = Enum.Parse<AuditKind>(dto.Kind), Kind = Enum.Parse<AuditKind>(dto.Kind),
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null, 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), SourceSiteId = NullIfEmpty(dto.SourceSiteId),
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId), SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
SourceScript = NullIfEmpty(dto.SourceScript), SourceScript = NullIfEmpty(dto.SourceScript),
@@ -91,6 +91,8 @@ message AuditEventDto {
string response_summary = 17; string response_summary = 17;
bool payload_truncated = 18; bool payload_truncated = 18;
string extra = 19; 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; } message AuditEventBatch { repeated AuditEventDto events = 1; }
@@ -41,7 +41,7 @@ namespace ScadaLink.Communication.Grpc {
"c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy", "c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy",
"aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90", "aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90",
"b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs", "b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs",
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAki9QMKDUF1ZGl0RXZlbnRE", "YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiqAQKDUF1ZGl0RXZlbnRE",
"dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL", "dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL",
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ", "MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ",
"EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291", "EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291",
@@ -52,43 +52,44 @@ namespace ScadaLink.Communication.Grpc {
"GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf", "GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf",
"bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz", "bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz",
"dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR", "dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR",
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkiPAoPQXVk", "cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhl",
"aXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVk", "Y3V0aW9uX2lkGBQgASgJEhsKE3BhcmVudF9leGVjdXRpb25faWQYFSABKAki",
"aXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRz", "PAoPQXVkaXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJl",
"GAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRyYWNrZWRf", "YW0uQXVkaXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZl",
"b3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoGdGFyZ2V0", "bnRfaWRzGAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRy",
"GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgFIAEoCRIT", "YWNrZWRfb3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoG",
"CgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJEjAKC2h0", "dGFyZ2V0GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgF",
"dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUS", "IAEoCRITCgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJ",
"MgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGlt", "EjAKC2h0dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMy",
"ZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xlLnByb3Rv", "VmFsdWUSMgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9i",
"YnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsyGi5nb29n", "dWYuVGltZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xl",
"bGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0cnlQYWNr", "LnByb3RvYnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsy",
"ZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZl", "Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0",
"bnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFtLlNpdGVD", "cnlQYWNrZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1",
"YWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gSMgoH", "ZGl0RXZlbnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFt",
"cGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5UGFj", "LlNpdGVDYWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0",
"a2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2VfdXRjGAEg", "Y2gSMgoHcGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1l",
"ASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRjaF9zaXpl", "dHJ5UGFja2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2Vf",
"GAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2ZW50cxgB", "dXRjGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRj",
"IAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3JlX2F2YWls", "aF9zaXplGAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2",
"YWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVDSUZJRUQQ", "ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3Jl",
"ABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJThACEg8K", "X2F2YWlsYWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVD",
"C1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxBUk1fU1RB", "SUZJRUQQABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJ",
"VEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQARIWChJB", "ThACEg8KC1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxB",
"TEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0SFAoQQUxB", "Uk1fU1RBVEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQ",
"Uk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcKE0FMQVJN", "ARIWChJBTEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0S",
"X0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMSGQoVQUxB", "FAoQQUxBUk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcK",
"Uk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2aWNlElUK", "E0FMQVJNX0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMS",
"EVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5jZVN0cmVh", "GQoVQUxBUk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2",
"bVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDABEkcKEUlu", "aWNlElUKEVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5j",
"Z2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50QmF0Y2ga", "ZVN0cmVhbVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDAB",
"FS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRUZWxlbWV0", "EkcKEUluZ2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50",
"cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUuc2l0ZXN0", "QmF0Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRU",
"cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0ZXN0cmVh", "ZWxlbWV0cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUu",
"bS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5QdWxsQXVk", "c2l0ZXN0cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0",
"aXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmljYXRpb24u", "ZXN0cmVhbS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5Q",
"R3JwY2IGcHJvdG8z")); "dWxsQXVkaXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmlj",
"YXRpb24uR3JwY2IGcHJvdG8z"));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, }, 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[] { 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.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.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.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" }, 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.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.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), 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),
@@ -1591,6 +1592,8 @@ namespace ScadaLink.Communication.Grpc {
responseSummary_ = other.responseSummary_; responseSummary_ = other.responseSummary_;
payloadTruncated_ = other.payloadTruncated_; payloadTruncated_ = other.payloadTruncated_;
extra_ = other.extra_; extra_ = other.extra_;
executionId_ = other.executionId_;
parentExecutionId_ = other.parentExecutionId_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
} }
@@ -1838,6 +1841,36 @@ namespace ScadaLink.Communication.Grpc {
} }
} }
/// <summary>Field number for the "execution_id" field.</summary>
public const int ExecutionIdFieldNumber = 20;
private string executionId_ = "";
/// <summary>
/// empty string represents null
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string ExecutionId {
get { return executionId_; }
set {
executionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <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.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) { public override bool Equals(object other) {
@@ -1872,6 +1905,8 @@ namespace ScadaLink.Communication.Grpc {
if (ResponseSummary != other.ResponseSummary) return false; if (ResponseSummary != other.ResponseSummary) return false;
if (PayloadTruncated != other.PayloadTruncated) return false; if (PayloadTruncated != other.PayloadTruncated) return false;
if (Extra != other.Extra) 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); return Equals(_unknownFields, other._unknownFields);
} }
@@ -1898,6 +1933,8 @@ namespace ScadaLink.Communication.Grpc {
if (ResponseSummary.Length != 0) hash ^= ResponseSummary.GetHashCode(); if (ResponseSummary.Length != 0) hash ^= ResponseSummary.GetHashCode();
if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode(); if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode();
if (Extra.Length != 0) hash ^= Extra.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) { if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode(); hash ^= _unknownFields.GetHashCode();
} }
@@ -1990,6 +2027,14 @@ namespace ScadaLink.Communication.Grpc {
output.WriteRawTag(154, 1); output.WriteRawTag(154, 1);
output.WriteString(Extra); output.WriteString(Extra);
} }
if (ExecutionId.Length != 0) {
output.WriteRawTag(162, 1);
output.WriteString(ExecutionId);
}
if (ParentExecutionId.Length != 0) {
output.WriteRawTag(170, 1);
output.WriteString(ParentExecutionId);
}
if (_unknownFields != null) { if (_unknownFields != null) {
_unknownFields.WriteTo(output); _unknownFields.WriteTo(output);
} }
@@ -2074,6 +2119,14 @@ namespace ScadaLink.Communication.Grpc {
output.WriteRawTag(154, 1); output.WriteRawTag(154, 1);
output.WriteString(Extra); output.WriteString(Extra);
} }
if (ExecutionId.Length != 0) {
output.WriteRawTag(162, 1);
output.WriteString(ExecutionId);
}
if (ParentExecutionId.Length != 0) {
output.WriteRawTag(170, 1);
output.WriteString(ParentExecutionId);
}
if (_unknownFields != null) { if (_unknownFields != null) {
_unknownFields.WriteTo(ref output); _unknownFields.WriteTo(ref output);
} }
@@ -2141,6 +2194,12 @@ namespace ScadaLink.Communication.Grpc {
if (Extra.Length != 0) { if (Extra.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(Extra); size += 2 + pb::CodedOutputStream.ComputeStringSize(Extra);
} }
if (ExecutionId.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(ExecutionId);
}
if (ParentExecutionId.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(ParentExecutionId);
}
if (_unknownFields != null) { if (_unknownFields != null) {
size += _unknownFields.CalculateSize(); size += _unknownFields.CalculateSize();
} }
@@ -2217,6 +2276,12 @@ namespace ScadaLink.Communication.Grpc {
if (other.Extra.Length != 0) { if (other.Extra.Length != 0) {
Extra = other.Extra; Extra = other.Extra;
} }
if (other.ExecutionId.Length != 0) {
ExecutionId = other.ExecutionId;
}
if (other.ParentExecutionId.Length != 0) {
ParentExecutionId = other.ParentExecutionId;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
} }
@@ -2321,6 +2386,14 @@ namespace ScadaLink.Communication.Grpc {
Extra = input.ReadString(); Extra = input.ReadString();
break; break;
} }
case 162: {
ExecutionId = input.ReadString();
break;
}
case 170: {
ParentExecutionId = input.ReadString();
break;
}
} }
} }
#endif #endif
@@ -2425,6 +2498,14 @@ namespace ScadaLink.Communication.Grpc {
Extra = input.ReadString(); Extra = input.ReadString();
break; break;
} }
case 162: {
ExecutionId = input.ReadString();
break;
}
case 170: {
ParentExecutionId = input.ReadString();
break;
}
} }
} }
} }
@@ -89,6 +89,14 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
.HasFilter("[CorrelationId] IS NOT NULL") .HasFilter("[CorrelationId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_CorrelationId"); .HasDatabaseName("IX_AuditLog_CorrelationId");
builder.HasIndex(e => e.ExecutionId)
.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 }) builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
.IsDescending(false, false, true) .IsDescending(false, false, true)
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
@@ -47,6 +47,14 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
builder.Property(n => n.SourceScript).HasMaxLength(200); builder.Property(n => n.SourceScript).HasMaxLength(200);
// OriginExecutionId (Audit Log #23): nullable uniqueidentifier carried from the
// 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.Status, n.NextAttemptAt });
builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt }); builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt });
@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Adds the universal <c>ExecutionId</c> correlation column to the centralized
/// <c>AuditLog</c> table (#23). <c>ExecutionId</c> identifies the originating
/// script execution / inbound request and is distinct from the per-operation
/// <c>CorrelationId</c>.
///
/// The change is purely additive:
/// 1. <c>ExecutionId 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_Execution</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 AddAuditLogExecutionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ExecutionId",
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_CorrelationId (filtered, aligned).
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution
ON dbo.AuditLog (ExecutionId)
WHERE ExecutionId 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_Execution' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_Execution ON dbo.AuditLog;");
migrationBuilder.DropColumn(
name: "ExecutionId",
table: "AuditLog");
}
}
}
@@ -0,0 +1,41 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Adds the <c>OriginExecutionId</c> correlation column to the central
/// <c>Notifications</c> table (#21). It carries the originating script execution's
/// <c>ExecutionId</c> from the site so the dispatcher can echo it onto the
/// <c>NotifyDeliver</c> audit rows (#23), linking them to the site's <c>NotifySend</c>
/// row for the same run.
///
/// The change is purely additive: <c>OriginExecutionId 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 AddNotificationOriginExecutionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "OriginExecutionId",
table: "Notifications",
type: "uniqueidentifier",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OriginExecutionId",
table: "Notifications");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -73,6 +73,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.HasMaxLength(1024) .HasMaxLength(1024)
.HasColumnType("nvarchar(1024)"); .HasColumnType("nvarchar(1024)");
b.Property<Guid?>("ExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Extra") b.Property<string>("Extra")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -93,6 +96,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsUnicode(false) .IsUnicode(false)
.HasColumnType("varchar(32)"); .HasColumnType("varchar(32)");
b.Property<Guid?>("ParentExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("PayloadTruncated") b.Property<bool>("PayloadTruncated")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -138,10 +144,18 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsUnique() .IsUnique()
.HasDatabaseName("UX_AuditLog_EventId"); .HasDatabaseName("UX_AuditLog_EventId");
b.HasIndex("ExecutionId")
.HasDatabaseName("IX_AuditLog_Execution")
.HasFilter("[ExecutionId] IS NOT NULL");
b.HasIndex("OccurredAtUtc") b.HasIndex("OccurredAtUtc")
.IsDescending() .IsDescending()
.HasDatabaseName("IX_AuditLog_OccurredAtUtc"); .HasDatabaseName("IX_AuditLog_OccurredAtUtc");
b.HasIndex("ParentExecutionId")
.HasDatabaseName("IX_AuditLog_ParentExecution")
.HasFilter("[ParentExecutionId] IS NOT NULL");
b.HasIndex("SourceSiteId", "OccurredAtUtc") b.HasIndex("SourceSiteId", "OccurredAtUtc")
.IsDescending(false, true) .IsDescending(false, true)
.HasDatabaseName("IX_AuditLog_Site_Occurred"); .HasDatabaseName("IX_AuditLog_Site_Occurred");
@@ -780,6 +794,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.Property<DateTimeOffset?>("NextAttemptAt") b.Property<DateTimeOffset?>("NextAttemptAt")
.HasColumnType("datetimeoffset"); .HasColumnType("datetimeoffset");
b.Property<Guid?>("OriginExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("OriginParentExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ResolvedTargets") b.Property<string>("ResolvedTargets")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -64,12 +64,12 @@ public class AuditLogRepository : IAuditLogRepository
await _context.Database.ExecuteSqlInterpolatedAsync( await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId}) $@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, (EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState) ResponseSummary, PayloadTruncated, Extra, ForwardState)
VALUES VALUES
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, ({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.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary}, {evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});", {evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
@@ -157,6 +157,16 @@ VALUES
query = query.Where(e => e.CorrelationId == correlationId); query = query.Where(e => e.CorrelationId == correlationId);
} }
if (filter.ExecutionId is { } executionId)
{
query = query.Where(e => e.ExecutionId == executionId);
}
if (filter.ParentExecutionId is { } parentExecutionId)
{
query = query.Where(e => e.ParentExecutionId == parentExecutionId);
}
if (filter.FromUtc is { } fromUtc) if (filter.FromUtc is { } fromUtc)
{ {
query = query.Where(e => e.OccurredAtUtc >= fromUtc); query = query.Where(e => e.OccurredAtUtc >= fromUtc);
@@ -263,6 +273,13 @@ VALUES
PayloadTruncated bit NOT NULL, PayloadTruncated bit NOT NULL,
Extra nvarchar(max) NULL, Extra nvarchar(max) NULL,
ForwardState varchar(32) NULL, ForwardState varchar(32) NULL,
-- 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) CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
) ON [PRIMARY]; ) ON [PRIMARY];
@@ -538,4 +555,227 @@ VALUES
BacklogTotal: 0L, BacklogTotal: 0L,
AsOfUtc: anchorUtc); 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();
}
} }
@@ -84,7 +84,10 @@ public class DatabaseGateway : IDatabaseGateway
IReadOnlyDictionary<string, object?>? parameters = null, IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null, string? originInstanceName = null,
CancellationToken cancellationToken = default, CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null) TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null,
Guid? parentExecutionId = null)
{ {
var definition = await ResolveConnectionAsync(connectionName, cancellationToken); var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
if (definition == null) if (definition == null)
@@ -124,7 +127,18 @@ public class DatabaseGateway : IDatabaseGateway
// read it back via StoreAndForwardMessage.Id and emit per-attempt + // read it back via StoreAndForwardMessage.Id and emit per-attempt +
// terminal cached-write telemetry. Null -> S&F mints its own GUID // terminal cached-write telemetry. Null -> S&F mints its own GUID
// (legacy pre-M3 behaviour). // (legacy pre-M3 behaviour).
messageId: trackedOperationId?.ToString()); messageId: trackedOperationId?.ToString(),
// Audit Log #23 (ExecutionId Task 4): thread the originating script
// execution's ExecutionId + SourceScript onto the buffered row so
// the retry-loop cached-write audit rows carry the same provenance
// the script-side cached rows do.
executionId: executionId,
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> /// <summary>
@@ -86,7 +86,10 @@ public class ExternalSystemClient : IExternalSystemClient
IReadOnlyDictionary<string, object?>? parameters = null, IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null, string? originInstanceName = null,
CancellationToken cancellationToken = default, CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null) TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null,
Guid? parentExecutionId = null)
{ {
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken); var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
if (system == null || method == null) if (system == null || method == null)
@@ -144,7 +147,18 @@ public class ExternalSystemClient : IExternalSystemClient
// StoreAndForwardMessage.Id and emit per-attempt + terminal // StoreAndForwardMessage.Id and emit per-attempt + terminal
// cached-call telemetry (Bundle E Tasks E4/E5). Null -> S&F // cached-call telemetry (Bundle E Tasks E4/E5). Null -> S&F
// mints its own GUID (legacy pre-M3 behaviour). // mints its own GUID (legacy pre-M3 behaviour).
messageId: trackedOperationId?.ToString()); messageId: trackedOperationId?.ToString(),
// Audit Log #23 (ExecutionId Task 4): thread the originating
// script execution's ExecutionId + SourceScript onto the
// buffered row so the retry-loop cached-call audit rows carry
// the same provenance the script-side cached rows do.
executionId: executionId,
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); return new ExternalCallResult(true, null, null, WasBuffered: true);
} }
+14 -1
View File
@@ -92,8 +92,21 @@ public static class EndpointExtensions
? TimeSpan.FromSeconds(method.TimeoutSeconds) ? TimeSpan.FromSeconds(method.TimeoutSeconds)
: options.DefaultMethodTimeout; : 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( var scriptResult = await executor.ExecuteAsync(
method, paramResult.Parameters, routeHelper, timeout, httpContext.RequestAborted); method, paramResult.Parameters, routeHelper, timeout,
httpContext.RequestAborted, parentExecutionId);
if (!scriptResult.Success) if (!scriptResult.Success)
{ {
@@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting; using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ScadaLink.Commons.Entities.InboundApi; using ScadaLink.Commons.Entities.InboundApi;
using ScadaLink.Commons.Messages.InboundApi;
using ScadaLink.Commons.Types; using ScadaLink.Commons.Types;
namespace ScadaLink.InboundAPI; namespace ScadaLink.InboundAPI;
@@ -156,12 +157,25 @@ public class InboundScriptExecutor
/// <summary> /// <summary>
/// Executes the script for the given method with the provided context. /// Executes the script for the given method with the provided context.
/// </summary> /// </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( public async Task<InboundScriptResult> ExecuteAsync(
ApiMethod method, ApiMethod method,
IReadOnlyDictionary<string, object?> parameters, IReadOnlyDictionary<string, object?> parameters,
RouteHelper route, RouteHelper route,
TimeSpan timeout, 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 // InboundAPI-004: keep the timeout source and the request-abort source
// separable. A single linked CTS makes a genuine client disconnect // 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 // InboundAPI-016: bind the route helper to the method deadline so a
// routed Route.To(...).Call(...) inherits the method-level timeout // routed Route.To(...).Call(...) inherits the method-level timeout
// without the script having to thread the context token by hand. // 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)) if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
{ {
@@ -59,6 +59,18 @@ public sealed class AuditWriteMiddleware
/// </summary> /// </summary>
public const string AuditActorItemKey = "ScadaLink.InboundAPI.AuditActor"; 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 RequestDelegate _next;
private readonly ICentralAuditWriter _auditWriter; private readonly ICentralAuditWriter _auditWriter;
private readonly ILogger<AuditWriteMiddleware> _logger; private readonly ILogger<AuditWriteMiddleware> _logger;
@@ -77,6 +89,17 @@ public sealed class AuditWriteMiddleware
{ {
var sw = Stopwatch.StartNew(); 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 // Buffer the request body up front so we can both audit it and let the
// downstream handler still parse it. EnableBuffering swaps the request // downstream handler still parse it. EnableBuffering swaps the request
// stream for a seekable wrapper that the framework rewinds at the end // stream for a seekable wrapper that the framework rewinds at the end
@@ -145,6 +168,18 @@ public sealed class AuditWriteMiddleware
OccurredAtUtc = DateTime.UtcNow, OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiInbound, Channel = AuditChannel.ApiInbound,
Kind = kind, Kind = kind,
// 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.
CorrelationId = null,
Actor = actor, Actor = actor,
Target = methodName, Target = methodName,
Status = status, Status = status,
@@ -210,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> /// <summary>
/// Reads the API key name the endpoint handler stashed on /// Reads the API key name the endpoint handler stashed on
/// <see cref="HttpContext.Items"/> after successful auth. Falls back to /// <see cref="HttpContext.Items"/> after successful auth. Falls back to
+30 -6
View File
@@ -19,22 +19,25 @@ public class RouteHelper
private readonly IInstanceLocator _instanceLocator; private readonly IInstanceLocator _instanceLocator;
private readonly IInstanceRouter _instanceRouter; private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken; private readonly CancellationToken _deadlineToken;
private readonly Guid? _parentExecutionId;
public RouteHelper( public RouteHelper(
IInstanceLocator instanceLocator, IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter) IInstanceRouter instanceRouter)
: this(instanceLocator, instanceRouter, CancellationToken.None) : this(instanceLocator, instanceRouter, CancellationToken.None, parentExecutionId: null)
{ {
} }
private RouteHelper( private RouteHelper(
IInstanceLocator instanceLocator, IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter, IInstanceRouter instanceRouter,
CancellationToken deadlineToken) CancellationToken deadlineToken,
Guid? parentExecutionId)
{ {
_instanceLocator = instanceLocator; _instanceLocator = instanceLocator;
_instanceRouter = instanceRouter; _instanceRouter = instanceRouter;
_deadlineToken = deadlineToken; _deadlineToken = deadlineToken;
_parentExecutionId = parentExecutionId;
} }
/// <summary> /// <summary>
@@ -45,14 +48,27 @@ public class RouteHelper
/// requires. /// requires.
/// </summary> /// </summary>
public RouteHelper WithDeadline(CancellationToken deadlineToken) => 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> /// <summary>
/// Creates a route target for the specified instance. /// Creates a route target for the specified instance.
/// </summary> /// </summary>
public RouteTarget To(string instanceCode) 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 IInstanceLocator _instanceLocator;
private readonly IInstanceRouter _instanceRouter; private readonly IInstanceRouter _instanceRouter;
private readonly CancellationToken _deadlineToken; private readonly CancellationToken _deadlineToken;
private readonly Guid? _parentExecutionId;
internal RouteTarget( internal RouteTarget(
string instanceCode, string instanceCode,
IInstanceLocator instanceLocator, IInstanceLocator instanceLocator,
IInstanceRouter instanceRouter, IInstanceRouter instanceRouter,
CancellationToken deadlineToken) CancellationToken deadlineToken,
Guid? parentExecutionId)
{ {
_instanceCode = instanceCode; _instanceCode = instanceCode;
_instanceLocator = instanceLocator; _instanceLocator = instanceLocator;
_instanceRouter = instanceRouter; _instanceRouter = instanceRouter;
_deadlineToken = deadlineToken; _deadlineToken = deadlineToken;
_parentExecutionId = parentExecutionId;
} }
/// <summary> /// <summary>
@@ -96,8 +115,13 @@ public class RouteTarget
var siteId = await ResolveSiteAsync(token); var siteId = await ResolveSiteAsync(token);
var correlationId = Guid.NewGuid().ToString(); 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( 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); var response = await _instanceRouter.RouteToCallAsync(siteId, request, token);
@@ -395,6 +395,20 @@ public static class AuditEndpoints
correlationId = parsedCorr; correlationId = parsedCorr;
} }
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
executionId = parsedExec;
}
Guid? parentExecutionId = null;
if (query.TryGetValue("parentExecutionId", out var parentExecValues)
&& Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
{
parentExecutionId = parsedParentExec;
}
return new AuditLogQueryFilter( return new AuditLogQueryFilter(
Channels: channels, Channels: channels,
Kinds: kinds, Kinds: kinds,
@@ -403,6 +417,8 @@ public static class AuditEndpoints
Target: TrimToNullable(query, "target"), Target: TrimToNullable(query, "target"),
Actor: TrimToNullable(query, "actor"), Actor: TrimToNullable(query, "actor"),
CorrelationId: correlationId, CorrelationId: correlationId,
ExecutionId: executionId,
ParentExecutionId: parentExecutionId,
FromUtc: ParseUtcDate(query, "fromUtc"), FromUtc: ParseUtcDate(query, "fromUtc"),
ToUtc: ParseUtcDate(query, "toUtc")); ToUtc: ParseUtcDate(query, "toUtc"));
} }
@@ -489,6 +489,11 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
/// parses the notification's id as a Guid; sites generate the id with /// parses the notification's id as a Guid; sites generate the id with
/// <c>Guid.NewGuid().ToString("N")</c> so the parse always succeeds, but /// <c>Guid.NewGuid().ToString("N")</c> so the parse always succeeds, but
/// a non-Guid id is recorded as null rather than crashing the dispatcher. /// a non-Guid id is recorded as null rather than crashing the dispatcher.
/// <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); <see cref="AuditEvent.ParentExecutionId"/>
/// is likewise copied from <see cref="Notification.OriginParentExecutionId"/>.
/// </summary> /// </summary>
private static AuditEvent BuildNotifyDeliverEvent( private static AuditEvent BuildNotifyDeliverEvent(
Notification notification, Notification notification,
@@ -515,6 +520,17 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
SourceSiteId = notification.SourceSiteId, SourceSiteId = notification.SourceSiteId,
SourceInstanceId = notification.SourceInstanceId, SourceInstanceId = notification.SourceInstanceId,
SourceScript = notification.SourceScript, SourceScript = notification.SourceScript,
// ExecutionId (Audit Log #23): the originating script execution's id,
// carried from the site on NotificationSubmit and persisted on the
// Notification row. Echoing it here links the central NotifyDeliver
// 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, Target = notification.ListName,
Status = status, Status = status,
ErrorMessage = errorMessage, ErrorMessage = errorMessage,
@@ -941,6 +957,13 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
{ {
SourceInstanceId = msg.SourceInstanceId, SourceInstanceId = msg.SourceInstanceId,
SourceScript = msg.SourceScript, SourceScript = msg.SourceScript,
// 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, SiteEnqueuedAt = msg.SiteEnqueuedAt,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
// Status stays at its Pending default for the dispatch sweep to claim. // Status stays at its Pending default for the dispatch sweep to claim.
@@ -735,9 +735,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{ {
if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor)) 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( var scriptCall = new ScriptCallRequest(
request.ScriptName, request.Parameters, 0, request.CorrelationId); request.ScriptName, request.Parameters, 0, request.CorrelationId,
ParentExecutionId: request.ParentExecutionId);
var sender = Sender; var sender = Sender;
instanceActor.Ask<ScriptCallResult>(scriptCall, TimeSpan.FromSeconds(30)) instanceActor.Ask<ScriptCallResult>(scriptCall, TimeSpan.FromSeconds(30))
.ContinueWith(t => .ContinueWith(t =>
@@ -320,7 +320,10 @@ public class InstanceActor : ReceiveActor
{ {
if (_scriptActors.TryGetValue(request.ScriptName, out var scriptActor)) 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); scriptActor.Forward(request);
} }
else else
@@ -184,7 +184,13 @@ public class ScriptActor : ReceiveActor, IWithTimers
return; 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> /// <summary>
@@ -379,7 +385,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
IReadOnlyDictionary<string, object?>? parameters, IReadOnlyDictionary<string, object?>? parameters,
int callDepth, int callDepth,
IActorRef replyTo, IActorRef replyTo,
string correlationId) string correlationId,
Guid? parentExecutionId = null)
{ {
var executionId = $"{_scriptName}-exec-{_executionCounter++}"; var executionId = $"{_scriptName}-exec-{_executionCounter++}";
@@ -401,7 +408,10 @@ public class ScriptActor : ReceiveActor, IWithTimers
_logger, _logger,
_scope, _scope,
_healthCollector, _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); Context.ActorOf(props, executionId);
} }
@@ -43,7 +43,11 @@ public class ScriptExecutionActor : ReceiveActor
ILogger logger, ILogger logger,
Commons.Types.Scripts.ScriptScope scope, Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector = null, 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 // Immediately begin execution
var self = Self; var self = Self;
@@ -52,7 +56,8 @@ public class ScriptExecutionActor : ReceiveActor
ExecuteScript( ExecuteScript(
scriptName, instanceName, compiledScript, parameters, callDepth, scriptName, instanceName, compiledScript, parameters, callDepth,
instanceActor, sharedScriptLibrary, options, replyTo, correlationId, instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
self, parent, logger, scope, healthCollector, serviceProvider); self, parent, logger, scope, healthCollector, serviceProvider,
parentExecutionId);
} }
private static void ExecuteScript( private static void ExecuteScript(
@@ -71,7 +76,8 @@ public class ScriptExecutionActor : ReceiveActor
ILogger logger, ILogger logger,
Commons.Types.Scripts.ScriptScope scope, Commons.Types.Scripts.ScriptScope scope,
ISiteHealthCollector? healthCollector, ISiteHealthCollector? healthCollector,
IServiceProvider? serviceProvider) IServiceProvider? serviceProvider,
Guid? parentExecutionId)
{ {
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds); var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
@@ -164,7 +170,12 @@ public class ScriptExecutionActor : ReceiveActor
// emission. Best-effort: null degrades the helpers to a // emission. Best-effort: null degrades the helpers to a
// no-emission path; the S&F handoff and TrackedOperationId // no-emission path; the S&F handoff and TrackedOperationId
// return are unaffected. // 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 var globals = new ScriptGlobals
{ {
@@ -37,9 +37,23 @@ internal sealed class AuditingDbCommand : DbCommand
private readonly string _siteId; private readonly string _siteId;
private readonly string _instanceName; private readonly string _instanceName;
private readonly string? _sourceScript; 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 readonly ILogger _logger;
private DbConnection? _wrappingConnection; private DbConnection? _wrappingConnection;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbConnection). parentExecutionId is a trailing
// optional param so existing positional callers stay source-compatible.
public AuditingDbCommand( public AuditingDbCommand(
DbCommand inner, DbCommand inner,
IAuditWriter auditWriter, IAuditWriter auditWriter,
@@ -47,7 +61,9 @@ internal sealed class AuditingDbCommand : DbCommand
string siteId, string siteId,
string instanceName, string instanceName,
string? sourceScript, string? sourceScript,
ILogger logger) ILogger logger,
Guid executionId,
Guid? parentExecutionId = null)
{ {
_inner = inner ?? throw new ArgumentNullException(nameof(inner)); _inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -56,6 +72,8 @@ internal sealed class AuditingDbCommand : DbCommand
_instanceName = instanceName ?? string.Empty; _instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_executionId = executionId;
_parentExecutionId = parentExecutionId;
} }
// -- Forwarded surface ------------------------------------------------ // -- Forwarded surface ------------------------------------------------
@@ -426,7 +444,15 @@ internal sealed class AuditingDbCommand : DbCommand
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound, Channel = AuditChannel.DbOutbound,
Kind = AuditKind.DbWrite, Kind = AuditKind.DbWrite,
// Audit Log #23: a sync one-shot DB write has no operation
// lifecycle, so CorrelationId is null. ExecutionId carries the
// per-execution id so this row shares an id with the other sync
// trust-boundary rows from the same script run.
CorrelationId = null, 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, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -36,8 +36,22 @@ internal sealed class AuditingDbConnection : DbConnection
private readonly string _siteId; private readonly string _siteId;
private readonly string _instanceName; private readonly string _instanceName;
private readonly string? _sourceScript; 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; private readonly ILogger _logger;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbCommand). parentExecutionId is a trailing
// optional param so existing positional callers stay source-compatible.
public AuditingDbConnection( public AuditingDbConnection(
DbConnection inner, DbConnection inner,
IAuditWriter auditWriter, IAuditWriter auditWriter,
@@ -45,7 +59,9 @@ internal sealed class AuditingDbConnection : DbConnection
string siteId, string siteId,
string instanceName, string instanceName,
string? sourceScript, string? sourceScript,
ILogger logger) ILogger logger,
Guid executionId,
Guid? parentExecutionId = null)
{ {
_inner = inner ?? throw new ArgumentNullException(nameof(inner)); _inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -54,6 +70,8 @@ internal sealed class AuditingDbConnection : DbConnection
_instanceName = instanceName ?? string.Empty; _instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_executionId = executionId;
_parentExecutionId = parentExecutionId;
} }
// ConnectionString is settable on DbConnection — forward both halves. // ConnectionString is settable on DbConnection — forward both halves.
@@ -92,7 +110,11 @@ internal sealed class AuditingDbConnection : DbConnection
_siteId, _siteId,
_instanceName, _instanceName,
_sourceScript, _sourceScript,
_logger); _logger,
_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) protected override void Dispose(bool disposing)
@@ -105,6 +105,44 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly ICachedCallTelemetryForwarder? _cachedForwarder; private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
/// <summary>
/// Audit Log #23: the per-execution id for this script run. Every
/// trust-boundary audit row emitted by this script execution
/// (sync <c>ApiCall</c>/<c>DbWrite</c>, cached-call lifecycle rows,
/// <c>NotifySend</c>) is stamped into <c>AuditEvent.ExecutionId</c> with
/// this value so all the rows from one script run can be correlated
/// together — independently of the per-operation
/// <c>AuditEvent.CorrelationId</c>.
/// </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
/// inbound caller may supply one to tie the execution to an upstream
/// 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( public ScriptRuntimeContext(
IActorRef instanceActor, IActorRef instanceActor,
IActorRef self, IActorRef self,
@@ -122,7 +160,9 @@ public class ScriptRuntimeContext
string? sourceScript = null, string? sourceScript = null,
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
IOperationTrackingStore? operationTrackingStore = null, IOperationTrackingStore? operationTrackingStore = null,
ICachedCallTelemetryForwarder? cachedForwarder = null) ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? executionId = null,
Guid? parentExecutionId = null)
{ {
_instanceActor = instanceActor; _instanceActor = instanceActor;
_self = self; _self = self;
@@ -141,6 +181,10 @@ public class ScriptRuntimeContext
_auditWriter = auditWriter; _auditWriter = auditWriter;
_operationTrackingStore = operationTrackingStore; _operationTrackingStore = operationTrackingStore;
_cachedForwarder = cachedForwarder; _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> /// <summary>
@@ -241,10 +285,13 @@ public class ScriptRuntimeContext
/// ExternalSystem.CachedCall("systemName", "methodName", params) /// ExternalSystem.CachedCall("systemName", "methodName", params)
/// </summary> /// </summary>
public ExternalSystemHelper ExternalSystem => new( public ExternalSystemHelper ExternalSystem => new(
_externalSystemClient, _instanceName, _logger, _auditWriter, _siteId, _sourceScript, _externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript,
// Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry // Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry
// on every ExternalSystem.CachedCall enqueue. // 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> /// <summary>
/// WP-13: Provides access to database operations. /// WP-13: Provides access to database operations.
@@ -255,6 +302,7 @@ public class ScriptRuntimeContext
_databaseGateway, _databaseGateway,
_instanceName, _instanceName,
_logger, _logger,
_executionId,
// Audit Log #23 (M4 Bundle A): wire the IAuditWriter so // Audit Log #23 (M4 Bundle A): wire the IAuditWriter so
// Database.Connection(name) returns an auditing decorator that // Database.Connection(name) returns an auditing decorator that
// emits one DbOutbound/DbWrite row per script-initiated // emits one DbOutbound/DbWrite row per script-initiated
@@ -264,7 +312,10 @@ public class ScriptRuntimeContext
_sourceScript, _sourceScript,
// Audit Log #23 (M3 Bundle E — Task E6): emit CachedSubmit telemetry on // Audit Log #23 (M3 Bundle E — Task E6): emit CachedSubmit telemetry on
// every Database.CachedWrite enqueue. // 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> /// <summary>
/// Provides access to the Notification Outbox API. /// Provides access to the Notification Outbox API.
@@ -281,7 +332,10 @@ public class ScriptRuntimeContext
/// </remarks> /// </remarks>
public NotifyHelper Notify => new( public NotifyHelper Notify => new(
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger, _storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
_auditWriter); _executionId, _auditWriter,
// Audit Log #23 (ParentExecutionId): the spawning execution's id,
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId);
/// <summary> /// <summary>
/// Audit Log #23 (M3): site-local tracking-status API for cached operations. /// Audit Log #23 (M3): site-local tracking-status API for cached operations.
@@ -362,6 +416,16 @@ public class ScriptRuntimeContext
private readonly IExternalSystemClient? _client; private readonly IExternalSystemClient? _client;
private readonly string _instanceName; private readonly string _instanceName;
private readonly ILogger _logger; 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 IAuditWriter? _auditWriter;
private readonly string _siteId; private readonly string _siteId;
private readonly string? _sourceScript; private readonly string? _sourceScript;
@@ -370,22 +434,35 @@ public class ScriptRuntimeContext
// Internal constructor for tests living in ScadaLink.SiteRuntime.Tests // Internal constructor for tests living in ScadaLink.SiteRuntime.Tests
// (via InternalsVisibleTo). Production sites resolve the helper through // (via InternalsVisibleTo). Production sites resolve the helper through
// ScriptRuntimeContext.ExternalSystem. // ScriptRuntimeContext.ExternalSystem.
//
// Parameter ordering: executionId sits immediately after the
// ILogger across all four audit-threaded ctors (ExternalSystemHelper,
// 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. The nullable
// parentExecutionId is a trailing optional param so existing positional
// callers stay source-compatible.
internal ExternalSystemHelper( internal ExternalSystemHelper(
IExternalSystemClient? client, IExternalSystemClient? client,
string instanceName, string instanceName,
ILogger logger, ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
string siteId = "", string siteId = "",
string? sourceScript = null, string? sourceScript = null,
ICachedCallTelemetryForwarder? cachedForwarder = null) ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? parentExecutionId = null)
{ {
_client = client; _client = client;
_instanceName = instanceName; _instanceName = instanceName;
_logger = logger; _logger = logger;
_executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
_siteId = siteId; _siteId = siteId;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_cachedForwarder = cachedForwarder; _cachedForwarder = cachedForwarder;
_parentExecutionId = parentExecutionId;
} }
public async Task<ExternalCallResult> Call( public async Task<ExternalCallResult> Call(
@@ -482,7 +559,17 @@ public class ScriptRuntimeContext
parameters, parameters,
_instanceName, _instanceName,
cancellationToken, cancellationToken,
trackedId).ConfigureAwait(false); trackedId,
// Audit Log #23 (ExecutionId Task 4): thread the script
// execution's ExecutionId + SourceScript so a buffered
// cached call's retry-loop audit rows carry them.
executionId: _executionId,
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) catch (Exception ex)
{ {
@@ -539,7 +626,14 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit, Kind = AuditKind.CachedSubmit,
// CorrelationId stays the per-operation lifecycle id
// (TrackedOperationId); ExecutionId carries the
// per-execution id shared across this script run.
CorrelationId = trackedId.Value, 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, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -649,7 +743,13 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCallCached, Kind = AuditKind.ApiCallCached,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value, 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, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -710,7 +810,13 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedResolve, Kind = AuditKind.CachedResolve,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value, 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, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -882,7 +988,15 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall, Kind = AuditKind.ApiCall,
// Audit Log #23: a sync one-shot call has no operation
// lifecycle, so CorrelationId is null. ExecutionId carries the
// per-execution id so all the sync ApiCall/DbWrite rows from
// one script run can be correlated together.
CorrelationId = null, 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, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -949,6 +1063,16 @@ public class ScriptRuntimeContext
private readonly IDatabaseGateway? _gateway; private readonly IDatabaseGateway? _gateway;
private readonly string _instanceName; private readonly string _instanceName;
private readonly ILogger _logger; 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 _siteId;
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly ICachedCallTelemetryForwarder? _cachedForwarder; private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
@@ -965,22 +1089,30 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly IAuditWriter? _auditWriter; private readonly IAuditWriter? _auditWriter;
// 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. parentExecutionId is a trailing optional param.
internal DatabaseHelper( internal DatabaseHelper(
IDatabaseGateway? gateway, IDatabaseGateway? gateway,
string instanceName, string instanceName,
ILogger logger, ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null, IAuditWriter? auditWriter = null,
string siteId = "", string siteId = "",
string? sourceScript = null, string? sourceScript = null,
ICachedCallTelemetryForwarder? cachedForwarder = null) ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? parentExecutionId = null)
{ {
_gateway = gateway; _gateway = gateway;
_instanceName = instanceName; _instanceName = instanceName;
_logger = logger; _logger = logger;
_executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
_siteId = siteId; _siteId = siteId;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_cachedForwarder = cachedForwarder; _cachedForwarder = cachedForwarder;
_parentExecutionId = parentExecutionId;
} }
public async Task<System.Data.Common.DbConnection> Connection( public async Task<System.Data.Common.DbConnection> Connection(
@@ -1011,7 +1143,11 @@ public class ScriptRuntimeContext
siteId: _siteId, siteId: _siteId,
instanceName: _instanceName, instanceName: _instanceName,
sourceScript: _sourceScript, sourceScript: _sourceScript,
logger: _logger); logger: _logger,
executionId: _executionId,
// Audit Log #23 (ParentExecutionId): the spawning execution's
// id, threaded alongside _executionId. Null for non-routed runs.
parentExecutionId: _parentExecutionId);
} }
/// <summary> /// <summary>
@@ -1042,7 +1178,17 @@ public class ScriptRuntimeContext
try try
{ {
await _gateway.CachedWriteAsync( await _gateway.CachedWriteAsync(
name, sql, parameters, _instanceName, cancellationToken, trackedId) name, sql, parameters, _instanceName, cancellationToken, trackedId,
// Audit Log #23 (ExecutionId Task 4): thread the script
// execution's ExecutionId + SourceScript so a buffered
// cached write's retry-loop audit rows carry them.
executionId: _executionId,
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); .ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
@@ -1078,7 +1224,13 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound, Channel = AuditChannel.DbOutbound,
Kind = AuditKind.CachedSubmit, Kind = AuditKind.CachedSubmit,
// CorrelationId = per-operation lifecycle id
// (TrackedOperationId); ExecutionId = per-execution id.
CorrelationId = trackedId.Value, 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, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -1140,6 +1292,20 @@ public class ScriptRuntimeContext
private readonly TimeSpan _askTimeout; private readonly TimeSpan _askTimeout;
private readonly ILogger _logger; private readonly ILogger _logger;
/// <summary>
/// Audit Log #23: the per-execution id for this script run, stamped
/// into <c>AuditEvent.ExecutionId</c> on the <c>NotifySend</c> row.
/// </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> /// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row produced when the script /// <c>Notification</c>/<c>NotifySend</c> row produced when the script
@@ -1150,6 +1316,9 @@ public class ScriptRuntimeContext
/// </summary> /// </summary>
private readonly IAuditWriter? _auditWriter; private readonly IAuditWriter? _auditWriter;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other audit-threaded ctors. parentExecutionId is
// a trailing optional param.
internal NotifyHelper( internal NotifyHelper(
StoreAndForwardService? storeAndForward, StoreAndForwardService? storeAndForward,
ICanTell? siteCommunicationActor, ICanTell? siteCommunicationActor,
@@ -1158,7 +1327,9 @@ public class ScriptRuntimeContext
string? sourceScript, string? sourceScript,
TimeSpan askTimeout, TimeSpan askTimeout,
ILogger logger, ILogger logger,
IAuditWriter? auditWriter = null) Guid executionId,
IAuditWriter? auditWriter = null,
Guid? parentExecutionId = null)
{ {
_storeAndForward = storeAndForward; _storeAndForward = storeAndForward;
_siteCommunicationActor = siteCommunicationActor; _siteCommunicationActor = siteCommunicationActor;
@@ -1167,7 +1338,9 @@ public class ScriptRuntimeContext
_sourceScript = sourceScript; _sourceScript = sourceScript;
_askTimeout = askTimeout; _askTimeout = askTimeout;
_logger = logger; _logger = logger;
_executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
_parentExecutionId = parentExecutionId;
} }
/// <summary> /// <summary>
@@ -1177,9 +1350,15 @@ public class ScriptRuntimeContext
{ {
return new NotifyTarget( return new NotifyTarget(
listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger, listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger,
// Audit Log #23: the per-execution id stamped into the
// NotifySend row's ExecutionId column.
_executionId,
// Audit Log #23 (M4 Bundle C): forward the writer so Send() // Audit Log #23 (M4 Bundle C): forward the writer so Send()
// can emit one NotifySend(Submitted) row per accepted submission. // 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> /// <summary>
@@ -1254,6 +1433,20 @@ public class ScriptRuntimeContext
private readonly string? _sourceScript; private readonly string? _sourceScript;
private readonly ILogger _logger; private readonly ILogger _logger;
/// <summary>
/// Audit Log #23: the per-execution id for this script run, stamped
/// into <c>AuditEvent.ExecutionId</c> on the <c>NotifySend</c> row.
/// </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> /// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row written immediately after /// <c>Notification</c>/<c>NotifySend</c> row written immediately after
@@ -1269,7 +1462,9 @@ public class ScriptRuntimeContext
string instanceName, string instanceName,
string? sourceScript, string? sourceScript,
ILogger logger, ILogger logger,
IAuditWriter? auditWriter = null) Guid executionId,
IAuditWriter? auditWriter = null,
Guid? parentExecutionId = null)
{ {
_listName = listName; _listName = listName;
_storeAndForward = storeAndForward; _storeAndForward = storeAndForward;
@@ -1277,7 +1472,9 @@ public class ScriptRuntimeContext
_instanceName = instanceName; _instanceName = instanceName;
_sourceScript = sourceScript; _sourceScript = sourceScript;
_logger = logger; _logger = logger;
_executionId = executionId;
_auditWriter = auditWriter; _auditWriter = auditWriter;
_parentExecutionId = parentExecutionId;
} }
/// <summary> /// <summary>
@@ -1319,7 +1516,18 @@ public class ScriptRuntimeContext
// notification, threaded down from the script-execution context for the // notification, threaded down from the script-execution context for the
// central audit trail. Null when no single script owns the context. // central audit trail. Null when no single script owns the context.
SourceScript: _sourceScript, SourceScript: _sourceScript,
SiteEnqueuedAt: DateTimeOffset.UtcNow); SiteEnqueuedAt: DateTimeOffset.UtcNow,
// OriginExecutionId (Audit Log #23): the SAME per-execution id stamped
// 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,
// 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); var payloadJson = JsonSerializer.Serialize(payload);
@@ -1393,7 +1601,13 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.Notification, Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend, Kind = AuditKind.NotifySend,
// CorrelationId is the NotificationId-derived per-operation
// lifecycle id; ExecutionId carries the per-execution id.
CorrelationId = correlationId, 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, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName, SourceInstanceId = _instanceName,
SourceScript = _sourceScript, SourceScript = _sourceScript,
@@ -55,4 +55,40 @@ public class StoreAndForwardMessage
/// WP-13: Messages are NOT cleared when instance is deleted. /// WP-13: Messages are NOT cleared when instance is deleted.
/// </summary> /// </summary>
public string? OriginInstanceName { get; set; } public string? OriginInstanceName { get; set; }
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id, threaded from <c>ScriptRuntimeContext</c> through
/// 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>).
/// <c>null</c> 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? ExecutionId { get; set; }
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded alongside <see cref="ExecutionId"/> from the cached-call enqueue
/// path so the retry-loop audit rows carry the same <c>SourceScript</c>
/// provenance the script-side cached rows already carry. <c>null</c> when not
/// 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; }
} }
@@ -175,6 +175,26 @@ public class StoreAndForwardService
/// it is the buffered row's <see cref="StoreAndForwardMessage.Id"/>, it is carried /// it is the buffered row's <see cref="StoreAndForwardMessage.Id"/>, it is carried
/// inside the payload, and it is the id the forwarder submits to central. /// inside the payload, and it is the id the forwarder submits to central.
/// </param> /// </param>
/// <param name="executionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id. Threaded onto the buffered row so the retry-loop
/// cached-call audit rows carry it. <c>null</c> for callers (notifications,
/// pre-Task-4 callers) that do not supply one.
/// </param>
/// <param name="sourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded onto the buffered row alongside <paramref name="executionId"/>
/// 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( public async Task<StoreAndForwardResult> EnqueueAsync(
StoreAndForwardCategory category, StoreAndForwardCategory category,
string target, string target,
@@ -183,7 +203,10 @@ public class StoreAndForwardService
int? maxRetries = null, int? maxRetries = null,
TimeSpan? retryInterval = null, TimeSpan? retryInterval = null,
bool attemptImmediateDelivery = true, bool attemptImmediateDelivery = true,
string? messageId = null) string? messageId = null,
Guid? executionId = null,
string? sourceScript = null,
Guid? parentExecutionId = null)
{ {
var message = new StoreAndForwardMessage var message = new StoreAndForwardMessage
{ {
@@ -196,7 +219,10 @@ public class StoreAndForwardService
RetryIntervalMs = (long)(retryInterval ?? _options.DefaultRetryInterval).TotalMilliseconds, RetryIntervalMs = (long)(retryInterval ?? _options.DefaultRetryInterval).TotalMilliseconds,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Pending, Status = StoreAndForwardMessageStatus.Pending,
OriginInstanceName = originInstanceName OriginInstanceName = originInstanceName,
ExecutionId = executionId,
SourceScript = sourceScript,
ParentExecutionId = parentExecutionId
}; };
// Attempt immediate delivery — unless the caller has already made a // Attempt immediate delivery — unless the caller has already made a
@@ -492,7 +518,20 @@ public class StoreAndForwardService
CreatedAtUtc: message.CreatedAt.UtcDateTime, CreatedAtUtc: message.CreatedAt.UtcDateTime,
OccurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc), OccurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
DurationMs: durationMs, DurationMs: durationMs,
SourceInstanceId: message.OriginInstanceName); SourceInstanceId: message.OriginInstanceName,
// Audit Log #23 (ExecutionId Task 4): the buffered message
// carries the originating script execution's ExecutionId +
// SourceScript; surface them on the context so the bridge can
// stamp the retry-loop cached audit rows. Null on rows buffered
// before Task 4 (back-compat).
ExecutionId: message.ExecutionId,
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) catch (Exception buildEx)
{ {
@@ -65,9 +65,51 @@ public class StoreAndForwardStorage
"; ";
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
// Audit Log #23 (ExecutionId Task 4): additively add the execution_id /
// source_script columns. CREATE TABLE IF NOT EXISTS above does NOT add
// columns to a table that already exists from before these fields, so a
// databases created by an older build needs the columns ALTER-ed in.
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
// probed first and the ALTER skipped when already there. Both columns
// are nullable with no default, so any row buffered before this
// migration reads back ExecutionId/SourceScript = null (back-compat).
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"); _logger.LogInformation("Store-and-forward SQLite storage initialized");
} }
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): adds a column to <c>sf_messages</c>
/// only when it is not already present. 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="InitializeAsync"/>.
/// </summary>
private static async Task AddColumnIfMissingAsync(
SqliteConnection connection, string columnName, string columnType)
{
await using var probe = connection.CreateCommand();
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('sf_messages') WHERE name = @name";
probe.Parameters.AddWithValue("@name", columnName);
var exists = Convert.ToInt32(await probe.ExecuteScalarAsync()) > 0;
if (exists)
{
return;
}
await using var alter = connection.CreateCommand();
// Column name + type are caller-controlled constants, never user input —
// safe to interpolate (parameters are not permitted in DDL).
alter.CommandText = $"ALTER TABLE sf_messages ADD COLUMN {columnName} {columnType}";
await alter.ExecuteNonQueryAsync();
}
/// <summary> /// <summary>
/// Ensures the directory for a file-backed SQLite database exists. SQLite creates /// Ensures the directory for a file-backed SQLite database exists. SQLite creates
/// the database file on demand but not its parent directory, so a configured path /// the database file on demand but not its parent directory, so a configured path
@@ -105,9 +147,11 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand(); await using var cmd = connection.CreateCommand();
cmd.CommandText = @" cmd.CommandText = @"
INSERT INTO sf_messages (id, category, target, payload_json, retry_count, max_retries, 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) retry_interval_ms, created_at, last_attempt_at, status, last_error,
origin_instance, execution_id, source_script, parent_execution_id)
VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries, VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries,
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError, @origin)"; @retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError,
@origin, @executionId, @sourceScript, @parentExecutionId)";
cmd.Parameters.AddWithValue("@id", message.Id); cmd.Parameters.AddWithValue("@id", message.Id);
cmd.Parameters.AddWithValue("@category", (int)message.Category); cmd.Parameters.AddWithValue("@category", (int)message.Category);
@@ -122,6 +166,17 @@ public class StoreAndForwardStorage
cmd.Parameters.AddWithValue("@status", (int)message.Status); cmd.Parameters.AddWithValue("@status", (int)message.Status);
cmd.Parameters.AddWithValue("@lastError", (object?)message.LastError ?? DBNull.Value); cmd.Parameters.AddWithValue("@lastError", (object?)message.LastError ?? DBNull.Value);
cmd.Parameters.AddWithValue("@origin", (object?)message.OriginInstanceName ?? DBNull.Value); cmd.Parameters.AddWithValue("@origin", (object?)message.OriginInstanceName ?? DBNull.Value);
// Audit Log #23 (ExecutionId Task 4): the execution id is stored as its
// canonical string form ("D") so it round-trips cleanly through the
// TEXT column; null when not a cached call / not threaded.
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(); await cmd.ExecuteNonQueryAsync();
} }
@@ -137,7 +192,8 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand(); await using var cmd = connection.CreateCommand();
cmd.CommandText = @" cmd.CommandText = @"
SELECT id, category, target, payload_json, retry_count, max_retries, SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script, parent_execution_id
FROM sf_messages FROM sf_messages
WHERE status = @pending WHERE status = @pending
AND (last_attempt_at IS NULL AND (last_attempt_at IS NULL
@@ -268,7 +324,8 @@ public class StoreAndForwardStorage
var categoryFilter = category.HasValue ? " AND category = @category" : ""; var categoryFilter = category.HasValue ? " AND category = @category" : "";
pageCmd.CommandText = $@" pageCmd.CommandText = $@"
SELECT id, category, target, payload_json, retry_count, max_retries, SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script, parent_execution_id
FROM sf_messages FROM sf_messages
WHERE status = @parked{categoryFilter} WHERE status = @parked{categoryFilter}
ORDER BY created_at ASC ORDER BY created_at ASC
@@ -389,7 +446,8 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand(); await using var cmd = connection.CreateCommand();
cmd.CommandText = @" cmd.CommandText = @"
SELECT id, category, target, payload_json, retry_count, max_retries, SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script, parent_execution_id
FROM sf_messages FROM sf_messages
WHERE id = @id"; WHERE id = @id";
cmd.Parameters.AddWithValue("@id", messageId); cmd.Parameters.AddWithValue("@id", messageId);
@@ -446,9 +504,42 @@ public class StoreAndForwardStorage
LastAttemptAt = reader.IsDBNull(8) ? null : DateTimeOffset.Parse(reader.GetString(8)), LastAttemptAt = reader.IsDBNull(8) ? null : DateTimeOffset.Parse(reader.GetString(8)),
Status = (StoreAndForwardMessageStatus)reader.GetInt32(9), Status = (StoreAndForwardMessageStatus)reader.GetInt32(9),
LastError = reader.IsDBNull(10) ? null : reader.GetString(10), LastError = reader.IsDBNull(10) ? null : reader.GetString(10),
OriginInstanceName = reader.IsDBNull(11) ? null : reader.GetString(11) OriginInstanceName = reader.IsDBNull(11) ? null : reader.GetString(11),
// Audit Log #23 (ExecutionId Task 4): rows persisted before the
// additive migration have no execution_id / source_script value;
// IsDBNull guards keep those reading back as null (back-compat).
// 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 = 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; return results;
} }
/// <summary>
/// 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? ParseGuidColumn(System.Data.Common.DbDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return null;
}
return Guid.TryParse(reader.GetString(ordinal), out var value)
? value
: null;
}
} }
@@ -224,5 +224,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
_inner.GetKpiSnapshotAsync(window, nowUtc, ct); _inner.GetKpiSnapshotAsync(window, nowUtc, ct);
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default) =>
_inner.GetExecutionTreeAsync(executionId, ct);
} }
} }
@@ -82,6 +82,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); 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) private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)
@@ -51,6 +51,10 @@ public class CentralAuditWriteFailuresTests : TestKit
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); 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> /// <summary>
@@ -97,6 +97,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync( public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); 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> /// <summary>
@@ -150,6 +150,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
client, client,
instanceName: "Plant.Pump42", instanceName: "Plant.Pump42",
NullLogger.Instance, NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer, auditWriter: writer,
siteId: "site-77", siteId: "site-77",
sourceScript: "ScriptActor:Sync", sourceScript: "ScriptActor:Sync",
@@ -193,6 +194,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
client, client,
instanceName: "Plant.Pump42", instanceName: "Plant.Pump42",
NullLogger.Instance, NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer, auditWriter: writer,
siteId: "site-77", siteId: "site-77",
sourceScript: "ScriptActor:Cached", sourceScript: "ScriptActor:Cached",
@@ -243,6 +245,7 @@ public class AuditWriteFailureSafetyTests : TestKit, IClassFixture<MsSqlMigratio
gateway, gateway,
instanceName, instanceName,
NullLogger.Instance, NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer, auditWriter: writer,
siteId: "site-77", siteId: "site-77",
sourceScript: "ScriptActor:Db", sourceScript: "ScriptActor:Db",
@@ -157,6 +157,7 @@ public class DatabaseSyncEmissionEndToEndTests : TestKit, IClassFixture<MsSqlMig
gateway, gateway,
InstanceName, InstanceName,
NullLogger.Instance, NullLogger.Instance,
Guid.NewGuid(),
auditWriter: writer, auditWriter: writer,
siteId: siteId, siteId: siteId,
sourceScript: SourceScript, sourceScript: SourceScript,
@@ -0,0 +1,274 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
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.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.AuditLog.Tests.Integration;
/// <summary>
/// Audit Log #23 — ExecutionId end-to-end correlation suite verifying the
/// universal per-run correlation promise: <b>every audit row produced by one
/// script execution carries the same non-null <see cref="AuditEvent.ExecutionId"/></b>.
/// </summary>
/// <remarks>
/// <para>
/// This is the integration-level counterpart to the unit-level
/// <c>ExecutionCorrelationContextTests</c> in <c>ScadaLink.SiteRuntime.Tests</c>:
/// where that test asserts the shared id on the in-memory captured rows, this
/// suite drives the rows all the way through the production pipeline — the real
/// <see cref="SqliteAuditWriter"/> site hot-path, the real
/// <see cref="SiteAuditTelemetryActor"/> drain loop, the real
/// <see cref="AuditLogIngestActor"/>, and the real <see cref="AuditLogRepository"/>
/// over the per-class <see cref="MsSqlMigrationFixture"/> MSSQL database — then
/// reads the rows back from the central store and asserts the shared id.
/// </para>
/// <para>
/// Composes the same pipeline as the M2 <see cref="SyncCallEmissionEndToEndTests"/>
/// and the M4 <see cref="DatabaseSyncEmissionEndToEndTests"/>: an in-memory
/// <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real
/// <see cref="SiteAuditTelemetryActor"/> through the shared
/// <see cref="DirectActorSiteStreamAuditClient"/> stub that short-circuits the
/// gRPC wire and Asks the central ingest actor. The production
/// <see cref="ScriptRuntimeContext"/> is driven directly: one context performs
/// two distinct trust-boundary actions — a sync <c>ExternalSystem.Call</c> and a
/// sync <c>Database</c> write — so the two emitted audit rows originate from one
/// execution. Each test uses a unique <c>ExecutionId</c> + <c>SourceSiteId</c>
/// (Guid suffixes) so concurrent tests sharing the MSSQL fixture don't interfere.
/// </para>
/// </remarks>
public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public ExecutionIdCorrelationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private const string ConnectionName = "machineData";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:OnTick";
private static string NewSiteId() =>
"test-execid-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private ScadaLinkDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaLinkDbContext(options);
}
/// <summary>
/// Per-test in-memory SQLite database with a tiny single-table schema the
/// sync DB write targets. The keep-alive root pins the in-memory file for
/// the duration of the test; the returned <c>live</c> connection is what the
/// stub gateway hands back to the auditing wrapper. Mirrors
/// <c>DatabaseSyncEmissionEndToEndTests.NewInMemoryDb</c>.
/// </summary>
private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
{
var dbName = $"db-{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
keepAlive = new SqliteConnection(connStr);
keepAlive.Open();
using (var seed = keepAlive.CreateCommand())
{
seed.CommandText =
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
seed.ExecuteNonQuery();
}
var live = new SqliteConnection(connStr);
live.Open();
return live;
}
private static SqliteAuditWriter CreateInMemorySqliteWriter() =>
new(
Options.Create(new SqliteAuditWriterOptions
{
DatabasePath = "ignored",
BatchSize = 64,
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
connectionStringOverride:
$"Data Source=file:auditlog-execid-{Guid.NewGuid():N}?mode=memory&cache=shared");
private static IOptions<SiteAuditTelemetryOptions> FastTelemetryOptions() =>
Options.Create(new SiteAuditTelemetryOptions
{
BatchSize = 256,
// 1s on both intervals so the initial scheduled tick fires quickly
// — drains the SQLite Pending rows and pushes them through the stub
// gRPC client into the central ingest actor.
BusyIntervalSeconds = 1,
IdleIntervalSeconds = 1,
});
private IActorRef CreateIngestActor(IAuditLogRepository repo) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
repo,
NullLogger<AuditLogIngestActor>.Instance)));
private IActorRef CreateTelemetryActor(
ISiteAuditQueue queue,
ISiteStreamAuditClient client) =>
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
queue,
client,
FastTelemetryOptions(),
NullLogger<SiteAuditTelemetryActor>.Instance)));
[SkippableFact]
public async Task OneExecution_ApiCallAndDbWrite_AllCentralRows_ShareOneNonNullExecutionId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
// An explicit per-run execution id — the value the test asserts on every
// audit row produced by the single script execution below.
var executionId = Guid.NewGuid();
// ── Central — repository + ingest actor backed by the MSSQL fixture ──
await using var ingestContext = CreateContext();
var ingestRepo = new AuditLogRepository(ingestContext);
var ingestActor = CreateIngestActor(ingestRepo);
// ── Site — SQLite audit writer + ring + fallback + telemetry actor ───
await using var sqliteWriter = CreateInMemorySqliteWriter();
var ring = new RingBufferFallback();
var fallback = new FallbackAuditWriter(
sqliteWriter,
ring,
new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance);
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor);
CreateTelemetryActor(sqliteWriter, stubClient);
// Outbound API client — one successful CallAsync, one audit row.
var externalClient = Substitute.For<IExternalSystemClient>();
externalClient
.CallAsync("ERP", "GetOrder", Arg.Any<IReadOnlyDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(new ExternalCallResult(true, "{}", null));
// SQLite-backed inner DB connection — the stub gateway hands it to the
// auditing wrapper as the connection the script would have got.
using var keepAlive = new SqliteConnection("Data Source=execid-k1;Mode=Memory;Cache=Shared");
var innerDb = NewInMemoryDb(out _);
var gateway = Substitute.For<IDatabaseGateway>();
gateway.GetConnectionAsync(ConnectionName, Arg.Any<CancellationToken>())
.Returns(innerDb);
// ── Act — ONE script execution: a sync ExternalSystem.Call AND a sync
// Database write, both performed through a SINGLE ScriptRuntimeContext
// stamped with the explicit executionId. Each helper emits exactly one
// trust-boundary audit row to the fallback writer; the telemetry actor's
// next tick drains both to central.
var context = CreateScriptContext(externalClient, gateway, fallback, siteId, executionId);
await context.ExternalSystem.Call("ERP", "GetOrder");
await using (var conn = await context.Database.Connection(ConnectionName))
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')";
var affected = await cmd.ExecuteNonQueryAsync();
Assert.Equal(1, affected);
}
// ── Assert — read the rows back from the CENTRAL store filtered by the
// execution id; both the ApiCall and the DbWrite row must be present and
// every one must carry the SAME non-null ExecutionId we minted above.
await AwaitAssertAsync(async () =>
{
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
// The ExecutionId filter dimension is the universal-correlation
// query an audit reader uses to pull every action of one run.
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(ExecutionId: executionId),
new AuditLogPaging(PageSize: 10));
// Both trust-boundary actions of the one execution have landed.
Assert.Equal(2, rows.Count);
// Every central row carries the SAME non-null ExecutionId — the
// core promise of the per-run correlation value.
Assert.All(rows, r =>
{
Assert.NotNull(r.ExecutionId);
Assert.Equal(executionId, r.ExecutionId);
Assert.Equal(siteId, r.SourceSiteId);
// Central stamps IngestedAtUtc; the site never sets it.
Assert.NotNull(r.IngestedAtUtc);
});
// The two rows are the two distinct trust-boundary actions — one
// outbound API call and one outbound DB write — proving the shared
// id spans different channels, not two rows of the same action.
Assert.Single(rows, r => r.Channel == AuditChannel.ApiOutbound && r.Kind == AuditKind.ApiCall);
Assert.Single(rows, r => r.Channel == AuditChannel.DbOutbound && r.Kind == AuditKind.DbWrite);
}, TimeSpan.FromSeconds(15));
}
/// <summary>
/// Builds a production <see cref="ScriptRuntimeContext"/> wired with the
/// outbound external-system client, the database gateway and the audit
/// writer, stamped with an explicit <paramref name="executionId"/>. The
/// actor refs are <see cref="ActorRefs.Nobody"/> — the ExternalSystem /
/// Database helpers exercised here never touch them.
/// </summary>
private static ScriptRuntimeContext CreateScriptContext(
IExternalSystemClient externalSystemClient,
IDatabaseGateway databaseGateway,
IAuditWriter auditWriter,
string siteId,
Guid executionId)
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
var sharedScriptLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.Instance);
return new ScriptRuntimeContext(
ActorRefs.Nobody,
ActorRefs.Nobody,
sharedScriptLibrary,
currentCallDepth: 0,
maxCallDepth: 10,
askTimeout: TimeSpan.FromSeconds(5),
instanceName: InstanceName,
logger: NullLogger.Instance,
externalSystemClient: externalSystemClient,
databaseGateway: databaseGateway,
storeAndForward: null,
siteCommunicationActor: null,
siteId: siteId,
sourceScript: SourceScript,
auditWriter: auditWriter,
operationTrackingStore: null,
cachedForwarder: null,
executionId: executionId);
}
}
@@ -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();
}
}
@@ -2,6 +2,8 @@ using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Site; using ScadaLink.AuditLog.Site;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.AuditLog.Tests.Site; namespace ScadaLink.AuditLog.Tests.Site;
@@ -41,9 +43,9 @@ public class SqliteAuditWriterSchemaTests
} }
[Fact] [Fact]
public void Opens_Creates_AuditLog_Table_With_20Columns_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_20Columns_And_PK_On_EventId)); var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId));
using (writer) using (writer)
{ {
using var connection = OpenVerifierConnection(dataSource); using var connection = OpenVerifierConnection(dataSource);
@@ -57,7 +59,7 @@ public class SqliteAuditWriterSchemaTests
columns.Add((reader.GetString(1), reader.GetInt32(5))); columns.Add((reader.GetString(1), reader.GetInt32(5)));
} }
Assert.Equal(20, columns.Count); Assert.Equal(22, columns.Count);
var expected = new[] var expected = new[]
{ {
@@ -65,7 +67,7 @@ public class SqliteAuditWriterSchemaTests
"SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target",
"Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail",
"RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra",
"ForwardState", "ForwardState", "ExecutionId", "ParentExecutionId",
}; };
Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n)); Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n));
@@ -125,4 +127,254 @@ public class SqliteAuditWriterSchemaTests
Assert.Equal(2, value); Assert.Equal(2, value);
} }
} }
// ----- ExecutionId schema-upgrade regression (persistent auditlog.db) ----- //
/// <summary>
/// The OLD pre-ExecutionId-branch <c>AuditLog</c> schema — the 20-column
/// CREATE TABLE WITHOUT the <c>ExecutionId</c> column. A real deployment's
/// on-disk <c>auditlog.db</c> already contains exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreExecutionIdSchema = """
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,
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 OLD 20-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, so closing this would discard the seeded
/// schema before the writer opens its own connection.
/// </summary>
private static SqliteConnection SeedOldSchemaDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreExecutionIdSchema;
cmd.ExecuteNonQuery();
return connection;
}
private static SqliteAuditWriter CreateWriterOver(string dataSource)
{
var options = new SqliteAuditWriterOptions { DatabasePath = dataSource };
return new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance,
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
}
private static bool ColumnExists(SqliteConnection connection, string columnName)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
cmd.Parameters.AddWithValue("$name", columnName);
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
}
[Fact]
public async Task Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips()
{
var dataSource = $"file:{nameof(Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A pre-branch deployment: auditlog.db already exists with the 20-column
// schema and NO ExecutionId column.
using var seedConnection = SeedOldSchemaDatabase(dataSource);
Assert.False(ColumnExists(seedConnection, "ExecutionId"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing ExecutionId column in — the
// CREATE TABLE IF NOT EXISTS alone is a no-op against the existing table.
var executionId = Guid.NewGuid();
await using (var writer = CreateWriterOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "ExecutionId"),
"SqliteAuditWriter must ALTER the ExecutionId column into a pre-existing AuditLog table.");
// A WriteAsync binding $ExecutionId must now succeed and round-trip;
// without the ALTER it would fail with "no such column: ExecutionId"
// 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,
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.ExecutionId);
}
// Idempotency: a second writer over the now-upgraded DB must not error
// (the probe sees ExecutionId already present and skips the ALTER).
await using (var writerAgain = CreateWriterOver(dataSource))
{
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);
}
}
} }
@@ -353,4 +353,37 @@ public class SqliteAuditWriterWriteTests
await writer.MarkReconciledAsync(new[] { Guid.NewGuid(), Guid.NewGuid() }); await writer.MarkReconciledAsync(new[] { Guid.NewGuid(), Guid.NewGuid() });
// Completes without throwing. // Completes without throwing.
} }
// ----- ExecutionId column (universal per-run correlation value) ----- //
[Fact]
public async Task WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow));
await using var _w = writer;
var executionId = Guid.NewGuid();
var evt = NewEvent() with { ExecutionId = executionId };
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.ExecutionId);
}
[Fact]
public async Task WriteAsync_NullExecutionId_RoundTripsAsNull()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_NullExecutionId_RoundTripsAsNull));
await using var _w = writer;
var evt = NewEvent() with { ExecutionId = null };
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Null(row.ExecutionId);
}
} }
@@ -31,7 +31,10 @@ public class CachedCallLifecycleBridgeTests
string channel = "ApiOutbound", string channel = "ApiOutbound",
int retryCount = 1, int retryCount = 1,
string? lastError = null, string? lastError = null,
int? httpStatus = null) => int? httpStatus = null,
Guid? executionId = null,
string? sourceScript = null,
Guid? parentExecutionId = null) =>
new( new(
TrackedOperationId: _id, TrackedOperationId: _id,
Channel: channel, Channel: channel,
@@ -44,7 +47,10 @@ public class CachedCallLifecycleBridgeTests
CreatedAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc), CreatedAtUtc: new DateTime(2026, 5, 20, 9, 0, 0, DateTimeKind.Utc),
OccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc), OccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
DurationMs: 42, DurationMs: 42,
SourceInstanceId: "Plant.Pump42"); SourceInstanceId: "Plant.Pump42",
ExecutionId: executionId,
SourceScript: sourceScript,
ParentExecutionId: parentExecutionId);
[Fact] [Fact]
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve() public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
@@ -184,4 +190,141 @@ public class CachedCallLifecycleBridgeTests
Assert.Equal(42, captured.Audit.DurationMs); Assert.Equal(42, captured.Audit.DurationMs);
Assert.Equal(_id.Value, captured.Audit.CorrelationId); Assert.Equal(_id.Value, captured.Audit.CorrelationId);
} }
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ──
[Fact]
public async Task RetryLoopAttemptedRow_CarriesExecutionIdAndSourceScript_FromContext()
{
// Task 4: the ExecutionId + SourceScript threaded through the S&F
// buffer arrive on the CachedCallAttemptContext; the bridge must stamp
// both onto the per-attempt ApiCallCached row (previously SourceScript
// was hard-coded null with a "not threaded through S&F" comment).
var executionId = 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,
executionId: executionId,
sourceScript: "Plant.Pump42/OnTick"));
var packet = Assert.Single(captured);
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
Assert.Equal(executionId, packet.Audit.ExecutionId);
Assert.Equal("Plant.Pump42/OnTick", packet.Audit.SourceScript);
}
[Fact]
public async Task RetryLoopCachedResolveRow_CarriesExecutionIdAndSourceScript_FromContext()
{
// The terminal CachedResolve row must also carry the threaded
// provenance so the whole retry-loop lifecycle is correlated.
var executionId = 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",
executionId: executionId,
sourceScript: "Plant.Tank/OnAlarm"));
Assert.Equal(2, captured.Count);
var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve);
Assert.Equal(executionId, resolve.Audit.ExecutionId);
Assert.Equal("Plant.Tank/OnAlarm", resolve.Audit.SourceScript);
var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached);
Assert.Equal(executionId, attempted.Audit.ExecutionId);
Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.SourceScript);
}
[Fact]
public async Task RetryLoopRow_NullExecutionIdAndSourceScript_RemainNull()
{
// Back-compat: a pre-Task-4 buffered row has no ExecutionId /
// SourceScript; the bridge must leave the audit row's fields 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.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);
}
} }
@@ -65,6 +65,8 @@ public class AuditQueryCommandTests
Target = "weather-api", Target = "weather-api",
Actor = "multi-role", Actor = "multi-role",
CorrelationId = "abc-123", CorrelationId = "abc-123",
ExecutionId = "def-456",
ParentExecutionId = "ghi-789",
ErrorsOnly = false, ErrorsOnly = false,
PageSize = 250, PageSize = 250,
}; };
@@ -81,6 +83,8 @@ public class AuditQueryCommandTests
Assert.Equal("weather-api", parsed["target"]); Assert.Equal("weather-api", parsed["target"]);
Assert.Equal("multi-role", parsed["actor"]); Assert.Equal("multi-role", parsed["actor"]);
Assert.Equal("abc-123", parsed["correlationId"]); 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("250", parsed["pageSize"]);
Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]); Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]);
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]); Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
@@ -155,9 +159,34 @@ public class AuditQueryCommandTests
Assert.Null(parsed["channel"]); Assert.Null(parsed["channel"]);
Assert.Null(parsed["status"]); Assert.Null(parsed["status"]);
Assert.Null(parsed["fromUtc"]); Assert.Null(parsed["fromUtc"]);
Assert.Null(parsed["correlationId"]);
Assert.Null(parsed["executionId"]);
Assert.Null(parsed["parentExecutionId"]);
Assert.Equal("100", parsed["pageSize"]); Assert.Equal("100", parsed["pageSize"]);
} }
[Fact]
public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter()
{
// --execution-id is a single-value Guid filter — mirrors --correlation-id.
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs { ExecutionId = "11111111-1111-1111-1111-111111111111" };
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
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 ------------------------------------------ // ---- HTTP execution / paging ------------------------------------------
private sealed class RecordingHandler : HttpMessageHandler private sealed class RecordingHandler : HttpMessageHandler
@@ -281,6 +310,30 @@ public class AuditQueryCommandTests
Assert.Empty(parse.Errors); Assert.Empty(parse.Errors);
} }
[Fact]
public void Query_ExecutionIdOption_IsAccepted()
{
// --execution-id is a single-value option — mirrors --correlation-id.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "query", "--execution-id", "11111111-1111-1111-1111-111111111111",
});
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) ---------------------------------- // ---- Enum-name validation (fast-fail) ----------------------------------
[Fact] [Fact]
@@ -65,6 +65,8 @@ internal static class AuditDataSeeder
string? target = null, string? target = null,
string? actor = null, string? actor = null,
Guid? correlationId = null, Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
int? httpStatus = null, int? httpStatus = null,
int? durationMs = null, int? durationMs = null,
string? errorMessage = null, string? errorMessage = null,
@@ -76,13 +78,13 @@ internal static class AuditDataSeeder
const string sql = @" const string sql = @"
INSERT INTO [AuditLog] INSERT INTO [AuditLog]
([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId], ([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId],
[SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target], [Status], [ExecutionId], [ParentExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target],
[HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary], [Status], [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
[ResponseSummary], [PayloadTruncated], [Extra], [ForwardState]) [ResponseSummary], [PayloadTruncated], [Extra], [ForwardState])
VALUES VALUES
(@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId, (@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId,
@sourceSiteId, NULL, NULL, @actor, @target, @status, @executionId, @parentExecutionId, @sourceSiteId, NULL, NULL, @actor, @target,
@httpStatus, @durationMs, @errorMessage, NULL, @requestSummary, @status, @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
@responseSummary, 0, @extra, NULL);"; @responseSummary, 0, @extra, NULL);";
await using var connection = new SqlConnection(ConnectionString); await using var connection = new SqlConnection(ConnectionString);
@@ -94,6 +96,8 @@ VALUES
cmd.Parameters.AddWithValue("@channel", channel); cmd.Parameters.AddWithValue("@channel", channel);
cmd.Parameters.AddWithValue("@kind", kind); cmd.Parameters.AddWithValue("@kind", kind);
cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value); 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("@sourceSiteId", (object?)sourceSiteId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value); cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value);
cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value); cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value);
@@ -24,6 +24,13 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
/// link relies on; verified by reproducing the link target directly because /// link relies on; verified by reproducing the link target directly because
/// seeding a notification visible to the report page requires the Akka query /// seeding a notification visible to the report page requires the Akka query
/// path, not just an INSERT).</item> /// path, not just an INSERT).</item>
/// <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> — /// <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
/// the report page wires drill-in links when notifications are present.</item> /// the report page wires drill-in links when notifications are present.</item>
/// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on /// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on
@@ -289,6 +296,226 @@ public class AuditLogPageTests
} }
} }
[Fact]
public async Task DrillInFromExecutionId_LandsOnAuditLogWithFilterContext()
{
// Mirrors the correlationId drill-in: the "View this execution" drawer
// action navigates to /audit/log?executionId={ExecutionId}. We seed a row
// carrying that ExecutionId, hit the deep link directly, and assert the
// page deserializes the param and auto-loads the seeded row.
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-drill-in/{runId}/";
var executionId = Guid.NewGuid();
var eventId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
await AuditDataSeeder.InsertAuditEventAsync(
eventId: eventId,
occurredAtUtc: now,
channel: "ApiOutbound",
kind: "ApiCall",
status: "Delivered",
target: targetPrefix + "endpoint",
executionId: executionId,
httpStatus: 200,
durationMs: 11);
var page = await _fixture.NewAuthenticatedPageAsync();
// The exact URL the drawer's "View this execution" button produces.
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={executionId}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
Assert.Contains($"executionId={executionId}", page.Url);
await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
await Assertions.Expect(page.Locator("[data-test='audit-filter-bar']")).ToBeVisibleAsync();
await Assertions.Expect(page.Locator("[data-test='audit-results-grid']")).ToBeVisibleAsync();
// Auto-load: the query-string drill-in resolves the ?executionId=
// filter on OnInitialized and the seeded row appears without an
// Apply click.
var seededRow = page.Locator($"[data-test='grid-row-{eventId}']");
await Assertions.Expect(seededRow).ToBeVisibleAsync();
// The ExecutionId column renders the row's short-form value.
var execCell = page.Locator($"[data-test='execution-id-{eventId}']");
await Assertions.Expect(execCell).ToBeVisibleAsync();
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[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();
// The drawer's NavigateTo is a same-page (query-string-only) Blazor
// navigation: it pushes history.pushState over the SignalR circuit
// rather than triggering a document load, so WaitForLoadState would
// return before the URL settles. WaitForURLAsync is the correct wait
// primitive for SPA/pushState navigations.
await page.WaitForURLAsync($"**/audit/log?executionId={parentExecutionId}");
// 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] [Fact]
public async Task NotificationsPage_RendersAuditDrillInLinkPattern() public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
{ {
@@ -138,6 +138,8 @@ public class AuditExportEndpointsTests
using (host) using (host)
{ {
var correlationId = Guid.NewGuid().ToString(); var correlationId = Guid.NewGuid().ToString();
var executionId = Guid.NewGuid().ToString();
var parentExecutionId = Guid.NewGuid().ToString();
var url = var url =
"/api/centralui/audit/export?" + "/api/centralui/audit/export?" +
"channel=ApiOutbound&" + "channel=ApiOutbound&" +
@@ -147,6 +149,8 @@ public class AuditExportEndpointsTests
"target=PaymentApi&" + "target=PaymentApi&" +
"actor=apikey-1&" + "actor=apikey-1&" +
$"correlationId={correlationId}&" + $"correlationId={correlationId}&" +
$"executionId={executionId}&" +
$"parentExecutionId={parentExecutionId}&" +
"from=2026-05-20T00:00:00Z&" + "from=2026-05-20T00:00:00Z&" +
"to=2026-05-20T23:59:59Z"; "to=2026-05-20T23:59:59Z";
@@ -167,6 +171,8 @@ public class AuditExportEndpointsTests
f.Target == "PaymentApi" && f.Target == "PaymentApi" &&
f.Actor == "apikey-1" && f.Actor == "apikey-1" &&
f.CorrelationId == Guid.Parse(correlationId) && 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.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) &&
f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)), f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)),
Arg.Any<AuditLogPaging>(), Arg.Any<AuditLogPaging>(),
@@ -195,6 +201,8 @@ public class AuditExportEndpointsTests
f.Target == null && f.Target == null &&
f.Actor == null && f.Actor == null &&
f.CorrelationId == null && f.CorrelationId == null &&
f.ExecutionId == null &&
f.ParentExecutionId == null &&
f.FromUtc == null && f.FromUtc == null &&
f.ToUtc == null), f.ToUtc == null),
Arg.Any<AuditLogPaging>(), Arg.Any<AuditLogPaging>(),
@@ -222,6 +230,44 @@ public class AuditExportEndpointsTests
} }
} }
[Fact]
public async Task ExportEndpoint_UnparseableExecutionId_SilentlyDropped()
{
// Lax-parse contract: an unparseable executionId is dropped (no 400) —
// mirrors the correlationId parse.
var (client, repo, host) = await BuildHostAsync();
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export?executionId=not-a-guid");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == null),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
[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> /// <summary>
/// Test-only authentication handler that signs every request in as an Admin. /// Test-only authentication handler that signs every request in as an Admin.
/// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy /// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy
@@ -40,6 +40,8 @@ public class AuditDrilldownDrawerTests : BunitContext
string? responseSummary = null, string? responseSummary = null,
string? extra = null, string? extra = null,
Guid? correlationId = null, Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
string? errorMessage = null, string? errorMessage = null,
string? errorDetail = null, string? errorDetail = null,
string? target = "demo-target") string? target = "demo-target")
@@ -51,6 +53,8 @@ public class AuditDrilldownDrawerTests : BunitContext
Channel = channel, Channel = channel,
Kind = kind, Kind = kind,
CorrelationId = correlationId, CorrelationId = correlationId,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
SourceSiteId = "plant-a", SourceSiteId = "plant-a",
SourceInstanceId = "boiler-3", SourceInstanceId = "boiler-3",
SourceScript = "OnAlarm.csx", SourceScript = "OnAlarm.csx",
@@ -216,6 +220,131 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains(corr.ToString(), nav.Uri); Assert.Contains(corr.ToString(), nav.Uri);
} }
[Fact]
public void Drawer_NullExecutionId_HidesViewThisExecutionButton()
{
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-this-execution\"", cut.Markup);
}
[Fact]
public void Drawer_NonNullExecutionId_ShowsViewThisExecutionButton()
{
var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-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-this-execution\"", cut.Markup);
}
[Fact]
public void ViewThisExecution_Navigates_WithExecutionIdQueryString()
{
var exec = Guid.Parse("dddddddd-cccc-bbbb-aaaa-999999999999");
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-this-execution\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
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] [Fact]
public async Task CopyAsCurl_InvokesClipboard_WithCurlString() public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
{ {
@@ -61,6 +61,8 @@ public class AuditFilterBarTests : BunitContext
"data-test=\"filter-script\"", "data-test=\"filter-script\"",
"data-test=\"filter-target\"", "data-test=\"filter-target\"",
"data-test=\"filter-actor\"", "data-test=\"filter-actor\"",
"data-test=\"filter-execution-id\"",
"data-test=\"filter-parent-execution-id\"",
"data-test=\"filter-errors-only\"", "data-test=\"filter-errors-only\"",
}; };
foreach (var marker in markers) foreach (var marker in markers)
@@ -178,6 +180,78 @@ public class AuditFilterBarTests : BunitContext
Assert.Contains(AuditStatus.Failed, captured.Statuses); Assert.Contains(AuditStatus.Failed, captured.Statuses);
} }
[Fact]
public void Apply_WithPastedExecutionId_MapsThroughToFilter()
{
// The operator pastes a Guid into the Execution ID box; Apply must map it
// straight onto AuditLogQueryFilter.ExecutionId.
var executionId = Guid.Parse("99999999-8888-7777-6666-555555555555");
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-execution-id\"] input").Change(executionId.ToString());
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(executionId, captured!.ExecutionId);
}
[Fact]
public void Apply_WithBlankOrUnparseableExecutionId_LeavesFilterExecutionIdNull()
{
// 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!.ExecutionId);
// Unparseable paste — still dropped, no error.
cut.Find("[data-test=\"filter-execution-id\"] input").Change("not-a-guid");
cut.Find("[data-test=\"filter-apply\"]").Click();
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] [Fact]
public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo() public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
{ {
@@ -22,7 +22,7 @@ public class AuditResultsGridTests : BunitContext
private readonly IAuditLogQueryService _service; private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new(); 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") 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() => new()
{ {
EventId = Guid.NewGuid(), EventId = Guid.NewGuid(),
@@ -33,6 +33,8 @@ public class AuditResultsGridTests : BunitContext
SourceSiteId = site, SourceSiteId = site,
Target = "demo-target", Target = "demo-target",
Actor = "tester", Actor = "tester",
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
DurationMs = 42, DurationMs = 42,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500, HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null, ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
@@ -121,6 +123,92 @@ public class AuditResultsGridTests : BunitContext
Assert.Equal(target.EventId, captured!.EventId); Assert.Equal(target.EventId, captured!.EventId);
} }
[Fact]
public void Render_IncludesExecutionIdColumn()
{
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// The ExecutionId column header is present alongside the spec columns.
Assert.Contains("data-test=\"col-header-ExecutionId\"", cut.Markup);
}
[Fact]
public void ExecutionId_NonNullRow_RendersShortMonospaceValue()
{
var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555");
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: executionId);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
var cell = cut.Find($"[data-test=\"execution-id-{row.EventId}\"]");
// Short form: first 8 hex digits of the "N" form.
Assert.Equal("abcdef01", cell.TextContent.Trim());
// Monospace presentation; full value retained in the title attribute.
Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty);
Assert.Equal(executionId.ToString(), cell.GetAttribute("title"));
}
[Fact]
public void ExecutionId_NullRow_RendersBlankPlaceholder_NoExecutionIdCell()
{
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: null);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// A null ExecutionId renders the em-dash placeholder, not a value cell.
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] [Fact]
public void Status_FailedRow_HasErrorBadgeClass() public void Status_FailedRow_HasErrorBadgeClass()
{ {
@@ -149,7 +237,8 @@ public class AuditResultsGridTests : BunitContext
private static readonly string[] DefaultOrder = private static readonly string[] DefaultOrder =
{ {
"OccurredAtUtc", "Site", "Channel", "Kind", "Status", "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) private static int HeaderIndex(string markup, string key)
@@ -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;
}
}
@@ -75,6 +75,34 @@ public class AuditLogPageExportUrlTests
Assert.Equal("Notification", query["channel"]); Assert.Equal("Notification", query["channel"]);
} }
[Fact]
public void BuildExportUrl_ExecutionIdSet_EmitsExecutionIdParam()
{
var exec = Guid.Parse("12121212-3434-5656-7878-909090909090");
var filter = new AuditLogQueryFilter(ExecutionId: exec);
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(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] [Fact]
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams() public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
{ {
@@ -176,6 +176,83 @@ public class AuditLogPageScaffoldTests : BunitContext
}); });
} }
[Fact]
public void NavigateWithExecutionIdParam_AppliesFilter_AndAutoLoads()
{
// The "View this execution" drill-in lands on /audit/log?executionId={id}.
// The page parses the Guid, builds an AuditLogQueryFilter with ExecutionId
// set, and auto-loads the grid.
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
_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($"executionId={executionId}", "Admin");
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == executionId),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void NavigateWithUnparseableExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
{
_queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderAuditLogPageWithQuery("executionId=not-a-guid", "Admin");
// An unparseable executionId leaves ExecutionId 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 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] [Fact]
public void NavigateWithTargetParam_AppliesTargetFilter() public void NavigateWithTargetParam_AppliesTargetFilter()
{ {
@@ -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);
}
}
@@ -222,6 +222,66 @@ public class AuditLogQueryServiceTests
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]); 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) private static SiteHealthState StateWithBacklog(string siteId, int? pending)
{ {
SiteAuditBacklogSnapshot? backlog = pending.HasValue SiteAuditBacklogSnapshot? backlog = pending.HasValue
@@ -17,6 +17,7 @@ public class AuditEventTests
var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc); var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc); var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc);
var corrId = Guid.NewGuid(); var corrId = Guid.NewGuid();
var execId = Guid.NewGuid();
var evt = new AuditEvent var evt = new AuditEvent
{ {
@@ -26,6 +27,7 @@ public class AuditEventTests
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall, Kind = AuditKind.ApiCall,
CorrelationId = corrId, CorrelationId = corrId,
ExecutionId = execId,
SourceSiteId = "site-01", SourceSiteId = "site-01",
SourceInstanceId = "inst-7", SourceInstanceId = "inst-7",
SourceScript = "OnAlarm", SourceScript = "OnAlarm",
@@ -49,6 +51,7 @@ public class AuditEventTests
Assert.Equal(AuditChannel.ApiOutbound, evt.Channel); Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
Assert.Equal(AuditKind.ApiCall, evt.Kind); Assert.Equal(AuditKind.ApiCall, evt.Kind);
Assert.Equal(corrId, evt.CorrelationId); Assert.Equal(corrId, evt.CorrelationId);
Assert.Equal(execId, evt.ExecutionId);
Assert.Equal("site-01", evt.SourceSiteId); Assert.Equal("site-01", evt.SourceSiteId);
Assert.Equal("inst-7", evt.SourceInstanceId); Assert.Equal("inst-7", evt.SourceInstanceId);
Assert.Equal("OnAlarm", evt.SourceScript); Assert.Equal("OnAlarm", evt.SourceScript);
@@ -77,6 +80,7 @@ public class AuditEventTests
Channel = AuditChannel.Notification, Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend, Kind = AuditKind.NotifySend,
CorrelationId = null, CorrelationId = null,
ExecutionId = null,
SourceSiteId = null, SourceSiteId = null,
SourceInstanceId = null, SourceInstanceId = null,
SourceScript = null, SourceScript = null,
@@ -96,6 +100,7 @@ public class AuditEventTests
Assert.Null(evt.IngestedAtUtc); Assert.Null(evt.IngestedAtUtc);
Assert.Null(evt.CorrelationId); Assert.Null(evt.CorrelationId);
Assert.Null(evt.ExecutionId);
Assert.Null(evt.SourceSiteId); Assert.Null(evt.SourceSiteId);
Assert.Null(evt.SourceInstanceId); Assert.Null(evt.SourceInstanceId);
Assert.Null(evt.SourceScript); Assert.Null(evt.SourceScript);
@@ -21,6 +21,36 @@ public class NotificationEntityTests
Assert.Equal("SiteA", n.SourceSiteId); Assert.Equal("SiteA", n.SourceSiteId);
} }
[Fact]
public void OriginExecutionId_DefaultsToNull_AndIsSettable()
{
// Audit Log #23: OriginExecutionId carries the originating script
// execution's id from the site so the dispatcher can echo it onto
// NotifyDeliver rows. Null for notifications submitted before the
// column existed; settable from the NotificationSubmit message.
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
Assert.Null(n.OriginExecutionId);
var executionId = Guid.NewGuid();
n.OriginExecutionId = executionId;
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] [Fact]
public void Constructor_NullArguments_Throw() public void Constructor_NullArguments_Throw()
{ {
@@ -40,6 +40,92 @@ public class NotificationMessagesTests
Assert.Null(msg.SourceScript); Assert.Null(msg.SourceScript);
} }
[Fact]
public void NotificationSubmit_OriginExecutionId_DefaultsToNull()
{
// Audit Log #23: OriginExecutionId is an additive trailing member — a
// submit built without it (old call sites / old serialized payloads)
// leaves the id null.
var msg = new NotificationSubmit(
"notif-3", "Operators", "Subject", "Body",
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow);
Assert.Null(msg.OriginExecutionId);
}
[Fact]
public void NotificationSubmit_OriginExecutionId_RoundTripsWhenSupplied()
{
var executionId = Guid.NewGuid();
var msg = new NotificationSubmit(
"notif-4", "Operators", "Subject", "Body",
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId);
Assert.Equal(executionId, msg.OriginExecutionId);
}
[Fact]
public void NotificationSubmit_OriginExecutionId_SurvivesJsonRoundTrip()
{
// The buffered S&F payload IS a serialized NotificationSubmit; the
// forwarder deserializes it, so OriginExecutionId must survive JSON.
var executionId = Guid.NewGuid();
var msg = new NotificationSubmit(
"notif-5", "Operators", "Subject", "Body",
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId);
var json = System.Text.Json.JsonSerializer.Serialize(msg);
var roundTripped = System.Text.Json.JsonSerializer.Deserialize<NotificationSubmit>(json);
Assert.NotNull(roundTripped);
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] [Fact]
public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch() public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
{ {
@@ -19,6 +19,8 @@ public class AuditEventDtoMapperTests
var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc); var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc);
var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc); var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc);
var correlationId = Guid.NewGuid(); var correlationId = Guid.NewGuid();
var executionId = Guid.NewGuid();
var parentExecutionId = Guid.NewGuid();
var eventId = Guid.NewGuid(); var eventId = Guid.NewGuid();
var original = new AuditEvent var original = new AuditEvent
@@ -29,6 +31,8 @@ public class AuditEventDtoMapperTests
Channel = AuditChannel.ApiOutbound, Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCallCached, Kind = AuditKind.ApiCallCached,
CorrelationId = correlationId, CorrelationId = correlationId,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
SourceSiteId = "site-1", SourceSiteId = "site-1",
SourceInstanceId = "Pump01", SourceInstanceId = "Pump01",
SourceScript = "OnDemand", SourceScript = "OnDemand",
@@ -54,6 +58,8 @@ public class AuditEventDtoMapperTests
Assert.Equal(original.Channel, roundTripped.Channel); Assert.Equal(original.Channel, roundTripped.Channel);
Assert.Equal(original.Kind, roundTripped.Kind); Assert.Equal(original.Kind, roundTripped.Kind);
Assert.Equal(original.CorrelationId, roundTripped.CorrelationId); 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.SourceSiteId, roundTripped.SourceSiteId);
Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId); Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId);
Assert.Equal(original.SourceScript, roundTripped.SourceScript); Assert.Equal(original.SourceScript, roundTripped.SourceScript);
@@ -90,6 +96,8 @@ public class AuditEventDtoMapperTests
var dto = AuditEventDtoMapper.ToDto(evt); var dto = AuditEventDtoMapper.ToDto(evt);
Assert.Equal(string.Empty, dto.CorrelationId); 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.SourceSiteId);
Assert.Equal(string.Empty, dto.SourceInstanceId); Assert.Equal(string.Empty, dto.SourceInstanceId);
Assert.Equal(string.Empty, dto.SourceScript); Assert.Equal(string.Empty, dto.SourceScript);
@@ -113,6 +121,8 @@ public class AuditEventDtoMapperTests
Kind = nameof(AuditKind.ApiCall), Kind = nameof(AuditKind.ApiCall),
Status = nameof(AuditStatus.Submitted), Status = nameof(AuditStatus.Submitted),
CorrelationId = string.Empty, CorrelationId = string.Empty,
ExecutionId = string.Empty,
ParentExecutionId = string.Empty,
SourceSiteId = string.Empty, SourceSiteId = string.Empty,
SourceInstanceId = string.Empty, SourceInstanceId = string.Empty,
SourceScript = string.Empty, SourceScript = string.Empty,
@@ -128,6 +138,8 @@ public class AuditEventDtoMapperTests
var evt = AuditEventDtoMapper.FromDto(dto); var evt = AuditEventDtoMapper.FromDto(dto);
Assert.Null(evt.CorrelationId); Assert.Null(evt.CorrelationId);
Assert.Null(evt.ExecutionId);
Assert.Null(evt.ParentExecutionId);
Assert.Null(evt.SourceSiteId); Assert.Null(evt.SourceSiteId);
Assert.Null(evt.SourceInstanceId); Assert.Null(evt.SourceInstanceId);
Assert.Null(evt.SourceScript); Assert.Null(evt.SourceScript);
@@ -74,8 +74,10 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
.Where(p => !p.IsShadowProperty()) .Where(p => !p.IsShadowProperty())
.ToList(); .ToList();
// AuditEvent record exposes 21 init-only properties (alog.md §4). // AuditEvent record exposes 23 init-only properties (alog.md §4 plus the
Assert.Equal(21, properties.Count); // additive ExecutionId universal correlation column and its
// ParentExecutionId sibling).
Assert.Equal(23, properties.Count);
} }
[Fact] [Fact]
@@ -90,12 +92,16 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
.ToList(); .ToList();
// Five reconciliation/query indexes from alog.md §4, plus the EventId unique // Five reconciliation/query indexes from alog.md §4, plus the EventId unique
// index introduced alongside the composite PK (Bundle C). // index introduced alongside the composite PK (Bundle C), plus the additive
// IX_AuditLog_Execution index supporting ExecutionId lookups and the
// IX_AuditLog_ParentExecution index supporting ParentExecutionId lookups.
var expected = new[] var expected = new[]
{ {
"IX_AuditLog_Channel_Status_Occurred", "IX_AuditLog_Channel_Status_Occurred",
"IX_AuditLog_CorrelationId", "IX_AuditLog_CorrelationId",
"IX_AuditLog_Execution",
"IX_AuditLog_OccurredAtUtc", "IX_AuditLog_OccurredAtUtc",
"IX_AuditLog_ParentExecution",
"IX_AuditLog_Site_Occurred", "IX_AuditLog_Site_Occurred",
"IX_AuditLog_Target_Occurred", "IX_AuditLog_Target_Occurred",
"UX_AuditLog_EventId", "UX_AuditLog_EventId",
@@ -136,5 +142,13 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
var targetIdx = entity.GetIndexes() var targetIdx = entity.GetIndexes()
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred"); .Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred");
Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter()); Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter());
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());
} }
} }

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