18 Commits

Author SHA1 Message Date
Joseph Doherty b58f3aeb61 docs: list DelmiaNotifier (#27) in component tables (standalone external client tool) 2026-06-26 05:25:09 -04:00
Joseph Doherty 99ceb6677f docs(delmia-notifier): README + publish/AOT instructions 2026-06-26 05:19:24 -04:00
Joseph Doherty 3931fa2101 feat(delmia-notifier): Program wiring, YES/NO reporter, diagnostics log 2026-06-26 05:18:19 -04:00
Joseph Doherty 71f680d542 feat(delmia-notifier): HttpClient recipe sender with connect-failure classification 2026-06-26 05:15:17 -04:00
Joseph Doherty d26462ed8d feat(delmia-notifier): connect-failure-only failover loop 2026-06-26 05:13:58 -04:00
Joseph Doherty 991c263c3e feat(delmia-notifier): config loader + SCADABRIDGE_API_KEY resolution 2026-06-26 05:12:27 -04:00
Joseph Doherty 3e964acff6 feat(delmia-notifier): CLI arg parser with required/optional validation 2026-06-26 05:11:19 -04:00
Joseph Doherty 82bde9693e feat(delmia-notifier): recipe DTOs + JSON source-gen context 2026-06-26 05:10:23 -04:00
Joseph Doherty 069881ac9b feat(delmia-notifier): scaffold DelmiaNotifier src + test projects 2026-06-26 05:08:54 -04:00
Joseph Doherty 9ce6783139 docs(delmia-notifier): implementation plan + task persistence (8 TDD tasks) 2026-06-26 05:01:16 -04:00
Joseph Doherty 0008ca891c docs(delmia-notifier): design for DelmiaNotifier console app (WWNotifier modern replacement)
Compact Native-AOT win-x64 console app DELMIA invokes to notify ScadaBridge of a
recipe download via POST /api/DelmiaRecipeDownload (X-API-Key). Drop-in CLI/output
parity with legacy WWNotifier; appsettings.json + SCADABRIDGE_API_KEY env var;
comma-list base URLs with connect-failure-only failover.
2026-06-26 04:55:40 -04:00
Joseph Doherty 8a78e759c0 docs: former-api-specs (MES + DNC/Delmia) + inbound compile-error known issue
- former-api-specs/mes: Alarm-API, MoveIn-MoveOut-API, API-key authgaps (from ~/Desktop/mesapi)
- former-api-specs/dnc: Delmia-Integration-API — Delmia document service + WW recipe-download notify (from ~/Desktop/delmiaintegration)
- known-issues: inbound API compile error not client-visible; no api-method validate
2026-06-26 04:13:19 -04:00
Joseph Doherty 33da8c797c docs(ipsen-movein): rewrite task plan to async QuerySingleAsync helper
Match the shipped InboundDatabaseHelper throughout the implementation plan:
QuerySingle->QuerySingleAsync (and Query->QueryAsync) — async signatures
(async Task<T?>), awaited async ADO.NET (ExecuteScalarAsync/ExecuteReaderAsync/
ReadAsync with the deadline token), async Task test methods with await, and the
architecture/step/acceptance prose + pseudocode now call
await Database.QuerySingleAsync<T>(...). Sibling fix to the design-doc correction.
2026-06-25 14:15:39 -04:00
Joseph Doherty 66bbbb7a31 docs(ipsen-movein): correct inbound DB helper to async QuerySingleAsync in design doc
The IpsenMES MoveIn design-doc pseudocode and helper-surface sketch used the
synchronous, read-only `Database.QuerySingle<T>`/`Query`. The shipped
InboundDatabaseHelper is async and write-capable: `await QuerySingleAsync<T>`,
`QueryAsync`, `ExecuteAsync` (InboundAPI-026/027).

Three inbound methods authored from this draft (IpsenMESMoveIn, MesMoveIn,
MesMoveOut) failed Roslyn compilation in production until corrected to
`await Database.QuerySingleAsync<...>(...)` (2026-06-25). Fix the pseudocode,
the helper-surface bullet, and the inline reference, and add a dated correction
note pointing at the authoritative Component-InboundAPI.md surface.
2026-06-25 14:11:25 -04:00
Joseph Doherty 1f261263b2 fix(centralui): surface inherited compositions in the templates tree (followup #9)
The templates tree rendered a derived/composed member (e.g. LeftReactorSide,
derived from ReactorSide) as a flat leaf, omitting compositions it inherits
from its base (e.g. LeakTest composed onto ReactorSide). BuildCompositionLeavesFor
recursed only over a template's OWN composition rows; an inherited composition
row lives on the ancestor, and TemplateComposition has no IsInherited placeholder
(unlike attributes/alarms/scripts/native-sources), so the child's own Compositions
was empty. Same 'derived templates don't surface inherited members' family as
followups #1/#2, but for compositions. Deploy/flatten was always correct
(TemplateResolver.ResolveAllMembers walks the chain) — display-only.

Fix:
- BuildCompositionLeavesFor now renders the EFFECTIVE composition set (own +
  inherited) via EffectiveCompositionsFor, which walks the inheritance chain
  (leaf->root, child wins on InstanceName), mirroring the resolver.
- Inherited slots are flagged (TemplateTreeNode.IsInherited), badged 'inherited'
  in the label, and their context menu offers only 'Open composed template'
  (Rename/Delete edit the ancestor's slot, so suppressed on inherited nodes).
- The same inherited row can appear under several derived members (LeakTest under
  both LeftReactorSide and RightReactorSide), so composition nodes use a
  path-qualified KeyOverride to keep TreeView selection/expansion keys unique;
  recursion is cycle-guarded.

Tests: +1 bUnit (TemplatesPageTests.Renders_InheritedComposition_UnderDerivedComposedMember);
CentralUI suite 867 green; full solution builds 0/0.

Docs: Component-CentralUI.md (effective composition set in tree); known-issues
tracker #9 recorded + resolved.

Note: CentralUI change — shows on wonder-app-vd03 only after that host is redeployed.
2026-06-24 19:29:48 -04:00
Joseph Doherty 7747f25c9e docs(known-issues): mark central-report singleton-hang fix as committed (was stale 'pending commit')
The HOST-021 fix (AkkaHostedService.GetOrCreateActorSystem + AddSingleton<ActorSystem>
in Program.cs/SiteServiceRegistration.cs) is committed and live on main; the tracker
still read 'pending commit' / 'Fix (pending, task #48)'. Status corrected.
2026-06-24 19:05:42 -04:00
Joseph Doherty cdd65beb6c feat(cli+templateengine+deploymanager): resolve follow-ups #4/#5/#6/#8 — CLI ergonomics + structured deploy validation error
Closes the four remaining items in the 2026-06-24 template-inheritance/CLI
follow-up tracker.

#4 — CLI `instance set-bindings` can now set DataSourceReferenceOverride.
  `--bindings` accepts an optional 3rd element per entry:
  [attributeName, dataConnectionId, dataSourceReferenceOverride]. A string
  sets the override; a JSON null or an omitted 3rd element leaves it unset
  (template default). TryParseBindings accepts 2- or 3-element entries and
  rejects a non-string/non-null 3rd element or 4+ elements with a clean
  error. Previously the CLI sent the override as null and silently wiped any
  existing one (only a raw POST /management could set it).

#5 — `template update` is partial, not full-replace (fixed server-side so all
  clients benefit). UpdateTemplateAsync now uses leave-unchanged semantics:
  a null description keeps the stored value (pass "" to clear); a null
  parentTemplateId keeps the existing parent. Parent stays immutable — a
  non-null differing value is still rejected — but omitting --parent-id is
  now a no-op instead of failing every derived-template update.

#6 — compact `template list`/`get` table output + `--detail`. Table output is
  now id/name/description/parent/derived + member counts (#attrs/#alarms/
  #scripts/#comps/#nativeAlarms) via TemplateTableProjection, fed through a
  new optional tableProjector seam on CommandHelpers. `--detail` restores the
  full dump. JSON output is left untouched (always full) so machine consumers
  are unaffected — the projector only runs on the table path.

#8 — structured deploy-time validation error. New ValidationResult.SummarizeErrors()
  (Commons) returns a grouped, capped summary: leading total count, one line
  per ValidationCategory, and a per-module rollup (canonical name up to its
  last dot) with counts + "... and N more module(s)" caps. DeploymentService
  uses it for the "Pre-deployment validation failed" message and logs the full
  per-entry list via LogWarning. Replaces the flat semicolon-joined dump that
  became a wall of text for instances with 50-194 unbound attributes.

Tests: +8 Commons (SummarizeErrors), +8 CLI (4 binding 3-element / 4 table
projection), +2 net TemplateEngine (partial-update). Affected suites green:
Commons 587, CLI 341, TemplateEngine 447, DeploymentManager 101,
ManagementService 230, CentralUI 866; full solution builds 0/0.

Docs: Component-DeploymentManager.md "Validation Error Reporting"; CLI README
(set-bindings 3-element form, template update leave-unchanged, list/get
--detail); UpdateTemplateCommand doc; known-issues tracker #4/#5/#6/#8 resolved
(all 8 items now closed).
2026-06-24 18:27:42 -04:00
Joseph Doherty 2b5949320c feat(templateengine+centralui): resolve follow-ups #1/#2 — inherited-member propagation & resync
Derived templates store IsInherited placeholder rows mirroring inherited
members, but a base member added/changed/removed AFTER a child was derived
never reached the child — leaving the editor's editable tabs incomplete (#1)
and stored rows drifted from the resolved set (#2).

Fix (one order-independent reconcile, two entry points):
- Auto-propagation: every attribute/alarm/script add/update/delete now
  reconciles the template's derived subtree (TemplateService.ReconcileDescendantsAsync),
  hooked into all member-mutating paths incl. native-alarm-source CRUD in the
  ManagementActor.
- Resync: ResyncInheritedMembersAsync repairs a template + its subtree on
  demand — materialize missing placeholders, re-sync drifted ones, remove
  orphans, across attributes/alarms/scripts/native sources. Exposed as
  management ResyncInheritedMembersCommand (Designer-gated, audited) → CLI
  `template resync-members` → a Resync button on the editor's staleness banner.

Reconcile drives off TemplateInheritanceResolver (same precedence + HiLo merge
as deploy), only ever touches IsInherited placeholders (never an authored
override), and matches the staleness comparison keys so the banner clears.
BuildDerivedTemplate now also materializes native-source placeholders at
compose time (previously omitted → any inherited native source was perpetually
stale).

Tests: +8 TemplateServiceTests (materialize / drift-update / orphan-remove /
override-untouched / base-cascade / multi-type / direct-propagate / end-to-end
add) + 1 ManagementService test fix (native-source add resolves TemplateService).
Affected suites green: TemplateEngine 446, ManagementService 230, CentralUI 866,
CLI 333, Transport 127, ConfigurationDatabase 307; full solution builds 0/0.

Docs: Component-TemplateEngine.md "Inherited-Member Propagation & Resync";
CLI README `template resync-members`; known-issues tracker #1/#2 resolved.
2026-06-24 15:51:26 -04:00
61 changed files with 4290 additions and 115 deletions
+2 -1
View File
@@ -63,7 +63,7 @@ Related repos cloned as sibling directories under `~/Desktop/` — referenced fo
- Commit related changes together with a descriptive message summarizing the design decision and the implementation slice.
- After non-trivial code changes, build (`dotnet build ZB.MOM.WW.ScadaBridge.slnx`) and run relevant tests before declaring done; for cluster-runtime changes, rebuild the image with `bash docker/deploy.sh`.
## Current Component List (26 components)
## Current Component List (27 components)
1. Template Engine — Template modeling, inheritance, composition, validation, flattening, diffs.
2. Deployment Manager — Central-side deployment pipeline, system-wide artifact deployment, instance lifecycle.
@@ -91,6 +91,7 @@ Related repos cloned as sibling directories under `~/Desktop/` — referenced fo
24. Transport — File-based, encrypted bundle export/import via Central UI. Templates, system artifacts, central-only configuration, plus site/instance-scoped config (`Site`s, site `DataConnection`s, `Instance`s) reconciled across environments by a `BundleNameMap` name-mapping subsystem. Per-conflict resolution with a per-line Myers diff. Correlated audit via `BundleImportId`. Never touches site runtime nodes (imported instances land `NotDeployed`).
25. Script Analysis — Shared authoritative script-trust analyzer: unified forbidden-API deny-list (`ScriptTrustPolicy`), fused semantic+syntactic validator (`ScriptTrustValidator`), Roslyn compile wrapper (`RoslynScriptCompiler`), and compile-only globals stubs (`ScriptCompileSurface`/`TriggerCompileSurface`); consumed by Template Engine, Site Runtime, Inbound API, and Central UI.
26. KPI History — Reusable central KPI-history backbone: tall/EAV `KpiSample` store in central MS SQL, `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated) sampling DI-registered `IKpiSampleSource`s every minute, bucketed query (`GetRawSeriesAsync` + `KpiSeriesBucketer`) + scoped `KpiHistoryQueryService`, and a reusable custom-SVG `KpiTrendChart`; ships trends for Notification Outbox, Site Call Audit, Audit Log, and Site Health.
27. DelmiaNotifier — Standalone external client tool (NOT a cluster component, NOT in the Host): a compact Native-AOT (`win-x64`) console app (`WWNotifier.exe`) that DELMIA Apriso shells out to per recipe download. POSTs to the Inbound API `DelmiaRecipeDownload` method (`X-API-Key`, key from `SCADABRIDGE_API_KEY`), with connect-failure-only failover across a comma-list of base URLs, and reports the legacy `YES`/`NO` + exit-code stdout contract — a drop-in replacement for the legacy `WWNotifier` (see `docs/former-api-specs/dnc/`). Zero-dependency BCL-only, `System.Text.Json` source-gen. Project README: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md`; design: `docs/plans/2026-06-26-delmia-recipe-notifier-design.md`.
## Key Design Decisions (for context across sessions)
+1
View File
@@ -102,6 +102,7 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA
| 24 | Transport | [docs/requirements/Component-Transport.md](docs/requirements/Component-Transport.md) | Bundle export/import for templates, shared scripts, external systems, central-only artifacts, plus site-scoped config — `Site`s, site `DataConnection`s, and `Instance`s — reconciled across environments by a `BundleNameMap` name-mapping subsystem (auto-match + Map wizard step / CLI flags). AES-256-GCM encryption (carry-full-config with encrypted connection secrets); per-conflict resolution with a per-line Myers diff for code fields; real stale-instance enumeration; correlated audit trail. Never touches site runtime nodes. |
| 25 | Script Analysis | [docs/requirements/Component-ScriptAnalysis.md](docs/requirements/Component-ScriptAnalysis.md) | Shared authoritative script-trust analyzer: unified forbidden-API deny-list (`ScriptTrustPolicy`), fused semantic+syntactic validator (`ScriptTrustValidator`), Roslyn compile wrapper (`RoslynScriptCompiler`), and compile-only globals stubs (`ScriptCompileSurface`/`TriggerCompileSurface`); consumed by Template Engine, Site Runtime, Inbound API, and Central UI. |
| 26 | KPI History | [docs/requirements/Component-KpiHistory.md](docs/requirements/Component-KpiHistory.md) | Reusable central KPI-history backbone: tall/EAV `KpiSample` store (central MS SQL), `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated) sampling DI-registered `IKpiSampleSource`s each minute, bucketed `GetRawSeriesAsync` + `KpiSeriesBucketer` query, and a reusable custom-SVG `KpiTrendChart`. Ships trends for Notification Outbox, Site Call Audit, Audit Log, and Site Health. |
| 27 | DelmiaNotifier | [src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md](src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md) | **Standalone external client tool** (not deployed to the cluster, not in the Host): a compact Native-AOT (`win-x64`) console app (`WWNotifier.exe`) that DELMIA Apriso shells out to per recipe download. POSTs to the Inbound API `DelmiaRecipeDownload` method (`X-API-Key`, key from `SCADABRIDGE_API_KEY`) with connect-failure-only failover across a comma-list of base URLs, and reports the legacy `YES`/`NO` + exit-code stdout contract. Drop-in replacement for the legacy `WWNotifier` ([docs/former-api-specs/dnc/](docs/former-api-specs/dnc/Delmia-Integration-API.md)). Design: [docs/plans/2026-06-26-delmia-recipe-notifier-design.md](docs/plans/2026-06-26-delmia-recipe-notifier-design.md). |
**Shared UI sub-component** (not a top-level component): [TreeView](docs/requirements/Component-TreeView.md) — reusable hierarchical tree/grid Blazor component used by the Central UI (#9) for the templates folder hierarchy, data-connection browse, and tag pickers.
+2
View File
@@ -25,6 +25,7 @@
<Project Path="src/ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />
<Project Path="src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.csproj" />
<Project Path="src/ZB.MOM.WW.ScadaBridge.KpiHistory/ZB.MOM.WW.ScadaBridge.KpiHistory.csproj" />
<Project Path="src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests.csproj" />
@@ -55,5 +56,6 @@
<Project Path="tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.csproj" />
<Project Path="tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests.csproj" />
<Project Path="tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests.csproj" />
</Folder>
</Solution>
+18
View File
@@ -0,0 +1,18 @@
# Former API Specs
Reference specifications for the **predecessor / legacy APIs** that ScadaBridge replaces or
interoperates with. These are *not* ScadaBridge's own contracts — they are kept here as
historical reference when porting integrations onto ScadaBridge's Inbound API (#14) and
External System Gateway (#7), and for parity-checking behavior during cutover.
ScadaBridge's own component design docs live under [`docs/requirements/`](../requirements);
this folder holds only the **outgoing/legacy** systems' specs.
## Subfolders
- [`mes/`](mes) — the legacy MES API (ServiceStack "WWSupport / APIServer", a.k.a. `mesapi`)
that bridges MES ↔ AVEVA Wonderware via MXAccess. Source repo: `~/Desktop/mesapi`.
- [`dnc/`](dnc) — the legacy DNC integration: DELMIA Apriso document/recipe download +
Wonderware recipe-download notification (`~/Desktop/delmiaintegration`).
Drop the relevant spec documents into the matching subfolder.
@@ -0,0 +1,206 @@
# Delmia / DNC Integration API — document download & WW recipe-download notification
Reference for the **`delmiaintegration`** solution (`~/Desktop/delmiaintegration`), the legacy
bridge that pulls "proven" manufacturing documents (NC programs / recipes) out of **DELMIA Apriso**
and notifies **AVEVA Wonderware** that a recipe was downloaded for a machine. The recipe/NC-program
push to machines is classic **DNC** (Distributed Numerical Control), hence this lives under
`former-api-specs/dnc/`.
There are **two distinct API surfaces** in this solution:
| # | Surface | Direction | Transport |
|---|---------|-----------|-----------|
| A | **Delmia document web service** | integration → Delmia (consumed) | form-urlencoded POST, **XML** response |
| B | **WW recipe-download notification** | `WWNotifier` CLI → Wonderware receiver | **JSON** POST (`/notify`) |
> Surface B is the **legacy predecessor of the ScadaBridge `DelmiaRecipeDownload` inbound method** —
> same field set and result shape (see *ScadaBridge equivalent* below).
Source files (under `~/Desktop/delmiaintegration`):
- `DelmiaIntegration/DelmiaClient.cs` — Delmia HTTP client (Surface A)
- `DelmiaContracts/*.cs` — XML contracts (`DownloadResult`, `SearchResults`/`SearchResult`, + unused `MachineInfo`/`MachineSearchResults`/`UserInfo`)
- `WWNotifier/Program.cs`, `WWNotifier/CommandLineOptions.cs`, `WWNotifier/Models/RecipeDownload*.cs` — the CLI notifier (Surface B)
- `WWNotifier/App.config``NotifyURL` / `NotifyTimeout`
### End-to-end flow
```
DELMIA Apriso ──(A) RequestProvenDocument / RequestDocument / Search──► DelmiaClient
(intercim "ruleset" web service, XML) │ downloads proven doc/recipe to a path
WWNotifier.exe ──(B) POST /notify {RecipeDownload}──► Wonderware "WW receiver"
(CLI, exit code + YES/NO) ◄── {RecipeDownloadResult} ──
```
---
## Surface A — Delmia document web service (consumed by `DelmiaClient`)
`DelmiaClient` (`DelmiaClient.cs`) is a thin `HttpClient` wrapper. The **base URL is supplied by
the caller** (constructor / `URL` property — there is no default in `DelmiaIntegration/App.config`);
`Timeout` defaults to **30 s**. Every call is an `application/x-www-form-urlencoded` POST to
`{URL}/<Operation>`, and every response is **XML** deserialized with `XmlSerializer` (root
namespace `http://intercim.com/ruleset` — the Apriso/InterCIM heritage).
| Verb | Path | Form fields | Response (XML) | Client method |
|------|------|-------------|----------------|---------------|
| `POST` | `{URL}/RequestProvenDocument` | `username`, `machineID`, `partNumber`, `operationNumber`, `workOrderNumber` | `DownloadResult` | `RequestProvenDocument[Async]` |
| `POST` | `{URL}/RequestDocument` | + `documentKey` | `DownloadResult` | `RequestDocument[Async]` |
| `POST` | `{URL}/Search` | `username`, `machineID`, `partNumber`, `operationNumber` | `SearchResults` | `Search[Async]` |
- **`RequestProvenDocument`** — fetch the single *proven* (released/approved) document for the
part + operation, logging it against `username` / `workOrderNumber`.
- **`Search`** — list candidate documents matching the part/operation (each carries a
`DocumentKey` + `DocumentURL`).
- **`RequestDocument`** — fetch one specific document chosen from a search by `documentKey`.
`username` here is **identity/audit only** — it is a form field, not an authentication credential.
There is **no API key, no `Authorization` header, no TLS requirement** on this surface (see gotchas).
### Response — `DownloadResult` (metadata about the download)
`DownloadResult` describes *who/what/which order+document* was downloaded — it does **not** carry
the file bytes (the file lands at a download path on the Delmia side). Fields:
| Group | Fields |
|-------|--------|
| User | `UserKey` (int), `UserName`, `UserSite` |
| Machine | `MachineKey` (int), `MachineID`, `MachineSite` |
| Order | `WorkOrderNumber`, `ShopOrderKey` (int), `ShopOrderID`, `ShopOrderStatus`, `ShopOrderOperKey` (int), `ShopOrderOperID`, `ShopOrderOperStatus` |
| Document | `DocumentKey` (int), `DocumentName`, `DocumentRev`, `DocumentStatus` |
| Part | `PartID`, `PartRev` |
| Outcome | **`TransferSuccessful`** (bool), `ErrorMessage` (string) |
> **Success is `TransferSuccessful`, not the HTTP status.** Treat `TransferSuccessful == true` as the
> only success signal; `ErrorMessage` carries the reason otherwise.
### Response — `SearchResults` / `SearchResult`
`SearchResults` = `{ List<SearchResult> Results; string ErrorMessage }`. Each `SearchResult`:
`ShopOrderKey`/`ShopOrderID`/`ShopOrderStatus`, `ShopOrderOperKey`/`ShopOrderOperID`/`ShopOrderOperStatus`,
`DocumentKey` (int), `DocumentObjectID` (int, with an `XmlIgnore` `…Specified` flag),
`DocumentName`, `DocumentRev`, `DocumentStatus`, **`DocumentURL`**, `PartID`, `PartRev`.
### Quick reference (curl, Surface A)
```bash
# Search for candidate documents (values from DownloadTestUtil's example)
curl -X POST "{URL}/Search" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "username=dohertj2" \
--data-urlencode "machineID=000005" \
--data-urlencode "partNumber=00444455599" \
--data-urlencode "operationNumber=0100"
# -> <SearchResults xmlns="http://intercim.com/ruleset"> … </SearchResults>
# Request the proven document for that part/operation
curl -X POST "{URL}/RequestProvenDocument" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "username=dohertj2" \
--data-urlencode "machineID=000005" \
--data-urlencode "partNumber=00444455599" \
--data-urlencode "operationNumber=0100" \
--data-urlencode "workOrderNumber=W111111"
```
---
## Surface B — WW recipe-download notification (`WWNotifier` → WW receiver)
`WWNotifier.exe` (`WWNotifier/Program.cs`) is a **command-line tool** invoked by the download
process to tell Wonderware that a recipe/NC file was placed at a path for a machine. It POSTs a
JSON `RecipeDownload` to the configured receiver and interprets the JSON `RecipeDownloadResult`.
### CLI options (`CommandLineOptions`)
| Short | Long | Required | Meaning |
|-------|------|----------|---------|
| `-d` | `--downloadpath` | **yes** | File download path |
| `-m` | `--machine` | **yes** | Machine code |
| `-w` | `--workorder` | **yes** | Work order number |
| `-p` | `--partnumber` | **yes** | Part / item number |
| `-s` | `--seqop` | no | Job step / sequence number |
| `-u` | `--username` | no | Operator username |
### Endpoint & payload
`POST {NotifyURL}` with a JSON body, expecting a JSON reply. From `WWNotifier/App.config`:
`NotifyURL = http://wonder-app-vd01.zmr.zimmer.com:9001/notify`, `NotifyTimeout = 30` (seconds).
`NotifyURL` may be a **comma-separated list** — each is tried in order and the **first success
wins** (failover).
**Request — `RecipeDownload` (JSON)**
| Field | Type | Source |
|-------|------|--------|
| `MachineCode` | string | `--machine` |
| `DownloadPath` | string | `--downloadpath` |
| `WorkOrderNumber` | string | `--workorder` |
| `PartNumber` | string | `--partnumber` |
| `JobStepNumber` | string | `--seqop` |
| `Username` | string | `--username` |
**Response — `RecipeDownloadResult` (JSON)**: `{ "Result": bool, "ResultText": string }`.
### Process contract (stdout + exit code)
The caller (Delmia) reads `WWNotifier`'s console output and exit code:
- On success → prints **`YES`**; exit code `0`.
- On any failure (arg parse, missing config, all receivers failed, `Result == false`) → prints
**`NO`** plus a reason line; `Environment.ExitCode = -1`.
```bash
WWNotifier.exe -m Z28061 -d "C:\recipes\wo111111.nc" -w W111111 -p P111111 -s 0100 -u chamalas
# stdout: YES (or: NO\n<reason>)
# POSTs {"MachineCode":"Z28061","DownloadPath":"C:\\recipes\\wo111111.nc", … } to /notify
```
---
## Other contracts (defined but unused)
`MachineInfo`, `MachineSearchResults` (`{ List<MachineInfo> Results; string ErrorMessage }`), and
`UserInfo` exist in `DelmiaContracts` (same `intercim.com/ruleset` namespace) but are **not wired to
any `DelmiaClient` operation** — scaffolding for machine-lookup / user-lookup endpoints that were
never implemented. Shapes, for reference: `MachineInfo` = `MachineKey`/`MachineID`/`MachineName`/
`DownloadPath`/`MachineDescription`/`MachineSite`/`MachineStatus`; `UserInfo` = `UserKey`/`UserName`/
`UserSite`/`IsActive`.
---
## Behavior notes & gotchas
- **No authentication on either surface.** Surface A's `username` is just a logged form field;
Surface B sends no credential at all. Both are plaintext HTTP. (Contrast the ScadaBridge inbound
API, which requires `X-API-Key`/`Bearer` — see `../mes/authgaps.md`.)
- **Surface A ignores HTTP status.** `DelmiaClient` never calls `EnsureSuccessStatusCode`; a non-2xx
body is handed straight to `XmlSerializer`, which typically throws → the `catch` returns a generic
`TransferSuccessful = false` / `"Failed to call Delmia web service at '<URL>'."`. The real HTTP
error is lost.
- **Sync methods block on `.Result`.** `RequestProvenDocument`/`RequestDocument`/`Search` call
`.Result` on the async POST (deadlock-prone in some contexts); async variants exist.
- **`WWNotifier` has a latent NPE in its error path.** On a notify exception it logs
`error.InnerException.Message` (`Program.cs:129`); if `InnerException` is null this throws inside
the `catch`, masking the original error.
- **`NotifyURL` failover is first-success-wins**, in list order; a slow first endpoint costs up to
`NotifyTimeout` before the next is tried.
- **Surface A base URL is caller-supplied** (no config default), so the effective Delmia endpoint
depends on whoever constructs `DelmiaClient` (e.g. `TestUI`/`DelmiaClientUI`).
---
## ScadaBridge equivalent (porting note)
- **Surface B → ScadaBridge Inbound API `DelmiaRecipeDownload`.** The legacy `WWNotifier.exe` + the
`/notify` WW receiver are replaced by `POST /api/DelmiaRecipeDownload` (authenticated with
`X-API-Key`/`Bearer`). The contract is identical: request `{ MachineCode, DownloadPath,
WorkOrderNumber, PartNumber, JobStepNumber, Username }` → response `{ Result, ResultText }`. The
inbound script routes to the site via `Route.To(MachineCode).Call("ProcessRecipeDownload", …)`.
The CLI's comma-list failover is superseded by Traefik active-node routing; the `YES`/`NO` + exit
code contract becomes the HTTP status + JSON body.
- **Surface A (Delmia document service) has no direct ScadaBridge equivalent** — retrieving proven
documents from DELMIA Apriso remains an external concern (it would be an External System Gateway
call if pulled into ScadaBridge).
This file documents the **legacy** `delmiaintegration` contracts for reference/parity during that
migration.
+266
View File
@@ -0,0 +1,266 @@
# WWSupport MES API — Alarm Status API
Reference for the alarm-status endpoints exposed by the **WWSupport / APIServer** ServiceStack
service. The API reads live machine-alarm state out of AVEVA Wonderware (System Platform /
Galaxy) via MXAccess and returns it to MES callers.
- **Service host:** `AppHost` (`AppSelfHostBase`, name `"APIServer"`) — `APIServer/APIServer/AppHost.cs`
- **Service implementation:** `MesServices``APIServer/APIServer.ServiceInterface/MesServices.cs`
- **Business logic:** `MesNotifier``APIServer/APIServer.ServiceInterface/MesNotifier.cs`
- **Framework:** ServiceStack 6.0.2, self-hosted; SQL Server (OrmLite, `SqlServer2016Dialect`)
> Serialization note: `JsConfig.IncludeNullValues = true`, so null fields **are** emitted in JSON
> responses (e.g. `"AckDT": null`).
---
## Endpoints
| Verb | Route | Request DTO | Response DTO |
|------|-------|-------------|--------------|
| `POST` | `/mes/alarmstatus` | `AlarmStatusRequest` | `AlarmStatusResponse` |
| `POST` | `/mes/simplealarmstatus` | `SimpleAlarmStatusRequest` | `AlarmStatusResponse` |
Both endpoints return the **same** `AlarmStatusResponse` shape. Both are dispatched through
ServiceStack `Any(...)` handlers in `MesServices`, which resolve the singleton `MesNotifier` and
call `AlarmStatus(...)` / `SimpleAlarmStatus(...)`.
The service also enables `PostmanFeature` and `OpenApiFeature` (Swagger), so a running instance
exposes a browsable contract and a Postman collection.
---
## Authentication & authorization
All MES services are decorated with:
```csharp
[Authenticate]
[RequiredRole("MESAPI")]
public class MesServices : Service { ... }
```
The caller must be authenticated **and** hold the `MESAPI` role. Auth is configured in
`AppHost.Configure`:
- **API key** — `ApiKeyAuthProvider`
- `SessionCacheDuration = 30 minutes`
- `RequireSecureConnection = false` (HTTP is accepted; TLS not enforced)
- An API key whose `Environment == "test"` is routed to the `TestDb` connection instead of the
production DB (`AppHost.GetDbConnection`).
- **LDAP** — `LdapAuthProvider` (directory credentials).
- `AllowGetAuthenticateRequests = true`.
User/role data is persisted with `OrmLiteAuthRepository` (`UseDistinctRoleTables = true`) in the
same SQL Server database.
---
## `POST /mes/simplealarmstatus`
The convenience endpoint: identify a machine by SAPID, get back its MES-relevant alarms.
### Request — `SimpleAlarmStatusRequest`
| Field | Type | Notes |
|-------|------|-------|
| `SAPID` | string | SAP identifier of the machine. Required. |
```json
{ "SAPID": "100012345" }
```
### Behavior
1. Look up the `Machine` by `SAPID`. If not found → `WasSuccessful = false`,
`ErrorText = "Failed to find machine with SAPID '<id>'"`.
2. Select that machine's alarms **filtered to `FlaggedForMES = true` only**.
3. Read live tag state and return every alarm that is **currently triggered** (`InAlarm == true`).
Notes specific to this endpoint:
- Always **flagged-only** (cannot return non-MES alarms).
- Does **not** honor an "include acked" toggle — acked alarms are always included (with
`StatusCode = "Triggered.Acked"`).
---
## `POST /mes/alarmstatus`
The full endpoint: pick the machine by any one of several keys, and filter the alarm set.
### Request — `AlarmStatusRequest`
| Field | Type | Notes |
|-------|------|-------|
| `MachineFilter` | `MachineFilter` | Selects the machine (see below). Defaults to empty. |
| `AlarmFilter` | `AlarmFilter` | Filters the alarms (see below). Defaults to "all flagged + unflagged, triggered + acked". |
#### `MachineFilter`
| Field | Type | Notes |
|-------|------|-------|
| `MachineID` | int? | DB primary key. |
| `SAPID` | string | SAP identifier. |
| `ZTag` | string | Z-tag identifier. |
| `Code` | string | Machine code (also the MXAccess tag prefix). |
**Resolution precedence** (first non-empty wins): `SAPID``Code``ZTag``MachineID`.
At least one selector must be supplied; if all are empty/unmatched the call fails with
`"Failed to find machine with given machine filter '<dump>'"`. A supplied-but-unmatched selector
fails with a selector-specific message, e.g. `"Failed to find machine with Code '<code>'"`.
#### `AlarmFilter`
| Field | Type | Default | Effect |
|-------|------|---------|--------|
| `NameFilter` | string | `null` | Case-insensitive **substring** match on alarm `Name`. |
| `MinSeverity` | int? | `null` | Keep alarms with `Severity >= MinSeverity`. |
| `MaxSeverity` | int? | `null` | Keep alarms with `Severity <= MaxSeverity`. |
| `IncludeTriggered` | bool | `true` | **Currently unused** — see Behavior notes. |
| `IncludeAcked` | bool | `true` | When `false`, acked alarms are excluded from the result. |
| `FlaggedOnly` | bool | `false` | When `true`, restrict to `FlaggedForMES = true` alarms. |
```json
{
"MachineFilter": { "SAPID": "100012345" },
"AlarmFilter": {
"MinSeverity": 500,
"FlaggedOnly": true,
"IncludeAcked": false
}
}
```
### Behavior
1. Resolve the machine via `MachineFilter` precedence (above).
2. Load all `MachineAlarm` rows for that machine, then apply the in-process `AlarmFilter` in this
order: `FlaggedOnly``MinSeverity``MaxSeverity``NameFilter`.
3. Read live tag state; return every remaining alarm that is **triggered** (`InAlarm == true`),
skipping acked alarms when `IncludeAcked == false`.
---
## Response — `AlarmStatusResponse`
| Field | Type | Notes |
|-------|------|-------|
| `WasSuccessful` | bool | `false` on any lookup or tag-read failure. |
| `ErrorText` | string | Populated when `WasSuccessful == false`. |
| `Alarms` | `AlarmInfo[]` | Triggered alarms matching the request. **Cleared if `WasSuccessful == false`.** |
### `AlarmInfo`
| Field | Type | Source |
|-------|------|--------|
| `Name` | string | `MachineAlarm.Name`. |
| `HierarchicalName` | string | `"{Machine.Code}.{Name}"`. |
| `Description` | string | Live `…​.DescAttrName` tag value. |
| `IsFlaggedForMES` | bool | `MachineAlarm.FlaggedForMES`. |
| `Severity` | int | `MachineAlarm.Severity` (0999). |
| `StatusCode` | string | `"Triggered"`, or `"Triggered.Acked"` when acked. |
| `TriggeredDT` | DateTime | Live `…​.TimeAlarmOn` tag value. |
| `AckDT` | DateTime? | Live `…​.TimeAlarmAcked` tag value (null if unacked). |
| `AckComment` | string | Live `…​.AckMsg` tag value. |
### Example response
```json
{
"WasSuccessful": true,
"ErrorText": null,
"Alarms": [
{
"Name": "HighVacuumFault",
"HierarchicalName": "Z28061.HighVacuumFault",
"Description": "High vacuum sensor out of range",
"IsFlaggedForMES": true,
"Severity": 800,
"StatusCode": "Triggered.Acked",
"TriggeredDT": "2026-06-25T01:14:22",
"AckDT": "2026-06-25T01:16:05",
"AckComment": "Investigating - day shift"
}
]
}
```
---
## How alarm state is read (MXAccess)
Alarm configuration (name, severity, MES flag) lives in SQL; **live state** is read from
Wonderware at request time:
- For each `MachineAlarm`, an `AlarmTagset` (`AlarmTagset.cs`) builds MXAccess tag paths of the
form `{Machine.Code}.{Alarm.Name}.<suffix>`, where `<suffix>` is one of:
`Quality`, `InAlarm`, `TimeAlarmOn`, `DescAttrName`, `Acked`, `TimeAlarmAcked`, `AckMsg`.
- `MesNotifier` subscribes via `LMXProxyServerClass.AdviseSupervisory` (handle registered as
`"MesNotifier"`), waits for the first data change per tag, then unsubscribes.
- **Quality gate:** every alarm's `Quality` must be OPC-Good (`192`); otherwise the whole request
fails with `ErrorText = "Failed to read machine alarm status"` and `Alarms` is cleared.
- Detail tags (`TimeAlarmOn`, `DescAttrName`, `Acked`, `TimeAlarmAcked`, `AckMsg`) are only read
for alarms whose `InAlarm` is true.
- **Per-request timeout:** 30 seconds (`CancellationTokenSource(30000)`); on timeout the pending
reads resolve as failed.
---
## Underlying data model — `MachineAlarm`
SQL table backing alarm configuration (`APIServer.ServiceModel/DTO/MachineAlarm.cs`):
| Column | Type | Constraints |
|--------|------|-------------|
| `MachineAlarmID` | int | PK, auto-increment |
| `MachineID` | int | FK → `Machine` |
| `Machine` | `Machine` | OrmLite `[Reference]` |
| `Name` | string | Required, ≤ 256 |
| `Severity` | int | 0999 |
| `FlaggedForMES` | bool | Marks an alarm as MES-relevant |
| `LastUpdate` | DateTime | |
| `LastUpdateBy` | string | Required, ≤ 128 |
| `OtherData` | string | Max-length text |
---
## Behavior notes & gotchas
- **`AlarmFilter.IncludeTriggered` is declared but never used.** Both endpoints only ever return
alarms whose `InAlarm` tag is `true`; setting `IncludeTriggered = false` has no effect.
- **Only triggered alarms are returned.** There is no "all configured alarms" mode — an alarm not
currently in alarm state never appears, regardless of filters.
- **`/simplealarmstatus` is flagged-only and ignores ack filtering**; use `/mes/alarmstatus` with
`FlaggedOnly` / `IncludeAcked` for control over those.
- **`MachineFilter` precedence matters** — supplying both `SAPID` and `Code` uses `SAPID`; the
others are ignored.
- **All-or-nothing on read errors.** A single bad-quality tag fails the whole response (success
flag flips, alarm list is emptied) rather than returning a partial set.
- **Severity bounds are inclusive** on both `MinSeverity` and `MaxSeverity`.
- The route attributes carry placeholder Swagger text (`Summary = "POST Summary"`,
`Notes = "Notes"`); these are cosmetic.
---
## Quick reference (curl)
```bash
# Simple — flagged alarms for one machine by SAPID
curl -X POST http://<host>:<port>/mes/simplealarmstatus \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <api-key>" \
-d '{"SAPID":"100012345"}'
# Full — filtered alarms, machine chosen by Code
curl -X POST http://<host>:<port>/mes/alarmstatus \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <api-key>" \
-d '{
"MachineFilter": { "Code": "Z28061" },
"AlarmFilter": { "MinSeverity": 500, "IncludeAcked": false }
}'
```
> Exact auth header format depends on how `ApiKeyAuthProvider` is configured for the deployment
> (bearer token vs. HTTP Basic with the key as username). Confirm against the live Swagger/Postman
> metadata for the target server.
@@ -0,0 +1,339 @@
# WWSupport MES API — Move In / Move Out API
Reference for the batch **move-in / move-out** endpoints exposed by the **WWSupport / APIServer**
ServiceStack service. These endpoints hand a container/work-order payload to a machine's
`MesReceiver` object in AVEVA Wonderware (System Platform / Galaxy) over MXAccess, using a
flag-based request/response handshake, and read back the resulting batch id (and, for move-out,
recorded cycle data from SQL).
- **Service host:** `AppHost` (`AppSelfHostBase`, name `"APIServer"`) — `APIServer/APIServer/AppHost.cs`
- **Service implementation:** `MesServices``APIServer/APIServer.ServiceInterface/MesServices.cs`
- **Business logic:** `MesNotifier``APIServer/APIServer.ServiceInterface/MesNotifier.cs`
- **Framework:** ServiceStack 6.0.2, self-hosted; SQL Server (OrmLite, `SqlServer2016Dialect`)
> Serialization note: `JsConfig.IncludeNullValues = true`, so null fields **are** emitted in JSON
> responses (e.g. `"BatchID": null`).
This document covers `/mes/movein` and `/mes/moveout`. The two alarm endpoints
(`/mes/alarmstatus`, `/mes/simplealarmstatus`) are documented in [`Alarm-API.md`](Alarm-API.md).
---
## Endpoints
| Verb | Route | Request DTO | Response DTO |
|------|-------|-------------|--------------|
| `POST` | `/mes/movein` | `MoveInRequest` | `MoveInResponse` |
| `POST` | `/mes/moveout` | `MoveOutRequest` | `MoveOutResponse` |
Both are dispatched through ServiceStack `Any(...)` handlers in `MesServices`, which resolve the
singleton `MesNotifier` and call `MoveIn(...)` / `MoveOut(...)`.
> The handlers call the async business methods with a blocking `.Result`
> (`mesNotifier.MoveIn(request).Result`), so each request occupies its thread until the
> handshake completes or the 30 s timeout fires.
The service also enables `PostmanFeature` and `OpenApiFeature` (Swagger), so a running instance
exposes a browsable contract and a Postman collection.
---
## Authentication & authorization
Identical to the alarm endpoints — all MES services are decorated with:
```csharp
[Authenticate]
[RequiredRole("MESAPI")]
public class MesServices : Service { ... }
```
The caller must be authenticated **and** hold the `MESAPI` role. Configured in `AppHost.Configure`:
- **API key** — `ApiKeyAuthProvider` (`SessionCacheDuration = 30 min`, `RequireSecureConnection = false`).
An API key whose `Environment == "test"` is routed to the `TestDb` connection instead of the
production DB (`AppHost.GetDbConnection`). **Caveat:** that redirect only applies to OrmLite
lookups — the move-out cycle-data read uses a raw `ConnectionStrings["BatchDB"]` connection and
is *not* affected by the test-key redirect (see gotchas).
- **LDAP** — `LdapAuthProvider`.
- `AllowGetAuthenticateRequests = true`.
Default API-key transport is the standard `Authorization` header (`Bearer <key>`, or HTTP Basic
with the key as username); `?apikey=<key>` also works since `AllowInHttpParams` defaults on.
---
## `POST /mes/movein`
Hands a container + its work orders to a machine's `MesReceiver` to start/stage a batch.
### Request — `MoveInRequest`
| Field | Type | Notes |
|-------|------|-------|
| `SAPID` | string | SAP identifier of the target machine. Used to resolve the `Machine` row. Required. |
| `OperatorName` | string | Operator initiating the move-in. |
| `JobSequenceNumber` | string | Job sequence number. |
| `ContainerNumber` | string | MES container number. |
| `WorkOrders` | `WorkOrderInfo[]` | Work orders being moved in (default empty). Each item: `WorkOrderNumber` (string), `PartNumber` (string). |
```json
{
"SAPID": "100012345",
"OperatorName": "chamalas",
"JobSequenceNumber": "50",
"ContainerNumber": "cont-012",
"WorkOrders": [
{ "WorkOrderNumber": "W111111", "PartNumber": "P111111" }
]
}
```
### Response — `MoveInResponse`
| Field | Type | Notes |
|-------|------|-------|
| `WasSuccessful` | bool | `true` only if the machine reported `MoveInSuccessfulFlag = true`. |
| `ErrorText` | string | Failure reason, or the machine's `MoveInErrorText` on a completed-but-failed move. |
| `BatchID` | int? | The machine's `MoveInBatchID`, **only when non-zero**; otherwise null. |
### Behavior
1. **30 s budget** (`CancellationTokenSource(30000)`).
2. **Resolve machine:** `db.Single<Machine>(x => x.SAPID == request.SAPID)`. If not found →
`WasSuccessful = false`, `ErrorText = "Failed to find machine with SAPID '<id>'"` (early return).
3. **Subscribe** to the machine's move-in tagset (`MesMoveInTagset`, all under
`{Machine.Code}.MesReceiver.*`) via `AdviseSupervisory`; wait for the first value of each.
If any subscription fails → `"Failed to connect to machine"`.
4. **Ready gate:** require `MoveInReadyFlag == true`, else `"Machine move in ready flag not set to true"`.
5. If still successful, perform the **handshake**:
- Arm a watcher for `MoveInCompleteFlag → true` (`OnValue`).
- Write the payload tags (see table) **and** set `MoveInFlag = true`. If any write is not
acknowledged → `"Failed to write move in information to machine"`.
- Wait for `MoveInCompleteFlag`:
- **Completed:** `WasSuccessful = MoveInSuccessfulFlag`; `ErrorText = MoveInErrorText`;
`BatchID = MoveInBatchID` (only if ≠ 0).
- **Timed out:** `"Timeout waiting for move in information to be processed"`.
6. **Unsubscribe** all tags.
### Tags written / read (`{Machine.Code}.MesReceiver.*`)
| Property | MXAccess tag suffix | Type | Direction | Source / meaning |
|----------|---------------------|------|-----------|------------------|
| `MoveInReadyFlag` | `MoveInReadyFlag` | bool | read (gate) | Machine must be ready to accept a move-in. |
| `MoveInMesContainerNumber` | `MoveInMesContainerNum` | string | write | `request.ContainerNumber`. |
| `MoveInOperatorName` | `MoveInOperatorName` | string | write | `request.OperatorName`. |
| `MoveInJobSequenceNumber` | `MoveInJobSequenceNumber` | string | write | `request.JobSequenceNumber`. |
| `MoveInNumberWorkOrders` | `MoveInNumberWorkOrders` | int | write | `request.WorkOrders.Count`. |
| `MoveInPartNumbers` | `MoveInPartNumbers[]` | string[] | write | `WorkOrders[*].PartNumber`, **fixed length 50**. |
| `MoveInWorkOrderNumbers` | `MoveInWorkOrderNumbers[]` | string[] | write | `WorkOrders[*].WorkOrderNumber`, **fixed length 50**. |
| `MoveInFlag` | `MoveInFlag` | bool | write (trigger) | Set `true` to start processing. |
| `MoveInCompleteFlag` | `MoveInCompleteFlag` | bool | watch | Machine sets `true` when done. |
| `MoveInSuccessfulFlag` | `MoveInSuccessfulFlag` | bool | read (result) | Machine's success verdict. |
| `MoveInErrorText` | `MoveInErrorText` | string | read (result) | Machine's error message. |
| `MoveInBatchID` | `MoveInBatchID` | int | read (result) | Created batch id (0 = none). |
---
## `POST /mes/moveout`
Closes out a container's work orders on a machine's `MesReceiver`, and (when the machine is
configured for it) returns the recorded cycle data for the resulting batch.
### Request — `MoveOutRequest`
Same as `MoveInRequest` **minus `JobSequenceNumber`**:
| Field | Type | Notes |
|-------|------|-------|
| `SAPID` | string | SAP identifier of the target machine. Required. |
| `OperatorName` | string | Operator initiating the move-out. |
| `ContainerNumber` | string | MES container number. |
| `WorkOrders` | `WorkOrderInfo[]` | Work orders being moved out. Each: `WorkOrderNumber`, `PartNumber`. |
```json
{
"SAPID": "100012345",
"OperatorName": "chamalas",
"ContainerNumber": "cont-012",
"WorkOrders": [
{ "WorkOrderNumber": "W111111", "PartNumber": "P111111" }
]
}
```
### Response — `MoveOutResponse`
| Field | Type | Notes |
|-------|------|-------|
| `WasSuccessful` | bool | `true` only if the machine reported `MoveOutSuccessfulFlag = true`. |
| `ErrorText` | string | Failure reason, or the machine's `MoveOutErrorText`. |
| `BatchID` | int? | The machine's `MoveOutBatchID` (non-zero), **and** only when cycle storage is enabled (below). |
| `Data` | `MoveOutData[]` | Recorded cycle values (empty unless cycle storage is enabled). Defaults to `[]`. |
#### `MoveOutData`
| Field | Type | Source |
|-------|------|--------|
| `BatchId` | int | `MachineCycle.MachineBatchId`. |
| `CycleId` | int | `MachineCycle.MachineCycleId`. |
| `ValueName` | string | One of the cycle-data keys (below). |
| `Value` | object | The value for that key (`null` becomes empty string in SQL via `COALESCE`). |
### Behavior
Steps 15 mirror move-in (resolve `Machine` by `SAPID`; subscribe `MesMoveOutTagset`; require
`MoveOutReadyFlag == true`; write payload + `MoveOutFlag = true`; wait for `MoveOutCompleteFlag`).
On completion: `WasSuccessful = MoveOutSuccessfulFlag`, `ErrorText = MoveOutErrorText`.
**Cycle-data read (move-out only).** If `MoveOutBatchID != 0` **and** the machine's `OtherData`
contains the literal `"StoreCycleDataForMES"`:
- `BatchID = MoveOutBatchID`.
- Open a **raw** `SqlConnection` on `ConnectionStrings["BatchDB"]` and run a parameterized query
(`@machineBatchID = MoveOutBatchID`) against `BT.dbo.MachineCycle`, expanding each cycle's
`OtherData` JSON (via `OPENJSON … CROSS APPLY (VALUES …)`) into one `MoveOutData` row per
key/value pair. Only rows where `ISJSON(mc.OtherData) = 1` are processed.
```sql
-- Shape of the cycle-data query (BT.dbo.MachineCycle, WHERE MachineBatchId = @machineBatchID)
SELECT mc.MachineBatchId, mc.MachineCycleId, v.ValueName, COALESCE(v.Value, N'') AS Value
FROM BT.dbo.MachineCycle mc
CROSS APPLY OPENJSON(mc.OtherData) WITH ( /* one column per key below */ ) od
CROSS APPLY (VALUES (N'ProgramNum', od.ProgramNum), /* … one row per key … */ ) AS v(ValueName, Value)
WHERE mc.MachineBatchId = @machineBatchID AND ISJSON(mc.OtherData) = 1;
```
**Cycle-data keys extracted** (19 per cycle): `ProgramNum`, `DewPointStart`, `SegmentStart2`,
`HighVacEndSeg1`, `SegmentStart3`, `SegmentStart4`, `SoakStartTime`, `SegmentStart5`,
`SoakEndTime`, `DurationFinalSoak`, `MaxSoakTemp`, `MinSoakTemp`, `MaxSoakPressure`,
`MinSoakPressure`, `SegmentStart6`, `QuenchTemp`, `DewPointMax`, `StartTimestamp`, `EndTimestamp`.
### Tags written / read (`{Machine.Code}.MesReceiver.*`)
Same set as move-in with the `MoveOut` prefix, **minus `JobSequenceNumber`**:
`MoveOutReadyFlag` (gate), `MoveOutMesContainerNum` / `MoveOutOperatorName` /
`MoveOutNumberWorkOrders` / `MoveOutPartNumbers[]` / `MoveOutWorkOrderNumbers[]` (write),
`MoveOutFlag` (trigger), `MoveOutCompleteFlag` (watch), `MoveOutSuccessfulFlag` /
`MoveOutErrorText` / `MoveOutBatchID` (result).
---
## How the handshake works (MXAccess)
`MesNotifier` holds a single process-wide `LMXProxyServerClass` (handle registered as
`"MesNotifier"`, wired to `OnDataChange` + `OnWriteComplete`). For each request it:
1. **Advise**`AddItem(path)` then `AdviseSupervisory`; the first `OnDataChange` per tag
resolves that tag's read task (success = quality `192` / OPC-Good). Values are coerced to the
tag's CLR type via `Convert.ChangeType`.
2. **Write**`LMXProxyServerClass.Write`; `OnWriteComplete` resolves the write task with the
driver's success flag.
3. **Watch**`Tag.OnValue(target)` completes when a subsequent `OnDataChange` reports a value
equal to the target (used for the `…CompleteFlag → true` step).
4. **Unadvise** — every tag is unsubscribed and removed at the end of the request.
Everything is bounded by the per-request **30 s** `CancellationTokenSource`; on expiry, all
pending reads/writes/watches resolve as `false`.
The move-in/move-out contract is therefore a classic flag protocol on the machine's `MesReceiver`:
**read ready → write payload + set request flag → wait for complete flag → read success/error/batch**.
---
## Underlying data model — `Machine`
SQL table backing machine lookup (`APIServer.ServiceModel/DTO/Machine.cs`); `Code` is also the
MXAccess tag prefix and `SAPID` is the move-in/move-out selector:
| Column | Type | Constraints |
|--------|------|-------------|
| `MachineID` | int | PK, auto-increment |
| `Code` | string | Required, ≤ 50 — **MXAccess tag prefix** (`{Code}.MesReceiver.*`) |
| `Name` | string | Required, ≤ 50 |
| `ZTag` | string | ≤ 10 |
| `SAPID` | string | ≤ 10 — **move-in/out selector** |
| `Description` | string | ≤ 256 |
| `TimeZone` | string | Required, ≤ 128 |
| `MultipleBatch` / `MultipleCycle` / `Active` | bool | |
| `LastUpdate` | DateTime | |
| `LastUpdateBy` | string | Required, ≤ 128 |
| `OtherData` | string | Max-length text — **gates cycle storage** when it contains `"StoreCycleDataForMES"` |
---
## Behavior notes & gotchas
- **Work-order arrays are fixed length 50.** `ToFixedLength(50)` always writes exactly 50-element
string arrays (padded with `null`); a 51st+ work order is **silently dropped**. `PartNumbers`
and `WorkOrderNumbers` are written as two parallel arrays — index *i* of one corresponds to
index *i* of the other (positional pairing from the same `WorkOrders` list). `NumberWorkOrders`
carries the true count.
- **Connect-failure message gets clobbered.** The "connect" check and the "ready flag" check are
sequential `if`s with no early return; if the subscribe fails, `MoveInReadyFlag.Value` is its
default (`false`), so the ready-flag check also fires and **overwrites** `ErrorText` with
`"…ready flag not set to true"`. A connection problem can surface as a ready-flag error.
- **`BatchID` is null unless the machine reports a non-zero batch id.** Move-out additionally
requires cycle storage to be enabled before it sets `BatchID`.
- **Cycle data is opt-in per machine.** `Data` is empty unless `Machine.OtherData` contains
`"StoreCycleDataForMES"` *and* `MoveOutBatchID != 0`.
- **Cycle-data read bypasses the test-DB redirect.** It uses a raw `ConnectionStrings["BatchDB"]`
connection against hard-coded `BT.dbo.MachineCycle`, so a `test`-environment API key (which
redirects *OrmLite* to `TestDb`) still reads cycle data from `BatchDB`.
- **`WasSuccessful` reflects the machine, not just the transport.** Even with a clean handshake,
`WasSuccessful` is whatever the machine wrote to `…SuccessfulFlag`, and `ErrorText` is the
machine's `…ErrorText`.
- **Synchronous blocking.** `MesServices` calls `.Result` on the async methods; combined with the
single shared MXAccess proxy, throughput is effectively serialized per request thread.
- **Machine lookup uses `db.Single`** on `SAPID`; returns null when unmatched (handled), and the
first match if `SAPID` is non-unique.
- The route attributes carry placeholder Swagger text (`Summary = "POST Summary"`,
`Notes = "Notes"`); cosmetic only.
---
## Quick reference (curl)
```bash
# Move in a container + work order
curl -X POST http://<host>:<port>/mes/movein \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <api-key>" \
-d '{
"SAPID":"100012345",
"OperatorName":"chamalas",
"JobSequenceNumber":"50",
"ContainerNumber":"cont-012",
"WorkOrders":[{"WorkOrderNumber":"W111111","PartNumber":"P111111"}]
}'
# Move out the same container (returns cycle Data when the machine stores it for MES)
curl -X POST http://<host>:<port>/mes/moveout \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <api-key>" \
-d '{
"SAPID":"100012345",
"OperatorName":"chamalas",
"ContainerNumber":"cont-012",
"WorkOrders":[{"WorkOrderNumber":"W111111","PartNumber":"P111111"}]
}'
```
> Exact auth header format depends on how `ApiKeyAuthProvider` is configured for the deployment
> (bearer token vs. HTTP Basic with the key as username). Confirm against the live Swagger/Postman
> metadata for the target server.
---
## ScadaBridge equivalent (porting note)
ScadaBridge re-implements these flows as **Inbound API methods** (`POST /api/{method}`,
`X-API-Key` header — *not* the ServiceStack `Authorization`/`apikey` scheme) that route to a
site's `MesReceiver` instance script via `Route.To(<instanceCode>).Call("MesMoveIn"/"MesMoveOut", …)`:
- `IpsenMESMoveIn` / `MesMoveIn``/mes/movein`; `MesMoveOut``/mes/moveout`.
- The ready/trigger/complete **flag handshake moves into the site instance script** (Site Runtime),
rather than the central API driving MXAccess tags directly.
- Machine resolution by SAP id is a `Database.QuerySingleAsync<string>("BTDB", "SELECT … Machine WHERE SAPID=@s")`
inside the inbound script (see `docs/plans/2026-06-16-ipsen-mes-movein-design.md`).
This file documents the **legacy** WWSupport contract for reference/parity during that migration.
+121
View File
@@ -0,0 +1,121 @@
# WWSupport MES API — API key authentication: support & gaps
How the legacy **WWSupport / APIServer** ServiceStack service authenticates API-key callers, and
the API-key-specific gaps in it **as of 2026-06-25** (the state of the `~/Desktop/mesapi` copy).
Captured as reference for the ScadaBridge migration so the replacement Inbound API does not inherit
these weaknesses.
> Scope: defensive review of an internally-owned legacy service — weaknesses and remediations, no
> exploit steps. Limited to API-key auth (other auth/config concerns are out of scope here).
Source files: `APIServer/AppHost.cs`, `APIServer.ServiceInterface/MesServices.cs`,
`APIServer/App.config`.
---
## What's supported (API key)
- **`ApiKeyAuthProvider`** is registered in the host `AuthFeature` (`AppHost.cs:63-70`) with
`SessionCacheDuration = 30 min` and `RequireSecureConnection = false`.
- **Keys are DB-backed.** Users, roles, and keys live in SQL via `OrmLiteAuthRepository`
(`UseDistinctRoleTables = true`); `InitSchema()` creates the tables at startup
(`AppHost.cs:53-59`). There is no API-key config in `App.config` — keys are issued/stored by the
ServiceStack auth repository.
- **Authorization.** A valid key authenticates the request; the key's user must also hold the
`MESAPI` role to reach any operation (`MesServices.cs:6-8`):
```csharp
[Authenticate]
[RequiredRole("MESAPI")]
public class MesServices : Service { ... } // movein, moveout, alarmstatus, simplealarmstatus
```
- **Transports for the key** (ServiceStack defaults, nothing overridden):
`Authorization: Bearer <key>`, HTTP Basic with the key as the username, or — since
`AllowInHttpParams` defaults on — `?apikey=<key>` in the query string/form.
- **Per-key environment tag.** A key whose `Environment == "test"` is routed to a `TestDb`
connection instead of production (`AppHost.GetDbConnection`, `AppHost.cs:102-108`).
- **Transport.** The listener is plain HTTP — `http://*:9501/` (DEV) / `http://*:9500/` (QA/PROD)
(`App.config:57,68,80`); no HTTPS listener is configured (relevant to gap #1).
---
## Gaps & risks (worst first)
### 1. 🟠 High — the key is exposed in transit and in logs
Three settings compound:
- **No TLS** — plain-HTTP listeners (`App.config:57`) and `RequireSecureConnection = false`
(`AppHost.cs:69`), so the key crosses the network in cleartext to any on-path observer.
- **Key accepted in the URL**`AllowInHttpParams` (default on) makes `?apikey=<key>` valid, so
keys land in proxy logs and browser history.
- **Request logging persists it** — the enabled `RequestLogsFeature` writes request data to a CSV
on disk (`AppHost.cs:79-86`), so a query-string key can be written to the log file.
**Fix:** terminate TLS and set `RequireSecureConnection = true`; set
`ApiKeyAuthProvider { AllowInHttpParams = false }` so keys must travel in the `Authorization`
header; redact auth fields from the request log and restrict the log file's ACLs.
### 2. 🟡 Medium — keys are not scoped to methods
A valid key + the `MESAPI` role grants access to **every** `[RequiredRole("MESAPI")]` endpoint
(move-in, move-out, both alarm reads). There is no per-key allow-list of methods, so one leaked
integration key exposes the entire MES surface.
**Fix:** scope keys per integration (separate roles/keys for read vs. move-in vs. move-out), the
way the ScadaBridge Inbound API does (keys carry an explicit method allow-list).
### 3. 🟢 LowMedium — the per-key `test` redirect is half-wired
`GetDbConnection` opens a `"TestDb"` connection for `Environment == "test"` keys
(`AppHost.cs:104-106`), but **no `TestDb` connection string is defined** in `App.config` — such
requests would throw. Separately, the move-out cycle-data read uses a raw
`ConnectionStrings["BatchDB"]` connection (see `MoveIn-MoveOut-API.md`), so it ignores the
redirect and reads production data even with a `test` key.
**Fix:** define `TestDb` (or remove the redirect), and route the raw cycle-data read through the
same environment-aware connection so a `test` key never touches production.
---
## Remediation checklist (priority order)
- [ ] **Enforce TLS** + `RequireSecureConnection = true`; stop accepting the key in the URL
(`AllowInHttpParams = false`); redact/secure the request logs (#1).
- [ ] **Scope API keys per method/integration** instead of one coarse `MESAPI` role (#2).
- [ ] **Fix or remove the `test` → `TestDb` redirect** and make the raw cycle-data read
environment-aware (#3).
---
## Contrast: ScadaBridge Inbound API
ScadaBridge uses an **`X-API-Key` header** on the data plane (`POST /api/{method}`), validated
server-side, with each key **scoped to an explicit method allow-list** — versus one coarse
`MESAPI` role granting all endpoints here. This file documents the legacy API-key posture so that
difference is intentional and verifiable during cutover.
### Accepted auth transports
| Transport | mesapi (ServiceStack `ApiKeyAuthProvider`) | ScadaBridge Inbound API |
|-----------|--------------------------------------------|-------------------------|
| `Authorization: Bearer <key>` | ✅ | ✅ — `Bearer sbk_<keyId>_<secret>` (the `Bearer ` prefix is optional; a bare token in `Authorization` also works) |
| `Authorization: Basic <base64("<key>:")>` (key as username) | ✅ | ❌ |
| `X-API-Key: <key>` | ❌ (not supported by stock ServiceStack) | ✅ — raw token `sbk_<keyId>_<secret>` |
| `?apikey=<key>` (query string / form param) | ✅ (`AllowInHttpParams` defaults on) | ❌ — headers only |
| Session cookie after first auth (`ss-id`/`ss-pid`) | ✅ (30-min `SessionCacheDuration`) | ❌ — stateless; every request re-presents the key |
Evidence: mesapi is stock `new ApiKeyAuthProvider(AppSettings)` with no header customization (so
ServiceStack defaults apply); ScadaBridge logic is `EndpointExtensions.cs:83-95`.
Notes:
- **The only common header is `Authorization: Bearer`** — the portable choice for a client that
must talk to both.
- **`X-API-Key` is ScadaBridge-only**; **`Basic` and `?apikey=` are mesapi-only.** A `curl -H
"X-API-Key: …"` authenticates ScadaBridge but is *rejected* by mesapi.
- **Precedence when two are sent:** ScadaBridge — `Authorization` wins over `X-API-Key`; mesapi —
ServiceStack checks `Authorization` (Bearer/Basic) before the `apikey` param.
- **mesapi is looser, ScadaBridge is tighter:** mesapi accepts the key in the URL and over a
long-lived session cookie (more leak surface — see gap #1); ScadaBridge restricts to two
headers, no URL param, no session.
- **Token shape & verification:** mesapi keys are opaque ServiceStack keys checked against the auth
repo; ScadaBridge keys are structured `sbk_<keyId>_<secret>` verified by a peppered-HMAC
constant-time compare and **scoped to specific method names** (case-sensitive).
@@ -1,6 +1,6 @@
# Central report pages hang ~30s — NotificationOutbox / SiteCallAudit singleton query Asks never reply
**Status:** FIXED — verified 2026-06-05 (pending commit) · **Severity:** High (real users see 30s page loads) · **Found:** 2026-06-05
**Status:** FIXED — verified 2026-06-05; committed & live on `main` (confirmed 2026-06-24: `AkkaHostedService.GetOrCreateActorSystem()` + `AddSingleton<ActorSystem>` registrations present in HEAD) · **Severity:** High (real users see 30s page loads) · **Found:** 2026-06-05
**Components:** Notification Outbox (#21), Site Call Audit (#22), Central UI (#9), Host/cluster (#15/#13)
## FIX APPLIED & VERIFIED (2026-06-05)
@@ -73,7 +73,7 @@ the `ActorSystem` is simply **dead** by the time a page queries it. "Determinist
restart and full redeploy" is fully explained: it is a DI-lifetime code defect that
re-triggers on the first post-`Up` health probe every boot.
**Fix (pending, task #48):** stop the container from disposing the externally-owned
**Fix (applied 2026-06-05 — see "FIX APPLIED & VERIFIED" above):** stop the container from disposing the externally-owned
`ActorSystem`. It must be resolvable from DI as the live instance (the kit calls
`GetService<ActorSystem>()`), re-readable (must not cache `null` during warmup), and never
disposed by a child scope. A `Transient`/`Scoped` factory returning the `IDisposable` system
@@ -1,16 +1,19 @@
# Follow-up tracker — template-inheritance UI gaps + CLI/validation footguns (2026-06-24 session)
**Status:** PARTIALLY RESOLVED · **Found:** 2026-06-24 · **Context:** live ops session on `wonder-app-vd03` (CvdReactor / Z28061 / Z28061Sim) — renaming the template, adding the LeakTest module, and adding MoveInType to the MESReceiver children.
**Components:** Central UI (#9), Template Engine (#1), CLI (#19), Configuration Database (#17)
**Status:** RESOLVED · **Found:** 2026-06-24 · **Context:** live ops session on `wonder-app-vd03` (CvdReactor / Z28061 / Z28061Sim) — renaming the template, adding the LeakTest module, and adding MoveInType to the MESReceiver children.
**Components:** Central UI (#9), Template Engine (#1), CLI (#19), Configuration Database (#17), Deployment Manager (#2)
**Resolved:** #3 (collision detector) and #7 (sandbox compile surface) fixed on branch `fix/followups-3-7` (2026-06-24). Open: #1, #2, #4, #5, #6, #8.
**Resolved:** #3 (collision detector) and #7 (sandbox compile surface) on branch `fix/followups-3-7`; #1 + #2 (inherited-member propagation & resync) on branch `fix/followups-1-2`; #4 + #5 + #6 + #8 (CLI ergonomics + structured deploy validation error) and #9 (inherited compositions in the templates tree) on branch `fix/followups-4-5-6-8` (all 2026-06-24). All items resolved.
Issues are listed worst-first. Severities are author estimates. None caused data loss; the runtime/flattened config and deployed instances are correct.
---
## 1. Template editor omits inherited-but-unmaterialized base attributes (user-reported)
**Severity:** Medium · **Components:** Central UI (#9), Template Engine (#1)
**Severity:** Medium · **Components:** Central UI (#9), Template Engine (#1) · **✅ RESOLVED 2026-06-24 (branch `fix/followups-1-2`)**
**Fix:** shared root cause with #2 — see #2's fix. Once a template's inherited rows are materialized (auto-propagation going forward, or the Resync action for already-stale templates), the editor's editable Attributes/Alarms/Scripts tabs list them. The "base changed" banner is now actionable: it carries a **Resync inherited members** button (`TemplateEdit.razor`) that calls `TemplateService.ResyncInheritedMembersAsync` and reloads. The read-only "Effective inherited set" preview is retained.
**Symptom:** On `/design/templates``LeftMESReceiver`, the **Attributes** tab does not list `MoveInType`. Same for `RightMESReceiver`. (Also missing from the list: all `MoveOut*` and `ScanStateCmd`.)
@@ -23,7 +26,10 @@ Issues are listed worst-first. Severities are author estimates. None caused data
---
## 2. Derived templates carry incomplete/stale `IsInherited` row sets
**Severity:** Medium · **Components:** Template Engine (#1), Configuration Database (#17)
**Severity:** Medium · **Components:** Template Engine (#1), Configuration Database (#17) · **✅ RESOLVED 2026-06-24 (branch `fix/followups-1-2`)**
**Fix:** root-cause fix — derived templates' stored inherited rows are now kept in sync two ways. (1) **Auto-propagation:** adding/updating/removing a member on a template now reconciles its entire derived subtree (`TemplateService.ReconcileDescendantsAsync`, called from every member-mutating path incl. native-alarm-source CRUD in the ManagementActor). (2) **Resync:** `ResyncInheritedMembersAsync` (CLI `template resync-members`, management `ResyncInheritedMembersCommand`, Designer-gated, audited; UI banner button) repairs a template + its subtree on demand — materializing missing placeholders, re-syncing drifted ones, removing orphans, across attributes/alarms/scripts/native sources. `BuildDerivedTemplate` also now materializes native-source placeholders at compose time (previously omitted, which made any inherited native source perpetually stale). Authored overrides are never touched. Covered by `TemplateServiceTests` (materialize / drift-update / orphan-remove / override-untouched / base-cascade / multi-type / propagation / end-to-end add). Documented in `Component-TemplateEngine.md` → "Inherited-Member Propagation & Resync".
**Symptom:** `LeftMESReceiver`/`RightMESReceiver` (parent=3) have 12 stored attribute rows vs the base's 26. By contrast `LeftReactorSide`/`RightReactorSide` (parent=7) mirror the full 61. So derived row-sets are inconsistent.
@@ -50,7 +56,17 @@ Issues are listed worst-first. Severities are author estimates. None caused data
---
## 4. CLI `instance set-bindings` cannot set `DataSourceReferenceOverride`
**Severity:** Medium · **Components:** CLI (#19)
**Severity:** Medium · **Components:** CLI (#19) · **✅ RESOLVED 2026-06-24 (branch `fix/followups-4-5-6-8`)**
**Fix:** `--bindings` now accepts an optional **third element** per entry —
`[attributeName, dataConnectionId, dataSourceReferenceOverride]` — so the CLI can set the
per-instance reference override that the wire contract (`ConnectionBinding`) already
carried. A string sets it; a JSON `null` or an omitted third element leaves it unset
(template default). `TryParseBindings` accepts 2- or 3-element entries and rejects a
non-string/non-null third element or 4+ elements with a clean validation error. The
`--bindings` help and CLI README now document the full-replace behaviour (omitting the
override on a re-bind clears any previously-set one). Covered by
`InstanceArgumentParsingTests` (three-element / explicit-null / wrong-type / four-element).
**Symptom:** `instance set-bindings --bindings` only accepts `[attributeName, dataConnectionId]` pairs (`InstanceCommands.cs``ConnectionBinding(name, connId)` 2-arg). The override is sent as `null`, and because `SetConnectionBindingsAsync` upserts `DataSourceReferenceOverride = b.DataSourceReferenceOverride` (`InstanceService.cs:340`), using the CLI on an attribute that already has an override would **wipe** it.
@@ -61,7 +77,19 @@ Issues are listed worst-first. Severities are author estimates. None caused data
---
## 5. CLI `template update` is full-replace, not partial
**Severity:** Low · **Components:** CLI (#19), Template Engine (#1)
**Severity:** Low · **Components:** CLI (#19), Template Engine (#1) · **✅ RESOLVED 2026-06-24 (branch `fix/followups-4-5-6-8`)**
**Fix:** `TemplateService.UpdateTemplateAsync` now uses **leave-unchanged** semantics for
optional fields (fixed server-side, so every client benefits): a `null` description keeps
the stored value (pass `""` to explicitly clear it), and a `null` `parentTemplateId` keeps
the existing parent. The parent remains immutable — a non-null value that differs from the
current parent is still rejected — but omitting it (the CLI default) is now a no-op instead
of tripping the immutability guard, which previously made `template update` fail on any
derived template unless `--parent-id` was re-passed. CLI `--description`/`--parent-id` help,
the `UpdateTemplateCommand` doc, and the CLI README document the semantics. Tests:
`UpdateTemplate_OmittedParentAndDescription_LeavesUnchanged`,
`UpdateTemplate_EmptyDescription_ClearsIt` (the prior `UpdateTemplate_ClearParent_Fails`
was repurposed, since a null parent now means leave-unchanged rather than clear-and-fail).
**Symptom:** omitting `--description` on `template update` overwrites the stored description to NULL (`TemplateService.cs:124-125` assigns Name+Description unconditionally). Renaming a template silently drops its description unless you re-pass it.
@@ -70,7 +98,16 @@ Issues are listed worst-first. Severities are author estimates. None caused data
---
## 6. (Minor) CLI `template list`/`get` table output dumps every attribute
**Severity:** Low · **Components:** CLI (#19)
**Severity:** Low · **Components:** CLI (#19) · **✅ RESOLVED 2026-06-24 (branch `fix/followups-4-5-6-8`)**
**Fix:** `template list`/`get` **table** output is now a compact projection — id / name /
description / parentTemplateId / isDerived plus member **counts** (`#attrs`, `#alarms`,
`#scripts`, `#comps`, `#nativeAlarms`) — via a new `TemplateTableProjection.ProjectSummary`
fed through an optional `tableProjector` seam on `CommandHelpers.ExecuteCommandAsync`/
`HandleResponse`. A `--detail` flag restores the full table dump. **JSON output is
deliberately left untouched** (always the full payload) so machine consumers are unaffected
— the projector only runs on the table path. Covered by `TemplateTableProjectionTests`
(array/object projection, counts, non-JSON passthrough, size-shrink sanity check).
**Symptom:** `--format table template list` emitted ~171 KB (the full attribute set per template inline), unusable in a terminal. `--format json` is fine.
@@ -96,10 +133,33 @@ Issues are listed worst-first. Severities are author estimates. None caused data
---
## 8. Deploy-time unbound-binding validation returns one giant semicolon-joined error string
**Severity:** Low · **Components:** Template Engine (#1), Deployment Manager (#2)
**Severity:** Low · **Components:** Template Engine (#1), Deployment Manager (#2) · **✅ RESOLVED 2026-06-24 (branch `fix/followups-4-5-6-8`)**
**Fix:** new `ValidationResult.SummarizeErrors()` (Commons) returns a grouped, capped
summary: a leading total count, one line per `ValidationCategory`, and within a category a
per-**module** rollup (canonical name up to its last dot) with counts and a `… and N more
module(s)` cap. `DeploymentService` now uses it for the `Pre-deployment validation failed:`
message and logs the full per-entry list via `LogWarning` so nothing is lost. Entity-less
findings (e.g. script-compile errors) fall back to a capped message list. Documented in
`Component-DeploymentManager.md` → "Validation Error Reporting". Covered by
`ValidationResultSummaryTests` (count header, module rollup, breadth cap, root grouping,
message fallback, mixed categories).
**Symptom:** Deploying an instance whose data-sourced attributes aren't all bound fails with a single error that concatenates one clause per attribute: `Pre-deployment validation failed: Attribute 'LeftReactorSide.LeakTest.DeltaVac' has a data source reference but no connection binding; Attribute 'LeftReactorSide.LeakTest.ResultType' has …; …`. For 50194 unbound attrs (e.g. Z28062's unbound LeakTest members) it's a wall of text that's hard to scan in a CLI/UI toast.
**Root cause:** `ValidateConnectionBindingCompleteness` emits one clause per unbound attribute and joins them into a flat string; there is no grouping or count.
**Suggested fix:** return a structured/summarized error — leading count (`52 attributes are unbound`) + grouped-by-module breakdown (or a capped list with "…and N more") — instead of the flat semicolon-joined dump. Keep the full list available in a detail/expandable view or the deploy log.
---
## 9. Templates tree omits inherited compositions under derived composed-members (user-reported)
**Severity:** Low-Medium · **Components:** Central UI (#9) · **✅ RESOLVED 2026-06-24 (branch `fix/followups-4-5-6-8`)**
**Fix:** `Templates.razor``BuildCompositionLeavesFor` now renders the **effective** composition set (own + inherited) via a new `EffectiveCompositionsFor` that walks the inheritance chain (leaf→root, child wins on `InstanceName`), mirroring `TemplateResolver.ResolveAllMembers`. Inherited slots are flagged (`TemplateTreeNode.IsInherited`), badged **"inherited"** in the label, and their context menu offers only **Open composed template** (Rename/Delete edit the ancestor's slot, so they're suppressed on inherited nodes). Because the same inherited row can now appear under several derived members (LeakTest under both LeftReactorSide and RightReactorSide), composition nodes use a path-qualified `KeyOverride` (`t:{owner}/c:{id}/…`) so TreeView selection/expansion keys stay unique; the recursion is cycle-guarded. Covered by `TemplatesPageTests.Renders_InheritedComposition_UnderDerivedComposedMember`.
**Symptom:** On `/design/templates`, the base `ReactorSide` node expands to show its composed `↳ LeakTest`. But under `CvdReactor`, the composed members `LeftReactorSide` / `RightReactorSide` (derived from `ReactorSide`) render as **flat leaves with no LeakTest** — even though they inherit it. Observed live on `wonder-app-vd03`.
**Root cause:** `BuildCompositionLeavesFor(owner)` recursed only over `owner.Compositions` (the template's **own** rows). A derived template's inherited composition row lives on its ancestor, and `TemplateComposition` has no `IsInherited` placeholder (unlike attributes/alarms/scripts/native-sources, which `BuildDerivedTemplate`/the #1/#2 reconcile materialize) — so the derived child's own `Compositions` is empty and the recursion found nothing. Same "derived templates don't surface inherited members" family as #1/#2, but for compositions, which the #1/#2 fix did not cover. Deploy/flatten was always correct (`TemplateResolver.ResolveAllMembers` walks the chain), so this was display-only.
**Note:** this is a Central UI change — it shows on `wonder-app-vd03` only after that host is redeployed with the new build.
@@ -0,0 +1,59 @@
# Known issue — inbound API method compile errors are not client-visible; no on-demand validation (2026-06-25 session)
**Status:** OPEN · **Found:** 2026-06-25 · **Context:** live ops session on `wonder-app-vd03` — three deployed inbound methods (`IpsenMESMoveIn`, `MesMoveIn`, `MesMoveOut`) returned `Script compilation failed for this method` after being authored from a design doc that used the wrong DB-helper name (`Database.QuerySingle<T>` instead of the shipped async `Database.QuerySingleAsync<T>`). Diagnosing the *actual* Roslyn error required an SSH dive into the central log; nothing in the CLI or the Management/data-plane API surfaces it.
**Components:** Inbound API (#14), CLI (#19), Management Service (#18)
Issues are listed worst-first. Severities are author estimates. Neither item caused data loss — once the scripts were corrected via `UpdateApiMethod` they compiled and ran (verified with a live `MesMoveIn` test against the `Z28061Sim` instance: `{"WasSuccessful":true,"ErrorText":"","BatchID":0}`).
Related: the runtime mechanics behind both items are captured in the recall notes `inbound-known-bad-method-cache` and `scadabridge-inbound-db-helper-querysingleasync`. The root-cause doc fix shipped in `66bbbb7a` / `33da8c79`.
---
## 1. The real inbound-script compile error is server-log-only; there is no `api-method validate`
**Severity:** Medium · **Components:** Inbound API (#14), CLI (#19), Management Service (#18)
**Symptom:** When an inbound method's script fails to compile, every caller of `POST /api/{method}` gets the same generic body — `Script compilation failed for this method` — with no diagnostic. The actual Roslyn error (e.g. `'InboundDatabaseHelper' does not contain a definition for 'QuerySingle'`) is written **only** to the central server log. There is no CLI command and no API verb to (a) retrieve the last compile error for a method, or (b) compile/validate a method's script on demand the way templates can be validated.
**Reproduction (this session):**
```bash
curl -s -X POST http://wonder-app-vd03.zmr.zimmer.com:8085/api/IpsenMESMoveIn \
-H "X-API-Key: <key>" -H "Content-Type: application/json" -d '{ ...MoveIn... }'
# -> {"WasSuccessful":false,"ErrorText":null,"...":"Script compilation failed for this method"} (no detail)
```
The only way to get the real cause was:
```
ssh -tt -p 2222 -i ~/.ssh/servecli_wonder dohertj2@wonder-app-vd03.zmr.zimmer.com
# grep E:\ApiInstall\ScadaBridge\central\logs\scadabridge-central-<date>.log for "script compilation failed"
```
**Root cause:** `InboundScriptExecutor` deliberately returns a non-leaky generic message to the data plane (`InboundScriptExecutor.cs:299` and `:311``"Script compilation failed for this method"`), while the genuine diagnostic only ever reaches the logger:
- `InboundScriptExecutor.cs:182-183``LogWarning("API method {Method} script compilation failed: {Errors}", …)`
- `InboundScriptExecutor.cs:197``LogError(ex, "Failed to compile API method {Method} script", …)`
Returning the raw Roslyn text to an *external, API-key* caller is the right default (it can leak code/internal type names), but it means an **operator/admin** has no first-class channel to that text either. Contrast `template validate` (CLI `template validate --id`, README §Template) which runs a real compile and returns the diagnostics — there is no `api-method validate` equivalent (`grep` for `ValidateApiMethod`/`CompileApiMethod`/`RecompileApiMethod` across `src/` returns nothing; `ApiMethodCommands.cs` registers only `list`/`get`/`create`/`update`/`delete`).
**Impact:** turns a one-line fix into a host-access investigation. Authoring/repairing an inbound script becomes "update → fire a request → if it fails, SSH into the host and read the log → repeat," instead of "validate → read the error → fix."
**Suggested fix (pick one or both):**
1. **`api-method validate --id <int>`** (CLI #19) backed by a management `ValidateApiMethodCommand` (#18) that runs `CompileAndRegister`'s compile path and returns the structured diagnostics to the *authenticated, role-gated* management caller (never the data plane). Mirror `template validate`.
2. Surface the **last compile state** on `api-method get` — e.g. `LastCompileError` (string, null when clean) + `IsKnownBad` (bool) — so an operator can see why a method is failing without re-firing it. Keep the data-plane `/api/{method}` body generic as-is.
---
## 2. The `_knownBadMethods` cache is neither observable per-method nor resettable without a full-replace update
**Severity:** Low-Medium · **Components:** Inbound API (#14)
**Symptom:** Once a method's script fails to compile, its name is recorded in an in-memory bad-methods set and every later request **short-circuits** to the generic message *without recompiling*. Consequences observed/known:
- Fixing the stored script **directly in the config DB does not take effect** — the running process keeps serving the compile-failed message until a management `UpdateApiMethod` (which calls `CompileAndRegister`) or a service restart. (This is exactly why one of the three methods this session, `MesMoveIn`, had to be re-saved via the management API even though its stored script was already fine.)
- There is no way to *see* whether a given method is currently in the bad set (only an aggregate `internal int KnownBadMethodCount`, not exposed to any client), and no lightweight way to force a recompile short of a full-entity replace.
**Root cause:** `InboundScriptExecutor` (`InboundScriptExecutor.cs`):
- declares the cache at `:39` (`ConcurrentDictionary<string, byte> _knownBadMethods`),
- short-circuits on it at `:58` / `:298`, adds to it at `:62`,
- and clears an entry in **exactly one place**`CompileAndRegister` (`:103`, removal at `:118`). A direct DB row edit never runs `CompileAndRegister`, so the stale entry persists.
The cache itself is correct and desirable (it stops every request re-running a doomed Roslyn compile). The gap is purely **observability + a targeted reset**.
**Suggested fix:**
- Expose per-method state via the item-1 fix (`IsKnownBad` / `LastCompileError` on `api-method get`).
- Add a lightweight **`api-method recompile --id`** (management `RecompileApiMethodCommand`) that re-runs `CompileAndRegister` for one method without requiring the caller to round-trip the whole entity (script + timeout + parameterDefinitions + returnDefinition) — today `UpdateApiMethod` is full-replace, so an operator must re-send every field just to bust the cache. This is the smaller, lower-risk sibling of item 1's validate verb.
@@ -74,16 +74,26 @@ receiver is ready to accept a MoveIn.
## 4. Components
### A. ScadaBridge source change — inbound read-only DB helper
- New `InboundDatabaseHelper` backed by `IDatabaseGateway`, surface:
`T? QuerySingle<T>(string conn, string sql, object? args = null)` and
`IReadOnlyList<IReadOnlyDictionary<string, object?>> Query(string conn, string sql, object? args = null)`.
**No write methods.**
- New `InboundDatabaseHelper` backed by `IDatabaseGateway`, surface (async — every call is
awaited and bound to the method deadline):
`Task<T?> QuerySingleAsync<T>(string conn, string sql, object? args = null)`,
`Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(string conn, string sql, object? args = null)`,
and `Task<int> ExecuteAsync(string conn, string sql, object? args = null)` (writes are supported —
see the correction below).
- Add `Database` property to `InboundScriptContext`; construct it in `InboundScriptExecutor`
from a service scope (gateway is scoped). Add the helper's assembly to the script
`ScriptOptions.WithReferences(...)` and confirm `ForbiddenApiChecker` still passes (the script
references only `Database.QuerySingle`, never `System.Data`).
references only `Database.QuerySingleAsync`, never `System.Data`).
- Unit tests with a fake `IDatabaseGateway`.
> **Correction (2026-06-25).** The shipped `InboundDatabaseHelper` is **async**
> `QuerySingleAsync<T>` / `QueryAsync` / `ExecuteAsync`, each `await`ed — not the synchronous
> `QuerySingle`/`Query` sketched in the original draft above, and **writes are supported**
> (InboundAPI-026) rather than read-only. Scripts MUST call `await Database.QuerySingleAsync<T>(...)`.
> Three deployed methods (`IpsenMESMoveIn`, `MesMoveIn`, `MesMoveOut`) were authored from the old
> `Database.QuerySingle` spelling and failed Roslyn compilation in production until corrected
> (2026-06-25). The authoritative surface lives in `docs/requirements/Component-InboundAPI.md`.
### B. Rewrite inbound `/api/IpsenMESMoveIn` script (data, via management API)
Pseudocode (always returns the 3-field shape; never throws out):
```csharp
@@ -96,7 +106,7 @@ try {
var side = suf == "A" ? "Left" : suf == "B" ? "Right" : null;
if (side == null) return new { WasSuccessful = false, ErrorText = $"Unsupported side '{suf}'", BatchID = 0 };
var code = Database.QuerySingle<string>("BTDB",
var code = await Database.QuerySingleAsync<string>("BTDB",
"SELECT TOP 1 Code FROM dbo.Machine WHERE SAPID=@s", new { s = sap });
if (string.IsNullOrEmpty(code)) return new { WasSuccessful = false, ErrorText = $"No machine for SAP {sap}", BatchID = 0 };
+24 -23
View File
@@ -4,7 +4,7 @@
**Goal:** Turn the stub `POST /api/IpsenMESMoveIn` into a real MoveIn that resolves the SAP number to a reactor instance via BTDB, then writes the MoveIn onto the correct Left/Right MES-receiver child — gated on that receiver's `MoveInReadyFlag`.
**Architecture:** The inbound `/api` script gains a scoped, read-only DB helper (`Database.QuerySingle`) so it can do the `dbo.Machine.SAPID → Code` lookup, derive the bare instance code, then `Route.To(instance).Call("IpsenMoveIn", {…params, side})`. A new on-demand template script `IpsenMoveIn` on the reactor template (`IpsenFurnaceTitan`, T1) gates on `MoveInReadyFlag` and writes the fields onto `Children["{side}MESReceiver"]` (DCL → Galaxy authorized write).
**Architecture:** The inbound `/api` script gains a scoped, async DB helper (`await Database.QuerySingleAsync`) so it can do the `dbo.Machine.SAPID → Code` lookup, derive the bare instance code, then `Route.To(instance).Call("IpsenMoveIn", {…params, side})`. A new on-demand template script `IpsenMoveIn` on the reactor template (`IpsenFurnaceTitan`, T1) gates on `MoveInReadyFlag` and writes the fields onto `Children["{side}MESReceiver"]` (DCL → Galaxy authorized write).
**Tech Stack:** .NET 10, Roslyn CSharpScript (inbound + template scripts), Akka.NET (Instance/Script actors), `IDatabaseGateway`/ADO.NET (SQL Server BTDB), ScadaBridge management API (`POST /management`).
@@ -62,19 +62,19 @@ public class InboundDatabaseHelperTests
}
[Fact]
public void QuerySingle_returns_first_column_with_bound_parameter()
public async Task QuerySingleAsync_returns_first_column_with_bound_parameter()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var code = helper.QuerySingle<string>("BTDB",
var code = await helper.QuerySingleAsync<string>("BTDB",
"SELECT Code FROM Machine WHERE SAPID=@s", new { s = "131453" });
Assert.Equal("Z28061A", code);
}
[Fact]
public void QuerySingle_returns_default_when_no_rows()
public async Task QuerySingleAsync_returns_default_when_no_rows()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var code = helper.QuerySingle<string>("BTDB",
var code = await helper.QuerySingleAsync<string>("BTDB",
"SELECT Code FROM Machine WHERE SAPID=@s", new { s = "999999" });
Assert.Null(code);
}
@@ -94,10 +94,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// Read-only database access exposed to inbound API scripts. All ADO.NET stays
/// internal here — scripts call QuerySingle/Query by name and never reference
/// System.Data. Named connections only; parameters are bound (anonymous-object
/// properties become @-prefixed SQL parameters), never string-concatenated.
/// Async database access exposed to inbound API scripts. All ADO.NET stays
/// internal here — scripts call (and await) QuerySingleAsync/QueryAsync by name and
/// never reference System.Data. Named connections only; parameters are bound
/// (anonymous-object properties become @-prefixed SQL parameters), never
/// string-concatenated.
/// </summary>
public sealed class InboundDatabaseHelper
{
@@ -108,29 +109,29 @@ public sealed class InboundDatabaseHelper
{ _gateway = gateway; _ct = ct; }
/// <summary>First column of the first row converted to T (default if no rows).</summary>
public T? QuerySingle<T>(string connectionName, string sql, object? parameters = null)
public async Task<T?> QuerySingleAsync<T>(string connectionName, string sql, object? parameters = null)
{
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
using var cmd = conn.CreateCommand();
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
AddParameters(cmd, parameters);
var result = cmd.ExecuteScalar();
var result = await cmd.ExecuteScalarAsync(_ct);
if (result is null or DBNull) return default;
if (result is T t) return t;
return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture);
}
/// <summary>All rows as column→value dictionaries (case-insensitive keys).</summary>
public IReadOnlyList<IReadOnlyDictionary<string, object?>> Query(
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
string connectionName, string sql, object? parameters = null)
{
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
using var cmd = conn.CreateCommand();
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
AddParameters(cmd, parameters);
using var reader = cmd.ExecuteReader();
await using var reader = await cmd.ExecuteReaderAsync(_ct);
var rows = new List<IReadOnlyDictionary<string, object?>>();
while (reader.Read())
while (await reader.ReadAsync(_ct))
{
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < reader.FieldCount; i++)
@@ -162,7 +163,7 @@ public sealed class InboundDatabaseHelper
**Step 5 — Commit:**
`git add src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs && git commit -m "feat(inbound): read-only InboundDatabaseHelper for inbound scripts"`
**Acceptance:** `QuerySingle<T>` binds anonymous-object params, returns first column or default; ADO.NET stays internal.
**Acceptance:** `QuerySingleAsync<T>` binds anonymous-object params and (awaited) returns first column or default; ADO.NET stays internal.
---
@@ -176,7 +177,7 @@ public sealed class InboundDatabaseHelper
- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs` (the `InboundScriptContext` class ~321-353; the `ExecuteAsync` context construction ~246-249; add `using Microsoft.Extensions.DependencyInjection;`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs` (add cases)
**Step 1 — Write failing tests:** (a) a script body `return new { v = Database.QuerySingle<string>("BTDB","SELECT Code FROM Machine WHERE SAPID=@s", new { s = (string)Parameters["sap"] }) };` resolves via a seeded SQLite gateway; (b) an existing script that does NOT use `Database` still compiles + runs (no regression). Use the same SQLite-gateway fake from Task 1; construct the executor with a real `IServiceProvider` (e.g. `new ServiceCollection().AddScoped<IDatabaseGateway>(_ => gateway).BuildServiceProvider()`).
**Step 1 — Write failing tests:** (a) a script body `return new { v = await Database.QuerySingleAsync<string>("BTDB","SELECT Code FROM Machine WHERE SAPID=@s", new { s = (string)Parameters["sap"] }) };` resolves via a seeded SQLite gateway; (b) an existing script that does NOT use `Database` still compiles + runs (no regression). Use the same SQLite-gateway fake from Task 1; construct the executor with a real `IServiceProvider` (e.g. `new ServiceCollection().AddScoped<IDatabaseGateway>(_ => gateway).BuildServiceProvider()`).
**Step 2 — Run, expect FAIL** (`Database` not a member of context).
@@ -218,14 +219,14 @@ var context = new InboundScriptContext(
cts.Token);
```
Add `using Microsoft.Extensions.DependencyInjection;` at the top. No `ScriptOptions.WithReferences` change is needed — `InboundDatabaseHelper` lives in the same assembly as `RouteHelper`, which is already referenced (line ~157). Leave imports as-is (script calls `Database.QuerySingle`, fully qualified through the globals object; no `using` required).
Add `using Microsoft.Extensions.DependencyInjection;` at the top. No `ScriptOptions.WithReferences` change is needed — `InboundDatabaseHelper` lives in the same assembly as `RouteHelper`, which is already referenced (line ~157). Leave imports as-is (script calls `await Database.QuerySingleAsync`, fully qualified through the globals object; no `using` required).
**Step 4 — Run tests, expect PASS** (both the Database-using script and the no-Database script).
**Step 5 — Commit:**
`git add -p` the two files; `git commit -m "feat(inbound): expose read-only Database helper on InboundScriptContext"`
**Acceptance:** inbound scripts can call `Database.QuerySingle`; the scope is disposed after each execution; existing no-DB scripts still compile + run; `ScriptTrustValidator` still passes (no forbidden API in the helper-call text).
**Acceptance:** inbound scripts can call `await Database.QuerySingleAsync`; the scope is disposed after each execution; existing no-DB scripts still compile + run; `ScriptTrustValidator` still passes (no forbidden API in the helper-call text).
---
@@ -296,7 +297,7 @@ try {
if (side == null)
return new { WasSuccessful = false, ErrorText = "Unsupported side suffix '" + suf + "' (only -A/-B supported)", BatchID = 0 };
var code = Database.QuerySingle<string>("BTDB",
var code = await Database.QuerySingleAsync<string>("BTDB",
"SELECT TOP 1 Code FROM dbo.Machine WHERE SAPID=@s", new { s = sap });
if (string.IsNullOrEmpty(code))
return new { WasSuccessful = false, ErrorText = "No machine found for SAP " + sap, BatchID = 0 };
@@ -0,0 +1,158 @@
# Design — Delmia Recipe-Download Notifier (`ZB.MOM.WW.ScadaBridge.DelmiaNotifier`)
**Date:** 2026-06-26 · **Status:** Approved (brainstorming) · **Next:** implementation plan (writing-plans)
## Purpose
A compact Windows console application that **DELMIA Apriso shells out to** on each recipe/NC-program
download to notify the plant system that a recipe was placed at a path for a machine. It is the
modern replacement for the legacy `WWNotifier` (see
[`../former-api-specs/dnc/Delmia-Integration-API.md`](../former-api-specs/dnc/Delmia-Integration-API.md),
Surface B), repointed from the old Wonderware `/notify` receiver to the **ScadaBridge Inbound API**
method `DelmiaRecipeDownload`.
It is a strict **drop-in** for Delmia's existing call site: same command-line flags, same `YES`/`NO`
stdout contract, same exit-code semantics — so Delmia's invocation and output parsing are unchanged.
## Key decisions (from brainstorming)
| Decision | Choice |
|---|---|
| Target | ScadaBridge Inbound API `POST {baseUrl}/api/DelmiaRecipeDownload` |
| Auth | `X-API-Key: <key>` header |
| CLI/output | Exact parity with legacy `WWNotifier` (flags + `YES`/`NO` + exit code) |
| Config | `appsettings.json` next to the exe (URLs, timeout, optional log path) |
| Secret | API key from env var `SCADABRIDGE_API_KEY` — never in a file |
| Packaging | Self-contained **Native AOT**, `win-x64` (fast startup for per-invocation use) |
| Failover | Comma-list of base URLs; advance **only on connect failure** |
| Location | New project `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier`, in `ZB.MOM.WW.ScadaBridge.slnx` |
| Implementation | Approach A — zero-dependency single-file; hand-rolled arg parser, `System.Text.Json` source-gen, raw `HttpClient` |
| Exe name | `AssemblyName = WWNotifier` (literal drop-in for Delmia's existing call path) |
## Architecture
Single-purpose console app, no DI/generic host. One `Program.cs` orchestrates four small pieces:
1. **Arg parser** — hand-rolled, the 6 legacy flags → an in-memory request model.
2. **Config loader** — reads `appsettings.json` (via `System.Text.Json` source generator) → POCO;
reads the API key from the environment.
3. **Notifier** — builds the JSON payload and runs the failover POST loop behind a small seam
(interface/delegate) so the loop + result mapping are unit-testable without real HTTP.
4. **Result reporter** — maps the outcome to the `YES`/`NO` + exit-code contract (stdout) and writes
diagnostics (stderr + optional log file).
Zero third-party NuGet dependencies (BCL only) to keep the Native-AOT surface minimal and trim-clean.
## CLI contract (drop-in parity)
| Short | Long | Required | → payload field |
|---|---|---|---|
| `-d` | `--downloadpath` | yes | `DownloadPath` |
| `-m` | `--machine` | yes | `MachineCode` |
| `-w` | `--workorder` | yes | `WorkOrderNumber` |
| `-p` | `--partnumber` | yes | `PartNumber` |
| `-s` | `--seqop` | no | `JobStepNumber` |
| `-u` | `--username` | no | `Username` |
- **stdout:** exactly `YES` on success, or `NO` followed by a reason line on failure. Nothing else is
written to stdout (Delmia parses it).
- **exit code:** `0` on success, `-1` on failure (matches the legacy `Environment.ExitCode`).
- A missing required flag → `NO` + reason, exit `-1`, **no** HTTP attempt.
## Configuration & secret
`appsettings.json` placed next to the exe, loaded directly into a small POCO with a
`JsonSerializerContext` source generator (no reflection-based binder):
```json
{
"ScadaBridge": {
"BaseUrls": "http://host-a:8085,http://host-b:8085",
"TimeoutSeconds": 30,
"LogPath": "logs/delmia-notifier.log"
}
}
```
- `BaseUrls` — comma-separated failover list (legacy-style). The method path
`/api/DelmiaRecipeDownload` is appended by the app; each entry is a base URL only.
- `TimeoutSeconds` — per-attempt `HttpClient.Timeout` (default 30).
- `LogPath` — optional diagnostic log file (relative to the exe). Omit to log to stderr only.
- **API key** comes from `SCADABRIDGE_API_KEY`. If unset/empty → `NO` + "API key not configured",
exit `-1`, no attempt. The key is never read from or written to a file.
## Request / response
- Per attempt: `POST {baseUrl}/api/DelmiaRecipeDownload`, header `X-API-Key: <key>`,
`Content-Type: application/json`, body = the flat `RecipeDownload` JSON.
- DTOs (local, source-gen serializable):
- `RecipeDownload { MachineCode, DownloadPath, WorkOrderNumber, PartNumber, JobStepNumber, Username }`
- `RecipeDownloadResult { bool Result, string ResultText }`
## Failover (connect-failure only)
Try each base URL in order; advance to the next **only when the attempt fails to connect** (no HTTP
response came back). A node that responds at all is authoritative — its answer is final.
| Attempt outcome | Failover? | Final result |
|---|---|---|
| **No response** — connection refused/reset, DNS failure, TLS error, or timeout | **Yes** → next URL | only if *all* URLs fail to connect → `NO` + last connection error, exit `-1` |
| HTTP 2xx + `Result == true` | No — stop | `YES`, exit `0` |
| HTTP 2xx + `Result == false` | No — stop | `NO` + `ResultText`, exit `-1` |
| HTTP non-2xx (401/403/4xx/**5xx**) | No — stop | `NO` + status/error, exit `-1` |
> Deliberate consequence: a `5xx` from the first node is reported as a failure, **not** rolled over
> to the next node — failover is strictly for unreachable nodes. (Revisit only if operations want
> `5xx`/`503` to also fail over.)
## Error handling & logging
- **stdout is reserved** for the `YES`/`NO` contract. All diagnostics — per-URL attempt, status code,
exception detail, which URL answered — go to **stderr** and, if `LogPath` is set, an appended log
file. Hand-written; no logging dependency.
- Failure reasons surfaced on the `NO` reason line: missing required arg, missing API key, no
`BaseUrls` configured, or "all URLs unreachable: \<last error\>".
- The HTTP call sits behind a tiny seam so the failover/result logic is unit-tested without network.
## Project layout & packaging
```
src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/
ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj
Program.cs # entry, arg parse, orchestration, result reporting
Config.cs # POCO + loader
RecipeDownload.cs # request DTO
RecipeDownloadResult.cs # response DTO
NotifierJsonContext.cs # JsonSerializerContext (source gen)
appsettings.json # copied to output
```
- `.csproj`: `net10.0`, `OutputType=Exe`, `AssemblyName=WWNotifier`, `PublishAot=true`,
`RuntimeIdentifier=win-x64`, `InvariantGlobalization=true`, AOT/trim analyzer warnings treated as
errors. Added to `ZB.MOM.WW.ScadaBridge.slnx`.
- **Build note:** the AOT `win-x64` native exe must be published **on Windows**
(`dotnet publish -c Release -r win-x64`) with the MSVC build tools present — Native AOT does not
cross-compile from macOS/Linux. Managed `dotnet build` / `dotnet test` still run cross-platform for
development and CI of the logic.
## Testing
`tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests` (xUnit), all logic-level (no AOT needed, runs
cross-platform):
- Arg parsing: all-flags, required-missing → failure, optional omitted.
- Config: comma-split of `BaseUrls`, defaults (`TimeoutSeconds`), missing file/section.
- Payload mapping: flags → `RecipeDownload` JSON (field-for-field).
- Result mapping: `Result true/false` and non-2xx → correct stdout + exit code.
- Failover loop (via the HTTP seam / a fake handler): connect-failure advances; first responding node
is final; `Result==false` does **not** advance; all-unreachable → `NO` + last error.
- Missing/empty `SCADABRIDGE_API_KEY` → fail-fast, no attempt.
- Manual live smoke against `wonder-app-vd03` (`/api/DelmiaRecipeDownload`) from the Windows build,
mirroring the earlier `curl` verification.
## Out of scope (YAGNI)
- No retry/backoff beyond the connect-failover loop; no Polly.
- No DI/generic host, no `Microsoft.Extensions.*`.
- No support for other inbound methods — recipe download only.
- No DPAPI/secret-store integration (env var is the agreed mechanism); revisit if required.
@@ -0,0 +1,429 @@
# Delmia Recipe-Download Notifier — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Build `ZB.MOM.WW.ScadaBridge.DelmiaNotifier` — a compact Native-AOT Windows console app that DELMIA shells out to per recipe download, which POSTs to the ScadaBridge Inbound API `DelmiaRecipeDownload` method and reports the legacy `YES`/`NO` + exit-code contract.
**Architecture:** Single `Program.cs` orchestrates four testable pieces (arg parser, config loader, notifier failover loop behind an `IRecipeSender` seam, result reporter). Zero third-party NuGet deps; `System.Text.Json` source generator for AOT-safe (de)serialization; raw `HttpClient`. Comma-list of base URLs with **connect-failure-only** failover.
**Tech Stack:** .NET 10, C#, Native AOT (`win-x64`), `System.Text.Json` source-gen, xUnit. Central package management (`Directory.Packages.props`); no `Directory.Build.props`.
Design doc: [`2026-06-26-delmia-recipe-notifier-design.md`](2026-06-26-delmia-recipe-notifier-design.md).
**Cross-cutting conventions (apply to every task):**
- TDD: failing test → run (fails) → minimal code → run (passes) → commit.
- Run a single test: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests --filter "FullyQualifiedName~<Name>"`.
- Build the two new projects only (fast): `dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier && dotnet build tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`.
- `TreatWarningsAsErrors=true` everywhere — AOT/trim analyzer warnings are build failures; fix, don't suppress.
- Native AOT cannot be published from macOS; all tasks here are managed build/test only (cross-platform). The win-x64 AOT exe is produced on Windows (Task 8).
- JSON keys are **PascalCase** (`MachineCode`, `Result`, …) to match the inbound `DelmiaRecipeDownload` contract — keep `System.Text.Json` default naming (no camelCase policy).
---
### Task 1: Scaffold the src + test projects
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (foundation)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj`
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/appsettings.json`
- Create: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests.csproj`
- Modify: `ZB.MOM.WW.ScadaBridge.slnx` (add both `<Project Path=...>` lines under the matching `/src/` and `/tests/` folders)
**Step 1: Create the src csproj**
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AssemblyName>WWNotifier</AssemblyName>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- No <RuntimeIdentifier> here: pass -r win-x64 at publish (Task 8) so build/test stay cross-platform. -->
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
```
**Step 2: Create a stub `Program.cs`**
```csharp
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal static class Program
{
public static int Main(string[] args) => 0; // replaced in Task 7
}
```
**Step 3: Create `appsettings.json`**
```json
{
"ScadaBridge": {
"BaseUrls": "http://localhost:9000",
"TimeoutSeconds": 30,
"LogPath": "logs/delmia-notifier.log"
}
}
```
**Step 4: Create the test csproj**
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj" />
</ItemGroup>
</Project>
```
**Step 5: Add both projects to `ZB.MOM.WW.ScadaBridge.slnx`** — one `<Project Path="src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj" />` under `<Folder Name="/src/">`, one `<Project Path="tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests.csproj" />` under `<Folder Name="/tests/">`.
**Step 6: Build both** — `dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier && dotnet build tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`. Expected: both succeed.
**Step 7: Commit** — `git add … && git commit -m "feat(delmia-notifier): scaffold DelmiaNotifier src + test projects"`
---
### Task 2: DTOs + JSON source-generation context
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 3, Task 4
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/RecipeDownload.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/RecipeDownloadResult.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/NotifierJsonContext.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/JsonContractTests.cs`
**Step 1: Failing test** — assert `RecipeDownload` serializes to PascalCase keys and `RecipeDownloadResult` round-trips:
```csharp
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
public class JsonContractTests
{
[Fact]
public void RecipeDownload_serializes_pascalcase()
{
var json = JsonSerializer.Serialize(
new RecipeDownload { MachineCode = "Z28061", DownloadPath = @"C:\r.nc",
WorkOrderNumber = "W1", PartNumber = "P1", JobStepNumber = "0100", Username = "op" },
NotifierJsonContext.Default.RecipeDownload);
Assert.Contains("\"MachineCode\":\"Z28061\"", json);
Assert.Contains("\"DownloadPath\"", json);
}
[Fact]
public void RecipeDownloadResult_deserializes_pascalcase()
{
var r = JsonSerializer.Deserialize("{\"Result\":true,\"ResultText\":\"ok\"}",
NotifierJsonContext.Default.RecipeDownloadResult);
Assert.True(r!.Result);
Assert.Equal("ok", r.ResultText);
}
}
```
**Step 2: Run → fails** (types don't exist).
**Step 3: Implement** the two DTOs and the context:
```csharp
// RecipeDownload.cs
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal sealed class RecipeDownload
{
public string? MachineCode { get; set; }
public string? DownloadPath { get; set; }
public string? WorkOrderNumber { get; set; }
public string? PartNumber { get; set; }
public string? JobStepNumber { get; set; }
public string? Username { get; set; }
}
```
```csharp
// RecipeDownloadResult.cs
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal sealed class RecipeDownloadResult
{
public bool Result { get; set; }
public string? ResultText { get; set; }
}
```
```csharp
// NotifierJsonContext.cs
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
[JsonSerializable(typeof(RecipeDownload))]
[JsonSerializable(typeof(RecipeDownloadResult))]
[JsonSerializable(typeof(NotifierConfig))] // added in Task 4
internal partial class NotifierJsonContext : JsonSerializerContext;
```
> If Task 4 isn't done yet, temporarily omit the `NotifierConfig` line and add it in Task 4.
**Step 4: Run → passes.**
**Step 5: Commit** — `feat(delmia-notifier): recipe DTOs + JSON source-gen context`
---
### Task 3: Command-line argument parser
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 4
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ArgParser.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ArgParserTests.cs`
**Behavior:** parse `-d/--downloadpath -m/--machine -w/--workorder -p/--partnumber` (required) + `-s/--seqop -u/--username` (optional) into a `RecipeDownload`. Return a discriminated result: success(payload) or error(message). Unknown flag or missing required → error with a human reason.
**Step 1: Failing tests**
```csharp
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
public class ArgParserTests
{
[Fact]
public void Parses_all_flags()
{
var r = ArgParser.Parse(new[] { "-m","Z28061","-d",@"C:\r.nc","-w","W1","-p","P1","-s","0100","-u","op" });
Assert.True(r.Ok);
Assert.Equal("Z28061", r.Payload!.MachineCode);
Assert.Equal("0100", r.Payload.JobStepNumber);
Assert.Equal("op", r.Payload.Username);
}
[Fact]
public void Missing_required_returns_error()
{
var r = ArgParser.Parse(new[] { "-m","Z28061","-d",@"C:\r.nc","-w","W1" }); // no -p
Assert.False(r.Ok);
Assert.Contains("partnumber", r.Error, System.StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Optional_flags_may_be_omitted()
{
var r = ArgParser.Parse(new[] { "-m","Z","-d","x","-w","W","-p","P" });
Assert.True(r.Ok);
Assert.Null(r.Payload!.Username);
Assert.Null(r.Payload.JobStepNumber);
}
[Fact]
public void Unknown_flag_returns_error()
{
var r = ArgParser.Parse(new[] { "-z","x" });
Assert.False(r.Ok);
}
}
```
**Step 2: Run → fails.**
**Step 3: Implement `ArgParser`** — a `ParseResult` record (`bool Ok`, `RecipeDownload? Payload`, `string? Error`) and a `Parse(string[])` that walks pairs, maps short+long flags, validates the four required fields, and returns the first missing/unknown as the error. Keep it allocation-light and reflection-free (AOT-safe).
**Step 4: Run → passes.**
**Step 5: Commit** — `feat(delmia-notifier): CLI arg parser with required/optional validation`
---
### Task 4: Config loader + API key
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/NotifierConfig.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ConfigLoader.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ConfigLoaderTests.cs`
- (Add `[JsonSerializable(typeof(NotifierConfig))]` to `NotifierJsonContext` if not already present.)
**Behavior:** `NotifierConfig` POCO (`ScadaBridge` section: `BaseUrls` string, `TimeoutSeconds` int = 30, `LogPath` string?). `ConfigLoader.Load(string jsonText)` deserializes via `NotifierJsonContext`. A helper `SplitBaseUrls(string?)` → trimmed, non-empty `string[]`. `ResolveApiKey(Func<string,string?> envGet)` reads `SCADABRIDGE_API_KEY` (inject the env accessor for testability).
**Step 1: Failing tests** — comma split (`"a, b ,,c"``[a,b,c]`), default `TimeoutSeconds == 30` when omitted, empty/whitespace key → null, present key → value.
**Step 2: Run → fails.**
**Step 3: Implement** POCO + loader. `Load` uses `JsonSerializer.Deserialize(jsonText, NotifierJsonContext.Default.NotifierConfig)`. Separate file read (`File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "appsettings.json"))`) into a thin wrapper so the parse logic is unit-tested from a string.
**Step 4: Run → passes.**
**Step 5: Commit** — `feat(delmia-notifier): config loader + SCADABRIDGE_API_KEY resolution`
---
### Task 5: Notifier failover loop (behind `IRecipeSender` seam)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (core logic; needs Task 2)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/IRecipeSender.cs` (seam: `AttemptKind`, `AttemptOutcome`, `IRecipeSender`)
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Notifier.cs` (the failover loop + `NotifyResult`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/NotifierTests.cs`
**Seam:**
```csharp
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal enum AttemptKind { Connected, ConnectFailed }
internal sealed record AttemptOutcome(AttemptKind Kind, int StatusCode, RecipeDownloadResult? Body, string? Error);
internal interface IRecipeSender
{
Task<AttemptOutcome> SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct);
}
```
**Loop rule (connect-failure-only failover):** iterate base URLs in order; `ConnectFailed` → record error, continue; `Connected` → authoritative, stop:
- 2xx + `Result == true` → success.
- 2xx + `Result == false` → failure(`ResultText`).
- non-2xx → failure(`HTTP <status>`).
- All `ConnectFailed` → failure(`all URLs unreachable: <lastError>`).
**Step 1: Failing tests** using a fake `IRecipeSender` (a queue of scripted outcomes), assert `NotifyResult` (Ok + Reason):
- first URL `ConnectFailed`, second `Connected 200 Result=true` → Ok.
- first `Connected 200 Result=false` → not Ok, reason from `ResultText`, **second sender never called**.
- first `Connected 500` → not Ok, reason contains `500`, second never called.
- all `ConnectFailed` → not Ok, reason contains `unreachable`.
**Step 2: Run → fails.**
**Step 3: Implement** `Notifier.RunAsync(string[] baseUrls, RecipeDownload payload, IRecipeSender sender, CancellationToken ct)` returning `NotifyResult(bool Ok, string Reason)`.
**Step 4: Run → passes.**
**Step 5: Commit** — `feat(delmia-notifier): connect-failure-only failover loop`
---
### Task 6: Real `HttpClient` sender (outcome classification)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (needs Task 2, Task 5)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/HttpRecipeSender.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs`
**Behavior:** implements `IRecipeSender` over an injected `HttpClient`. `POST {baseUrl.TrimEnd('/')}/api/DelmiaRecipeDownload`, header `X-API-Key: <key>`, body = `RecipeDownload` JSON via `NotifierJsonContext`. Classification:
- `HttpRequestException` (incl. inner `SocketException`) or `TaskCanceledException`/`OperationCanceledException` from timeout → `AttemptOutcome(ConnectFailed, 0, null, ex.Message)`.
- Got a response → `Connected`, with `StatusCode`; on 2xx parse body into `RecipeDownloadResult` (tolerate parse failure → `Body=null`, treated as failure by the loop).
**Step 1: Failing tests** with a fake `HttpMessageHandler` (`Func<HttpRequestMessage, HttpResponseMessage>`):
- handler throws `HttpRequestException``ConnectFailed`.
- handler returns 200 `{"Result":true,"ResultText":""}``Connected`, 200, `Body.Result == true`; assert request had `X-API-Key` header and path `/api/DelmiaRecipeDownload`.
- handler returns 500 → `Connected`, 500.
**Step 2: Run → fails.**
**Step 3: Implement** `HttpRecipeSender`.
**Step 4: Run → passes.**
**Step 5: Commit** — `feat(delmia-notifier): HttpClient recipe sender with connect-failure classification`
---
### Task 7: `Program.Main` wiring + result reporter + logger
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (needs Tasks 26)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs`
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Reporter.cs` (maps outcome → stdout + exit code)
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/DiagLog.cs` (stderr + optional file)
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs`
**Reporter contract (drop-in parity):** success → write `YES` to a `TextWriter`, return `0`. Failure → write `NO` then the reason line, return `-1`. (Inject the `TextWriter` so tests assert the exact lines.)
**Step 1: Failing tests** for `Reporter.Report(bool ok, string reason, TextWriter stdout)`:
- ok → stdout is `"YES"` (+ newline), returns `0`.
- not ok with reason `"boom"` → stdout is `"NO\nboom"`, returns `-1`.
**Step 2: Run → fails.**
**Step 3: Implement** `Reporter`, `DiagLog` (writes timestamped lines to `Console.Error` and, if `LogPath` set, appends to the file — create parent dir; never throw), and wire `Program.Main`:
1. `ArgParser.Parse(args)` → on error: `DiagLog`, `Reporter.Report(false, error)`.
2. Load config; if no `BaseUrls` → report false "no BaseUrls configured".
3. `ResolveApiKey`; if null → report false "API key not configured (SCADABRIDGE_API_KEY)".
4. Build `HttpClient { Timeout = TimeSpan.FromSeconds(cfg.TimeoutSeconds) }` + `HttpRecipeSender`.
5. `await Notifier.RunAsync(...)`; log per-attempt diagnostics; `Reporter.Report(result.Ok, result.Reason)`.
6. Wrap in try/catch → on unexpected error: `DiagLog` + report false with the message. **stdout only ever gets `YES`/`NO`+reason.**
**Step 4: Run → passes** (reporter tests; full-build the two projects).
**Step 5: Commit** — `feat(delmia-notifier): Program wiring, YES/NO reporter, diagnostics log`
---
### Task 8: README, publish/AOT instructions, final verification
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 27 docs portion (verification depends on all)
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md`
- Test: (none new) — run the full suite.
**Step 1: Write `README.md`** — purpose (Delmia → ScadaBridge `DelmiaRecipeDownload`), CLI flags table, `appsettings.json` schema, the `SCADABRIDGE_API_KEY` env var, the `YES`/`NO` + exit-code contract, and the **publish** command:
```
# On Windows (Native AOT can't cross-compile from macOS/Linux):
dotnet publish src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier -c Release -r win-x64
# → WWNotifier.exe (self-contained, single native file) + appsettings.json
```
Plus the manual smoke test (`curl`/run against `wonder-app-vd03` `/api/DelmiaRecipeDownload`).
**Step 2: Final verification** — `dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier && dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`. Expected: build clean (no warnings, since warnings-as-errors), all tests pass.
**Step 3: Commit** — `docs(delmia-notifier): README + publish/AOT instructions`
---
## Notes for the executor
- Do **not** add NuGet packages to the src project — BCL only (AOT-clean). The test project uses only the standard xUnit set already in `Directory.Packages.props`.
- If any task needs a file not listed in its `Files:` block, that's a plan defect — surface it.
- The AOT native publish + live smoke against `wonder-app-vd03` happen on a Windows host; the managed build/test in every task is the cross-platform gate here.
@@ -0,0 +1,14 @@
{
"planPath": "docs/plans/2026-06-26-delmia-recipe-notifier.md",
"tasks": [
{"id": 11, "subject": "Task 1: Scaffold src + test projects", "status": "completed"},
{"id": 12, "subject": "Task 2: DTOs + JSON source-gen context", "status": "completed", "blockedBy": [11]},
{"id": 13, "subject": "Task 3: CLI arg parser", "status": "completed", "blockedBy": [11]},
{"id": 14, "subject": "Task 4: Config loader + API key", "status": "completed", "blockedBy": [11]},
{"id": 15, "subject": "Task 5: Failover loop (IRecipeSender seam)", "status": "completed", "blockedBy": [12]},
{"id": 16, "subject": "Task 6: HttpClient recipe sender", "status": "completed", "blockedBy": [12, 15]},
{"id": 17, "subject": "Task 7: Program wiring + reporter + logger", "status": "completed", "blockedBy": [13, 14, 15, 16]},
{"id": 18, "subject": "Task 8: README + publish/AOT + final verify", "status": "completed", "blockedBy": [17]}
],
"lastUpdated": "2026-06-26"
}
+1 -1
View File
@@ -38,7 +38,7 @@ Central cluster only. Sites have no user interface.
### Template Authoring (Design Role)
- The `/design/templates` page uses a **split-pane layout**: a folder/template tree sidebar on the left and the editor on the right.
- The tree shows nested `TemplateFolder` entities with their templates underneath; composition children render inline as leaf nodes beneath their owning template (right-click "Open composed template" reveals and selects the target).
- The tree shows nested `TemplateFolder` entities with their templates underneath; composition children render inline beneath their owning template (right-click "Open composed template" reveals and selects the target). Compositions are shown by **effective set** (own + inherited): a derived/composed member surfaces the slots it inherits from its base — e.g. `LeakTest` composed onto base `ReactorSide` appears under each derived `…ReactorSide` member — badged **"inherited"** and read-only (Rename/Delete are offered on the base's own slot, not the inherited copy).
- **Per-kind context menus** on folder, template, and composition nodes expose the relevant operations (new folder, new template, rename, move, delete, move to folder). Root-level folders also carry a context menu. **Folder sibling reorder** is done via **Move up / Move down** menu items (M9/T23, `ReorderTemplateFolderCommand`); drag-drop is **not implemented** (permanently deferred). Tree expansion state persists in `sessionStorage`, and deep links (`/design/templates/{id}`) reveal and select the target node.
- A **search box** above the tree (M9/T22) filters visible nodes by substring match; it is wired to `TemplateFolderTree.Filter`.
- The `TemplateEdit` page shows a read-only **"Inherited members" panel** (M9/T26) listing the full multi-level effective inherited member set (origin, locked state, merged HiLo config) resolved by `GetResolvedTemplateMembersCommand` / `TemplateInheritanceResolver`. A **"Base changed" banner** appears when the resolver's staleness summary indicates the parent template has changed since the child was last edited. This is read-only — no "update-derived" mutation is exposed; the child is redeployed through the normal flow to pick up base changes.
@@ -64,6 +64,19 @@ flowchart TD
class step4 start
```
### Validation Error Reporting
When step 2 fails, the returned error is a **grouped, capped summary** rather than a flat
semicolon-joined dump (followup #8). `ValidationResult.SummarizeErrors()` (Commons) leads
with the total error count, then lists one line per `ValidationCategory`; within a
category, entity-scoped findings (notably the unbound connection-binding case, which can
produce 50194 entries for a richly data-sourced instance) are rolled up by **module**
the attribute's canonical name up to its last dot — with per-module counts, and the breadth
is capped with a `… and N more module(s)` suffix. The complete per-entry list remains on
`ValidationResult.Errors` and is written to the deploy log (`LogWarning`) so operators can
still see every clause when needed. This keeps the UI/CLI failure toast scannable while
preserving full detail for diagnosis.
## Deployment Identity & Idempotency
- Every deployment is assigned a unique **deployment ID** and includes the flattened configuration's **revision hash** (from the Template Engine).
@@ -158,6 +158,15 @@ The resolved result carries, per member:
The resolver is consumed only by the Central UI `TemplateEdit` page. It is not part of the flattening pipeline and is not called during deployment.
## Inherited-Member Propagation & Resync
A derived template stores `IsInherited` **placeholder rows** mirroring every member it inherits (attributes, alarms, scripts, native alarm sources). These placeholders are what the editor's editable member tabs render and what the staleness summary above compares against. They are materialized when the derived template is created (compose / inherit), and kept in sync by two mechanisms so the stored rows never drift incomplete:
- **Auto-propagation.** Whenever a member is **added, updated, or removed** on a template, the change is propagated to that template's entire derived subtree: a missing inherited placeholder is materialized, a drifted one is re-synced to the live effective value, and an orphaned one (its base member removed) is deleted. This runs automatically as part of the member-mutating commands, so children stay complete going forward.
- **Resync (operator repair).** `ResyncInheritedMembersAsync` (CLI `template resync-members`, and a **Resync** button on the editor's "base changed" banner) reconciles a template **and its subtree** on demand — repairing templates that drifted before auto-propagation existed (e.g. base members added after a child was derived). Resyncing a base repairs every derivation; resyncing a leaf repairs just it.
Both delegate to one **order-independent reconcile** that compares a template's stored inherited rows against the inheritance resolver's effective set (the same precedence + HiLo merge the editor preview and deploy use) and only ever touches `IsInherited` placeholder rows — never an authored override (`IsInherited == false`). Because the resolver ignores placeholder rows when picking winners, reconciling one template never changes what another resolves, so the operation needs no particular ordering. The effective value mirrored into each placeholder matches the staleness comparison key per member type, so after a reconcile the "base changed" banner clears. Reconcile is best-effort housekeeping for the *stored authoring rows*: a deploy always re-resolves the chain fresh regardless, so a not-yet-resynced template still deploys correctly.
## Diff Calculation
The Template Engine can compare:
@@ -30,6 +30,13 @@ internal static class CommandHelpers
/// (<see cref="IsAuthorizationFailure"/>) is preserved on the error path either way,
/// closing CLI-017's regression.
/// </param>
/// <param name="tableProjector">
/// Optional transform applied to the success JSON body <em>only</em> when the resolved
/// format is <c>table</c>. Lets a command render a compact table projection (e.g.
/// <c>template list</c> dropping per-template attribute dumps, followup #6) while
/// leaving JSON output untouched for machine consumers. Ignored when
/// <paramref name="onSuccess"/> is supplied.
/// </param>
/// <returns>A task that resolves to the process exit code (0 = success, 1 = error, 2 = authorization failure).</returns>
internal static async Task<int> ExecuteCommandAsync(
ParseResult result,
@@ -39,7 +46,8 @@ internal static class CommandHelpers
Option<string> passwordOption,
object command,
TimeSpan? timeout = null,
Func<string, int>? onSuccess = null)
Func<string, int>? onSuccess = null,
Func<string, string>? tableProjector = null)
{
var config = CliConfig.Load();
var format = ResolveFormat(result, formatOption, config);
@@ -98,7 +106,7 @@ internal static class CommandHelpers
return IsAuthorizationFailure(response) ? 2 : 1;
}
return HandleResponse(response, format);
return HandleResponse(response, format, tableProjector);
}
/// <summary>
@@ -158,8 +166,12 @@ internal static class CommandHelpers
/// </summary>
/// <param name="response">Response received from the management API.</param>
/// <param name="format">Output format (<c>json</c> or <c>table</c>).</param>
/// <param name="tableProjector">
/// Optional transform applied to the JSON body before table rendering only — JSON
/// output is never altered. See <see cref="ExecuteCommandAsync"/>.
/// </param>
/// <returns>The process exit code (0 = success, 1 = error, 2 = authorization failure).</returns>
internal static int HandleResponse(ManagementResponse response, string format)
internal static int HandleResponse(ManagementResponse response, string format, Func<string, string>? tableProjector = null)
{
if (response.JsonData != null)
{
@@ -173,7 +185,11 @@ internal static class CommandHelpers
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
{
WriteAsTable(response.JsonData);
// A table projector compacts the body for terminal display (e.g. dropping
// per-template attribute dumps). JSON output stays full/untouched so
// machine consumers keep the complete payload.
var body = tableProjector != null ? tableProjector(response.JsonData) : response.JsonData;
WriteAsTable(body);
}
else
{
@@ -54,7 +54,18 @@ public static class InstanceCommands
private static Command BuildSetBindings(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Instance ID", Required = true };
var bindingsOption = new Option<string>("--bindings") { Description = "JSON array of [attributeName, dataConnectionId] pairs", Required = true };
var bindingsOption = new Option<string>("--bindings")
{
Description = "JSON array of binding entries. Each entry is either " +
"[attributeName, dataConnectionId] or " +
"[attributeName, dataConnectionId, dataSourceReferenceOverride] " +
"(the 3rd element overrides the attribute's data-source reference; " +
"pass null or omit it to use the template default). " +
"NOTE: this REPLACES all bindings for the instance — include the " +
"override on every entry that needs one, or omitting it clears any " +
"previously-set override.",
Required = true
};
var cmd = new Command("set-bindings") { Description = "Set data connection bindings for an instance" };
cmd.Add(idOption);
@@ -76,11 +87,16 @@ public static class InstanceCommands
}
/// <summary>
/// Parses the <c>--bindings</c> argument — a JSON array of
/// <c>[attributeName, dataConnectionId]</c> pairs — into a typed list.
/// Returns <c>false</c> with a descriptive <paramref name="error"/> instead of
/// throwing when the JSON is malformed, a pair has the wrong arity, or an element
/// has the wrong type.
/// Parses the <c>--bindings</c> argument — a JSON array of binding entries — into a
/// typed list. Each entry is either a two-element
/// <c>[attributeName, dataConnectionId]</c> pair or a three-element
/// <c>[attributeName, dataConnectionId, dataSourceReferenceOverride]</c> triple. The
/// optional third element carries the per-instance data-source reference override
/// (<see cref="ConnectionBinding.DataSourceReferenceOverride"/>); a JSON
/// <c>null</c> (or an omitted third element) leaves it unset so the template default
/// applies. Returns <c>false</c> with a descriptive <paramref name="error"/> instead
/// of throwing when the JSON is malformed, an entry has the wrong arity, or an
/// element has the wrong type.
/// </summary>
/// <param name="json">The JSON string to parse.</param>
/// <param name="bindings">The parsed bindings list, or null if parsing fails.</param>
@@ -99,16 +115,19 @@ public static class InstanceCommands
.Deserialize<List<List<System.Text.Json.JsonElement>>>(json);
if (pairs == null)
{
error = "Bindings JSON must be a non-null array of [attributeName, dataConnectionId] pairs.";
error = "Bindings JSON must be a non-null array of "
+ "[attributeName, dataConnectionId] or "
+ "[attributeName, dataConnectionId, dataSourceReferenceOverride] entries.";
return false;
}
var result = new List<ConnectionBinding>(pairs.Count);
foreach (var pair in pairs)
{
if (pair.Count != 2)
if (pair.Count is not (2 or 3))
{
error = "Each binding must be a [attributeName, dataConnectionId] pair of exactly two elements.";
error = "Each binding must be a [attributeName, dataConnectionId] pair, "
+ "optionally with a third dataSourceReferenceOverride element.";
return false;
}
if (pair[0].ValueKind != System.Text.Json.JsonValueKind.String)
@@ -122,7 +141,24 @@ public static class InstanceCommands
error = "The second element of each binding (dataConnectionId) must be an integer.";
return false;
}
result.Add(new ConnectionBinding(pair[0].GetString()!, connectionId));
string? referenceOverride = null;
if (pair.Count == 3)
{
var third = pair[2];
if (third.ValueKind == System.Text.Json.JsonValueKind.String)
{
referenceOverride = third.GetString();
}
else if (third.ValueKind != System.Text.Json.JsonValueKind.Null)
{
error = "The third element of each binding (dataSourceReferenceOverride) "
+ "must be a string or null.";
return false;
}
}
result.Add(new ConnectionBinding(pair[0].GetString()!, connectionId, referenceOverride));
}
bindings = result;
@@ -29,17 +29,44 @@ public static class TemplateCommands
command.Add(BuildNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildScript(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildComposition(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildResyncMembers(urlOption, formatOption, usernameOption, passwordOption));
return command;
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
private static Command BuildResyncMembers(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("list") { Description = "List all templates" };
var idOption = new Option<int>("--id") { Description = "Template ID (its derived subtree is included)", Required = true };
var cmd = new Command("resync-members")
{
Description = "Resync inherited members onto a template and its derived subtree " +
"(materialize missing, re-sync drifted, remove orphaned)"
};
cmd.Add(idOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListTemplatesCommand());
result, urlOption, formatOption, usernameOption, passwordOption, new ResyncInheritedMembersCommand(id));
});
return cmd;
}
private static Command BuildList(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var detailOption = new Option<bool>("--detail")
{
Description = "Include full template definitions (all attributes/alarms/scripts) in table output. "
+ "Without it, table output is a compact summary (counts only). JSON output is always full."
};
var cmd = new Command("list") { Description = "List all templates (compact table summary; use --detail for the full dump)" };
cmd.Add(detailOption);
cmd.SetAction(async (ParseResult result) =>
{
var detail = result.GetValue(detailOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new ListTemplatesCommand(),
tableProjector: detail ? null : TemplateTableProjection.ProjectSummary);
});
return cmd;
}
@@ -47,13 +74,21 @@ public static class TemplateCommands
private static Command BuildGet(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
var cmd = new Command("get") { Description = "Get a template by ID" };
var detailOption = new Option<bool>("--detail")
{
Description = "Include full template definitions (all attributes/alarms/scripts) in table output. "
+ "Without it, table output is a compact summary (counts only). JSON output is always full."
};
var cmd = new Command("get") { Description = "Get a template by ID (compact table summary; use --detail for the full dump)" };
cmd.Add(idOption);
cmd.Add(detailOption);
cmd.SetAction(async (ParseResult result) =>
{
var id = result.GetValue(idOption);
var detail = result.GetValue(detailOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetTemplateCommand(id));
result, urlOption, formatOption, usernameOption, passwordOption, new GetTemplateCommand(id),
tableProjector: detail ? null : TemplateTableProjection.ProjectSummary);
});
return cmd;
}
@@ -84,10 +119,10 @@ public static class TemplateCommands
{
var idOption = new Option<int>("--id") { Description = "Template ID", Required = true };
var nameOption = new Option<string>("--name") { Description = "Template name", Required = true };
var descOption = new Option<string?>("--description") { Description = "Template description" };
var parentOption = new Option<int?>("--parent-id") { Description = "Parent template ID" };
var descOption = new Option<string?>("--description") { Description = "Template description. Omit to leave unchanged; pass an empty string (\"\") to clear it." };
var parentOption = new Option<int?>("--parent-id") { Description = "Parent template ID. Immutable after creation; omit to leave unchanged." };
var cmd = new Command("update") { Description = "Update a template" };
var cmd = new Command("update") { Description = "Update a template (omitted optional fields are left unchanged)" };
cmd.Add(idOption);
cmd.Add(nameOption);
cmd.Add(descOption);
@@ -0,0 +1,112 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// Compact table projection for <c>template list</c> / <c>template get</c> (followup #6).
/// The management API returns full <c>Template</c> entities — every attribute, alarm,
/// script, and composition inline — which the generic table renderer dumps as one giant
/// cell per template (~171 KB for a real catalogue, unusable in a terminal). This
/// projector reduces each template to id / name / description / parent / derived plus
/// member <em>counts</em>, leaving JSON output untouched (callers pass this only on the
/// table path) and the full dump available via the command's <c>--detail</c> flag.
/// </summary>
internal static class TemplateTableProjection
{
/// <summary>
/// Projects a templates JSON response (an array from <c>list</c> or a single object
/// from <c>get</c>) to its compact summary form. Returns the input unchanged when it
/// is not JSON or not the expected shape, so the generic renderer's own fallbacks
/// still apply.
/// </summary>
/// <param name="json">The raw success JSON body from the management API.</param>
/// <returns>Compact JSON (same array/object shape) suitable for table rendering.</returns>
internal static string ProjectSummary(string json)
{
JsonDocument doc;
try
{
doc = JsonDocument.Parse(json);
}
catch (JsonException)
{
// Not JSON (e.g. a proxy error page) — let the renderer print it verbatim.
return json;
}
using (doc)
{
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Array)
{
var arr = new JsonArray();
foreach (var item in root.EnumerateArray())
arr.Add(ProjectElement(item));
return arr.ToJsonString();
}
if (root.ValueKind == JsonValueKind.Object)
{
return ProjectElement(root).ToJsonString();
}
return json;
}
}
/// <summary>Projects a single template object to its compact summary node.</summary>
private static JsonNode ProjectElement(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
return JsonValue.Create(element.ToString())!;
// JsonObject preserves insertion order, fixing the column order for the table.
return new JsonObject
{
["id"] = Int(element, "id"),
["name"] = Str(element, "name"),
["description"] = Str(element, "description"),
["parentTemplateId"] = Int(element, "parentTemplateId"),
["isDerived"] = Bool(element, "isDerived"),
["#attrs"] = Count(element, "attributes"),
["#alarms"] = Count(element, "alarms"),
["#scripts"] = Count(element, "scripts"),
["#comps"] = Count(element, "compositions"),
["#nativeAlarms"] = Count(element, "nativeAlarmSources"),
};
}
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
private static JsonNode? Str(JsonElement obj, string name)
=> TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.String
? JsonValue.Create(v.GetString())
: null;
private static JsonNode? Int(JsonElement obj, string name)
=> TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n)
? JsonValue.Create(n)
: null;
private static JsonNode? Bool(JsonElement obj, string name)
=> TryGetPropertyCI(obj, name, out var v) && (v.ValueKind == JsonValueKind.True || v.ValueKind == JsonValueKind.False)
? JsonValue.Create(v.GetBoolean())
: null;
private static JsonNode Count(JsonElement obj, string name)
=> JsonValue.Create(
TryGetPropertyCI(obj, name, out var v) && v.ValueKind == JsonValueKind.Array
? v.GetArrayLength()
: 0);
}
+51 -10
View File
@@ -86,23 +86,35 @@ Exit codes:
#### `template list`
List all templates with their full attribute, alarm, script, and composition definitions.
List all templates. **Table** output (`--format table`) shows a compact summary — id,
name, description, parent, and member **counts** (`#attrs`, `#alarms`, `#scripts`,
`#comps`, `#nativeAlarms`) — so it stays readable in a terminal. Add `--detail` to dump
the full attribute/alarm/script/composition definitions in the table. **JSON** output
(`--format json`) is always the full, unmodified payload regardless of `--detail`.
```sh
scadabridge --url <url> template list
scadabridge --url <url> template list # compact table
scadabridge --url <url> --format table template list --detail # full table dump
scadabridge --url <url> --format json template list # full JSON (always)
```
| Option | Required | Description |
|--------|----------|-------------|
| `--detail` | no | Include full definitions in table output (no effect on JSON) |
#### `template get`
Get a single template by ID.
Get a single template by ID. Like `template list`, table output is a compact summary
unless `--detail` is supplied; JSON output is always full.
```sh
scadabridge --url <url> template get --id <int>
scadabridge --url <url> template get --id <int> [--detail]
```
| Option | Required | Description |
|--------|----------|-------------|
| `--id` | yes | Template ID |
| `--detail` | no | Include full definitions in table output (no effect on JSON) |
#### `template create`
@@ -120,9 +132,11 @@ scadabridge --url <url> template create --name <string> [--description <string>]
#### `template update`
Update an existing template. An update **replaces** the whole entity — every required
field below must be supplied with the value it should have after the update, even if
it is unchanged.
Update an existing template. Optional fields use **leave-unchanged** semantics: omitting
`--description` or `--parent-id` keeps the stored value rather than wiping it. To
explicitly clear the description, pass an empty string (`--description ""`). The parent
template is immutable after creation — omit `--parent-id` (or pass the current value);
passing a different value is rejected.
```sh
scadabridge --url <url> template update --id <int> --name <string> [--description <string>] [--parent-id <int>]
@@ -132,8 +146,8 @@ scadabridge --url <url> template update --id <int> --name <string> [--descriptio
|--------|----------|-------------|
| `--id` | yes | Template ID |
| `--name` | yes | Template name |
| `--description` | no | Updated description |
| `--parent-id` | no | Updated parent template ID |
| `--description` | no | Updated description. Omit to leave unchanged; pass `""` to clear. |
| `--parent-id` | no | Immutable; omit to leave unchanged. |
#### `template delete`
@@ -403,6 +417,18 @@ scadabridge --url <url> template composition delete --id <int>
|--------|----------|-------------|
| `--id` | yes | Composition ID to remove |
#### `template resync-members`
Reconcile a template's stored *inherited* member rows (and those of its whole derived subtree) with the resolved effective inherited set: materialize missing inherited placeholders (e.g. base members added after a derived template was created), re-sync drifted ones to the live base value, and remove orphaned ones. After a resync the template editor's member tabs are complete and the "Base template changed" banner clears. Authored overrides are never touched. Base member changes auto-propagate going forward; this command repairs templates that drifted before that was in place. Returns the per-subtree change counts (templates changed, members added/updated/removed).
```sh
scadabridge --url <url> template resync-members --id <int>
```
| Option | Required | Description |
|--------|----------|-------------|
| `--id` | yes | Template ID (its derived subtree is included) |
---
### `instance` — Manage instances
@@ -532,7 +558,22 @@ scadabridge --url <url> instance set-bindings --id <int> --bindings <json>
| Option | Required | Description |
|--------|----------|-------------|
| `--id` | yes | Instance ID |
| `--bindings` | yes | JSON array of `[attributeName, dataConnectionId]` pairs (e.g. `[["Speed",7],["Temperature",7]]`) |
| `--bindings` | yes | JSON array of binding entries, each either `[attributeName, dataConnectionId]` or `[attributeName, dataConnectionId, dataSourceReferenceOverride]` |
The optional **third element** sets the per-instance data-source reference override
(the OPC UA node id / protocol address used in place of the template's
`DataSourceReference` for that attribute). Pass a string to set it, or `null` / omit
it to use the template default:
```sh
# Bind Speed with an address override; bind Mode using the template default reference.
scadabridge --url <url> instance set-bindings --id 42 \
--bindings '[["Speed",7,"ns=2;s=Reactor.Speed"],["Mode",7]]'
```
> **Note:** this command **replaces** all bindings for the instance. Include the
> override on every entry that needs one — omitting the third element clears any
> previously-set override for that attribute.
#### `instance set-overrides`
@@ -288,18 +288,29 @@
</div>
}
@* M9-T26b: read-only base-changed banner. Informational only — surfaced
when the freshly-resolved inherited set differs from this template's
stored copy (a multi-level inherited member, or a base member added
after this template was created). No action button: a deploy already
resolves fresh, so this is purely an authoring heads-up. *@
@* Base-changed banner (follow-up #1/#2). Surfaced when the freshly-resolved
inherited set differs from this template's stored copy (a base member
added/changed/removed after this template was created, or a multi-level
inherited member). The Resync action materializes the missing inherited
rows, re-syncs drifted ones, and removes orphans on this template and its
derived subtree — so the editable tabs below become complete and the
banner clears. (A deploy already resolves fresh regardless.) *@
@if (_resolved?.Staleness.IsStale == true)
{
<div class="alert alert-info py-2 mb-3" role="status">
<div class="alert alert-info d-flex align-items-center justify-content-between py-2 mb-3" role="status">
<div>
<i class="bi bi-info-circle me-1"></i>
<strong>Base template changed</strong> —
@_resolved.Staleness.DifferingMemberCount inherited member(s) differ from this template's stored copy.
The effective set above reflects the live base; a deploy resolves fresh.
Resync to bring this template's stored members in line with the base.
</div>
<button class="btn btn-sm btn-primary ms-3 text-nowrap" @onclick="ResyncInheritedMembers" disabled="@_resyncing">
@if (_resyncing)
{
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
}
Resync inherited members
</button>
</div>
}
@@ -944,6 +955,38 @@
else _toast.ShowError(result.Error);
}
private bool _resyncing;
// Follow-up #1/#2: materialize/refresh this template's (and its subtree's)
// inherited member rows so the editable tabs are complete and the
// base-changed banner clears. In-process TemplateService call (the page is
// already RequireDesign-gated, matching the management command's role gate).
private async Task ResyncInheritedMembers()
{
if (_resyncing) return;
_resyncing = true;
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.ResyncInheritedMembersAsync(Id, user);
if (result.IsSuccess)
{
var r = result.Value;
_toast.ShowSuccess(
$"Resynced inherited members — {r.MembersAdded} added, {r.MembersUpdated} updated, {r.MembersRemoved} removed across {r.TemplatesChanged} template(s).");
await LoadAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
finally
{
_resyncing = false;
}
}
// ---- Alarms Tab ----
private RenderFragment RenderAlarmsTab() => __builder =>
{
@@ -162,24 +162,46 @@
// hook: walks each template's compositions recursively so cascaded slots
// appear as nested children. The Transport Export wizard intentionally
// does NOT supply this hook — compositions aren't independently exportable.
//
// followup #9: a derived template inherits its ancestors' compositions (e.g.
// LeftReactorSide, derived from ReactorSide, inherits ReactorSide's LeakTest
// slot). The inherited TemplateComposition row lives on the ancestor — there is
// no IsInherited placeholder row on the child — so the recursion uses the
// EFFECTIVE composition set (own + inherited), mirroring
// TemplateResolver.ResolveAllMembers which walks the inheritance chain. Inherited
// slots are flagged so the label badges them and the menu suppresses edits.
private IReadOnlyList<TemplateTreeNode> BuildCompositionLeavesFor(Template owner)
=> BuildCompositionLeavesFor(owner, $"t:{owner.Id}", new HashSet<int>());
private IReadOnlyList<TemplateTreeNode> BuildCompositionLeavesFor(
Template owner, string parentKey, HashSet<int> compositionPath)
{
var result = new List<TemplateTreeNode>();
foreach (var c in owner.Compositions.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase))
foreach (var (composition, inherited) in EffectiveCompositionsFor(owner)
.OrderBy(x => x.Composition.InstanceName, StringComparer.OrdinalIgnoreCase))
{
// Path-qualified key: the same inherited row can appear under multiple
// derived members, so qualify by the parent path to keep keys unique.
var key = $"{parentKey}/c:{composition.Id}";
var node = new TemplateTreeNode
{
Kind = TemplateTreeNodeKind.Composition,
Id = c.Id,
Name = c.InstanceName,
Id = composition.Id,
Name = composition.InstanceName,
IsInherited = inherited,
KeyOverride = key,
};
if (_templatesById.TryGetValue(c.ComposedTemplateId, out var composed))
// Cycle guard: the composition graph is acyclic by construction, but
// pulling in inherited compositions means guarding the recursion defensively.
if (_templatesById.TryGetValue(composition.ComposedTemplateId, out var composed)
&& compositionPath.Add(composition.ComposedTemplateId))
{
foreach (var nested in BuildCompositionLeavesFor(composed))
foreach (var nested in BuildCompositionLeavesFor(composed, key, compositionPath))
{
node.Children.Add(nested);
}
compositionPath.Remove(composition.ComposedTemplateId);
}
result.Add(node);
@@ -187,6 +209,26 @@
return result;
}
// The compositions visible on a template: its own rows plus those inherited from
// ancestors. Walks leaf -> root so a child's own slot wins over an inherited slot
// of the same InstanceName (child-overrides-parent, matching the resolver). The
// Inherited flag is true when the row's owning template is an ancestor, not `owner`.
private IEnumerable<(TemplateComposition Composition, bool Inherited)> EffectiveCompositionsFor(Template owner)
{
var byName = new Dictionary<string, (TemplateComposition Composition, bool Inherited)>(
StringComparer.OrdinalIgnoreCase);
var visited = new HashSet<int>();
for (Template? t = owner;
t is not null && visited.Add(t.Id);
t = t.ParentTemplateId is int pid && _templatesById.TryGetValue(pid, out var parent) ? parent : null)
{
var inherited = t.Id != owner.Id;
foreach (var c in t.Compositions)
byName.TryAdd(c.InstanceName, (c, inherited)); // leaf seen first -> wins
}
return byName.Values;
}
private TemplateFolderTree _tree = default!;
private void OpenTemplate(int templateId) =>
@@ -220,6 +262,13 @@
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
<span class="tv-label" title="@node.Name"
@ondblclick="() => OpenTemplate(composedId)">@node.Name</span>
@if (node.IsInherited)
{
<span class="tv-meta">
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis"
title="Inherited from a base template">inherited</span>
</span>
}
break;
}
};
@@ -261,10 +310,21 @@
if (_compositionsById.TryGetValue(node.Id, out var ctx))
{
<button class="dropdown-item" @onclick='() => NavigationManager.NavigateTo($"/design/templates/{ctx.ComposedTemplateId}")'>Open composed template</button>
@if (node.IsInherited)
{
// Inherited slot — Rename/Delete would mutate the ancestor's
// composition (affecting every derivation), so they are offered
// on the base template's own slot, not here (followup #9).
<div class="dropdown-divider"></div>
<span class="dropdown-item-text text-muted small">Inherited from a base template — edit on the base.</span>
}
else
{
<button class="dropdown-item" @onclick="() => RenameComposition(ctx)">Rename…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteComposition(ctx)">Delete</button>
}
}
break;
}
};
@@ -31,8 +31,26 @@ public sealed class TemplateTreeNode
/// <summary>Child nodes (sub-folders, templates, or composition slots).</summary>
public List<TemplateTreeNode> Children { get; } = new();
/// <summary>
/// True when this composition node is <em>inherited</em> — its underlying
/// <c>TemplateComposition</c> row belongs to an ancestor of the template it is
/// rendered under, not to that template itself (followup #9). Inherited nodes are
/// badged read-only and their context menu suppresses Rename/Delete (those edit the
/// base). Always false for folders and templates.
/// </summary>
public bool IsInherited { get; init; }
/// <summary>
/// Explicit, path-qualified key override. The same inherited composition row can
/// surface under several derived members (e.g. LeakTest under both LeftReactorSide
/// and RightReactorSide), so a plain <c>c:{Id}</c> key would collide and break the
/// TreeView's selection/expansion tracking. Composition nodes set this to a
/// parent-path-qualified value; folders/templates leave it null and use the default.
/// </summary>
public string? KeyOverride { get; init; }
/// <summary>Stable key for TreeView selection / expansion tracking.</summary>
public string Key => Kind switch
public string Key => KeyOverride ?? Kind switch
{
TemplateTreeNodeKind.Folder => $"f:{Id}",
TemplateTreeNodeKind.Template => $"t:{Id}",
@@ -105,6 +105,23 @@ public sealed record ResolvedTemplateMemberInfo
public string? EffectiveTriggerConfiguration { get; init; }
}
/// <summary>
/// Result of a <see cref="ResyncInheritedMembersCommand"/>: how many derived
/// templates were brought into sync and how many inherited member rows were
/// added / updated / removed across the reconciled subtree.
/// </summary>
public sealed record ResyncInheritedMembersResult
{
/// <summary>Number of templates in the subtree that had at least one inherited-row change.</summary>
public int TemplatesChanged { get; init; }
/// <summary>Inherited placeholder rows materialized (members missing from the stored set).</summary>
public int MembersAdded { get; init; }
/// <summary>Inherited placeholder rows re-synced to the live effective value.</summary>
public int MembersUpdated { get; init; }
/// <summary>Orphaned inherited placeholder rows removed (the base no longer defines them).</summary>
public int MembersRemoved { get; init; }
}
/// <summary>
/// Staleness summary comparing a template's STORED member rows against the
/// freshly-resolved inherited member set.
@@ -3,6 +3,13 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record ListTemplatesCommand;
public record GetTemplateCommand(int TemplateId);
public record CreateTemplateCommand(string Name, string? Description, int? ParentTemplateId);
/// <summary>
/// Updates a template. Optional fields use leave-unchanged semantics (followup #5):
/// a <c>null</c> <see cref="Description"/> keeps the stored description (pass an empty
/// string to clear it), and a <c>null</c> <see cref="ParentTemplateId"/> keeps the
/// existing parent (the parent is immutable; a non-null value that differs is rejected).
/// </summary>
public record UpdateTemplateCommand(int TemplateId, string Name, string? Description, int? ParentTemplateId);
public record DeleteTemplateCommand(int TemplateId);
public record ValidateTemplateCommand(int TemplateId);
@@ -17,6 +24,18 @@ public record ValidateTemplateCommand(int TemplateId);
/// </summary>
public record GetResolvedTemplateMembersCommand(int TemplateId);
/// <summary>
/// Reconciles a template's STORED inherited member rows with the resolved
/// effective inherited set, for the template and its whole derived subtree:
/// materializes missing inherited placeholders (e.g. base members added after a
/// derived template was created), re-syncs drifted placeholders to the live
/// effective value, and removes orphaned inherited rows. After a resync the
/// editor's editable member tabs are complete and the "base changed" staleness
/// banner clears. Designer-gated, audited. Response:
/// <see cref="ResyncInheritedMembersResult"/>.
/// </summary>
public record ResyncInheritedMembersCommand(int TemplateId);
// Template member operations
public record AddTemplateAttributeCommand(int TemplateId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
public record UpdateTemplateAttributeCommand(int AttributeId, string Name, string DataType, string? Value, string? Description, string? DataSourceReference, bool IsLocked, string? ElementDataType = null);
@@ -1,3 +1,5 @@
using System.Text;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
/// <summary>
@@ -12,6 +14,98 @@ public sealed record ValidationResult
/// <summary>Non-blocking validation warnings.</summary>
public IReadOnlyList<ValidationEntry> Warnings { get; init; } = [];
/// <summary>
/// Produces a compact, human-readable summary of the validation errors instead of a
/// flat semicolon-joined dump (followup #8). The old behaviour concatenated one clause
/// per error — for an instance with 50194 unbound attributes that is a wall of text
/// unreadable in a CLI/UI toast. This groups errors by <see cref="ValidationCategory"/>
/// and, within a category, rolls entries up by "module" (the entity's canonical name up
/// to its last dot) with counts, capping the breadth with "… and N more". The full,
/// per-entry list is still available on <see cref="Errors"/> for a detail view or log.
/// </summary>
/// <param name="maxGroups">
/// Maximum number of modules (or messages, when entries are not entity-scoped) to list
/// per category before collapsing the remainder into a "… and N more" suffix.
/// </param>
/// <returns>
/// A multi-line summary, or an empty string when there are no errors. The first line is
/// the total error count; subsequent lines are one per category.
/// </returns>
public string SummarizeErrors(int maxGroups = 6)
{
if (Errors.Count == 0)
return string.Empty;
var sb = new StringBuilder();
sb.Append(Errors.Count).Append(Errors.Count == 1 ? " error:" : " errors:");
// Group errors by category, preserving first-seen order for a stable rendering.
var byCategory = new List<(ValidationCategory Category, List<ValidationEntry> Items)>();
var categoryIndex = new Dictionary<ValidationCategory, int>();
foreach (var error in Errors)
{
if (!categoryIndex.TryGetValue(error.Category, out var i))
{
i = byCategory.Count;
categoryIndex[error.Category] = i;
byCategory.Add((error.Category, []));
}
byCategory[i].Items.Add(error);
}
foreach (var (category, items) in byCategory)
{
sb.AppendLine();
sb.Append(" • ").Append(category).Append(" (").Append(items.Count).Append("): ");
// Prefer an entity/module rollup when every entry names an entity (the unbound-
// binding case). Otherwise fall back to a capped list of the raw messages.
if (items.TrueForAll(e => !string.IsNullOrEmpty(e.EntityName)))
{
var modules = new List<(string Module, int Count)>();
var moduleIndex = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var entry in items)
{
var module = ModuleOf(entry.EntityName!);
if (!moduleIndex.TryGetValue(module, out var mi))
{
mi = modules.Count;
moduleIndex[module] = mi;
modules.Add((module, 0));
}
modules[mi] = (module, modules[mi].Count + 1);
}
var shown = modules.Take(maxGroups).Select(m => $"{m.Module} ({m.Count})");
sb.Append(string.Join(", ", shown));
if (modules.Count > maxGroups)
sb.Append($", … and {modules.Count - maxGroups} more module(s)");
}
else
{
var shown = items.Take(maxGroups).Select(e => e.Message);
sb.Append(string.Join("; ", shown));
if (items.Count > maxGroups)
sb.Append($"; … and {items.Count - maxGroups} more");
}
}
return sb.ToString();
}
/// <summary>
/// Returns the "module" portion of a canonical attribute name — everything up to the
/// last <c>.</c> (e.g. <c>LeftReactorSide.LeakTest.DeltaVac</c> → <c>LeftReactorSide.LeakTest</c>).
/// A name with no dot is reported under <c>(root)</c>.
/// </summary>
/// <param name="canonicalName">The entity's canonical name.</param>
/// <returns>The module prefix, or <c>(root)</c> for top-level members.</returns>
private static string ModuleOf(string canonicalName)
{
var lastDot = canonicalName.LastIndexOf('.');
return lastDot > 0 ? canonicalName[..lastDot] : "(root)";
}
/// <summary>Returns a result with no errors or warnings.</summary>
/// <returns>A <see cref="ValidationResult"/> with empty error and warning lists.</returns>
public static ValidationResult Success() => new();
@@ -0,0 +1,73 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>Outcome of parsing the command line: success carries the payload, failure carries a human reason.</summary>
internal sealed record ParseResult(bool Ok, RecipeDownload? Payload, string? Error)
{
public static ParseResult Success(RecipeDownload payload) => new(true, payload, null);
public static ParseResult Fail(string error) => new(false, null, error);
}
/// <summary>
/// Hand-rolled, reflection-free parser for the legacy WWNotifier flags. Each flag takes one value
/// (short or long form). The four required flags must be present; the two optional flags may be omitted.
/// </summary>
internal static class ArgParser
{
public static ParseResult Parse(string[] args)
{
var payload = new RecipeDownload();
for (var i = 0; i < args.Length; i++)
{
var flag = args[i];
if (i + 1 >= args.Length)
{
return ParseResult.Fail($"missing value for flag '{flag}'");
}
var value = args[++i];
switch (flag)
{
case "-m" or "--machine":
payload.MachineCode = value;
break;
case "-d" or "--downloadpath":
payload.DownloadPath = value;
break;
case "-w" or "--workorder":
payload.WorkOrderNumber = value;
break;
case "-p" or "--partnumber":
payload.PartNumber = value;
break;
case "-s" or "--seqop":
payload.JobStepNumber = value;
break;
case "-u" or "--username":
payload.Username = value;
break;
default:
return ParseResult.Fail($"unknown flag '{flag}'");
}
}
if (string.IsNullOrEmpty(payload.MachineCode))
{
return ParseResult.Fail("missing required flag -m/--machine");
}
if (string.IsNullOrEmpty(payload.DownloadPath))
{
return ParseResult.Fail("missing required flag -d/--downloadpath");
}
if (string.IsNullOrEmpty(payload.WorkOrderNumber))
{
return ParseResult.Fail("missing required flag -w/--workorder");
}
if (string.IsNullOrEmpty(payload.PartNumber))
{
return ParseResult.Fail("missing required flag -p/--partnumber");
}
return ParseResult.Success(payload);
}
}
@@ -0,0 +1,38 @@
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>Reads and interprets the notifier's configuration and secret. Parse logic is string-based so it is unit-testable without touching disk.</summary>
internal static class ConfigLoader
{
private const string ApiKeyEnvVar = "SCADABRIDGE_API_KEY";
/// <summary>Deserialize the <c>appsettings.json</c> text via the source-gen context.</summary>
public static NotifierConfig Load(string jsonText) =>
JsonSerializer.Deserialize(jsonText, NotifierJsonContext.Default.NotifierConfig) ?? new NotifierConfig();
/// <summary>Read and parse the <c>appsettings.json</c> sitting next to the executable.</summary>
public static NotifierConfig LoadFromDefaultFile()
{
var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
return Load(File.ReadAllText(path));
}
/// <summary>Split a comma-separated base-URL list into trimmed, non-empty entries.</summary>
public static string[] SplitBaseUrls(string? baseUrls)
{
if (string.IsNullOrWhiteSpace(baseUrls))
{
return [];
}
return baseUrls.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
/// <summary>Resolve the API key from <c>SCADABRIDGE_API_KEY</c>; null/whitespace → null. Env accessor is injected for testability.</summary>
public static string? ResolveApiKey(Func<string, string?> envGet)
{
var key = envGet(ApiKeyEnvVar);
return string.IsNullOrWhiteSpace(key) ? null : key;
}
}
@@ -0,0 +1,39 @@
using System.Globalization;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>
/// Best-effort diagnostics: writes UTC-timestamped lines to stderr and, when a log path is configured,
/// appends them to a file (relative to the exe). Never throws — diagnostics must not break the YES/NO path.
/// </summary>
internal sealed class DiagLog(string? logPath)
{
private readonly string? _logPath = string.IsNullOrWhiteSpace(logPath) ? null : logPath;
public void Write(string message)
{
var line = string.Create(CultureInfo.InvariantCulture, $"{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ} {message}");
Console.Error.WriteLine(line);
if (_logPath is null)
{
return;
}
try
{
var fullPath = Path.IsPathRooted(_logPath) ? _logPath : Path.Combine(AppContext.BaseDirectory, _logPath);
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
File.AppendAllText(fullPath, line + Environment.NewLine);
}
catch
{
// diagnostics are best-effort; swallow file errors so the YES/NO contract is unaffected
}
}
}
@@ -0,0 +1,58 @@
using System.Text;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>
/// <see cref="IRecipeSender"/> over a real <see cref="HttpClient"/>: POSTs the recipe payload to
/// <c>{baseUrl}/api/DelmiaRecipeDownload</c> with the <c>X-API-Key</c> header. A reachable server
/// (any status) is <see cref="AttemptKind.Connected"/>; a connection/timeout failure is <see cref="AttemptKind.ConnectFailed"/>.
/// </summary>
internal sealed class HttpRecipeSender(HttpClient http, string apiKey) : IRecipeSender
{
private const string MethodPath = "/api/DelmiaRecipeDownload";
public async Task<AttemptOutcome> SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct)
{
var url = baseUrl.TrimEnd('/') + MethodPath;
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.TryAddWithoutValidation("X-API-Key", apiKey);
var json = JsonSerializer.Serialize(payload, NotifierJsonContext.Default.RecipeDownload);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await http.SendAsync(request, ct);
var status = (int)response.StatusCode;
RecipeDownloadResult? body = null;
if (response.IsSuccessStatusCode)
{
var responseText = await response.Content.ReadAsStringAsync(ct);
body = TryParse(responseText);
}
return new AttemptOutcome(AttemptKind.Connected, status, body, null);
}
catch (HttpRequestException ex)
{
return new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, ex.Message);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
// No external cancellation requested → this is an HttpClient.Timeout, i.e. the node never answered.
return new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, "timeout: " + ex.Message);
}
}
private static RecipeDownloadResult? TryParse(string responseText)
{
try
{
return JsonSerializer.Deserialize(responseText, NotifierJsonContext.Default.RecipeDownloadResult);
}
catch (JsonException)
{
return null; // unparseable body on a 2xx → treated as failure by the loop
}
}
}
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>Whether a single POST attempt reached the server (<see cref="Connected"/>) or never did (<see cref="ConnectFailed"/>).</summary>
internal enum AttemptKind
{
Connected,
ConnectFailed,
}
/// <summary>Result of one POST attempt against a single base URL.</summary>
/// <param name="Kind">Did the attempt reach the server?</param>
/// <param name="StatusCode">HTTP status when <see cref="AttemptKind.Connected"/>; 0 otherwise.</param>
/// <param name="Body">Parsed response body on a 2xx; null otherwise.</param>
/// <param name="Error">Connection/exception detail when <see cref="AttemptKind.ConnectFailed"/>; null otherwise.</param>
internal sealed record AttemptOutcome(AttemptKind Kind, int StatusCode, RecipeDownloadResult? Body, string? Error);
/// <summary>Seam over a single recipe-download POST attempt, so the failover loop is testable without real HTTP.</summary>
internal interface IRecipeSender
{
Task<AttemptOutcome> SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct);
}
@@ -0,0 +1,47 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>Final outcome of the notify operation, mapped 1:1 to the YES/NO + exit-code contract.</summary>
internal sealed record NotifyResult(bool Ok, string Reason);
/// <summary>
/// Connect-failure-only failover loop: tries each base URL in order. A node that responds at all is
/// authoritative — its answer is final (success, business rejection, or HTTP error alike). Only a
/// failure to connect rolls over to the next URL; if every URL fails to connect the last error is reported.
/// </summary>
internal static class Notifier
{
public static async Task<NotifyResult> RunAsync(
string[] baseUrls, RecipeDownload payload, IRecipeSender sender, CancellationToken ct)
{
var lastError = "no base URLs configured";
foreach (var baseUrl in baseUrls)
{
var outcome = await sender.SendAsync(baseUrl, payload, ct);
if (outcome.Kind == AttemptKind.ConnectFailed)
{
lastError = outcome.Error ?? "connection failed";
continue; // unreachable node → try the next
}
// Connected — this node's answer is authoritative; never fail over past it.
if (IsSuccessStatus(outcome.StatusCode))
{
if (outcome.Body is { Result: true })
{
return new NotifyResult(true, outcome.Body.ResultText ?? string.Empty);
}
var reason = outcome.Body?.ResultText;
return new NotifyResult(false, string.IsNullOrEmpty(reason) ? "request rejected" : reason);
}
return new NotifyResult(false, $"HTTP {outcome.StatusCode}");
}
return new NotifyResult(false, $"all URLs unreachable: {lastError}");
}
private static bool IsSuccessStatus(int status) => status is >= 200 and < 300;
}
@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>Root of <c>appsettings.json</c>; mirrors the <c>ScadaBridge</c> section only.</summary>
internal sealed class NotifierConfig
{
public ScadaBridgeSection ScadaBridge { get; set; } = new();
}
internal sealed class ScadaBridgeSection
{
/// <summary>Comma-separated failover list of base URLs (the method path is appended by the app).</summary>
public string? BaseUrls { get; set; }
/// <summary>Per-attempt HTTP timeout in seconds.</summary>
public int TimeoutSeconds { get; set; } = 30;
/// <summary>Optional diagnostic log file (relative to the exe); null/omitted → stderr only.</summary>
public string? LogPath { get; set; }
}
@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
[JsonSerializable(typeof(RecipeDownload))]
[JsonSerializable(typeof(RecipeDownloadResult))]
[JsonSerializable(typeof(NotifierConfig))]
internal partial class NotifierJsonContext : JsonSerializerContext;
@@ -0,0 +1,73 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal static class Program
{
public static async Task<int> Main(string[] args)
{
var stdout = Console.Out;
// Config first, so the diagnostic log can honour the configured LogPath even for early failures.
NotifierConfig cfg;
try
{
cfg = ConfigLoader.LoadFromDefaultFile();
}
catch (Exception ex)
{
new DiagLog(null).Write("config load failed: " + ex.Message);
return Reporter.Report(false, "config load failed: " + ex.Message, stdout);
}
var log = new DiagLog(cfg.ScadaBridge.LogPath);
try
{
var parse = ArgParser.Parse(args);
if (!parse.Ok)
{
log.Write("arg error: " + parse.Error);
return Reporter.Report(false, parse.Error!, stdout);
}
var baseUrls = ConfigLoader.SplitBaseUrls(cfg.ScadaBridge.BaseUrls);
if (baseUrls.Length == 0)
{
log.Write("no BaseUrls configured");
return Reporter.Report(false, "no BaseUrls configured", stdout);
}
var apiKey = ConfigLoader.ResolveApiKey(Environment.GetEnvironmentVariable);
if (apiKey is null)
{
log.Write("API key not configured (SCADABRIDGE_API_KEY)");
return Reporter.Report(false, "API key not configured (SCADABRIDGE_API_KEY)", stdout);
}
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(cfg.ScadaBridge.TimeoutSeconds) };
var sender = new LoggingRecipeSender(new HttpRecipeSender(http, apiKey), log);
log.Write($"notifying {baseUrls.Length} URL(s) for machine {parse.Payload!.MachineCode}");
var result = await Notifier.RunAsync(baseUrls, parse.Payload, sender, CancellationToken.None);
log.Write($"result: ok={result.Ok} reason={result.Reason}");
return Reporter.Report(result.Ok, result.Reason, stdout);
}
catch (Exception ex)
{
log.Write("unexpected error: " + ex);
return Reporter.Report(false, "unexpected error: " + ex.Message, stdout);
}
}
/// <summary>Decorates a sender to emit a per-attempt diagnostic line; keeps stdout reserved for the YES/NO contract.</summary>
private sealed class LoggingRecipeSender(IRecipeSender inner, DiagLog log) : IRecipeSender
{
public async Task<AttemptOutcome> SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct)
{
var outcome = await inner.SendAsync(baseUrl, payload, ct);
log.Write(outcome.Kind == AttemptKind.ConnectFailed
? $"attempt {baseUrl}: connect failed: {outcome.Error}"
: $"attempt {baseUrl}: HTTP {outcome.StatusCode}");
return outcome;
}
}
}
@@ -0,0 +1,114 @@
# DelmiaNotifier (`WWNotifier.exe`)
A compact, self-contained Windows console app that **DELMIA Apriso shells out to** on each
recipe / NC-program download to notify the plant that a recipe was placed at a path for a machine.
It is the modern, drop-in replacement for the legacy `WWNotifier` (see
[`../../docs/former-api-specs/dnc/Delmia-Integration-API.md`](../../docs/former-api-specs/dnc/Delmia-Integration-API.md),
*Surface B*), repointed from the old Wonderware `/notify` receiver to the **ScadaBridge Inbound API**
method `DelmiaRecipeDownload`. The command-line flags, the `YES`/`NO` stdout contract, and the
exit-code semantics are **unchanged**, so Delmia's call site and output parsing need no changes — only
the executable on disk and its `appsettings.json` are swapped.
> The published assembly name is **`WWNotifier`** precisely so it is a literal file-for-file drop-in
> for Delmia's existing call path.
## What it does
Per invocation it POSTs the recipe-download notification to the configured ScadaBridge node(s) and
maps the result back to the legacy contract:
```
DELMIA → WWNotifier.exe → POST {baseUrl}/api/DelmiaRecipeDownload (X-API-Key: <key>)
← { "Result": true/false, "ResultText": "..." }
← stdout: YES | NO\n<reason> · exit: 0 | -1
```
## Command-line flags
| Short | Long | Required | → payload field |
|---|---|---|---|
| `-d` | `--downloadpath` | yes | `DownloadPath` |
| `-m` | `--machine` | yes | `MachineCode` |
| `-w` | `--workorder` | yes | `WorkOrderNumber` |
| `-p` | `--partnumber` | yes | `PartNumber` |
| `-s` | `--seqop` | no | `JobStepNumber` |
| `-u` | `--username` | no | `Username` |
Each flag takes one value (`-m Z28061`). A missing required flag → `NO` + reason, exit `-1`, **no**
HTTP attempt.
```
WWNotifier.exe -m Z28061 -d "C:\recipes\job.nc" -w WO12345 -p PN-7788 -s 0100 -u operator1
```
## Output contract (drop-in parity)
- **stdout** is reserved for the contract Delmia parses:
- success → `YES` (exit `0`)
- failure → `NO` on the first line, then a human-readable reason on the next line (exit `-1`)
- **exit code**: `0` on success, `-1` on failure. (On POSIX shells `-1` surfaces as `255`; on Windows
`%ERRORLEVEL%` is `-1`.)
- All diagnostics (per-URL attempt, status code, which URL answered, exceptions) go to **stderr** and,
if `LogPath` is set, an appended log file — never to stdout.
## Configuration — `appsettings.json`
Placed next to the exe (copied to the output on build/publish):
```json
{
"ScadaBridge": {
"BaseUrls": "http://host-a:8085,http://host-b:8085",
"TimeoutSeconds": 30,
"LogPath": "logs/delmia-notifier.log"
}
}
```
- **`BaseUrls`** — comma-separated failover list of **base URLs only**; the app appends
`/api/DelmiaRecipeDownload`. URLs are tried in order with **connect-failure-only failover**: a node
that responds at all (even `4xx`/`5xx`) is authoritative and its answer is final; only a node that
fails to connect (refused/reset, DNS, TLS, timeout) rolls over to the next. If every URL fails to
connect → `NO` + last connection error.
- **`TimeoutSeconds`** — per-attempt HTTP timeout (default `30`).
- **`LogPath`** — optional diagnostic log file, relative to the exe. Omit to log to stderr only.
## Secret — `SCADABRIDGE_API_KEY`
The inbound API key is read **only** from the environment variable `SCADABRIDGE_API_KEY` and is sent
as the `X-API-Key` header. It is never read from or written to a file. If unset/empty → `NO` +
`API key not configured (SCADABRIDGE_API_KEY)`, exit `-1`, no HTTP attempt.
Set it on the machine/account that DELMIA runs under (e.g. a system/user environment variable, or the
service account's environment).
## Building & publishing (Native AOT, `win-x64`)
The runtime artifact is a self-contained, single-file native exe. **Native AOT does not cross-compile
from macOS/Linux** — publish on Windows with the MSVC build tools (Desktop C++ workload) installed:
```powershell
# On Windows:
dotnet publish src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier -c Release -r win-x64
# → bin/Release/net10.0/win-x64/publish/WWNotifier.exe (+ appsettings.json)
```
Copy `WWNotifier.exe` **and** `appsettings.json` to the Delmia host, set `SCADABRIDGE_API_KEY`, and
point `BaseUrls` at the central node(s).
> Managed `dotnet build` / `dotnet test` run cross-platform and are the development/CI gate; only the
> final native publish requires Windows.
## Manual smoke test
After deploying (or against a reachable test cluster such as `wonder-app-vd03`):
```powershell
$env:SCADABRIDGE_API_KEY = "sbk_..."
.\WWNotifier.exe -m Z28061Sim -d "C:\recipes\test.nc" -w WO-TEST -p PN-TEST -s 0100 -u smoketest
# expect: YES (exit 0) — or NO + reason (exit -1) with details on stderr / in the log file
```
This mirrors the earlier `curl` verification of `POST /api/DelmiaRecipeDownload` with the
`X-API-Key` header.
@@ -0,0 +1,11 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal sealed class RecipeDownload
{
public string? MachineCode { get; set; }
public string? DownloadPath { get; set; }
public string? WorkOrderNumber { get; set; }
public string? PartNumber { get; set; }
public string? JobStepNumber { get; set; }
public string? Username { get; set; }
}
@@ -0,0 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
internal sealed class RecipeDownloadResult
{
public bool Result { get; set; }
public string? ResultText { get; set; }
}
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
/// <summary>
/// Maps an outcome to the legacy WWNotifier stdout contract: <c>YES</c> + exit 0 on success,
/// <c>NO</c> followed by a reason line + exit -1 on failure. The writer is injected so stdout is the
/// only thing Delmia ever parses (LF-terminated, deterministic across platforms).
/// </summary>
internal static class Reporter
{
public static int Report(bool ok, string reason, TextWriter stdout)
{
if (ok)
{
stdout.Write("YES\n");
return 0;
}
stdout.Write("NO\n");
stdout.Write(reason);
return -1;
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AssemblyName>WWNotifier</AssemblyName>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- No <RuntimeIdentifier> here: pass -r win-x64 at publish (Task 8) so build/test stay cross-platform. -->
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,7 @@
{
"ScadaBridge": {
"BaseUrls": "http://localhost:9000",
"TimeoutSeconds": 30,
"LogPath": "logs/delmia-notifier.log"
}
}
@@ -179,8 +179,18 @@ public class DeploymentService
if (!validationResult.IsValid)
{
var errors = string.Join("; ", validationResult.Errors.Select(e => e.Message));
return Result<DeploymentRecord>.Failure($"Pre-deployment validation failed: {errors}");
// Followup #8: return a grouped/summarized error (leading count + per-module
// rollup, capped) instead of a flat semicolon-joined dump that becomes a wall
// of text for instances with dozens of unbound attributes. The full per-entry
// list still goes to the deploy log for operators who need every clause.
_logger.LogWarning(
"Pre-deployment validation failed for instance {InstanceId} ({ErrorCount} error(s)): {Detail}",
instanceId,
validationResult.Errors.Count,
string.Join("; ", validationResult.Errors.Select(e => e.Message)));
return Result<DeploymentRecord>.Failure(
$"Pre-deployment validation failed: {validationResult.SummarizeErrors()}");
}
// Serialize for transmission (also the payload stored in the deployed
@@ -200,6 +200,7 @@ public class ManagementActor : ReceiveActor
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand
or AddTemplateCompositionCommand or DeleteTemplateCompositionCommand
or ResyncInheritedMembersCommand
or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand
or CreateSharedSchemaCommand or UpdateSharedSchemaCommand or DeleteSharedSchemaCommand
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
@@ -253,6 +254,7 @@ public class ManagementActor : ReceiveActor
DeleteTemplateCommand cmd => await HandleDeleteTemplate(sp, cmd, user.Username),
ValidateTemplateCommand cmd => await HandleValidateTemplate(sp, cmd),
GetResolvedTemplateMembersCommand cmd => await HandleGetResolvedTemplateMembers(sp, cmd),
ResyncInheritedMembersCommand cmd => await HandleResyncInheritedMembers(sp, cmd, user.Username),
// Template members
AddTemplateAttributeCommand cmd => await HandleAddAttribute(sp, cmd, user.Username),
@@ -261,9 +263,9 @@ public class ManagementActor : ReceiveActor
AddTemplateAlarmCommand cmd => await HandleAddAlarm(sp, cmd, user.Username),
UpdateTemplateAlarmCommand cmd => await HandleUpdateAlarm(sp, cmd, user.Username),
DeleteTemplateAlarmCommand cmd => await HandleDeleteAlarm(sp, cmd, user.Username),
AddTemplateNativeAlarmSourceCommand cmd => await HandleAddNativeAlarmSource(sp, cmd),
UpdateTemplateNativeAlarmSourceCommand cmd => await HandleUpdateNativeAlarmSource(sp, cmd),
DeleteTemplateNativeAlarmSourceCommand cmd => await HandleDeleteNativeAlarmSource(sp, cmd),
AddTemplateNativeAlarmSourceCommand cmd => await HandleAddNativeAlarmSource(sp, cmd, user.Username),
UpdateTemplateNativeAlarmSourceCommand cmd => await HandleUpdateNativeAlarmSource(sp, cmd, user.Username),
DeleteTemplateNativeAlarmSourceCommand cmd => await HandleDeleteNativeAlarmSource(sp, cmd, user.Username),
ListTemplateNativeAlarmSourcesCommand cmd => await HandleListNativeAlarmSources(sp, cmd),
AddTemplateScriptCommand cmd => await HandleAddScript(sp, cmd, user.Username),
UpdateTemplateScriptCommand cmd => await HandleUpdateScript(sp, cmd, user.Username),
@@ -645,6 +647,20 @@ public class ManagementActor : ReceiveActor
return TemplateInheritanceResolver.Resolve(cmd.TemplateId, allTemplates);
}
/// <summary>
/// Resync inherited members (follow-up #1/#2): reconciles a template's stored
/// inherited rows (and its derived subtree's) with the resolved effective set —
/// materializing missing placeholders, re-syncing drifted ones, and removing
/// orphans — so the editor's editable tabs are complete and the staleness
/// banner clears. Designer-gated; the service owns the audit row.
/// </summary>
private static async Task<object?> HandleResyncInheritedMembers(IServiceProvider sp, ResyncInheritedMembersCommand cmd, string user)
{
var svc = sp.GetRequiredService<TemplateService>();
var result = await svc.ResyncInheritedMembersAsync(cmd.TemplateId, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
// ========================================================================
// Template folder handlers
// ========================================================================
@@ -2226,7 +2242,7 @@ public class ManagementActor : ReceiveActor
// ── Native alarm source bindings (read-only mirror; repository-direct CRUD) ──
private static async Task<object?> HandleAddNativeAlarmSource(IServiceProvider sp, AddTemplateNativeAlarmSourceCommand cmd)
private static async Task<object?> HandleAddNativeAlarmSource(IServiceProvider sp, AddTemplateNativeAlarmSourceCommand cmd, string user)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
var source = new TemplateNativeAlarmSource(cmd.Name)
@@ -2240,10 +2256,13 @@ public class ManagementActor : ReceiveActor
};
await repo.AddTemplateNativeAlarmSourceAsync(source);
await repo.SaveChangesAsync();
// Propagate the new source to derived descendants (#1/#2). Native-source
// CRUD lives here (not TemplateService), so call the propagation directly.
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(cmd.TemplateId, user);
return source;
}
private static async Task<object?> HandleUpdateNativeAlarmSource(IServiceProvider sp, UpdateTemplateNativeAlarmSourceCommand cmd)
private static async Task<object?> HandleUpdateNativeAlarmSource(IServiceProvider sp, UpdateTemplateNativeAlarmSourceCommand cmd, string user)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId)
@@ -2256,14 +2275,21 @@ public class ManagementActor : ReceiveActor
source.IsLocked = cmd.IsLocked;
await repo.UpdateTemplateNativeAlarmSourceAsync(source);
await repo.SaveChangesAsync();
// Propagate the changed source to derived descendants (#1/#2).
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
return source;
}
private static async Task<object?> HandleDeleteNativeAlarmSource(IServiceProvider sp, DeleteTemplateNativeAlarmSourceCommand cmd)
private static async Task<object?> HandleDeleteNativeAlarmSource(IServiceProvider sp, DeleteTemplateNativeAlarmSourceCommand cmd, string user)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
// Capture the owning template before delete so descendants can be reconciled.
var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId);
await repo.DeleteTemplateNativeAlarmSourceAsync(cmd.NativeAlarmSourceId);
await repo.SaveChangesAsync();
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
if (source != null)
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
return cmd.NativeAlarmSourceId;
}
@@ -1,6 +1,7 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
@@ -89,12 +90,17 @@ public class TemplateService
}
/// <summary>
/// Updates a template's name and description. Parent template is immutable after creation.
/// Updates a template's name and (optionally) description. Optional fields use
/// leave-unchanged semantics so an omitted field is not silently wiped (followup #5):
/// a <c>null</c> <paramref name="description"/> leaves the stored description as-is —
/// pass an empty string to explicitly clear it. Parent template is immutable after
/// creation; a <c>null</c> <paramref name="parentTemplateId"/> leaves it unchanged,
/// and a non-null value that differs from the current parent is rejected.
/// </summary>
/// <param name="templateId">ID of the template to update.</param>
/// <param name="name">New name for the template.</param>
/// <param name="description">New description.</param>
/// <param name="parentTemplateId">Must match the existing parent (cannot be changed).</param>
/// <param name="name">New name for the template (required, non-empty).</param>
/// <param name="description">New description, or <c>null</c> to leave unchanged. Empty string clears it.</param>
/// <param name="parentTemplateId"><c>null</c> to leave unchanged; a non-null value must match the existing parent (cannot be changed).</param>
/// <param name="user">Username of the user updating the template.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the updated template or failure message.</returns>
@@ -114,14 +120,19 @@ public class TemplateService
return Result<Template>.Failure($"Template with ID {templateId} not found.");
// ParentTemplateId is immutable after creation — set once at create time.
// Reject any attempt to change it (null→value, value→null, or value→other).
if (parentTemplateId != template.ParentTemplateId)
// A null parentTemplateId means "leave unchanged" (an omitted CLI/API field),
// so only a non-null value that differs from the current parent is rejected.
// Immutability still holds: there is no path that mutates ParentTemplateId here.
if (parentTemplateId != null && parentTemplateId != template.ParentTemplateId)
{
return Result<Template>.Failure(
"Parent template cannot be changed after creation.");
}
template.Name = name;
// Leave-unchanged semantics: only overwrite the description when a value is
// supplied. null = not provided (keep existing); "" = explicit clear.
if (description != null)
template.Description = description;
// Check for naming collisions after the change
@@ -302,6 +313,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Create", "TemplateAttribute", attribute.Id.ToString(), attribute.Name, attribute, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the new member to derived descendants (#1/#2).
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
return Result<TemplateAttribute>.Success(attribute);
}
@@ -396,6 +410,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the changed effective value to derived descendants (#1/#2).
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
return Result<TemplateAttribute>.Success(existing);
}
@@ -431,6 +448,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Delete", "TemplateAttribute", attributeId.ToString(), attribute.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
await ReconcileDescendantsAsync(attribute.TemplateId, user, cancellationToken);
return Result<bool>.Success(true);
}
@@ -526,6 +546,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Create", "TemplateAlarm", alarm.Id.ToString(), alarm.Name, alarm, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the new member to derived descendants (#1/#2).
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
return Result<TemplateAlarm>.Success(alarm);
}
@@ -608,6 +631,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Update", "TemplateAlarm", alarmId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the changed effective value to derived descendants (#1/#2).
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
return Result<TemplateAlarm>.Success(existing);
}
@@ -642,6 +668,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Delete", "TemplateAlarm", alarmId.ToString(), alarm.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
await ReconcileDescendantsAsync(alarm.TemplateId, user, cancellationToken);
return Result<bool>.Success(true);
}
@@ -687,6 +716,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Create", "TemplateScript", script.Id.ToString(), script.Name, script, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the new member to derived descendants (#1/#2).
await ReconcileDescendantsAsync(templateId, user, cancellationToken);
return Result<TemplateScript>.Success(script);
}
@@ -770,6 +802,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Update", "TemplateScript", scriptId.ToString(), existing.Name, existing, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Propagate the changed effective value to derived descendants (#1/#2).
await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);
return Result<TemplateScript>.Success(existing);
}
@@ -804,6 +839,9 @@ public class TemplateService
await _auditService.LogAsync(user, "Delete", "TemplateScript", scriptId.ToString(), script.Name, null, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
// Remove the now-orphaned inherited placeholder from derived descendants (#1/#2).
await ReconcileDescendantsAsync(script.TemplateId, user, cancellationToken);
return Result<bool>.Success(true);
}
@@ -1090,9 +1128,450 @@ public class TemplateService
});
}
// Native alarm sources follow the same inherit/override/lock model as the
// other member types (TemplateNativeAlarmSource), and the inheritance
// resolver + staleness summary count them — so materialize their inherited
// placeholders here too, or a freshly-composed derived template reports
// stale for every inherited native source (follow-up #1/#2).
foreach (var src in baseTemplate.NativeAlarmSources)
{
derived.NativeAlarmSources.Add(new TemplateNativeAlarmSource(src.Name)
{
Description = src.Description,
ConnectionName = src.ConnectionName,
SourceReference = src.SourceReference,
ConditionFilter = src.ConditionFilter,
IsLocked = src.IsLocked,
IsInherited = true,
LockedInDerived = false,
});
}
return derived;
}
// ========================================================================
// Inherited-member propagation & resync (follow-up #1/#2)
// ========================================================================
//
// A derived template stores IsInherited placeholder rows mirroring the
// members it inherits. BuildDerivedTemplate materializes them at compose time,
// but a member ADDED / UPDATED / REMOVED on a base AFTER the derivation
// existed never reached the children — so derived row-sets drifted incomplete
// (the editor's editable tabs missed inherited members; #1) and stored rows
// diverged from the resolved set (#2). Two mechanisms keep them in sync:
// • ReconcileDescendantsAsync — called automatically after every base member
// mutation, propagates the change to the whole derived subtree.
// • ResyncInheritedMembersAsync — an explicit operator action (CLI + UI)
// that repairs an already-stale template and its subtree.
// Both delegate to one order-independent reconcile that only ever touches
// IsInherited placeholder rows (never an authored override), which the
// inheritance resolver ignores when picking winners — so reconciling one
// template never changes what another resolves.
/// <summary>
/// Reconciles the stored inherited member rows of a template and its entire
/// derived subtree against the resolved effective inherited set: materializes
/// missing placeholders, re-syncs drifted ones, and removes orphaned ones.
/// This is the operator-facing repair action behind the CLI
/// <c>template resync-members</c> command and the editor's "Resync" button.
/// </summary>
/// <param name="templateId">The template to resync (its subtree is included).</param>
/// <param name="user">Username for the audit row.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result carrying the per-subtree change counts.</returns>
public async Task<Result<ResyncInheritedMembersResult>> ResyncInheritedMembersAsync(
int templateId,
string user,
CancellationToken cancellationToken = default)
{
var all = await _repository.GetAllTemplatesAsync(cancellationToken) ?? Array.Empty<Template>();
var byId = BuildTemplateLookup(all);
if (!byId.TryGetValue(templateId, out var root))
return Result<ResyncInheritedMembersResult>.Failure($"Template with ID {templateId} not found.");
// Reconcile the target itself AND its subtree: resyncing a base repairs
// every derivation; resyncing a leaf repairs just it.
var targets = new List<int> { templateId };
targets.AddRange(GetDescendantIdsBreadthFirst(templateId, all));
var total = new ReconcileCounts();
var changed = 0;
foreach (var id in targets)
{
var counts = await ReconcileInheritedRowsAsync(id, all, byId, cancellationToken);
if (counts.Any) changed++;
total.Add(counts);
}
if (total.Any)
{
await _auditService.LogAsync(
user, "Resync", "Template", templateId.ToString(), root.Name,
new { templatesChanged = changed, total.Added, total.Updated, total.Removed },
cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
}
return Result<ResyncInheritedMembersResult>.Success(new ResyncInheritedMembersResult
{
TemplatesChanged = changed,
MembersAdded = total.Added,
MembersUpdated = total.Updated,
MembersRemoved = total.Removed
});
}
/// <summary>
/// Propagates a base member change to every derived descendant by reconciling
/// their inherited rows. Called automatically after a base member is added,
/// updated, or deleted. The base template itself is NOT reconciled — it is the
/// author of the change. A no-op (and a single cheap query) when the template
/// has no descendants, which is the common case.
/// </summary>
/// <param name="templateId">The template whose member set changed.</param>
/// <param name="user">Username for the audit row.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task ReconcileDescendantsAsync(
int templateId,
string user,
CancellationToken cancellationToken = default)
{
var all = await _repository.GetAllTemplatesAsync(cancellationToken);
var descendants = GetDescendantIdsBreadthFirst(templateId, all);
if (descendants.Count == 0)
return;
var byId = BuildTemplateLookup(all);
var total = new ReconcileCounts();
foreach (var id in descendants)
total.Add(await ReconcileInheritedRowsAsync(id, all, byId, cancellationToken));
if (total.Any)
{
var name = byId.TryGetValue(templateId, out var t) ? t.Name : templateId.ToString();
await _auditService.LogAsync(
user, "Resync", "Template", templateId.ToString(), name,
new { propagatedFrom = templateId, total.Added, total.Updated, total.Removed },
cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
}
}
/// <summary>
/// Reconciles ONE template's stored IsInherited rows against its resolved
/// effective inherited set (the same resolver the editor preview + staleness
/// banner use). Adds missing placeholders, re-syncs drifted ones, removes
/// orphans. Authored override rows (<c>IsInherited == false</c>) are never
/// touched. Repository writes are queued; the caller owns SaveChanges.
/// </summary>
private async Task<ReconcileCounts> ReconcileInheritedRowsAsync(
int templateId,
IReadOnlyList<Template> all,
Dictionary<int, Template> byId,
CancellationToken cancellationToken)
{
var counts = new ReconcileCounts();
if (!byId.TryGetValue(templateId, out var child) || child.ParentTemplateId == null)
return counts; // root templates inherit nothing
var resolved = TemplateInheritanceResolver.Resolve(templateId, all);
await ReconcileAttributesAsync(child, resolved.Attributes, byId, counts, cancellationToken);
await ReconcileAlarmsAsync(child, resolved.Alarms, byId, counts, cancellationToken);
await ReconcileScriptsAsync(child, resolved.Scripts, byId, counts, cancellationToken);
await ReconcileNativeSourcesAsync(child, resolved.NativeAlarmSources, byId, counts, cancellationToken);
return counts;
}
private async Task ReconcileAttributesAsync(
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
{
var inherited = new HashSet<string>(StringComparer.Ordinal);
foreach (var m in resolved)
{
if (!m.IsInherited) continue; // own / override rows are authoritative
inherited.Add(m.Name);
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
var win = origin.Attributes.FirstOrDefault(a => a.Name == m.Name);
if (win == null) continue;
var existing = child.Attributes.FirstOrDefault(a => a.Name == m.Name);
if (existing == null)
{
await _repository.AddTemplateAttributeAsync(new TemplateAttribute(m.Name)
{
TemplateId = child.Id,
Value = m.EffectiveValue,
DataType = win.DataType,
ElementDataType = win.ElementDataType,
Description = win.Description,
DataSourceReference = win.DataSourceReference,
IsLocked = win.IsLocked,
IsInherited = true,
LockedInDerived = false,
}, ct);
counts.Added++;
}
else if (existing.IsInherited && (
existing.Value != m.EffectiveValue ||
existing.DataType != win.DataType ||
existing.ElementDataType != win.ElementDataType ||
existing.Description != win.Description ||
existing.DataSourceReference != win.DataSourceReference ||
existing.IsLocked != win.IsLocked))
{
existing.Value = m.EffectiveValue;
existing.DataType = win.DataType;
existing.ElementDataType = win.ElementDataType;
existing.Description = win.Description;
existing.DataSourceReference = win.DataSourceReference;
existing.IsLocked = win.IsLocked;
await _repository.UpdateTemplateAttributeAsync(existing, ct);
counts.Updated++;
}
}
foreach (var orphan in child.Attributes.Where(a => a.IsInherited && !inherited.Contains(a.Name)).ToList())
{
await _repository.DeleteTemplateAttributeAsync(orphan.Id, ct);
counts.Removed++;
}
}
private async Task ReconcileAlarmsAsync(
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
{
var inherited = new HashSet<string>(StringComparer.Ordinal);
foreach (var m in resolved)
{
if (!m.IsInherited) continue;
inherited.Add(m.Name);
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
var win = origin.Alarms.FirstOrDefault(a => a.Name == m.Name);
if (win == null) continue;
// EffectiveTriggerConfiguration is the per-setpoint MERGED HiLo config
// (mirrors the flattener), so the placeholder matches what a deploy and
// the staleness comparison produce.
var existing = child.Alarms.FirstOrDefault(a => a.Name == m.Name);
if (existing == null)
{
await _repository.AddTemplateAlarmAsync(new TemplateAlarm(m.Name)
{
TemplateId = child.Id,
Description = win.Description,
PriorityLevel = win.PriorityLevel,
IsLocked = win.IsLocked,
TriggerType = win.TriggerType,
TriggerConfiguration = m.EffectiveTriggerConfiguration,
OnTriggerScriptId = win.OnTriggerScriptId,
IsInherited = true,
LockedInDerived = false,
}, ct);
counts.Added++;
}
else if (existing.IsInherited && (
existing.PriorityLevel != win.PriorityLevel ||
existing.TriggerConfiguration != m.EffectiveTriggerConfiguration ||
existing.Description != win.Description ||
existing.OnTriggerScriptId != win.OnTriggerScriptId ||
existing.TriggerType != win.TriggerType ||
existing.IsLocked != win.IsLocked))
{
existing.PriorityLevel = win.PriorityLevel;
existing.TriggerConfiguration = m.EffectiveTriggerConfiguration;
existing.Description = win.Description;
existing.OnTriggerScriptId = win.OnTriggerScriptId;
existing.TriggerType = win.TriggerType;
existing.IsLocked = win.IsLocked;
await _repository.UpdateTemplateAlarmAsync(existing, ct);
counts.Updated++;
}
}
foreach (var orphan in child.Alarms.Where(a => a.IsInherited && !inherited.Contains(a.Name)).ToList())
{
await _repository.DeleteTemplateAlarmAsync(orphan.Id, ct);
counts.Removed++;
}
}
private async Task ReconcileScriptsAsync(
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
{
var inherited = new HashSet<string>(StringComparer.Ordinal);
foreach (var m in resolved)
{
if (!m.IsInherited) continue;
inherited.Add(m.Name);
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
var win = origin.Scripts.FirstOrDefault(s => s.Name == m.Name);
if (win == null) continue;
var existing = child.Scripts.FirstOrDefault(s => s.Name == m.Name);
if (existing == null)
{
await _repository.AddTemplateScriptAsync(new TemplateScript(m.Name, win.Code)
{
TemplateId = child.Id,
IsLocked = win.IsLocked,
TriggerType = win.TriggerType,
TriggerConfiguration = win.TriggerConfiguration,
ParameterDefinitions = win.ParameterDefinitions,
ReturnDefinition = win.ReturnDefinition,
MinTimeBetweenRuns = win.MinTimeBetweenRuns,
ExecutionTimeoutSeconds = win.ExecutionTimeoutSeconds,
IsInherited = true,
LockedInDerived = false,
}, ct);
counts.Added++;
}
else if (existing.IsInherited && (
existing.Code != win.Code ||
existing.TriggerType != win.TriggerType ||
existing.TriggerConfiguration != win.TriggerConfiguration ||
existing.ParameterDefinitions != win.ParameterDefinitions ||
existing.ReturnDefinition != win.ReturnDefinition ||
existing.MinTimeBetweenRuns != win.MinTimeBetweenRuns ||
existing.ExecutionTimeoutSeconds != win.ExecutionTimeoutSeconds ||
existing.IsLocked != win.IsLocked))
{
existing.Code = win.Code;
existing.TriggerType = win.TriggerType;
existing.TriggerConfiguration = win.TriggerConfiguration;
existing.ParameterDefinitions = win.ParameterDefinitions;
existing.ReturnDefinition = win.ReturnDefinition;
existing.MinTimeBetweenRuns = win.MinTimeBetweenRuns;
existing.ExecutionTimeoutSeconds = win.ExecutionTimeoutSeconds;
existing.IsLocked = win.IsLocked;
await _repository.UpdateTemplateScriptAsync(existing, ct);
counts.Updated++;
}
}
foreach (var orphan in child.Scripts.Where(s => s.IsInherited && !inherited.Contains(s.Name)).ToList())
{
await _repository.DeleteTemplateScriptAsync(orphan.Id, ct);
counts.Removed++;
}
}
private async Task ReconcileNativeSourcesAsync(
Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
{
var inherited = new HashSet<string>(StringComparer.Ordinal);
foreach (var m in resolved)
{
if (!m.IsInherited) continue;
inherited.Add(m.Name);
if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
var win = origin.NativeAlarmSources.FirstOrDefault(s => s.Name == m.Name);
if (win == null) continue;
var existing = child.NativeAlarmSources.FirstOrDefault(s => s.Name == m.Name);
if (existing == null)
{
await _repository.AddTemplateNativeAlarmSourceAsync(new TemplateNativeAlarmSource(m.Name)
{
TemplateId = child.Id,
Description = win.Description,
ConnectionName = win.ConnectionName,
SourceReference = win.SourceReference,
ConditionFilter = win.ConditionFilter,
IsLocked = win.IsLocked,
IsInherited = true,
LockedInDerived = false,
}, ct);
counts.Added++;
}
else if (existing.IsInherited && (
existing.ConnectionName != win.ConnectionName ||
existing.SourceReference != win.SourceReference ||
existing.ConditionFilter != win.ConditionFilter ||
existing.Description != win.Description ||
existing.IsLocked != win.IsLocked))
{
existing.ConnectionName = win.ConnectionName;
existing.SourceReference = win.SourceReference;
existing.ConditionFilter = win.ConditionFilter;
existing.Description = win.Description;
existing.IsLocked = win.IsLocked;
await _repository.UpdateTemplateNativeAlarmSourceAsync(existing, ct);
counts.Updated++;
}
}
foreach (var orphan in child.NativeAlarmSources.Where(s => s.IsInherited && !inherited.Contains(s.Name)).ToList())
{
await _repository.DeleteTemplateNativeAlarmSourceAsync(orphan.Id, ct);
counts.Removed++;
}
}
/// <summary>
/// Breadth-first list of every template that (transitively) derives from
/// <paramref name="rootId"/>, excluding the root itself. Cycle-guarded.
/// </summary>
private static List<int> GetDescendantIdsBreadthFirst(int rootId, IReadOnlyList<Template> all)
{
if (all is null) return new List<int>();
var childrenByParent = new Dictionary<int, List<int>>();
foreach (var t in all)
{
if (t.ParentTemplateId is { } pid)
{
if (!childrenByParent.TryGetValue(pid, out var list))
childrenByParent[pid] = list = new List<int>();
list.Add(t.Id);
}
}
var result = new List<int>();
var seen = new HashSet<int> { rootId };
var queue = new Queue<int>();
if (childrenByParent.TryGetValue(rootId, out var direct))
foreach (var c in direct) queue.Enqueue(c);
while (queue.Count > 0)
{
var id = queue.Dequeue();
if (!seen.Add(id)) continue;
result.Add(id);
if (childrenByParent.TryGetValue(id, out var kids))
foreach (var c in kids) queue.Enqueue(c);
}
return result;
}
private static Dictionary<int, Template> BuildTemplateLookup(IReadOnlyList<Template> all)
{
var byId = new Dictionary<int, Template>(all.Count);
foreach (var t in all) byId[t.Id] = t; // DB ids are unique; last-wins is harmless
return byId;
}
/// <summary>Running tally of inherited-row changes during a reconcile.</summary>
private sealed class ReconcileCounts
{
public int Added;
public int Updated;
public int Removed;
public bool Any => Added > 0 || Updated > 0 || Removed > 0;
public void Add(ReconcileCounts other)
{
Added += other.Added;
Updated += other.Updated;
Removed += other.Removed;
}
}
// ========================================================================
// WP-7: Path-Qualified Canonical Naming (via TemplateResolver)
// ========================================================================
@@ -64,6 +64,58 @@ public class InstanceArgumentParsingTests
Assert.NotNull(error);
}
[Fact]
public void ParseBindings_ThreeElementForm_CapturesReferenceOverride()
{
// The optional 3rd element carries the per-instance data-source reference
// override (followup #4) so the CLI can SET it instead of always wiping it.
var ok = InstanceCommands.TryParseBindings(
"[[\"Speed\", 5, \"ns=2;s=Spd\"], [\"Mode\", 7]]", out var bindings, out var error);
Assert.True(ok);
Assert.Null(error);
Assert.Equal(2, bindings!.Count);
Assert.Equal(new ConnectionBinding("Speed", 5, "ns=2;s=Spd"), bindings[0]);
// A 2-element entry still leaves the override null (template default applies).
Assert.Equal(new ConnectionBinding("Mode", 7, null), bindings[1]);
Assert.Null(bindings[1].DataSourceReferenceOverride);
}
[Fact]
public void ParseBindings_ThirdElementExplicitNull_LeavesOverrideUnset()
{
var ok = InstanceCommands.TryParseBindings(
"[[\"Speed\", 5, null]]", out var bindings, out var error);
Assert.True(ok);
Assert.Null(error);
Assert.Single(bindings!);
Assert.Null(bindings![0].DataSourceReferenceOverride);
}
[Fact]
public void ParseBindings_ThirdElementWrongType_ReturnsErrorNotException()
{
// A non-string, non-null 3rd element is rejected with a clean error.
var ok = InstanceCommands.TryParseBindings(
"[[\"Speed\", 5, 99]]", out var bindings, out var error);
Assert.False(ok);
Assert.Null(bindings);
Assert.NotNull(error);
}
[Fact]
public void ParseBindings_FourElements_ReturnsErrorNotException()
{
var ok = InstanceCommands.TryParseBindings(
"[[\"Speed\", 5, \"ref\", \"extra\"]]", out var bindings, out var error);
Assert.False(ok);
Assert.Null(bindings);
Assert.NotNull(error);
}
[Fact]
public void ParseOverrides_ValidJson_ReturnsDictionary()
{
@@ -0,0 +1,122 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Tests for the compact <c>template list</c>/<c>get</c> table projection (followup #6):
/// the full per-template attribute/alarm/script dumps are collapsed to counts so table
/// output is usable in a terminal, while the array/object shape is preserved.
/// </summary>
public class TemplateTableProjectionTests
{
private const string ListJson = """
[
{
"id": 3,
"name": "MESReceiver",
"description": "base",
"parentTemplateId": null,
"isDerived": false,
"attributes": [ {"id":1},{"id":2},{"id":3} ],
"alarms": [ {"id":10} ],
"scripts": [ {"id":20},{"id":21} ],
"compositions": [],
"nativeAlarmSources": []
},
{
"id": 5,
"name": "LeftMESReceiver",
"description": null,
"parentTemplateId": 3,
"isDerived": false,
"attributes": [ {"id":1} ],
"alarms": [],
"scripts": [],
"compositions": [ {"id":99} ],
"nativeAlarmSources": [ {"id":7} ]
}
]
""";
[Fact]
public void ProjectSummary_Array_DropsMemberArraysAndKeepsCounts()
{
var compact = TemplateTableProjection.ProjectSummary(ListJson);
using var doc = JsonDocument.Parse(compact);
var root = doc.RootElement;
Assert.Equal(JsonValueKind.Array, root.ValueKind);
Assert.Equal(2, root.GetArrayLength());
var first = root[0];
Assert.Equal(3, first.GetProperty("id").GetInt32());
Assert.Equal("MESReceiver", first.GetProperty("name").GetString());
Assert.Equal("base", first.GetProperty("description").GetString());
Assert.Equal(JsonValueKind.Null, first.GetProperty("parentTemplateId").ValueKind);
Assert.Equal(3, first.GetProperty("#attrs").GetInt32());
Assert.Equal(1, first.GetProperty("#alarms").GetInt32());
Assert.Equal(2, first.GetProperty("#scripts").GetInt32());
Assert.Equal(0, first.GetProperty("#comps").GetInt32());
Assert.Equal(0, first.GetProperty("#nativeAlarms").GetInt32());
// The full member arrays must NOT survive — that is the whole point of the projection.
Assert.False(first.TryGetProperty("attributes", out _));
Assert.False(first.TryGetProperty("scripts", out _));
var second = root[1];
Assert.Equal(5, second.GetProperty("id").GetInt32());
Assert.Equal(3, second.GetProperty("parentTemplateId").GetInt32());
Assert.Equal(1, second.GetProperty("#comps").GetInt32());
Assert.Equal(1, second.GetProperty("#nativeAlarms").GetInt32());
// A null description stays null (it is not invented).
Assert.Equal(JsonValueKind.Null, second.GetProperty("description").ValueKind);
}
[Fact]
public void ProjectSummary_SingleObject_ProducesCompactObject()
{
const string getJson = """
{ "id": 7, "name": "ReactorSide", "description": "d", "parentTemplateId": null,
"isDerived": false, "attributes": [ {"id":1},{"id":2} ], "alarms": [], "scripts": [],
"compositions": [], "nativeAlarmSources": [] }
""";
var compact = TemplateTableProjection.ProjectSummary(getJson);
using var doc = JsonDocument.Parse(compact);
var root = doc.RootElement;
Assert.Equal(JsonValueKind.Object, root.ValueKind);
Assert.Equal(7, root.GetProperty("id").GetInt32());
Assert.Equal(2, root.GetProperty("#attrs").GetInt32());
Assert.False(root.TryGetProperty("attributes", out _));
}
[Fact]
public void ProjectSummary_NonJson_ReturnedVerbatim()
{
const string notJson = "<html>proxy error</html>";
Assert.Equal(notJson, TemplateTableProjection.ProjectSummary(notJson));
}
[Fact]
public void ProjectSummary_IsSubstantiallySmallerThanFullDump()
{
// Sanity check that the projection actually shrinks output (the reported symptom
// was ~171 KB table dumps). A template with a fat attribute array should collapse.
var fatAttributes = string.Join(",",
Enumerable.Range(0, 200).Select(i =>
$"{{\"id\":{i},\"name\":\"Attr{i}\",\"dataType\":\"String\",\"value\":\"some long-ish default value {i}\"}}"));
var fullJson = $$"""
[ { "id": 1, "name": "T", "description": "d", "parentTemplateId": null, "isDerived": false,
"attributes": [ {{fatAttributes}} ], "alarms": [], "scripts": [], "compositions": [], "nativeAlarmSources": [] } ]
""";
var compact = TemplateTableProjection.ProjectSummary(fullJson);
Assert.True(compact.Length * 4 < fullJson.Length,
$"Expected compact ({compact.Length}) to be far smaller than full ({fullJson.Length}).");
using var doc = JsonDocument.Parse(compact);
Assert.Equal(200, doc.RootElement[0].GetProperty("#attrs").GetInt32());
}
}
@@ -129,6 +129,51 @@ public class TemplatesPageTests : BunitContext
Assert.Contains("bi-arrow-return-right", cut.Markup);
}
[Fact]
public void Renders_InheritedComposition_UnderDerivedComposedMember()
{
// followup #9: LeakTest is composed onto the BASE ReactorSide. LeftReactorSide is
// DERIVED from ReactorSide and composed into CvdReactor, so the tree must surface
// the INHERITED LeakTest slot under LeftReactorSide — the composition row lives on
// ReactorSide (no IsInherited placeholder on the child), so the builder must use
// the effective (own + inherited) composition set, mirroring the resolver.
var leakTest = new Template("LeakTest") { Id = 50 };
var reactorSide = new Template("ReactorSide") { Id = 7 };
reactorSide.Compositions.Add(
new TemplateComposition("LeakTest") { Id = 100, TemplateId = 7, ComposedTemplateId = 50 });
var leftReactorSide = new Template("LeftReactorSide") { Id = 8, ParentTemplateId = 7, IsDerived = true };
var cvdReactor = new Template("CvdReactor") { Id = 1 };
cvdReactor.Compositions.Add(
new TemplateComposition("LeftReactorSide") { Id = 200, TemplateId = 1, ComposedTemplateId = 8 });
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(
new List<Template> { cvdReactor, reactorSide, leftReactorSide, leakTest }));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
var cut = Render<TemplatesPage>();
// Expand CvdReactor to reveal its composed member LeftReactorSide.
cut.FindAll("li[role='treeitem']")
.First(li => li.QuerySelector(".tv-label")?.TextContent.Trim() == "CvdReactor")
.QuerySelector(".tv-toggle")!.Click();
// LeftReactorSide must be expandable: before the fix it was a flat leaf with no
// toggle because its own Compositions are empty (LeakTest is inherited, not owned).
var leftLi = cut.FindAll("li[role='treeitem']")
.First(li => li.QuerySelector(".tv-label")?.TextContent.Trim() == "LeftReactorSide");
var leftToggle = leftLi.QuerySelector(".tv-toggle");
Assert.NotNull(leftToggle);
leftToggle!.Click();
// The inherited LeakTest slot now renders under LeftReactorSide, badged "inherited".
var leftLiAfter = cut.FindAll("li[role='treeitem']")
.First(li => li.QuerySelector(".tv-label")?.TextContent.Trim() == "LeftReactorSide");
Assert.Contains("LeakTest", leftLiAfter.TextContent);
Assert.Contains("inherited", leftLiAfter.TextContent);
}
[Fact]
public void SearchBox_IsPresentAndBound_ToTemplateFolderTreeFilter()
{
@@ -0,0 +1,111 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
/// <summary>
/// Tests for <see cref="ValidationResult.SummarizeErrors"/> (followup #8) — the grouped,
/// capped summary that replaces the flat semicolon-joined deploy error dump.
/// </summary>
public class ValidationResultSummaryTests
{
private static ValidationEntry Unbound(string canonicalName) =>
ValidationEntry.Error(
ValidationCategory.ConnectionBinding,
$"Attribute '{canonicalName}' has a data source reference but no connection binding.",
canonicalName);
[Fact]
public void SummarizeErrors_NoErrors_ReturnsEmpty()
{
Assert.Equal(string.Empty, ValidationResult.Success().SummarizeErrors());
}
[Fact]
public void SummarizeErrors_LeadsWithTotalCount()
{
var result = new ValidationResult
{
Errors = [Unbound("A.X"), Unbound("A.Y"), Unbound("B.Z")]
};
var summary = result.SummarizeErrors();
Assert.StartsWith("3 errors:", summary);
}
[Fact]
public void SummarizeErrors_SingleError_UsesSingularNoun()
{
var result = new ValidationResult { Errors = [Unbound("A.X")] };
Assert.StartsWith("1 error:", result.SummarizeErrors());
}
[Fact]
public void SummarizeErrors_RollsUpUnboundAttributesByModuleWithCounts()
{
// 12 + 12 unbound across two LeakTest modules — the reported wall-of-text case.
var errors = new List<ValidationEntry>();
for (var i = 0; i < 12; i++) errors.Add(Unbound($"LeftReactorSide.LeakTest.Attr{i}"));
for (var i = 0; i < 12; i++) errors.Add(Unbound($"RightReactorSide.LeakTest.Attr{i}"));
var summary = new ValidationResult { Errors = errors }.SummarizeErrors();
Assert.Contains("24 errors:", summary);
Assert.Contains("ConnectionBinding (24)", summary);
Assert.Contains("LeftReactorSide.LeakTest (12)", summary);
Assert.Contains("RightReactorSide.LeakTest (12)", summary);
// The full 24 individual clauses must NOT all appear inline.
Assert.DoesNotContain("Attr11' has a data source reference", summary);
}
[Fact]
public void SummarizeErrors_CapsModuleBreadthWithAndNMore()
{
// 10 distinct modules, cap at 3 → 7 collapsed.
var errors = Enumerable.Range(0, 10).Select(i => Unbound($"Module{i}.Attr")).ToList();
var summary = new ValidationResult { Errors = errors }.SummarizeErrors(maxGroups: 3);
Assert.Contains("… and 7 more module(s)", summary);
Assert.Contains("Module0 (1)", summary);
}
[Fact]
public void SummarizeErrors_TopLevelMember_GroupsUnderRoot()
{
var summary = new ValidationResult { Errors = [Unbound("Speed")] }.SummarizeErrors();
Assert.Contains("(root) (1)", summary);
}
[Fact]
public void SummarizeErrors_EntriesWithoutEntityName_FallBackToCappedMessageList()
{
var errors = Enumerable.Range(0, 5)
.Select(i => ValidationEntry.Error(ValidationCategory.ScriptCompilation, $"compile error {i}"))
.ToList();
var summary = new ValidationResult { Errors = errors }.SummarizeErrors(maxGroups: 2);
Assert.Contains("ScriptCompilation (5)", summary);
Assert.Contains("compile error 0", summary);
Assert.Contains("… and 3 more", summary);
}
[Fact]
public void SummarizeErrors_MixedCategories_ListsEachCategoryGroup()
{
var result = new ValidationResult
{
Errors =
[
Unbound("A.X"),
ValidationEntry.Error(ValidationCategory.NamingCollision, "dup name 'Foo'"),
]
};
var summary = result.SummarizeErrors();
Assert.Contains("ConnectionBinding (1)", summary);
Assert.Contains("NamingCollision (1)", summary);
}
}
@@ -0,0 +1,59 @@
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
public class ArgParserTests
{
[Fact]
public void Parses_all_flags()
{
var r = ArgParser.Parse(new[] { "-m", "Z28061", "-d", @"C:\r.nc", "-w", "W1", "-p", "P1", "-s", "0100", "-u", "op" });
Assert.True(r.Ok);
Assert.Equal("Z28061", r.Payload!.MachineCode);
Assert.Equal(@"C:\r.nc", r.Payload.DownloadPath);
Assert.Equal("W1", r.Payload.WorkOrderNumber);
Assert.Equal("P1", r.Payload.PartNumber);
Assert.Equal("0100", r.Payload.JobStepNumber);
Assert.Equal("op", r.Payload.Username);
}
[Fact]
public void Parses_long_flags()
{
var r = ArgParser.Parse(new[] { "--machine", "Z", "--downloadpath", "x", "--workorder", "W", "--partnumber", "P" });
Assert.True(r.Ok);
Assert.Equal("Z", r.Payload!.MachineCode);
Assert.Equal("x", r.Payload.DownloadPath);
}
[Fact]
public void Missing_required_returns_error()
{
var r = ArgParser.Parse(new[] { "-m", "Z28061", "-d", @"C:\r.nc", "-w", "W1" }); // no -p
Assert.False(r.Ok);
Assert.Contains("partnumber", r.Error, System.StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Optional_flags_may_be_omitted()
{
var r = ArgParser.Parse(new[] { "-m", "Z", "-d", "x", "-w", "W", "-p", "P" });
Assert.True(r.Ok);
Assert.Null(r.Payload!.Username);
Assert.Null(r.Payload.JobStepNumber);
}
[Fact]
public void Unknown_flag_returns_error()
{
var r = ArgParser.Parse(new[] { "-z", "x" });
Assert.False(r.Ok);
}
[Fact]
public void Flag_without_value_returns_error()
{
var r = ArgParser.Parse(new[] { "-m" });
Assert.False(r.Ok);
}
}
@@ -0,0 +1,50 @@
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
public class ConfigLoaderTests
{
[Fact]
public void SplitBaseUrls_trims_and_drops_empties()
{
var urls = ConfigLoader.SplitBaseUrls("a, b ,,c");
Assert.Equal(new[] { "a", "b", "c" }, urls);
}
[Fact]
public void SplitBaseUrls_null_or_whitespace_returns_empty()
{
Assert.Empty(ConfigLoader.SplitBaseUrls(null));
Assert.Empty(ConfigLoader.SplitBaseUrls(" "));
}
[Fact]
public void Load_defaults_timeout_to_30_when_omitted()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"BaseUrls\":\"http://x\"}}");
Assert.Equal(30, cfg.ScadaBridge.TimeoutSeconds);
Assert.Equal("http://x", cfg.ScadaBridge.BaseUrls);
}
[Fact]
public void Load_reads_all_fields()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"BaseUrls\":\"a,b\",\"TimeoutSeconds\":5,\"LogPath\":\"l.log\"}}");
Assert.Equal("a,b", cfg.ScadaBridge.BaseUrls);
Assert.Equal(5, cfg.ScadaBridge.TimeoutSeconds);
Assert.Equal("l.log", cfg.ScadaBridge.LogPath);
}
[Fact]
public void ResolveApiKey_returns_null_when_unset_or_whitespace()
{
Assert.Null(ConfigLoader.ResolveApiKey(_ => null));
Assert.Null(ConfigLoader.ResolveApiKey(_ => " "));
}
[Fact]
public void ResolveApiKey_returns_value_when_present()
{
Assert.Equal("sbk_x", ConfigLoader.ResolveApiKey(n => n == "SCADABRIDGE_API_KEY" ? "sbk_x" : null));
}
}
@@ -0,0 +1,62 @@
using System.Net;
using System.Text;
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
public class HttpRecipeSenderTests
{
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(responder(request));
}
}
private static readonly RecipeDownload Payload =
new() { MachineCode = "Z", DownloadPath = "x", WorkOrderNumber = "W", PartNumber = "P" };
[Fact]
public async Task ConnectFailure_maps_to_ConnectFailed()
{
var handler = new StubHandler(_ => throw new HttpRequestException("refused"));
using var http = new HttpClient(handler);
var sender = new HttpRecipeSender(http, "sbk_x");
var outcome = await sender.SendAsync("http://a", Payload, CancellationToken.None);
Assert.Equal(AttemptKind.ConnectFailed, outcome.Kind);
Assert.Equal(0, outcome.StatusCode);
}
[Fact]
public async Task Success_200_parses_body_and_sends_header_and_path()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Result\":true,\"ResultText\":\"\"}", Encoding.UTF8, "application/json"),
});
using var http = new HttpClient(handler);
var sender = new HttpRecipeSender(http, "sbk_x");
var outcome = await sender.SendAsync("http://a/", Payload, CancellationToken.None);
Assert.Equal(AttemptKind.Connected, outcome.Kind);
Assert.Equal(200, outcome.StatusCode);
Assert.NotNull(outcome.Body);
Assert.True(outcome.Body!.Result);
Assert.Equal("sbk_x", handler.LastRequest!.Headers.GetValues("X-API-Key").Single());
Assert.Equal("http://a/api/DelmiaRecipeDownload", handler.LastRequest.RequestUri!.ToString());
}
[Fact]
public async Task Non2xx_is_Connected_with_status()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
using var http = new HttpClient(handler);
var sender = new HttpRecipeSender(http, "sbk_x");
var outcome = await sender.SendAsync("http://a", Payload, CancellationToken.None);
Assert.Equal(AttemptKind.Connected, outcome.Kind);
Assert.Equal(500, outcome.StatusCode);
}
}
@@ -0,0 +1,27 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
public class JsonContractTests
{
[Fact]
public void RecipeDownload_serializes_pascalcase()
{
var json = JsonSerializer.Serialize(
new RecipeDownload { MachineCode = "Z28061", DownloadPath = @"C:\r.nc",
WorkOrderNumber = "W1", PartNumber = "P1", JobStepNumber = "0100", Username = "op" },
NotifierJsonContext.Default.RecipeDownload);
Assert.Contains("\"MachineCode\":\"Z28061\"", json);
Assert.Contains("\"DownloadPath\"", json);
}
[Fact]
public void RecipeDownloadResult_deserializes_pascalcase()
{
var r = JsonSerializer.Deserialize("{\"Result\":true,\"ResultText\":\"ok\"}",
NotifierJsonContext.Default.RecipeDownloadResult);
Assert.True(r!.Result);
Assert.Equal("ok", r.ResultText);
}
}
@@ -0,0 +1,70 @@
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
public class NotifierTests
{
private sealed class FakeSender(params AttemptOutcome[] outcomes) : IRecipeSender
{
private readonly Queue<AttemptOutcome> _outcomes = new(outcomes);
public int Calls { get; private set; }
public Task<AttemptOutcome> SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct)
{
Calls++;
return Task.FromResult(_outcomes.Dequeue());
}
}
private static readonly string[] TwoUrls = ["http://a", "http://b"];
private static readonly RecipeDownload Payload =
new() { MachineCode = "Z", DownloadPath = "x", WorkOrderNumber = "W", PartNumber = "P" };
[Fact]
public async Task ConnectFailure_advances_to_next_url()
{
var sender = new FakeSender(
new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, "refused"),
new AttemptOutcome(AttemptKind.Connected, 200, new RecipeDownloadResult { Result = true }, null));
var result = await Notifier.RunAsync(TwoUrls, Payload, sender, CancellationToken.None);
Assert.True(result.Ok);
Assert.Equal(2, sender.Calls);
}
[Fact]
public async Task Connected_result_false_is_final_no_failover()
{
var sender = new FakeSender(
new AttemptOutcome(AttemptKind.Connected, 200, new RecipeDownloadResult { Result = false, ResultText = "bad machine" }, null),
new AttemptOutcome(AttemptKind.Connected, 200, new RecipeDownloadResult { Result = true }, null));
var result = await Notifier.RunAsync(TwoUrls, Payload, sender, CancellationToken.None);
Assert.False(result.Ok);
Assert.Contains("bad machine", result.Reason);
Assert.Equal(1, sender.Calls);
}
[Fact]
public async Task Connected_5xx_is_final_no_failover()
{
var sender = new FakeSender(
new AttemptOutcome(AttemptKind.Connected, 500, null, null),
new AttemptOutcome(AttemptKind.Connected, 200, new RecipeDownloadResult { Result = true }, null));
var result = await Notifier.RunAsync(TwoUrls, Payload, sender, CancellationToken.None);
Assert.False(result.Ok);
Assert.Contains("500", result.Reason);
Assert.Equal(1, sender.Calls);
}
[Fact]
public async Task All_connect_failures_report_unreachable_with_last_error()
{
var sender = new FakeSender(
new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, "refused-a"),
new AttemptOutcome(AttemptKind.ConnectFailed, 0, null, "refused-b"));
var result = await Notifier.RunAsync(TwoUrls, Payload, sender, CancellationToken.None);
Assert.False(result.Ok);
Assert.Contains("unreachable", result.Reason);
Assert.Contains("refused-b", result.Reason);
Assert.Equal(2, sender.Calls);
}
}
@@ -0,0 +1,24 @@
using ZB.MOM.WW.ScadaBridge.DelmiaNotifier;
namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests;
public class ReporterTests
{
[Fact]
public void Success_writes_YES_and_returns_0()
{
using var sw = new StringWriter();
var code = Reporter.Report(true, "", sw);
Assert.Equal("YES\n", sw.ToString());
Assert.Equal(0, code);
}
[Fact]
public void Failure_writes_NO_then_reason_and_returns_minus1()
{
using var sw = new StringWriter();
var code = Reporter.Report(false, "boom", sw);
Assert.Equal("NO\nboom", sw.ToString());
Assert.Equal(-1, code);
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj" />
</ItemGroup>
</Project>
@@ -2397,6 +2397,10 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
public void AddTemplateNativeAlarmSource_WithDesignRole_ReturnsSuccess()
{
// Native-source add now propagates inherited rows to derived descendants,
// so the handler resolves TemplateService (no-op here: the substitute repo's
// GetAllTemplatesAsync yields no descendants).
_services.AddScoped<TemplateService>();
var actor = CreateActor();
var envelope = Envelope(
new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, "desc", false),
@@ -1286,17 +1286,39 @@ public class TemplateServiceTests
}
[Fact]
public async Task UpdateTemplate_ClearParent_Fails()
public async Task UpdateTemplate_OmittedParentAndDescription_LeavesUnchanged()
{
var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
// Followup #5: a null parent/description means "leave unchanged" (an omitted
// CLI/API field), not an attempt to clear. Renaming without re-passing the
// description must not wipe it, and must not trip the parent-immutability guard.
var child = new Template("Child") { Id = 2, ParentTemplateId = 1, Description = "keep me" };
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { child });
// Attempt to clear the parent.
var result = await _service.UpdateTemplateAsync(2, "Child", null, null, "admin");
var result = await _service.UpdateTemplateAsync(2, "ChildRenamed", null, null, "admin");
Assert.True(result.IsFailure);
Assert.Contains("cannot be changed", result.Error, StringComparison.OrdinalIgnoreCase);
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
Assert.Equal("ChildRenamed", result.Value.Name);
Assert.Equal("keep me", result.Value.Description); // null description left unchanged
Assert.Equal(1, result.Value.ParentTemplateId); // parent preserved
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task UpdateTemplate_EmptyDescription_ClearsIt()
{
// An explicit empty string (distinct from omitted/null) clears the description.
var t = new Template("T") { Id = 1, Description = "old" };
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _service.UpdateTemplateAsync(1, "T", "", null, "admin");
Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
Assert.Equal("", result.Value.Description);
_repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
@@ -1631,4 +1653,241 @@ public class TemplateServiceTests
Assert.Equal("123", auditedEntityId);
Assert.NotEqual("0", auditedEntityId);
}
// ========================================================================
// Follow-up #1/#2: inherited-member propagation & resync
// ========================================================================
private static Template BaseTemplate(int id, string name) => new(name) { Id = id };
private static Template DerivedTemplate(int id, string name, int parentId) =>
new(name) { Id = id, ParentTemplateId = parentId, IsDerived = true };
[Fact]
public async Task Resync_MaterializesMissingInheritedAttribute()
{
// The reported #1/#2 shape: a base member added after the child was derived
// never reached the child's stored rows. Resync materializes it.
var baseT = BaseTemplate(7, "Base");
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
baseT.Attributes.Add(new TemplateAttribute("B") { Id = 71, TemplateId = 7, DataType = DataType.String, Value = "x" });
var child = DerivedTemplate(8, "Child", 7);
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var added = new List<TemplateAttribute>();
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
.Returns(Task.CompletedTask);
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value.MembersAdded);
Assert.Equal(1, result.Value.TemplatesChanged);
var b = Assert.Single(added);
Assert.Equal("B", b.Name);
Assert.Equal(8, b.TemplateId);
Assert.True(b.IsInherited);
Assert.Equal("x", b.Value);
Assert.Equal(DataType.String, b.DataType);
}
[Fact]
public async Task Resync_RemovesOrphanedInheritedRow()
{
var baseT = BaseTemplate(7, "Base");
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
var child = DerivedTemplate(8, "Child", 7);
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
// Orphan: an inherited placeholder whose base member was removed.
child.Attributes.Add(new TemplateAttribute("Gone") { Id = 81, TemplateId = 8, DataType = DataType.String, Value = "old", IsInherited = true });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var deleted = new List<int>();
_repoMock.Setup(r => r.DeleteTemplateAttributeAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Callback<int, CancellationToken>((id, _) => deleted.Add(id))
.Returns(Task.CompletedTask);
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value.MembersRemoved);
Assert.Equal(81, Assert.Single(deleted));
}
[Fact]
public async Task Resync_UpdatesDriftedInheritedRow()
{
var baseT = BaseTemplate(7, "Base");
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.String, Value = "NEW" });
var child = DerivedTemplate(8, "Child", 7);
var childA = new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.String, Value = "OLD", IsInherited = true };
child.Attributes.Add(childA);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var updated = new List<TemplateAttribute>();
_repoMock.Setup(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => updated.Add(a))
.Returns(Task.CompletedTask);
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value.MembersUpdated);
Assert.Same(childA, Assert.Single(updated));
Assert.Equal("NEW", childA.Value); // re-synced in place to the live base value
}
[Fact]
public async Task Resync_LeavesAuthoredOverridesUntouched()
{
var baseT = BaseTemplate(7, "Base");
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.String, Value = "base" });
var child = DerivedTemplate(8, "Child", 7);
// Authored override (IsInherited == false) with a divergent value.
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.String, Value = "override", IsInherited = false });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(0, result.Value.MembersAdded);
Assert.Equal(0, result.Value.MembersUpdated);
Assert.Equal(0, result.Value.MembersRemoved);
_repoMock.Verify(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
_repoMock.Verify(r => r.DeleteTemplateAttributeAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Resync_OnBase_CascadesToAllDerivedChildren()
{
var baseT = BaseTemplate(7, "ReactorSide");
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
baseT.Attributes.Add(new TemplateAttribute("B") { Id = 71, TemplateId = 7, DataType = DataType.String, Value = "x" });
var left = DerivedTemplate(8, "Left", 7);
left.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
var right = DerivedTemplate(9, "Right", 7);
right.Attributes.Add(new TemplateAttribute("A") { Id = 90, TemplateId = 9, DataType = DataType.Float, Value = "1", IsInherited = true });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, left, right });
var added = new List<TemplateAttribute>();
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
.Returns(Task.CompletedTask);
// Resyncing the base repairs the whole subtree.
var result = await _service.ResyncInheritedMembersAsync(7, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.MembersAdded); // B materialized on Left and Right
Assert.Equal(2, result.Value.TemplatesChanged);
Assert.Contains(added, a => a.TemplateId == 8 && a.Name == "B" && a.IsInherited);
Assert.Contains(added, a => a.TemplateId == 9 && a.Name == "B" && a.IsInherited);
}
[Fact]
public async Task Resync_MaterializesMissingInheritedScriptAndNativeSource()
{
// Scripts and native alarm sources use the same inherit model and are
// counted by the staleness summary, so reconcile must materialize them too.
var baseT = BaseTemplate(7, "Base");
baseT.Scripts.Add(new TemplateScript("OnStart", "return 1;") { Id = 70, TemplateId = 7, TriggerType = "Startup" });
baseT.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src") { Id = 75, TemplateId = 7, ConnectionName = "opc", SourceReference = "ns=2;s=X" });
var child = DerivedTemplate(8, "Child", 7); // no inherited rows yet
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var addedScripts = new List<TemplateScript>();
var addedSources = new List<TemplateNativeAlarmSource>();
_repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
.Callback<TemplateScript, CancellationToken>((s, _) => addedScripts.Add(s)).Returns(Task.CompletedTask);
_repoMock.Setup(r => r.AddTemplateNativeAlarmSourceAsync(It.IsAny<TemplateNativeAlarmSource>(), It.IsAny<CancellationToken>()))
.Callback<TemplateNativeAlarmSource, CancellationToken>((s, _) => addedSources.Add(s)).Returns(Task.CompletedTask);
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.MembersAdded);
var script = Assert.Single(addedScripts);
Assert.Equal("OnStart", script.Name);
Assert.Equal("return 1;", script.Code);
Assert.True(script.IsInherited);
Assert.Equal(8, script.TemplateId);
var src = Assert.Single(addedSources);
Assert.Equal("Src", src.Name);
Assert.Equal("ns=2;s=X", src.SourceReference);
Assert.True(src.IsInherited);
Assert.Equal(8, src.TemplateId);
}
[Fact]
public async Task ReconcileDescendants_PropagatesNewBaseMemberToChild()
{
// Base already carries the new member C; reconciling its descendants
// materializes C onto the child (the engine behind auto-propagation).
var baseT = BaseTemplate(7, "Base");
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
baseT.Attributes.Add(new TemplateAttribute("C") { Id = 72, TemplateId = 7, DataType = DataType.String, Value = "c" });
var child = DerivedTemplate(8, "Child", 7);
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var added = new List<TemplateAttribute>();
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a)).Returns(Task.CompletedTask);
await _service.ReconcileDescendantsAsync(7, "admin");
var c = Assert.Single(added);
Assert.Equal("C", c.Name);
Assert.Equal(8, c.TemplateId);
Assert.True(c.IsInherited);
}
[Fact]
public async Task AddAttribute_PropagatesNewMemberToDerivedChild()
{
// End-to-end: adding a member to a base auto-propagates an inherited
// placeholder to its derived children (#1/#2).
var baseT = BaseTemplate(7, "Base");
baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
var child = DerivedTemplate(8, "Child", 7);
child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
_repoMock.Setup(r => r.GetTemplateByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(baseT);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var added = new List<TemplateAttribute>();
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) =>
{
added.Add(a);
// Simulate persistence so the post-add reconcile re-fetch sees the
// base's new member and propagates it down.
if (a.TemplateId == 7) baseT.Attributes.Add(a);
})
.Returns(Task.CompletedTask);
var newAttr = new TemplateAttribute("C") { DataType = DataType.String, Value = "c" };
var result = await _service.AddAttributeAsync(7, newAttr, "admin");
Assert.True(result.IsSuccess);
Assert.Contains(added, a => a.TemplateId == 7 && a.Name == "C" && !a.IsInherited); // base's own
Assert.Contains(added, a => a.TemplateId == 8 && a.Name == "C" && a.IsInherited); // child placeholder
}
}