bf4b9c6c09
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
1868 lines
112 KiB
Markdown
1868 lines
112 KiB
Markdown
# AckMsg-as-Operator Alarm Acknowledgement Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Make `AcknowledgeAlarm` acknowledge alarms by writing the comment to `<TAGNAME>.AckMsg` over MXAccess after authenticating as the requesting operator, so the AVEVA Historian events table records the real operator name, domain and full name (the way InTouch does).
|
|
|
|
**Architecture:** The public `AcknowledgeAlarmRequest` gains `operator_password` (credential) and `operator_domain`; the gateway forwards them on the worker ack commands. In the x86 worker a new `AlarmAckWriter` owns its own registered `LMXProxyServer`, authenticates the operator (`AuthenticateUser`), advises `<item>.AckMsg`, writes the comment with that user id and waits (bounded, pumped) for `OnWriteComplete`. Alarm consumers stop acking themselves and only resolve the ack item address (alarm-manager GUID → snapshot `TAGNAME`; by-name → tag; subtag → `AckCommentSubtag`). The dead wnwrap/managed alarm-client ack paths are removed.
|
|
|
|
**Tech Stack:** .NET 10 gateway, .NET Framework 4.8 x86 worker (no init-only props / positional records), protobuf/gRPC (Grpc.Tools 2.80.0), ArchestrA MXAccess COM (`LMXProxyServerClass`), xUnit; clients in .NET, Python, Go, Rust, Java.
|
|
|
|
**Spec:** this document's **Design** section (below) plus the live evidence in memory `project_managed_alarm_ack_live_result.md` and `docs/AlarmClientDiscovery.md` §4.
|
|
|
|
## Global Constraints
|
|
|
|
- **Field numbers (exact):** `AcknowledgeAlarmRequest.operator_password = 6`, `AcknowledgeAlarmRequest.operator_domain = 7`, `AcknowledgeAlarmCommand.operator_password = 7`, `AcknowledgeAlarmByNameCommand.operator_password = 9`. Additive only; `GatewayProtocolVersion` stays `3`.
|
|
- **Credential handling:** `operator_password` must never reach logs, metrics labels, command lines, `DiagnosticMessage`, `ProtocolStatus.Message`, exception messages, or `ToString()`/`Debug` output that is logged. Mirror the existing `AuthenticateUserCommand.verify_user_password` comment and tests (`DispatchAsync_AuthenticateUser_DoesNotLeakPassword`).
|
|
- **Ack item address:** `"{TagName}.AckMsg"` for Galaxy-provider alarm-manager alarms (constant `AlarmAckWriter.AckCommentAttribute = "AckMsg"`); subtag mode uses the target's `AckCommentSubtag` verbatim. Alarms whose provider is not `Galaxy` (case-insensitive) are rejected as not ackable.
|
|
- **Verify user:** `operator_domain` empty → `AuthenticateUser(operator_user, operator_password)`; non-empty → `AuthenticateUser($"{operator_domain}\\{operator_user}", operator_password)`.
|
|
- **operator_user is required** on `AcknowledgeAlarmRequest` (gateway returns `InvalidArgument` when empty) — the requirement is that the real operator is recorded; an empty password IS allowed (Galaxy accounts may have empty passwords).
|
|
- **Historian OperatorNode** = the ack writer's MXAccess `Register()` client name: gateway option `MxGateway:Alarms:AckClientName`, default `"MxGateway"`, conveyed to workers as env `MXGATEWAY_ALARM_ACK_CLIENT_NAME`.
|
|
- **Reply semantics:** `Hresult`/`AcknowledgeAlarm.NativeStatus` = 0 on applied or unconfirmed-but-sent; non-zero = `AuthenticateUser` HRESULT or the write status `detail`. `reply.Statuses` carries the `OnWriteComplete` `MxStatusProxy` rows when observed.
|
|
- **net48 worker:** no `init` accessors, no positional records; `TreatWarningsAsErrors=true`; xUnit1030 forbids `.ConfigureAwait(false)` in `[Fact]` bodies (only surfaces on Windows).
|
|
- **Generated code is committed:** `src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs`, `clients/proto/descriptors/mxaccessgw-client-v1.protoset`, `clients/rust/protos/mxaccess_gateway.proto`, Go `clients/go/internal/generated/*.pb.go`, Python `clients/python/src/zb_mom_ww_mxgateway/generated/*_pb2*.py` (regenerate with grpcio-tools **1.80.0** only), Java `clients/java/src/main/generated/**`.
|
|
- **Docs change in the same commit as behaviour** (CLAUDE.md). Commits end with `Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT`.
|
|
- Work on branch `feat/ackmsg-as-operator` off `main`. Run targeted tests per task (`--filter`); full gateway suite at most once at the end.
|
|
|
|
**User decisions (already made):**
|
|
- "build the AckMsg-as-operator route" — ack = authenticated `AckMsg` write as the operator (not a service account, not the alarm-client string ack).
|
|
- "we need to capture the operator name in the historian in the events table just as intouch does" — success is a Historian `ACK_ALM` row whose `Operator` is the requesting operator.
|
|
- "yes operators have galaxy accounts" — authenticating each operator with their own Galaxy credentials is acceptable.
|
|
- "dont forget to verify the historian is logging the event with the username captured" — live Historian verification is mandatory.
|
|
- Earlier (2026-10-18): remove the dead managed alarm-ack path from PR #160 was recommended; this plan removes it because the new path supersedes it.
|
|
|
|
---
|
|
|
|
## Design
|
|
|
|
**Why this route.** Live tests (2026-10-18/19, windev rig) showed every alarm-client ack (wnwrap `AlarmAckByName`/`AlarmAckByGUID`/`AlarmAck`/`AlarmAckSelected`, managed `AlarmClient.*`, native WNAL `UserRequest`) returns 0 and is silently dropped for a headless consumer, regardless of security classification, operator name or consumer identity. Writing `<Object>.<Alarm>.AckMsg` over MXAccess **does** acknowledge, and when the write is authenticated (`AuthenticateUser` + `Write(value, userId)`) the Historian `Runtime.dbo.v_AlarmEventHistory2` `ACK_ALM` row carries `Operator`, `DomainName`, `UserFullName` from the authenticated account and `OperatorNode` = the MXAccess client name.
|
|
|
|
**Data flow.**
|
|
```
|
|
Client AcknowledgeAlarmRequest{alarm_full_reference, comment, operator_user, operator_password, operator_domain}
|
|
→ MxAccessGatewayService.AcknowledgeAlarm (validates reference + operator_user)
|
|
→ GatewayAlarmMonitor.AcknowledgeAsync → BuildAcknowledgeCommand
|
|
GUID ref → AcknowledgeAlarmCommand{alarm_guid, comment, operator_user, operator_domain, operator_password}
|
|
P!G.T ref → AcknowledgeAlarmByNameCommand{alarm_name, provider_name, group_name, comment, operator_user, operator_domain, operator_password}
|
|
→ worker MxAccessCommandExecutor.ExecuteAcknowledgeAlarm[ByName]
|
|
→ AlarmCommandHandler.Acknowledge[ByName](…, AlarmAckOperator) → AlarmDispatcher
|
|
→ consumer.ResolveAckTarget(guid) / ResolveAckTargetByName(name, provider, group) → AlarmAckTarget{ItemAddress}
|
|
→ AlarmAckWriter.Acknowledge(itemAddress, comment, operator) → AlarmAckResult
|
|
→ executor maps AlarmAckResult → MxCommandReply (status, hresult, native_status, statuses, diagnostic)
|
|
```
|
|
|
|
**New worker types** (namespace `ZB.MOM.WW.MxGateway.Worker.MxAccess`):
|
|
- `AlarmAckOperator` — `UserName`, `Domain`, `Password` (get/set; `ToString()` overridden to omit the password); `VerifyUser` computed.
|
|
- `AlarmAckTarget` — `Found`, `ItemAddress`, `Outcome` (`AlarmAckOutcome.AlarmNotFound`/`NotAckable` when not found), `Reason`; static factories `For(item)`, `NotFound(reason)`, `NotAckable(reason)`.
|
|
- `AlarmAckOutcome` enum — `Applied, Unconfirmed, AlarmNotFound, NotAckable, AuthenticationFailed, WriteRejected`.
|
|
- `AlarmAckResult` — `Outcome`, `NativeStatus`, `Diagnostic`, `Statuses` (`IReadOnlyList<MxStatusProxy>`); static factories.
|
|
- `IAlarmAckWriter : IDisposable` — `AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)`.
|
|
- `AlarmAckWriter` — production implementation over its own `LMXProxyServerClass`.
|
|
|
|
**Removed:** `AaManagedAlarmAckConsumer`, `IManagedAlarmAckClient`, `WnalNativeAckConsumer`, `AlarmManagedGuidAckLiveSmokeTests`, `MXGATEWAY_USE_MANAGED_ALARM_ACK`, `AlarmDispatcher.IsSubtagActive`, `IMxAccessAlarmConsumer.AcknowledgeByGuid/AcknowledgeByName` and their implementations, wnwrap's second `ackClient` consumer, `ISubtagAlarmSource.Write`/`LmxSubtagAlarmSource.Write`.
|
|
|
|
**Ack transitions.** wnwrap's XML carries `ACK_COMMENT`, `ACK_OPERATOR_NAME`, `ACK_DOMAIN_NAME`, `ACK_OPERATOR_NODE` once acked. `MxAlarmSnapshotRecord` gains `AckComment`, `AckOperatorName`, `AckOperatorDomain`, `AckOperatorNode`; `AlarmDispatcher` emits Acknowledge transitions (and acked snapshot rows) with `operator_user = AckOperatorName` and `operator_comment = AckComment` when those are present.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
| File | Change | Responsibility |
|
|
|---|---|---|
|
|
| `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` | Modify | new fields + comments |
|
|
| `src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs` | Regenerate | committed C# bindings |
|
|
| `src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs` | Modify | `AlarmAckClientName` env name |
|
|
| `clients/proto/descriptors/mxaccessgw-client-v1.protoset` | Regenerate | client descriptor |
|
|
| `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAck*.cs` (`AlarmAckOperator`, `AlarmAckTarget`, `AlarmAckOutcome`, `AlarmAckResult`, `IAlarmAckWriter`, `AlarmAckWriter`) | Create | ack value types + writer |
|
|
| `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs` | Modify | `Ack*` fields |
|
|
| `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs` | Modify | parse `ACK_*`, `ResolveAckTarget*`, drop ack consumer |
|
|
| `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs`, `FailoverAlarmConsumer.cs`, `IMxAccessAlarmConsumer.cs`, `ISubtagAlarmSource.cs`, `LmxSubtagAlarmSource.cs` | Modify | resolve-only ack contract |
|
|
| `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs`, `AlarmCommandHandler.cs`, `IAlarmCommandHandler.cs`, `MxAccessCommandExecutor.cs` | Modify | route acks to writer, map result |
|
|
| `src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs` | Modify | drop managed factory |
|
|
| `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/{AaManagedAlarmAckConsumer,IManagedAlarmAckClient,WnalNativeAckConsumer}.cs`, `src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs` | Delete | dead path |
|
|
| `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs`, `GatewayOptionsValidator.cs`, `Workers/WorkerProcessLauncher.cs` | Modify | `AckClientName` option + env |
|
|
| `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs`, `Grpc/MxAccessGatewayService.cs` | Modify | forward credentials, require operator_user |
|
|
| `src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs`, `GatewayLogRedactorSeam.cs` | Modify | ack methods credential-bearing; name-based `password` redaction |
|
|
| `clients/{dotnet,python,go,rust,java}/**` | Modify | regenerate, CLI flags, redaction, tests, README |
|
|
| `gateway.md`, `docs/{Grpc,Contracts,DesignDecisions,AlarmClientDiscovery,AlarmProbeFindings,GatewayConfiguration,Diagnostics,Authorization}.md`, `CLAUDE.md` | Modify | describe new behaviour |
|
|
|
|
---
|
|
|
|
### Task 1: Contract fields and regenerated bindings
|
|
|
|
**Goal:** Add the credential/domain fields to the public request and the worker ack commands, plus the worker env-var name, and commit regenerated C# bindings and the client descriptor.
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (`AcknowledgeAlarmCommand` ~394-406, `AcknowledgeAlarmByNameCommand` ~415-434, `AcknowledgeAlarmReplyPayload` ~726-737, `AcknowledgeAlarmRequest` ~977-990)
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/WorkerLaunchEnvironment.cs`
|
|
- Regenerate: `src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs`, `MxaccessGatewayGrpc.cs`; `clients/proto/descriptors/mxaccessgw-client-v1.protoset`; `clients/rust/protos/mxaccess_gateway.proto` (byte copy)
|
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs`
|
|
|
|
**Acceptance Criteria:**
|
|
- [ ] `AcknowledgeAlarmRequest` has `operator_password = 6` and `operator_domain = 7`; `AcknowledgeAlarmCommand` has `operator_password = 7`; `AcknowledgeAlarmByNameCommand` has `operator_password = 9`; each password field carries the credential comment.
|
|
- [ ] `WorkerLaunchEnvironment.AlarmAckClientName == "MXGATEWAY_ALARM_ACK_CLIENT_NAME"`.
|
|
- [ ] Round-trip tests cover the new fields and pass.
|
|
- [ ] `pwsh -File scripts/publish-client-proto-inputs.ps1 -Check` passes and `ClientProtoInputTests` passes.
|
|
- [ ] Rust vendored proto is byte-identical to the Contracts proto.
|
|
|
|
**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~ProtobufContractRoundTripTests|FullyQualifiedName~ClientProtoInputTests"` → all pass
|
|
|
|
**Steps:**
|
|
|
|
- [ ] **Step 1: Create the branch**
|
|
|
|
```bash
|
|
git checkout main && git pull --ff-only origin main && git checkout -b feat/ackmsg-as-operator
|
|
```
|
|
|
|
- [ ] **Step 2: Write failing round-trip tests** — in `ProtobufContractRoundTripTests.cs`, extend `AcknowledgeAlarmRequest_RoundTripsAllFields` and add command round-trips:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public void AcknowledgeAlarmRequest_RoundTripsAllFields()
|
|
{
|
|
AcknowledgeAlarmRequest original = new()
|
|
{
|
|
ClientCorrelationId = "corr-1",
|
|
AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
|
|
Comment = "investigating",
|
|
OperatorUser = "alice",
|
|
OperatorPassword = "s3cret",
|
|
OperatorDomain = "CORP",
|
|
};
|
|
|
|
AcknowledgeAlarmRequest parsed = AcknowledgeAlarmRequest.Parser.ParseFrom(original.ToByteArray());
|
|
|
|
Assert.Equal(original, parsed);
|
|
Assert.Equal("s3cret", parsed.OperatorPassword);
|
|
Assert.Equal("CORP", parsed.OperatorDomain);
|
|
}
|
|
|
|
[Fact]
|
|
public void AcknowledgeAlarmCommands_RoundTripOperatorPassword()
|
|
{
|
|
MxCommand byGuid = new()
|
|
{
|
|
Kind = MxCommandKind.AcknowledgeAlarm,
|
|
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
|
|
{
|
|
AlarmGuid = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73",
|
|
OperatorUser = "alice",
|
|
OperatorDomain = "CORP",
|
|
OperatorPassword = "s3cret",
|
|
},
|
|
};
|
|
MxCommand byName = new()
|
|
{
|
|
Kind = MxCommandKind.AcknowledgeAlarmByName,
|
|
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
|
|
{
|
|
AlarmName = "TestMachine_001.TestAlarm001",
|
|
ProviderName = "Galaxy",
|
|
GroupName = "TestArea",
|
|
OperatorUser = "alice",
|
|
OperatorPassword = "s3cret",
|
|
},
|
|
};
|
|
|
|
Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byGuid.ToByteArray()).AcknowledgeAlarmCommand.OperatorPassword);
|
|
Assert.Equal("s3cret", MxCommand.Parser.ParseFrom(byName.ToByteArray()).AcknowledgeAlarmByNameCommand.OperatorPassword);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run to verify they fail to compile** — `dotnet build src/ZB.MOM.WW.MxGateway.Tests` → CS1061 `OperatorPassword` not found.
|
|
|
|
- [ ] **Step 4: Edit the proto.** In `AcknowledgeAlarmCommand` replace the leading comment and append field 7:
|
|
|
|
```proto
|
|
// Acknowledge a single alarm by its GUID. The worker resolves the GUID to the
|
|
// alarm's Galaxy reference from its active-alarm snapshot and acknowledges it
|
|
// by writing `comment` to `<TAGNAME>.AckMsg` over MXAccess after
|
|
// AuthenticateUser as the operator, so the alarm history records the real
|
|
// operator. The reply's hresult / native_status is 0 when the write was
|
|
// applied (or sent but unconfirmed), otherwise the AuthenticateUser HRESULT or
|
|
// the OnWriteComplete status detail.
|
|
message AcknowledgeAlarmCommand {
|
|
// Canonical 8-4-4-4-12 GUID string (e.g. "BCC47053-9542-4D65-BDAA-BCDEA6A32A73").
|
|
string alarm_guid = 1;
|
|
string comment = 2;
|
|
string operator_user = 3;
|
|
string operator_node = 4;
|
|
string operator_domain = 5;
|
|
string operator_full_name = 6;
|
|
// Operator credential used for AuthenticateUser before the AckMsg write.
|
|
// Implementations must keep this field out of logs, metrics labels, command
|
|
// lines, and diagnostics.
|
|
string operator_password = 7;
|
|
}
|
|
```
|
|
|
|
In `AcknowledgeAlarmByNameCommand` replace the leading comment's last two lines ("The worker invokes wwAlarmConsumerClass.AlarmAckByName which reaches the same alarm history path as AlarmAckByGUID.") with "The worker acknowledges by writing `comment` to `<alarm_name>.AckMsg` (Galaxy provider only) after AuthenticateUser as the operator." and append:
|
|
|
|
```proto
|
|
// Operator credential used for AuthenticateUser before the AckMsg write.
|
|
// Implementations must keep this field out of logs, metrics labels, command
|
|
// lines, and diagnostics.
|
|
string operator_password = 9;
|
|
```
|
|
|
|
In the `AcknowledgeAlarmReplyPayload` comment replace "Surfaces AVEVA's native ack return code (AlarmAckByGUID for the GUID arm, AlarmAckByName for the by-name arm); 0 means success." with "Surfaces the ack outcome: 0 when the AckMsg write was applied or sent but unconfirmed; otherwise the AuthenticateUser HRESULT or the OnWriteComplete status detail." and replace "the gateway-side WorkerAlarmRpcDispatcher" with "GatewayAlarmMonitor".
|
|
|
|
In `AcknowledgeAlarmRequest` append after `operator_user = 5`:
|
|
|
|
```proto
|
|
// Operator's Galaxy (or OS) password. The gateway authenticates as
|
|
// operator_user with it before writing the acknowledgement so the alarm
|
|
// history records the real operator. May be empty for accounts without a
|
|
// password. Credential-sensitive: implementations must keep this field out
|
|
// of logs, metrics labels, command lines, and diagnostics.
|
|
string operator_password = 6;
|
|
// Optional domain for OS-authenticated galaxies. When set the gateway
|
|
// authenticates as "<operator_domain>\<operator_user>".
|
|
string operator_domain = 7;
|
|
```
|
|
|
|
Also update the `AcknowledgeAlarmReply.hresult` comment: replace "= AlarmAckByName / AlarmAckByGUID return code; 0 = success); the gateway's WorkerAlarmRpcDispatcher copies" with "0 = applied or sent; otherwise the AuthenticateUser HRESULT or OnWriteComplete detail); GatewayAlarmMonitor copies".
|
|
|
|
- [ ] **Step 5: Add the env name** in `WorkerLaunchEnvironment.cs` after `MaxAlarmsPerFetch`:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// MXAccess client name the worker's alarm-ack writer registers with. The
|
|
/// Historian records it as the acknowledgement's OperatorNode.
|
|
/// </summary>
|
|
public const string AlarmAckClientName = "MXGATEWAY_ALARM_ACK_CLIENT_NAME";
|
|
```
|
|
|
|
- [ ] **Step 6: Regenerate and copy**
|
|
|
|
```bash
|
|
rm src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs
|
|
dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
|
|
pwsh -File scripts/publish-client-proto-inputs.ps1
|
|
cp src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto clients/rust/protos/mxaccess_gateway.proto
|
|
pwsh -File scripts/publish-client-proto-inputs.ps1 -Check
|
|
```
|
|
|
|
- [ ] **Step 7: Run tests** — the Verify command → PASS.
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add src/ZB.MOM.WW.MxGateway.Contracts clients/proto/descriptors clients/rust/protos src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs
|
|
git commit -m "feat(contracts): carry operator credentials on alarm acknowledgement" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Parse wnwrap ACK_* fields and attribute ack transitions
|
|
|
|
**Goal:** Capture the `ACK_COMMENT`/`ACK_OPERATOR_NAME`/`ACK_DOMAIN_NAME`/`ACK_OPERATOR_NODE` elements on snapshot records and use them for Acknowledge transitions and acked snapshot rows.
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs`
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs` (`ParseSnapshotXml` ~739-849)
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs` (`OnTransition` ~235-260, `MapToSnapshot` ~273-274)
|
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/WnWrapAlarmConsumerXmlTests.cs`, `AlarmDispatcherTests.cs`
|
|
|
|
**Acceptance Criteria:**
|
|
- [ ] `ParseSnapshotXml` fills `AckComment`, `AckOperatorName`, `AckOperatorDomain`, `AckOperatorNode` from the live XML sample below.
|
|
- [ ] An `UnackAlm → AckAlm` transition whose record has `AckOperatorName="alice"`, `AckComment="checked"` enqueues an `OnAlarmTransitionEvent` with `OperatorUser == "alice"` and `OperatorComment == "checked"`.
|
|
- [ ] Non-ack transitions keep `OperatorUser = OperatorName`, `OperatorComment = AlarmComment`.
|
|
|
|
**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WnWrapAlarmConsumerXmlTests|FullyQualifiedName~AlarmDispatcherTests"` (windev) → pass
|
|
|
|
**Steps:**
|
|
|
|
- [ ] **Step 1: Failing parser test** in `WnWrapAlarmConsumerXmlTests.cs`:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public void ParseSnapshotXml_WithAckElements_PopulatesAckFields()
|
|
{
|
|
const string xml =
|
|
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\"><ALARM><GUID>2D8A9BE88FE04B0F8D996BB4D56620C7</GUID>" +
|
|
"<DATE>2026/10/19</DATE><TIME>18:2:53.812</TIME><GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>" +
|
|
"<PROVIDER_NODE>DESKTOP-6JL3KKO</PROVIDER_NODE><PROVIDER_NAME>Galaxy</PROVIDER_NAME><GROUP>TestArea</GROUP>" +
|
|
"<TAGNAME>TestMachine_001.TestAlarm001</TAGNAME><TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>" +
|
|
"<PRIORITY>500</PRIORITY><STATE>ACK_ALM</STATE><OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>" +
|
|
"<ACK_DOMAIN_NAME>ArchestrA</ACK_DOMAIN_NAME><ACK_OPERATOR_NODE>mxa</ACK_OPERATOR_NODE>" +
|
|
"<ACK_OPERATOR_NAME>Administrator</ACK_OPERATOR_NAME><ALARM_COMMENT>Test alarm #1</ALARM_COMMENT>" +
|
|
"<ACK_COMMENT>mgack-authwrite 180714</ACK_COMMENT></ALARM></ALARM_RECORDS>";
|
|
|
|
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
|
|
|
|
MxAlarmSnapshotRecord record = Assert.Single(records.Values);
|
|
Assert.Equal("Administrator", record.AckOperatorName);
|
|
Assert.Equal("ArchestrA", record.AckOperatorDomain);
|
|
Assert.Equal("mxa", record.AckOperatorNode);
|
|
Assert.Equal("mgack-authwrite 180714", record.AckComment);
|
|
Assert.Equal("Test alarm #1", record.AlarmComment);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Failing dispatcher test** in `AlarmDispatcherTests.cs` (reuses the file's `FakeAlarmConsumer`):
|
|
|
|
```csharp
|
|
[Fact]
|
|
public void OnTransition_WhenAcknowledged_UsesAckOperatorAndComment()
|
|
{
|
|
FakeAlarmConsumer consumer = new FakeAlarmConsumer();
|
|
MxAccessEventQueue queue = new MxAccessEventQueue();
|
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
|
consumer, new MxAccessAlarmEventSink(queue, new MxAccessEventMapper()), SessionId, new FakeAckWriter());
|
|
|
|
consumer.RaiseTransition(new MxAlarmTransitionEvent
|
|
{
|
|
PreviousState = MxAlarmStateKind.UnackAlm,
|
|
Record = new MxAlarmSnapshotRecord
|
|
{
|
|
AlarmGuid = Guid.NewGuid(),
|
|
ProviderName = "Galaxy",
|
|
Group = "TestArea",
|
|
TagName = "TestMachine_001.TestAlarm001",
|
|
State = MxAlarmStateKind.AckAlm,
|
|
AlarmComment = "Test alarm #1",
|
|
AckOperatorName = "alice",
|
|
AckComment = "checked",
|
|
},
|
|
});
|
|
|
|
Assert.True(queue.TryDequeue(out WorkerEvent? evt));
|
|
OnAlarmTransitionEvent body = evt!.Event.OnAlarmTransition;
|
|
Assert.Equal(AlarmTransitionKind.Acknowledge, body.TransitionKind);
|
|
Assert.Equal("alice", body.OperatorUser);
|
|
Assert.Equal("checked", body.OperatorComment);
|
|
}
|
|
```
|
|
|
|
(`FakeAckWriter` and the 4-arg `AlarmDispatcher` constructor arrive in Task 5; until then write this test against the current 3-arg constructor — drop the 4th argument — and update it in Task 5.)
|
|
|
|
- [ ] **Step 3: Add fields** to `MxAlarmSnapshotRecord` (after `AlarmComment`):
|
|
|
|
```csharp
|
|
/// <summary>Gets or sets the acknowledgement comment (wnwrap <c>ACK_COMMENT</c>).</summary>
|
|
public string AckComment { get; set; } = string.Empty;
|
|
|
|
/// <summary>Gets or sets the acknowledging operator's name (wnwrap <c>ACK_OPERATOR_NAME</c>).</summary>
|
|
public string AckOperatorName { get; set; } = string.Empty;
|
|
|
|
/// <summary>Gets or sets the acknowledging operator's domain (wnwrap <c>ACK_DOMAIN_NAME</c>).</summary>
|
|
public string AckOperatorDomain { get; set; } = string.Empty;
|
|
|
|
/// <summary>Gets or sets the acknowledging node, i.e. the MXAccess client name (wnwrap <c>ACK_OPERATOR_NODE</c>).</summary>
|
|
public string AckOperatorNode { get; set; } = string.Empty;
|
|
```
|
|
|
|
- [ ] **Step 4: Parse them** in `ParseSnapshotXml`: declare `string? ackComment = null; string? ackOperatorName = null; string? ackOperatorDomain = null; string? ackOperatorNode = null;` next to `alarmComment`; add switch arms
|
|
|
|
```csharp
|
|
case "ACK_COMMENT": ackComment ??= child.InnerText; break;
|
|
case "ACK_OPERATOR_NAME": ackOperatorName ??= child.InnerText; break;
|
|
case "ACK_DOMAIN_NAME": ackOperatorDomain ??= child.InnerText; break;
|
|
case "ACK_OPERATOR_NODE": ackOperatorNode ??= child.InnerText; break;
|
|
```
|
|
|
|
and initializers `AckComment = ackComment ?? string.Empty, AckOperatorName = ackOperatorName ?? string.Empty, AckOperatorDomain = ackOperatorDomain ?? string.Empty, AckOperatorNode = ackOperatorNode ?? string.Empty,`.
|
|
|
|
- [ ] **Step 5: Use them in `AlarmDispatcher`.** Add helpers and use them in `OnTransition` and `MapToSnapshot`:
|
|
|
|
```csharp
|
|
private static bool HasAckAttribution(MxAlarmSnapshotRecord record) =>
|
|
!string.IsNullOrEmpty(record.AckOperatorName) || !string.IsNullOrEmpty(record.AckComment);
|
|
|
|
private static bool IsAcked(MxAlarmStateKind state) =>
|
|
state == MxAlarmStateKind.AckAlm || state == MxAlarmStateKind.AckRtn;
|
|
```
|
|
|
|
In `OnTransition` replace `operatorUser: record.OperatorName, operatorComment: record.AlarmComment,` with:
|
|
|
|
```csharp
|
|
bool useAck = kind == AlarmTransitionKind.Acknowledge && HasAckAttribution(record);
|
|
...
|
|
operatorUser: useAck ? record.AckOperatorName : record.OperatorName,
|
|
operatorComment: useAck ? record.AckComment : record.AlarmComment,
|
|
```
|
|
|
|
In `MapToSnapshot` use `IsAcked(record.State) && HasAckAttribution(record)` the same way. (Check `MxAlarmStateKind` member names in `AlarmRecordTransitionMapper.ParseStateKind`; use the exact acked members it defines.)
|
|
|
|
- [ ] **Step 6: Commit** after the Verify command passes on windev (or after Task 5 when verifying the worker together):
|
|
|
|
```bash
|
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs src/ZB.MOM.WW.MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess
|
|
git commit -m "feat(worker): attribute alarm ack transitions from wnwrap ACK fields" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: AlarmAckWriter (authenticated AckMsg write)
|
|
|
|
**Goal:** Add the ack value types and `AlarmAckWriter`, which authenticates the operator on its own registered MXAccess server, writes the comment to the ack item, and waits for `OnWriteComplete`.
|
|
|
|
**Files:**
|
|
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAckOperator.cs`, `AlarmAckOutcome.cs`, `AlarmAckTarget.cs`, `AlarmAckResult.cs`, `IAlarmAckWriter.cs`, `AlarmAckWriter.cs`
|
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs`
|
|
|
|
**Acceptance Criteria:**
|
|
- [ ] With a fake `IMxAccessServer` returning user id 5: `Acknowledge("T.A.AckMsg", "c", {alice, CORP, pw})` calls `Register(clientName)` once, `AuthenticateUser(h, "CORP\\alice", "pw")`, `AddItem("T.A.AckMsg")`, `Advise`, `Write(h, item, "c", 5)` in that order; a recorded `OnWriteComplete` with category Ok yields `Outcome == Applied`, `NativeStatus == 0`.
|
|
- [ ] Completion with category `SecurityError`, detail 1008 → `WriteRejected`, `NativeStatus == 1008`, statuses returned.
|
|
- [ ] No completion before the timeout → `Unconfirmed`, `NativeStatus == 0`.
|
|
- [ ] `AuthenticateUser` throwing `COMException(hr)` or returning `0` → `AuthenticationFailed`, no write; the password never appears in `Diagnostic` or in the fake's operation log.
|
|
- [ ] A second ack to the same item reuses the item handle (one `AddItem`, one `Advise`).
|
|
- [ ] `AlarmAckOperator.ToString()` does not contain the password.
|
|
|
|
**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~AlarmAckWriterTests"` (windev) → pass
|
|
|
|
**Steps:**
|
|
|
|
- [ ] **Step 1: Value types.**
|
|
|
|
`AlarmAckOperator.cs`:
|
|
```csharp
|
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
|
|
/// <summary>
|
|
/// Operator identity an alarm acknowledgement authenticates as. The password
|
|
/// is a raw credential: it is passed to MXAccess <c>AuthenticateUser</c> only
|
|
/// and must never be logged or echoed.
|
|
/// </summary>
|
|
public sealed class AlarmAckOperator
|
|
{
|
|
/// <summary>Gets or sets the operator user name.</summary>
|
|
public string UserName { get; set; } = string.Empty;
|
|
|
|
/// <summary>Gets or sets the optional domain for OS-authenticated galaxies.</summary>
|
|
public string Domain { get; set; } = string.Empty;
|
|
|
|
/// <summary>Gets or sets the operator password (credential-sensitive).</summary>
|
|
public string Password { get; set; } = string.Empty;
|
|
|
|
/// <summary>Gets the user string passed to <c>AuthenticateUser</c>: <c>Domain\UserName</c> when a domain is set.</summary>
|
|
public string VerifyUser => string.IsNullOrEmpty(Domain) ? UserName : Domain + "\\" + UserName;
|
|
|
|
/// <inheritdoc />
|
|
public override string ToString() => "AlarmAckOperator(" + VerifyUser + ")";
|
|
}
|
|
```
|
|
|
|
`AlarmAckOutcome.cs`:
|
|
```csharp
|
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
|
|
/// <summary>Outcome of an alarm acknowledgement attempt.</summary>
|
|
public enum AlarmAckOutcome
|
|
{
|
|
/// <summary>The AckMsg write completed with an OK status.</summary>
|
|
Applied,
|
|
/// <summary>The write was sent but no OnWriteComplete arrived before the timeout.</summary>
|
|
Unconfirmed,
|
|
/// <summary>The alarm is not in the consumer's current view.</summary>
|
|
AlarmNotFound,
|
|
/// <summary>The alarm exists but has no MXAccess ack attribute (non-Galaxy provider or no ack subtag).</summary>
|
|
NotAckable,
|
|
/// <summary>AuthenticateUser rejected the operator credentials.</summary>
|
|
AuthenticationFailed,
|
|
/// <summary>The AckMsg write completed with a non-OK status.</summary>
|
|
WriteRejected,
|
|
}
|
|
```
|
|
|
|
`AlarmAckTarget.cs`:
|
|
```csharp
|
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
|
|
/// <summary>The MXAccess item an acknowledgement writes, or why none exists.</summary>
|
|
public sealed class AlarmAckTarget
|
|
{
|
|
private AlarmAckTarget(string itemAddress, AlarmAckOutcome failure, string reason)
|
|
{
|
|
ItemAddress = itemAddress;
|
|
Failure = failure;
|
|
Reason = reason;
|
|
}
|
|
|
|
/// <summary>Gets a value indicating whether an ack item was resolved.</summary>
|
|
public bool Found => ItemAddress.Length > 0;
|
|
|
|
/// <summary>Gets the ack item address (e.g. <c>TestMachine_001.TestAlarm001.AckMsg</c>), empty when not found.</summary>
|
|
public string ItemAddress { get; }
|
|
|
|
/// <summary>Gets the failure outcome when <see cref="Found"/> is false.</summary>
|
|
public AlarmAckOutcome Failure { get; }
|
|
|
|
/// <summary>Gets a human-readable reason when <see cref="Found"/> is false.</summary>
|
|
public string Reason { get; }
|
|
|
|
/// <summary>Creates a resolved target.</summary>
|
|
/// <param name="itemAddress">The ack item address.</param>
|
|
/// <returns>The target.</returns>
|
|
public static AlarmAckTarget For(string itemAddress) =>
|
|
new AlarmAckTarget(itemAddress ?? string.Empty, AlarmAckOutcome.Applied, string.Empty);
|
|
|
|
/// <summary>Creates a not-found result.</summary>
|
|
/// <param name="reason">Why the alarm was not found.</param>
|
|
/// <returns>The target.</returns>
|
|
public static AlarmAckTarget NotFound(string reason) =>
|
|
new AlarmAckTarget(string.Empty, AlarmAckOutcome.AlarmNotFound, reason);
|
|
|
|
/// <summary>Creates a not-ackable result.</summary>
|
|
/// <param name="reason">Why the alarm cannot be acknowledged over MXAccess.</param>
|
|
/// <returns>The target.</returns>
|
|
public static AlarmAckTarget NotAckable(string reason) =>
|
|
new AlarmAckTarget(string.Empty, AlarmAckOutcome.NotAckable, reason);
|
|
}
|
|
```
|
|
|
|
`AlarmAckResult.cs`:
|
|
```csharp
|
|
using System.Collections.Generic;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
|
|
/// <summary>Result of an alarm acknowledgement.</summary>
|
|
public sealed class AlarmAckResult
|
|
{
|
|
private static readonly IReadOnlyList<MxStatusProxy> NoStatuses = new MxStatusProxy[0];
|
|
|
|
private AlarmAckResult(AlarmAckOutcome outcome, int nativeStatus, string diagnostic, IReadOnlyList<MxStatusProxy>? statuses)
|
|
{
|
|
Outcome = outcome;
|
|
NativeStatus = nativeStatus;
|
|
Diagnostic = diagnostic;
|
|
Statuses = statuses ?? NoStatuses;
|
|
}
|
|
|
|
/// <summary>Gets the outcome.</summary>
|
|
public AlarmAckOutcome Outcome { get; }
|
|
|
|
/// <summary>Gets the native status: 0 for applied/unconfirmed, else the HRESULT or status detail.</summary>
|
|
public int NativeStatus { get; }
|
|
|
|
/// <summary>Gets a diagnostic message. Never contains credentials.</summary>
|
|
public string Diagnostic { get; }
|
|
|
|
/// <summary>Gets the OnWriteComplete statuses, empty when none was observed.</summary>
|
|
public IReadOnlyList<MxStatusProxy> Statuses { get; }
|
|
|
|
/// <summary>Creates a result.</summary>
|
|
/// <param name="outcome">The outcome.</param>
|
|
/// <param name="nativeStatus">The native status.</param>
|
|
/// <param name="diagnostic">A credential-free diagnostic.</param>
|
|
/// <param name="statuses">Observed write statuses.</param>
|
|
/// <returns>The result.</returns>
|
|
public static AlarmAckResult Create(
|
|
AlarmAckOutcome outcome,
|
|
int nativeStatus,
|
|
string diagnostic,
|
|
IReadOnlyList<MxStatusProxy>? statuses = null) =>
|
|
new AlarmAckResult(outcome, nativeStatus, diagnostic ?? string.Empty, statuses);
|
|
|
|
/// <summary>Creates the result for an unresolved ack target.</summary>
|
|
/// <param name="target">The unresolved target.</param>
|
|
/// <returns>The result.</returns>
|
|
public static AlarmAckResult FromTarget(AlarmAckTarget target) =>
|
|
new AlarmAckResult(target.Failure, -1, target.Reason, null);
|
|
}
|
|
```
|
|
|
|
`IAlarmAckWriter.cs`:
|
|
```csharp
|
|
using System;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
|
|
/// <summary>
|
|
/// Acknowledges an alarm by writing its MXAccess ack item (<c>AckMsg</c>)
|
|
/// after authenticating as the operator. Must be called on the worker STA.
|
|
/// </summary>
|
|
public interface IAlarmAckWriter : IDisposable
|
|
{
|
|
/// <summary>Authenticates <paramref name="ackOperator"/> and writes <paramref name="comment"/> to <paramref name="itemAddress"/>.</summary>
|
|
/// <param name="itemAddress">The ack item, e.g. <c>Obj.Alarm.AckMsg</c>.</param>
|
|
/// <param name="comment">The acknowledgement comment.</param>
|
|
/// <param name="ackOperator">The operator to authenticate as.</param>
|
|
/// <returns>The outcome.</returns>
|
|
AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Failing tests** `AlarmAckWriterTests.cs`. Build a `RecordingAckServer : IMxAccessServer` modelled on `LmxSubtagAlarmSourceTests.RecordingMxAccessServer` (copy that class and change: `Register` records the client name and returns 7; `AuthenticateUser` records `verifyUser` (NOT the password) into `Operations`, stores the password in `LastPassword`, returns `AuthenticateResult` or throws `AuthenticateException`; `Write` records `(itemHandle, value, userId)` and invokes `OnWrite`). Tests construct the writer through the internal seam `new AlarmAckWriter(server, completionCache, pumpStep: () => { }, timeout)`:
|
|
|
|
```csharp
|
|
public sealed class AlarmAckWriterTests
|
|
{
|
|
private static AlarmAckOperator Alice() =>
|
|
new AlarmAckOperator { UserName = "alice", Domain = "CORP", Password = "pw-secret" };
|
|
|
|
private static RepeatedField<MxStatusProxy> Rows(MxStatusCategory category, int detail) =>
|
|
new RepeatedField<MxStatusProxy> { new MxStatusProxy { Success = category == MxStatusCategory.Ok ? 1 : 0, Category = category, Detail = detail } };
|
|
|
|
[Fact]
|
|
public void Acknowledge_AuthenticatesThenWritesCommentWithOperatorUserId()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
|
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
|
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.Ok, 0));
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
|
|
|
|
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
|
|
|
|
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
|
|
Assert.Equal(0, result.NativeStatus);
|
|
Assert.Equal(new[] { "Register:MxGateway", "AuthenticateUser:CORP\\alice", "AddItem:T.A.AckMsg", "Advise", "Write:checked:5" }, server.Operations);
|
|
Assert.Equal("pw-secret", server.LastPassword);
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenWriteRejected_ReturnsDetailAndStatuses()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
|
MxAccessWriteCompletionCache cache = new MxAccessWriteCompletionCache();
|
|
server.OnWrite = (handle, item) => cache.Record(handle, item, Rows(MxStatusCategory.SecurityError, 1008));
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, cache, () => { }, TimeSpan.FromSeconds(1), "MxGateway");
|
|
|
|
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
|
|
|
|
Assert.Equal(AlarmAckOutcome.WriteRejected, result.Outcome);
|
|
Assert.Equal(1008, result.NativeStatus);
|
|
Assert.Single(result.Statuses);
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenNoCompletion_ReturnsUnconfirmed()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
|
|
|
|
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
|
|
|
|
Assert.Equal(AlarmAckOutcome.Unconfirmed, result.Outcome);
|
|
Assert.Equal(0, result.NativeStatus);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(true)]
|
|
[InlineData(false)]
|
|
public void Acknowledge_WhenAuthenticationFails_DoesNotWriteOrLeakPassword(bool throwComException)
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer
|
|
{
|
|
AuthenticateResult = 0,
|
|
AuthenticateException = throwComException ? new COMException("bad credentials", unchecked((int)0x80070005)) : null,
|
|
};
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(50), "MxGateway");
|
|
|
|
AlarmAckResult result = writer.Acknowledge("T.A.AckMsg", "checked", Alice());
|
|
|
|
Assert.Equal(AlarmAckOutcome.AuthenticationFailed, result.Outcome);
|
|
Assert.NotEqual(0, result.NativeStatus);
|
|
Assert.DoesNotContain(server.Operations, op => op.StartsWith("Write", StringComparison.Ordinal));
|
|
Assert.DoesNotContain("pw-secret", result.Diagnostic, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(server.Operations, op => op.Contains("pw-secret"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_Twice_ReusesItemHandle()
|
|
{
|
|
RecordingAckServer server = new RecordingAckServer { AuthenticateResult = 5 };
|
|
using AlarmAckWriter writer = new AlarmAckWriter(server, new MxAccessWriteCompletionCache(), () => { }, TimeSpan.FromMilliseconds(10), "MxGateway");
|
|
|
|
writer.Acknowledge("T.A.AckMsg", "one", Alice());
|
|
writer.Acknowledge("T.A.AckMsg", "two", Alice());
|
|
|
|
Assert.Equal(1, server.Operations.Count(op => op.StartsWith("AddItem", StringComparison.Ordinal)));
|
|
Assert.Equal(1, server.Operations.Count(op => op == "Advise"));
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmAckOperator_ToString_OmitsPassword()
|
|
{
|
|
Assert.DoesNotContain("pw-secret", Alice().ToString(), StringComparison.Ordinal);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Implement `AlarmAckWriter.cs`.**
|
|
|
|
```csharp
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Runtime.InteropServices;
|
|
using ArchestrA.MxAccess;
|
|
using Google.Protobuf.Collections;
|
|
using ZB.MOM.WW.MxGateway.Contracts;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Worker.Conversion;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
|
|
/// <summary>
|
|
/// Acknowledges alarms the way the AVEVA runtime records operator identity:
|
|
/// AuthenticateUser as the operator, then write the comment to the alarm's
|
|
/// <c>AckMsg</c> attribute with that user id. Owns its own
|
|
/// <c>LMXProxyServerClass</c> registered under the configured client name,
|
|
/// which the Historian records as the acknowledgement's OperatorNode.
|
|
/// Apartment-threaded: every call must run on the worker STA. The password
|
|
/// is passed to MXAccess only; it never reaches diagnostics.
|
|
/// </summary>
|
|
public sealed class AlarmAckWriter : IAlarmAckWriter
|
|
{
|
|
/// <summary>The ArchestrA alarm-primitive attribute whose write acknowledges the alarm.</summary>
|
|
public const string AckCommentAttribute = "AckMsg";
|
|
|
|
/// <summary>Default MXAccess client name when none is configured.</summary>
|
|
public const string DefaultClientName = "MxGateway";
|
|
|
|
private readonly IMxAccessComObjectFactory? factory;
|
|
private readonly MxAccessWriteCompletionCache completionCache;
|
|
private readonly Action pumpStep;
|
|
private readonly TimeSpan completionTimeout;
|
|
private readonly string clientName;
|
|
private readonly MxStatusProxyConverter statusConverter = new MxStatusProxyConverter();
|
|
private readonly Dictionary<string, int> itemHandles = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
private object? comObject;
|
|
private LMXProxyServerClass? comEventSource;
|
|
private IMxAccessServer? server;
|
|
private int serverHandle;
|
|
private bool disposed;
|
|
|
|
/// <summary>Creates a writer that registers its own MXAccess server on first use.</summary>
|
|
/// <param name="factory">Creates the <c>LMXProxyServerClass</c>.</param>
|
|
/// <param name="pumpStep">Drains the STA message queue (OnWriteComplete is delivered by pumping).</param>
|
|
/// <param name="completionTimeout">Bounded wait for OnWriteComplete.</param>
|
|
/// <param name="clientName">MXAccess client name (Historian OperatorNode).</param>
|
|
public AlarmAckWriter(
|
|
IMxAccessComObjectFactory factory,
|
|
Action pumpStep,
|
|
TimeSpan completionTimeout,
|
|
string? clientName)
|
|
{
|
|
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
|
this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep));
|
|
this.completionTimeout = completionTimeout;
|
|
this.clientName = string.IsNullOrWhiteSpace(clientName) ? DefaultClientName : clientName!;
|
|
completionCache = new MxAccessWriteCompletionCache();
|
|
}
|
|
|
|
/// <summary>Test seam: uses an already-constructed server and completion cache.</summary>
|
|
internal AlarmAckWriter(
|
|
IMxAccessServer server,
|
|
MxAccessWriteCompletionCache completionCache,
|
|
Action pumpStep,
|
|
TimeSpan completionTimeout,
|
|
string clientName)
|
|
{
|
|
this.server = server ?? throw new ArgumentNullException(nameof(server));
|
|
this.completionCache = completionCache ?? throw new ArgumentNullException(nameof(completionCache));
|
|
this.pumpStep = pumpStep ?? throw new ArgumentNullException(nameof(pumpStep));
|
|
this.completionTimeout = completionTimeout;
|
|
this.clientName = clientName;
|
|
serverHandle = server.Register(clientName);
|
|
}
|
|
|
|
/// <summary>Resolves the configured client name from the launcher environment.</summary>
|
|
/// <returns>The client name, or <see cref="DefaultClientName"/>.</returns>
|
|
public static string ResolveClientName()
|
|
{
|
|
string? value = Environment.GetEnvironmentVariable(WorkerLaunchEnvironment.AlarmAckClientName);
|
|
return string.IsNullOrWhiteSpace(value) ? DefaultClientName : value!.Trim();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(AlarmAckWriter));
|
|
if (string.IsNullOrWhiteSpace(itemAddress)) throw new ArgumentException("Ack item address is required.", nameof(itemAddress));
|
|
if (ackOperator is null) throw new ArgumentNullException(nameof(ackOperator));
|
|
|
|
IMxAccessServer mx = EnsureRegistered();
|
|
|
|
int userId;
|
|
try
|
|
{
|
|
userId = mx.AuthenticateUser(serverHandle, ackOperator.VerifyUser, ackOperator.Password ?? string.Empty);
|
|
}
|
|
catch (COMException ex)
|
|
{
|
|
return AlarmAckResult.Create(
|
|
AlarmAckOutcome.AuthenticationFailed,
|
|
ex.HResult,
|
|
$"Operator authentication failed for '{ackOperator.VerifyUser}' (HRESULT 0x{ex.HResult:X8}).");
|
|
}
|
|
|
|
if (userId == 0)
|
|
{
|
|
return AlarmAckResult.Create(
|
|
AlarmAckOutcome.AuthenticationFailed,
|
|
-1,
|
|
$"Operator authentication failed for '{ackOperator.VerifyUser}' (no user id returned).");
|
|
}
|
|
|
|
if (!itemHandles.TryGetValue(itemAddress, out int itemHandle))
|
|
{
|
|
itemHandle = mx.AddItem(serverHandle, itemAddress);
|
|
// MXAccess rejects writes to added-but-unadvised items with E_INVALIDARG.
|
|
mx.Advise(serverHandle, itemHandle);
|
|
itemHandles[itemAddress] = itemHandle;
|
|
}
|
|
|
|
pumpStep();
|
|
ulong baseline = completionCache.CurrentVersion(serverHandle, itemHandle);
|
|
mx.Write(serverHandle, itemHandle, comment ?? string.Empty, userId);
|
|
|
|
if (!completionCache.TryWaitForCompletion(
|
|
serverHandle,
|
|
itemHandle,
|
|
baseline,
|
|
DateTime.UtcNow + completionTimeout,
|
|
pumpStep,
|
|
out RepeatedField<MxStatusProxy> statuses))
|
|
{
|
|
return AlarmAckResult.Create(
|
|
AlarmAckOutcome.Unconfirmed,
|
|
0,
|
|
$"AckMsg write to '{itemAddress}' sent; OnWriteComplete not observed within {completionTimeout.TotalMilliseconds:F0} ms.");
|
|
}
|
|
|
|
foreach (MxStatusProxy status in statuses)
|
|
{
|
|
if (status.Category != MxStatusCategory.Ok)
|
|
{
|
|
return AlarmAckResult.Create(
|
|
AlarmAckOutcome.WriteRejected,
|
|
status.Detail != 0 ? status.Detail : -1,
|
|
$"AckMsg write to '{itemAddress}' rejected: category {status.Category}, detail {status.Detail}.",
|
|
statuses);
|
|
}
|
|
}
|
|
|
|
return AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty, statuses);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
if (disposed) return;
|
|
disposed = true;
|
|
|
|
if (comEventSource is not null)
|
|
{
|
|
try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ }
|
|
}
|
|
|
|
IMxAccessServer? mx = server;
|
|
if (mx is not null)
|
|
{
|
|
foreach (int itemHandle in itemHandles.Values)
|
|
{
|
|
try { mx.UnAdvise(serverHandle, itemHandle); } catch { /* best effort */ }
|
|
try { mx.RemoveItem(serverHandle, itemHandle); } catch { /* best effort */ }
|
|
}
|
|
|
|
try { mx.Unregister(serverHandle); } catch { /* best effort */ }
|
|
}
|
|
|
|
itemHandles.Clear();
|
|
server = null;
|
|
comEventSource = null;
|
|
object? toRelease = comObject;
|
|
comObject = null;
|
|
if (toRelease is not null && Marshal.IsComObject(toRelease))
|
|
{
|
|
try { Marshal.FinalReleaseComObject(toRelease); } catch { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
private IMxAccessServer EnsureRegistered()
|
|
{
|
|
if (server is not null) return server;
|
|
|
|
object created = factory!.Create() ?? throw new InvalidOperationException("MXAccess COM factory returned null.");
|
|
try
|
|
{
|
|
comObject = created;
|
|
if (created is LMXProxyServerClass comServer)
|
|
{
|
|
comEventSource = comServer;
|
|
comServer.OnWriteComplete += OnWriteComplete;
|
|
}
|
|
|
|
IMxAccessServer mx = new MxAccessComServer(created);
|
|
serverHandle = mx.Register(clientName);
|
|
server = mx;
|
|
return mx;
|
|
}
|
|
catch
|
|
{
|
|
if (comEventSource is not null)
|
|
{
|
|
try { comEventSource.OnWriteComplete -= OnWriteComplete; } catch { /* best effort */ }
|
|
comEventSource = null;
|
|
}
|
|
|
|
if (Marshal.IsComObject(created))
|
|
{
|
|
try { Marshal.FinalReleaseComObject(created); } catch { /* best effort */ }
|
|
}
|
|
|
|
comObject = null;
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] pVars)
|
|
{
|
|
RepeatedField<MxStatusProxy> rows = new RepeatedField<MxStatusProxy>();
|
|
rows.AddRange(statusConverter.ConvertMany(pVars));
|
|
completionCache.Record(hLMXServerHandle, phItemHandle, rows);
|
|
}
|
|
}
|
|
```
|
|
|
|
(Confirm `MxStatusProxyConverter`'s namespace and that `ConvertMany(Array?)` accepts `MXSTATUS_PROXY[]`; adjust the `using`. `MxAccessComObjectFactory` already returns `LMXProxyServerClass`.)
|
|
|
|
- [ ] **Step 4: Build and run** the Verify command on windev (push branch, `windev-worker-ci.ps1 -Sha <sha> -Mode test`, or the filtered `dotnet test` in the CI clone). Expected: all `AlarmAckWriterTests` pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmAck*.cs src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmAckWriter.cs src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmAckWriterTests.cs
|
|
git commit -m "feat(worker): acknowledge alarms via authenticated AckMsg write" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Consumers resolve the ack item instead of acking
|
|
|
|
**Goal:** Replace `IMxAccessAlarmConsumer.AcknowledgeByGuid/AcknowledgeByName` with `ResolveAckTarget/ResolveAckTargetByName`, implement them in the wnwrap, subtag and failover consumers, and delete the inert wnwrap ack consumer and the subtag write path.
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IMxAccessAlarmConsumer.cs`, `WnWrapAlarmConsumer.cs`, `SubtagAlarmConsumer.cs`, `FailoverAlarmConsumer.cs`, `ISubtagAlarmSource.cs`, `LmxSubtagAlarmSource.cs`
|
|
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/{WnWrapAlarmConsumerXmlTests,SubtagAlarmConsumerTests,FailoverAlarmConsumerTests,LmxSubtagAlarmSourceTests}.cs` and every test fake implementing the changed interfaces
|
|
|
|
**Acceptance Criteria:**
|
|
- [ ] `WnWrapAlarmConsumer.ResolveAckTarget(guid)` returns `TestMachine_001.TestAlarm001.AckMsg` for a snapshot record with provider `Galaxy` and that tag; `NotFound` for an unknown GUID; `NotAckable` for provider `InTouch`.
|
|
- [ ] `WnWrapAlarmConsumer.ResolveAckTargetByName("TestMachine_001.TestAlarm001", "Galaxy", "TestArea")` returns `TestMachine_001.TestAlarm001.AckMsg` without needing a snapshot; non-Galaxy provider → `NotAckable`.
|
|
- [ ] `SubtagAlarmConsumer` resolves synthetic GUIDs and names to the target's `AckCommentSubtag`; empty subtag → `NotAckable`; unknown → `NotFound`.
|
|
- [ ] `FailoverAlarmConsumer` delegates both to `ActiveChild`.
|
|
- [ ] wnwrap no longer creates the second `ackClient` consumer; `ISubtagAlarmSource.Write` and `LmxSubtagAlarmSource.Write` are gone; the solution builds with no warnings.
|
|
|
|
**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter "FullyQualifiedName~WnWrapAlarmConsumerXmlTests|FullyQualifiedName~SubtagAlarmConsumerTests|FullyQualifiedName~FailoverAlarmConsumerTests|FullyQualifiedName~LmxSubtagAlarmSourceTests"` (windev) → pass
|
|
|
|
**Steps:**
|
|
|
|
- [ ] **Step 1: Interface.** In `IMxAccessAlarmConsumer.cs` delete `AcknowledgeByGuid` and `AcknowledgeByName` (and their docs) and add:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Resolves the MXAccess item whose write acknowledges the alarm with the
|
|
/// given GUID (e.g. <c>Obj.Alarm.AckMsg</c>). Does not perform the ack.
|
|
/// </summary>
|
|
/// <param name="alarmGuid">The alarm GUID as reported by this consumer.</param>
|
|
/// <returns>The resolved target or the reason none exists.</returns>
|
|
AlarmAckTarget ResolveAckTarget(Guid alarmGuid);
|
|
|
|
/// <summary>Resolves the ack item for an alarm identified by name, provider and group.</summary>
|
|
/// <param name="alarmName">The tag/alarm name, e.g. <c>TestMachine_001.TestAlarm001</c>.</param>
|
|
/// <param name="providerName">The provider (literal <c>Galaxy</c> for ArchestrA).</param>
|
|
/// <param name="groupName">The area/group.</param>
|
|
/// <returns>The resolved target or the reason none exists.</returns>
|
|
AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName);
|
|
```
|
|
|
|
- [ ] **Step 2: Failing tests.** wnwrap (in `WnWrapAlarmConsumerXmlTests.cs`, using whatever seam the file already uses to load a snapshot — e.g. `FoldFetch`/`ApplySnapshotUpdate` via a fake client — or add `internal void ReplaceSnapshotForTests(IEnumerable<MxAlarmSnapshotRecord>)` under `syncRoot`):
|
|
|
|
```csharp
|
|
[Fact]
|
|
public void ResolveAckTarget_GalaxyRecord_ReturnsAckMsgItem()
|
|
{
|
|
Guid guid = Guid.NewGuid();
|
|
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
|
|
{
|
|
AlarmGuid = guid, ProviderName = "Galaxy", Group = "TestArea", TagName = "TestMachine_001.TestAlarm001", State = MxAlarmStateKind.UnackAlm,
|
|
});
|
|
|
|
AlarmAckTarget target = consumer.ResolveAckTarget(guid);
|
|
|
|
Assert.True(target.Found);
|
|
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg", target.ItemAddress);
|
|
Assert.Equal(AlarmAckOutcome.AlarmNotFound, consumer.ResolveAckTarget(Guid.NewGuid()).Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveAckTarget_NonGalaxyProvider_IsNotAckable()
|
|
{
|
|
Guid guid = Guid.NewGuid();
|
|
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot(new MxAlarmSnapshotRecord
|
|
{
|
|
AlarmGuid = guid, ProviderName = "InTouch", Group = "G", TagName = "Tag1", State = MxAlarmStateKind.UnackAlm,
|
|
});
|
|
|
|
Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTarget(guid).Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResolveAckTargetByName_Galaxy_ReturnsAckMsgItem()
|
|
{
|
|
using WnWrapAlarmConsumer consumer = CreateConsumerWithSnapshot();
|
|
|
|
Assert.Equal("TestMachine_001.TestAlarm001.AckMsg",
|
|
consumer.ResolveAckTargetByName("TestMachine_001.TestAlarm001", "Galaxy", "TestArea").ItemAddress);
|
|
Assert.Equal(AlarmAckOutcome.NotAckable, consumer.ResolveAckTargetByName("Tag1", "InTouch", "G").Failure);
|
|
}
|
|
```
|
|
|
|
Subtag (replace the existing `AcknowledgeBy*_WritesCommentToAckCommentSubtag` / `_UnknownGuid_ReturnsNonZero` / `_UnknownAlarm_ReturnsNonZero` / `_PrefixNameDoesNotFalseMatch` tests with resolve equivalents asserting `ItemAddress == target.AckCommentSubtag`, `Failure == AlarmNotFound` for unknown, `NotAckable` for empty `AckCommentSubtag`, and that a prefix name does not false-match). Failover: replace `Acknowledge_DelegatesToActiveChild` with `ResolveAckTarget_DelegatesToActiveChild` (primary before failover, standby after).
|
|
|
|
- [ ] **Step 3: wnwrap implementation.** Delete `AcknowledgeByGuid`/`AcknowledgeByName`, the `ackClient` field and its provisioning block in `Subscribe` (the "Provision a parallel COM consumer for ack calls" block) and its teardown in `Dispose`; update the class remarks that describe the two-consumer split. Add:
|
|
|
|
```csharp
|
|
private const string GalaxyProviderName = "Galaxy";
|
|
|
|
/// <inheritdoc />
|
|
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
|
MxAlarmSnapshotRecord? record;
|
|
lock (syncRoot)
|
|
{
|
|
latestSnapshot.TryGetValue(alarmGuid, out record);
|
|
}
|
|
|
|
if (record is null)
|
|
{
|
|
return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not in the current alarm snapshot.");
|
|
}
|
|
|
|
return ResolveGalaxyAckTarget(record.ProviderName, record.TagName);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
|
return ResolveGalaxyAckTarget(providerName, alarmName);
|
|
}
|
|
|
|
private static AlarmAckTarget ResolveGalaxyAckTarget(string? providerName, string? tagName)
|
|
{
|
|
if (!string.Equals(providerName, GalaxyProviderName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return AlarmAckTarget.NotAckable(
|
|
$"Alarm provider '{providerName}' is not a Galaxy provider; only Galaxy alarms can be acknowledged over MXAccess.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(tagName))
|
|
{
|
|
return AlarmAckTarget.NotFound("Alarm record has no tag name.");
|
|
}
|
|
|
|
return AlarmAckTarget.For(tagName + "." + AlarmAckWriter.AckCommentAttribute);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Subtag implementation.** Replace `AcknowledgeByGuid`/`AcknowledgeByName`/`WriteAckComment` with:
|
|
|
|
```csharp
|
|
/// <inheritdoc />
|
|
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(SubtagAlarmConsumer));
|
|
if (!referencesBySyntheticGuid.TryGetValue(alarmGuid, out string reference) ||
|
|
!targetsByReference.TryGetValue(reference, out AlarmSubtagTarget target))
|
|
{
|
|
return AlarmAckTarget.NotFound($"Alarm {alarmGuid} is not on the subtag watch list.");
|
|
}
|
|
|
|
return ToAckTarget(target);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(SubtagAlarmConsumer));
|
|
AlarmSubtagTarget? target = ResolveTargetByName(alarmName, providerName, groupName);
|
|
return target is null
|
|
? AlarmAckTarget.NotFound($"Alarm '{providerName}!{groupName}.{alarmName}' is not on the subtag watch list.")
|
|
: ToAckTarget(target);
|
|
}
|
|
|
|
private static AlarmAckTarget ToAckTarget(AlarmSubtagTarget target)
|
|
{
|
|
string ackItem = target.AckCommentSubtag ?? string.Empty;
|
|
return ackItem.Length == 0
|
|
? AlarmAckTarget.NotAckable($"Alarm '{target.AlarmFullReference}' has no ack-comment subtag configured.")
|
|
: AlarmAckTarget.For(ackItem);
|
|
}
|
|
```
|
|
|
|
Keep `Subscribe` advising the ack-comment subtag only if other code reads it; otherwise drop it from the advise list (the ack writer advises on its own server). Remove `Write` from `ISubtagAlarmSource` and `LmxSubtagAlarmSource` (and `LmxSubtagAlarmSourceTests.Write_*` tests and the subtag fake's `Write`).
|
|
|
|
- [ ] **Step 5: Failover** — replace the two ack methods:
|
|
|
|
```csharp
|
|
/// <inheritdoc />
|
|
public AlarmAckTarget ResolveAckTarget(Guid alarmGuid)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
|
|
return ActiveChild.ResolveAckTarget(alarmGuid);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public AlarmAckTarget ResolveAckTargetByName(string alarmName, string providerName, string groupName)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(FailoverAlarmConsumer));
|
|
return ActiveChild.ResolveAckTargetByName(alarmName, providerName, groupName);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Update every fake** implementing `IMxAccessAlarmConsumer` (`grep -rn ": IMxAccessAlarmConsumer" src/ZB.MOM.WW.MxGateway.Worker.Tests`) — replace the two ack members with `ResolveAckTarget`/`ResolveAckTargetByName` that record their arguments and return a configurable `AlarmAckTarget` (default `AlarmAckTarget.For("Fake.Alarm.AckMsg")`).
|
|
|
|
- [ ] **Step 7: Verify** (Task 5 must land before the worker project compiles end to end, because `AlarmDispatcher` still calls the removed members — do Tasks 4 and 5 in one windev build) and **commit**:
|
|
|
|
```bash
|
|
git add src/ZB.MOM.WW.MxGateway.Worker src/ZB.MOM.WW.MxGateway.Worker.Tests
|
|
git commit -m "refactor(worker): alarm consumers resolve the AckMsg item instead of acking" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Route acks through the writer and remove the managed path
|
|
|
|
**Goal:** Wire `AlarmAckWriter` through `AlarmCommandHandler` → `AlarmDispatcher` → `MxAccessCommandExecutor` with an `AlarmAckOperator` built from the command, map `AlarmAckResult` to the reply, and delete the dead managed/WNAL ack path.
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmDispatcher.cs`, `AlarmCommandHandler.cs`, `IAlarmCommandHandler.cs`, `MxAccessCommandExecutor.cs` (~894-990), `src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeSession.cs` (:79, :101, :1588), `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs` (handler factory call ~288)
|
|
- Delete: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AaManagedAlarmAckConsumer.cs`, `IManagedAlarmAckClient.cs`, `WnalNativeAckConsumer.cs`, `src/ZB.MOM.WW.MxGateway.Worker.Tests/Probes/AlarmManagedGuidAckLiveSmokeTests.cs`
|
|
- Test: `AlarmDispatcherTests.cs`, `AlarmCommandHandlerTests.cs`, `AlarmCommandExecutorTests.cs`, `MxAccessCommandExecutorTests.cs`
|
|
|
|
**Acceptance Criteria:**
|
|
- [ ] `AlarmDispatcher.Acknowledge(guid, comment, op)` resolves via the consumer and calls the writer with the resolved item; an unresolved target returns `AlarmAckResult.FromTarget` without calling the writer.
|
|
- [ ] `ExecuteAcknowledgeAlarm` builds `AlarmAckOperator{UserName=operator_user, Domain=operator_domain, Password=operator_password}`; maps `Applied`/`Unconfirmed` → `Ok`, `AlarmNotFound`/`NotAckable` → `InvalidRequest`, `AuthenticationFailed`/`WriteRejected` → `MxaccessFailure`; sets `Hresult` and `AcknowledgeAlarm.NativeStatus` to `result.NativeStatus`; copies `result.Statuses` into `reply.Statuses`; `DiagnosticMessage = result.Diagnostic`.
|
|
- [ ] A test with `operator_password = "pw-secret"` asserts the secret is absent from `DiagnosticMessage`, `ProtocolStatus.Message`, for every outcome.
|
|
- [ ] No references remain to `AaManagedAlarmAckConsumer`, `IManagedAlarmAckClient`, `WnalNativeAckConsumer`, `MXGATEWAY_USE_MANAGED_ALARM_ACK`, `IsSubtagActive` (`grep -rn` returns nothing under `src/`).
|
|
- [ ] Production wiring creates one `AlarmAckWriter(comFactory, () => new StaMessagePump().PumpPendingMessages(), MxAccessStaSession.ResolveWriteCompletionTimeout(), AlarmAckWriter.ResolveClientName())` per `AlarmCommandHandler`, disposed with the handler.
|
|
- [ ] x86 worker builds with 0 warnings; all `Worker.Tests` pass on windev.
|
|
|
|
**Verify:** on windev: `powershell -NoProfile -ExecutionPolicy Bypass -File C:\build\mxaccessgw-ci\scripts\ci\windev-worker-ci.ps1 -Sha <pushed sha> -Mode test` → `SUCCESS mode=test`, Failed: 0
|
|
|
|
**Steps:**
|
|
|
|
- [ ] **Step 1: Failing dispatcher tests** (replace the four managed tests and `FakeManagedAckClient` in `AlarmDispatcherTests.cs`):
|
|
|
|
```csharp
|
|
[Fact]
|
|
public void Acknowledge_ResolvesTargetThenWritesWithOperator()
|
|
{
|
|
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.For("T.A.AckMsg") };
|
|
FakeAckWriter writer = new FakeAckWriter();
|
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
|
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
|
|
Guid guid = Guid.NewGuid();
|
|
AlarmAckOperator op = new AlarmAckOperator { UserName = "alice", Password = "pw" };
|
|
|
|
AlarmAckResult result = dispatcher.Acknowledge(guid, "checked", op);
|
|
|
|
Assert.Equal(AlarmAckOutcome.Applied, result.Outcome);
|
|
Assert.Equal(guid, consumer.LastResolvedGuid);
|
|
Assert.Equal("T.A.AckMsg", writer.LastItemAddress);
|
|
Assert.Equal("checked", writer.LastComment);
|
|
Assert.Same(op, writer.LastOperator);
|
|
}
|
|
|
|
[Fact]
|
|
public void Acknowledge_WhenTargetNotFound_DoesNotWrite()
|
|
{
|
|
FakeAlarmConsumer consumer = new FakeAlarmConsumer { AckTarget = AlarmAckTarget.NotFound("gone") };
|
|
FakeAckWriter writer = new FakeAckWriter();
|
|
using AlarmDispatcher dispatcher = new AlarmDispatcher(
|
|
consumer, new MxAccessAlarmEventSink(new MxAccessEventQueue(), new MxAccessEventMapper()), SessionId, writer);
|
|
|
|
AlarmAckResult result = dispatcher.Acknowledge(Guid.NewGuid(), "c", new AlarmAckOperator { UserName = "alice" });
|
|
|
|
Assert.Equal(AlarmAckOutcome.AlarmNotFound, result.Outcome);
|
|
Assert.Null(writer.LastItemAddress);
|
|
}
|
|
|
|
private sealed class FakeAckWriter : IAlarmAckWriter
|
|
{
|
|
public string? LastItemAddress { get; private set; }
|
|
public string? LastComment { get; private set; }
|
|
public AlarmAckOperator? LastOperator { get; private set; }
|
|
public AlarmAckResult Result { get; set; } = AlarmAckResult.Create(AlarmAckOutcome.Applied, 0, string.Empty);
|
|
public bool Disposed { get; private set; }
|
|
|
|
public AlarmAckResult Acknowledge(string itemAddress, string comment, AlarmAckOperator ackOperator)
|
|
{
|
|
LastItemAddress = itemAddress;
|
|
LastComment = comment;
|
|
LastOperator = ackOperator;
|
|
return Result;
|
|
}
|
|
|
|
public void Dispose() => Disposed = true;
|
|
}
|
|
```
|
|
|
|
Add the matching `AcknowledgeByName` test (`consumer.LastResolvedName == ("TestMachine_001.TestAlarm001","Galaxy","TestArea")`).
|
|
|
|
- [ ] **Step 2: Failing executor tests** in `AlarmCommandExecutorTests.cs` (extend the existing fake `IAlarmCommandHandler` so `Acknowledge` returns a configurable `AlarmAckResult` and records the `AlarmAckOperator`):
|
|
|
|
```csharp
|
|
[Theory]
|
|
[InlineData(AlarmAckOutcome.Applied, ProtocolStatusCode.Ok)]
|
|
[InlineData(AlarmAckOutcome.Unconfirmed, ProtocolStatusCode.Ok)]
|
|
[InlineData(AlarmAckOutcome.AlarmNotFound, ProtocolStatusCode.InvalidRequest)]
|
|
[InlineData(AlarmAckOutcome.NotAckable, ProtocolStatusCode.InvalidRequest)]
|
|
[InlineData(AlarmAckOutcome.AuthenticationFailed, ProtocolStatusCode.MxaccessFailure)]
|
|
[InlineData(AlarmAckOutcome.WriteRejected, ProtocolStatusCode.MxaccessFailure)]
|
|
public async Task AcknowledgeAlarm_MapsOutcomeWithoutLeakingPassword(AlarmAckOutcome outcome, ProtocolStatusCode expected)
|
|
{
|
|
int native = outcome is AlarmAckOutcome.Applied or AlarmAckOutcome.Unconfirmed ? 0 : 1008;
|
|
FakeAlarmCommandHandler handler = new FakeAlarmCommandHandler
|
|
{
|
|
AckResult = AlarmAckResult.Create(outcome, native, "diagnostic for alice"),
|
|
};
|
|
MxCommandReply reply = await DispatchAsync(handler, new MxCommand
|
|
{
|
|
Kind = MxCommandKind.AcknowledgeAlarm,
|
|
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
|
|
{
|
|
AlarmGuid = Guid.NewGuid().ToString(),
|
|
Comment = "checked",
|
|
OperatorUser = "alice",
|
|
OperatorDomain = "CORP",
|
|
OperatorPassword = "pw-secret",
|
|
},
|
|
});
|
|
|
|
Assert.Equal(expected, reply.ProtocolStatus.Code);
|
|
Assert.Equal(native, reply.Hresult);
|
|
Assert.Equal(native, reply.AcknowledgeAlarm.NativeStatus);
|
|
Assert.Equal("alice", handler.LastOperator!.UserName);
|
|
Assert.Equal("CORP", handler.LastOperator.Domain);
|
|
Assert.Equal("pw-secret", handler.LastOperator.Password);
|
|
Assert.DoesNotContain("pw-secret", reply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("pw-secret", reply.ProtocolStatus.Message ?? string.Empty, StringComparison.Ordinal);
|
|
}
|
|
```
|
|
|
|
(Use the file's existing dispatch helper name; xUnit1030: no `.ConfigureAwait(false)` in the test body.) Add the same theory for `AcknowledgeAlarmByName`.
|
|
|
|
- [ ] **Step 3: `IAlarmCommandHandler`** — change the two ack members to:
|
|
|
|
```csharp
|
|
/// <summary>Acknowledges an alarm by GUID as <paramref name="ackOperator"/>.</summary>
|
|
AlarmAckResult Acknowledge(Guid alarmGuid, string comment, AlarmAckOperator ackOperator);
|
|
|
|
/// <summary>Acknowledges an alarm by name/provider/group as <paramref name="ackOperator"/>.</summary>
|
|
AlarmAckResult AcknowledgeByName(string alarmName, string providerName, string groupName, string comment, AlarmAckOperator ackOperator);
|
|
```
|
|
|
|
- [ ] **Step 4: `AlarmDispatcher`** — remove `managedAck`, the managed block in `Subscribe`, `IsSubtagActive`, the managed dispose; add `private readonly IAlarmAckWriter ackWriter;` and make the constructor `AlarmDispatcher(IMxAccessAlarmConsumer consumer, MxAccessAlarmEventSink sink, string sessionId, IAlarmAckWriter ackWriter)` (null → `ArgumentNullException`). The dispatcher does **not** dispose the writer (the handler owns it). Replace the ack methods:
|
|
|
|
```csharp
|
|
/// <summary>Acknowledges the alarm with the given GUID by writing its ack item as the operator.</summary>
|
|
/// <param name="alarmGuid">The alarm GUID.</param>
|
|
/// <param name="ackComment">The acknowledgement comment.</param>
|
|
/// <param name="ackOperator">The operator to authenticate as.</param>
|
|
/// <returns>The outcome.</returns>
|
|
public AlarmAckResult Acknowledge(Guid alarmGuid, string ackComment, AlarmAckOperator ackOperator)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
|
AlarmAckTarget target = consumer.ResolveAckTarget(alarmGuid);
|
|
return target.Found
|
|
? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
|
|
: AlarmAckResult.FromTarget(target);
|
|
}
|
|
|
|
/// <summary>Acknowledges the alarm identified by name/provider/group as the operator.</summary>
|
|
/// <param name="alarmName">The tag/alarm name.</param>
|
|
/// <param name="providerName">The provider.</param>
|
|
/// <param name="groupName">The group/area.</param>
|
|
/// <param name="ackComment">The acknowledgement comment.</param>
|
|
/// <param name="ackOperator">The operator to authenticate as.</param>
|
|
/// <returns>The outcome.</returns>
|
|
public AlarmAckResult AcknowledgeByName(
|
|
string alarmName, string providerName, string groupName, string ackComment, AlarmAckOperator ackOperator)
|
|
{
|
|
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
|
AlarmAckTarget target = consumer.ResolveAckTargetByName(alarmName, providerName, groupName);
|
|
return target.Found
|
|
? ackWriter.Acknowledge(target.ItemAddress, ackComment, ackOperator)
|
|
: AlarmAckResult.FromTarget(target);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: `AlarmCommandHandler`** — replace the `managedAckFactory` field/ctor param/doc with `Func<IAlarmAckWriter>? ackWriterFactory = null`; add `private IAlarmAckWriter? ackWriter;` and
|
|
|
|
```csharp
|
|
private IAlarmAckWriter GetOrCreateAckWriter()
|
|
{
|
|
if (ackWriter is not null) return ackWriter;
|
|
if (ackWriterFactory is not null)
|
|
{
|
|
ackWriter = ackWriterFactory() ?? throw new InvalidOperationException("Alarm ack writer factory returned null.");
|
|
return ackWriter;
|
|
}
|
|
|
|
if (comFactory is null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Alarm acknowledgement requires an IMxAccessComObjectFactory; the alarm command handler was constructed without one.");
|
|
}
|
|
|
|
ackWriter = new AlarmAckWriter(
|
|
comFactory,
|
|
() => new StaMessagePump().PumpPendingMessages(),
|
|
MxAccessStaSession.ResolveWriteCompletionTimeout(),
|
|
AlarmAckWriter.ResolveClientName());
|
|
return ackWriter;
|
|
}
|
|
```
|
|
|
|
In `Subscribe` pass `GetOrCreateAckWriter()` as the dispatcher's 4th argument (delete the managed block). Change `Acknowledge`/`AcknowledgeByName` to the new signatures (null comment → empty, null operator → `ArgumentNullException`). In `Dispose` dispose `ackWriter` after the dispatcher (`try { ackWriter?.Dispose(); } catch { }`). (`StaMessagePump.PumpPendingMessages` is `PeekMessage` on the calling thread, i.e. the STA when called from an ack.)
|
|
|
|
- [ ] **Step 6: Executor** — rewrite the try-blocks of `ExecuteAcknowledgeAlarm` and `ExecuteAcknowledgeAlarmByName` to:
|
|
|
|
```csharp
|
|
try
|
|
{
|
|
AlarmAckResult result = alarmCommandHandler.Acknowledge(
|
|
alarmGuid,
|
|
payload.Comment,
|
|
new AlarmAckOperator
|
|
{
|
|
UserName = payload.OperatorUser,
|
|
Domain = payload.OperatorDomain,
|
|
Password = payload.OperatorPassword,
|
|
});
|
|
return CreateAcknowledgeReply(command, result);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return CreateAlarmFailureReply(command, ex);
|
|
}
|
|
```
|
|
|
|
(by-name passes `payload.AlarmName, payload.ProviderName, payload.GroupName, payload.Comment, new AlarmAckOperator { … }`) and add:
|
|
|
|
```csharp
|
|
private MxCommandReply CreateAcknowledgeReply(StaCommand command, AlarmAckResult result)
|
|
{
|
|
MxCommandReply reply = CreateOkReply(command);
|
|
reply.Hresult = result.NativeStatus;
|
|
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload { NativeStatus = result.NativeStatus };
|
|
reply.Statuses.Add(result.Statuses);
|
|
reply.DiagnosticMessage = result.Diagnostic;
|
|
switch (result.Outcome)
|
|
{
|
|
case AlarmAckOutcome.AlarmNotFound:
|
|
case AlarmAckOutcome.NotAckable:
|
|
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.InvalidRequest, Message = result.Diagnostic };
|
|
break;
|
|
case AlarmAckOutcome.AuthenticationFailed:
|
|
case AlarmAckOutcome.WriteRejected:
|
|
reply.ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure, Message = result.Diagnostic };
|
|
break;
|
|
}
|
|
|
|
return reply;
|
|
}
|
|
```
|
|
|
|
(`Statuses` must be the repeated field on `MxCommandReply`; if `AddRange` is required on `RepeatedField`, use `reply.Statuses.AddRange(result.Statuses)`.) The only operator-derived text in diagnostics is `VerifyUser`; the password never enters `AlarmAckResult`.
|
|
|
|
- [ ] **Step 7: Delete the managed path** — `git rm` the four files; in `WorkerPipeSession.cs` remove `, managedAckFactory: AaManagedAlarmAckConsumer.EnvGatedFactory` from the three handler constructions; fix `AlarmCommandHandlerTests` constructions that passed `managedAckFactory`.
|
|
|
|
- [ ] **Step 8: Grep, push, verify on windev**
|
|
|
|
```bash
|
|
grep -rn "AaManagedAlarmAckConsumer\|IManagedAlarmAckClient\|WnalNativeAckConsumer\|MXGATEWAY_USE_MANAGED_ALARM_ACK\|IsSubtagActive\|AcknowledgeByGuid" src/ --include=*.cs
|
|
git push -u origin feat/ackmsg-as-operator
|
|
ssh windev 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\build\mxaccessgw-ci\scripts\ci\windev-worker-ci.ps1 -Sha <sha> -Mode test'
|
|
```
|
|
|
|
Expected: grep prints nothing; CI log ends `SUCCESS mode=test`, `Failed: 0`.
|
|
|
|
- [ ] **Step 9: Commit**
|
|
|
|
```bash
|
|
git add -A src/ZB.MOM.WW.MxGateway.Worker src/ZB.MOM.WW.MxGateway.Worker.Tests
|
|
git commit -m "feat(worker): route alarm acks through the operator-authenticated AckMsg writer" -m "Removes the inert managed/WNAL alarm-client ack path." -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Gateway forwards operator credentials and the ack client name
|
|
|
|
**Goal:** Forward `operator_password`/`operator_domain` from `AcknowledgeAlarmRequest` onto the worker ack commands, require `operator_user`, treat ack commands as credential-bearing in redaction, and add the `AckClientName` option conveyed to workers.
|
|
|
|
**Files:**
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` (`BuildAcknowledgeCommand` ~1172-1222)
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Grpc/MxAccessGatewayService.cs` (`AcknowledgeAlarm` ~187-207)
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs`, `GatewayOptionsValidator.cs` (`ValidateAlarms` ~473-515), `Workers/WorkerProcessLauncher.cs` (~214-224)
|
|
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Diagnostics/GatewayLogRedactor.cs` (:11-16), `GatewayLogRedactorSeam.cs`
|
|
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorAcknowledgeTests.cs` (create), `Gateway/Grpc/MxAccessGatewayServiceTests.cs`, `Configuration/GatewayOptionsValidatorTests.cs`, `Gateway/Workers/WorkerProcessLauncherTests.cs`, `Diagnostics/GatewayLogRedactorTests.cs`, `Diagnostics/GatewayLogRedactorSeamTests.cs`
|
|
|
|
**Acceptance Criteria:**
|
|
- [ ] A GUID-reference ack produces `AcknowledgeAlarmCommand{OperatorUser="alice", OperatorDomain="CORP", OperatorPassword="pw-secret"}`; a `Galaxy!TestArea.T.A` ack produces the by-name command with the same three fields.
|
|
- [ ] The gRPC handler returns `InvalidArgument` "operator_user is required." when `operator_user` is empty.
|
|
- [ ] `GatewayLogRedactor.IsCredentialBearingCommand("AcknowledgeAlarm")` and `("AcknowledgeAlarmByName")` are true; the seam redacts any property whose name contains `password` (case-insensitive).
|
|
- [ ] `MxGateway:Alarms:AckClientName` defaults to `"MxGateway"`, rejects values over 64 chars or containing control characters, and the launcher sets `MXGATEWAY_ALARM_ACK_CLIENT_NAME` to it.
|
|
- [ ] Gateway builds with 0 warnings; the filtered tests pass.
|
|
|
|
**Verify:** `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~GatewayAlarmMonitorAcknowledgeTests|FullyQualifiedName~MxAccessGatewayServiceTests|FullyQualifiedName~GatewayOptionsValidatorTests|FullyQualifiedName~WorkerProcessLauncherTests|FullyQualifiedName~GatewayLogRedactor"` → pass
|
|
|
|
**Steps:**
|
|
|
|
- [ ] **Step 1: Failing monitor test.** `BuildAcknowledgeCommand` is private; make it `internal static` (the test project already has `InternalsVisibleTo` for Server — confirm with `grep -rn InternalsVisibleTo src/ZB.MOM.WW.MxGateway.Server`) and test it directly:
|
|
|
|
```csharp
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
|
|
|
|
public sealed class GatewayAlarmMonitorAcknowledgeTests
|
|
{
|
|
[Fact]
|
|
public void BuildAcknowledgeCommand_GuidReference_ForwardsOperatorCredentials()
|
|
{
|
|
MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest
|
|
{
|
|
AlarmFullReference = "BCC47053-9542-4D65-BDAA-BCDEA6A32A73",
|
|
Comment = "checked",
|
|
OperatorUser = "alice",
|
|
OperatorDomain = "CORP",
|
|
OperatorPassword = "pw-secret",
|
|
}, out string? error);
|
|
|
|
Assert.Null(error);
|
|
Assert.Equal(MxCommandKind.AcknowledgeAlarm, command!.Kind);
|
|
Assert.Equal("alice", command.AcknowledgeAlarmCommand.OperatorUser);
|
|
Assert.Equal("CORP", command.AcknowledgeAlarmCommand.OperatorDomain);
|
|
Assert.Equal("pw-secret", command.AcknowledgeAlarmCommand.OperatorPassword);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildAcknowledgeCommand_NameReference_ForwardsOperatorCredentials()
|
|
{
|
|
MxCommand? command = GatewayAlarmMonitor.BuildAcknowledgeCommand(new AcknowledgeAlarmRequest
|
|
{
|
|
AlarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001",
|
|
OperatorUser = "alice",
|
|
OperatorPassword = "pw-secret",
|
|
}, out _);
|
|
|
|
Assert.Equal(MxCommandKind.AcknowledgeAlarmByName, command!.Kind);
|
|
Assert.Equal("TestMachine_001.TestAlarm001", command.AcknowledgeAlarmByNameCommand.AlarmName);
|
|
Assert.Equal("alice", command.AcknowledgeAlarmByNameCommand.OperatorUser);
|
|
Assert.Equal("pw-secret", command.AcknowledgeAlarmByNameCommand.OperatorPassword);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Implement** — in `BuildAcknowledgeCommand` add `string operatorPassword = request.OperatorPassword ?? string.Empty; string operatorDomain = request.OperatorDomain ?? string.Empty;` and in both command initializers set `OperatorDomain = operatorDomain, OperatorPassword = operatorPassword,` (replacing `OperatorDomain = string.Empty`). Keep `OperatorNode`/`OperatorFullName` empty (the Historian takes them from the authenticated account and the ack client name).
|
|
|
|
- [ ] **Step 3: Handler validation** — failing test in `MxAccessGatewayServiceTests.cs`:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task AcknowledgeAlarm_WithMissingOperatorUser_ThrowsInvalidArgument()
|
|
{
|
|
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
|
async () => await service.AcknowledgeAlarm(
|
|
new AcknowledgeAlarmRequest { AlarmFullReference = "Galaxy!Area.Tag" },
|
|
new TestServerCallContext()));
|
|
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
|
Assert.Contains("operator_user", exception.Status.Detail, StringComparison.Ordinal);
|
|
}
|
|
```
|
|
|
|
then add after the reference check in the handler:
|
|
|
|
```csharp
|
|
if (string.IsNullOrWhiteSpace(request.OperatorUser))
|
|
{
|
|
throw new RpcException(new Status(StatusCode.InvalidArgument, "operator_user is required."));
|
|
}
|
|
```
|
|
|
|
Update existing ack tests that omit `OperatorUser` (e.g. `AcknowledgeAlarm_WithMissingAlarmReference_ThrowsInvalidArgument` already sets it).
|
|
|
|
- [ ] **Step 4: Redaction** — add `"AcknowledgeAlarm"` and `"AcknowledgeAlarmByName"` to `SensitiveCommandMethods` with a comment ("carry operator_password"); in `GatewayLogRedactorSeam.Redact` add name-based redaction:
|
|
|
|
```csharp
|
|
foreach (string key in properties.Keys.ToList())
|
|
{
|
|
if (key.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0 && properties[key] is not null)
|
|
{
|
|
properties[key] = GatewayLogRedactor.RedactedValue;
|
|
}
|
|
}
|
|
```
|
|
|
|
(use the existing redacted-value constant name from `GatewayLogRedactor`). Tests: `IsCredentialBearingCommand_AckCommands_ReturnsTrue` and `Redact_PasswordNamedProperty_IsRedacted` (`{"OperatorPassword": "pw-secret"}` → `[redacted]`).
|
|
|
|
- [ ] **Step 5: AckClientName option** — in `AlarmsOptions.cs`:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// MXAccess client name the alarm worker's ack writer registers with. The
|
|
/// AVEVA Historian records it as each acknowledgement's OperatorNode.
|
|
/// Conveyed to workers via MXGATEWAY_ALARM_ACK_CLIENT_NAME.
|
|
/// </summary>
|
|
public string AckClientName { get; init; } = "MxGateway";
|
|
```
|
|
|
|
validator (in `ValidateAlarms`): fail when empty/whitespace, longer than 64, or `AckClientName.Any(char.IsControl)` with message `"MxGateway:Alarms:AckClientName must be 1-64 printable characters."`; launcher: `startInfo.Environment[WorkerLaunchEnvironment.AlarmAckClientName] = _alarmsOptions.AckClientName;`. Tests: validator rejects `""` and a 65-char name, accepts default; launcher test asserts the env var equals the configured name (follow the existing `MXGATEWAY_ALARM_POLL_INTERVAL_MS` launcher test).
|
|
|
|
- [ ] **Step 6: Build + Verify** — `dotnet build src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj` (0 warnings) then the Verify command → pass.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/ZB.MOM.WW.MxGateway.Server src/ZB.MOM.WW.MxGateway.Tests
|
|
git commit -m "feat(gateway): forward operator credentials on alarm acknowledgement" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: .NET client and CLI
|
|
|
|
**Goal:** Expose the new fields in the .NET client CLI with safe password input and make failures never echo the password.
|
|
|
|
**Files:**
|
|
- Modify: `clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs` (:191-204), `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs` (:176-188, :1526-1545, :2468), `clients/dotnet/README.md` (:144-149, :294)
|
|
- Test: `clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientAlarmsTests.cs`, `MxGatewayClientCliTests.cs`
|
|
|
|
**Acceptance Criteria:**
|
|
- [ ] `AcknowledgeAlarmAsync` doc mentions operator credentials; on failure the thrown exception is passed through `MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword)`.
|
|
- [ ] CLI `acknowledge-alarm` accepts `--operator`, `--operator-domain`, `--operator-password`, `--operator-password-env` (default env `MXGATEWAY_OPERATOR_PASSWORD`); the request carries them; the password is never printed and is added to the CLI error-redaction list.
|
|
- [ ] Tests assert the request fields and that a failing call's message does not contain the password.
|
|
|
|
**Verify:** `dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx && dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests --filter "FullyQualifiedName~MxGatewayClientAlarmsTests|FullyQualifiedName~MxGatewayClientCliTests"` → pass
|
|
|
|
**Steps:**
|
|
|
|
- [ ] **Step 1: Failing tests** — extend `AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply` to set `OperatorPassword = "pw-secret", OperatorDomain = "CORP"` and assert they reach `call.Request`; add a test where the fake transport throws `RpcException(new Status(StatusCode.Internal, "boom pw-secret"))` and assert the resulting exception's `Message` lacks `pw-secret`. CLI: extend `RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply` with `--operator-domain CORP --operator-password-env MXGW_TEST_OPPW` (set the env var in the test) and assert `request.OperatorPassword` and that stdout lacks the password.
|
|
- [ ] **Step 2: Implement** — client:
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Acknowledges an alarm as the operator. The gateway authenticates
|
|
/// <see cref="AcknowledgeAlarmRequest.OperatorUser"/> with
|
|
/// <see cref="AcknowledgeAlarmRequest.OperatorPassword"/> and writes the
|
|
/// comment to the alarm's AckMsg attribute, so the alarm history records
|
|
/// the operator. The password is redacted from any surfaced error.
|
|
/// </summary>
|
|
public async Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
|
AcknowledgeAlarmRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
ThrowIfDisposed();
|
|
try
|
|
{
|
|
return await ExecuteSafeUnaryAsync(
|
|
token => _transport.AcknowledgeAlarmAsync(request, CreateCallOptions(token)),
|
|
cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (MxGatewayException ex) when (request.OperatorPassword.Length > 0)
|
|
{
|
|
throw MxGatewaySecretRedaction.Redacted(ex, request.OperatorPassword);
|
|
}
|
|
}
|
|
```
|
|
|
|
CLI builder: `OperatorDomain = arguments.GetOptional("operator-domain") ?? string.Empty, OperatorPassword = ResolveOperatorPassword(arguments),` where `ResolveOperatorPassword` mirrors `TryResolveVerifyUserPassword` with option `operator-password`, env option `operator-password-env`, default env `MXGATEWAY_OPERATOR_PASSWORD`; add it to the `MxGatewayCliSecretRedactor.Redact(...)` list; update usage string to `mxgw-dotnet acknowledge-alarm --reference <ref> --operator <user> [--operator-domain <dom>] [--operator-password <pw> | --operator-password-env <VAR>] [--comment <text>] [--json]`.
|
|
- [ ] **Step 3: README** — replace the alarms sentence with "…and `AcknowledgeAlarmAsync` (acknowledges as the operator: the gateway authenticates `OperatorUser`/`OperatorPassword` and writes the comment to the alarm's `AckMsg`, so the Historian records the operator)"; update the CLI example to `--operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD` and note the password is never echoed.
|
|
- [ ] **Step 4: Verify** → pass. **Step 5: Commit** `git add clients/dotnet && git commit -m "feat(dotnet-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"`
|
|
|
|
---
|
|
|
|
### Task 8: Python client and CLI
|
|
|
|
**Goal:** Regenerate Python bindings and add operator credential options to the CLI with redaction.
|
|
|
|
**Files:**
|
|
- Regenerate: `clients/python/src/zb_mom_ww_mxgateway/generated/*_pb2*.py` via `clients/python/generate-proto.ps1` (grpcio-tools **1.80.0**)
|
|
- Modify: `clients/python/src/zb_mom_ww_mxgateway/client.py` (:147-161), `src/zb_mom_ww_mxgateway_cli/commands.py` (:527-539, :1156-1166), `clients/python/README.md` (:111-116, :460)
|
|
- Test: `clients/python/tests/test_alarms.py`, `tests/test_review_findings_022_to_026.py`
|
|
|
|
**Acceptance Criteria:**
|
|
- [ ] Regenerated `_pb2.py` keeps `# Protobuf Python Version: 6.31.1` and only contains the real descriptor delta.
|
|
- [ ] `acknowledge_alarm` redacts `request.operator_password` from raised errors (`redact_secret`).
|
|
- [ ] CLI accepts `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`); password added to `secrets=`.
|
|
- [ ] README example still parses in `test_review_findings_022_to_026.py`.
|
|
|
|
**Verify:** `cd clients/python && python -m pytest tests/test_alarms.py tests/test_review_findings_022_to_026.py tests/test_cli.py -q` → pass
|
|
|
|
**Steps:**
|
|
|
|
- [ ] **Step 1: Regenerate** — `cd clients/python && python -m pip install "grpcio-tools==1.80.0" && pwsh -File generate-proto.ps1`; `git diff --stat src/zb_mom_ww_mxgateway/generated` shows only `mxaccess_gateway_pb2.py`/`.pyi` changes.
|
|
- [ ] **Step 2: Failing tests** — in `test_alarms.py` set `operator_password="pw-secret", operator_domain="CORP"` on the request and assert `captured_request.operator_password == "pw-secret"`; add `test_acknowledge_alarm_error_redacts_operator_password` where the fake stub raises an `RpcError` whose details contain `pw-secret` and assert `"pw-secret" not in str(excinfo.value)`. CLI: extend `test_cli_acknowledge_alarm_happy_path` with `--operator-domain CORP --operator-password-env MXGW_TEST_OPPW` (monkeypatch env) and assert the request field and that output lacks the secret.
|
|
- [ ] **Step 3: Implement** — in `client.py`:
|
|
|
|
```python
|
|
async def acknowledge_alarm(
|
|
self,
|
|
request: pb.AcknowledgeAlarmRequest,
|
|
) -> pb.AcknowledgeAlarmReply:
|
|
"""Acknowledge an alarm as the operator.
|
|
|
|
The gateway authenticates ``operator_user`` with ``operator_password`` and
|
|
writes the comment to the alarm's ``AckMsg``, so the alarm history records
|
|
the operator. The password is redacted from any raised error.
|
|
"""
|
|
try:
|
|
reply = await self._unary("acknowledge alarm", self.raw_stub.AcknowledgeAlarm, request)
|
|
ensure_protocol_success("acknowledge alarm", reply.protocol_status, reply)
|
|
return reply
|
|
except MxGatewayError as error:
|
|
if request.operator_password:
|
|
raise type(error)(redact_secret(str(error), [request.operator_password])) from None
|
|
raise
|
|
```
|
|
|
|
(Use the module's actual base error class name and import `redact_secret` from `.auth`; if error types take structured args, mirror the session's `_invoke_redacted` approach instead.) CLI: add `@click.option("--operator-domain", default="", ...)`, `@click.option("--operator-password", default=None, ...)`, `@click.option("--operator-password-env", default="MXGATEWAY_OPERATOR_PASSWORD", ...)`; resolve with a helper modelled on `_resolve_password`; set `operator_domain=`, `operator_password=` in the request; pass `secrets=_secrets(kwargs) + [password]`.
|
|
- [ ] **Step 4: README** — same wording as Task 7; CLI example adds `--operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD`.
|
|
- [ ] **Step 5: Verify** → pass. **Step 6: Commit** `git add clients/python && git commit -m "feat(python-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"`
|
|
|
|
---
|
|
|
|
### Task 9: Go client and CLI
|
|
|
|
**Goal:** Regenerate Go bindings and add operator credential flags to `mxgw-go acknowledge-alarm` with error redaction.
|
|
|
|
**Files:**
|
|
- Regenerate: `clients/go/internal/generated/mxaccess_gateway.pb.go`, `mxaccess_gateway_grpc.pb.go` via `clients/go/generate-proto.ps1`
|
|
- Modify: `clients/go/mxgateway/alarms.go` (:15-32), `clients/go/cmd/mxgw-go/main.go` (:1122-1162, usage :1434), `clients/go/README.md` (:141-146, :427)
|
|
- Test: `clients/go/mxgateway/alarms_test.go`
|
|
|
|
**Acceptance Criteria:**
|
|
- [ ] `AcknowledgeAlarm` wraps errors with `redactSecrets(err, req.GetOperatorPassword())` when the password is non-empty.
|
|
- [ ] CLI flags `-operator-domain`, `-operator-password`, `-operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`) populate the request.
|
|
- [ ] `gofmt -l .` prints nothing; tests pass.
|
|
|
|
**Verify:** `cd clients/go && gofmt -l . && go build ./... && go test ./...` → no gofmt output, ok
|
|
|
|
**Steps:**
|
|
|
|
- [ ] **Step 1: Regenerate** — `cd clients/go && pwsh -File generate-proto.ps1` (needs `protoc-gen-go v1.36.11`, `protoc-gen-go-grpc 1.6.2`).
|
|
- [ ] **Step 2: Failing test** in `alarms_test.go`: send `OperatorPassword: "pw-secret", OperatorDomain: "CORP"` and assert `fake.acknowledgeRequest.GetOperatorPassword() == "pw-secret"`; add `TestAcknowledgeAlarmRedactsPasswordFromError` where the fake returns `status.Error(codes.Internal, "boom pw-secret")` and assert `!strings.Contains(err.Error(), "pw-secret")`.
|
|
- [ ] **Step 3: Implement** in `alarms.go`:
|
|
|
|
```go
|
|
reply, err := c.raw.AcknowledgeAlarm(callCtx, req)
|
|
if err != nil {
|
|
return nil, redactSecrets(&GatewayError{Op: "acknowledge alarm", Err: err}, req.GetOperatorPassword())
|
|
}
|
|
if err := EnsureProtocolSuccess("acknowledge alarm", reply.GetProtocolStatus(), nil); err != nil {
|
|
return reply, redactSecrets(err, req.GetOperatorPassword())
|
|
}
|
|
```
|
|
|
|
(Confirm `redactSecrets` returns the error unchanged for empty secrets.) CLI: add the three flags; resolve password like the `authenticate-user` `-password/-password-env` code at main.go:449-503; set `OperatorDomain`, `OperatorPassword`; update usage.
|
|
- [ ] **Step 4: README** — wording as Task 7; CLI table row "Acknowledge an alarm as an operator (`-operator`, `-operator-password-env`)".
|
|
- [ ] **Step 5: Verify** → pass. **Step 6: Commit** `git add clients/go && git commit -m "feat(go-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"`
|
|
|
|
---
|
|
|
|
### Task 10: Rust client and CLI
|
|
|
|
**Goal:** Make the Rust workspace compile against the new fields, add CLI credential options, and keep the password out of `Debug` output and errors.
|
|
|
|
**Files:**
|
|
- Modify: `clients/rust/src/client.rs` (:335-346), `clients/rust/crates/mxgw-cli/src/main.rs` (:370-381, :1079-1095), `clients/rust/tests/client_behavior.rs` (:219-235, ~:1041-1079, :1423-1440), `clients/rust/README.md` (:81, :123-126), `clients/rust/RustClientDesign.md` (:160, :349)
|
|
- (Vendored proto already updated in Task 1.)
|
|
|
|
**Acceptance Criteria:**
|
|
- [ ] All `AcknowledgeAlarmRequest { … }` literals include `operator_password`/`operator_domain` (or `..Default::default()`).
|
|
- [ ] `acknowledge_alarm` attaches the password via `MxAccessError::with_secrets` (or equivalent) so `{:?}` of a returned error lacks it.
|
|
- [ ] CLI `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`).
|
|
- [ ] Stale README example (`--session-id`, `--alarm-reference`) fixed to `--reference … --operator …`.
|
|
- [ ] fmt/check/test/clippy clean.
|
|
|
|
**Verify:** `cd clients/rust && cargo fmt --all --check && cargo check --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings` → all succeed
|
|
|
|
**Steps:**
|
|
|
|
- [ ] **Step 1: Fix literals** — add `operator_password: String::new(), operator_domain: String::new(),` to the two test literals and the CLI literal (CLI sets real values).
|
|
- [ ] **Step 2: Failing test** in `client_behavior.rs`: send `operator_password: "pw-secret".to_owned()` with a fake that returns `Status::internal("boom pw-secret")`; assert `!format!("{err:?}").contains("pw-secret")` and `!err.to_string().contains("pw-secret")`.
|
|
- [ ] **Step 3: Implement** in `client.rs`:
|
|
|
|
```rust
|
|
pub async fn acknowledge_alarm(
|
|
&self,
|
|
request: AcknowledgeAlarmRequest,
|
|
) -> Result<AcknowledgeAlarmReply, Error> {
|
|
let secret = request.operator_password.clone();
|
|
let mut client = self.inner.clone();
|
|
let result = async {
|
|
let response = client.acknowledge_alarm(self.unary_request(request)).await?;
|
|
let reply = response.into_inner();
|
|
ensure_protocol_success("acknowledge alarm", reply.protocol_status.as_ref())?;
|
|
Ok(reply)
|
|
}
|
|
.await;
|
|
result.map_err(|error: Error| if secret.is_empty() { error } else { error.with_secrets(&[secret.as_str()]) })
|
|
}
|
|
```
|
|
|
|
(Use the crate's actual secret-attachment API name from `src/error.rs`; if `with_secrets` lives on `MxAccessError` only, add an `Error::redact_secrets(self, &[&str]) -> Error` helper that scrubs `Display`/`Debug` text, with a unit test.) CLI: add clap args `#[arg(long, default_value = "")] operator_domain`, `#[arg(long)] operator_password: Option<String>`, `#[arg(long, default_value = "MXGATEWAY_OPERATOR_PASSWORD")] operator_password_env: String`; resolve like `resolve_verify_user_password`.
|
|
- [ ] **Step 4: Docs** — README/RustClientDesign wording as Task 7 and the corrected CLI example.
|
|
- [ ] **Step 5: Verify** → pass. **Step 6: Commit** `git add clients/rust && git commit -m "feat(rust-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"`
|
|
|
|
---
|
|
|
|
### Task 11: Java client and CLI
|
|
|
|
**Goal:** Regenerate Java bindings and add operator credential options to the Java CLI with redaction.
|
|
|
|
**Files:**
|
|
- Regenerate: `clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java` (`gradle :zb-mom-ww-mxgateway-client:generateProto`) — **commit it** (real proto change)
|
|
- Modify: `clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java` (:280-306), `clients/java/zb-mom-ww-mxgateway-cli/src/main/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCli.java` (:1814-1846), `clients/java/README.md` (:114-118, :420)
|
|
- Test: `clients/java/zb-mom-ww-mxgateway-cli/src/test/java/com/zb/mom/ww/mxgateway/cli/MxGatewayCliTests.java` (:706, :758)
|
|
|
|
**Acceptance Criteria:**
|
|
- [ ] `acknowledgeAlarm` rethrows failures with `MxGatewaySecrets.redactExact(message, request.getOperatorPassword())` applied when the password is non-empty.
|
|
- [ ] CLI `--operator-domain`, `--operator-password`, `--operator-password-env` (default `MXGATEWAY_OPERATOR_PASSWORD`, picocli `arity`/`interactive` not required).
|
|
- [ ] `readmeDocumentedAcknowledgeAlarmExampleParsesCleanly` updated to the new README tokens and passes; `checkGeneratedClean` passes after commit.
|
|
|
|
**Verify:** `cd clients/java && JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test :zb-mom-ww-mxgateway-client:checkGeneratedClean` → BUILD SUCCESSFUL
|
|
|
|
**Steps:**
|
|
|
|
- [ ] **Step 1: Regenerate** — `cd clients/java && JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle :zb-mom-ww-mxgateway-client:generateProto`; `git diff --stat` shows `MxaccessGateway.java` with the new fields.
|
|
- [ ] **Step 2: Failing CLI test** — extend `acknowledgeAlarmCommandForwardsOptionsAndPrintsReply` with `"--operator-domain", "CORP", "--operator-password", "pw-secret"` and assert `factory.client.lastAcknowledgeAlarmRequest.getOperatorPassword()` equals it and `run.output()` does not contain `pw-secret`.
|
|
- [ ] **Step 3: Implement** — CLI options and `.setOperatorDomain(operatorDomain).setOperatorPassword(resolveOperatorPassword())` where `resolveOperatorPassword()` returns the option value or `System.getenv(operatorPasswordEnv)` or `""`; client `acknowledgeAlarm` catch block:
|
|
|
|
```java
|
|
} catch (RuntimeException error) {
|
|
MxGatewayException mapped = error instanceof MxGatewayException gatewayError
|
|
? gatewayError
|
|
: MxGatewayErrors.fromGrpc("acknowledge alarm", error);
|
|
String secret = request.getOperatorPassword();
|
|
if (secret.isEmpty()) {
|
|
throw mapped;
|
|
}
|
|
throw MxGatewayErrors.redacted(mapped, secret);
|
|
}
|
|
```
|
|
|
|
(If no `MxGatewayErrors.redacted` exists, add one that rebuilds the exception with `MxGatewaySecrets.redactExact(message, secret)` — mirror `MxGatewaySession.rebuildRedacted`.)
|
|
- [ ] **Step 4: README** — wording as Task 7; CLI example adds `--operator operator1 --operator-password-env MXGATEWAY_OPERATOR_PASSWORD`; update the README-pinned test tokens.
|
|
- [ ] **Step 5: Verify** → BUILD SUCCESSFUL. **Step 6: Commit** `git add clients/java && git commit -m "feat(java-client): operator credentials on acknowledge-alarm" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"`
|
|
|
|
---
|
|
|
|
### Task 12: Documentation
|
|
|
|
**Goal:** Describe the operator-authenticated AckMsg ack everywhere the old alarm-client ack is described, add the credential to the never-log rules, and record the decision.
|
|
|
|
**Files:**
|
|
- Modify: `gateway.md` (:143-144, :224-237, :1031-1032), `docs/Grpc.md` (:99-101, validation table :219), `docs/Contracts.md` (add ack/credential paragraph near :69-71), `docs/DesignDecisions.md` (:704-713 + new decision section), `docs/AlarmClientDiscovery.md` (§2-§4 :738-843, :997-1019), `docs/AlarmProbeFindings.md` (new "Fourth attempt (2026-10-19)" section; update Outcome table), `docs/GatewayConfiguration.md` (:449-455 replace; add `AckClientName` row in the Alarm Options table), `docs/Diagnostics.md` (:7, :66-83), `docs/Authorization.md` (:128 note), `CLAUDE.md` (:129)
|
|
|
|
**Acceptance Criteria:**
|
|
- [ ] No doc says acks use `AlarmAckByName`/`AlarmAckByGUID` in current tense or mentions `MXGATEWAY_USE_MANAGED_ALARM_ACK` / `AaManagedAlarmAckConsumer` (`grep -rn` over `docs gateway.md CLAUDE.md` returns only historical, dated finding sections).
|
|
- [ ] Grpc.md documents `operator_user` required, `operator_password`/`operator_domain`, the status mapping (Ok / InvalidRequest / MxaccessFailure) and that Historian records the operator; validation table has the `operator_user` row.
|
|
- [ ] GatewayConfiguration.md documents `MxGateway:Alarms:AckClientName` (default `MxGateway`, = Historian OperatorNode).
|
|
- [ ] Diagnostics.md and CLAUDE.md list `AcknowledgeAlarm` `operator_password` among never-log credentials; DesignDecisions.md has "Alarm acknowledgement writes AckMsg as the authenticated operator" with the why (alarm-client acks inert headless; AckMsg write historizes the authenticated identity).
|
|
- [ ] AlarmProbeFindings.md's new section records: authenticated AckMsg write acks and logs Operator/Domain/FullName/Node; alarm-client acks inert in every variant (secured and free-access, operator names, consumer names, pump).
|
|
|
|
**Verify:** `grep -rn "MXGATEWAY_USE_MANAGED_ALARM_ACK\|AaManagedAlarmAckConsumer" docs gateway.md CLAUDE.md` → only lines inside dated historical sections; manual read of the edited sections.
|
|
|
|
**Steps:**
|
|
|
|
- [ ] **Step 1: gateway.md** — replace :224-237 with:
|
|
|
|
```markdown
|
|
**Acknowledge:** the gateway acknowledges an alarm by writing the operator's
|
|
comment to the alarm attribute's `AckMsg` (subtag mode: the configured
|
|
ack-comment subtag) over MXAccess, after `AuthenticateUser` as the requesting
|
|
operator (`operator_user`, `operator_password`, optional `operator_domain`).
|
|
The AVEVA runtime treats that write as the acknowledgement and historizes it
|
|
with the authenticated account's name, domain and full name; the Historian's
|
|
OperatorNode is the ack writer's MXAccess client name
|
|
(`MxGateway:Alarms:AckClientName`). The worker waits a bounded time for
|
|
`OnWriteComplete`: an OK status is reported as success, a non-OK status as
|
|
`MxaccessFailure` with the status detail, and no completion as success with a
|
|
diagnostic noting the write was sent but unconfirmed. Alarm-client ack calls
|
|
(`AlarmAckByName`/`AlarmAckByGUID`, the managed `AlarmClient`, WNAL) are not
|
|
used: live tests showed a headless consumer's acks are accepted and silently
|
|
dropped (see `docs/AlarmProbeFindings.md`).
|
|
```
|
|
|
|
and add "`AcknowledgeAlarm`'s `operator_password`" to the credential sentence at :1031-1032.
|
|
- [ ] **Step 2: Grpc.md** — rewrite :99-101 with the request fields, required `operator_user`, mechanism (same as Step 1, shorter), outcome mapping table (`Applied`/`Unconfirmed` → `OK` + `hresult 0`; alarm not found / not ackable → `INVALID_REQUEST`; authentication failed / write rejected → `MXACCESS_FAILURE` + `hresult`), and add validation row `| AcknowledgeAlarm | operator_user must be non-empty. | InvalidArgument |`.
|
|
- [ ] **Step 3: DesignDecisions.md** — replace :704-713 ("Acknowledge via ack-comment write") so it states both modes write AckMsg as the operator and fix the stale "empty by default" (default is `AckMsg`); add the new decision section with rationale and the credential-handling rule.
|
|
- [ ] **Step 4: AlarmClientDiscovery.md** — in §2-§4 mark the two-consumer split and by-name ack as historical ("Superseded 2026-10-19: acks now write AckMsg as the operator; see AlarmProbeFindings.md"), delete the "Opt-in managed GUID ack" flag instructions but keep its findings table under a dated heading; update "Acknowledge in subtag mode" (:997-1019) to say the write is authenticated and goes through the ack writer.
|
|
- [ ] **Step 5: AlarmProbeFindings.md** — add "## Fourth attempt (2026-10-19): authenticated AckMsg write" with the Historian rows from memory `project_managed_alarm_ack_live_result.md` (Administrator/ArchestrA/mxa, NULL-operator unauthenticated row) and the inert-variants list; update the Outcome table's acknowledge row to "Answered — AckMsg write as the operator".
|
|
- [ ] **Step 6: GatewayConfiguration.md** — replace :449-455 with the `AckClientName` explanation and add the table row `| MxGateway:Alarms:AckClientName | MxGateway | MXAccess client name the alarm worker's ack writer registers with; the Historian records it as each acknowledgement's OperatorNode. 1-64 printable characters. Conveyed via MXGATEWAY_ALARM_ACK_CLIENT_NAME. |`.
|
|
- [ ] **Step 7: Diagnostics.md / CLAUDE.md / Contracts.md / Authorization.md** — add `AcknowledgeAlarm`/`AcknowledgeAlarmByName` to the documented `SensitiveCommandMethods` set and the name-based `password` seam rule; CLAUDE.md :129 append "and `AcknowledgeAlarm` `operator_password`"; Contracts.md paragraph on the ack credential fields and their never-log rule; Authorization.md note that `AcknowledgeAlarm` stays `invoke:write` because the operator's own credential authorizes the write.
|
|
- [ ] **Step 8: Verify** grep; **Commit** `git add gateway.md docs CLAUDE.md && git commit -m "docs: alarm acknowledgement writes AckMsg as the authenticated operator" -m "Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT"`
|
|
|
|
---
|
|
|
|
### Task 13: Full verification and live Historian check on windev
|
|
|
|
**Goal:** Prove end to end on the windev rig that an `AcknowledgeAlarm` RPC with operator credentials acknowledges the alarm and the Historian events table records that operator.
|
|
|
|
> **USER-ORDERED GATE — NON-SKIPPABLE.** This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it "verified inline", or by substituting a cheaper check. Close only after every item in `acceptanceCriteria` has been re-validated independently, with output captured.
|
|
|
|
**Files:**
|
|
- No source changes (deploy only). Uses windev `C:\build\mxaccessgw-ci`, `C:\publish\mxaccessgw\Server-YYYYMMDD`, `C:\publish\mxaccessgw\Worker`, NSSM service `MxAccessGw`.
|
|
|
|
**Acceptance Criteria:**
|
|
- [ ] Gateway full suite passes once on macOS (`dotnet test src/ZB.MOM.WW.MxGateway.Tests`) and windev worker CI `SUCCESS mode=test` with `Failed: 0` for the final SHA.
|
|
- [ ] Server + Worker for the final SHA deployed to windev (Server to a dated `Server-YYYYMMDD` dir with previous `appsettings*.json` copied; Worker swapped with `Worker.bak-YYYYMMDD` backup); `/health/ready` 200; `mxgateway_alarms_provider_mode 1`.
|
|
- [ ] With `TestMachine_001.TestAlarm001` raised (UNACK_ALM), `grpcurl … AcknowledgeAlarm {"alarm_full_reference":"Galaxy!TestArea.TestMachine_001.TestAlarm001","comment":"<unique>","operator_user":"Administrator","operator_password":""}` returns `PROTOCOL_STATUS_CODE_OK`, `hresult` 0.
|
|
- [ ] `mxa read TestMachine_001.TestAlarm001.Acked` → `True`; `.AckMsg` = the unique comment.
|
|
- [ ] `Runtime.dbo.v_AlarmEventHistory2` newest `ACK_ALM` row for `TestMachine_001.TestAlarm001` has `Operator = Administrator`, `DomainName = ArchestrA`, `OperatorNode = MxGateway`, `Description = <unique comment>`.
|
|
- [ ] Negative: same RPC with `operator_password` = a wrong value for a password-protected account (or `operator_user` = a non-existent user) returns `PROTOCOL_STATUS_CODE_MXACCESS_FAILURE`, the alarm stays unacked, no new ACK_ALM row, and the gateway/worker logs (`Server-*/logs/mxgateway-*.log`, `stderr.log` tail) do not contain the password string.
|
|
- [ ] Rig restored: alarm cleared (`mxa write … false --secured -u Administrator`), scratch files removed.
|
|
|
|
**Verify:** windev: `sqlcmd -S localhost -E -W -s "|" -i C:\build\hist-q.sql` (query below) → newest ACK_ALM row shows `Administrator|ArchestrA|…|MxGateway|<unique comment>`
|
|
|
|
```json:metadata
|
|
{"userGate": true, "tags": ["user-gate"], "verifyCommand": "sqlcmd -S localhost -E -W -s \"|\" -i C:\\build\\hist-q.sql", "acceptanceCriteria": ["AcknowledgeAlarm RPC returns OK hresult 0", "TestAlarm001.Acked = True and AckMsg = unique comment", "Historian ACK_ALM row Operator=Administrator DomainName=ArchestrA OperatorNode=MxGateway Description=unique comment", "wrong-password ack returns MXACCESS_FAILURE, no ACK row, password absent from logs"]}
|
|
```
|
|
|
|
**Steps:**
|
|
|
|
- [ ] **Step 1: Full suites** — `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj` (once); push; windev CI `-Mode test` for the final SHA.
|
|
- [ ] **Step 2: Deploy** per memory `project_deploy_mechanics.md`: build in a detached worktree off the CI clone at the final SHA (`git -C C:\build\mxaccessgw-ci worktree add C:\build\mgack-deploy <sha>`), `dotnet publish` Server (net10, framework-dependent) to `C:\publish\mxaccessgw\Server-YYYYMMDD` and copy `appsettings.json`/`appsettings.Development.json` from the current Server dir; publish Worker x86 to a temp dir; `Stop-Service MxAccessGw -Force`; rename `Worker` → `Worker.bak-YYYYMMDD`; copy the new worker; `nssm set MxAccessGw Application <new>\ZB.MOM.WW.MxGateway.Server.exe` and `AppDirectory`; `Start-Service`; check health + metrics. Use base64 `-EncodedCommand` or scp'd `.ps1` files; read logs by content (not file size).
|
|
- [ ] **Step 3: Raise** — `mxa write TestMachine_001.TestAlarm001 true --type bool --secured -u Administrator`; wait until `QueryActiveAlarms` shows the alarm `ALARM_CONDITION_STATE_ACTIVE`.
|
|
- [ ] **Step 4: Ack via RPC** from the Mac:
|
|
|
|
```bash
|
|
K=$(ssh windev 'type C:\publish\mxaccessgw\.apikey' | tr -d '\r\n'); C="ackmsg-op $(date -u +%H%M%S)"
|
|
grpcurl -plaintext -import-path src/ZB.MOM.WW.MxGateway.Contracts/Protos -proto mxaccess_gateway.proto \
|
|
-H "authorization: Bearer $K" \
|
|
-d "{\"client_correlation_id\":\"ackmsg-live\",\"alarm_full_reference\":\"Galaxy!TestArea.TestMachine_001.TestAlarm001\",\"comment\":\"$C\",\"operator_user\":\"Administrator\",\"operator_password\":\"\"}" \
|
|
10.100.0.48:5120 mxaccess_gateway.v1.MxAccessGateway/AcknowledgeAlarm
|
|
```
|
|
|
|
- [ ] **Step 5: Check** `mxa read TestMachine_001.TestAlarm001.Acked TestMachine_001.TestAlarm001.AckMsg`, then the Historian with `C:\build\hist-q.sql`:
|
|
|
|
```sql
|
|
set nocount on;
|
|
select top 6 convert(varchar(23),EventStampUTC,121) as utc, AlarmState, TagName, Operator, DomainName, UserFullName, OperatorNode, left(Description,40) as Description
|
|
from Runtime.dbo.v_AlarmEventHistory2
|
|
where TagName like 'TestMachine_001.TestAlarm001%' and EventStampUTC > dateadd(hour,-3,getutcdate())
|
|
order by EventStampUTC desc;
|
|
```
|
|
|
|
- [ ] **Step 6: Negative case** — clear and re-raise, send the ack with a bad credential (e.g. `operator_user":"NoSuchOperator"`), confirm `MXACCESS_FAILURE`, `Acked=False`, no ACK row; `Select-String` the password-like value you sent in `C:\publish\mxaccessgw\Server-*\logs\*.log` and `C:\publish\mxaccessgw\stderr.log` → no matches.
|
|
- [ ] **Step 7: Restore** — clear the alarm, remove `C:\build\mgack-*`, `git worktree prune`. Record results (Historian rows) in `docs/AlarmProbeFindings.md`'s new section if they differ from Task 12's text, commit, push, open the PR to `main` with a body ending `https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT`.
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
- **Spec coverage:** request/command fields (T1), ack transitions attribution (T2), authenticated write + completion (T3), resolve-only consumers + non-Galaxy rejection (T4), routing/reply mapping + managed removal (T5), gateway forwarding/validation/redaction/client name (T6), five clients (T7-T11), docs + never-log rules (T12), live Historian proof incl. negative + no-leak (T13). No gaps found.
|
|
- **Placeholders:** none; the few "confirm the exact name" notes point at a specific file to read, not deferred design.
|
|
- **Type consistency:** `AlarmAckOperator{UserName,Domain,Password,VerifyUser}`, `AlarmAckTarget{Found,ItemAddress,Failure,Reason; For/NotFound/NotAckable}`, `AlarmAckResult{Outcome,NativeStatus,Diagnostic,Statuses; Create/FromTarget}`, `IAlarmAckWriter.Acknowledge(string,string,AlarmAckOperator)`, `IMxAccessAlarmConsumer.ResolveAckTarget(Guid)/ResolveAckTargetByName(string,string,string)`, `AlarmDispatcher(consumer, sink, sessionId, IAlarmAckWriter)`, `IAlarmCommandHandler.Acknowledge(Guid,string,AlarmAckOperator)/AcknowledgeByName(string,string,string,string,AlarmAckOperator)` — used consistently across T2-T5.
|
|
- **Ordering note:** T4 and T5 change interfaces together; verify the worker build after T5 (T2's dispatcher test is adjusted in T5).
|