mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
feat(gateway): add authenticated loopback text relay (#2624)
Adds a relay connector to the gateway: an HTTP loopback transport to an operator-owned bridge process, configured per connector from an absolute secrets file (OPENRIG_RELAY_URL, OPENRIG_RELAY_OUTBOUND_TOKEN, OPENRIG_RELAY_INBOUND_TOKEN), with no environment fallback. Outbound, each decision freezes its complete relayEnvelope on its DispatchBuffer entry before the first attempt and posts it whole, once per attempt, with deliveryId set to the decision ID; a 2xx records relay-owner-notification-accepted and drains the entry, a failure records relay-owner-notification-transport-failed and keeps it for replay of the same bytes, and a stop mid-attempt records neither. Inbound, POST /api/gateway/relay/inbound requires the connector's bearer token from a direct loopback socket peer (forwarding headers are ignored), attributes the reply by sender.handle against that connector's registered people, and commits the answer or resolve with its audit record through the Mission Control write contract, deduplicating an exact replay of the same eventId. Inbound size limits are a documented protocol contract, outbound references are fixed-width digests that the inbound contract accepts whole, and a ledger whose state can't be read returns a scoped retryable 503. Readiness warns, rather than going unready, on group or other bits on a readable secrets file and on one token used for both directions within a connector. docs/reference/gateway-relay.md describes the topology, custody, configuration and wire protocol.
This commit is contained in:
1 parent
bfbe416861
commit
30b9556355
48 files changed
+12552
-198
No files matched your search
@@ -14,7 +14,7 @@ returns `503` with `error: "human_registry_unavailable"`. The result does not in
|
||||
## Participants
|
||||
|
||||
A participant is either:
|
||||
- **a person**, registered with `rig gateway human add`, with or without a connector binding; or
|
||||
- **a person**, registered with `rig gateway human add`, with or without a connector binding (such as Slack or an HTTP loopback [relay](gateway-relay.md)); or
|
||||
- **a shared channel**: one vetted Slack channel registered as one participant, `<id>@external`, not a person per
|
||||
sender.
|
||||
|
||||
@@ -22,6 +22,9 @@ People and channels share one address space: a channel can't take a person's id.
|
||||
|
||||
A registered person is addressed as `<id>@external`, including without Slack: for example,
|
||||
`rig gateway human add alex --display-name "Alex" --delivery-class B` creates `alex@external`, reached in the Feed and Steering.
|
||||
A person can also bind to a local bridge adapter using a `relay` binding:
|
||||
`rig gateway human add bob --display-name "Bob" --delivery-class B --binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops"`
|
||||
(see [Gateway Relay Reference](gateway-relay.md) for endpoint, secret provisioning, and custody architecture).
|
||||
`human@host` is the unnamed person at the terminal; `human@kernel` is also accepted as a human/gate address.
|
||||
These aliases (including `human-<name>@host` and `human-<name>@kernel`) are not agent sessions: reply with
|
||||
`rig queue create --destination human@host --human-intent update --summary "<one line>" --evidence-ref "<path>" --body "..." --verify`,
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
# Gateway Relay: HTTP Loopback Transport Adapter & Custody Reference
|
||||
|
||||
OpenRig's Gateway Relay is a local HTTP loopback transport adapter. It enables registered people to receive outbound notifications and questions (asks, updates, and operator digests) through an operator-owned local bridge process, and to post answers back into OpenRig with sender attribution resolved against registered human bindings.
|
||||
|
||||
OpenRig does not embed third-party messaging platform SDKs (such as WhatsApp, Matrix, or Signal). An external bridge process translates between OpenRig loopback HTTP requests and external messaging platforms.
|
||||
|
||||
```
|
||||
+--------------------+ HTTP Loopback +--------------------+
|
||||
| OpenRig Daemon | POST <OPENRIG_RELAY_URL> ---> | Operator Bridge |
|
||||
| (Gateway Subsys) | <--- POST .../relay/inbound | (Loopback Server) |
|
||||
+--------------------+ +--------------------+
|
||||
|
|
||||
Platform-specific transport
|
||||
v
|
||||
+--------------------+
|
||||
| External Platform |
|
||||
| (Mobile / Client) |
|
||||
+--------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. System Topology & Custody Boundaries
|
||||
|
||||
### Outbound Custody & Recovery
|
||||
- OpenRig enqueues outbound decisions to a local `DispatchBuffer` before issuing any HTTP `POST` to `OPENRIG_RELAY_URL`.
|
||||
- Each logical decision freezes its complete `relayEnvelope` onto the parent `DispatchBuffer` entry before the first network attempt, with no self-imposed outbound 64 KiB cap or multi-part splitting.
|
||||
- Transport is at-least-once: OpenRig sends the complete JSON envelope in a single HTTP `POST` per attempt with `deliveryId = <decisionId>` (preserving the full `body`, `humanDetail`, `humanQuestions`/options, `summary` overflow, and digest `items` without silent clipping).
|
||||
- An HTTP `2xx` response from the bridge acknowledges durable bridge custody of that complete envelope (`deliveryId`). The bridge must persist each envelope by `deliveryId` and deduplicate replayed `deliveryId`s idempotently.
|
||||
- OpenRig records the logical queue acceptance receipt (`relay-owner-notification-accepted` with `deliveryId`) and drains the decision from `DispatchBuffer` once the HTTP `POST` returns `2xx`. Bridge acceptance confirms custody by the bridge process only (and is projected as `accepted` / `"Accepted by bridge"`, whereas Slack posts use `posted` / `"Delivered"`). It does not indicate downstream platform delivery, posting, or human receipt.
|
||||
- Failed dispatches remain in OpenRig's `DispatchBuffer` and record a relay-specific failure transition note (`relay-owner-notification-transport-failed` naming the `connectorRef`, distinct from Slack's `slack-owner-notification-transport-failed`). Replays after a lost ACK or daemon/bridge restart re-send the exact frozen `relayEnvelope` with the same `deliveryId` and bytes. If the relay subsystem stops while an outbound HTTP `POST` is in flight (or before it starts), the transport aborts cleanly (`stopped`) without recording a false failure transition note or false acceptance receipt, retaining the buffered decision for replay on restart. There is currently no periodic background timer redriving outbound dispatches on timeout.
|
||||
|
||||
### Inbound Custody & Resolution
|
||||
- The operator bridge posts replies to `POST /api/gateway/relay/inbound`.
|
||||
- Requests require `Authorization: Bearer <OPENRIG_RELAY_INBOUND_TOKEN>` matching a configured, ready relay profile.
|
||||
- OpenRig verifies that the caller is a direct loopback peer (`127.0.0.1/8` or `::1`) over the underlying TCP socket. Proxy and forwarding headers (`Via`, `X-Forwarded-*`, `Forwarded`, `X-Real-Ip`) are ignored rather than rejected because caller identity is established directly from the loopback socket peer and the bearer token.
|
||||
- Sender attribution resolves by matching `sender.handle` against human fragments registered to the authenticated connector. Harmless extra fields on the top-level inbound JSON object or inside `sender` are ignored and can never override handle-to-person attribution or alter the event fingerprint. The handle is asserted by the trusted bridge; it does not provide independent proof of the physical human on the external platform.
|
||||
- When an inbound reply is accepted (`200 OK`), OpenRig reserves the event in `relay-inbound-seen.jsonl` (`received:<fingerprint>`), durably commits the answer/resolve action and its `mission_control_actions` audit record atomically through its Mission Control write contract (using existing `excludeOperationReply` revision checking without a second answer store or synthetic revision refresh), and marks the event `processed:<fingerprint>`. Exact duplicate replays of the same `eventId` and payload succeed idempotently (`deduplicated: true`).
|
||||
|
||||
### Exclusions & Scope
|
||||
The relay adapter does not handle interactive UI buttons, message attachments, group rooms, unsolicited inbound triggers, multi-bridge failover clustering, or third-party platform SDK backends.
|
||||
|
||||
---
|
||||
|
||||
## 2. Secrets & Environment Configuration
|
||||
|
||||
Each relay connector loads configuration from an absolute file referenced by the binding's `secretsRef` (`file:<absolute-path>`).
|
||||
|
||||
### Security & File Requirements
|
||||
- **Absolute Path Only**: Must begin with `file:/`. Relative paths, bare paths, and `file://` authority URLs are rejected.
|
||||
- **File Permissions**: The file should restrict access to the owner only (no group or other permission bits set, i.e. `(mode & 0o077) === 0`, such as `0600` or `0400`). Owner-only modes (`0600`, `0400`) produce no warning. If group or world permission bits are set (e.g. `0644`), OpenRig logs a redacted warning in the profile's readiness `warnings` and `reason` (matching the Slack secret-file posture) while keeping the profile `ready`. Missing or unreadable secret files remain `not-ready`.
|
||||
- **No Environment Fallback**: OpenRig does not fall back to process environment variables (`process.env`). All variables must come directly from the referenced file.
|
||||
- **Profile Independence**: If a secrets file is missing, unreadable, or missing required keys, only connectors bound to that file report `not-ready`. Other connectors remain functional.
|
||||
|
||||
### Required Secrets Keys
|
||||
|
||||
```ini
|
||||
OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook
|
||||
OPENRIG_RELAY_OUTBOUND_TOKEN=sec_outbound_token_value
|
||||
OPENRIG_RELAY_INBOUND_TOKEN=sec_inbound_token_value
|
||||
```
|
||||
|
||||
- `OPENRIG_RELAY_URL`: Must use `http:`, loopback host (`127.0.0.1/8`, `::1`, or `localhost`), without URL credentials or hash fragments. Redirects (3xx) are rejected.
|
||||
- `OPENRIG_RELAY_OUTBOUND_TOKEN`: Non-empty Bearer token sent by OpenRig on outbound requests to the bridge.
|
||||
- `OPENRIG_RELAY_INBOUND_TOKEN`: Non-empty Bearer token required on inbound requests from the bridge. Must be unique across different configured relay connector profiles (`connectorRef`); sharing the same inbound token across multiple relay profiles is rejected as ambiguous (`not-ready`). Using the same token value for both `OPENRIG_RELAY_OUTBOUND_TOKEN` and `OPENRIG_RELAY_INBOUND_TOKEN` within a single profile is allowed, with a redacted readiness warning explaining why separate directional tokens are recommended so an outbound capture cannot authenticate inbound replies.
|
||||
|
||||
---
|
||||
|
||||
## 3. Wire Protocol Specifications
|
||||
|
||||
### Outbound Envelope (`POST <OPENRIG_RELAY_URL>`)
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"deliveryId": "dec-019283-abc",
|
||||
"recipient": { "handle": "alice-ops" },
|
||||
"intent": "decision",
|
||||
"outcome": "interrupt",
|
||||
"summary": "Database Migration Approval",
|
||||
"body": "Production migration script 004_add_indexes.sql is ready to execute. Reply YES to approve, NO to abort.",
|
||||
"replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...<base64url>"
|
||||
}
|
||||
```
|
||||
|
||||
- `v`: Protocol version integer (`1`).
|
||||
- `deliveryId`: Stable whole-decision delivery identifier (`<decisionId>`).
|
||||
- `recipient.handle`: Recipient's registered safe alias for this connector.
|
||||
- `intent`: `"decision"` (actionable question) or `"update"` (informational notice).
|
||||
- `outcome`: Delivery classification (`"interrupt"`, `"notify"`, or `"digest"`).
|
||||
- `summary`: Plain text summary preview (up to 1,000 characters on the `summary` field, cut on a Unicode code-point boundary; any summary text exceeding 1,000 characters is preserved in full at the beginning of `body` or `item.body`).
|
||||
- `body`: Complete plain text content (including full `summary` overflow, `body`, `humanDetail`, and rendered `humanQuestions` headers, prompts, and option labels/descriptions) sent in a single complete JSON envelope without silent character clipping or self-imposed outbound byte splitting.
|
||||
- `replyRef`: Fixed-size opaque base64url token (`<= 4,096` characters for any supported ID lengths — `246`–`266` characters in protocol v1, hashing the canonical frozen `RelayReplyRefPayload` and binding `connectorHash`, `mode`, and optional `digest: true`) for actionable decisions; `null` for informational updates (and `null` at the top level of multi-item digests).
|
||||
- `items`: Present when `outcome` is `"digest"`. Array of objects with `qitemId`, `notificationKey`, `summary`, optional `body`, and per-item `replyRef` (or `null`).
|
||||
|
||||
### Inbound Envelope (`POST /api/gateway/relay/inbound`)
|
||||
|
||||
```json
|
||||
{
|
||||
"eventId": "evt-relay-20261107-001",
|
||||
"sender": { "handle": "alice-ops" },
|
||||
"replyRef": "eyJ2IjoxLCJwYXlsb2FkSGFzaCI6...<base64url>",
|
||||
"text": "Approved. Proceed with rollout.",
|
||||
"ordering": "after-question"
|
||||
}
|
||||
```
|
||||
|
||||
- `eventId`: Bridge-assigned identifier (`1..256` chars, matching `/^[A-Za-z0-9._:-]{1,256}$/`).
|
||||
- `sender.handle`: Sender's alias, matching `/^[A-Za-z0-9._-]+$/`. Must match a human fragment bound to the authenticated connector. Harmless extra fields inside `sender` or on the top-level JSON object are ignored and never override `sender.handle` resolution or affect the event fingerprint.
|
||||
- `replyRef`: Exact fixed-size opaque base64url token copied from the outbound envelope (maximum 4,096 characters). Bridges must not parse or alter `replyRef`.
|
||||
- `text`: Plain text response (maximum 32,768 characters, non-empty after trim, no NUL bytes). Processed as unstructured text without automatic natural-language approval parsing.
|
||||
- `ordering`: Optional enum (`"after-question"` | `"uncertain"`, default `"uncertain"`). When `"uncertain"`, text is accepted as recorded context rather than verified sequence relative to the question.
|
||||
|
||||
#### Inbound Statuses & Responses
|
||||
- **200 OK**: Request accepted (`{ ok: true, deduplicated: false, reconciled: false, qitemId, verb, actorSession, actionId }`). Exact duplicate submissions of the same `eventId` return `ok: true` with `deduplicated: true`.
|
||||
- **400 Bad Request**: Malformed JSON, non-object payload, or invalid required field syntax/bounds (`eventId`, `sender.handle`, `replyRef`, `text`, or `ordering`). Harmless extra fields are ignored.
|
||||
- **401 Unauthorized**: Missing, malformed, unrecognized, unready, or cross-profile ambiguous inbound Bearer token, or no humans configured (`noHumans`).
|
||||
- **403 Forbidden**: Non-loopback TCP socket peer, browser cross-origin request, connector mismatch (`replyRef` emitted by another connector), or unregistered `sender.handle`. (`Via` and `X-Forwarded-*` headers are ignored).
|
||||
- **404 Not Found**: Queue item referenced in `replyRef` does not exist.
|
||||
- **409 Conflict**: Stale `decisionRevision` (question cancelled, answered, or re-asked), attempt to answer an informational update, unrecognized `replyRef` when both durable receipts and the pending dispatch buffer are readable, or conflicting payload submitted under an existing `eventId`.
|
||||
- **413 Payload Too Large**: Request body exceeds 64 KiB (65,536 bytes).
|
||||
- **503 Service Unavailable**:
|
||||
- `human_registry_unavailable`: Local human registry (`gateway/humans/*.yaml`) failed to load due to a broken fragment or filesystem read error (normal `noHumans` returns `401`).
|
||||
- `queue_store_unavailable`: Local SQLite queue/transition store read failed while resolving or verifying `replyRef`, returning `503` with zero seen-store reservation or Mission Control action side effects.
|
||||
- `dispatch_buffer_unavailable`: An un-receipted `replyRef` required fallback verification against `<openrig-home>/gateway/dispatch-buffer.json`, and reading the buffer failed (`pendingStrict()`), returning `503` with zero seen-store reservation or Mission Control action side effects. (`replyRef`s already vouched for by durable accepted receipts in `queue_transitions` or `queue_transitions_archive` are verified before consulting the buffer and still succeed.)
|
||||
- `seen_store_unavailable` (response includes `seenPath`): Local seen-ledger (`state/relay-inbound-seen.jsonl`) is unreadable, contains unparseable JSONL or unkeyable records (missing `id`), failed a reservation/processed mark, or has a corrupt/unvouched record for the requested `eventId`'s key. Keyed suspect records (`missing/invalid status` or conflicting recorded fingerprints for the same key; diagnostic `ts` or extra metadata fields are ignored) scope refusal to that `eventId` key only so unrelated `eventId`s continue to operate normally, while never treating unparseable or suspect history as unseen.
|
||||
|
||||
---
|
||||
|
||||
## 4. Identity & Binding Configuration
|
||||
|
||||
Bindings are managed through human fragment files (`gateway/humans/<entityId>.yaml`) and CLI verbs (`rig gateway human`).
|
||||
|
||||
### Binding Syntax
|
||||
```
|
||||
relay:<connectorRef>:file:<absolute-env-file>:<role>[:handle=<alias>]
|
||||
```
|
||||
- `kind`: `relay`.
|
||||
- `connectorRef`: Identifier for the connector instance (e.g. `local-bridge`).
|
||||
- `secretsRef`: Absolute file path reference (`file:/path/to/relay.env`).
|
||||
- `role`: `primary` or `secondary` (at least one primary binding required for outbound dispatch).
|
||||
- `handle`: Optional platform alias (`handle=<alias>`). Required for inbound reply resolution; omitted means outbound-only.
|
||||
|
||||
Handles must match `/^[A-Za-z0-9._-]+$/` and are unique per `connectorRef`. Multiple people can share a connector, each registered with their own handle.
|
||||
|
||||
---
|
||||
|
||||
## 5. Operational Lifecycle
|
||||
|
||||
### Step 1: Provision Secrets File
|
||||
Create an owner-only secrets file (no group/other access bits, e.g. `0600` or `0400`):
|
||||
```bash
|
||||
mkdir -p "$HOME/.config/openrig-relay"
|
||||
touch "$HOME/.config/openrig-relay/relay.env"
|
||||
chmod 600 "$HOME/.config/openrig-relay/relay.env"
|
||||
|
||||
cat << 'EOF' > "$HOME/.config/openrig-relay/relay.env"
|
||||
OPENRIG_RELAY_URL=http://127.0.0.1:8088/webhook
|
||||
OPENRIG_RELAY_OUTBOUND_TOKEN=replace_with_outbound_token
|
||||
OPENRIG_RELAY_INBOUND_TOKEN=replace_with_inbound_token
|
||||
EOF
|
||||
```
|
||||
|
||||
### Step 2: Register Human Binding
|
||||
```bash
|
||||
# Add a person with a primary relay binding
|
||||
rig gateway human add alice --display-name "Alice Smith" --delivery-class B \
|
||||
--binding "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops"
|
||||
|
||||
# Replace an existing binding slot (requires the full binding spec)
|
||||
rig gateway human set alice binding.0 "relay:local-bridge:file:$HOME/.config/openrig-relay/relay.env:primary:handle=alice-ops"
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `rig gateway human set alice binding.<n>` requires a full binding specification string. Supplying `unset` is not supported for binding slots. To reassign a participant, overwrite the slot with a full binding string or remove and re-add the human fragment.
|
||||
|
||||
Operator notices use the existing default person. If several people are registered, choose the recipient explicitly when needed:
|
||||
|
||||
```bash
|
||||
rig gateway default alice
|
||||
```
|
||||
|
||||
### Step 3: Activate Daemon
|
||||
When bindings or secrets files change, restart the daemon process:
|
||||
```bash
|
||||
rig daemon stop
|
||||
rig daemon start
|
||||
```
|
||||
*(OpenRig does not provide a `rig daemon restart` command).*
|
||||
|
||||
### Step 4: Verify Readiness & Status
|
||||
```bash
|
||||
# Check delivery readiness for all participants
|
||||
rig gateway human list
|
||||
|
||||
# Inspect detailed readiness status for an individual
|
||||
rig gateway human show alice
|
||||
|
||||
# Check daemon process status
|
||||
rig daemon status
|
||||
```
|
||||
|
||||
In `rig gateway human show <entityId> --json`, `deliveryReadiness.state` reports `ready` when the binding configuration and local secrets file are valid. This confirms local readiness, not whether the operator bridge process is currently running.
|
||||
|
||||
### Step 5: Removing Bindings or Participants
|
||||
Before removing a participant, ensure any pending queue items and conversations assigned to them are resolved.
|
||||
```bash
|
||||
rig gateway human remove alice
|
||||
```
|
||||
*(The remove verb refuses to delete a participant if open conversations or non-terminal items exist, unless `--force` is specified).*
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting Common Conditions
|
||||
|
||||
| Condition | Observation | Resolution |
|
||||
| :--- | :--- | :--- |
|
||||
| **Missing Secrets File** | `deliveryReadiness.state: not-ready`<br>`reason: relay secret file ... is missing or unreadable` | Verify file path in `secretsRef` is absolute and readable. |
|
||||
| **Insecure Permissions** | `deliveryReadiness.state: ready` (with warning)<br>`reason: ... warning: ... insecure permissions (mode 0644; group/other access bits are set ...)` | Restrict permissions so group/other have no access (`chmod 600 <secrets-file>` or `chmod 400 <secrets-file>`). |
|
||||
| **Identical Directional Tokens** | `deliveryReadiness.state: ready` (with warning)<br>`reason: ... warning: ... uses the same token for OPENRIG_RELAY_OUTBOUND_TOKEN and OPENRIG_RELAY_INBOUND_TOKEN; separate tokens are recommended ...` | Provision distinct outbound and inbound tokens so outbound capture cannot authenticate inbound replies. |
|
||||
| **Invalid Loopback URL** | `deliveryReadiness.state: not-ready`<br>`reason: ... OPENRIG_RELAY_URL host must be loopback ...` | Ensure URL uses `http://` and host is `127.0.0.1/8`, `::1`, or `localhost`. |
|
||||
| **Reused Inbound Token Across Profiles** | `deliveryReadiness.state: not-ready`<br>`reason: ... reuses an ambiguous OPENRIG_RELAY_INBOUND_TOKEN shared with relay profile(s): ...` | Each distinct relay connector profile (`connectorRef`) must declare a unique inbound token. |
|
||||
| **Stale Question Reply** | HTTP 409 on inbound endpoint | Question was already answered, cancelled, or superseded by a newer revision. |
|
||||
| **Unregistered Handle** | HTTP 403 on inbound endpoint | The `sender.handle` is not bound to the authenticated `connectorRef`. |
|
||||
| **Non-Loopback Inbound** | HTTP 403 on inbound endpoint | Connection originated from a non-loopback TCP socket peer or included a cross-origin browser header. (`Via` and `X-Forwarded-*` headers are ignored). |
|
||||
| **Registry or Buffer Read Failure** | HTTP 503 on inbound endpoint (`human_registry_unavailable` or `dispatch_buffer_unavailable`) | Inspect local `gateway/humans/*.yaml` fragments or `<openrig-home>/gateway/dispatch-buffer.json` for syntax/permission corruption before retrying the bridge event. |
|
||||
| **Seen-Ledger Corrupt / Suspect** | HTTP 503 on inbound endpoint (`seen_store_unavailable`, includes `seenPath`) | Unreadable/unparseable/unkeyable `relay-inbound-seen.jsonl` refuses all inbound requests; a keyed suspect entry refuses only that specific `eventId`. Follow the conservative recovery procedure below. |
|
||||
|
||||
### Conservative Seen-Ledger Recovery (`seen_store_unavailable`)
|
||||
|
||||
When `POST /api/gateway/relay/inbound` returns `503` with `error: "seen_store_unavailable"`, the response body and message include the resolved `seenPath` (`<openrig-home>/state/relay-inbound-seen.jsonl`):
|
||||
|
||||
1. **Quiesce writers first**: Pause the loopback bridge and stop the daemon (`rig daemon stop`) so no concurrent inbound request or retry appends to the ledger during inspection.
|
||||
2. **Preserve a full backup**: Copy `<seenPath>` to a timestamped backup outside `state/` before making any edit.
|
||||
3. **Correlate against durable evidence**: Each ledger entry keys `id` by `eventKey = SHA256(JSON.stringify([connectorRef, eventId]))` (64-character lowercase hex digest) and stores `status` as `received:<fingerprint>` or `processed:<fingerprint>`. Because `eventKey` is a one-way hash, correlate any damaged line or keyed suspect `id` against `mission_control_actions.audit_notes_json` (which records `operationId: "relay:<eventKey>:<fingerprint>"`, `connectorRef`, and `eventId`) and bridge delivery logs by computing `SHA256(JSON.stringify([connectorRef, eventId]))` for candidate events—never by guessing or assuming the hash is reversible.
|
||||
4. **Apply only evidence-verified, history-preserving repair**: Repair a damaged record only when its exact `id` (`<eventKey>`) and `status` (`received:<fingerprint>` or `processed:<fingerprint>`) are verified from `mission_control_actions.audit_notes_json` or bridge evidence, or restore only from a backup that preserves every subsequently acknowledged event. Do **not** delete, truncate, or skip unknown/unparseable records, and do **not** restore an older backup that omits acknowledged events; if an entry cannot be verified without losing history, leave the ledger in place (continuing to fail closed with `503`) and retain the backup.
|
||||
5. **Resume and retry**: After a verified, history-preserving repair is saved at `<seenPath>`, start the daemon (`rig daemon start`), resume the loopback bridge, and retry the original inbound delivery with the same `eventId`.
|
||||
|
||||
For automated loopback bridge test reference, see `packages/daemon/test/fixtures/recording-relay.ts`.
|
||||
@@ -175,7 +175,7 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
.requiredOption("--display-name <name>", "Human-readable display name")
|
||||
.option(
|
||||
"--binding <kind:connectorRef:secretsRef:role[:handle=<id>]>",
|
||||
"Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind); omit it for outbound-only.",
|
||||
"Connector binding (repeatable); leave it out for a person with no connector, who's reached in the Feed and Steering only. With bindings, role primary|secondary, EXACTLY ONE primary. connectorRef and secretsRef are config/vault labels (e.g. for Slack: slack:openrig-personal:file:<env-path>:primary:handle=<id>; tokens come from 'rig slack setup --secrets-env-file', never inline; or file:<path> for relay: relay:<connectorRef>:file:<absolute-env-file>:primary:handle=<safe-alias>). Optional handle=<platform id> makes the binding inbound-resolvable (unique per kind for Slack, unique per connectorRef+handle for relay); omit it for outbound-only.",
|
||||
(v: string, acc: string[] = []) => { acc.push(v); return acc; },
|
||||
)
|
||||
.requiredOption("--delivery-class <A|B|C|D>", "Notification loudness class (the notifications register selection)")
|
||||
@@ -253,16 +253,24 @@ export function gatewayCommand(deps: GatewayCommandDeps = {}): Command {
|
||||
.addHelpText("after", `
|
||||
Binding specification details:
|
||||
Format: <kind:connectorRef:secretsRef:role[:handle=<id>]>
|
||||
- kind: Connector kind (e.g. "slack")
|
||||
- connectorRef: Configuration label identifying the connector instance (e.g. "openrig-personal" or "main")
|
||||
- secretsRef: Pointer or label identifying where secrets are resolved (e.g. "file:/path/to/slack.env" or "vault://slack/user").
|
||||
NOTE: Never pass credentials or tokens inline here; credentials (like SLACK_BOT_TOKEN) are loaded from the connector's secrets env file (configured via 'rig slack setup --secrets-env-file').
|
||||
- kind: Connector kind: "slack" or "relay"
|
||||
- connectorRef: Configuration label identifying the connector instance (e.g. "openrig-personal", "main", or "local-bridge")
|
||||
- secretsRef: Pointer or label identifying where secrets are resolved:
|
||||
* For Slack: "file:/path/to/slack.env" or "vault://slack/user". Credentials (like SLACK_BOT_TOKEN) are loaded from the connector's secrets env file (configured via 'rig slack setup --secrets-env-file').
|
||||
* For Relay: "file:<absolute-env-file>" pointing to a chmod 600 file holding OPENRIG_RELAY_URL (HTTP loopback only, e.g. http://127.0.0.1:8088), OPENRIG_RELAY_OUTBOUND_TOKEN, and OPENRIG_RELAY_INBOUND_TOKEN (distinct non-empty values; no environment fallback).
|
||||
NOTE: Never pass credentials or tokens inline here.
|
||||
- role: "primary" (exactly one binding must be primary) or "secondary"
|
||||
- handle: Optional platform user identifier (e.g. "handle=U12345678") for inbound routing
|
||||
- handle: Optional platform user identifier (e.g. "handle=U12345678" for Slack, or "handle=<safe-alias>" matching /^[A-Za-z0-9._-]+$/ for relay) for inbound routing. For relay, handles are unique per connectorRef.
|
||||
|
||||
Example:
|
||||
Examples:
|
||||
# Slack binding
|
||||
rig gateway human add alice --display-name "Alice" \\
|
||||
--binding "slack:openrig-personal:file:/etc/openrig/slack.env:primary:handle=U12345678" \\
|
||||
--delivery-class B
|
||||
|
||||
# Relay binding (loopback bridge adapter)
|
||||
rig gateway human add bob --display-name "Bob" \\
|
||||
--binding "relay:local-bridge:file:/etc/openrig/relay.env:primary:handle=bob-ops" \\
|
||||
--delivery-class B`);
|
||||
|
||||
// ── S12 (OPR.0.5.5.12): the fragment lifecycle beyond add. Every verb operates through
|
||||
|
||||
@@ -48,6 +48,23 @@ describe("rig gateway human add verb (post-relocate)", () => {
|
||||
expect(bindingOpt).toBeDefined();
|
||||
expect(bindingOpt!.description).toContain("connectorRef and secretsRef are config/vault labels");
|
||||
expect(bindingOpt!.description).toContain("tokens come from 'rig slack setup --secrets-env-file'");
|
||||
expect(bindingOpt!.description).toContain("or file:<path> for relay");
|
||||
});
|
||||
|
||||
it("gateway human add help text documents relay binding syntax and requirements", () => {
|
||||
const gw = createProgram().commands.find((c) => c.name() === "gateway");
|
||||
const human = gw!.commands.find((c) => c.name() === "human");
|
||||
const addCmd = human!.commands.find((c) => c.name() === "add");
|
||||
let help = "";
|
||||
addCmd!.configureOutput({
|
||||
writeOut: (text) => { help += text; },
|
||||
writeErr: () => {},
|
||||
});
|
||||
addCmd!.outputHelp();
|
||||
expect(help).toContain("relay:<connectorRef>:file:<absolute-env-file>:primary:handle=<safe-alias>");
|
||||
expect(help).toContain("OPENRIG_RELAY_URL");
|
||||
expect(help).toContain("OPENRIG_RELAY_OUTBOUND_TOKEN");
|
||||
expect(help).toContain("OPENRIG_RELAY_INBOUND_TOKEN");
|
||||
});
|
||||
|
||||
it("add writes a fragment + projection; address DERIVED; vault-pointer secretsRef (with ':') survives", async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
|
||||
import { loadConfig, configPathFor, DEFAULT_CONFIG, type SlackConnectorConfig } from "./slack/config.js";
|
||||
import { resolveSecret } from "./slack/secrets.js";
|
||||
import { loadHumanRegistry } from "./human-registry.js";
|
||||
import { loadRelayProfiles } from "./relay-config.js";
|
||||
import { channelStateDigest } from "./channel-operations.js";
|
||||
import type { SettingsStore } from "../user-settings/settings-store.js";
|
||||
|
||||
@@ -82,33 +83,58 @@ export function connectionsProjection(home: string, gateway: Record<string, unkn
|
||||
: "rig slack verify --json";
|
||||
let registry: ReturnType<typeof loadHumanRegistry>;
|
||||
try { registry = loadHumanRegistry(home, { readOnly: true }); } catch { registry = { ok: false, error: "registry unavailable" }; }
|
||||
const relayProfilesResult = registry.ok ? loadRelayProfiles(home, { entities: registry.entities }) : null;
|
||||
const relayProfileValues = relayProfilesResult ? [...relayProfilesResult.profiles.values()] : [];
|
||||
const relayTokens = relayProfileValues
|
||||
.flatMap((p) => [p.credentials?.inboundToken, p.credentials?.outboundToken])
|
||||
.filter((t): t is string => Boolean(t));
|
||||
const safeText = (v: unknown): string | null => {
|
||||
const base = text(v);
|
||||
if (base === null) return null;
|
||||
let out = base;
|
||||
for (const tok of relayTokens) out = out.split(tok).join("[redacted]");
|
||||
return out;
|
||||
};
|
||||
const relaySummary = {
|
||||
configured: relayProfileValues.length > 0,
|
||||
ready: relayProfileValues.length > 0 && relayProfileValues.every((p) => p.ready),
|
||||
profiles: relayProfileValues.map((p) => ({
|
||||
connectorRef: safeText(p.connectorRef),
|
||||
state: p.state,
|
||||
configured: p.configured,
|
||||
ready: p.ready,
|
||||
reason: safeText(p.reason),
|
||||
nextAction: safeText(p.nextAction),
|
||||
})),
|
||||
};
|
||||
const instance = ["host.name", "workspace.root", "workspace.operator_seat_name"] as const;
|
||||
const browserKey = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||
return {
|
||||
observedAt: new Date().toISOString(), home: text(home), pid: process.pid,
|
||||
settingsSource: text(settings?.configPath),
|
||||
observedAt: new Date().toISOString(), home: safeText(home), pid: process.pid,
|
||||
settingsSource: safeText(settings?.configPath),
|
||||
settings: instance.map((key) => {
|
||||
try {
|
||||
const r = settings?.resolveOne(key);
|
||||
return { key, value: text(r?.value), source: r?.source ?? "unavailable" };
|
||||
return { key, value: safeText(r?.value), source: r?.source ?? "unavailable" };
|
||||
} catch { return { key, value: null, source: "unavailable" }; }
|
||||
}),
|
||||
configSource: { state: configState, path: text(configPath), sourceState }, configuration,
|
||||
running: { state: text(gateway?.state) ?? "unavailable", activatedAt: text(gateway?.activatedAt),
|
||||
configSource: { state: configState, path: safeText(configPath), sourceState }, configuration,
|
||||
running: { state: safeText(gateway?.state) ?? "unavailable", activatedAt: safeText(gateway?.activatedAt),
|
||||
outboundReady: typeof connector?.outboundReady === "boolean" ? connector.outboundReady : null,
|
||||
inboundReady: typeof connector?.inboundReady === "boolean" ? connector.inboundReady : null,
|
||||
inboundState: text(inbound?.state) ?? "unverified",
|
||||
inboundState: safeText(inbound?.state) ?? "unverified",
|
||||
// Delivery health beside the socket state: an open socket is not proof that events arrive.
|
||||
inboundDelivery: text(inbound?.delivery), inboundEventsMissingSince: text(inbound?.eventsMissingSince), applied },
|
||||
inboundDelivery: safeText(inbound?.delivery), inboundEventsMissingSince: safeText(inbound?.eventsMissingSince), applied },
|
||||
state, nextAction,
|
||||
registry: { state: registry.ok ? "available" : "unavailable", path: text(join(home, "gateway", "humans")) },
|
||||
relay: relaySummary,
|
||||
registry: { state: registry.ok ? "available" : "unavailable", path: safeText(join(home, "gateway", "humans")) },
|
||||
humans: registry.ok ? registry.entities.map((h) => ({
|
||||
browserKey: browserKey(h.entityId),
|
||||
entityId: text(h.entityId) ?? "[withheld]", address: text(h.address) ?? "[withheld]", displayName: text(h.displayName), class: h.class, away: h.prefs.away ?? null,
|
||||
entityId: safeText(h.entityId) ?? "[withheld]", address: safeText(h.address) ?? "[withheld]", displayName: safeText(h.displayName), class: h.class, away: h.prefs.away ?? null,
|
||||
deliveryClass: h.prefs.deliveryClass, availability: h.prefs.availability ?? (h.prefs.away ? "away" : "available"),
|
||||
excluded: cfg ? cfg.outboundDestinations.length > 0 && !cfg.outboundDestinations.includes(h.address) : null,
|
||||
bindings: h.connectorBindings.map((b) => ({ browserKey: browserKey(JSON.stringify([b.kind, b.connectorRef, b.role, b.handle ?? null])),
|
||||
kind: b.kind, ref: text(b.connectorRef), role: b.role, handle: text(b.handle), credentialReference: Boolean(b.secretsRef) })),
|
||||
kind: b.kind, ref: safeText(b.connectorRef), role: b.role, handle: safeText(b.handle), credentialReference: Boolean(b.secretsRef) })),
|
||||
})) : [],
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export interface DispatcherDeps {
|
||||
send: (decision: OutboundDecision) => void;
|
||||
/** Injectable id source (tests pin decisionIds; prod = a UUID). */
|
||||
newDecisionId?: () => string;
|
||||
/** Optional synchronous preparation seam run BEFORE durable enqueue (e.g. to freeze relay envelope/refs on the decision payload). */
|
||||
prepare?: (decision: OutboundDecision) => OutboundDecision | void;
|
||||
}
|
||||
|
||||
export type DispatchResult =
|
||||
@@ -54,7 +56,20 @@ export class GatewayDispatcher {
|
||||
if (opts?.decisionId && this.deps.buffer.pending().some((d) => d.decisionId === decisionId)) {
|
||||
return { ok: true, decisionId }; // idempotent accept: already durably enqueued; replay owns the redrive
|
||||
}
|
||||
const decision: OutboundDecision = { kind: "outbound_decision", decisionId, op, entityBindingRef, payload };
|
||||
let payloadToEnqueue = payload;
|
||||
if (this.deps.prepare) {
|
||||
const prepared = this.deps.prepare({ kind: "outbound_decision", decisionId, op, entityBindingRef, payload });
|
||||
if (prepared && prepared.payload !== undefined) {
|
||||
payloadToEnqueue = prepared.payload;
|
||||
}
|
||||
}
|
||||
const decision: OutboundDecision = {
|
||||
kind: "outbound_decision",
|
||||
decisionId,
|
||||
op,
|
||||
entityBindingRef,
|
||||
payload: payloadToEnqueue,
|
||||
};
|
||||
this.deps.buffer.enqueue(decision); // durable BEFORE dispatch
|
||||
this.deps.send(decision);
|
||||
return { ok: true, decisionId };
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface InProcessWireOpts {
|
||||
/** The platform delivery seam. Until the relay cutover wires Slack, startup passes an
|
||||
* honest unwired refusal — dispatches are retained durably, never silently dropped. */
|
||||
deliver: SubsystemDeliverFn;
|
||||
/** Optional synchronous preparation seam run BEFORE durable enqueue so relay envelopes/refs are frozen once. */
|
||||
prepare?: (decision: OutboundDecision) => OutboundDecision | void;
|
||||
/** Ops the delivery layer advertises. The shipped proof-9 rail holds in-process: an
|
||||
* unadvertised op is refused, never attempted. Empty = nothing dispatchable yet. */
|
||||
ops?: string[];
|
||||
@@ -72,6 +74,7 @@ export function buildInProcessWire(opts: InProcessWireOpts): GatewayWire {
|
||||
const dispatcher: GatewayDispatcher = new GatewayDispatcher({
|
||||
buffer,
|
||||
send: (decision) => { void deliverAndAck(decision); },
|
||||
...(opts.prepare ? { prepare: opts.prepare } : {}),
|
||||
});
|
||||
dispatcher.onCapability({
|
||||
kind: "capability",
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { HumanFragment } from "./human-registry.js";
|
||||
import type { SlackConnectorConfig } from "./slack/config.js";
|
||||
import { verifyScopes, verifyChannelMembership, type ScopeVerdict } from "./slack/slack-api.js";
|
||||
import { mappedChannels } from "./slack/channel-map.js";
|
||||
import { loadRelayProfiles, type LoadRelayProfilesResult } from "./relay-config.js";
|
||||
import type { SecretFsOps } from "./slack/secrets.js";
|
||||
|
||||
export type HumanDeliveryReadinessState = "ready" | "not-ready" | "indeterminate";
|
||||
|
||||
@@ -23,12 +25,17 @@ export interface HumanDeliveryReadinessInput {
|
||||
config: SlackConnectorConfig;
|
||||
gatewayState: string;
|
||||
botToken: string | null;
|
||||
/** Optional preloaded relay profiles; when omitted for a relay primary binding, loaded from the full human registry. */
|
||||
relayProfiles?: LoadRelayProfilesResult | null;
|
||||
home?: string;
|
||||
}
|
||||
|
||||
export interface HumanDeliveryReadinessDeps {
|
||||
verifyScopes?: (token: string, required: string[]) => Promise<ScopeVerdict>;
|
||||
verifyMembership?: (token: string, channel: string) => Promise<{ ok: boolean; isMember: boolean; error?: string }>;
|
||||
now?: () => Date;
|
||||
fsops?: SecretFsOps;
|
||||
entities?: readonly HumanFragment[];
|
||||
}
|
||||
|
||||
/** Resolve the registered human's PRIMARY binding to delivery truth in one record.
|
||||
@@ -49,6 +56,50 @@ export async function resolveHumanDeliveryReadiness(
|
||||
connector: { kind: primary.kind, ref: primary.connectorRef },
|
||||
checkedAt,
|
||||
};
|
||||
if (primary.kind === "relay") {
|
||||
const loaded = input.relayProfiles !== undefined
|
||||
? input.relayProfiles
|
||||
: loadRelayProfiles(input.home, { entities: deps.entities, fsops: deps.fsops });
|
||||
const profile = loaded?.ok ? loaded.get(primary.connectorRef) : undefined;
|
||||
const defaultNextAction = "rig gateway human show " + input.human.entityId + " --json";
|
||||
if (!profile || !profile.ready) {
|
||||
return {
|
||||
state: "not-ready",
|
||||
configured: profile?.configured ?? false,
|
||||
enabled: true,
|
||||
active: false,
|
||||
ready: false,
|
||||
...base,
|
||||
reason: profile?.reason
|
||||
?? (loaded && !loaded.ok && loaded.registryError
|
||||
? loaded.registryError
|
||||
: `relay profile '${primary.connectorRef}' is not configured`),
|
||||
nextAction: profile?.nextAction ?? defaultNextAction,
|
||||
};
|
||||
}
|
||||
if (input.gatewayState !== "active") {
|
||||
return {
|
||||
state: "not-ready",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
active: false,
|
||||
ready: false,
|
||||
...base,
|
||||
reason: `gateway subsystem is ${input.gatewayState}`,
|
||||
nextAction: "rig daemon logs",
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: "ready",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
active: true,
|
||||
ready: true,
|
||||
...base,
|
||||
reason: profile.reason,
|
||||
nextAction: null,
|
||||
};
|
||||
}
|
||||
const configured = primary.kind === "slack" && input.botToken !== null && input.config.channel !== null;
|
||||
const enabled = input.config.enabled;
|
||||
const active = enabled && input.gatewayState === "active";
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { join, dirname, isAbsolute } from "node:path";
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||
import { getOpenRigHome } from "../../openrig-compat.js";
|
||||
import { DispatchBuffer } from "./dispatch-buffer.js";
|
||||
@@ -24,7 +24,8 @@ import { parseSessionName } from "../session-name.js";
|
||||
|
||||
// ── Schema (closed enums; extend only additively behind the contract) ──
|
||||
export const HUMAN_ENTITY_CLASSES = new Set(["human"]); // M1's only class
|
||||
export const HUMAN_CONNECTOR_KINDS = new Set(["slack"]); // M1's only kind
|
||||
export const HUMAN_CONNECTOR_KINDS = new Set(["slack", "relay"]);
|
||||
export type HumanConnectorKind = "slack" | "relay";
|
||||
export const HUMAN_BINDING_ROLES = new Set(["primary", "secondary"]);
|
||||
export const HUMAN_DELIVERY_CLASSES = new Set(["A", "B", "C", "D"]);
|
||||
// entityId is the fragment key + filename: a stable slug that survives platform
|
||||
@@ -109,7 +110,7 @@ function unknownKey(obj: Record<string, unknown>, allowed: Set<string>): string
|
||||
}
|
||||
|
||||
export interface HumanConnectorBinding {
|
||||
kind: "slack";
|
||||
kind: HumanConnectorKind;
|
||||
connectorRef: string;
|
||||
/** POINTER at the connector's vault (secrets-on-connector) — NEVER the secret. */
|
||||
secretsRef: string;
|
||||
@@ -118,11 +119,24 @@ export interface HumanConnectorBinding {
|
||||
/** A6 v3: the platform-native id of this human on the connector (e.g. Slack user id).
|
||||
* OPTIONAL — a handle-less binding is OUTBOUND-ONLY (delivery works; it is NOT
|
||||
* inbound-resolvable, so an inbound event on it fails admission LOUDLY). REQUIRED to be
|
||||
* inbound-resolvable. UNIQUE per kind across ALL bindings of ALL humans (one platform id =
|
||||
* exactly one human; a duplicate is a registration conflict, REFUSED). */
|
||||
* inbound-resolvable. UNIQUE per kind (Slack) or per connectorRef (relay) across ALL
|
||||
* bindings of ALL humans (one platform id = one human; a duplicate is a registration
|
||||
* conflict, REFUSED). */
|
||||
handle?: string;
|
||||
}
|
||||
|
||||
function handleScopeKey(binding: Pick<HumanConnectorBinding, "kind" | "connectorRef"> & { handle: string }): string {
|
||||
return binding.kind === "relay"
|
||||
? JSON.stringify(["relay", binding.connectorRef, binding.handle])
|
||||
: `${binding.kind}:${binding.handle}`;
|
||||
}
|
||||
|
||||
function handleScopeLabel(binding: Pick<HumanConnectorBinding, "kind" | "connectorRef"> & { handle: string }): string {
|
||||
return binding.kind === "relay"
|
||||
? `relay (${binding.connectorRef}) handle "${binding.handle}"`
|
||||
: `${binding.kind} handle "${binding.handle}"`;
|
||||
}
|
||||
|
||||
export interface HumanPrefs {
|
||||
/** Per-ENTITY loudness — a SELECTION from the notifications register (spec §6);
|
||||
* A3 carries it, never redefines it. Forward-compatible if the register grows. */
|
||||
@@ -214,7 +228,7 @@ export function validateHumanFragment(raw: unknown): ValidateResult {
|
||||
return { ok: false, error: `connectorBindings[${i}].handle "${String(b.handle)}" must match ${HANDLE_PATTERN} (a platform id — no ':' '@' or whitespace, which could forge a ref)` };
|
||||
}
|
||||
}
|
||||
const binding: HumanConnectorBinding = { kind: "slack", connectorRef: b.connectorRef, secretsRef: b.secretsRef, role: b.role as "primary" | "secondary" };
|
||||
const binding: HumanConnectorBinding = { kind: b.kind as HumanConnectorKind, connectorRef: b.connectorRef, secretsRef: b.secretsRef, role: b.role as "primary" | "secondary" };
|
||||
if (b.handle !== undefined) { handle = b.handle as string; binding.handle = handle; }
|
||||
bindings.push(binding);
|
||||
}
|
||||
@@ -222,15 +236,20 @@ export function validateHumanFragment(raw: unknown): ValidateResult {
|
||||
if (bindings.length > 0 && primaryCount !== 1) {
|
||||
return { ok: false, error: `exactly one connectorBinding must have role "primary" (found ${primaryCount}) — it is the default delivery channel` };
|
||||
}
|
||||
// A6 v3 pin-1 (within-fragment): a handle is UNIQUE per kind across this human's bindings —
|
||||
// one human must not claim the same platform id twice. (Cross-fragment uniqueness is enforced
|
||||
// in projectHumans, which sees every human at once.)
|
||||
// A6 v3 pin-1 (within-fragment): a handle is UNIQUE per kind (Slack) or per connectorRef
|
||||
// (relay) across this human's bindings — one human must not claim the same platform id twice.
|
||||
// (Cross-fragment uniqueness is enforced in projectHumans, which sees every human at once.)
|
||||
const seenHandles = new Set<string>();
|
||||
for (const b of bindings) {
|
||||
if (b.handle === undefined) continue;
|
||||
const key = `${b.kind}:${b.handle}`;
|
||||
const key = handleScopeKey({ kind: b.kind, connectorRef: b.connectorRef, handle: b.handle });
|
||||
if (seenHandles.has(key)) {
|
||||
return { ok: false, error: `duplicate ${b.kind} handle "${b.handle}" across this human's bindings — a handle is unique per kind (one platform id = one human)` };
|
||||
return {
|
||||
ok: false,
|
||||
error: b.kind === "relay"
|
||||
? `duplicate relay (${b.connectorRef}) handle "${b.handle}" across this human's bindings — a relay handle is unique per connectorRef`
|
||||
: `duplicate ${b.kind} handle "${b.handle}" across this human's bindings — a handle is unique per kind (one platform id = one human)`,
|
||||
};
|
||||
}
|
||||
seenHandles.add(key);
|
||||
}
|
||||
@@ -321,10 +340,10 @@ function validateEntityCollection(entities: readonly HumanFragment[]): string |
|
||||
entityIds.add(entity.entityId);
|
||||
for (const binding of entity.connectorBindings) {
|
||||
if (binding.handle === undefined) continue;
|
||||
const key = `${binding.kind}:${binding.handle}`;
|
||||
const key = handleScopeKey({ kind: binding.kind, connectorRef: binding.connectorRef, handle: binding.handle });
|
||||
const prior = handleOwner.get(key);
|
||||
if (prior !== undefined && prior !== entity.entityId) {
|
||||
return `${binding.kind} handle "${binding.handle}" is claimed by both "${prior}" and "${entity.entityId}" — a handle maps to exactly one human (registration conflict)`;
|
||||
return `${handleScopeLabel({ kind: binding.kind, connectorRef: binding.connectorRef, handle: binding.handle })} is claimed by both "${prior}" and "${entity.entityId}" — a handle maps to exactly one human (registration conflict)`;
|
||||
}
|
||||
handleOwner.set(key, entity.entityId);
|
||||
}
|
||||
@@ -403,6 +422,41 @@ export function resolveSlackHandle(
|
||||
};
|
||||
}
|
||||
|
||||
export type RelayHandleResolution =
|
||||
| { kind: "registered"; entityId: string; address: string; connectorRef: string; handle: string }
|
||||
| { kind: "unregistered"; connectorRef: string; handle: string; error: string };
|
||||
|
||||
/** Resolve an INBOUND relay handle scoped to the authenticated relay `connectorRef`.
|
||||
* Unlike Slack (where user IDs are globally unique across kind=slack), relay aliases are
|
||||
* scoped to `(kind="relay", connectorRef, handle)` so an authenticated relay can assert
|
||||
* only its own bindings while allowing multiple registered people on the same relay to
|
||||
* answer with their own entity address. Handle-less relay bindings remain outbound-only. */
|
||||
export function resolveRelayHandle(
|
||||
connectorRef: string,
|
||||
handle: string,
|
||||
entities: readonly HumanFragment[],
|
||||
): RelayHandleResolution {
|
||||
if (connectorRef.length > 0 && handle.length > 0) {
|
||||
for (const e of entities) {
|
||||
for (const b of e.connectorBindings) {
|
||||
if (b.kind === "relay" && b.connectorRef === connectorRef && b.handle !== undefined && b.handle === handle) {
|
||||
return { kind: "registered", entityId: e.entityId, address: e.address, connectorRef, handle };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: "unregistered",
|
||||
connectorRef,
|
||||
handle,
|
||||
error:
|
||||
`inbound relay (${connectorRef}) sender "${handle}" is not a registered human (no relay binding for "${connectorRef}" with handle "${handle}"). ` +
|
||||
`Register the human + their handle first: rig gateway human add <entityId> --display-name … ` +
|
||||
`--binding relay:${connectorRef}:file:<absolute-env-file>:primary:handle=${handle} --delivery-class …; ` +
|
||||
`until then this message is REFUSED (it was NOT landed as a fabricated human seat).`,
|
||||
};
|
||||
}
|
||||
|
||||
export function atomicWrite(path: string, body: string): { ok: true } | { ok: false; error: string } {
|
||||
try {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
@@ -451,20 +505,8 @@ export function addHumanFragment(
|
||||
}
|
||||
// A6 v3 pin-1: reject a handle already claimed by a DIFFERENT human BEFORE writing (never leave a
|
||||
// conflicting fragment on disk with a failed re-projection). projectHumans is the load-time backstop.
|
||||
const existing = projectHumans(home);
|
||||
if (!existing.ok) return { ok: false, error: `cannot validate handle uniqueness — existing registry is invalid: ${existing.error}` };
|
||||
const claimed = new Map<string, string>();
|
||||
for (const e of existing.entities) {
|
||||
if (e.entityId === v.fragment.entityId) continue; // a replace of the same human is fine
|
||||
for (const b of e.connectorBindings) if (b.handle !== undefined) claimed.set(`${b.kind}:${b.handle}`, e.entityId);
|
||||
}
|
||||
for (const b of v.fragment.connectorBindings) {
|
||||
if (b.handle === undefined) continue;
|
||||
const owner = claimed.get(`${b.kind}:${b.handle}`);
|
||||
if (owner !== undefined) {
|
||||
return { ok: false, error: `${b.kind} handle "${b.handle}" is already registered to human "${owner}" — a handle maps to exactly one human (registration conflict)` };
|
||||
}
|
||||
}
|
||||
const conflict = handleConflict(v.fragment, home);
|
||||
if (conflict) return { ok: false, error: conflict };
|
||||
const w = atomicWrite(file, stringifyYaml(v.fragment));
|
||||
if (!w.ok) return { ok: false, error: w.error };
|
||||
// A new or replaced record may author any availability: it's a change of its own.
|
||||
@@ -570,6 +612,12 @@ export function parseBindingSpec(spec: string):
|
||||
if (!kind || !connectorRef || !secretsRef || !role) {
|
||||
return { ok: false, error: `binding spec fields must be non-empty: kind:connectorRef:secretsRef:role[:handle=<id>] (got "${spec}")` };
|
||||
}
|
||||
if (kind === "relay" && secretsRef.startsWith("file:")) {
|
||||
const filePath = secretsRef.slice("file:".length);
|
||||
if (!filePath || filePath.startsWith("//") || !isAbsolute(filePath)) {
|
||||
return { ok: false, error: `binding file secretsRef must be file:<absolute-path> (got "${spec}")` };
|
||||
}
|
||||
}
|
||||
return { ok: true, binding: handle !== undefined ? { kind, connectorRef, secretsRef, role, handle } : { kind, connectorRef, secretsRef, role } };
|
||||
}
|
||||
|
||||
@@ -667,13 +715,17 @@ function handleConflict(fragment: HumanFragment, home: string): string | undefin
|
||||
const claimed = new Map<string, string>();
|
||||
for (const e of existing.entities) {
|
||||
if (e.entityId === fragment.entityId) continue;
|
||||
for (const b of e.connectorBindings) if (b.handle !== undefined) claimed.set(`${b.kind}:${b.handle}`, e.entityId);
|
||||
for (const b of e.connectorBindings) {
|
||||
if (b.handle !== undefined) {
|
||||
claimed.set(handleScopeKey({ kind: b.kind, connectorRef: b.connectorRef, handle: b.handle }), e.entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const b of fragment.connectorBindings) {
|
||||
if (b.handle === undefined) continue;
|
||||
const owner = claimed.get(`${b.kind}:${b.handle}`);
|
||||
const owner = claimed.get(handleScopeKey({ kind: b.kind, connectorRef: b.connectorRef, handle: b.handle }));
|
||||
if (owner !== undefined) {
|
||||
return `${b.kind} handle "${b.handle}" is already registered to human "${owner}" — a handle maps to exactly one human (registration conflict)`;
|
||||
return `${handleScopeLabel({ kind: b.kind, connectorRef: b.connectorRef, handle: b.handle })} is already registered to human "${owner}" — a handle maps to exactly one human (registration conflict)`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// dispatcher and the REAL tick.
|
||||
|
||||
import type { QueueItem, QueueRepository } from "../queue-repository.js";
|
||||
import { RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX } from "../queue-transition-log.js";
|
||||
import { DispatchBuffer } from "./dispatch-buffer.js";
|
||||
import { lastMeaningfulTransition } from "../queue-waiting.js";
|
||||
import type { DispatchResult } from "./dispatcher.js";
|
||||
@@ -92,6 +93,7 @@ export function makeOperatorDeliveryEngine(deps: {
|
||||
if (note.startsWith("escalation-rung: operator dispatched-to-engine")) resolved = null;
|
||||
if (note.split(/\s+/).includes(`notification_key=${key}`)) {
|
||||
if (note.startsWith("slack-owner-notification-posted ")) resolved = "posted";
|
||||
if (note.startsWith(`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX} `)) resolved = "accepted";
|
||||
if (note.startsWith("delivery-termination:")) resolved = "terminated";
|
||||
}
|
||||
}
|
||||
@@ -134,9 +136,12 @@ export function makeOperatorDeliveryEngine(deps: {
|
||||
// Already-accepted decisions returned above retain gateway custody, even if readiness later changes.
|
||||
try {
|
||||
const readiness = await (deps.readiness?.(human) ?? resolveHumanDeliveryReadiness({
|
||||
human, config: cfg, gatewayState: deps.gatewayState?.() ?? "unavailable",
|
||||
human,
|
||||
config: cfg,
|
||||
gatewayState: deps.gatewayState?.() ?? "unavailable",
|
||||
botToken: resolveSecret("SLACK_BOT_TOKEN", { envFile: cfg.secretsEnvFile ?? undefined }),
|
||||
}));
|
||||
home: deps.home,
|
||||
}, { entities: reg.entities }));
|
||||
if (!readiness.ready) return { decision: `unavailable:human-delivery:${readiness.reason}`, resolved: false, dispatched: false };
|
||||
} catch {
|
||||
return { decision: "unavailable:human-delivery:readiness-unavailable", resolved: false, dispatched: false };
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import { isAbsolute, resolve as resolvePath } from "node:path";
|
||||
import { getOpenRigHome } from "../../openrig-compat.js";
|
||||
import { constantTimeEqual } from "../../middleware/auth-bearer-token.js";
|
||||
import {
|
||||
loadHumanRegistry,
|
||||
registeredPeople,
|
||||
type HumanFragment,
|
||||
} from "./human-registry.js";
|
||||
import {
|
||||
nodeSecretFs,
|
||||
parseEnvFile,
|
||||
type SecretFsOps,
|
||||
} from "./slack/secrets.js";
|
||||
|
||||
export const RELAY_ENV_KEYS = {
|
||||
url: "OPENRIG_RELAY_URL",
|
||||
outboundToken: "OPENRIG_RELAY_OUTBOUND_TOKEN",
|
||||
inboundToken: "OPENRIG_RELAY_INBOUND_TOKEN",
|
||||
} as const;
|
||||
|
||||
export interface RelayCredentials {
|
||||
readonly url: string;
|
||||
readonly outboundToken: string;
|
||||
readonly inboundToken: string;
|
||||
}
|
||||
|
||||
export interface RelayProfileReadiness {
|
||||
readonly connectorRef: string;
|
||||
readonly state: "ready" | "not-ready";
|
||||
readonly configured: boolean;
|
||||
readonly ready: boolean;
|
||||
readonly reason: string;
|
||||
readonly nextAction: string | null;
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
|
||||
export interface RelayBoundHuman {
|
||||
readonly entityId: string;
|
||||
readonly address: string;
|
||||
readonly role: "primary" | "secondary";
|
||||
readonly secretsRef: string;
|
||||
readonly handle?: string;
|
||||
}
|
||||
|
||||
export interface RelayProfile extends RelayProfileReadiness {
|
||||
readonly secretsRef: string | null;
|
||||
readonly envFile: string | null;
|
||||
readonly endpointUrl: string | null;
|
||||
readonly readiness: RelayProfileReadiness;
|
||||
readonly humans: readonly RelayBoundHuman[];
|
||||
/** Private credentials for outbound/inbound transport; null when not ready; non-enumerable and never serialized. */
|
||||
readonly credentials: RelayCredentials | null;
|
||||
}
|
||||
|
||||
export interface LoadRelayProfilesOptions {
|
||||
readonly entities?: readonly HumanFragment[];
|
||||
readonly fsops?: SecretFsOps;
|
||||
}
|
||||
|
||||
export interface LoadRelayProfilesResult {
|
||||
readonly ok: boolean;
|
||||
readonly registryError: string | null;
|
||||
readonly profiles: ReadonlyMap<string, RelayProfile>;
|
||||
get(connectorRef: string): RelayProfile | undefined;
|
||||
}
|
||||
|
||||
/** Parse a relay binding's `secretsRef` (`file:<absolute-env-file>`).
|
||||
* Rejects bare paths, relative paths, empty pointers, and `file://` authority URLs. */
|
||||
export function parseRelaySecretsRef(
|
||||
secretsRef: string,
|
||||
): { ok: true; envFile: string } | { ok: false; reason: string } {
|
||||
const trimmed = secretsRef.trim();
|
||||
if (!trimmed.startsWith("file:") || trimmed.includes("\0")) {
|
||||
return { ok: false, reason: "relay secretsRef must use file:<absolute-path> syntax" };
|
||||
}
|
||||
const rawPath = trimmed.slice("file:".length);
|
||||
if (!rawPath || rawPath.startsWith("//") || !isAbsolute(rawPath)) {
|
||||
return { ok: false, reason: "relay secretsRef must point to an absolute file path" };
|
||||
}
|
||||
return { ok: true, envFile: resolvePath(rawPath) };
|
||||
}
|
||||
|
||||
const IPV4_LOOPBACK_RE = /^127(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
|
||||
function extractRawAuthorityHost(rawUrl: string): string | null {
|
||||
const match = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]+)/.exec(rawUrl);
|
||||
if (!match) return null;
|
||||
const authority = match[1]!;
|
||||
if (authority.startsWith("[")) {
|
||||
const end = authority.indexOf("]");
|
||||
return end === -1 ? null : authority.slice(1, end).toLowerCase();
|
||||
}
|
||||
const colon = authority.indexOf(":");
|
||||
return (colon === -1 ? authority : authority.slice(0, colon)).toLowerCase();
|
||||
}
|
||||
|
||||
function isStrictLoopbackHost(hostname: string, rawHost: string): boolean {
|
||||
const norm = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (norm === "localhost" && rawHost === "localhost") return true;
|
||||
if ((norm === "::1" || norm === "0:0:0:0:0:0:0:1") && (rawHost === "::1" || rawHost === "0:0:0:0:0:0:0:1")) {
|
||||
return true;
|
||||
}
|
||||
return IPV4_LOOPBACK_RE.test(norm) && IPV4_LOOPBACK_RE.test(rawHost);
|
||||
}
|
||||
|
||||
/** Validate `OPENRIG_RELAY_URL`: must be `http:`, loopback host (`127.x.y.z`, `::1`, or `localhost`),
|
||||
* with no embedded credentials and no fragment. Never echoes raw URL or credentials in reasons. */
|
||||
export function validateRelayUrl(
|
||||
rawUrl: string,
|
||||
): { ok: true; url: string } | { ok: false; reason: string } {
|
||||
const trimmed = rawUrl.trim();
|
||||
if (!trimmed || /[\r\n\0]/.test(trimmed)) {
|
||||
return { ok: false, reason: "OPENRIG_RELAY_URL is empty or malformed" };
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
return { ok: false, reason: "OPENRIG_RELAY_URL is not a valid URL" };
|
||||
}
|
||||
if (parsed.protocol !== "http:") {
|
||||
return { ok: false, reason: "OPENRIG_RELAY_URL must use http: scheme" };
|
||||
}
|
||||
if (parsed.username !== "" || parsed.password !== "") {
|
||||
return { ok: false, reason: "OPENRIG_RELAY_URL must not contain embedded credentials" };
|
||||
}
|
||||
if (parsed.hash !== "") {
|
||||
return { ok: false, reason: "OPENRIG_RELAY_URL must not contain a URL fragment" };
|
||||
}
|
||||
const rawHost = extractRawAuthorityHost(trimmed);
|
||||
if (!rawHost || !isStrictLoopbackHost(parsed.hostname, rawHost)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "OPENRIG_RELAY_URL host must be loopback (numeric 127.0.0.1/8, ::1, or localhost)",
|
||||
};
|
||||
}
|
||||
return { ok: true, url: parsed.toString() };
|
||||
}
|
||||
|
||||
function createPrivateCredentials(url: string, outboundToken: string, inboundToken: string): RelayCredentials {
|
||||
const creds = { url, outboundToken, inboundToken };
|
||||
Object.defineProperty(creds, "toJSON", { value: () => "[REDACTED]", enumerable: false });
|
||||
Object.defineProperty(creds, Symbol.for("nodejs.util.inspect.custom"), {
|
||||
value: () => "[RelayCredentials REDACTED]",
|
||||
enumerable: false,
|
||||
});
|
||||
return Object.freeze(creds);
|
||||
}
|
||||
|
||||
/** Check permissions on a relay secret file. Like Slack secret file checks, unsafe group/other
|
||||
* permission bits (`(mode & 0o077) !== 0`) produce a warning rather than making the profile unready.
|
||||
* Owner-only modes such as `0600` or `0400` produce no warning (`null`). */
|
||||
function checkRelaySecretFilePermissions(
|
||||
envFile: string,
|
||||
fsops: SecretFsOps,
|
||||
connectorRef: string,
|
||||
): string | null {
|
||||
const mode = fsops.statMode(envFile);
|
||||
if (mode === null) return null;
|
||||
if ((mode & 0o077) !== 0) {
|
||||
return `relay secret file for '${connectorRef}' has insecure permissions (mode 0${mode.toString(8).padStart(3, "0")}; group/other access bits are set — restrict so group/other have no access, e.g. 0600 or 0400)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface CandidateProfile {
|
||||
connectorRef: string;
|
||||
secretsRef: string | null;
|
||||
envFile: string | null;
|
||||
endpointUrl: string | null;
|
||||
humans: RelayBoundHuman[];
|
||||
nextAction: string | null;
|
||||
warnings: string[];
|
||||
unreadyReason?: string;
|
||||
url?: string;
|
||||
outboundToken?: string;
|
||||
inboundToken?: string;
|
||||
}
|
||||
|
||||
function evaluateRelayCandidate(
|
||||
connectorRef: string,
|
||||
humans: RelayBoundHuman[],
|
||||
fsops: SecretFsOps,
|
||||
): CandidateProfile {
|
||||
const firstHuman = humans[0]!;
|
||||
const defaultNextAction = `rig gateway human show ${firstHuman.entityId} --json`;
|
||||
const warnings: string[] = [];
|
||||
const fail = (
|
||||
unreadyReason: string,
|
||||
secretsRef: string | null = firstHuman.secretsRef,
|
||||
envFile: string | null = null,
|
||||
nextAction: string = defaultNextAction,
|
||||
): CandidateProfile => ({
|
||||
connectorRef,
|
||||
secretsRef,
|
||||
envFile,
|
||||
endpointUrl: null,
|
||||
humans,
|
||||
nextAction,
|
||||
warnings,
|
||||
unreadyReason,
|
||||
});
|
||||
|
||||
const envFiles = new Set<string>();
|
||||
for (const h of humans) {
|
||||
const parsed = parseRelaySecretsRef(h.secretsRef);
|
||||
if (!parsed.ok) {
|
||||
return fail(
|
||||
`relay profile '${connectorRef}' has invalid secretsRef: ${parsed.reason}`,
|
||||
h.secretsRef,
|
||||
null,
|
||||
`rig gateway human show ${h.entityId} --json`,
|
||||
);
|
||||
}
|
||||
envFiles.add(parsed.envFile);
|
||||
}
|
||||
if (envFiles.size > 1) {
|
||||
return fail(
|
||||
`relay profile '${connectorRef}' has inconsistent provisioning across human bindings (conflicting secretsRef file paths)`,
|
||||
null,
|
||||
null,
|
||||
"rig gateway human list --json",
|
||||
);
|
||||
}
|
||||
|
||||
const [envFile] = envFiles as Set<string> & [string];
|
||||
const secretsRef = firstHuman.secretsRef;
|
||||
const mode = fsops.statMode(envFile!);
|
||||
if (mode === null) {
|
||||
return fail(`relay secret file for '${connectorRef}' is missing or unreadable`, secretsRef, envFile!);
|
||||
}
|
||||
const permWarning = checkRelaySecretFilePermissions(envFile!, fsops, connectorRef);
|
||||
if (permWarning) {
|
||||
warnings.push(permWarning);
|
||||
}
|
||||
|
||||
let rawText: string;
|
||||
try {
|
||||
rawText = fsops.readFileSync(envFile!);
|
||||
} catch {
|
||||
return fail(`relay secret file for '${connectorRef}' is missing or unreadable`, secretsRef, envFile!);
|
||||
}
|
||||
|
||||
const parsedEnv = parseEnvFile(rawText);
|
||||
const rawUrl = (parsedEnv[RELAY_ENV_KEYS.url] ?? "").trim();
|
||||
const outboundToken = (parsedEnv[RELAY_ENV_KEYS.outboundToken] ?? "").trim();
|
||||
const inboundToken = (parsedEnv[RELAY_ENV_KEYS.inboundToken] ?? "").trim();
|
||||
|
||||
if (!rawUrl) {
|
||||
return fail(`relay profile '${connectorRef}' is missing ${RELAY_ENV_KEYS.url}`, secretsRef, envFile!);
|
||||
}
|
||||
if (!outboundToken || /[\r\n\0]/.test(outboundToken)) {
|
||||
return fail(`relay profile '${connectorRef}' is missing ${RELAY_ENV_KEYS.outboundToken}`, secretsRef, envFile!);
|
||||
}
|
||||
if (!inboundToken || /[\r\n\0]/.test(inboundToken)) {
|
||||
return fail(`relay profile '${connectorRef}' is missing ${RELAY_ENV_KEYS.inboundToken}`, secretsRef, envFile!);
|
||||
}
|
||||
if (constantTimeEqual(outboundToken, inboundToken)) {
|
||||
warnings.push(
|
||||
`relay profile '${connectorRef}' uses the same token for ${RELAY_ENV_KEYS.outboundToken} and ${RELAY_ENV_KEYS.inboundToken}; separate tokens are recommended so outbound bridge capture cannot authenticate inbound replies`,
|
||||
);
|
||||
}
|
||||
|
||||
const urlVerdict = validateRelayUrl(rawUrl);
|
||||
if (!urlVerdict.ok) {
|
||||
return fail(
|
||||
`relay profile '${connectorRef}' has invalid ${RELAY_ENV_KEYS.url}: ${urlVerdict.reason}`,
|
||||
secretsRef,
|
||||
envFile!,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
connectorRef,
|
||||
secretsRef,
|
||||
envFile: envFile!,
|
||||
endpointUrl: urlVerdict.url,
|
||||
humans,
|
||||
nextAction: defaultNextAction,
|
||||
warnings,
|
||||
url: urlVerdict.url,
|
||||
outboundToken,
|
||||
inboundToken,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRelayProfile(c: CandidateProfile): RelayProfile {
|
||||
const ready = !c.unreadyReason;
|
||||
const frozenWarnings = Object.freeze([...c.warnings]);
|
||||
const baseVerifiedReason = "relay profile verified (loopback HTTP endpoint and directional tokens configured)";
|
||||
const readyReason =
|
||||
frozenWarnings.length > 0
|
||||
? `${baseVerifiedReason}; warning: ${frozenWarnings.join("; ")}`
|
||||
: baseVerifiedReason;
|
||||
const readiness: RelayProfileReadiness = Object.freeze({
|
||||
connectorRef: c.connectorRef,
|
||||
state: ready ? "ready" : "not-ready",
|
||||
configured: ready,
|
||||
ready,
|
||||
reason: c.unreadyReason ?? readyReason,
|
||||
nextAction: ready ? null : c.nextAction,
|
||||
warnings: frozenWarnings,
|
||||
});
|
||||
const credentials = ready ? createPrivateCredentials(c.url!, c.outboundToken!, c.inboundToken!) : null;
|
||||
const profile = {
|
||||
...readiness,
|
||||
secretsRef: c.secretsRef,
|
||||
envFile: c.envFile,
|
||||
endpointUrl: c.endpointUrl,
|
||||
readiness,
|
||||
humans: Object.freeze([...c.humans]),
|
||||
} as unknown as RelayProfile;
|
||||
|
||||
Object.defineProperty(profile, "credentials", {
|
||||
value: credentials,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
});
|
||||
|
||||
return Object.freeze(profile);
|
||||
}
|
||||
|
||||
/** Load and validate all relay profiles referenced by registered humans' `kind: "relay"` bindings.
|
||||
* Performs zero network I/O and never reads from `process.env`. */
|
||||
export function loadRelayProfiles(
|
||||
home: string = getOpenRigHome(),
|
||||
opts: LoadRelayProfilesOptions = {},
|
||||
): LoadRelayProfilesResult {
|
||||
const fsops = opts.fsops ?? nodeSecretFs;
|
||||
let entities: readonly HumanFragment[] = [];
|
||||
let registryError: string | null = null;
|
||||
let ok = true;
|
||||
|
||||
if (opts.entities !== undefined) {
|
||||
entities = opts.entities;
|
||||
} else {
|
||||
const loaded = loadHumanRegistry(home, { readOnly: true });
|
||||
const people = registeredPeople(loaded);
|
||||
if (people === null) {
|
||||
ok = false;
|
||||
registryError = loaded.ok ? null : loaded.error;
|
||||
} else {
|
||||
entities = people;
|
||||
}
|
||||
}
|
||||
|
||||
const grouped = new Map<string, RelayBoundHuman[]>();
|
||||
for (const entity of entities) {
|
||||
for (const binding of entity.connectorBindings) {
|
||||
if (binding.kind !== "relay") continue;
|
||||
const list = grouped.get(binding.connectorRef) ?? [];
|
||||
list.push({
|
||||
entityId: entity.entityId,
|
||||
address: entity.address,
|
||||
role: binding.role,
|
||||
secretsRef: binding.secretsRef,
|
||||
...(binding.handle !== undefined ? { handle: binding.handle } : {}),
|
||||
});
|
||||
grouped.set(binding.connectorRef, list);
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = [...grouped.keys()]
|
||||
.sort()
|
||||
.map((connectorRef) => evaluateRelayCandidate(connectorRef, grouped.get(connectorRef)!, fsops));
|
||||
|
||||
const validCandidates = candidates.filter((c) => !c.unreadyReason && c.inboundToken !== undefined);
|
||||
for (const current of validCandidates) {
|
||||
const collidingRefs = validCandidates
|
||||
.filter((other) => other.connectorRef !== current.connectorRef && constantTimeEqual(current.inboundToken!, other.inboundToken!))
|
||||
.map((other) => other.connectorRef);
|
||||
if (collidingRefs.length > 0) {
|
||||
current.unreadyReason =
|
||||
`relay profile '${current.connectorRef}' reuses an ambiguous ${RELAY_ENV_KEYS.inboundToken} shared with relay profile(s): ${collidingRefs.join(", ")}`;
|
||||
}
|
||||
}
|
||||
|
||||
const profiles = new Map<string, RelayProfile>();
|
||||
for (const c of candidates) {
|
||||
profiles.set(c.connectorRef, buildRelayProfile(c));
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
ok,
|
||||
registryError,
|
||||
profiles,
|
||||
get(connectorRef: string): RelayProfile | undefined {
|
||||
return profiles.get(connectorRef);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type RelayInboundAuthResult =
|
||||
| { ok: true; connectorRef: string; profile: RelayProfile }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
/** Derive authenticated relay identity from a bearer token by comparing against all ready
|
||||
* profiles' `inboundToken` using `constantTimeEqual` without short-circuiting. */
|
||||
export function authenticateRelayInboundToken(
|
||||
loaded: LoadRelayProfilesResult,
|
||||
bearerToken: string | null | undefined,
|
||||
): RelayInboundAuthResult {
|
||||
if (typeof bearerToken !== "string" || bearerToken.length === 0) {
|
||||
return { ok: false, reason: "missing relay bearer token" };
|
||||
}
|
||||
const matches: RelayProfile[] = [];
|
||||
for (const profile of loaded.profiles.values()) {
|
||||
if (profile.ready && profile.credentials && constantTimeEqual(bearerToken, profile.credentials.inboundToken)) {
|
||||
matches.push(profile);
|
||||
}
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
return { ok: true, connectorRef: matches[0]!.connectorRef, profile: matches[0]! };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: matches.length > 1 ? "ambiguous relay bearer token across profiles" : "invalid relay bearer token",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,897 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
HANDLE_PATTERN,
|
||||
loadHumanRegistry,
|
||||
registeredPeople,
|
||||
resolveRelayHandle,
|
||||
type HumanFragment,
|
||||
} from "./human-registry.js";
|
||||
import {
|
||||
authenticateRelayInboundToken,
|
||||
loadRelayProfiles,
|
||||
} from "./relay-config.js";
|
||||
import {
|
||||
RELAY_PROTOCOL_VERSION,
|
||||
decodeRelayReplyRef,
|
||||
encodeRelayReplyRef,
|
||||
findPendingRelayReplyRef,
|
||||
hashRelayConnectorRef,
|
||||
type RelayReplyRefPayload,
|
||||
type RelayReplyRefToken,
|
||||
} from "./relay-outbound.js";
|
||||
import { SeenStore, nodeStateFs, type StateFsOps } from "./slack/state-store.js";
|
||||
import type { RelayOwnerNotificationReceipt } from "../queue-transition-log.js";
|
||||
import type { QueueRepository } from "../queue-repository.js";
|
||||
import {
|
||||
MissionControlWriteContractError,
|
||||
type MissionControlActionInput,
|
||||
type MissionControlWriteContract,
|
||||
} from "../mission-control/mission-control-write-contract.js";
|
||||
import type { MissionControlActionLog } from "../mission-control/mission-control-action-log.js";
|
||||
|
||||
export const RELAY_INBOUND_SEEN_FILE = "relay-inbound-seen.jsonl" as const;
|
||||
export const RELAY_INBOUND_MAX_BODY_BYTES = 64 * 1024;
|
||||
|
||||
const EVENT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,256}$/;
|
||||
const MAX_REPLY_REF_CHARS = 4096;
|
||||
const MAX_REPLY_TEXT_CHARS = 32_768;
|
||||
const VALID_SEEN_STATUS_PATTERN = /^(received|processed):([0-9a-f]{64})$/;
|
||||
const SUSPECT_SEEN_STATUS = "suspect";
|
||||
|
||||
export type RelayInboundOrdering = "after-question" | "uncertain";
|
||||
|
||||
export interface RelayInboundEnvelope {
|
||||
eventId: string;
|
||||
sender: { handle: string };
|
||||
replyRef: string;
|
||||
text: string;
|
||||
ordering: RelayInboundOrdering;
|
||||
}
|
||||
|
||||
export interface RelayInboundRequestInput {
|
||||
authorizationHeader: string | undefined;
|
||||
rawBody: string;
|
||||
}
|
||||
|
||||
export interface RelayInboundResponse {
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RelayInboundHandlerDeps {
|
||||
home: string;
|
||||
queueRepo: QueueRepository;
|
||||
missionControlWriteContract: MissionControlWriteContract;
|
||||
missionControlActionLog?: MissionControlActionLog;
|
||||
stateFs?: StateFsOps;
|
||||
loadEntities?: () =>
|
||||
| { ok: true; entities: HumanFragment[] }
|
||||
| { ok: false; error: string; noHumans?: true };
|
||||
}
|
||||
|
||||
export interface RelayInboundHandler {
|
||||
handle(input: RelayInboundRequestInput): Promise<RelayInboundResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a TCP socket remoteAddress is a direct local loopback peer.
|
||||
* Never trusts HTTP Host or X-Forwarded-* headers.
|
||||
* Rejects missing/empty remoteAddress (e.g. in-memory app.request without a socket).
|
||||
*/
|
||||
export function isDirectLoopbackPeer(remoteAddress: string | undefined | null): boolean {
|
||||
if (typeof remoteAddress !== "string") return false;
|
||||
const trimmed = remoteAddress.trim().toLowerCase();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed === "::1") return true;
|
||||
const stripped = trimmed.startsWith("::ffff:") ? trimmed.slice("::ffff:".length) : trimmed;
|
||||
const ipv4Match = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(stripped);
|
||||
if (!ipv4Match) return false;
|
||||
return [ipv4Match[1], ipv4Match[2], ipv4Match[3]].every((octet) => {
|
||||
const num = Number(octet);
|
||||
return Number.isInteger(num) && num >= 0 && num <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic SeenStore key for a relay inbound event, scoped globally across
|
||||
* the authenticated relay profile's questions so reusing an eventId on a
|
||||
* different question is detected as equivocation.
|
||||
*/
|
||||
export function computeRelayEventKey(connectorRef: string, eventId: string): string {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify([connectorRef, eventId]))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic metadata-only fingerprint of the canonical inbound envelope and
|
||||
* resolved responder identity. Never stores plaintext reply text.
|
||||
*/
|
||||
export function computeRelayEventFingerprint(input: {
|
||||
connectorRef: string;
|
||||
eventId: string;
|
||||
senderHandle: string;
|
||||
actorSession: string;
|
||||
replyRef: string;
|
||||
qitemId: string;
|
||||
episodeKey: string;
|
||||
decisionRevision: string;
|
||||
mode: "ask" | "park";
|
||||
ordering: RelayInboundOrdering;
|
||||
text: string;
|
||||
}): string {
|
||||
const textHash = createHash("sha256").update(input.text).digest("hex");
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
connectorRef: input.connectorRef,
|
||||
eventId: input.eventId,
|
||||
senderHandle: input.senderHandle,
|
||||
actorSession: input.actorSession,
|
||||
replyRef: input.replyRef,
|
||||
qitemId: input.qitemId,
|
||||
episodeKey: input.episodeKey,
|
||||
decisionRevision: input.decisionRevision,
|
||||
mode: input.mode,
|
||||
ordering: input.ordering,
|
||||
textHash,
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function extractBearerToken(header: string | undefined): string | null {
|
||||
if (typeof header !== "string") return null;
|
||||
const match = /^Bearer\s+(\S+)$/.exec(header.trim());
|
||||
if (!match || !match[1]) return null;
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function parseInboundEnvelope(
|
||||
rawBody: string,
|
||||
): { ok: true; envelope: RelayInboundEnvelope } | { ok: false; status: number; code: string; message: string } {
|
||||
if (Buffer.byteLength(rawBody, "utf8") > RELAY_INBOUND_MAX_BODY_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 413,
|
||||
code: "payload_too_large",
|
||||
message: `relay inbound payload exceeds ${RELAY_INBOUND_MAX_BODY_BYTES} bytes`,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
code: "invalid_json",
|
||||
message: "request body must be valid JSON",
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
code: "invalid_envelope",
|
||||
message: "relay inbound payload must be a JSON object",
|
||||
};
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const { eventId, sender, replyRef, text, ordering } = obj;
|
||||
if (typeof eventId !== "string" || !EVENT_ID_PATTERN.test(eventId)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
code: "invalid_envelope",
|
||||
message: "eventId must be a non-empty identifier (1..256 chars, [A-Za-z0-9._:-])",
|
||||
};
|
||||
}
|
||||
|
||||
if (!sender || typeof sender !== "object" || Array.isArray(sender)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
code: "invalid_envelope",
|
||||
message: "sender must be an object with a handle field",
|
||||
};
|
||||
}
|
||||
const senderObj = sender as Record<string, unknown>;
|
||||
if (typeof senderObj.handle !== "string" || !HANDLE_PATTERN.test(senderObj.handle)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
code: "invalid_envelope",
|
||||
message: "sender.handle must be a valid non-empty handle",
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof replyRef !== "string" || replyRef.length === 0 || replyRef.length > MAX_REPLY_REF_CHARS) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
code: "invalid_envelope",
|
||||
message: "replyRef must be a non-empty string",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
typeof text !== "string" ||
|
||||
text.includes("\0") ||
|
||||
text.length > MAX_REPLY_TEXT_CHARS ||
|
||||
text.trim().length === 0
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
code: "invalid_envelope",
|
||||
message: "text must be a non-empty string without NUL bytes",
|
||||
};
|
||||
}
|
||||
|
||||
let normalizedOrdering: RelayInboundOrdering = "uncertain";
|
||||
if (ordering !== undefined) {
|
||||
if (ordering !== "after-question" && ordering !== "uncertain") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
code: "invalid_envelope",
|
||||
message: "ordering must be 'after-question' or 'uncertain' when provided",
|
||||
};
|
||||
}
|
||||
normalizedOrdering = ordering;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
envelope: {
|
||||
eventId,
|
||||
sender: { handle: senderObj.handle },
|
||||
replyRef,
|
||||
text: text.trim(),
|
||||
ordering: normalizedOrdering,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function reconstructPayloadFromReceipt(
|
||||
receipt: RelayOwnerNotificationReceipt & { qitemId: string },
|
||||
replyRef: string,
|
||||
tokenRef: RelayReplyRefToken,
|
||||
): { ok: true; payload: RelayReplyRefPayload; receiptKind: string | null } | { ok: false } {
|
||||
const episodeKey = receipt.episodeKey ?? receipt.notificationKey;
|
||||
const decisionRevision = receipt.revision;
|
||||
const decisionId = receipt.deliveryId;
|
||||
const connectorRef = receipt.connectorRef;
|
||||
if (
|
||||
typeof receipt.qitemId !== "string" ||
|
||||
receipt.qitemId.length === 0 ||
|
||||
typeof episodeKey !== "string" ||
|
||||
episodeKey.length === 0 ||
|
||||
typeof decisionRevision !== "string" ||
|
||||
decisionRevision.length === 0 ||
|
||||
typeof decisionId !== "string" ||
|
||||
decisionId.length === 0 ||
|
||||
typeof connectorRef !== "string" ||
|
||||
connectorRef.length === 0
|
||||
) {
|
||||
return { ok: false };
|
||||
}
|
||||
const candidate: RelayReplyRefPayload = {
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
qitemId: receipt.qitemId,
|
||||
episodeKey,
|
||||
decisionRevision,
|
||||
decisionId,
|
||||
connectorRef,
|
||||
mode: tokenRef.mode,
|
||||
...(tokenRef.digest === true ? { digest: true } : {}),
|
||||
};
|
||||
if (encodeRelayReplyRef(candidate) !== replyRef) {
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, payload: candidate, receiptKind: receipt.kind ?? null };
|
||||
}
|
||||
|
||||
function resolveOfferedReplyRefContext(
|
||||
home: string,
|
||||
queueRepo: QueueRepository,
|
||||
replyRef: string,
|
||||
tokenRef: RelayReplyRefToken,
|
||||
authenticatedConnectorRef: string,
|
||||
):
|
||||
| { ok: true; ref: RelayReplyRefPayload; receiptKind: string | null }
|
||||
| { ok: false; status: 403 | 409 | 503; code: string; message: string } {
|
||||
let resolvedRef: RelayReplyRefPayload | null = null;
|
||||
let receiptKind: string | null = null;
|
||||
|
||||
// 1. Check durable accepted receipts FIRST (active queue_transitions + queue_transitions_archive)
|
||||
let receipt: (RelayOwnerNotificationReceipt & { qitemId: string }) | null = null;
|
||||
try {
|
||||
if (typeof queueRepo.findRelayOwnerNotificationReceiptByReplyRef === "function") {
|
||||
receipt = queueRepo.findRelayOwnerNotificationReceiptByReplyRef(replyRef);
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
code: "queue_store_unavailable",
|
||||
message: "Relay inbound receipt lookup failed because queue transition store is unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
if (receipt) {
|
||||
const reconstructed = reconstructPayloadFromReceipt(receipt, replyRef, tokenRef);
|
||||
if (reconstructed.ok) {
|
||||
resolvedRef = reconstructed.payload;
|
||||
receiptKind = reconstructed.receiptKind;
|
||||
} else if (receipt.kind === "human-update") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
code: "reply_not_allowed_for_update",
|
||||
message: `qitem ${receipt.qitemId} is an informational update and cannot be answered`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to pending DispatchBuffer ONLY when no durable accepted receipt matched
|
||||
if (!resolvedRef) {
|
||||
try {
|
||||
const pending = findPendingRelayReplyRef(home, replyRef);
|
||||
if (
|
||||
pending.found &&
|
||||
pending.qitemId &&
|
||||
pending.episodeKey &&
|
||||
pending.decisionRevision &&
|
||||
pending.decisionId &&
|
||||
pending.connectorRef &&
|
||||
pending.mode
|
||||
) {
|
||||
const candidate: RelayReplyRefPayload = {
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
qitemId: pending.qitemId,
|
||||
episodeKey: pending.episodeKey,
|
||||
decisionRevision: pending.decisionRevision,
|
||||
decisionId: pending.decisionId,
|
||||
connectorRef: pending.connectorRef,
|
||||
mode: pending.mode,
|
||||
...(pending.digest === true ? { digest: true } : {}),
|
||||
};
|
||||
if (encodeRelayReplyRef(candidate) === replyRef) {
|
||||
resolvedRef = candidate;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
code: "dispatch_buffer_unavailable",
|
||||
message:
|
||||
"Relay outbound dispatch buffer is unreadable; refusing to reject or reserve un-receipted replyRef",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedRef) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
code: "unrecognized_reply_ref",
|
||||
message: "replyRef does not match any active or accepted outbound relay decision",
|
||||
};
|
||||
}
|
||||
|
||||
if (resolvedRef.connectorRef !== authenticatedConnectorRef) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
code: "connector_ref_mismatch",
|
||||
message: "replyRef connectorRef does not match the authenticated relay profile",
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, ref: resolvedRef, receiptKind };
|
||||
}
|
||||
|
||||
function verifyOfferedReplyTarget(
|
||||
queueRepo: QueueRepository,
|
||||
resolvedRef: RelayReplyRefPayload,
|
||||
receiptKind: string | null,
|
||||
): { ok: true } | { ok: false; status: 404 | 409 | 503; code: string; message: string } {
|
||||
if (receiptKind === "human-update") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
code: "reply_not_allowed_for_update",
|
||||
message: `qitem ${resolvedRef.qitemId} is an informational update and cannot be answered`,
|
||||
};
|
||||
}
|
||||
|
||||
let item: ReturnType<QueueRepository["getById"]>;
|
||||
let transitions: ReturnType<QueueRepository["listTransitions"]>;
|
||||
try {
|
||||
item = queueRepo.getById(resolvedRef.qitemId);
|
||||
transitions = item ? queueRepo.listTransitions(resolvedRef.qitemId) : [];
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
code: "queue_store_unavailable",
|
||||
message: "Relay inbound target verification failed because queue store is unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
code: "qitem_not_found",
|
||||
message: `qitem ${resolvedRef.qitemId} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.humanIntent === "update") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
code: "reply_not_allowed_for_update",
|
||||
message: `qitem ${resolvedRef.qitemId} is an informational update and cannot be answered`,
|
||||
};
|
||||
}
|
||||
|
||||
const episodeIdPart = Number(resolvedRef.episodeKey.slice(`${resolvedRef.qitemId}:`.length));
|
||||
const episodeTransition = transitions.find((t) => t.transitionId === episodeIdPart);
|
||||
if (episodeTransition?.ownerNotificationKind === "human-update") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
code: "reply_not_allowed_for_update",
|
||||
message: `episode ${resolvedRef.episodeKey} is an informational update and cannot be answered`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function formatDecisionText(text: string, ordering: RelayInboundOrdering): string {
|
||||
if (ordering === "after-question") return text;
|
||||
return `${text} [relay ordering=uncertain: timing relative to question was not verified by relay]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap StateFsOps so SeenStore.statuses() fails closed (503) when history is
|
||||
* unreadable or contains unparseable/unkeyable lines, while scoping keyed
|
||||
* suspect records (missing/invalid status or conflicting fingerprints) to only
|
||||
* their own eventKey so unrelated keys remain available and unvouched history
|
||||
* never counts as unseen. Diagnostic fields such as `ts` or extra metadata are
|
||||
* ignored when `id` and `status` vouch for the event.
|
||||
*/
|
||||
function strictRelayStateFs(base: StateFsOps = nodeStateFs): StateFsOps {
|
||||
return {
|
||||
...base,
|
||||
readFileSync(p: string): string {
|
||||
const raw = base.readFileSync(p);
|
||||
const suspectIds = new Set<string>();
|
||||
const seenFingerprints = new Map<string, string>();
|
||||
const normalizedLines: string[] = [];
|
||||
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error("corrupt relay inbound seen-store JSONL line");
|
||||
}
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed) ||
|
||||
typeof (parsed as { id?: unknown }).id !== "string" ||
|
||||
!(parsed as { id: string }).id.trim()
|
||||
) {
|
||||
throw new Error("unkeyable relay inbound seen-store record");
|
||||
}
|
||||
const rec = parsed as { id: string; status?: unknown };
|
||||
const id = rec.id;
|
||||
const statusStr = typeof rec.status === "string" ? rec.status : "";
|
||||
const match = VALID_SEEN_STATUS_PATTERN.exec(statusStr);
|
||||
const priorFp = seenFingerprints.get(id);
|
||||
if (
|
||||
!match ||
|
||||
suspectIds.has(id) ||
|
||||
(priorFp !== undefined && priorFp !== match[2])
|
||||
) {
|
||||
suspectIds.add(id);
|
||||
normalizedLines.push(JSON.stringify({ id, status: SUSPECT_SEEN_STATUS }));
|
||||
} else {
|
||||
seenFingerprints.set(id, match[2]!);
|
||||
normalizedLines.push(trimmed);
|
||||
}
|
||||
}
|
||||
return normalizedLines.join("\n");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createRelayInboundHandler(deps: RelayInboundHandlerDeps): RelayInboundHandler {
|
||||
const seenPath = join(deps.home, "state", RELAY_INBOUND_SEEN_FILE);
|
||||
const seenStore = new SeenStore(seenPath, strictRelayStateFs(deps.stateFs ?? nodeStateFs));
|
||||
const inFlightByKey = new Map<string, Promise<RelayInboundResponse>>();
|
||||
|
||||
const loadEntities =
|
||||
deps.loadEntities ??
|
||||
(() => loadHumanRegistry(deps.home, { readOnly: true }));
|
||||
|
||||
const runSerialized = async (
|
||||
eventKey: string,
|
||||
fn: () => Promise<RelayInboundResponse>,
|
||||
): Promise<RelayInboundResponse> => {
|
||||
const prior = inFlightByKey.get(eventKey) ?? Promise.resolve({ status: 200, body: {} });
|
||||
const next = prior.then(
|
||||
() => fn(),
|
||||
() => fn(),
|
||||
);
|
||||
inFlightByKey.set(eventKey, next);
|
||||
try {
|
||||
return await next;
|
||||
} finally {
|
||||
if (inFlightByKey.get(eventKey) === next) {
|
||||
inFlightByKey.delete(eventKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
async handle(input: RelayInboundRequestInput): Promise<RelayInboundResponse> {
|
||||
// 1. Require explicit Bearer token; missing/unready never bypasses authentication
|
||||
const bearerToken = extractBearerToken(input.authorizationHeader);
|
||||
if (!bearerToken) {
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: "unauthorized",
|
||||
message: "Missing or malformed Authorization: Bearer token for relay inbound",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let entities: HumanFragment[] | null;
|
||||
try {
|
||||
entities = registeredPeople(loadEntities());
|
||||
} catch {
|
||||
entities = null;
|
||||
}
|
||||
if (entities === null) {
|
||||
return {
|
||||
status: 503,
|
||||
body: {
|
||||
error: "human_registry_unavailable",
|
||||
message: "Relay inbound authentication is unavailable because human registry cannot be read",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const profiles = loadRelayProfiles(deps.home, { entities });
|
||||
const auth = authenticateRelayInboundToken(profiles, bearerToken);
|
||||
if (!auth.ok) {
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: "unauthorized",
|
||||
reason: auth.reason,
|
||||
message: "Invalid or unready relay inbound bearer token",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const authenticatedConnectorRef = auth.profile.connectorRef;
|
||||
|
||||
// 2. Parse and validate closed JSON envelope
|
||||
const parsedEnv = parseInboundEnvelope(input.rawBody);
|
||||
if (!parsedEnv.ok) {
|
||||
return {
|
||||
status: parsedEnv.status,
|
||||
body: {
|
||||
error: parsedEnv.code,
|
||||
message: parsedEnv.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
const { envelope } = parsedEnv;
|
||||
|
||||
// 3. Decode replyRef token and enforce profile match + sender handle resolution
|
||||
const decodedRef = decodeRelayReplyRef(envelope.replyRef);
|
||||
if (!decodedRef.ok) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
error: "invalid_reply_ref",
|
||||
message: decodedRef.error,
|
||||
},
|
||||
};
|
||||
}
|
||||
const tokenRef = decodedRef.ref;
|
||||
|
||||
if (tokenRef.connectorHash !== hashRelayConnectorRef(authenticatedConnectorRef)) {
|
||||
return {
|
||||
status: 403,
|
||||
body: {
|
||||
error: "connector_ref_mismatch",
|
||||
message: "replyRef connectorRef does not match the authenticated relay profile",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const senderResolution = resolveRelayHandle(
|
||||
authenticatedConnectorRef,
|
||||
envelope.sender.handle,
|
||||
entities,
|
||||
);
|
||||
if (senderResolution.kind !== "registered") {
|
||||
return {
|
||||
status: 403,
|
||||
body: {
|
||||
error: "sender_not_bound",
|
||||
message: senderResolution.error,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const actorSession = senderResolution.address;
|
||||
const eventKey = computeRelayEventKey(authenticatedConnectorRef, envelope.eventId);
|
||||
|
||||
return runSerialized(eventKey, async () => {
|
||||
// 4. Strict SeenStore read path: fail closed (503) if corrupt/unreadable or keyed suspect
|
||||
let statuses: Map<string, string>;
|
||||
try {
|
||||
statuses = seenStore.statuses();
|
||||
} catch {
|
||||
return {
|
||||
status: 503,
|
||||
body: {
|
||||
error: "seen_store_unavailable",
|
||||
seenPath,
|
||||
message: `Relay inbound seen-store is unreadable (${seenPath}); refusing to process or acknowledge`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const existingStatus = statuses.get(eventKey);
|
||||
let existingFp: string | undefined;
|
||||
if (existingStatus !== undefined) {
|
||||
const match = VALID_SEEN_STATUS_PATTERN.exec(existingStatus);
|
||||
if (!match) {
|
||||
return {
|
||||
status: 503,
|
||||
body: {
|
||||
error: "seen_store_unavailable",
|
||||
seenPath,
|
||||
message: `Relay inbound seen-store record for this eventId is corrupt or unvouched (${seenPath}); refusing to process or acknowledge`,
|
||||
},
|
||||
};
|
||||
}
|
||||
existingFp = match[2];
|
||||
}
|
||||
|
||||
// 5. Resolve offered replyRef context (durable accepted receipt first, then pending DispatchBuffer)
|
||||
const resolved = resolveOfferedReplyRefContext(
|
||||
deps.home,
|
||||
deps.queueRepo,
|
||||
envelope.replyRef,
|
||||
tokenRef,
|
||||
authenticatedConnectorRef,
|
||||
);
|
||||
if (!resolved.ok) {
|
||||
return {
|
||||
status: resolved.status,
|
||||
body: {
|
||||
error: resolved.code,
|
||||
message: resolved.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ref = resolved.ref;
|
||||
const fingerprint = computeRelayEventFingerprint({
|
||||
connectorRef: authenticatedConnectorRef,
|
||||
eventId: envelope.eventId,
|
||||
senderHandle: envelope.sender.handle,
|
||||
actorSession,
|
||||
replyRef: envelope.replyRef,
|
||||
qitemId: ref.qitemId,
|
||||
episodeKey: ref.episodeKey,
|
||||
decisionRevision: ref.decisionRevision,
|
||||
mode: ref.mode,
|
||||
ordering: envelope.ordering,
|
||||
text: envelope.text,
|
||||
});
|
||||
|
||||
const receivedTag = `received:${fingerprint}`;
|
||||
const processedTag = `processed:${fingerprint}`;
|
||||
const verb = ref.mode === "ask" ? ("answer" as const) : ("resolve" as const);
|
||||
|
||||
// 6. Check existing seen-store status BEFORE reading current queue target so processed replays never depend on current getById/listTransitions
|
||||
if (existingStatus !== undefined) {
|
||||
if (existingFp !== fingerprint) {
|
||||
return {
|
||||
status: 409,
|
||||
body: {
|
||||
error: "event_conflict",
|
||||
message: "eventId was already used with a different payload, sender, or target",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (existingStatus === processedTag) {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
ok: true,
|
||||
deduplicated: true,
|
||||
qitemId: ref.qitemId,
|
||||
verb,
|
||||
actorSession,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 7. Verify current target & non-update guards only for new actions before first reservation
|
||||
const targetVerified = verifyOfferedReplyTarget(
|
||||
deps.queueRepo,
|
||||
ref,
|
||||
resolved.receiptKind,
|
||||
);
|
||||
if (!targetVerified.ok) {
|
||||
return {
|
||||
status: targetVerified.status,
|
||||
body: {
|
||||
error: targetVerified.code,
|
||||
message: targetVerified.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
seenStore.mark(eventKey, receivedTag);
|
||||
} catch {
|
||||
return {
|
||||
status: 503,
|
||||
body: {
|
||||
error: "seen_store_unavailable",
|
||||
seenPath,
|
||||
message: `Failed to reserve relay inbound event in seen-store (${seenPath})`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const operationId = `relay:${eventKey}:${fingerprint}`;
|
||||
const decision = formatDecisionText(envelope.text, envelope.ordering);
|
||||
const actionInput: MissionControlActionInput = {
|
||||
verb,
|
||||
qitemId: ref.qitemId,
|
||||
actorSession,
|
||||
identityProvenance: "transport:v1",
|
||||
decision,
|
||||
...(verb === "resolve" && envelope.ordering === "after-question"
|
||||
? { answerText: envelope.text }
|
||||
: {}),
|
||||
expectedRevision: ref.decisionRevision,
|
||||
auditNotes: {
|
||||
operationId,
|
||||
connectorRef: authenticatedConnectorRef,
|
||||
eventId: envelope.eventId,
|
||||
senderHandle: envelope.sender.handle,
|
||||
episodeKey: ref.episodeKey,
|
||||
ordering: envelope.ordering,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await deps.missionControlWriteContract.act(actionInput);
|
||||
try {
|
||||
seenStore.mark(eventKey, processedTag);
|
||||
} catch {
|
||||
return {
|
||||
status: 503,
|
||||
body: {
|
||||
error: "seen_store_unavailable",
|
||||
seenPath,
|
||||
message: `Action committed but seen-store processed mark failed (${seenPath}); retry safely with same eventId`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
ok: true,
|
||||
deduplicated: Boolean(result.reconciled),
|
||||
reconciled: Boolean(result.reconciled),
|
||||
qitemId: ref.qitemId,
|
||||
verb,
|
||||
actorSession,
|
||||
actionId: result.actionId,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof MissionControlWriteContractError) {
|
||||
// If this event was already reserved as `received:<fingerprint>` and its own
|
||||
// committed action changed the item's revision before `processed:<fingerprint>`
|
||||
// was marked (e.g. a resolve that committed and was later re-parked), check
|
||||
// whether the audit log already holds this exact operationId and reconcile
|
||||
// without any new mutation.
|
||||
if (
|
||||
existingStatus === receivedTag &&
|
||||
err.code === "decision_revision_changed" &&
|
||||
deps.missionControlActionLog
|
||||
) {
|
||||
const priorAudit = deps.missionControlActionLog
|
||||
.listForQitem(ref.qitemId, 50)
|
||||
.find((entry) => entry.auditNotes?.operationId === operationId);
|
||||
if (priorAudit) {
|
||||
try {
|
||||
seenStore.mark(eventKey, processedTag);
|
||||
} catch {
|
||||
return {
|
||||
status: 503,
|
||||
body: {
|
||||
error: "seen_store_unavailable",
|
||||
seenPath,
|
||||
message: `Action reconciled but seen-store processed mark failed (${seenPath})`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
ok: true,
|
||||
deduplicated: true,
|
||||
reconciled: true,
|
||||
qitemId: ref.qitemId,
|
||||
verb,
|
||||
actorSession,
|
||||
actionId: priorAudit.actionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const status =
|
||||
err.code === "qitem_not_found"
|
||||
? 404
|
||||
: err.code === "decision_revision_changed" ||
|
||||
err.code === "qitem_already_terminal" ||
|
||||
err.code === "qitem_not_leg1_parked" ||
|
||||
err.code === "answer_not_recorded" ||
|
||||
err.code === "answer_not_closed"
|
||||
? 409
|
||||
: 400;
|
||||
return {
|
||||
status,
|
||||
body: {
|
||||
error: err.code,
|
||||
message: err.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
error: "internal_error",
|
||||
message: "Relay inbound action failed before completion",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { DispatchBuffer } from "./dispatch-buffer.js";
|
||||
import type { SubsystemDeliverFn, SubsystemDeliveryOutcome } from "./gateway-subsystem.js";
|
||||
import { HANDLE_PATTERN, type HumanFragment, type LoadResult } from "./human-registry.js";
|
||||
import type { OutboundDecision } from "./protocol.js";
|
||||
import { validateRelayUrl } from "./relay-config.js";
|
||||
import { redactSecrets } from "./slack/message.js";
|
||||
import { OUTBOUND_OP, type OutboundPostPayload } from "./slack/outbound-driver.js";
|
||||
import type { FetchImpl } from "./slack/slack-api.js";
|
||||
import { SeenStore } from "./slack/state-store.js";
|
||||
import type { QueueRepository } from "../queue-repository.js";
|
||||
|
||||
export const RELAY_PROTOCOL_VERSION = 1 as const;
|
||||
export const RELAY_ATTEMPTED_DECISIONS_FILE = "relay-attempted-decisions.jsonl";
|
||||
export const RELAY_DELIVERED_DECISIONS_FILE = "relay-delivered-decisions.jsonl";
|
||||
|
||||
export const RELAY_SUMMARY_MAX_CHARS = 1000;
|
||||
export const RELAY_DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export type RelayQuestionMode = "ask" | "park";
|
||||
export type RelayIntent = "decision" | "update";
|
||||
export type RelayDeliveryOutcome = "interrupt" | "notify" | "digest";
|
||||
|
||||
export interface RelayReplyRefPayload {
|
||||
v: 1;
|
||||
qitemId: string;
|
||||
episodeKey: string;
|
||||
decisionRevision: string;
|
||||
decisionId: string;
|
||||
connectorRef: string;
|
||||
mode: RelayQuestionMode;
|
||||
digest?: boolean;
|
||||
}
|
||||
|
||||
export interface RelayReplyRefToken {
|
||||
v: 1;
|
||||
payloadHash: string;
|
||||
connectorHash: string;
|
||||
mode: RelayQuestionMode;
|
||||
digest?: boolean;
|
||||
}
|
||||
|
||||
export interface RelayDigestItem {
|
||||
qitemId: string;
|
||||
notificationKey: string;
|
||||
summary: string;
|
||||
body?: string;
|
||||
replyRef: string | null;
|
||||
}
|
||||
|
||||
export interface RelayOutboundEnvelope {
|
||||
v: 1;
|
||||
deliveryId: string;
|
||||
recipient: {
|
||||
handle: string;
|
||||
};
|
||||
intent: RelayIntent;
|
||||
outcome: RelayDeliveryOutcome;
|
||||
summary: string;
|
||||
body: string;
|
||||
replyRef: string | null;
|
||||
items?: RelayDigestItem[];
|
||||
}
|
||||
|
||||
export interface RelayPayloadCarry extends OutboundPostPayload {
|
||||
relayConnectorRef?: string;
|
||||
relayEnvelope?: RelayOutboundEnvelope;
|
||||
relayRevision?: string | null;
|
||||
relayReplyRefs?: Record<string, RelayReplyRefPayload>;
|
||||
deliveryDigestPost?: boolean;
|
||||
digestId?: string;
|
||||
memberReceipts?: Array<{
|
||||
qitemId: string;
|
||||
notificationKey: string;
|
||||
level: string;
|
||||
kind: string;
|
||||
summary?: string;
|
||||
body?: string;
|
||||
}>;
|
||||
quietRelease?: boolean;
|
||||
deliveryDeferralFire?: boolean;
|
||||
}
|
||||
|
||||
function asRelayPayloadCarry(v: unknown): RelayPayloadCarry | null {
|
||||
if (!isObj(v)) return null;
|
||||
return v as unknown as RelayPayloadCarry;
|
||||
}
|
||||
|
||||
const ALLOWED_REPLY_REF_TOKEN_KEYS = new Set([
|
||||
"v",
|
||||
"payloadHash",
|
||||
"connectorHash",
|
||||
"mode",
|
||||
"digest",
|
||||
]);
|
||||
|
||||
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
const ALLOWED_ENVELOPE_KEYS = new Set([
|
||||
"v",
|
||||
"deliveryId",
|
||||
"recipient",
|
||||
"intent",
|
||||
"outcome",
|
||||
"summary",
|
||||
"body",
|
||||
"replyRef",
|
||||
"items",
|
||||
]);
|
||||
|
||||
const ALLOWED_RECIPIENT_KEYS = new Set(["handle"]);
|
||||
const ALLOWED_DIGEST_ITEM_KEYS = new Set(["qitemId", "notificationKey", "summary", "body", "replyRef"]);
|
||||
|
||||
function isObj(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function nonEmptyStr(v: unknown): v is string {
|
||||
return typeof v === "string" && v.trim().length > 0;
|
||||
}
|
||||
|
||||
function unknownKey(obj: Record<string, unknown>, allowed: Set<string>): string | undefined {
|
||||
for (const k of Object.keys(obj)) {
|
||||
if (!allowed.has(k)) return k;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function canonicalizeRelayReplyRefPayload(payload: RelayReplyRefPayload): RelayReplyRefPayload {
|
||||
return {
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
qitemId: payload.qitemId,
|
||||
episodeKey: payload.episodeKey,
|
||||
decisionRevision: payload.decisionRevision,
|
||||
decisionId: payload.decisionId,
|
||||
connectorRef: payload.connectorRef,
|
||||
mode: payload.mode,
|
||||
...(payload.digest === true ? { digest: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Deterministic SHA-256 hex digest of the canonical frozen RelayReplyRefPayload. */
|
||||
export function hashRelayReplyRefPayload(payload: RelayReplyRefPayload): string {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify(canonicalizeRelayReplyRefPayload(payload)), "utf8")
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** Deterministic SHA-256 hex digest of a relay connectorRef for bounded token binding. */
|
||||
export function hashRelayConnectorRef(connectorRef: string): string {
|
||||
return createHash("sha256").update(connectorRef, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/** Encode structured correlation fields into a fixed-size opaque base64url replyRef token. */
|
||||
export function encodeRelayReplyRef(payload: RelayReplyRefPayload): string {
|
||||
const canonical = canonicalizeRelayReplyRefPayload(payload);
|
||||
const token: RelayReplyRefToken = {
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
payloadHash: hashRelayReplyRefPayload(canonical),
|
||||
connectorHash: hashRelayConnectorRef(canonical.connectorRef),
|
||||
mode: canonical.mode,
|
||||
...(canonical.digest === true ? { digest: true } : {}),
|
||||
};
|
||||
return Buffer.from(JSON.stringify(token), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
/** Decode and strictly validate a fixed-size opaque base64url replyRef token. */
|
||||
export function decodeRelayReplyRef(
|
||||
raw: unknown,
|
||||
): { ok: true; ref: RelayReplyRefToken } | { ok: false; error: string } {
|
||||
if (typeof raw !== "string" || raw.length === 0 || raw.length > 4096 || !/^[A-Za-z0-9_-]+$/.test(raw)) {
|
||||
return { ok: false, error: "replyRef must be a non-empty base64url string" };
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return { ok: false, error: "replyRef is not valid base64url JSON" };
|
||||
}
|
||||
if (!isObj(parsed)) return { ok: false, error: "replyRef payload must be a JSON object" };
|
||||
const uk = unknownKey(parsed, ALLOWED_REPLY_REF_TOKEN_KEYS);
|
||||
if (uk) return { ok: false, error: `replyRef has unknown key "${uk}"` };
|
||||
if (parsed.v !== RELAY_PROTOCOL_VERSION) return { ok: false, error: `replyRef.v must be ${RELAY_PROTOCOL_VERSION}` };
|
||||
if (typeof parsed.payloadHash !== "string" || !SHA256_HEX_PATTERN.test(parsed.payloadHash)) {
|
||||
return { ok: false, error: "replyRef.payloadHash must be a 64-char lowercase hex sha256" };
|
||||
}
|
||||
if (typeof parsed.connectorHash !== "string" || !SHA256_HEX_PATTERN.test(parsed.connectorHash)) {
|
||||
return { ok: false, error: "replyRef.connectorHash must be a 64-char lowercase hex sha256" };
|
||||
}
|
||||
if (parsed.mode !== "ask" && parsed.mode !== "park") {
|
||||
return { ok: false, error: "replyRef.mode must be ask|park" };
|
||||
}
|
||||
if (parsed.digest !== undefined && typeof parsed.digest !== "boolean") {
|
||||
return { ok: false, error: "replyRef.digest must be a boolean when present" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
ref: {
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
payloadHash: parsed.payloadHash,
|
||||
connectorHash: parsed.connectorHash,
|
||||
mode: parsed.mode,
|
||||
...(parsed.digest === true ? { digest: true } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Deterministic SHA-256 hex digest of an offered replyRef for transition-note evidence. */
|
||||
export function hashRelayReplyRef(replyRef: string): string {
|
||||
return createHash("sha256").update(replyRef, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/** Validate a closed protocol v1 RelayOutboundEnvelope. */
|
||||
export function validateRelayOutboundEnvelope(
|
||||
raw: unknown,
|
||||
): { ok: true; envelope: RelayOutboundEnvelope } | { ok: false; error: string } {
|
||||
if (!isObj(raw)) return { ok: false, error: "relay envelope must be an object" };
|
||||
const uk = unknownKey(raw, ALLOWED_ENVELOPE_KEYS);
|
||||
if (uk) return { ok: false, error: `relay envelope has unknown key "${uk}"` };
|
||||
if (raw.v !== RELAY_PROTOCOL_VERSION) return { ok: false, error: `relay envelope.v must be ${RELAY_PROTOCOL_VERSION}` };
|
||||
if (!nonEmptyStr(raw.deliveryId)) return { ok: false, error: "relay envelope.deliveryId must be a non-empty string" };
|
||||
if (!isObj(raw.recipient)) return { ok: false, error: "relay envelope.recipient must be an object" };
|
||||
const ruk = unknownKey(raw.recipient, ALLOWED_RECIPIENT_KEYS);
|
||||
if (ruk) return { ok: false, error: `relay envelope.recipient has unknown key "${ruk}"` };
|
||||
if (!nonEmptyStr(raw.recipient.handle) || !HANDLE_PATTERN.test(raw.recipient.handle)) {
|
||||
return { ok: false, error: "relay envelope.recipient.handle must match HANDLE_PATTERN" };
|
||||
}
|
||||
if (raw.intent !== "decision" && raw.intent !== "update") {
|
||||
return { ok: false, error: "relay envelope.intent must be decision|update" };
|
||||
}
|
||||
if (raw.outcome !== "interrupt" && raw.outcome !== "notify" && raw.outcome !== "digest") {
|
||||
return { ok: false, error: "relay envelope.outcome must be interrupt|notify|digest" };
|
||||
}
|
||||
if (typeof raw.summary !== "string") return { ok: false, error: "relay envelope.summary must be a string" };
|
||||
if (typeof raw.body !== "string") return { ok: false, error: "relay envelope.body must be a string" };
|
||||
if (raw.replyRef !== null && typeof raw.replyRef !== "string") {
|
||||
return { ok: false, error: "relay envelope.replyRef must be string or null" };
|
||||
}
|
||||
if (raw.intent === "update" && raw.replyRef !== null) {
|
||||
return { ok: false, error: "relay envelope.replyRef must be null when intent is update" };
|
||||
}
|
||||
if (raw.replyRef !== null) {
|
||||
const decoded = decodeRelayReplyRef(raw.replyRef);
|
||||
if (!decoded.ok) return { ok: false, error: `relay envelope.replyRef invalid: ${decoded.error}` };
|
||||
}
|
||||
let items: RelayDigestItem[] | undefined;
|
||||
if (raw.items !== undefined) {
|
||||
if (!Array.isArray(raw.items)) return { ok: false, error: "relay envelope.items must be an array when present" };
|
||||
items = [];
|
||||
for (const [i, item] of raw.items.entries()) {
|
||||
if (!isObj(item)) return { ok: false, error: `relay envelope.items[${i}] must be an object` };
|
||||
const iuk = unknownKey(item, ALLOWED_DIGEST_ITEM_KEYS);
|
||||
if (iuk) return { ok: false, error: `relay envelope.items[${i}] has unknown key "${iuk}"` };
|
||||
if (!nonEmptyStr(item.qitemId) || !nonEmptyStr(item.notificationKey) || typeof item.summary !== "string") {
|
||||
return { ok: false, error: `relay envelope.items[${i}] missing required fields` };
|
||||
}
|
||||
if (item.body !== undefined && typeof item.body !== "string") {
|
||||
return { ok: false, error: `relay envelope.items[${i}].body must be a string when present` };
|
||||
}
|
||||
if (item.replyRef !== null && typeof item.replyRef !== "string") {
|
||||
return { ok: false, error: `relay envelope.items[${i}].replyRef must be string or null` };
|
||||
}
|
||||
if (typeof item.replyRef === "string") {
|
||||
const dec = decodeRelayReplyRef(item.replyRef);
|
||||
if (!dec.ok) return { ok: false, error: `relay envelope.items[${i}].replyRef invalid: ${dec.error}` };
|
||||
}
|
||||
items.push({
|
||||
qitemId: item.qitemId,
|
||||
notificationKey: item.notificationKey,
|
||||
summary: item.summary,
|
||||
...(typeof item.body === "string" ? { body: item.body } : {}),
|
||||
replyRef: item.replyRef,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
envelope: {
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
deliveryId: raw.deliveryId,
|
||||
recipient: { handle: raw.recipient.handle },
|
||||
intent: raw.intent,
|
||||
outcome: raw.outcome,
|
||||
summary: raw.summary,
|
||||
body: raw.body,
|
||||
replyRef: raw.replyRef,
|
||||
...(items !== undefined ? { items } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Snap a UTF-16 index so it never splits a surrogate pair (`0xD800..0xDBFF` followed by `0xDC00..0xDFFF`). */
|
||||
function snapCodePointIndex(str: string, idx: number): number {
|
||||
if (idx <= 0) return 0;
|
||||
if (idx >= str.length) return str.length;
|
||||
const prev = str.charCodeAt(idx - 1);
|
||||
const curr = str.charCodeAt(idx);
|
||||
if (prev >= 0xd800 && prev <= 0xdbff && curr >= 0xdc00 && curr <= 0xdfff) {
|
||||
return idx - 1;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
export interface RelayQuestionState {
|
||||
qitemId: string;
|
||||
mode: RelayQuestionMode;
|
||||
episodeKey: string;
|
||||
decisionRevision: string;
|
||||
humanIntent?: "decision" | "update" | null;
|
||||
summary?: string | null;
|
||||
body?: string | null;
|
||||
}
|
||||
|
||||
export interface PrepareRelayDecisionOpts {
|
||||
loadHumanRegistry: () => LoadResult | { ok: boolean; entities?: readonly HumanFragment[] };
|
||||
queueRepo?: Pick<QueueRepository, "getById" | "decisionRevision" | "currentHumanRequiredEpisode">;
|
||||
lookupQuestionState?: (qitemId: string, fallbackKey?: string | null) => RelayQuestionState | null;
|
||||
resolveOutcome?: (payload: OutboundPostPayload) => RelayDeliveryOutcome;
|
||||
}
|
||||
|
||||
/** Look up actual current question state from QueueRepository. Never mints synthetic state when row or current episode is missing. */
|
||||
function defaultLookupQuestionState(
|
||||
queueRepo: Pick<QueueRepository, "getById" | "decisionRevision" | "currentHumanRequiredEpisode"> | undefined,
|
||||
qitemId: string,
|
||||
expectedEpisodeKey?: string | null,
|
||||
): RelayQuestionState | null {
|
||||
if (!queueRepo) return null;
|
||||
const row = queueRepo.getById(qitemId);
|
||||
if (!row || (row.state !== "pending" && row.state !== "blocked")) return null;
|
||||
const ep = queueRepo.currentHumanRequiredEpisode(qitemId);
|
||||
if (!ep) return null;
|
||||
if (expectedEpisodeKey && expectedEpisodeKey !== ep.key) return null;
|
||||
const decisionRevision = queueRepo.decisionRevision(row);
|
||||
const mode: RelayQuestionMode = row.state === "blocked" ? "park" : "ask";
|
||||
return {
|
||||
qitemId,
|
||||
mode,
|
||||
episodeKey: ep.key,
|
||||
decisionRevision,
|
||||
humanIntent: row.humanIntent ?? null,
|
||||
summary: row.summary ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPlainTextBody(p: OutboundPostPayload): string {
|
||||
const sections: string[] = [];
|
||||
const cleanedSummary = redactSecrets(String(p.summary ?? "")).replace(/\s+/g, " ").trim();
|
||||
const mainBody = redactSecrets(String(p.body ?? "")).trim();
|
||||
if (cleanedSummary.length > RELAY_SUMMARY_MAX_CHARS && !mainBody.includes(cleanedSummary)) {
|
||||
sections.push(cleanedSummary);
|
||||
}
|
||||
if (mainBody) sections.push(mainBody);
|
||||
const detail = redactSecrets(String(p.humanDetail ?? "")).trim();
|
||||
if (detail) sections.push(detail);
|
||||
if (Array.isArray(p.humanQuestions) && p.humanQuestions.length > 0) {
|
||||
const qLines: string[] = [];
|
||||
for (const q of p.humanQuestions) {
|
||||
const header = redactSecrets(String((q as { header?: string }).header ?? "")).trim();
|
||||
if (header) qLines.push(`[${header}]`);
|
||||
qLines.push(redactSecrets(String(q.question ?? "")).trim());
|
||||
for (const opt of q.options ?? []) {
|
||||
const desc = redactSecrets(String((opt as { description?: string }).description ?? "")).trim();
|
||||
const descSuffix = desc ? ` — ${desc}` : "";
|
||||
qLines.push(`- ${redactSecrets(String(opt.label ?? "")).trim()}${opt.recommended ? " (recommended)" : ""}${descSuffix}`);
|
||||
}
|
||||
}
|
||||
if (qLines.length > 0) sections.push(qLines.join("\n"));
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
function renderPlainTextSummary(summary: string | null | undefined, fallback: string): string {
|
||||
const cleaned = redactSecrets(String(summary ?? fallback)).replace(/\s+/g, " ").trim() || fallback;
|
||||
return cleaned.length > RELAY_SUMMARY_MAX_CHARS
|
||||
? cleaned.slice(0, snapCodePointIndex(cleaned, RELAY_SUMMARY_MAX_CHARS))
|
||||
: cleaned;
|
||||
}
|
||||
|
||||
/** Resolve the recipient human's primary binding when it is a `relay` binding.
|
||||
* Requires exact registered human address or exact bare entityId (no broad localpart/domain splitting). */
|
||||
export function resolvePrimaryRelayBinding(
|
||||
destinationOrRef: string | null | undefined,
|
||||
reg: LoadResult | { ok: boolean; entities?: readonly HumanFragment[] },
|
||||
): { entity: HumanFragment; connectorRef: string; handle: string; secretsRef: string } | null {
|
||||
if (!reg.ok || !reg.entities) return null;
|
||||
const raw = String(destinationOrRef ?? "").trim();
|
||||
if (!raw) return null;
|
||||
const human = reg.entities.find((e) => e.address === raw || e.entityId === raw);
|
||||
if (!human) return null;
|
||||
const primary = human.connectorBindings.find((b) => b.role === "primary");
|
||||
if (!primary || (primary.kind as string) !== "relay") return null;
|
||||
return {
|
||||
entity: human,
|
||||
connectorRef: primary.connectorRef,
|
||||
handle: primary.handle ?? human.entityId,
|
||||
secretsRef: primary.secretsRef,
|
||||
};
|
||||
}
|
||||
|
||||
/** Synchronous pre-enqueue preparation hook: freezes the protocol v1 relay envelope and replyRef(s)
|
||||
* onto the decision payload when the recipient's primary binding is `relay`. */
|
||||
export function prepareRelayDecision(decision: OutboundDecision, opts: PrepareRelayDecisionOpts): OutboundDecision {
|
||||
if (decision.op !== OUTBOUND_OP) return decision;
|
||||
const p = asRelayPayloadCarry(decision.payload);
|
||||
if (!p) return decision;
|
||||
// Already frozen on an earlier pass: never recompute refs.
|
||||
if (p.relayEnvelope && p.relayConnectorRef) return decision;
|
||||
|
||||
const reg = opts.loadHumanRegistry();
|
||||
const binding = resolvePrimaryRelayBinding(p.destinationSession ?? decision.entityBindingRef, reg);
|
||||
if (!binding) return decision;
|
||||
|
||||
const outcome: RelayDeliveryOutcome = p.deliveryDigestPost
|
||||
? "digest"
|
||||
: p.quietRelease
|
||||
? "notify"
|
||||
: p.deliveryDeferralFire
|
||||
? "interrupt"
|
||||
: opts.resolveOutcome?.(p) ?? "notify";
|
||||
|
||||
const lookup = (qitemId: string, fallbackKey?: string | null): RelayQuestionState | null =>
|
||||
opts.lookupQuestionState
|
||||
? opts.lookupQuestionState(qitemId, fallbackKey)
|
||||
: defaultLookupQuestionState(opts.queueRepo, qitemId, fallbackKey);
|
||||
|
||||
const memberReceipts = Array.isArray(p.memberReceipts) ? p.memberReceipts : undefined;
|
||||
let intent: RelayIntent;
|
||||
let replyRef: string | null = null;
|
||||
let items: RelayDigestItem[] | undefined;
|
||||
let relayRevision: string | null = null;
|
||||
const relayReplyRefs: Record<string, RelayReplyRefPayload> = {};
|
||||
|
||||
if (memberReceipts && memberReceipts.length > 0) {
|
||||
items = memberReceipts.map((m) => {
|
||||
const qState = lookup(m.qitemId, m.notificationKey);
|
||||
const isDecisionMember = m.kind === "human-required" && qState !== null && qState.humanIntent !== "update";
|
||||
let memberRef: string | null = null;
|
||||
if (isDecisionMember) {
|
||||
const memberPayload: RelayReplyRefPayload = {
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
qitemId: m.qitemId,
|
||||
episodeKey: qState.episodeKey,
|
||||
decisionRevision: qState.decisionRevision,
|
||||
decisionId: decision.decisionId,
|
||||
connectorRef: binding.connectorRef,
|
||||
mode: qState.mode,
|
||||
digest: true,
|
||||
};
|
||||
memberRef = encodeRelayReplyRef(memberPayload);
|
||||
relayReplyRefs[memberRef] = memberPayload;
|
||||
}
|
||||
const rawItemSummary = qState?.summary ?? m.summary;
|
||||
const rawItemBody = qState?.body ?? m.body;
|
||||
const cleanedItemSummary =
|
||||
typeof rawItemSummary === "string" ? redactSecrets(rawItemSummary).replace(/\s+/g, " ").trim() : "";
|
||||
let cleanedItemBody = typeof rawItemBody === "string" ? redactSecrets(rawItemBody).trim() : "";
|
||||
if (cleanedItemSummary.length > RELAY_SUMMARY_MAX_CHARS && !cleanedItemBody.includes(cleanedItemSummary)) {
|
||||
cleanedItemBody = cleanedItemBody ? `${cleanedItemSummary}\n\n${cleanedItemBody}` : cleanedItemSummary;
|
||||
}
|
||||
return {
|
||||
qitemId: m.qitemId,
|
||||
notificationKey: m.notificationKey,
|
||||
summary: renderPlainTextSummary(rawItemSummary, m.qitemId),
|
||||
...(cleanedItemBody ? { body: cleanedItemBody } : {}),
|
||||
replyRef: memberRef,
|
||||
};
|
||||
});
|
||||
const anyDecision = items.some((it) => it.replyRef !== null);
|
||||
intent = anyDecision ? "decision" : "update";
|
||||
// Multi-item digests must have top-level replyRef = null so one reply cannot resolve several questions.
|
||||
replyRef = items.length === 1 ? items[0]!.replyRef : null;
|
||||
} else {
|
||||
const isDecision =
|
||||
p.humanIntent !== "update" &&
|
||||
(p.ownerNotificationKind === "human-required" ||
|
||||
(p.humanIntent === "decision" && p.ownerNotificationKind !== "human-decision-resolved"));
|
||||
intent = isDecision ? "decision" : "update";
|
||||
if (isDecision && p.qitemId) {
|
||||
const qState = lookup(p.qitemId, p.notificationKey);
|
||||
if (qState) {
|
||||
relayRevision = qState.decisionRevision;
|
||||
const askPayload: RelayReplyRefPayload = {
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
qitemId: p.qitemId,
|
||||
episodeKey: qState.episodeKey,
|
||||
decisionRevision: qState.decisionRevision,
|
||||
decisionId: decision.decisionId,
|
||||
connectorRef: binding.connectorRef,
|
||||
mode: qState.mode,
|
||||
...(outcome === "digest" ? { digest: true } : {}),
|
||||
};
|
||||
replyRef = encodeRelayReplyRef(askPayload);
|
||||
relayReplyRefs[replyRef] = askPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const envelope: RelayOutboundEnvelope = {
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
deliveryId: decision.decisionId,
|
||||
recipient: { handle: binding.handle },
|
||||
intent,
|
||||
outcome,
|
||||
summary: renderPlainTextSummary(p.summary, p.qitemId || decision.decisionId),
|
||||
body: renderPlainTextBody(p),
|
||||
replyRef,
|
||||
...(items ? { items } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
...decision,
|
||||
payload: {
|
||||
...p,
|
||||
relayConnectorRef: binding.connectorRef,
|
||||
relayEnvelope: envelope,
|
||||
...(relayRevision ? { relayRevision } : {}),
|
||||
...(Object.keys(relayReplyRefs).length > 0 ? { relayReplyRefs } : {}),
|
||||
} satisfies RelayPayloadCarry,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PendingRelayReplyRefMatch {
|
||||
found: boolean;
|
||||
decisionId?: string;
|
||||
connectorRef?: string;
|
||||
qitemId?: string;
|
||||
episodeKey?: string;
|
||||
decisionRevision?: string;
|
||||
mode?: RelayQuestionMode;
|
||||
digest?: boolean;
|
||||
}
|
||||
|
||||
/** Check the durable DispatchBuffer for an un-Acked decision that offered the exact `replyRef`. */
|
||||
export function findPendingRelayReplyRef(home: string, replyRef: string): PendingRelayReplyRefMatch {
|
||||
const decoded = decodeRelayReplyRef(replyRef);
|
||||
if (!decoded.ok) return { found: false };
|
||||
const pending = new DispatchBuffer(home).pendingStrict();
|
||||
for (const decision of pending) {
|
||||
const p = asRelayPayloadCarry(decision.payload);
|
||||
if (!p) continue;
|
||||
const env = p.relayEnvelope;
|
||||
if (!env) continue;
|
||||
const matchedTop = env.replyRef === replyRef;
|
||||
const matchedItem = env.items?.some((it) => it.replyRef === replyRef) ?? false;
|
||||
if (!matchedTop && !matchedItem) continue;
|
||||
const frozen = p.relayReplyRefs?.[replyRef];
|
||||
if (
|
||||
!isObj(frozen) ||
|
||||
frozen.v !== RELAY_PROTOCOL_VERSION ||
|
||||
!nonEmptyStr(frozen.qitemId) ||
|
||||
!nonEmptyStr(frozen.episodeKey) ||
|
||||
!nonEmptyStr(frozen.decisionRevision) ||
|
||||
!nonEmptyStr(frozen.decisionId) ||
|
||||
!nonEmptyStr(frozen.connectorRef) ||
|
||||
(frozen.mode !== "ask" && frozen.mode !== "park") ||
|
||||
(frozen.digest !== undefined && typeof frozen.digest !== "boolean")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const candidate: RelayReplyRefPayload = {
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
qitemId: frozen.qitemId,
|
||||
episodeKey: frozen.episodeKey,
|
||||
decisionRevision: frozen.decisionRevision,
|
||||
decisionId: frozen.decisionId,
|
||||
connectorRef: frozen.connectorRef,
|
||||
mode: frozen.mode,
|
||||
...(frozen.digest === true ? { digest: true } : {}),
|
||||
};
|
||||
if (encodeRelayReplyRef(candidate) !== replyRef) continue;
|
||||
if (decision.decisionId !== candidate.decisionId) continue;
|
||||
if (p.relayConnectorRef && p.relayConnectorRef !== candidate.connectorRef) continue;
|
||||
return {
|
||||
found: true,
|
||||
decisionId: decision.decisionId,
|
||||
connectorRef: candidate.connectorRef,
|
||||
qitemId: candidate.qitemId,
|
||||
episodeKey: candidate.episodeKey,
|
||||
decisionRevision: candidate.decisionRevision,
|
||||
mode: candidate.mode,
|
||||
...(candidate.digest ? { digest: true } : {}),
|
||||
};
|
||||
}
|
||||
return { found: false };
|
||||
}
|
||||
|
||||
export interface PostRelayEnvelopeInput {
|
||||
url: string;
|
||||
outboundToken: string;
|
||||
envelope: RelayOutboundEnvelope;
|
||||
fetchImpl?: FetchImpl;
|
||||
timeoutMs?: number;
|
||||
stopSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type RelayHttpResult =
|
||||
| { ok: true; status: number }
|
||||
| { ok: false; status: number; class: string; detail: string };
|
||||
|
||||
/** Loopback HTTP POST to the operator's relay bridge.
|
||||
* - Validates loopback HTTP URL via `validateRelayUrl`.
|
||||
* - Rejects redirects (`redirect: "error"`).
|
||||
* - Does not buffer or log arbitrary bridge response bodies (status is sufficient for custody). */
|
||||
export async function postRelayEnvelope(input: PostRelayEnvelopeInput): Promise<RelayHttpResult> {
|
||||
const urlCheck = validateRelayUrl(input.url);
|
||||
if (!urlCheck.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
class: "invalid-endpoint",
|
||||
detail: urlCheck.reason,
|
||||
};
|
||||
}
|
||||
const token = input.outboundToken.trim();
|
||||
if (!token) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
class: "missing-credential",
|
||||
detail: "relay outbound token is empty",
|
||||
};
|
||||
}
|
||||
const valid = validateRelayOutboundEnvelope(input.envelope);
|
||||
if (!valid.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
class: "invalid-envelope",
|
||||
detail: valid.error,
|
||||
};
|
||||
}
|
||||
const bodyJson = JSON.stringify(valid.envelope);
|
||||
|
||||
if (input.stopSignal?.aborted) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
class: "stopped",
|
||||
detail: "relay transport stopped",
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutMs = input.timeoutMs ?? RELAY_DEFAULT_TIMEOUT_MS;
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||
const signal = input.stopSignal ? AbortSignal.any([ctrl.signal, input.stopSignal]) : ctrl.signal;
|
||||
|
||||
try {
|
||||
const fetchFn = input.fetchImpl ?? fetch;
|
||||
const res = await fetchFn(urlCheck.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: bodyJson,
|
||||
redirect: "error",
|
||||
signal,
|
||||
});
|
||||
void res.body?.cancel?.().catch(() => {});
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
class: "redirect-refused",
|
||||
detail: `relay redirect (${res.status}) is refused`,
|
||||
};
|
||||
}
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
class: `http-${res.status}`,
|
||||
detail: `http ${res.status}`,
|
||||
};
|
||||
}
|
||||
return { ok: true, status: res.status };
|
||||
} catch (error) {
|
||||
if (input.stopSignal?.aborted) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
class: "stopped",
|
||||
detail: "relay transport stopped",
|
||||
};
|
||||
}
|
||||
const rawMsg = (error as Error)?.message || "";
|
||||
const isRedirect = /redirect/i.test(rawMsg);
|
||||
const isTimeout = ctrl.signal.aborted || /timeout|abort/i.test(rawMsg);
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
class: isRedirect ? "redirect-refused" : isTimeout ? "timeout" : "transport",
|
||||
detail: isRedirect
|
||||
? "relay redirect is refused"
|
||||
: isTimeout
|
||||
? `relay request timed out after ${timeoutMs}ms`
|
||||
: "relay transport request failed",
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RelayAcceptedReceiptInfo {
|
||||
qitemId: string;
|
||||
notificationKey: string;
|
||||
connectorRef: string;
|
||||
deliveryId: string;
|
||||
replyRef: string | null;
|
||||
refHash: string | null;
|
||||
revision: string | null;
|
||||
episodeKey: string | null;
|
||||
level: string;
|
||||
kind: string;
|
||||
humanIntent?: "decision" | "update" | null;
|
||||
digestId?: string | null;
|
||||
}
|
||||
|
||||
export type ResolvedRelayOutboundProfile =
|
||||
| { ok: true; connectorRef: string; url: string; outboundToken: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
export interface RelayOutboundTransportOpts {
|
||||
attempted: SeenStore;
|
||||
delivered: SeenStore;
|
||||
outboundSeen: SeenStore;
|
||||
resolveProfile: (connectorRef: string) => ResolvedRelayOutboundProfile;
|
||||
onAccepted: (info: RelayAcceptedReceiptInfo) => void;
|
||||
hasAcceptedReceipt?: (info: { qitemId: string; notificationKey: string }) => boolean;
|
||||
onTransportFailed?: (payload: OutboundPostPayload, failureClass: string, detail: string) => void;
|
||||
release?: (notificationKey: string) => void;
|
||||
fetchImpl?: FetchImpl;
|
||||
timeoutMs?: number;
|
||||
stopSignal?: AbortSignal;
|
||||
log?: (msg: string) => void;
|
||||
}
|
||||
|
||||
export interface RelayOutboundTransport {
|
||||
deliver: SubsystemDeliverFn;
|
||||
wasAttempted: (decision: OutboundDecision) => boolean;
|
||||
}
|
||||
|
||||
function buildExpectedReceipts(decision: OutboundDecision, p: RelayPayloadCarry, connectorRef: string, envelope: RelayOutboundEnvelope): RelayAcceptedReceiptInfo[] {
|
||||
if (p.deliveryDigestPost && Array.isArray(p.memberReceipts) && p.memberReceipts.length > 0) {
|
||||
return p.memberReceipts.map((m) => {
|
||||
const itemEntry = envelope.items?.find((it) => it.qitemId === m.qitemId && it.notificationKey === m.notificationKey);
|
||||
const itemRef = itemEntry?.replyRef ?? (p.memberReceipts!.length === 1 ? envelope.replyRef : null);
|
||||
const frozenItemRef = itemRef ? p.relayReplyRefs?.[itemRef] : undefined;
|
||||
return {
|
||||
qitemId: m.qitemId,
|
||||
notificationKey: m.notificationKey,
|
||||
connectorRef,
|
||||
deliveryId: decision.decisionId,
|
||||
replyRef: itemRef,
|
||||
refHash: itemRef ? hashRelayReplyRef(itemRef) : null,
|
||||
revision: frozenItemRef?.decisionRevision ?? null,
|
||||
episodeKey: frozenItemRef?.episodeKey ?? m.notificationKey,
|
||||
level: m.level,
|
||||
kind: m.kind,
|
||||
humanIntent: m.kind === "human-required" ? "decision" : "update",
|
||||
digestId: p.digestId ?? "unknown",
|
||||
};
|
||||
});
|
||||
}
|
||||
if (p.qitemId) {
|
||||
const key = p.notificationKey ?? p.qitemId;
|
||||
const frozenRef = envelope.replyRef ? p.relayReplyRefs?.[envelope.replyRef] : undefined;
|
||||
return [
|
||||
{
|
||||
qitemId: p.qitemId,
|
||||
notificationKey: key,
|
||||
connectorRef,
|
||||
deliveryId: decision.decisionId,
|
||||
replyRef: envelope.replyRef,
|
||||
refHash: envelope.replyRef ? hashRelayReplyRef(envelope.replyRef) : null,
|
||||
revision: frozenRef?.decisionRevision ?? (p.relayRevision ?? null),
|
||||
episodeKey: frozenRef?.episodeKey ?? key,
|
||||
level: p.ownerNotificationLevel ?? "RECORD",
|
||||
kind: p.ownerNotificationKind ?? "unclassified",
|
||||
humanIntent: p.humanIntent ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** SeenStore-backed outbound relay transport.
|
||||
* - Strict SeenStore reads (`statuses()`): unreadable state retains the decision in DispatchBuffer.
|
||||
* - Persists attempt metadata BEFORE the first HTTP request (no secrets/answers in metadata).
|
||||
* - Replays the exact frozen `relayEnvelope` with the same `deliveryId`.
|
||||
* - Marks `outboundSeen` and releases poll guard BEFORE marking `delivered`; reconciles from durable receipt on retry without reposting. */
|
||||
export function createRelayOutboundTransport(opts: RelayOutboundTransportOpts): RelayOutboundTransport {
|
||||
const log = opts.log ?? (() => {});
|
||||
|
||||
const wasAttempted = (decision: OutboundDecision): boolean => {
|
||||
const map = opts.attempted.statuses();
|
||||
return map.has(decision.decisionId);
|
||||
};
|
||||
|
||||
const completeLocalGuards = (
|
||||
decision: OutboundDecision,
|
||||
p: RelayPayloadCarry,
|
||||
outboundSeenMap: Map<string, string>,
|
||||
deliveredMap: Map<string, string>,
|
||||
): SubsystemDeliveryOutcome => {
|
||||
try {
|
||||
if (p.qitemId && !p.deliveryDigestPost) {
|
||||
const key = p.notificationKey ?? p.qitemId;
|
||||
if (!outboundSeenMap.has(key)) {
|
||||
opts.outboundSeen.mark(key, "relay-accepted");
|
||||
}
|
||||
opts.release?.(key);
|
||||
}
|
||||
if (!deliveredMap.has(decision.decisionId)) {
|
||||
opts.delivered.mark(decision.decisionId, "delivered");
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const detail = (error as Error).message || "relay state write failed";
|
||||
log(`relay local state mark FAILED for ${decision.decisionId}: ${detail} — retained for replay`);
|
||||
return { ok: false, class: "relay-state-write-failed", detail };
|
||||
}
|
||||
};
|
||||
|
||||
const deliver: SubsystemDeliverFn = async (decision: OutboundDecision): Promise<SubsystemDeliveryOutcome> => {
|
||||
let deliveredMap: Map<string, string>;
|
||||
let attemptedMap: Map<string, string>;
|
||||
let outboundSeenMap: Map<string, string>;
|
||||
try {
|
||||
deliveredMap = opts.delivered.statuses();
|
||||
attemptedMap = opts.attempted.statuses();
|
||||
outboundSeenMap = opts.outboundSeen.statuses();
|
||||
} catch (error) {
|
||||
const detail = (error as Error).message || "relay SeenStore unreadable";
|
||||
log(`relay state unreadable for ${decision.decisionId}: ${detail} — retained`);
|
||||
return { ok: false, class: "relay-state-unreadable", detail };
|
||||
}
|
||||
|
||||
const p = (decision.payload ?? {}) as RelayPayloadCarry;
|
||||
const connectorRef = p.relayConnectorRef;
|
||||
const envelope = p.relayEnvelope;
|
||||
if (!connectorRef || !envelope) {
|
||||
const detail = "relay decision is missing frozen relayEnvelope or relayConnectorRef";
|
||||
log(`relay delivery ${decision.decisionId} refused: ${detail}`);
|
||||
return { ok: false, class: "relay-envelope-missing", detail };
|
||||
}
|
||||
|
||||
const expectedReceipts = buildExpectedReceipts(decision, p, connectorRef, envelope);
|
||||
const alreadyReceipted =
|
||||
expectedReceipts.length > 0 &&
|
||||
opts.hasAcceptedReceipt !== undefined &&
|
||||
expectedReceipts.every((r) => opts.hasAcceptedReceipt!({ qitemId: r.qitemId, notificationKey: r.notificationKey }));
|
||||
|
||||
if (deliveredMap.has(decision.decisionId) || alreadyReceipted) {
|
||||
log(`relay delivery: decision ${decision.decisionId} already accepted — reconciling local guards without repost`);
|
||||
return completeLocalGuards(decision, p, outboundSeenMap, deliveredMap);
|
||||
}
|
||||
|
||||
const profile = opts.resolveProfile(connectorRef);
|
||||
if (!profile.ok) {
|
||||
log(`relay profile "${connectorRef}" unready for ${decision.decisionId}: ${profile.reason} — retained`);
|
||||
try {
|
||||
opts.onTransportFailed?.(p, "relay-profile-unready", profile.reason);
|
||||
} catch (e) {
|
||||
log(`relay transport-failed receipt write FAILED for ${p.qitemId ?? decision.decisionId}: ${(e as Error).message}`);
|
||||
}
|
||||
return { ok: false, class: "relay-profile-unready", detail: profile.reason };
|
||||
}
|
||||
|
||||
if (opts.stopSignal?.aborted) {
|
||||
return { ok: false, class: "stopped", detail: "relay transport stopped" };
|
||||
}
|
||||
|
||||
if (!attemptedMap.has(decision.decisionId)) {
|
||||
try {
|
||||
opts.attempted.mark(decision.decisionId, `attempted:${connectorRef}:${envelope.outcome}`);
|
||||
} catch (error) {
|
||||
const detail = (error as Error).message || "failed to record relay attempt";
|
||||
log(`relay attempt record FAILED for ${decision.decisionId}: ${detail} — retained`);
|
||||
return { ok: false, class: "relay-attempt-unrecorded", detail };
|
||||
}
|
||||
}
|
||||
|
||||
const res = await postRelayEnvelope({
|
||||
url: profile.url,
|
||||
outboundToken: profile.outboundToken,
|
||||
envelope,
|
||||
fetchImpl: opts.fetchImpl,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
stopSignal: opts.stopSignal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.class !== "stopped") {
|
||||
try {
|
||||
opts.onTransportFailed?.(p, res.class, res.detail);
|
||||
} catch (e) {
|
||||
log(`relay transport-failed receipt write FAILED for ${p.qitemId ?? decision.decisionId}: ${(e as Error).message}`);
|
||||
return { ok: false, class: "receipt-failed", detail: (e as Error).message };
|
||||
}
|
||||
}
|
||||
return { ok: false, class: res.class, detail: res.detail };
|
||||
}
|
||||
|
||||
try {
|
||||
for (const receipt of expectedReceipts) {
|
||||
if (opts.hasAcceptedReceipt?.({ qitemId: receipt.qitemId, notificationKey: receipt.notificationKey })) {
|
||||
continue;
|
||||
}
|
||||
opts.onAccepted(receipt);
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = (error as Error).message || "relay acceptance receipt write failed";
|
||||
log(`relay acceptance receipt write FAILED for ${p.qitemId ?? decision.decisionId}: ${detail} — retained for replay`);
|
||||
return { ok: false, class: "receipt-failed", detail };
|
||||
}
|
||||
|
||||
const finalized = completeLocalGuards(decision, p, outboundSeenMap, deliveredMap);
|
||||
if (!finalized.ok) return finalized;
|
||||
|
||||
log(`relay delivered ${decision.decisionId}${p.qitemId ? ` (qitem ${p.qitemId})` : ""} via ${connectorRef}`);
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
return {
|
||||
deliver,
|
||||
wasAttempted,
|
||||
};
|
||||
}
|
||||
|
||||
export function relayStatePaths(home: string): { attemptedPath: string; deliveredPath: string } {
|
||||
const dir = path.join(home, "state");
|
||||
return {
|
||||
attemptedPath: path.join(dir, RELAY_ATTEMPTED_DECISIONS_FILE),
|
||||
deliveredPath: path.join(dir, RELAY_DELIVERED_DECISIONS_FILE),
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import type { QueueRepository } from "../../queue-repository.js";
|
||||
import type { DispatchResult } from "../dispatcher.js";
|
||||
import { DispatchBuffer } from "../dispatch-buffer.js";
|
||||
import type { LoadResult } from "../human-registry.js";
|
||||
import { sharedChannelAddresses } from "../participants.js";
|
||||
import { makeQueuePorts } from "./queue-access.js";
|
||||
import { SeenStore } from "./state-store.js";
|
||||
import { OUTBOUND_OP, toPayload, type OutboundPostPayload } from "./outbound-driver.js";
|
||||
@@ -120,7 +121,20 @@ export interface PostOpenAsksInput {
|
||||
/** Show the open asks again: reconcile each, offer the ones enable seeded, then report every ask's outcome. */
|
||||
export async function postOpenAsks(input: PostOpenAsksInput): Promise<PostOpenAsksResult> {
|
||||
const { queueRepo } = input;
|
||||
const ports = makeQueuePorts(queueRepo, { loadHumanRegistry: input.loadHumanRegistry });
|
||||
const loadSlackHumanRegistry = (): LoadResult => {
|
||||
const loaded = input.loadHumanRegistry();
|
||||
if (!loaded.ok) return loaded;
|
||||
return {
|
||||
...loaded,
|
||||
entities: loaded.entities.filter((entity) =>
|
||||
entity.connectorBindings.some((b) => b.role === "primary" && b.kind === "slack"),
|
||||
),
|
||||
};
|
||||
};
|
||||
const ports = makeQueuePorts(queueRepo, {
|
||||
loadHumanRegistry: loadSlackHumanRegistry,
|
||||
sharedChannels: () => sharedChannelAddresses(input.home),
|
||||
});
|
||||
const episodes = await ports.listOpenAsks({ minimumLevel: input.minimumLevelThatPosts });
|
||||
const seen = new SeenStore(input.seenPath ?? path.join(input.home, "state", "slack-outbound-seen.jsonl"));
|
||||
const attempted = new SeenStore(path.join(input.home, "state", "slack-attempted-decisions.jsonl"));
|
||||
|
||||
@@ -46,7 +46,10 @@ import { humanAnswerText } from "../../human-questions.js";
|
||||
import { formatReplyToChoice, formatReplyToThreadMissing, REPLY_TO_CHOICE_ACTOR, REPLY_TO_CHAIN_LIMIT, type ReplyToChoice } from "../../reply-to-choice.js";
|
||||
import { parseSessionName } from "../../session-name.js";
|
||||
import type { FetchImpl } from "./slack-api.js";
|
||||
import { ownerNotificationLevelAtLeast } from "../../queue-transition-log.js";
|
||||
import {
|
||||
ownerNotificationLevelAtLeast,
|
||||
RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX,
|
||||
} from "../../queue-transition-log.js";
|
||||
// OPR.0.5.6.1 — the delivery rules engine: ONE decision per message replaces
|
||||
// the two dial-reads (the S14 default-decision stub). The legacy dial path
|
||||
// survives ONLY as the stated degrade when the destination is not a
|
||||
@@ -64,6 +67,14 @@ import { confirmReleaseAtPost, currentAskVerdict, holdForQuiet } from "../../pol
|
||||
import { noteRePresentNotPosted, RE_PRESENT_DECISION_PREFIX } from "./open-asks.js";
|
||||
import { quietHoldFor } from "../human-quiet-windows.js";
|
||||
import { WatchdogJobsRepository } from "../../watchdog-jobs-repository.js";
|
||||
import { loadRelayProfiles } from "../relay-config.js";
|
||||
import {
|
||||
createRelayOutboundTransport,
|
||||
prepareRelayDecision,
|
||||
relayStatePaths,
|
||||
resolvePrimaryRelayBinding,
|
||||
type RelayPayloadCarry,
|
||||
} from "../relay-outbound.js";
|
||||
|
||||
const SECRET_BOT = "SLACK_BOT_TOKEN";
|
||||
const SECRET_APP = "SLACK_APP_TOKEN";
|
||||
@@ -306,7 +317,34 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
const deadForwards = new DeadLetterStore<{ host: string; envelope: SocketEnvelope }>(
|
||||
path.join(stateDir(opts.home), "slack-inbound-forward-deadletter.jsonl"));
|
||||
|
||||
if (!outboundReady && !inboundReady) {
|
||||
const registrySurface: RegistrySurface = opts.registry ?? { loadHumanRegistry, resolveSlackHandle };
|
||||
const hasRelayPrimaryBinding = (): boolean => {
|
||||
try {
|
||||
const reg = registrySurface.loadHumanRegistry(opts.home);
|
||||
return (
|
||||
reg.ok &&
|
||||
reg.entities.some((e) =>
|
||||
e.connectorBindings.some((b) => b.role === "primary" && (b.kind as string) === "relay"),
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const hasPendingRelayDecisions = (): boolean => {
|
||||
try {
|
||||
return new DispatchBuffer(opts.home).pending().some((d) => {
|
||||
const p = (d.payload ?? {}) as RelayPayloadCarry;
|
||||
return Boolean(p.relayConnectorRef || p.relayEnvelope);
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const relayConfigured = hasRelayPrimaryBinding() || hasPendingRelayDecisions();
|
||||
const anyOutboundEnabled = outboundReady || relayConfigured;
|
||||
|
||||
if (!outboundReady && !inboundReady && !relayConfigured) {
|
||||
const missing = !cfg.enabled ? "connector disabled (rig slack enable)" : !bot ? "SLACK_BOT_TOKEN unresolved" : "channel unconfigured";
|
||||
log(`slack delivery not configured (${missing}) — wire is inert; dispatches would be refused honestly`);
|
||||
const inert = buildInProcessWire({
|
||||
@@ -318,7 +356,6 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
return { ...inert, status: () => ({ platform: "slack", configurationDigest: channelStateDigest(cfg), outboundReady: false, inboundReady: false, inboundRouting, recovery: { state: "unavailable", reason: "inbound-not-configured" }, inbound: { state: "not-configured" }, ...deadLetterBacklogFields(dead, deadActions, deadForwards) }) };
|
||||
}
|
||||
|
||||
const registrySurface: RegistrySurface = opts.registry ?? { loadHumanRegistry, resolveSlackHandle };
|
||||
const ports = makeQueuePorts(opts.queueRepo, {
|
||||
loadHumanRegistry: () => registrySurface.loadHumanRegistry(opts.home),
|
||||
sharedChannels: () => sharedChannelAddresses(opts.home),
|
||||
@@ -401,19 +438,53 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
}).catch((e) => log(`undeliverable notice FAILED for ${p.qitemId}: ${(e as Error).message}`));
|
||||
};
|
||||
|
||||
const recordTransportFailed = (p: OutboundPostPayload, failureClass: string, detail: string, partlyPosted?: boolean): void => {
|
||||
const resolveRelayConnectorRef = (p: OutboundPostPayload): string | null => {
|
||||
const carry = p as RelayPayloadCarry;
|
||||
if (typeof carry.relayConnectorRef === "string" && carry.relayConnectorRef.trim().length > 0) {
|
||||
return carry.relayConnectorRef.trim();
|
||||
}
|
||||
try {
|
||||
const reg = registrySurface.loadHumanRegistry(opts.home);
|
||||
return resolvePrimaryRelayBinding(p.destinationSession, reg)?.connectorRef ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const recordTransportFailed = (
|
||||
p: OutboundPostPayload,
|
||||
failureClass: string,
|
||||
detail: string,
|
||||
partlyPosted?: boolean,
|
||||
relay = false,
|
||||
): void => {
|
||||
if (!p.qitemId) return;
|
||||
if (failureClass === "human-message-unrenderable" || failureClass === "slack-invalid-blocks") {
|
||||
const carry = p as RelayPayloadCarry;
|
||||
const isRelay = relay || Boolean(carry.relayConnectorRef || carry.relayEnvelope);
|
||||
if (!isRelay && (failureClass === "human-message-unrenderable" || failureClass === "slack-invalid-blocks")) {
|
||||
notifySourceUndeliverable(p, detail, partlyPosted, failureClass === "slack-invalid-blocks");
|
||||
}
|
||||
const key = p.notificationKey ?? p.qitemId;
|
||||
if (isRelay) {
|
||||
opts.queueRepo.recordRelayOwnerNotificationTransportFailed({
|
||||
qitemId: p.qitemId,
|
||||
notificationKey: key,
|
||||
connectorRef: resolveRelayConnectorRef(p),
|
||||
failureClass,
|
||||
detail,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const previousFailure = opts.queueRepo.transitionLog.listForQitem(p.qitemId).filter((transition) =>
|
||||
transition.transitionNote?.startsWith("slack-owner-notification-transport-failed ")
|
||||
(transition.transitionNote?.startsWith("slack-owner-notification-transport-failed ")
|
||||
|| transition.transitionNote?.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `))
|
||||
&& transition.transitionNote.split(/\s+/).includes(`notification_key=${key}`)).at(-1);
|
||||
// A corrected attempt can fail transiently after a permanent rejection, and a
|
||||
// transient failure can be followed by a definite rejection. Preserve both changes.
|
||||
const alreadyRecorded = previousFailure &&
|
||||
previousFailure.transitionNote!.includes("class=slack-invalid-blocks ") === (failureClass === "slack-invalid-blocks");
|
||||
const alreadyRecorded = Boolean(
|
||||
previousFailure?.transitionNote?.startsWith("slack-owner-notification-transport-failed ") &&
|
||||
previousFailure.transitionNote.includes("class=slack-invalid-blocks ") === (failureClass === "slack-invalid-blocks"),
|
||||
);
|
||||
if (alreadyRecorded) return;
|
||||
opts.queueRepo.update({
|
||||
qitemId: p.qitemId,
|
||||
@@ -694,6 +765,98 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
})
|
||||
: async () => ({ ok: false as const, class: "slack-outbound-not-configured", detail: "bot token or channel missing" });
|
||||
|
||||
const { attemptedPath: relayAttemptedPath, deliveredPath: relayDeliveredPath } = relayStatePaths(opts.home);
|
||||
const relayAttempted = new SeenStore(relayAttemptedPath);
|
||||
const relayDelivered = new SeenStore(relayDeliveredPath);
|
||||
const relayTransport = createRelayOutboundTransport({
|
||||
attempted: relayAttempted,
|
||||
delivered: relayDelivered,
|
||||
outboundSeen,
|
||||
fetchImpl: opts.fetchImpl,
|
||||
stopSignal: runCtl.signal,
|
||||
log,
|
||||
resolveProfile: (connectorRef) => {
|
||||
const reg = registrySurface.loadHumanRegistry(opts.home);
|
||||
const loaded = loadRelayProfiles(opts.home, reg.ok ? { entities: reg.entities } : undefined);
|
||||
if (!loaded.ok && loaded.registryError) {
|
||||
return { ok: false, reason: loaded.registryError };
|
||||
}
|
||||
const profile = loaded.get(connectorRef);
|
||||
if (!profile) {
|
||||
return { ok: false, reason: `relay profile "${connectorRef}" is not registered` };
|
||||
}
|
||||
if (!profile.ready || !profile.credentials) {
|
||||
return { ok: false, reason: profile.reason };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
connectorRef: profile.connectorRef,
|
||||
url: profile.credentials.url,
|
||||
outboundToken: profile.credentials.outboundToken,
|
||||
};
|
||||
},
|
||||
hasAcceptedReceipt: ({ qitemId, notificationKey }) =>
|
||||
opts.queueRepo.hasRelayOwnerNotificationReceipt(qitemId, notificationKey),
|
||||
onAccepted: (info) => {
|
||||
opts.queueRepo.recordRelayOwnerNotificationAccepted({
|
||||
qitemId: info.qitemId,
|
||||
notificationKey: info.notificationKey,
|
||||
deliveryId: info.deliveryId,
|
||||
connectorRef: info.connectorRef,
|
||||
replyRef: info.replyRef,
|
||||
replyRefHash: info.refHash,
|
||||
revision: info.revision,
|
||||
episodeKey: info.episodeKey,
|
||||
level: info.level,
|
||||
kind: info.kind,
|
||||
humanIntent: info.humanIntent,
|
||||
closeUpdate: !info.digestId,
|
||||
...(info.digestId ? { digest: info.digestId } : {}),
|
||||
});
|
||||
},
|
||||
onTransportFailed: (p, failureClass, detail) => recordTransportFailed(p, failureClass, detail, false, true),
|
||||
release: (q) => releaseRef(q),
|
||||
});
|
||||
|
||||
const prepareDecision = (decision: Parameters<SubsystemDeliverFn>[0]) =>
|
||||
prepareRelayDecision(decision, {
|
||||
loadHumanRegistry: () => registrySurface.loadHumanRegistry(opts.home),
|
||||
queueRepo: opts.queueRepo,
|
||||
resolveOutcome: (p) => {
|
||||
let ruled: DeliveryDecision | null = null;
|
||||
try { ruled = p.qitemId ? decideForPayload(p) : null; } catch { ruled = null; }
|
||||
if (ruled) {
|
||||
if (ruled.outcome === "interrupt" && ruled.deferMinutes === undefined) return "interrupt";
|
||||
if (ruled.outcome === "digest") return "digest";
|
||||
return "notify";
|
||||
}
|
||||
const interrupt = Boolean(
|
||||
p.ownerNotificationLevel && ownerNotificationLevelAtLeast(p.ownerNotificationLevel, cfg.minimumLevelThatInterrupts),
|
||||
);
|
||||
return interrupt ? "interrupt" : "notify";
|
||||
},
|
||||
});
|
||||
|
||||
const isRelayDecision = (decision: Parameters<SubsystemDeliverFn>[0], p: OutboundPostPayload): boolean => {
|
||||
const carry = p as RelayPayloadCarry;
|
||||
if (carry.relayConnectorRef || carry.relayEnvelope) return true;
|
||||
try {
|
||||
const reg = registrySurface.loadHumanRegistry(opts.home);
|
||||
return resolvePrimaryRelayBinding(p.destinationSession ?? decision.entityBindingRef, reg) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const targetDeliverFor = (decision: Parameters<SubsystemDeliverFn>[0], p: OutboundPostPayload): SubsystemDeliverFn => {
|
||||
if (!isRelayDecision(decision, p)) return deliver;
|
||||
return (d) => {
|
||||
const carry = (d.payload ?? {}) as RelayPayloadCarry;
|
||||
const readyDecision = carry.relayConnectorRef && carry.relayEnvelope ? d : prepareDecision(d);
|
||||
return relayTransport.deliver(readyDecision);
|
||||
};
|
||||
};
|
||||
|
||||
// OPR.0.5.6.1 — decision containment ahead of the transport: log-class and
|
||||
// digest-class produce ZERO post attempts (AM-F5 tooth); an away-escalation
|
||||
// deferral arms the watchdog substrate and posts nothing now (AM-F1); an
|
||||
@@ -753,8 +916,12 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
// contents, and receipts bind to exactly the members posted under it. An earlier attempt of the original that may
|
||||
// have landed is resolved by the reconciler first. If the quiet state can't be read, nothing posts (see the catch).
|
||||
const deliverDigestPost = async (decision: Parameters<SubsystemDeliverFn>[0], p: OutboundPostPayload) => {
|
||||
const settled = await resolveEarlierAttempt(decision);
|
||||
if (settled) return settled;
|
||||
const relay = isRelayDecision(decision, p);
|
||||
const targetDeliver = targetDeliverFor(decision, p);
|
||||
if (!relay) {
|
||||
const settled = await resolveEarlierAttempt(decision);
|
||||
if (settled) return settled;
|
||||
}
|
||||
const recipient = (p.destinationSession ?? "").split("@")[0] ?? "";
|
||||
let quiet: ReturnType<typeof quietHoldFor>;
|
||||
try {
|
||||
@@ -763,7 +930,10 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
// An attempted digest (it may be in Slack) is retained: acked, the next flush would mint the ask again from the
|
||||
// row as it is now. A never-attempted one is acked: its members keep their digest notes, so the next flush
|
||||
// selects them again. A store check only: no Slack read on a failure path.
|
||||
if ("attempted" in deliver && (deliver as { attempted: (d: typeof decision) => boolean }).attempted(decision)) {
|
||||
const wasAttempted = relay
|
||||
? relayTransport.wasAttempted(decision)
|
||||
: "attempted" in deliver && (deliver as { attempted: (d: typeof decision) => boolean }).attempted(decision);
|
||||
if (wasAttempted) {
|
||||
log(`digest post ${decision.decisionId} retained: quiet hours couldn't be read: ${(error as Error).message}`);
|
||||
return { ok: false as const, class: "quiet-unreadable", detail: (error as Error).message };
|
||||
}
|
||||
@@ -788,7 +958,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
holdForQuiet({ queueRepo: opts.queueRepo, jobsRepo, qitemId: m.qitemId, entityId: recipient, hold: { ...quiet!, notificationKey: m.notificationKey } });
|
||||
return false;
|
||||
});
|
||||
if (kept.length === members.length) return deliver(decision); // unchanged: the original, as it is
|
||||
if (kept.length === members.length) return targetDeliver(decision); // unchanged: the original, as it is
|
||||
if (kept.length === 0 || !aggregate) return { ok: true as const }; // nothing left to post under it
|
||||
const keptIds = new Set(kept.map((m) => m.qitemId));
|
||||
const body = String(p.body ?? "").split("\n").filter((line) => [...keptIds].some((id) => line.endsWith(`[${id}]`))).join("\n");
|
||||
@@ -796,12 +966,26 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
const keptId = `${decision.decisionId}:kept:${createHash("sha256").update(kept.map((m) => m.notificationKey).sort().join(",")).digest("hex").slice(0, 16)}`;
|
||||
// Durable first: the new decision is in the buffer before the original is acked (returning ok). A crash between the
|
||||
// two replays the original, which computes the same kept set and id; the dispatcher accepts the pending id once.
|
||||
const res = selfDispatch(keptId, { ...p, memberReceipts: kept, body, summary });
|
||||
const { relayConnectorRef: _rc, relayEnvelope: _re, relayRevision: _rr, ...restPayload } = p as RelayPayloadCarry;
|
||||
const res = selfDispatch(keptId, { ...restPayload, memberReceipts: kept, body, summary });
|
||||
return res.ok ? { ok: true as const } : { ok: false as const, class: "kept-dispatch-refused", detail: res.error };
|
||||
};
|
||||
|
||||
const engineDeliver: SubsystemDeliverFn = async (decision) => {
|
||||
const p = ((decision as { payload?: unknown }).payload ?? {}) as OutboundPostPayload & { deliveryDeferralFire?: boolean };
|
||||
const relay = isRelayDecision(decision, p);
|
||||
const targetDeliver = targetDeliverFor(decision, p);
|
||||
if (relay) {
|
||||
let alreadyAttempted = false;
|
||||
try {
|
||||
alreadyAttempted = relayTransport.wasAttempted(decision);
|
||||
} catch {
|
||||
return targetDeliver(decision);
|
||||
}
|
||||
if (alreadyAttempted) {
|
||||
return targetDeliver(decision);
|
||||
}
|
||||
}
|
||||
// The decision-resolved notice is written at closure, so a closed row is its normal case.
|
||||
if (replayedAtStart.has(decision.decisionId) && p.qitemId && p.ownerNotificationKind !== "human-decision-resolved"
|
||||
&& !(p as { deliveryDigestPost?: boolean }).deliveryDigestPost) {
|
||||
@@ -827,8 +1011,10 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
releaseRef(p.qitemId);
|
||||
return { ok: true as const };
|
||||
}
|
||||
const settled = await resolveEarlierAttempt(decision);
|
||||
if (settled) return settled;
|
||||
if (!relay) {
|
||||
const settled = await resolveEarlierAttempt(decision);
|
||||
if (settled) return settled;
|
||||
}
|
||||
const verdict = !p.qitemId ? "post" : await confirmReleaseAtPost({
|
||||
queueRepo: opts.queueRepo,
|
||||
jobsRepo: new WatchdogJobsRepository(opts.queueRepo.db),
|
||||
@@ -838,7 +1024,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
qitemId: p.qitemId,
|
||||
notificationKey: key,
|
||||
});
|
||||
if (verdict === "post") return deliver(decision);
|
||||
if (verdict === "post") return targetDeliver(decision);
|
||||
// Declined (the row says why) or couldn't be checked (nothing written): either way nothing posts now. Acking is
|
||||
// safe because the window's release job owns redelivery until the ask has a receipt or a disposition; keeping it
|
||||
// buffered would wait for a restart and block the job's own re-dispatch of the same decision id.
|
||||
@@ -859,21 +1045,27 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
// leniently. A log that exists but can't be read is unknown, not "never attempted": the decision stays in the buffer,
|
||||
// with no post (delivery's reconcile reads the same log) and no not-posted record, until it reads.
|
||||
try {
|
||||
attempted.statuses();
|
||||
(relay ? relayAttempted : attempted).statuses();
|
||||
} catch (error) {
|
||||
log(`re-shown ${decision.decisionId} retained: the attempt log couldn't be read (${(error as Error).message})`);
|
||||
return { ok: false as const, class: "re-present-attempt-log-unreadable", detail: (error as Error).message };
|
||||
}
|
||||
// An earlier attempt of this decision found in Slack goes to delivery, which records it.
|
||||
const landing = "landing" in deliver ? await (deliver as { landing: (d: typeof decision) => Promise<string> }).landing(decision) : "unattempted";
|
||||
if (landing === "found" || landing === "partial") return deliver(decision);
|
||||
const landing = !relay && "landing" in deliver ? await (deliver as { landing: (d: typeof decision) => Promise<string> }).landing(decision) : "unattempted";
|
||||
if (landing === "found" || landing === "partial") return targetDeliver(decision);
|
||||
const regForVerdict = registrySurface.loadHumanRegistry(opts.home);
|
||||
const alertsForVerdict =
|
||||
regForVerdict.ok || regForVerdict.noHumans
|
||||
? await ports.listHumanAlerts({ minimumLevel: cfg.minimumLevelThatPosts })
|
||||
: undefined;
|
||||
const verdict = await currentAskVerdict({
|
||||
queueRepo: opts.queueRepo,
|
||||
home: opts.home,
|
||||
registry: { loadHumanRegistry: () => registrySurface.loadHumanRegistry(opts.home) },
|
||||
registry: { loadHumanRegistry: () => regForVerdict },
|
||||
entityId: (p.destinationSession ?? "").split("@")[0] ?? "",
|
||||
qitemId: p.qitemId,
|
||||
notificationKey: key,
|
||||
...(alertsForVerdict ? { alerts: alertsForVerdict } : {}),
|
||||
});
|
||||
if (verdict.kind !== "post") {
|
||||
// One whose earlier attempt Slack couldn't rule out may already be posted: it's never recorded as not posted, and
|
||||
@@ -894,12 +1086,12 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
releaseRef(p.qitemId);
|
||||
return { ok: true as const };
|
||||
}
|
||||
return deliver(decision);
|
||||
return targetDeliver(decision);
|
||||
}
|
||||
// A post confirmed partly in Slack finishes without consulting quiet hours, so it is resolved before the decision
|
||||
// (which reads them). Only a started multipart post is checked here, from the stores first, so an ordinary
|
||||
// retry isn't scanned twice; any other attempted decision is resolved at the gate below.
|
||||
const started = "unfinished" in deliver && (deliver as { unfinished: (d: typeof decision) => boolean }).unfinished(decision);
|
||||
const started = !relay && "unfinished" in deliver && (deliver as { unfinished: (d: typeof decision) => boolean }).unfinished(decision);
|
||||
if (started) {
|
||||
const settled = await resolveEarlierAttempt(decision);
|
||||
if (settled) return settled;
|
||||
@@ -911,7 +1103,10 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
// An attempted decision (it may be partly or wholly in Slack) is retained and nothing posts: acking it would drop
|
||||
// the post's missing parts and let the sweep offer the ask again under a new id, posting it from the start. Its
|
||||
// replay decides again once the quiet state reads. A store check only: no Slack read on a failure path.
|
||||
if ("attempted" in deliver && (deliver as { attempted: (d: typeof decision) => boolean }).attempted(decision)) {
|
||||
const wasAttempted = relay
|
||||
? relayTransport.wasAttempted(decision)
|
||||
: "attempted" in deliver && (deliver as { attempted: (d: typeof decision) => boolean }).attempted(decision);
|
||||
if (wasAttempted) {
|
||||
log(`delivery decision for attempted ${decision.decisionId} couldn't be made; retained: ${(error as Error).message}`);
|
||||
return { ok: false as const, class: "quiet-unreadable", detail: (error as Error).message };
|
||||
}
|
||||
@@ -927,7 +1122,7 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
releaseRef(p.notificationKey ?? p.qitemId!);
|
||||
return { ok: true as const };
|
||||
}
|
||||
if (!started && ruled && (ruled.outcome === "log" || (ruled.outcome === "hold" && ruled.quietHold)
|
||||
if (!relay && !started && ruled && (ruled.outcome === "log" || (ruled.outcome === "hold" && ruled.quietHold)
|
||||
|| ruled.outcome === "digest" || ruled.deferMinutes !== undefined)) {
|
||||
const settled = await resolveEarlierAttempt(decision);
|
||||
if (settled) return settled;
|
||||
@@ -983,20 +1178,21 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
});
|
||||
} catch (e) {
|
||||
log(`deferral arm failed for ${p.qitemId}: ${(e as Error).message} — falling through to immediate notify-class delivery (fail-loud beats silent loss)`);
|
||||
return deliver(decision);
|
||||
return targetDeliver(decision);
|
||||
}
|
||||
outboundSeen.mark(episodeKey, "interrupt-deferred");
|
||||
releaseRef(p.qitemId);
|
||||
return { ok: true as const };
|
||||
}
|
||||
}
|
||||
return deliver(decision);
|
||||
return targetDeliver(decision);
|
||||
};
|
||||
|
||||
const wire = buildInProcessWire({
|
||||
home: opts.home,
|
||||
ops: outboundReady ? [OUTBOUND_OP] : [],
|
||||
deliver: outboundReady ? engineDeliver : deliver,
|
||||
ops: anyOutboundEnabled ? [OUTBOUND_OP] : [],
|
||||
...(anyOutboundEnabled ? { prepare: prepareDecision } : {}),
|
||||
deliver: anyOutboundEnabled ? engineDeliver : deliver,
|
||||
log,
|
||||
});
|
||||
selfDispatch = (decisionId, payload) =>
|
||||
@@ -1026,10 +1222,23 @@ export function buildSlackGatewayWire(opts: SlackWireOpts): GatewayWire {
|
||||
...(relayed ? { relayed: true } : {}),
|
||||
});
|
||||
|
||||
if (outboundReady) {
|
||||
if (anyOutboundEnabled) {
|
||||
const driverQueue = outboundReady
|
||||
? ports
|
||||
: {
|
||||
listHumanAlerts: async (filter: Parameters<typeof ports.listHumanAlerts>[0]) => {
|
||||
const alerts = await ports.listHumanAlerts(filter);
|
||||
try {
|
||||
const reg = registrySurface.loadHumanRegistry(opts.home);
|
||||
return alerts.filter((alert) => resolvePrimaryRelayBinding(alert.destinationSession, reg) !== null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
const driver = new SlackOutboundDriver({
|
||||
home: opts.home,
|
||||
queue: ports,
|
||||
queue: driverQueue,
|
||||
seen: outboundSeen,
|
||||
filter: { minimumLevel: cfg.minimumLevelThatPosts },
|
||||
dispatch: (op, ref, payload) => wire.dispatcher.dispatch(op, ref, payload),
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { EventBus } from "../event-bus.js";
|
||||
import { isTypedGateBlocker, UNATTRIBUTED_ACTOR, type QueueRepository, type QueueItem } from "../queue-repository.js";
|
||||
import { isTypedGateBlocker, mcOperationTag, UNATTRIBUTED_ACTOR, type QueueRepository, type QueueItem } from "../queue-repository.js";
|
||||
import type { PersistedEvent } from "../types.js";
|
||||
import {
|
||||
MissionControlActionLog,
|
||||
@@ -178,7 +178,13 @@ export class MissionControlWriteContract {
|
||||
return { actionId: last.actionId, verb: input.verb, qitemId: input.qitemId, closedQitem: item, createdQitemId: null,
|
||||
notifyAttempted: false, notifyResult: null, auditedAt: last.actedAt, reconciled: true, decisionRevision: current! };
|
||||
}
|
||||
throw this.revisionChanged(item, current!);
|
||||
const withoutOwnReply =
|
||||
operation && input.verb === "answer"
|
||||
? this.queueRepo.decisionRevision(item, { excludeOperationReply: operation })
|
||||
: current;
|
||||
if (withoutOwnReply !== input.expectedRevision) {
|
||||
throw this.revisionChanged(item, current!);
|
||||
}
|
||||
}
|
||||
const result = await this.actOn(operation ? { ...input, auditNotes: { ...input.auditNotes, operation } } : input);
|
||||
const saved = this.queueRepo.getById(input.qitemId);
|
||||
@@ -596,11 +602,29 @@ export class MissionControlWriteContract {
|
||||
if (existing && (ask.state === "pending" || ask.state === "done")) {
|
||||
const decision = questions.length && unansweredQuestions(questions, ask.humanAnswers ?? {}).length === 0
|
||||
? formatHumanAnswers(questions, ask.humanAnswers ?? {}).join("; ") : humanAnswerText(existing.body);
|
||||
if (ask.state === "pending" && !this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision, primaryUser })) {
|
||||
throw new MissionControlWriteContractError("answer_not_closed", `The answer is already in ${existing.qitemId}, but ${input.qitemId} didn't close.`, { qitemId: input.qitemId });
|
||||
}
|
||||
return this.recordAnswer(input, before, decision, { outcome: "answered", unanswered: [],
|
||||
message: `Already answered; the original answer went to ${existing.destinationSession} as ${existing.qitemId}. No new reply was sent.` }, existing.qitemId);
|
||||
return this.recordAnswer(
|
||||
input,
|
||||
before,
|
||||
decision,
|
||||
{
|
||||
outcome: "answered",
|
||||
unanswered: [],
|
||||
message: `Already answered; the original answer went to ${existing.destinationSession} as ${existing.qitemId}. No new reply was sent.`,
|
||||
},
|
||||
existing.qitemId,
|
||||
ask.state === "pending"
|
||||
? {
|
||||
primaryUser,
|
||||
allowAlreadyDone: false,
|
||||
onUnclosed: () =>
|
||||
new MissionControlWriteContractError(
|
||||
"answer_not_closed",
|
||||
`The answer is already in ${existing.qitemId}, but ${input.qitemId} didn't close.`,
|
||||
{ qitemId: input.qitemId },
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
// One answer per ask: another person's committed return is the answer. Before anything of this person's is recorded,
|
||||
// finish settling the ask with it and say who answered; nothing of theirs is recorded or sent.
|
||||
@@ -650,13 +674,21 @@ export class MissionControlWriteContract {
|
||||
const answeredBy = !forAsked ? `Answered by ${input.actorSession}`
|
||||
: unattributed ? `Answered at the terminal by someone OpenRig can't name (${UNNAMED_PRIMARY_USER}), unattributed, on ${forAsked}'s ask`
|
||||
: `Answered by ${input.actorSession}, ${primaryUser ? "the primary user at the terminal" : "a registered person"}, on ${forAsked}'s ask`;
|
||||
const operation =
|
||||
typeof input.auditNotes?.operation === "string" && input.auditNotes.operation.length > 0
|
||||
? input.auditNotes.operation
|
||||
: null;
|
||||
let returned: QueueItem;
|
||||
try {
|
||||
returned = await this.queueRepo.createHumanAnswer({
|
||||
sourceSession: input.actorSession, destinationSession: ask.sourceSession,
|
||||
body: `${reply}\n${answeredBy} in OpenRig.`,
|
||||
summary: `${forAsked && unattributed ? `Unattributed (${UNNAMED_PRIMARY_USER})` : input.actorSession} answered${forAsked ? ` ${forAsked}'s ask` : ""}: ${ask.summary ?? input.qitemId}`,
|
||||
tags: ["human-answer", ...(ask.tags ?? []).filter((t) => /^(project|mission|slice):/.test(t))],
|
||||
tags: [
|
||||
"human-answer",
|
||||
...(ask.tags ?? []).filter((t) => /^(project|mission|slice):/.test(t)),
|
||||
...(operation ? [mcOperationTag(operation)] : []),
|
||||
],
|
||||
inboundReplyTo: input.qitemId, ...(input.notify === false ? { nudge: false } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -681,13 +713,23 @@ export class MissionControlWriteContract {
|
||||
const revision = now ? this.queueRepo.decisionRevision(now) : null;
|
||||
if (now && revision !== input.expectedRevision) throw this.revisionChanged(now, revision!);
|
||||
}
|
||||
if (!this.queueRepo.resolveDirectHumanReply({ qitemId: input.qitemId, actorSession: input.actorSession, decision, primaryUser })
|
||||
&& this.queueRepo.getById(input.qitemId)?.state !== "done") {
|
||||
throw new MissionControlWriteContractError("answer_not_closed",
|
||||
`The answer went back to ${ask.sourceSession} as ${replyId}, but qitem ${input.qitemId} didn't close: it changed meanwhile.`,
|
||||
{ qitemId: input.qitemId, replyQitemId: replyId });
|
||||
}
|
||||
return this.recordAnswer(input, before, decision, { outcome: "answered", unanswered: [] }, replyId);
|
||||
return this.recordAnswer(
|
||||
input,
|
||||
before,
|
||||
decision,
|
||||
{ outcome: "answered", unanswered: [] },
|
||||
replyId,
|
||||
{
|
||||
primaryUser,
|
||||
allowAlreadyDone: true,
|
||||
onUnclosed: () =>
|
||||
new MissionControlWriteContractError(
|
||||
"answer_not_closed",
|
||||
`The answer went back to ${ask.sourceSession} as ${replyId}, but qitem ${input.qitemId} didn't close: it changed meanwhile.`,
|
||||
{ qitemId: input.qitemId, replyQitemId: replyId },
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Another person's committed return already answers the ask: settle the ask with it if it's still pending (the
|
||||
@@ -700,23 +742,74 @@ export class MissionControlWriteContract {
|
||||
{ qitemId: input.qitemId, answeredBy: other.sourceSession, replyQitemId: other.qitemId });
|
||||
}
|
||||
|
||||
/** The audit row and event for one answer (its queue writes are the repository's own). */
|
||||
private recordAnswer(input: MissionControlActionInput, before: Record<string, unknown>, reason: string,
|
||||
answer: NonNullable<MissionControlActionResult["answer"]>, createdQitemId: string | null = null): MissionControlActionResult {
|
||||
/** Close a pending ask (when requested) and record its audit row and event in one atomic transaction. */
|
||||
private recordAnswer(
|
||||
input: MissionControlActionInput,
|
||||
before: Record<string, unknown>,
|
||||
reason: string,
|
||||
answer: NonNullable<MissionControlActionResult["answer"]>,
|
||||
createdQitemId: string | null = null,
|
||||
closePending?: {
|
||||
primaryUser: boolean;
|
||||
allowAlreadyDone: boolean;
|
||||
onUnclosed: () => MissionControlWriteContractError;
|
||||
},
|
||||
): MissionControlActionResult {
|
||||
const evaluatedAt = this.now().toISOString();
|
||||
const after = this.queueRepo.getById(input.qitemId);
|
||||
let after: QueueItem | null = null;
|
||||
let actionEntry: ReturnType<MissionControlActionLog["record"]> | null = null;
|
||||
const persistedEvents: PersistedEvent[] = [];
|
||||
this.db.transaction(() => {
|
||||
actionEntry = this.actionLog.record({ actionVerb: "answer", qitemId: input.qitemId, actorSession: input.actorSession,
|
||||
actedAt: evaluatedAt, beforeState: before, afterState: after ? snapshotQitem(after) : null, reason,
|
||||
auditNotes: input.auditNotes ?? null, identityProvenance: input.identityProvenance ?? null });
|
||||
persistedEvents.push(this.eventBus.persistWithinTransaction({ type: "mission_control.action_executed",
|
||||
actionId: actionEntry.actionId, actionVerb: "answer", qitemId: input.qitemId, actorSession: input.actorSession }));
|
||||
})();
|
||||
for (const e of persistedEvents) this.eventBus.notifySubscribers(e);
|
||||
return { actionId: actionEntry!.actionId, verb: "answer", qitemId: input.qitemId, closedQitem: after, createdQitemId,
|
||||
notifyAttempted: false, notifyResult: null, auditedAt: evaluatedAt, answer };
|
||||
try {
|
||||
this.eventBus.withNotifyEnvelope((register) => {
|
||||
if (closePending) {
|
||||
const closed = this.queueRepo.resolveDirectHumanReply({
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
decision: reason,
|
||||
primaryUser: closePending.primaryUser,
|
||||
});
|
||||
if (!closed && (!closePending.allowAlreadyDone || this.queueRepo.getById(input.qitemId)?.state !== "done")) {
|
||||
throw closePending.onUnclosed();
|
||||
}
|
||||
}
|
||||
after = this.queueRepo.getById(input.qitemId);
|
||||
actionEntry = this.actionLog.record({
|
||||
actionVerb: "answer",
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
actedAt: evaluatedAt,
|
||||
beforeState: before,
|
||||
afterState: after ? snapshotQitem(after) : null,
|
||||
reason,
|
||||
auditNotes: input.auditNotes ?? null,
|
||||
identityProvenance: input.identityProvenance ?? null,
|
||||
});
|
||||
register(
|
||||
this.eventBus.persistWithinTransaction({
|
||||
type: "mission_control.action_executed",
|
||||
actionId: actionEntry.actionId,
|
||||
actionVerb: "answer",
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession,
|
||||
}),
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof MissionControlActionLogError) {
|
||||
throw new MissionControlWriteContractError(err.code, err.message, err.details);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
actionId: actionEntry!.actionId,
|
||||
verb: "answer",
|
||||
qitemId: input.qitemId,
|
||||
closedQitem: after,
|
||||
createdQitemId,
|
||||
notifyAttempted: false,
|
||||
notifyResult: null,
|
||||
auditedAt: evaluatedAt,
|
||||
answer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -256,7 +256,7 @@ export async function runQuietRelease(input: RunQuietReleaseInput): Promise<{ st
|
||||
return { state: "pending", released, dispositions };
|
||||
}
|
||||
if (open === 0) {
|
||||
input.jobsRepo.markTerminal(input.jobId, "quiet release complete: every held ask posted or given a disposition");
|
||||
input.jobsRepo.markTerminal(input.jobId, "quiet release complete: every held ask posted, accepted by its bridge, or given a disposition");
|
||||
return { state: "released", released, dispositions };
|
||||
}
|
||||
return { state: "pending", released, dispositions };
|
||||
@@ -274,10 +274,13 @@ function heldKeys(queueRepo: QueueRepository, windowId: string): Array<[string,
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Posted, or given a disposition: nothing more to release for this key. */
|
||||
/** Posted, accepted by a relay bridge (which ends local quiet-release custody without claiming downstream person delivery),
|
||||
* or given a disposition: nothing more to release for this key. */
|
||||
function resolved(queueRepo: QueueRepository, qitemId: string, key: string, notes?: string[]): boolean {
|
||||
const all = notes ?? queueRepo.listTransitions(qitemId).map((t) => t.transitionNote ?? "");
|
||||
return all.some((n) => hasToken(n, "slack-owner-notification-posted", key) || hasToken(n, DISPOSITION_PREFIX, key));
|
||||
return all.some((n) => hasToken(n, "slack-owner-notification-posted", key)
|
||||
|| hasToken(n, "relay-owner-notification-accepted", key)
|
||||
|| hasToken(n, DISPOSITION_PREFIX, key));
|
||||
}
|
||||
|
||||
/** Watchdog-engine policy wrapper: the scheduler's cadence drives evaluation; the current quiet state owns the timing. */
|
||||
|
||||
@@ -8,7 +8,59 @@ import { loadHumanRegistry, registeredPeople, resolveRegisteredHumanAddress, sla
|
||||
import { sharedChannelAddresses } from "./gateway/participants.js";
|
||||
import { resolveExternal } from "./gateway/external-admission.js";
|
||||
import type { AgentActivity, PersistedEvent } from "./types.js";
|
||||
import { QueueTransitionLog, type OwnerNotificationLevel, type QueueTransition, type RecentQueueTransitionScope } from "./queue-transition-log.js";
|
||||
import {
|
||||
QueueTransitionLog,
|
||||
RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX,
|
||||
RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX,
|
||||
formatRelayOwnerNotificationAccepted,
|
||||
formatRelayOwnerNotificationTransportFailed,
|
||||
parseRelayOwnerNotificationAccepted,
|
||||
parseRelayOwnerNotificationTransportFailed,
|
||||
type FormatRelayOwnerNotificationAcceptedInput,
|
||||
type FormatRelayOwnerNotificationTransportFailedInput,
|
||||
type OwnerNotificationLevel,
|
||||
type QueueTransition,
|
||||
type RecentQueueTransitionScope,
|
||||
type RelayOwnerNotificationReceipt,
|
||||
type RelayOwnerNotificationTransportFailure,
|
||||
} from "./queue-transition-log.js";
|
||||
|
||||
export {
|
||||
RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX,
|
||||
RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX,
|
||||
formatRelayOwnerNotificationAccepted,
|
||||
formatRelayOwnerNotificationTransportFailed,
|
||||
parseRelayOwnerNotificationAccepted,
|
||||
parseRelayOwnerNotificationTransportFailed,
|
||||
type FormatRelayOwnerNotificationAcceptedInput,
|
||||
type FormatRelayOwnerNotificationTransportFailedInput,
|
||||
type RelayOwnerNotificationReceipt,
|
||||
type RelayOwnerNotificationTransportFailure,
|
||||
};
|
||||
|
||||
export type HumanIntent = "decision" | "update";
|
||||
|
||||
export const MC_OPERATION_TAG_PREFIX = "mc-operation:" as const;
|
||||
|
||||
export function mcOperationTag(operation: string): string {
|
||||
return `${MC_OPERATION_TAG_PREFIX}${operation}`;
|
||||
}
|
||||
|
||||
export interface DecisionRevisionOptions {
|
||||
excludeOperationReply?: string | null;
|
||||
}
|
||||
|
||||
export interface RecordRelayOwnerNotificationAcceptedInput extends FormatRelayOwnerNotificationAcceptedInput {
|
||||
qitemId: string;
|
||||
actorSession?: string;
|
||||
closeUpdate?: boolean;
|
||||
humanIntent?: HumanIntent | null;
|
||||
}
|
||||
|
||||
export interface RecordRelayOwnerNotificationTransportFailedInput extends FormatRelayOwnerNotificationTransportFailedInput {
|
||||
qitemId: string;
|
||||
actorSession?: string;
|
||||
}
|
||||
import { WAKE_INTENT_PREFIX, type OutboxHandler, type OutboxEntry } from "./outbox-handler.js";
|
||||
import { physicalSessionName } from "./session-registry.js";
|
||||
import type { QueueWakeFacts } from "./draft-aware-delivery.js";
|
||||
@@ -210,10 +262,10 @@ export interface QueueItem {
|
||||
priority: QueuePriority;
|
||||
tier: string | null;
|
||||
/** OPR.0.5.6.14 — the delivery LEDGER verdict for gateway-routed rows
|
||||
* (posted / transport-failed / never-posted), derived from the row's own
|
||||
* (posted / accepted / transport-failed / never-posted), derived from the row's own
|
||||
* transitions. Pane-bound rows carry null — the field never lies about a
|
||||
* class it does not govern. Populated on getById and findUndelivered. */
|
||||
deliveryOutcome?: "posted" | "transport-failed" | "never-posted" | null;
|
||||
deliveryOutcome?: "posted" | "accepted" | "transport-failed" | "never-posted" | null;
|
||||
/** The undelivered surface's class for ledger-derived entries (the route
|
||||
* prefers this over the nudge-literal regex when present). */
|
||||
deliveryFailureClass?: string;
|
||||
@@ -1206,10 +1258,15 @@ export class QueueRepository {
|
||||
// A person's Slack message routed to a person is already in Slack: as it arrived, it's on Steering and the TUI and
|
||||
// isn't echoed back. A later park of it on a person is a new ask, classified (and posted) on that update.
|
||||
if (input.gatewayInbound) return { kind: "human-required", level: "RECORD" };
|
||||
// The person's Slack subscription decides: `every-ask` (the default) posts every ask (ALERT); `urgent-only` posts only
|
||||
// one marked --alert, and a plain ask lands on Steering and the TUI (RECORD: below the posting dial, so no post is owed).
|
||||
// Slack subscription filters only apply when Slack is the primary connector. When a person's primary connector
|
||||
// is relay (or by default when no Slack subscription restricts it), plain asks post by default (`every-ask`),
|
||||
// so ordinary relay asks post (ALERT) independently of any retained Slack-specific subscription.
|
||||
const person = people.find((entity) => entity.address === destinationHuman);
|
||||
const posts = input.humanAlert === true || slackSubscriptionFor(person).preset === "every-ask";
|
||||
const primaryBinding = person?.connectorBindings.find((b) => b.role === "primary");
|
||||
const posts = input.humanAlert === true
|
||||
|| (primaryBinding?.kind === "relay"
|
||||
? true
|
||||
: slackSubscriptionFor(person).preset === "every-ask");
|
||||
return input.humanIntent === "update"
|
||||
? { kind: "human-update", level: "NOTICE" }
|
||||
: { kind: "human-required", level: posts ? "ALERT" : "RECORD" };
|
||||
@@ -3908,7 +3965,9 @@ export class QueueRepository {
|
||||
return this.closeDirectAsk(item, input.actorSession, input.decision, input.primaryUser === true);
|
||||
})();
|
||||
if (!result) return false;
|
||||
for (const event of result.persistedEvents) this.eventBus.notifySubscribers(event);
|
||||
for (const event of result.persistedEvents) {
|
||||
if (!this.eventBus.registerPersistedWithinActiveEnvelope(event)) this.eventBus.notifySubscribers(event);
|
||||
}
|
||||
return mine;
|
||||
}
|
||||
|
||||
@@ -4031,10 +4090,24 @@ export class QueueRepository {
|
||||
/** The opaque revision of what a person decides on (#2446): the question (body, intent, questions) and its decision state
|
||||
* (open, blocked or terminal; who it's addressed to or parked on; its answers, a decision made for them, the current park,
|
||||
* and the replies returned). A label, a wake, a claim or a note on a park doesn't change it; a new park does, on any blocker. */
|
||||
decisionRevision(item: QueueItem): string {
|
||||
decisionRevision(item: QueueItem, opts?: DecisionRevisionOptions): string {
|
||||
const phase = item.state === "blocked" ? "blocked" : item.state === "pending" || item.state === "in-progress" ? "open" : item.state;
|
||||
const replies = this.hasReplyToColumn ? (this.db.prepare("SELECT qitem_id FROM queue_items WHERE reply_to = ? ORDER BY ts_created, rowid")
|
||||
.all(item.qitemId) as Array<{ qitem_id: string }>).map((row) => row.qitem_id) : [];
|
||||
const excludeOp =
|
||||
typeof opts?.excludeOperationReply === "string" && opts.excludeOperationReply.length > 0
|
||||
? mcOperationTag(opts.excludeOperationReply)
|
||||
: null;
|
||||
const replies = this.hasReplyToColumn
|
||||
? (excludeOp
|
||||
? (this.db
|
||||
.prepare(
|
||||
"SELECT qitem_id FROM queue_items WHERE reply_to = ? AND NOT (tags IS NOT NULL AND json_valid(tags) AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)) ORDER BY ts_created, rowid",
|
||||
)
|
||||
.all(item.qitemId, excludeOp) as Array<{ qitem_id: string }>)
|
||||
: (this.db
|
||||
.prepare("SELECT qitem_id FROM queue_items WHERE reply_to = ? ORDER BY ts_created, rowid")
|
||||
.all(item.qitemId) as Array<{ qitem_id: string }>)
|
||||
).map((row) => row.qitem_id)
|
||||
: [];
|
||||
// The park this is: the transition into blocked from another state (a later blocked-to-blocked note stays this park).
|
||||
const transitions = item.state === "blocked" ? this.transitionLog.listForQitem(item.qitemId) : [];
|
||||
let parkEntry: number | null = null;
|
||||
@@ -4046,6 +4119,92 @@ export class QueueRepository {
|
||||
return `dr1-${createHash("sha256").update(JSON.stringify(decision)).digest("hex").slice(0, 24)}`;
|
||||
}
|
||||
|
||||
listRelayOwnerNotificationReceipts(qitemId: string): RelayOwnerNotificationReceipt[] {
|
||||
return this.transitionLog.listRelayOwnerNotificationReceipts(qitemId);
|
||||
}
|
||||
|
||||
getRelayOwnerNotificationReceipt(qitemId: string, notificationKey?: string): RelayOwnerNotificationReceipt | null {
|
||||
return this.transitionLog.getRelayOwnerNotificationReceipt(qitemId, notificationKey);
|
||||
}
|
||||
|
||||
hasRelayOwnerNotificationReceipt(qitemId: string, notificationKey: string): boolean {
|
||||
return this.transitionLog.hasRelayOwnerNotificationReceipt(qitemId, notificationKey);
|
||||
}
|
||||
|
||||
findRelayOwnerNotificationReceiptByReplyRef(
|
||||
replyRef: string,
|
||||
): (RelayOwnerNotificationReceipt & { qitemId: string }) | null {
|
||||
return this.transitionLog.findRelayOwnerNotificationReceiptByReplyRef(replyRef);
|
||||
}
|
||||
|
||||
recordRelayOwnerNotificationTransportFailed(input: RecordRelayOwnerNotificationTransportFailedInput): QueueItem {
|
||||
const current = this.getByIdOrThrow(input.qitemId);
|
||||
const key = typeof input.notificationKey === "string" && input.notificationKey.trim().length > 0
|
||||
? input.notificationKey.trim()
|
||||
: input.qitemId;
|
||||
const normalizedConnectorRef =
|
||||
typeof input.connectorRef === "string" && input.connectorRef.trim().length > 0
|
||||
? input.connectorRef.trim()
|
||||
: null;
|
||||
const previousFailure = this.transitionLog
|
||||
.listForQitem(input.qitemId)
|
||||
.filter(
|
||||
(transition) =>
|
||||
(transition.transitionNote?.startsWith("slack-owner-notification-transport-failed ") ||
|
||||
transition.transitionNote?.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `)) &&
|
||||
transition.transitionNote.split(/\s+/).includes(`notification_key=${key}`),
|
||||
)
|
||||
.at(-1);
|
||||
if (previousFailure?.transitionNote?.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `)) {
|
||||
const parsedPrevious = parseRelayOwnerNotificationTransportFailed(previousFailure.transitionNote);
|
||||
if (parsedPrevious && parsedPrevious.connectorRef === normalizedConnectorRef) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
const note = formatRelayOwnerNotificationTransportFailed({
|
||||
notificationKey: key,
|
||||
connectorRef: normalizedConnectorRef,
|
||||
failureClass: input.failureClass,
|
||||
detail: input.detail,
|
||||
});
|
||||
return this.update({
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession ?? "daemon@kernel",
|
||||
transitionNote: note,
|
||||
});
|
||||
}
|
||||
|
||||
recordRelayOwnerNotificationAccepted(input: RecordRelayOwnerNotificationAcceptedInput): QueueItem {
|
||||
const current = this.getByIdOrThrow(input.qitemId);
|
||||
const isUpdate =
|
||||
current.humanIntent === "update" &&
|
||||
current.state !== "blocked" &&
|
||||
!current.blockedOn &&
|
||||
this.isPersonAddress(current.destinationSession);
|
||||
const shouldCloseUpdate = isUpdate && input.closeUpdate !== false && isBlockerLive(current.state);
|
||||
if (this.transitionLog.hasRelayOwnerNotificationReceipt(input.qitemId, input.notificationKey)) {
|
||||
if (shouldCloseUpdate) {
|
||||
return this.update({
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession ?? "daemon@kernel",
|
||||
state: "done",
|
||||
closureReason: "no-follow-on",
|
||||
});
|
||||
}
|
||||
return current;
|
||||
}
|
||||
const note = formatRelayOwnerNotificationAccepted({
|
||||
...input,
|
||||
revision: input.revision ?? this.decisionRevision(current),
|
||||
});
|
||||
return this.update({
|
||||
qitemId: input.qitemId,
|
||||
actorSession: input.actorSession ?? "daemon@kernel",
|
||||
...(shouldCloseUpdate ? { state: "done", closureReason: "no-follow-on" } : {}),
|
||||
transitionNote: note,
|
||||
});
|
||||
}
|
||||
|
||||
currentHumanRequiredEpisode(qitemId: string): { key: string; receipt: ReturnType<QueueTransitionLog["getOwnerNotificationReceipt"]> } | null {
|
||||
const episode = this.transitionLog.listForQitem(qitemId).filter((transition) => transition.ownerNotificationKind === "human-required").at(-1);
|
||||
if (!episode) return null;
|
||||
@@ -4160,7 +4319,7 @@ export class QueueRepository {
|
||||
return {
|
||||
...item,
|
||||
deliveryOutcome: ledger?.outcome ?? null,
|
||||
...(ledger && ledger.outcome !== "posted" ? { deliveryFailureDetail: ledger.detail } : {}),
|
||||
...(ledger && ledger.outcome !== "posted" && ledger.outcome !== "accepted" ? { deliveryFailureDetail: ledger.detail } : {}),
|
||||
...(item.replyTo ? { replyToFallback: this.replyToFallbackFor(item.qitemId) } : {}),
|
||||
};
|
||||
}
|
||||
@@ -4313,7 +4472,7 @@ export class QueueRepository {
|
||||
return {
|
||||
...item,
|
||||
deliveryOutcome: ledger?.outcome ?? null,
|
||||
...(ledger && ledger.outcome !== "posted" ? { deliveryFailureDetail: ledger.detail } : {}),
|
||||
...(ledger && ledger.outcome !== "posted" && ledger.outcome !== "accepted" ? { deliveryFailureDetail: ledger.detail } : {}),
|
||||
};
|
||||
});
|
||||
return opts?.compact ? items.map(compactRow) : items;
|
||||
@@ -4350,7 +4509,7 @@ export class QueueRepository {
|
||||
*/
|
||||
/** Delivered informational records remain queryable after closure. Receipt filtering
|
||||
* happens before LIMIT; no prose/tier classifier and no second event store. */
|
||||
listDeliveredHumanUpdates(opts: { limit?: number } = {}): Array<QueueItem & { deliveredAt: string; deliveryReceipt: string }> {
|
||||
listDeliveredHumanUpdates(opts: { limit?: number } = {}): Array<QueueItem & { deliveredAt: string; deliveryReceipt: string; deliveryOutcome: "posted" | "accepted" }> {
|
||||
if (!this.hasHumanIntentColumn) return [];
|
||||
const limit = Number.isFinite(opts.limit) ? Math.max(1, Math.min(101, Math.floor(opts.limit!))) : 20;
|
||||
const receipts = `SELECT qitem_id, ts, transition_note FROM queue_transitions` +
|
||||
@@ -4359,10 +4518,20 @@ export class QueueRepository {
|
||||
SELECT q.*, r.ts AS delivered_at, r.transition_note AS delivery_receipt
|
||||
FROM queue_items q JOIN (${receipts}) r ON r.qitem_id = q.qitem_id
|
||||
WHERE q.human_intent = 'update' AND is_human_seat_session(q.destination_session) = 1
|
||||
AND r.transition_note LIKE 'slack-owner-notification-posted %'
|
||||
AND (r.transition_note LIKE 'slack-owner-notification-posted %' OR r.transition_note LIKE 'relay-owner-notification-accepted %')
|
||||
ORDER BY r.ts DESC, q.qitem_id DESC LIMIT ?
|
||||
`).all(limit) as Array<QueueItemRow & { delivered_at: string; delivery_receipt: string }>;
|
||||
return rows.map((row) => ({ ...this.rowToItem(row), deliveredAt: row.delivered_at, deliveryReceipt: row.delivery_receipt }));
|
||||
return rows.map((row) => {
|
||||
const deliveryOutcome: "posted" | "accepted" = row.delivery_receipt.startsWith(`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX} `)
|
||||
? "accepted"
|
||||
: "posted";
|
||||
return {
|
||||
...this.rowToItem(row),
|
||||
deliveryOutcome,
|
||||
deliveredAt: row.delivered_at,
|
||||
deliveryReceipt: row.delivery_receipt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Current explicit human FYIs; delivery receipts are not required for local visibility.
|
||||
@@ -4578,7 +4747,9 @@ export class QueueRepository {
|
||||
OR EXISTS (SELECT 1 FROM queue_transitions receipt
|
||||
WHERE receipt.qitem_id = queue_items.qitem_id
|
||||
AND (receipt.transition_note LIKE 'slack-owner-notification-posted %'
|
||||
OR receipt.transition_note LIKE 'slack-owner-notification-transport-failed %')))`,
|
||||
OR receipt.transition_note LIKE 'relay-owner-notification-accepted %'
|
||||
OR receipt.transition_note LIKE 'slack-owner-notification-transport-failed %'
|
||||
OR receipt.transition_note LIKE 'relay-owner-notification-transport-failed %')))`,
|
||||
];
|
||||
const params: unknown[] = [];
|
||||
if (opts?.rig) {
|
||||
@@ -4593,7 +4764,7 @@ export class QueueRepository {
|
||||
for (const r of rows) {
|
||||
const item = this.rowToItem(r, !opts?.compact);
|
||||
const ledger = this.deliveryOutcomeFor(item.qitemId);
|
||||
if (ledger?.outcome === "posted") continue; // the receipt wins, always
|
||||
if (ledger?.outcome === "posted" || ledger?.outcome === "accepted") continue; // the receipt wins, always
|
||||
if (ledger?.outcome === "transport-failed") {
|
||||
out.push({
|
||||
...item,
|
||||
@@ -4761,23 +4932,34 @@ export class QueueRepository {
|
||||
* transitions are same-row but keyed by qitemId:OWNER-transitionId; an old
|
||||
* posted receipt cannot mask a later human park. Legacy pre-OWNER or literal
|
||||
* external rows retain their row-scoped fallback. */
|
||||
deliveryOutcomeFor(qitemId: string): { outcome: "posted" | "transport-failed" | "never-posted"; detail: string } | null {
|
||||
deliveryOutcomeFor(qitemId: string): { outcome: "posted" | "accepted" | "transport-failed" | "never-posted"; detail: string } | null {
|
||||
const ledger = this.deliveryLedger(qitemId);
|
||||
return ledger ? { outcome: ledger.outcome, detail: ledger.detail } : null;
|
||||
}
|
||||
|
||||
/** #514 — true only when this row's CURRENT human-notification episode (OWNER history addressed
|
||||
* to a registered human, aliases included) has a POSTED receipt. Legacy rows with no OWNER
|
||||
/** OPR.0.5.6.14 + #514 — true when the row's CURRENT ask episode (create-time pending ask
|
||||
* to a registered human, aliases included) has a POSTED or ACCEPTED receipt. Legacy rows with no OWNER
|
||||
* history, inactive episodes, never-posted and transport-failed all read false. Same derivation
|
||||
* as deliveryOutcomeFor; it reads row fields, never the waiting view. */
|
||||
humanNotificationPostedThisEpisode(qitemId: string, listed?: ListedLedger): boolean {
|
||||
if (!this.hasQueueTransitionsTable) return false;
|
||||
return this.humanNotificationDeliveryThisEpisode(qitemId, listed) !== null;
|
||||
}
|
||||
|
||||
private humanNotificationDeliveryThisEpisode(qitemId: string, listed?: ListedLedger): "posted" | "accepted" | null {
|
||||
if (!this.hasQueueTransitionsTable) return null;
|
||||
const owners = listed?.owners ?? this.transitionLog.ownerNotificationsForQitems([qitemId]).get(qitemId) ?? [];
|
||||
const owner = this.currentAskNotification(qitemId, owners);
|
||||
if (!owner) return false;
|
||||
if (!owner) return null;
|
||||
// The ledger's episode exists only when the destination resolves to a registered human (aliases included).
|
||||
const ledger = listed ? listed.ledger : this.deliveryLedger(qitemId, owners);
|
||||
return ledger !== null && ledger.outcome === "posted" && ledger.episode?.notificationKey === `${qitemId}:${owner.transitionId}`;
|
||||
if (
|
||||
ledger !== null &&
|
||||
(ledger.outcome === "posted" || ledger.outcome === "accepted") &&
|
||||
ledger.episode?.notificationKey === `${qitemId}:${owner.transitionId}`
|
||||
) {
|
||||
return ledger.outcome;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The ask's own notification while it still binds its recipient. Only that notification does: create writes it in
|
||||
@@ -4860,7 +5042,9 @@ export class QueueRepository {
|
||||
this.receiptNotesStatement ??= this.db.prepare(
|
||||
`SELECT qitem_id, transition_note FROM queue_transitions
|
||||
WHERE qitem_id IN (SELECT value FROM json_each(?)) AND (transition_note LIKE 'slack-owner-notification-posted %'
|
||||
OR transition_note LIKE 'slack-owner-notification-transport-failed %')
|
||||
OR transition_note LIKE 'relay-owner-notification-accepted %'
|
||||
OR transition_note LIKE 'slack-owner-notification-transport-failed %'
|
||||
OR transition_note LIKE 'relay-owner-notification-transport-failed %')
|
||||
ORDER BY transition_id DESC`,
|
||||
);
|
||||
const notes = new Map<string, string[]>();
|
||||
@@ -4879,7 +5063,13 @@ export class QueueRepository {
|
||||
: notes;
|
||||
const posted = currentNotes.find((note) => note.startsWith("slack-owner-notification-posted "));
|
||||
if (posted) return { outcome: "posted", detail: posted, episode };
|
||||
const failed = currentNotes.find((note) => note.startsWith("slack-owner-notification-transport-failed "));
|
||||
const accepted = currentNotes.find((note) => note.startsWith("relay-owner-notification-accepted "));
|
||||
if (accepted) return { outcome: "accepted", detail: accepted, episode };
|
||||
const failed = currentNotes.find(
|
||||
(note) =>
|
||||
note.startsWith("slack-owner-notification-transport-failed ") ||
|
||||
note.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `),
|
||||
);
|
||||
if (failed) return { outcome: "transport-failed", detail: failed, episode };
|
||||
if (episode?.quiet) return null;
|
||||
const startedAt = episode?.startedAt ?? item.tsCreated;
|
||||
@@ -5177,7 +5367,8 @@ export class QueueRepository {
|
||||
if (view && ["pending", "in-progress"].includes(view.state)) {
|
||||
// #514 — the unclaimed sweep skips a pending ask already posted to its human in the current
|
||||
// episode (same predicate as runStuckSweep), so the view must not promise that sweep.
|
||||
const posted = view.state === "pending" && this.humanNotificationPostedThisEpisode(qitemId, listed);
|
||||
const episodeDelivery = view.state === "pending" ? this.humanNotificationDeliveryThisEpisode(qitemId, listed) : null;
|
||||
const posted = episodeDelivery !== null;
|
||||
const onSteering = !posted && view.state === "pending" && this.humanAskWithoutAlert(qitemId, listed);
|
||||
const row = view.state === "pending" && !onSteering
|
||||
? this.db.prepare(`SELECT state, ${this.hasHumanIntentColumn ? "human_intent" : "NULL AS human_intent"}, destination_session, last_nudge_result FROM queue_items WHERE qitem_id = ?`).get(qitemId) as { state: QueueItem["state"]; human_intent: QueueItem["humanIntent"]; destination_session: string; last_nudge_result: string | null } | undefined
|
||||
@@ -5188,13 +5379,14 @@ export class QueueRepository {
|
||||
&& this.isLocalHumanDecisionWithoutGateway({ state: row.state, humanIntent: row.human_intent, destinationSession: row.destination_session, lastNudgeResult: row.last_nudge_result });
|
||||
const awaitingHuman = posted || onSteering || localOnly;
|
||||
if (awaitingHuman || humanUpdate) {
|
||||
const deliveryPhrase = episodeDelivery === "accepted" ? "accepted by the bridge" : "posted to the human";
|
||||
view.nextBackstop = { owner: view.owner, mechanism: localOnly ? "local attention feed (no registered human gateway)" : onSteering
|
||||
? "none (on Steering for the human, with no Slack alert; awaiting their decision)"
|
||||
: humanUpdate
|
||||
? posted
|
||||
? "none (posted to the human; informational correspondence requires no claim)"
|
||||
? `none (${deliveryPhrase}; informational correspondence requires no claim)`
|
||||
: "none (informational correspondence to the human; no claim required)"
|
||||
: "none (posted to the human; awaiting their decision)", dueAt: null, intervalSeconds: null };
|
||||
: `none (${deliveryPhrase}; awaiting their decision)`, dueAt: null, intervalSeconds: null };
|
||||
}
|
||||
// Quiet hours: a held ask is owned by its window's release job until it posts.
|
||||
const quiet = awaitingHuman ? null : quietCustodyBackstop(view.owner, quietHoldCustody(this.db, qitemId));
|
||||
@@ -5317,7 +5509,7 @@ export class QueueRepository {
|
||||
/** The row fields a delivery ledger reads, as rowToItem presents them. */
|
||||
type LedgerItem = Pick<QueueItem, "qitemId" | "state" | "blockedOn" | "destinationSession" | "tsCreated" | "lastNudgeResult">;
|
||||
|
||||
type DeliveryLedger = { outcome: "posted" | "transport-failed" | "never-posted"; detail: string; episode: { notificationKey: string; startedAt: string } | null };
|
||||
type DeliveryLedger = { outcome: "posted" | "accepted" | "transport-failed" | "never-posted"; detail: string; episode: { notificationKey: string; startedAt: string } | null };
|
||||
|
||||
/** A listed row's owner notifications (newest first) and its delivery ledger, read once for the whole listing. */
|
||||
type ListedLedger = { owners: QueueTransition[]; ledger: DeliveryLedger | null };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { ClosureReason } from "./hot-potato-enforcer.js";
|
||||
import { archiveWhereClause } from "./rig-repository.js";
|
||||
@@ -6,6 +7,198 @@ import { isHumanSeatSessionRef, parseSessionName } from "./session-name.js";
|
||||
export const OWNER_NOTIFICATION_LEVELS = ["RECORD", "NOTICE", "ALERT"] as const;
|
||||
export type OwnerNotificationLevel = (typeof OWNER_NOTIFICATION_LEVELS)[number];
|
||||
|
||||
export const RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX = "relay-owner-notification-accepted" as const;
|
||||
export const RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX = "relay-owner-notification-transport-failed" as const;
|
||||
|
||||
export interface RelayOwnerNotificationReceipt {
|
||||
notificationKey: string;
|
||||
deliveryId: string | null;
|
||||
connectorRef: string | null;
|
||||
replyRef: string | null;
|
||||
replyRefHash: string | null;
|
||||
revision: string | null;
|
||||
episodeKey: string | null;
|
||||
level: OwnerNotificationLevel | null;
|
||||
kind: string | null;
|
||||
digest: string | null;
|
||||
}
|
||||
|
||||
export interface FormatRelayOwnerNotificationAcceptedInput {
|
||||
notificationKey: string;
|
||||
deliveryId?: string | null;
|
||||
connectorRef?: string | null;
|
||||
replyRef?: string | null;
|
||||
replyRefHash?: string | null;
|
||||
revision?: string | null;
|
||||
episodeKey?: string | null;
|
||||
level?: OwnerNotificationLevel | string | null;
|
||||
kind?: string | null;
|
||||
digest?: string | null;
|
||||
}
|
||||
|
||||
export interface RelayOwnerNotificationTransportFailure {
|
||||
notificationKey: string;
|
||||
connectorRef: string | null;
|
||||
failureClass: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface FormatRelayOwnerNotificationTransportFailedInput {
|
||||
notificationKey: string;
|
||||
connectorRef?: string | null;
|
||||
failureClass: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
function encodeReceiptToken(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
return trimmed.replace(/%|[\s=]/g, (ch) => encodeURIComponent(ch));
|
||||
}
|
||||
|
||||
function encodeConnectorRefToken(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string" || value.length === 0) return null;
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function decodeReceiptToken(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string" || value.length === 0) return null;
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatRelayOwnerNotificationAccepted(input: FormatRelayOwnerNotificationAcceptedInput): string {
|
||||
const notificationKey = typeof input.notificationKey === "string" ? input.notificationKey.trim() : "";
|
||||
if (!notificationKey || /\s/.test(notificationKey)) {
|
||||
throw new Error("formatRelayOwnerNotificationAccepted requires a non-empty notificationKey");
|
||||
}
|
||||
const deliveryId = encodeReceiptToken(input.deliveryId);
|
||||
const connectorRef = encodeConnectorRefToken(input.connectorRef ?? null);
|
||||
const rawReplyRef = typeof input.replyRef === "string" && input.replyRef.trim().length > 0 ? input.replyRef.trim() : null;
|
||||
const replyRef = encodeReceiptToken(rawReplyRef);
|
||||
const replyRefHash =
|
||||
encodeReceiptToken(input.replyRefHash) ??
|
||||
(rawReplyRef ? createHash("sha256").update(rawReplyRef).digest("hex") : null);
|
||||
const revision = encodeReceiptToken(input.revision);
|
||||
const episodeKey = encodeReceiptToken(input.episodeKey);
|
||||
const level = encodeReceiptToken(input.level);
|
||||
const kind = encodeReceiptToken(input.kind);
|
||||
const digest = encodeReceiptToken(input.digest);
|
||||
|
||||
const tokens = [`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX}`, `notification_key=${notificationKey}`];
|
||||
if (deliveryId) tokens.push(`delivery_id=${deliveryId}`);
|
||||
if (connectorRef) tokens.push(`connector_ref=${connectorRef}`);
|
||||
if (replyRef) tokens.push(`reply_ref=${replyRef}`);
|
||||
if (replyRefHash) tokens.push(`reply_ref_hash=${replyRefHash}`);
|
||||
if (revision) tokens.push(`revision=${revision}`);
|
||||
if (episodeKey) tokens.push(`episode_key=${episodeKey}`);
|
||||
if (level) tokens.push(`level=${level}`);
|
||||
if (kind) tokens.push(`kind=${kind}`);
|
||||
if (digest) tokens.push(`digest=${digest}`);
|
||||
return tokens.join(" ");
|
||||
}
|
||||
|
||||
export function parseRelayOwnerNotificationAccepted(
|
||||
note: string | null | undefined,
|
||||
): RelayOwnerNotificationReceipt | null {
|
||||
if (!note || !note.startsWith(`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX} `)) return null;
|
||||
const fields = note.trim().split(/\s+/);
|
||||
const rawValue = (...names: string[]): string | null => {
|
||||
for (const name of names) {
|
||||
const prefix = `${name}=`;
|
||||
const found = fields.find((field) => field.startsWith(prefix));
|
||||
if (found) {
|
||||
const sliced = found.slice(prefix.length);
|
||||
if (sliced.length > 0) return sliced;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const decodedValue = (...names: string[]): string | null => decodeReceiptToken(rawValue(...names));
|
||||
const notificationKey = rawValue("notification_key");
|
||||
if (!notificationKey) return null;
|
||||
const replyRef = decodedValue("reply_ref");
|
||||
const replyRefHash =
|
||||
decodedValue("reply_ref_hash") ??
|
||||
(replyRef ? createHash("sha256").update(replyRef).digest("hex") : null);
|
||||
const rawLevel = decodedValue("level");
|
||||
const level =
|
||||
rawLevel && OWNER_NOTIFICATION_LEVELS.includes(rawLevel as OwnerNotificationLevel)
|
||||
? (rawLevel as OwnerNotificationLevel)
|
||||
: null;
|
||||
return {
|
||||
notificationKey,
|
||||
deliveryId: decodedValue("delivery_id"),
|
||||
connectorRef: decodedValue("connector_ref"),
|
||||
replyRef,
|
||||
replyRefHash,
|
||||
revision: decodedValue("revision"),
|
||||
episodeKey: decodedValue("episode_key"),
|
||||
level,
|
||||
kind: decodedValue("kind"),
|
||||
digest: decodedValue("digest"),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatRelayOwnerNotificationTransportFailed(
|
||||
input: FormatRelayOwnerNotificationTransportFailedInput,
|
||||
): string {
|
||||
const notificationKey = typeof input.notificationKey === "string" ? input.notificationKey.trim() : "";
|
||||
if (!notificationKey || /\s/.test(notificationKey)) {
|
||||
throw new Error("formatRelayOwnerNotificationTransportFailed requires a non-empty notificationKey");
|
||||
}
|
||||
const connectorRef = encodeConnectorRefToken(input.connectorRef ?? null);
|
||||
const failureClass = encodeReceiptToken(input.failureClass);
|
||||
if (!failureClass) {
|
||||
throw new Error("formatRelayOwnerNotificationTransportFailed requires a non-empty failureClass");
|
||||
}
|
||||
const detail = typeof input.detail === "string" ? input.detail.replace(/[\r\n]+/g, " ").trim() : "";
|
||||
const tokens = [
|
||||
`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX}`,
|
||||
`notification_key=${notificationKey}`,
|
||||
];
|
||||
if (connectorRef) tokens.push(`connector_ref=${connectorRef}`);
|
||||
tokens.push(`class=${failureClass}`);
|
||||
tokens.push(`error=${detail}`);
|
||||
return tokens.join(" ");
|
||||
}
|
||||
|
||||
export function parseRelayOwnerNotificationTransportFailed(
|
||||
note: string | null | undefined,
|
||||
): RelayOwnerNotificationTransportFailure | null {
|
||||
if (!note || !note.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `)) return null;
|
||||
const trimmed = note.trim();
|
||||
const fields = trimmed.split(/\s+/);
|
||||
const rawValue = (...names: string[]): string | null => {
|
||||
for (const name of names) {
|
||||
const prefix = `${name}=`;
|
||||
const found = fields.find((field) => field.startsWith(prefix));
|
||||
if (found) {
|
||||
const sliced = found.slice(prefix.length);
|
||||
if (sliced.length > 0) return sliced;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const notificationKey = rawValue("notification_key");
|
||||
if (!notificationKey) return null;
|
||||
const connectorRef = decodeReceiptToken(rawValue("connector_ref"));
|
||||
const failureClass = decodeReceiptToken(rawValue("class"));
|
||||
if (!failureClass) return null;
|
||||
const errorIdx = trimmed.indexOf(" error=");
|
||||
const error = errorIdx >= 0 ? trimmed.slice(errorIdx + " error=".length) : (rawValue("error") ?? "");
|
||||
return {
|
||||
notificationKey,
|
||||
connectorRef,
|
||||
failureClass,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function ownerNotificationLevelAtLeast(
|
||||
level: OwnerNotificationLevel,
|
||||
minimum: OwnerNotificationLevel,
|
||||
@@ -464,7 +657,14 @@ export class QueueTransitionLog {
|
||||
}
|
||||
|
||||
hasOwnerNotificationReceipt(qitemId: string, notificationKey: string): boolean {
|
||||
return this.getOwnerNotificationReceipt(qitemId, notificationKey) !== null;
|
||||
return (
|
||||
this.getOwnerNotificationReceipt(qitemId, notificationKey) !== null ||
|
||||
this.getRelayOwnerNotificationReceipt(qitemId, notificationKey) !== null
|
||||
);
|
||||
}
|
||||
|
||||
hasRelayOwnerNotificationReceipt(qitemId: string, notificationKey: string): boolean {
|
||||
return this.getRelayOwnerNotificationReceipt(qitemId, notificationKey) !== null;
|
||||
}
|
||||
|
||||
/** The latest owner-notification episode per ACTIVE queue row, in queue order. One query
|
||||
@@ -476,7 +676,7 @@ export class QueueTransitionLog {
|
||||
*
|
||||
* The eligible set is restricted to active queue items before any ranking happens, so a
|
||||
* large notification archive never adds scan or ranking work to a sweep with no active item. */
|
||||
activeOwnerNotificationEpisodes(includePosted: boolean): Array<{ qitemId: string; transition: QueueTransition; posted: boolean }> {
|
||||
activeOwnerNotificationEpisodes(includePosted = false): Array<{ qitemId: string; transition: QueueTransition; posted: boolean }> {
|
||||
if (!this.hasOwnerNotificationColumns) return [];
|
||||
// Exact token match with no wildcard risk: pad both ends so a key at the note's
|
||||
// start or end still matches, and instr() compares literally (qitem ids may
|
||||
@@ -485,7 +685,7 @@ export class QueueTransitionLog {
|
||||
`EXISTS (
|
||||
SELECT 1 FROM ${this.historySource} r
|
||||
WHERE r.qitem_id = ${qitemIdExpr}
|
||||
AND r.transition_note LIKE 'slack-owner-notification-posted %'
|
||||
AND (r.transition_note LIKE 'slack-owner-notification-posted %' OR r.transition_note LIKE 'relay-owner-notification-accepted %')
|
||||
AND instr(' ' || r.transition_note || ' ', ' notification_key=' || ${qitemIdExpr} || ':' || ${transitionIdExpr} || ' ') > 0
|
||||
)`;
|
||||
const postedFilter = includePosted ? "" : `AND NOT ${receipt("latest.qitem_id", "latest.transition_id")}`;
|
||||
@@ -540,6 +740,64 @@ export class QueueTransitionLog {
|
||||
return null;
|
||||
}
|
||||
|
||||
listRelayOwnerNotificationReceipts(qitemId: string): RelayOwnerNotificationReceipt[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT transition_id, transition_note FROM ${this.historySource}
|
||||
WHERE qitem_id = ? AND transition_note LIKE 'relay-owner-notification-accepted %'
|
||||
ORDER BY transition_id DESC`,
|
||||
)
|
||||
.all(qitemId) as Array<{ transition_id: number; transition_note: string }>;
|
||||
const receipts: RelayOwnerNotificationReceipt[] = [];
|
||||
for (const row of rows) {
|
||||
const parsed = parseRelayOwnerNotificationAccepted(row.transition_note);
|
||||
if (parsed) receipts.push(parsed);
|
||||
}
|
||||
return receipts;
|
||||
}
|
||||
|
||||
getRelayOwnerNotificationReceipt(qitemId: string, notificationKey?: string): RelayOwnerNotificationReceipt | null {
|
||||
for (const parsed of this.listRelayOwnerNotificationReceipts(qitemId)) {
|
||||
if (notificationKey === undefined || parsed.notificationKey === notificationKey) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
findRelayOwnerNotificationReceiptByReplyRef(
|
||||
replyRef: string,
|
||||
): (RelayOwnerNotificationReceipt & { qitemId: string }) | null {
|
||||
const trimmed = replyRef.trim();
|
||||
if (!trimmed) return null;
|
||||
const fullHash = createHash("sha256").update(trimmed, "utf8").digest("hex");
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT qitem_id, transition_note FROM ${this.historySource}
|
||||
WHERE transition_note LIKE 'relay-owner-notification-accepted %'
|
||||
AND (
|
||||
instr(' ' || transition_note || ' ', ' reply_ref_hash=' || ? || ' ') > 0
|
||||
OR instr(' ' || transition_note || ' ', ' reply_ref=' || ? || ' ') > 0
|
||||
)
|
||||
ORDER BY transition_id DESC`,
|
||||
)
|
||||
.all(fullHash, trimmed) as Array<{ qitem_id: string; transition_note: string }>;
|
||||
for (const row of rows) {
|
||||
const parsed = parseRelayOwnerNotificationAccepted(row.transition_note);
|
||||
if (!parsed) continue;
|
||||
if (parsed.replyRef !== null) {
|
||||
if (parsed.replyRef === trimmed && parsed.replyRefHash === fullHash) {
|
||||
return { qitemId: row.qitem_id, ...parsed };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (parsed.replyRefHash === fullHash) {
|
||||
return { qitemId: row.qitem_id, ...parsed };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private rowToTransition(row: QueueTransitionRow): QueueTransition {
|
||||
return {
|
||||
transitionId: row.transition_id,
|
||||
|
||||
@@ -72,6 +72,10 @@ import {
|
||||
resolveOperatorSeat,
|
||||
resolveSessionNodeId,
|
||||
} from "./queue-stuck-sweep.js";
|
||||
import {
|
||||
RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX,
|
||||
RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX,
|
||||
} from "./queue-transition-log.js";
|
||||
|
||||
export const WAKE_RETRY_INTERVAL_KEY = "queue.wake_retry_interval_seconds";
|
||||
export const DEFAULT_WAKE_RETRY_INTERVAL_SECONDS = 300;
|
||||
@@ -308,12 +312,16 @@ function readLadder(db: Database.Database, qitemId: string, after = lastMeaningf
|
||||
view.opOutcomeResolved = false;
|
||||
}
|
||||
// Outcome resolution (AM-F3, R1 B-3, R2 pre-marker discriminator): the S14
|
||||
// posted receipt or the termination record closes the episode ONLY when it
|
||||
// posted receipt, relay-owner-notification-accepted receipt, or the termination record closes the episode ONLY when it
|
||||
// (a) follows the dispatch marker chronologically (this loop resets the
|
||||
// flag at each dispatch, so pre-marker notes never survive) and (b) carries
|
||||
// the dispatched key when the marker is keyed. An unkeyed dispatch
|
||||
// (injected legacy ports) keeps the any-following-note shape.
|
||||
if (note.startsWith("slack-owner-notification-posted ") || note.startsWith("delivery-termination:")) {
|
||||
if (
|
||||
note.startsWith("slack-owner-notification-posted ") ||
|
||||
note.startsWith(`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX} `) ||
|
||||
note.startsWith("delivery-termination:")
|
||||
) {
|
||||
if (view.opEngineKey === null || note.split(/\s+/).includes(`notification_key=${view.opEngineKey}`)) {
|
||||
view.opOutcomeResolved = true;
|
||||
}
|
||||
@@ -357,6 +365,8 @@ function hasPickupEvidence(db: Database.Database, row: Pick<QueueItem, "qitemId"
|
||||
// a receipt/termination/deferral stamp must not pull the row out of the
|
||||
// ladder before the resolution pass reads it.
|
||||
note.startsWith("slack-owner-notification-") ||
|
||||
note.startsWith(`${RELAY_OWNER_NOTIFICATION_ACCEPTED_PREFIX} `) ||
|
||||
note.startsWith(`${RELAY_OWNER_NOTIFICATION_TRANSPORT_FAILED_PREFIX} `) ||
|
||||
note.startsWith("delivery-termination:") ||
|
||||
note.startsWith("delivery-deferral-") ||
|
||||
// Quiet hours: holding an ask and releasing it are delivery machinery too, never someone picking it up.
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
// - orphaned: held or released, but no active release job for its window: nothing will release or redrive it, so the
|
||||
// sweep must find it. The hold note and its job are written together, and the job retires only once every ask it
|
||||
// holds is resolved, so this happens only when a job is stopped or terminated from outside.
|
||||
// An ask that has posted, or whose release gave it a disposition, isn't quiet custody any more (null), and neither is
|
||||
// An ask that has posted (or been accepted by a relay bridge, which ends local quiet custody without claiming
|
||||
// downstream person delivery), or whose release gave it a disposition, isn't quiet custody any more (null), and neither is
|
||||
// any row without a quiet-hold note: digest-held and ordinary rows read exactly as before, and so does an attempted
|
||||
// ask kept in the dispatch buffer because quiet hours couldn't be read (it has no hold; it is ordinary in-flight work).
|
||||
import type Database from "better-sqlite3";
|
||||
@@ -44,7 +45,9 @@ export function quietHoldCustody(db: Database.Database, qitemId: string, now: Da
|
||||
const hold = notes.map(parseQuietHoldNote).find((h) => h?.notificationKey === key);
|
||||
if (!hold) return null;
|
||||
const windowId = hold.windowId;
|
||||
if (notes.some((n) => hasToken(n, "slack-owner-notification-posted", key) || hasToken(n, "quiet-release-disposition", key))) return null;
|
||||
if (notes.some((n) => hasToken(n, "slack-owner-notification-posted", key)
|
||||
|| hasToken(n, "relay-owner-notification-accepted", key)
|
||||
|| hasToken(n, "quiet-release-disposition", key))) return null;
|
||||
const live = db.prepare("SELECT 1 FROM watchdog_jobs WHERE policy = 'quiet-release' AND state = 'active' AND spec_yaml LIKE ? LIMIT 1")
|
||||
.get(`%${releaseJobWindowSpec(windowId)}%`);
|
||||
// Without a live job nothing redrives a release or releases a hold, whichever step it reached: orphaned.
|
||||
@@ -55,11 +58,13 @@ export function quietHoldCustody(db: Database.Database, qitemId: string, now: Da
|
||||
if (hasToken(notes[i]!, "quiet-release-dispatched", key) || hasToken(notes[i]!, "quiet-release-returned", key)) { step = i; break; }
|
||||
}
|
||||
if (step >= 0 && notes[step]!.startsWith("quiet-release-dispatched ")) {
|
||||
// A post failure for this notification with no receipt (a receipt or disposition ended custody above) is still
|
||||
// A post or relay transport failure for this notification with no receipt (a Slack post receipt, relay bridge
|
||||
// acceptance, or disposition ended custody above) is still
|
||||
// unresolved, even one recorded before this dispatch: the gateway records a failure once per notification, so a
|
||||
// failed re-dispatch adds none. That is the ledger's rule too: failed until a receipt. A release still in flight
|
||||
// after an earlier failure therefore reads as failed until its post lands.
|
||||
const failed = notes.find((n) => hasToken(n, "slack-owner-notification-transport-failed", key));
|
||||
const failed = notes.find((n) => hasToken(n, "slack-owner-notification-transport-failed", key)
|
||||
|| hasToken(n, "relay-owner-notification-transport-failed", key));
|
||||
if (failed) {
|
||||
const failure = failed.split(/\s+/).find((t) => t.startsWith("class="))?.slice("class=".length) ?? "unknown";
|
||||
return { state: "release-failed", windowId, notificationKey: key, nextBoundary: null, failure };
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { connectionsProjection } from "../domain/gateway/connections-projection.js";
|
||||
import type { SettingsStore } from "../domain/user-settings/settings-store.js";
|
||||
import { Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import path from "node:path";
|
||||
import type { QueueRepository } from "../domain/queue-repository.js";
|
||||
@@ -18,11 +19,17 @@ import { SeenStore } from "../domain/gateway/slack/state-store.js";
|
||||
import { makeQueuePorts, seedBacklogAsHistory } from "../domain/gateway/slack/queue-access.js";
|
||||
import { postOpenAsks } from "../domain/gateway/slack/open-asks.js";
|
||||
import { DispatchBuffer } from "../domain/gateway/dispatch-buffer.js";
|
||||
import { OPENRIG_HOME } from "../openrig-compat.js";
|
||||
import { OPENRIG_HOME, getOpenRigHome } from "../openrig-compat.js";
|
||||
import { loadHumanRegistry } from "../domain/gateway/human-registry.js";
|
||||
import { participantsJsonView } from "../domain/gateway/participants.js";
|
||||
import { participantsJsonView, sharedChannelAddresses } from "../domain/gateway/participants.js";
|
||||
import { resolvePrimaryRelayBinding, type RelayPayloadCarry } from "../domain/gateway/relay-outbound.js";
|
||||
import { resolveSecret } from "../domain/gateway/slack/secrets.js";
|
||||
import { resolveHumanDeliveryReadiness, type HumanDeliveryReadiness } from "../domain/gateway/human-readiness.js";
|
||||
import {
|
||||
RELAY_INBOUND_MAX_BODY_BYTES,
|
||||
isDirectLoopbackPeer,
|
||||
type RelayInboundHandler,
|
||||
} from "../domain/gateway/relay-inbound.js";
|
||||
import { requireSenderIdentity } from "./require-sender-identity.js";
|
||||
import { runChannelOperation } from "../domain/gateway/channel-operations.js";
|
||||
import { buildSlackAppManifest } from "../domain/gateway/slack/manifest.js";
|
||||
@@ -49,6 +56,8 @@ export function gatewayRoutes(opts: {
|
||||
/** The daemon's auth bearer (OPENRIG_AUTH_BEARER_TOKEN, the one `rig host pair` issues); a relay from a
|
||||
* shared app's hub must carry it. Null: no bearer configured, so the route trusts the bind (loopback or tailnet). */
|
||||
relayBearerToken?: string | null;
|
||||
/** Concrete local relay inbound handler (`POST /api/gateway/relay/inbound`). */
|
||||
relayInboundHandler?: RelayInboundHandler;
|
||||
/** The clock quiet hours are resolved against (tests inject one). */
|
||||
now?: () => Date;
|
||||
/** The Slack directory reads' fetch and secret environment (tests inject both). */
|
||||
@@ -56,6 +65,58 @@ export function gatewayRoutes(opts: {
|
||||
} = {}): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.post(
|
||||
"/relay/inbound",
|
||||
async (c, next) => {
|
||||
const remoteAddress = (
|
||||
c.env as { incoming?: { socket?: { remoteAddress?: string | null } } } | undefined
|
||||
)?.incoming?.socket?.remoteAddress;
|
||||
if (!isDirectLoopbackPeer(remoteAddress)) {
|
||||
return c.json(
|
||||
{
|
||||
error: "loopback_peer_required",
|
||||
message: "Relay inbound requests must originate from a direct local loopback TCP peer.",
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
},
|
||||
bodyLimit({
|
||||
maxSize: RELAY_INBOUND_MAX_BODY_BYTES,
|
||||
onError: (c) =>
|
||||
c.json(
|
||||
{
|
||||
error: "payload_too_large",
|
||||
message: `relay inbound payload exceeds ${RELAY_INBOUND_MAX_BODY_BYTES} bytes`,
|
||||
},
|
||||
413,
|
||||
),
|
||||
}),
|
||||
async (c) => {
|
||||
const handler =
|
||||
opts.relayInboundHandler ??
|
||||
(c.get("relayInboundHandler" as never) as RelayInboundHandler | undefined);
|
||||
if (!handler) {
|
||||
return c.json(
|
||||
{
|
||||
error: "relay_inbound_unavailable",
|
||||
message: "Relay inbound handler is not configured on this daemon.",
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const rawBody = await c.req.text();
|
||||
const result = await handler.handle({
|
||||
authorizationHeader: c.req.header("Authorization") ?? c.req.header("authorization"),
|
||||
rawBody,
|
||||
});
|
||||
return c.json(result.body, result.status as never);
|
||||
},
|
||||
);
|
||||
|
||||
// A shared Slack app across machines: the hub relays an event or click for a channel this machine
|
||||
// owns, and it runs through this machine's own inbound router. 503 keeps it in the hub's retry.
|
||||
// Registered before /slack/:operation, which would otherwise answer it with 404.
|
||||
@@ -74,12 +135,12 @@ export function gatewayRoutes(opts: {
|
||||
const subsystem = c.get("gatewaySubsystem" as never) as SubsystemHandle | undefined;
|
||||
let status: Record<string, unknown> | null = null;
|
||||
try { status = subsystem?.status() ?? null; } catch { /* unavailable is preserved */ }
|
||||
return c.json(connectionsProjection(opts.home ?? OPENRIG_HOME, status,
|
||||
return c.json(connectionsProjection(opts.home ?? getOpenRigHome(), status,
|
||||
c.get("settingsStore" as never) as SettingsStore | undefined));
|
||||
});
|
||||
|
||||
app.get("/participants", (c) => {
|
||||
const result = participantsJsonView(opts.home ?? OPENRIG_HOME, c.req.query("organization"));
|
||||
const result = participantsJsonView(opts.home ?? getOpenRigHome(), c.req.query("organization"));
|
||||
return result.ok ? c.json(result) : c.json({ error: "human_registry_unavailable", message: result.error }, 503);
|
||||
});
|
||||
|
||||
@@ -131,14 +192,17 @@ export function gatewayRoutes(opts: {
|
||||
const readiness = await opts.readiness(entityId, gatewayState);
|
||||
return readiness ? c.json({ ok: true, readiness }) : c.json({ error: "human_not_found", entityId }, 404);
|
||||
}
|
||||
const home = opts.home ?? OPENRIG_HOME;
|
||||
const home = opts.home ?? getOpenRigHome();
|
||||
const registry = loadHumanRegistry(home);
|
||||
if (!registry.ok) return c.json({ error: "human_registry_unavailable", message: registry.error }, 503);
|
||||
const human = registry.entities.find((candidate) => candidate.entityId === entityId);
|
||||
if (!human) return c.json({ error: "human_not_found", entityId }, 404);
|
||||
const cfg = loadConfig(home);
|
||||
const botToken = resolveSecret("SLACK_BOT_TOKEN", { envFile: cfg.secretsEnvFile ?? undefined });
|
||||
const readiness = await resolveHumanDeliveryReadiness({ human, config: cfg, gatewayState, botToken });
|
||||
const readiness = await resolveHumanDeliveryReadiness(
|
||||
{ human, config: cfg, gatewayState, botToken, home },
|
||||
{ entities: registry.entities },
|
||||
);
|
||||
return c.json({ ok: true, readiness });
|
||||
});
|
||||
|
||||
@@ -370,10 +434,28 @@ export function gatewayRoutes(opts: {
|
||||
"Register a human with rig gateway human add, or repair and re-project the existing human registry before enabling delivery.",
|
||||
}, 503) });
|
||||
const seen = new SeenStore(path.join(home, "state", "slack-outbound-seen.jsonl"));
|
||||
const slackOnlyRegistry = {
|
||||
...registry,
|
||||
entities: registry.entities.filter((entity) =>
|
||||
entity.connectorBindings.some((b) => b.role === "primary" && b.kind === "slack"),
|
||||
),
|
||||
};
|
||||
const dispatchBuffer = new DispatchBuffer(home);
|
||||
value = await seedBacklogAsHistory({
|
||||
queue: makeQueuePorts(queueRepo!, { loadHumanRegistry: () => registry }), seen,
|
||||
queue: makeQueuePorts(queueRepo!, {
|
||||
loadHumanRegistry: () => slackOnlyRegistry,
|
||||
sharedChannels: () => sharedChannelAddresses(home),
|
||||
}),
|
||||
seen,
|
||||
filter: { minimumLevel: cfg.minimumLevelThatPosts },
|
||||
buffer: new DispatchBuffer(home),
|
||||
buffer: {
|
||||
pending: () =>
|
||||
dispatchBuffer.pending().filter((d) => {
|
||||
const p = (d.payload ?? {}) as RelayPayloadCarry;
|
||||
if (p.relayConnectorRef || p.relayEnvelope) return false;
|
||||
return resolvePrimaryRelayBinding(p.destinationSession ?? d.entityBindingRef, registry) === null;
|
||||
}),
|
||||
},
|
||||
});
|
||||
} else value.onlineStatus = "slack connector disabled";
|
||||
saveConfig({ ...cfg, enabled }, home);
|
||||
|
||||
@@ -248,6 +248,8 @@ export interface AppDeps {
|
||||
* Optional so test harnesses constructing AppDeps directly need not provide one; the health
|
||||
* route reports honestly when absent. */
|
||||
gatewaySubsystem?: import("./domain/gateway/gateway-subsystem.js").GatewaySubsystem;
|
||||
/** Concrete local relay inbound handler (`POST /api/gateway/relay/inbound`). */
|
||||
relayInboundHandler?: import("./domain/gateway/relay-inbound.js").RelayInboundHandler;
|
||||
periodicSnapshotScheduler?: import("./domain/periodic-snapshot-scheduler.js").PeriodicSnapshotScheduler;
|
||||
workflowRuntime?: WorkflowRuntime;
|
||||
/**
|
||||
@@ -577,6 +579,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
c.set("cmuxAdapter" as never, deps.cmuxAdapter);
|
||||
// S10 — the in-daemon gateway subsystem handle (health surface + dispatch seam).
|
||||
c.set("gatewaySubsystem" as never, deps.gatewaySubsystem);
|
||||
c.set("relayInboundHandler" as never, deps.relayInboundHandler);
|
||||
// Slice 24 — per-rig CMUX workspace launcher wiring.
|
||||
c.set(
|
||||
"cmuxLayoutService" as never,
|
||||
@@ -901,7 +904,13 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/health-diagnosis", healthDiagnosisRoutes());
|
||||
// S10 — gateway subsystem admin (slack enable/disable with the seeding rule preserved).
|
||||
// A shared Slack app's relay carries the token `rig host pair` issued: this daemon's auth bearer.
|
||||
app.route("/api/gateway", gatewayRoutes({ relayBearerToken: deps.missionControlBearerToken ?? null }));
|
||||
app.route(
|
||||
"/api/gateway",
|
||||
gatewayRoutes({
|
||||
relayBearerToken: deps.missionControlBearerToken ?? null,
|
||||
relayInboundHandler: deps.relayInboundHandler,
|
||||
}),
|
||||
);
|
||||
app.route("/api/rigs/:rigId/env", envRoutes());
|
||||
app.route("/api/restore-check", restoreCheckRoutes);
|
||||
// Slice 09 (OPR.0.3.2.9) — rig-policy bindings (operator context mode).
|
||||
|
||||
@@ -211,6 +211,7 @@ import { managedBlockRecords } from "./domain/managed-block-records.js";
|
||||
import {
|
||||
getCompatibleOpenRigPath,
|
||||
getDefaultOpenRigPath,
|
||||
getOpenRigHome,
|
||||
readOpenRigEnv,
|
||||
} from "./openrig-compat.js";
|
||||
|
||||
@@ -2764,18 +2765,28 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// no drivers) — `rig slack status` names what is missing.
|
||||
const { GatewaySubsystem } = await import("./domain/gateway/gateway-subsystem.js");
|
||||
const { buildSlackGatewayWire, makeHumanReplyResolver } = await import("./domain/gateway/slack/slack-subsystem.js");
|
||||
const { createRelayInboundHandler } = await import("./domain/gateway/relay-inbound.js");
|
||||
const effectiveOpenRigHome = getOpenRigHome();
|
||||
const gatewaySubsystem = new GatewaySubsystem({
|
||||
home: OPENRIG_HOME,
|
||||
home: effectiveOpenRigHome,
|
||||
wire: () => buildSlackGatewayWire({
|
||||
home: OPENRIG_HOME,
|
||||
home: effectiveOpenRigHome,
|
||||
queueRepo: queueRepoInstance,
|
||||
resolveHumanReply: makeHumanReplyResolver(queueRepoInstance, deps.missionControlWriteContract, OPENRIG_HOME),
|
||||
resolveHumanReply: makeHumanReplyResolver(queueRepoInstance, deps.missionControlWriteContract, effectiveOpenRigHome),
|
||||
log: (m) => console.log(`[gateway] ${m}`),
|
||||
}),
|
||||
log: (m) => console.log(`[gateway] ${m}`),
|
||||
});
|
||||
gatewaySubsystem.start();
|
||||
deps.gatewaySubsystem = gatewaySubsystem;
|
||||
if (deps.missionControlWriteContract) {
|
||||
deps.relayInboundHandler = createRelayInboundHandler({
|
||||
home: effectiveOpenRigHome,
|
||||
queueRepo: queueRepoInstance,
|
||||
missionControlWriteContract: deps.missionControlWriteContract,
|
||||
missionControlActionLog: deps.missionControlActionLog,
|
||||
});
|
||||
}
|
||||
// OPR.0.5.6.1 — bind the delivery policies' late gateway ref.
|
||||
lateGatewayDispatch.fn = (op, ref, payload, opts) => gatewaySubsystem.dispatch(op, ref, payload, opts);
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { QueueTransitionLog } from "../src/domain/queue-transition-log.js";
|
||||
import {
|
||||
QueueTransitionLog,
|
||||
formatRelayOwnerNotificationAccepted,
|
||||
parseRelayOwnerNotificationAccepted,
|
||||
parseRelayOwnerNotificationTransportFailed,
|
||||
} from "../src/domain/queue-transition-log.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { queueRoutes } from "../src/routes/queue.js";
|
||||
@@ -116,4 +122,226 @@ describe("archived audit identity — real migration, writer and history readers
|
||||
expect(db.prepare("SELECT * FROM queue_transitions").all()).toEqual(before);
|
||||
expect(db.prepare("SELECT * FROM queue_transitions_archive").all()).toEqual([]);
|
||||
});
|
||||
|
||||
it("records and reads relay owner notification acceptance separately from Slack posted receipts across live and archive", async () => {
|
||||
const db = database();
|
||||
const repo = new QueueRepository(db, new EventBus(db), {
|
||||
loadHumanRegistry: () => ({
|
||||
ok: true as const,
|
||||
entities: [
|
||||
{
|
||||
entityId: "human-founder",
|
||||
class: "human" as const,
|
||||
displayName: "Founder",
|
||||
address: "human-founder@external",
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "slack" as const,
|
||||
connectorRef: "primary",
|
||||
secretsRef: "env:SLACK_BOT_TOKEN",
|
||||
role: "primary" as const,
|
||||
handle: "UFOUNDER",
|
||||
},
|
||||
],
|
||||
prefs: { deliveryClass: "A" as const },
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as never);
|
||||
const log = new QueueTransitionLog(db);
|
||||
const ask = await repo.create({
|
||||
sourceSession: "sender@rig-a",
|
||||
destinationSession: "human-founder@external",
|
||||
body: "Relay ask",
|
||||
summary: "Ask?",
|
||||
nudge: false,
|
||||
});
|
||||
const qitemId = ask.qitemId;
|
||||
const episode = log.latestOwnerNotificationForQitem(ask.qitemId)!;
|
||||
const key = `${ask.qitemId}:${episode.transitionId}`;
|
||||
|
||||
expect(log.activeOwnerNotificationEpisodes(false).some((e) => e.qitemId === qitemId)).toBe(true);
|
||||
|
||||
repo.recordRelayOwnerNotificationAccepted({
|
||||
qitemId,
|
||||
notificationKey: key,
|
||||
deliveryId: "del-1",
|
||||
connectorRef: "tg-main",
|
||||
replyRef: "tg:msg:99",
|
||||
revision: "dr1-abc",
|
||||
episodeKey: key,
|
||||
level: "ALERT",
|
||||
kind: "human-required",
|
||||
digest: "sha256:111",
|
||||
});
|
||||
|
||||
// Verify full 64-hex sha256 derivation when replyRefHash is not explicitly passed
|
||||
const rawReplyRef = "tg:msg:99";
|
||||
const expectedFullSha256 = createHash("sha256").update(rawReplyRef).digest("hex");
|
||||
expect(expectedFullSha256).toHaveLength(64);
|
||||
|
||||
expect(log.getOwnerNotificationReceipt(qitemId, key)).toBeNull();
|
||||
const expectedReceipt = {
|
||||
notificationKey: key,
|
||||
deliveryId: "del-1",
|
||||
connectorRef: "tg-main",
|
||||
replyRef: rawReplyRef,
|
||||
replyRefHash: expectedFullSha256,
|
||||
revision: "dr1-abc",
|
||||
episodeKey: key,
|
||||
level: "ALERT",
|
||||
kind: "human-required",
|
||||
digest: "sha256:111",
|
||||
};
|
||||
expect(log.getRelayOwnerNotificationReceipt(qitemId, key)).toEqual(expectedReceipt);
|
||||
expect(repo.getRelayOwnerNotificationReceipt(qitemId, key)).toEqual(expectedReceipt);
|
||||
expect(log.findRelayOwnerNotificationReceiptByReplyRef(rawReplyRef)).toEqual({
|
||||
qitemId,
|
||||
...expectedReceipt,
|
||||
});
|
||||
expect(repo.findRelayOwnerNotificationReceiptByReplyRef(rawReplyRef)).toEqual({
|
||||
qitemId,
|
||||
...expectedReceipt,
|
||||
});
|
||||
expect(log.findRelayOwnerNotificationReceiptByReplyRef("tg:msg:unknown")).toBeNull();
|
||||
expect(log.hasRelayOwnerNotificationReceipt(qitemId, key)).toBe(true);
|
||||
expect(log.hasRelayOwnerNotificationReceipt(qitemId, "other:key")).toBe(false);
|
||||
expect(log.hasOwnerNotificationReceipt(qitemId, key)).toBe(true);
|
||||
expect(log.activeOwnerNotificationEpisodes(false).some((e) => e.qitemId === qitemId)).toBe(false);
|
||||
expect(log.activeOwnerNotificationEpisodes(true).find((e) => e.qitemId === qitemId)).toMatchObject({
|
||||
qitemId,
|
||||
posted: true,
|
||||
});
|
||||
const outcome = repo.deliveryOutcomeFor(qitemId);
|
||||
expect(outcome).toEqual({
|
||||
outcome: "accepted",
|
||||
detail: expect.stringContaining("relay-owner-notification-accepted"),
|
||||
});
|
||||
expect(outcome.outcome).not.toBe("posted");
|
||||
expect(outcome.outcome).not.toBe("never-posted");
|
||||
|
||||
// Archive and verify readers still work across archive
|
||||
db.prepare("UPDATE queue_items SET state = 'done', ts_updated = ? WHERE qitem_id = ?").run(OLD, qitemId);
|
||||
db.prepare("UPDATE queue_transitions SET ts = ? WHERE qitem_id = ?").run(OLD, qitemId);
|
||||
expect(archiveAgedTerminalTransitions(db, { nowIso: NOW }).archivedQitems).toBe(1);
|
||||
expect(log.getOwnerNotificationReceipt(qitemId, key)).toBeNull();
|
||||
expect(log.getRelayOwnerNotificationReceipt(qitemId, key)).toEqual(expectedReceipt);
|
||||
expect(log.findRelayOwnerNotificationReceiptByReplyRef(rawReplyRef)).toEqual({
|
||||
qitemId,
|
||||
...expectedReceipt,
|
||||
});
|
||||
expect(repo.findRelayOwnerNotificationReceiptByReplyRef(rawReplyRef)).toEqual({
|
||||
qitemId,
|
||||
...expectedReceipt,
|
||||
});
|
||||
expect(log.hasRelayOwnerNotificationReceipt(qitemId, key)).toBe(true);
|
||||
expect(log.hasOwnerNotificationReceipt(qitemId, key)).toBe(true);
|
||||
|
||||
// Lossless connectorRef round-trip across whitespace, underscore, and delimiter punctuation without altering literal notification_key
|
||||
const distinctConnectorRefs = [
|
||||
"bridge one",
|
||||
"bridge_one",
|
||||
"bridge=one:two/three?four#five&six%25seven\teight",
|
||||
];
|
||||
for (const [idx, connectorRef] of distinctConnectorRefs.entries()) {
|
||||
const distinctKey = `${qitemId}:distinct-${idx}`;
|
||||
const distinctReplyRef = `replyRef_${idx}`;
|
||||
const distinctFullHash = createHash("sha256").update(distinctReplyRef).digest("hex");
|
||||
const formatted = formatRelayOwnerNotificationAccepted({
|
||||
notificationKey: distinctKey,
|
||||
deliveryId: `del-${idx}`,
|
||||
connectorRef,
|
||||
replyRef: distinctReplyRef,
|
||||
revision: "dr1-abc",
|
||||
episodeKey: distinctKey,
|
||||
level: "ALERT",
|
||||
kind: "human-required",
|
||||
});
|
||||
// SQL activeOwnerNotificationEpisodes relies on literal ' notification_key=<qitemId>:<transitionId> '
|
||||
expect(` ${formatted} `).toContain(` notification_key=${distinctKey} `);
|
||||
const parsed = parseRelayOwnerNotificationAccepted(formatted);
|
||||
expect(parsed?.connectorRef).toBe(connectorRef);
|
||||
expect(parsed?.notificationKey).toBe(distinctKey);
|
||||
expect(parsed?.replyRef).toBe(distinctReplyRef);
|
||||
expect(parsed?.replyRefHash).toBe(distinctFullHash);
|
||||
expect(parsed?.replyRefHash).toHaveLength(64);
|
||||
}
|
||||
const noteWithSpaces = formatRelayOwnerNotificationAccepted({
|
||||
notificationKey: `${qitemId}:space`,
|
||||
connectorRef: "bridge one",
|
||||
});
|
||||
const noteWithUnderscore = formatRelayOwnerNotificationAccepted({
|
||||
notificationKey: `${qitemId}:space`,
|
||||
connectorRef: "bridge_one",
|
||||
});
|
||||
expect(noteWithSpaces).not.toBe(noteWithUnderscore);
|
||||
expect(parseRelayOwnerNotificationAccepted(noteWithSpaces)?.connectorRef).toBe("bridge one");
|
||||
expect(parseRelayOwnerNotificationAccepted(noteWithUnderscore)?.connectorRef).toBe("bridge_one");
|
||||
|
||||
// Relay transport-failed note with connector_ref, dedupe, and queue-repository readers
|
||||
const failedAsk = await repo.create({
|
||||
sourceSession: "sender@rig-a",
|
||||
destinationSession: "human-founder@external",
|
||||
body: "Relay ask that fails transport",
|
||||
summary: "Fail ask?",
|
||||
nudge: false,
|
||||
});
|
||||
const failedEpisode = log.latestOwnerNotificationForQitem(failedAsk.qitemId)!;
|
||||
const failedKey = `${failedAsk.qitemId}:${failedEpisode.transitionId}`;
|
||||
repo.recordRelayOwnerNotificationTransportFailed({
|
||||
qitemId: failedAsk.qitemId,
|
||||
notificationKey: failedKey,
|
||||
connectorRef: "tg-main",
|
||||
failureClass: "relay-http-503",
|
||||
detail: "bridge unavailable",
|
||||
});
|
||||
const countAfterFirstFailure = repo.listTransitions(failedAsk.qitemId).length;
|
||||
// Duplicate failure for the same episode and same connector is deduplicated
|
||||
repo.recordRelayOwnerNotificationTransportFailed({
|
||||
qitemId: failedAsk.qitemId,
|
||||
notificationKey: failedKey,
|
||||
connectorRef: "tg-main",
|
||||
failureClass: "relay-http-503",
|
||||
detail: "bridge unavailable",
|
||||
});
|
||||
expect(repo.listTransitions(failedAsk.qitemId)).toHaveLength(countAfterFirstFailure);
|
||||
expect(repo.deliveryOutcomeFor(failedAsk.qitemId)).toEqual({
|
||||
outcome: "transport-failed",
|
||||
detail: `relay-owner-notification-transport-failed notification_key=${failedKey} connector_ref=tg-main class=relay-http-503 error=bridge unavailable`,
|
||||
});
|
||||
expect(repo.getById(failedAsk.qitemId)).toMatchObject({
|
||||
deliveryOutcome: "transport-failed",
|
||||
deliveryFailureDetail: expect.stringContaining("relay-owner-notification-transport-failed"),
|
||||
});
|
||||
expect(repo.findUndelivered().find((q) => q.qitemId === failedAsk.qitemId)).toMatchObject({
|
||||
qitemId: failedAsk.qitemId,
|
||||
deliveryOutcome: "transport-failed",
|
||||
deliveryFailureClass: "transport-failed",
|
||||
deliveryFailureDetail: expect.stringContaining("connector_ref=tg-main"),
|
||||
});
|
||||
|
||||
// Failure under a changed relay connectorRef must record a new note naming the new connector rather than suppressing
|
||||
repo.recordRelayOwnerNotificationTransportFailed({
|
||||
qitemId: failedAsk.qitemId,
|
||||
notificationKey: failedKey,
|
||||
connectorRef: "wa-backup",
|
||||
failureClass: "relay-http-502",
|
||||
detail: "backup bridge bad gateway",
|
||||
});
|
||||
expect(repo.listTransitions(failedAsk.qitemId)).toHaveLength(countAfterFirstFailure + 1);
|
||||
const parsedFailures = repo
|
||||
.listTransitions(failedAsk.qitemId)
|
||||
.map((t) => parseRelayOwnerNotificationTransportFailed(t.transitionNote))
|
||||
.filter((f): f is NonNullable<typeof f> => f !== null);
|
||||
expect(parsedFailures.at(-1)).toMatchObject({
|
||||
notificationKey: failedKey,
|
||||
connectorRef: "wa-backup",
|
||||
failureClass: "relay-http-502",
|
||||
error: "backup bridge bad gateway",
|
||||
});
|
||||
expect(repo.deliveryOutcomeFor(failedAsk.qitemId)).toEqual({
|
||||
outcome: "transport-failed",
|
||||
detail: `relay-owner-notification-transport-failed notification_key=${failedKey} connector_ref=wa-backup class=relay-http-502 error=backup bridge bad gateway`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -188,11 +188,29 @@ describe("complete human notifications", () => {
|
||||
expect(await (await app.request("/api/queue/list?attention=1")).json()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps failed FYIs pending and out of delivered history", async () => {
|
||||
it("keeps failed FYIs pending and out of delivered history for both Slack and Relay failure notes", async () => {
|
||||
const item = await repo.create({ ...request, humanIntent: "update" });
|
||||
expect(item.state).toBe("pending"); expect(repo.listDeliveredHumanUpdates()).toEqual([]);
|
||||
repo.update({ qitemId: item.qitemId, actorSession: "daemon@kernel", transitionNote: "slack-owner-notification-transport-failed notification_key=x class=transport error=synthetic" });
|
||||
expect(repo.getById(item.qitemId)?.state).toBe("pending"); expect(repo.listDeliveredHumanUpdates()).toEqual([]);
|
||||
const slackKey = `${item.qitemId}:${repo.transitionLog.latestOwnerNotificationForQitem(item.qitemId)!.transitionId}`;
|
||||
repo.update({ qitemId: item.qitemId, actorSession: "daemon@kernel", transitionNote: `slack-owner-notification-transport-failed notification_key=${slackKey} class=transport error=synthetic` });
|
||||
expect(repo.getById(item.qitemId)).toMatchObject({ state: "pending", deliveryOutcome: "transport-failed" });
|
||||
expect(repo.listDeliveredHumanUpdates()).toEqual([]);
|
||||
|
||||
const relayItem = await repo.create({ ...request, humanIntent: "update", summary: "Relay FYI" });
|
||||
const relayKey = `${relayItem.qitemId}:${repo.transitionLog.latestOwnerNotificationForQitem(relayItem.qitemId)!.transitionId}`;
|
||||
repo.update({
|
||||
qitemId: relayItem.qitemId,
|
||||
actorSession: "daemon@kernel",
|
||||
transitionNote: `relay-owner-notification-transport-failed notification_key=${relayKey} connector_ref=relay-1 class=relay-http-502 error=bad-gateway`,
|
||||
});
|
||||
expect(repo.getById(relayItem.qitemId)).toMatchObject({
|
||||
state: "pending",
|
||||
deliveryOutcome: "transport-failed",
|
||||
deliveryFailureDetail: expect.stringContaining("relay-owner-notification-transport-failed"),
|
||||
});
|
||||
expect(repo.getById(relayItem.qitemId)?.deliveryFailureDetail).toContain("connector_ref=relay-1");
|
||||
expect(repo.findUndelivered().map((q) => q.qitemId)).toContain(relayItem.qitemId);
|
||||
expect(repo.listDeliveredHumanUpdates()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps digest timing but dispatches each human request/update completely with stable identity", async () => {
|
||||
@@ -210,4 +228,120 @@ describe("complete human notifications", () => {
|
||||
await runDeliveryDigestFlush(input);
|
||||
expect((dispatch.mock.calls.slice(2) as unknown as typeof first).map((call) => call[3].decisionId)).toEqual(ids);
|
||||
});
|
||||
|
||||
it("closes informational updates on relay acceptance while keeping decision asks and blocked work open", async () => {
|
||||
const update = await repo.create({ ...request, humanIntent: "update", body: "FYI update via relay" });
|
||||
const ask = await repo.create({
|
||||
...request,
|
||||
humanIntent: "decision",
|
||||
body: "Decision ask via relay",
|
||||
humanQuestions: [
|
||||
{
|
||||
id: "q1",
|
||||
question: "Approve phase 1?",
|
||||
options: [
|
||||
{ id: "yes", label: "Yes" },
|
||||
{ id: "no", label: "No" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
question: "Approve phase 2?",
|
||||
options: [
|
||||
{ id: "yes", label: "Yes" },
|
||||
{ id: "no", label: "No" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
repo.recordHumanAnswer({
|
||||
qitemId: ask.qitemId,
|
||||
actorSession: "human-founder@external",
|
||||
questionId: "q1",
|
||||
optionId: "yes",
|
||||
}),
|
||||
).toMatchObject({ status: "recorded", complete: false });
|
||||
|
||||
const blockedWork = await repo.create({
|
||||
...request,
|
||||
destinationSession: "worker@rig",
|
||||
body: "Agent work parked on human decision",
|
||||
});
|
||||
repo.update({
|
||||
qitemId: blockedWork.qitemId,
|
||||
actorSession: "worker@rig",
|
||||
state: "blocked",
|
||||
blockedOn: "human-founder@external",
|
||||
summary: "Blocked on founder",
|
||||
evidenceRef: "/private/retained-proof.md",
|
||||
transitionNote: "parked on founder",
|
||||
});
|
||||
|
||||
repo.recordRelayOwnerNotificationAccepted({
|
||||
qitemId: update.qitemId,
|
||||
notificationKey: `${update.qitemId}:1`,
|
||||
deliveryId: "d-update",
|
||||
connectorRef: "relay-1",
|
||||
level: "NOTICE",
|
||||
kind: "human-update",
|
||||
});
|
||||
const transitionsAfterFirstUpdateReceipt = repo.listTransitions(update.qitemId).length;
|
||||
|
||||
// Idempotent duplicate receipt does not append another transition
|
||||
repo.recordRelayOwnerNotificationAccepted({
|
||||
qitemId: update.qitemId,
|
||||
notificationKey: `${update.qitemId}:1`,
|
||||
deliveryId: "d-update",
|
||||
connectorRef: "relay-1",
|
||||
level: "NOTICE",
|
||||
kind: "human-update",
|
||||
});
|
||||
expect(repo.listTransitions(update.qitemId)).toHaveLength(transitionsAfterFirstUpdateReceipt);
|
||||
|
||||
expect(repo.getById(update.qitemId)).toMatchObject({
|
||||
state: "done",
|
||||
closureReason: "no-follow-on",
|
||||
});
|
||||
const delivered = repo.listDeliveredHumanUpdates({ limit: 10 });
|
||||
const deliveredUpdate = delivered.find((item) => item.qitemId === update.qitemId);
|
||||
expect(deliveredUpdate).toBeDefined();
|
||||
expect(deliveredUpdate?.deliveryReceipt.startsWith("relay-owner-notification-accepted ")).toBe(true);
|
||||
expect(deliveredUpdate?.deliveryOutcome).toBe("accepted");
|
||||
|
||||
// Caller humanIntent override cannot close a decision ask or alter recorded answers
|
||||
repo.recordRelayOwnerNotificationAccepted({
|
||||
qitemId: ask.qitemId,
|
||||
notificationKey: `${ask.qitemId}:2`,
|
||||
deliveryId: "d-ask",
|
||||
connectorRef: "relay-1",
|
||||
replyRef: "ref-ask",
|
||||
level: "ALERT",
|
||||
kind: "human-required",
|
||||
humanIntent: "update",
|
||||
});
|
||||
|
||||
expect(repo.getById(ask.qitemId)).toMatchObject({
|
||||
state: "pending",
|
||||
humanAnswers: { q1: "yes" },
|
||||
});
|
||||
|
||||
// Notice acceptance with caller humanIntent="update" on blocked agent work must NEVER close or unpark it
|
||||
repo.recordRelayOwnerNotificationAccepted({
|
||||
qitemId: blockedWork.qitemId,
|
||||
notificationKey: `${blockedWork.qitemId}:3`,
|
||||
deliveryId: "d-blocked-notice",
|
||||
connectorRef: "relay-1",
|
||||
level: "NOTICE",
|
||||
kind: "human-update",
|
||||
humanIntent: "update",
|
||||
});
|
||||
|
||||
expect(repo.getById(blockedWork.qitemId)).toMatchObject({
|
||||
state: "blocked",
|
||||
blockedOn: "human-founder@external",
|
||||
humanAnswers: null,
|
||||
});
|
||||
expect(repo.hasRelayOwnerNotificationReceipt(blockedWork.qitemId, `${blockedWork.qitemId}:3`)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -64,15 +64,16 @@ function fixture() {
|
||||
};
|
||||
const revision = async (id: string) => (await (await app.request(`/api/queue/${id}`)).json() as { decisionRevision?: unknown }).decisionRevision;
|
||||
const act = async (body: Record<string, unknown>) => {
|
||||
const actor = (body.actorSession as string | undefined) ?? PERSON;
|
||||
const response = await app.request("/api/mission-control/action", { method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-OpenRig-Session": PERSON }, body: JSON.stringify({ actorSession: PERSON, ...body }) });
|
||||
headers: { "Content-Type": "application/json", "X-OpenRig-Session": actor }, body: JSON.stringify({ actorSession: actor, ...body }) });
|
||||
return { status: response.status, body: await response.json() as Record<string, any> };
|
||||
};
|
||||
const count = (sql: string, id: string) => (db.prepare(sql).get(id) as { n: number }).n;
|
||||
const written = (id: string) => ({ row: repo.getById(id), transitions: repo.listTransitions(id).length,
|
||||
replies: count("SELECT count(*) n FROM queue_items WHERE reply_to = ?", id),
|
||||
actions: count("SELECT count(*) n FROM mission_control_actions WHERE qitem_id = ?", id) });
|
||||
return { db, repo, ask, park, permissionAsk, revision, act, written };
|
||||
return { db, repo, contract, ask, park, permissionAsk, revision, act, written };
|
||||
}
|
||||
|
||||
describe("an opt-in decision revision on the settling verbs (#2446)", () => {
|
||||
@@ -199,6 +200,188 @@ describe("an opt-in decision revision on the settling verbs (#2446)", () => {
|
||||
expect(await f.revision(id)).toBe(read);
|
||||
expect(await f.act({ verb: "approve", qitemId: id, expectedRevision: read })).toMatchObject({ status: 200, body: { closedQitem: { state: "done" } } });
|
||||
});
|
||||
|
||||
it("repairs a direct-answer partial commit on retry without duplicating the reply row, then reconciles subsequent retries", async () => {
|
||||
const f = fixture();
|
||||
const row = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Ship the relay?", summary: "Ship?", humanIntent: "decision", nudge: false });
|
||||
const id = row.qitemId;
|
||||
const read = await f.revision(id);
|
||||
|
||||
const original = f.repo.createHumanAnswer.bind(f.repo);
|
||||
vi.spyOn(f.repo, "createHumanAnswer").mockImplementationOnce(async input => {
|
||||
await original(input);
|
||||
throw new Error("simulated crash after answer row commit before audit");
|
||||
});
|
||||
|
||||
await expect(
|
||||
f.contract.act({
|
||||
verb: "answer",
|
||||
qitemId: id,
|
||||
actorSession: PERSON,
|
||||
decision: "Ship it now",
|
||||
expectedRevision: read as string,
|
||||
auditNotes: { operationId: "relay-op-1" },
|
||||
}),
|
||||
).rejects.toThrow("simulated crash after answer row commit before audit");
|
||||
|
||||
expect(f.written(id)).toMatchObject({ replies: 1, actions: 0, row: { state: "pending" } });
|
||||
expect(await f.revision(id)).not.toBe(read);
|
||||
|
||||
const retry = await f.act({
|
||||
verb: "answer",
|
||||
qitemId: id,
|
||||
decision: "Ship it now",
|
||||
expectedRevision: read,
|
||||
auditNotes: { operationId: "relay-op-1" },
|
||||
});
|
||||
expect(retry.status).toBe(200);
|
||||
expect(f.written(id)).toMatchObject({ replies: 1, actions: 1, row: { state: "done" } });
|
||||
|
||||
const replyRow = f.db.prepare("SELECT tags FROM queue_items WHERE reply_to = ?").get(id) as { tags: string };
|
||||
const tags = JSON.parse(replyRow.tags) as string[];
|
||||
expect(tags.some(t => t.startsWith("mc-operation:"))).toBe(true);
|
||||
|
||||
const third = await f.act({
|
||||
verb: "answer",
|
||||
qitemId: id,
|
||||
decision: "Ship it now",
|
||||
expectedRevision: read,
|
||||
auditNotes: { operationId: "relay-op-1" },
|
||||
});
|
||||
expect(third).toMatchObject({ status: 200, body: { reconciled: true } });
|
||||
expect(f.written(id)).toMatchObject({ replies: 1, actions: 1 });
|
||||
});
|
||||
|
||||
it("recovers from a crash between ask close and audit record with one answer, one close transition, and one audit row", async () => {
|
||||
const f = fixture();
|
||||
const id = await f.ask();
|
||||
const read = await f.revision(id);
|
||||
|
||||
// Fault injected at audit record time (after resolveDirectHumanReply inside the atomic close+audit transaction)
|
||||
const actionLog = (f.contract as unknown as { actionLog: MissionControlActionLog }).actionLog;
|
||||
const origRecord = actionLog.record.bind(actionLog);
|
||||
let stateAtAuditCall: string | undefined;
|
||||
vi.spyOn(actionLog, "record").mockImplementationOnce((entry) => {
|
||||
stateAtAuditCall = f.repo.getById(id)?.state;
|
||||
throw new Error("simulated crash after ask close before audit");
|
||||
return origRecord(entry);
|
||||
});
|
||||
|
||||
await expect(
|
||||
f.contract.act({
|
||||
verb: "answer",
|
||||
qitemId: id,
|
||||
actorSession: PERSON,
|
||||
decision: "Ship Option A",
|
||||
expectedRevision: read as string,
|
||||
auditNotes: { operationId: "relay-close-audit-op-1" },
|
||||
}),
|
||||
).rejects.toThrow("simulated crash after ask close before audit");
|
||||
|
||||
expect(stateAtAuditCall).toBe("done");
|
||||
// Atomic rollback of close+audit leaves the ask pending with only the 1 tagged reply row
|
||||
expect(f.written(id)).toMatchObject({ replies: 1, actions: 0, row: { state: "pending" } });
|
||||
|
||||
// Conflicting payload or different operationId under the original revision still rejects (409)
|
||||
expect(
|
||||
await f.act({
|
||||
verb: "answer",
|
||||
qitemId: id,
|
||||
decision: "Different words",
|
||||
expectedRevision: read,
|
||||
auditNotes: { operationId: "relay-close-audit-op-1" },
|
||||
}),
|
||||
).toMatchObject({ status: 409, body: { error: "decision_revision_changed" } });
|
||||
|
||||
const retry = await f.act({
|
||||
verb: "answer",
|
||||
qitemId: id,
|
||||
decision: "Ship Option A",
|
||||
expectedRevision: read,
|
||||
auditNotes: { operationId: "relay-close-audit-op-1" },
|
||||
});
|
||||
expect(retry.status).toBe(200);
|
||||
expect(f.written(id)).toMatchObject({ replies: 1, actions: 1, row: { state: "done" } });
|
||||
const resolvedTransitions = f.repo
|
||||
.listTransitions(id)
|
||||
.filter((t) => t.ownerNotificationKind === "human-decision-resolved");
|
||||
expect(resolvedTransitions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("enforces guarded retry non-bypass invariants across different payloads, stale edits, and cross-responder attempts", async () => {
|
||||
const f = fixture();
|
||||
const row1 = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Ship the relay?", summary: "Ship?", humanIntent: "decision", nudge: false });
|
||||
const id1 = row1.qitemId;
|
||||
const read1 = await f.revision(id1);
|
||||
|
||||
const original = f.repo.createHumanAnswer.bind(f.repo);
|
||||
vi.spyOn(f.repo, "createHumanAnswer").mockImplementationOnce(async input => {
|
||||
await original(input);
|
||||
throw new Error("simulated crash after answer row commit before audit");
|
||||
});
|
||||
|
||||
await expect(
|
||||
f.contract.act({
|
||||
verb: "answer",
|
||||
qitemId: id1,
|
||||
actorSession: PERSON,
|
||||
decision: "Yes",
|
||||
expectedRevision: read1 as string,
|
||||
auditNotes: { operationId: "relay-op-2" },
|
||||
}),
|
||||
).rejects.toThrow("simulated crash after answer row commit before audit");
|
||||
|
||||
expect(await f.act({ verb: "answer", qitemId: id1, decision: "Yes", expectedRevision: read1, auditNotes: { operationId: "relay-op-other" } }))
|
||||
.toMatchObject({ status: 409, body: { error: "decision_revision_changed" } });
|
||||
expect(await f.act({ verb: "answer", qitemId: id1, decision: "No", expectedRevision: read1, auditNotes: { operationId: "relay-op-2" } }))
|
||||
.toMatchObject({ status: 409, body: { error: "decision_revision_changed" } });
|
||||
expect(await f.act({ verb: "answer", qitemId: id1, decision: "Yes", expectedRevision: read1 }))
|
||||
.toMatchObject({ status: 409, body: { error: "decision_revision_changed" } });
|
||||
expect(await f.act({ verb: "answer", qitemId: id1, actorSession: OTHER, decision: "Yes", expectedRevision: read1, auditNotes: { operationId: "relay-op-2" } }))
|
||||
.toMatchObject({ status: 409, body: { error: "decision_revision_changed" } });
|
||||
|
||||
// Stale edit during partial commit
|
||||
const row2 = await f.repo.create({ sourceSession: ASKER, destinationSession: PERSON, body: "Initial ask text", summary: "Ship?", humanIntent: "decision", nudge: false });
|
||||
const id2 = row2.qitemId;
|
||||
const read2 = await f.revision(id2);
|
||||
|
||||
vi.spyOn(f.repo, "createHumanAnswer").mockImplementationOnce(async input => {
|
||||
await original(input);
|
||||
throw new Error("simulated crash after answer row commit before audit");
|
||||
});
|
||||
|
||||
await expect(
|
||||
f.contract.act({
|
||||
verb: "answer",
|
||||
qitemId: id2,
|
||||
actorSession: PERSON,
|
||||
decision: "Proceed",
|
||||
expectedRevision: read2 as string,
|
||||
auditNotes: { operationId: "relay-op-3" },
|
||||
}),
|
||||
).rejects.toThrow("simulated crash after answer row commit before audit");
|
||||
|
||||
f.db.prepare("UPDATE queue_items SET body = 'Edited ask text' WHERE qitem_id = ?").run(id2);
|
||||
|
||||
expect(await f.act({ verb: "answer", qitemId: id2, decision: "Proceed", expectedRevision: read2, auditNotes: { operationId: "relay-op-3" } }))
|
||||
.toMatchObject({ status: 409, body: { error: "decision_revision_changed" } });
|
||||
});
|
||||
|
||||
it("keeps resolve operation fingerprint retry intact and rejects a new park episode under an old revision", async () => {
|
||||
const f = fixture();
|
||||
const id = await f.park();
|
||||
const read = await f.revision(id);
|
||||
|
||||
const first = await f.act({ verb: "resolve", qitemId: id, decision: "Unblocked", expectedRevision: read, auditNotes: { operationId: "resolve-op-1" } });
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
const retry = await f.act({ verb: "resolve", qitemId: id, decision: "Unblocked", expectedRevision: read, auditNotes: { operationId: "resolve-op-1" } });
|
||||
expect(retry).toMatchObject({ status: 200, body: { reconciled: true } });
|
||||
|
||||
f.repo.update({ qitemId: id, actorSession: "owner@test-rig", state: "blocked", blockedOn: PERSON });
|
||||
const staleNewEpisode = await f.act({ verb: "resolve", qitemId: id, decision: "Unblocked", expectedRevision: read, auditNotes: { operationId: "resolve-op-2" } });
|
||||
expect(staleNewEpisode).toMatchObject({ status: 409, body: { error: "decision_revision_changed" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the route carries a client's revision to the write that compares it (#2446)", () => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// Every section below fails at base for exactly those reasons.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import Database from "better-sqlite3";
|
||||
@@ -1193,4 +1193,141 @@ describe("OPR.0.5.6.1 §8 — the production composition is live (R2 B-1/B-2/B-3
|
||||
wire.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes relay owner acceptance on operator escalations as outcome accepted (never posted) without closing underlying work", async () => {
|
||||
const mod = await operatorEngineModule();
|
||||
expect(mod).not.toBeNull();
|
||||
|
||||
const src = await repo.create({ sourceSession: "sender@r", destinationSession: "relay@r", body: "obligation" });
|
||||
const { created } = await repo.handoff({ qitemId: src.qitemId, fromSession: "relay@r", toSession: "worker@r", nudge: false });
|
||||
db.prepare("UPDATE queue_items SET last_nudge_attempt = ?, last_nudge_result = ? WHERE qitem_id = ?")
|
||||
.run(new Date(Date.now() - 10 * 60_000).toISOString(), "failed:tmux session not found", created.qitemId);
|
||||
|
||||
const relayEnvPath = join(home, "relay-op.env");
|
||||
writeFileSync(
|
||||
relayEnvPath,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:43117/outbound",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token",
|
||||
].join("\n") + "\n",
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const relayRegistry = {
|
||||
ok: true as const,
|
||||
entities: [
|
||||
{
|
||||
entityId: "human-founder",
|
||||
class: "human" as const,
|
||||
displayName: "Founder",
|
||||
address: "human-founder@external",
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "relay" as const,
|
||||
connectorRef: "relay-op",
|
||||
secretsRef: `file:${relayEnvPath}`,
|
||||
role: "primary" as const,
|
||||
handle: "founder_relay",
|
||||
},
|
||||
],
|
||||
prefs: { deliveryClass: "B" as const, availability: "available" as const },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const make = (mod as {
|
||||
makeOperatorDeliveryEngine: (deps: unknown) => {
|
||||
dispatchEscalation: (row: QueueItem, reason: string) => Promise<{ decision: string; resolved: boolean; dispatched?: boolean; notificationKey?: string }>;
|
||||
hasAcceptedEscalation: (row: QueueItem) => boolean;
|
||||
};
|
||||
}).makeOperatorDeliveryEngine;
|
||||
|
||||
// Cross-entity provisioning conflict must be caught by default readiness via { entities: reg.entities }
|
||||
const conflictingRegistry = {
|
||||
ok: true as const,
|
||||
entities: [
|
||||
...relayRegistry.entities,
|
||||
{
|
||||
entityId: "human-second",
|
||||
class: "human" as const,
|
||||
displayName: "Second",
|
||||
address: "human-second@external",
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "relay" as const,
|
||||
connectorRef: "relay-op",
|
||||
secretsRef: `file:${join(home, "other-relay.env")}`,
|
||||
role: "secondary" as const,
|
||||
handle: "second_relay",
|
||||
},
|
||||
],
|
||||
prefs: { deliveryClass: "B" as const, availability: "available" as const },
|
||||
},
|
||||
],
|
||||
};
|
||||
mkdirSync(join(home, "gateway"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, "gateway", "participants.yaml"),
|
||||
"version: 1\ndefault: human-founder\norganizations: {}\ngroups: {}\nparticipants: {}\n",
|
||||
);
|
||||
|
||||
const conflictingPort = make({
|
||||
home,
|
||||
gatewayState: () => "active",
|
||||
queueRepo: repo,
|
||||
dispatch: () => ({ ok: true, decisionId: "d-conflict" }),
|
||||
registry: { loadHumanRegistry: () => conflictingRegistry },
|
||||
});
|
||||
const conflictResult = await conflictingPort.dispatchEscalation(created, "wake failed");
|
||||
expect(conflictResult).toMatchObject({
|
||||
dispatched: false,
|
||||
resolved: false,
|
||||
decision: expect.stringMatching(/^unavailable:human-delivery:/),
|
||||
});
|
||||
|
||||
// Default readiness with provisioned relay profile (no custom readiness stub, no Slack config/network probe)
|
||||
const port = make({
|
||||
home,
|
||||
gatewayState: () => "active",
|
||||
queueRepo: repo,
|
||||
dispatch: () => ({ ok: true, decisionId: "d-relay" }),
|
||||
registry: { loadHumanRegistry: () => relayRegistry },
|
||||
});
|
||||
|
||||
const first = await port.dispatchEscalation(created, "wake failed");
|
||||
expect(first).toMatchObject({ decision: "interrupt", dispatched: true, resolved: false });
|
||||
expect(first.notificationKey).toBeTruthy();
|
||||
|
||||
// Hypothetical non-shipped alternative formats must NOT resolve the escalation
|
||||
repo.update({
|
||||
qitemId: created.qitemId,
|
||||
actorSession: "daemon@kernel",
|
||||
transitionNote: `relay_accepted notification_key=${first.notificationKey!} outcome=relay_accepted`,
|
||||
});
|
||||
expect(port.hasAcceptedEscalation(created)).toBe(false);
|
||||
|
||||
// Real relay-owner-notification-accepted evidence resolves as "accepted" (never "posted") without closing work
|
||||
repo.recordRelayOwnerNotificationAccepted({
|
||||
qitemId: created.qitemId,
|
||||
notificationKey: first.notificationKey!,
|
||||
deliveryId: "del-op-1",
|
||||
connectorRef: "relay-op",
|
||||
level: "ALERT",
|
||||
kind: "human-required",
|
||||
});
|
||||
|
||||
expect(port.hasAcceptedEscalation(created)).toBe(true);
|
||||
|
||||
const second = await port.dispatchEscalation(created, "wake failed");
|
||||
expect(second).toMatchObject({
|
||||
decision: "accepted",
|
||||
resolved: true,
|
||||
dispatched: true,
|
||||
notificationKey: first.notificationKey,
|
||||
});
|
||||
expect(second.decision).toBe("accepted");
|
||||
expect(second.decision).not.toBe("posted");
|
||||
expect(repo.getById(created.qitemId)?.state).toBe("pending");
|
||||
});
|
||||
});
|
||||
+601
@@ -0,0 +1,601 @@
|
||||
/**
|
||||
* Test-only recording local HTTP bridge fixture (`recording-relay.ts`).
|
||||
*
|
||||
* NOTE: This is a local test recording bridge for verifying OpenRig's loopback
|
||||
* HTTP relay contract in integration tests and the private environment validator.
|
||||
* It is NOT an installed or production messaging platform backend.
|
||||
*
|
||||
* Strictly zero `vitest` runtime imports so standalone `tsx` scripts can reuse it.
|
||||
*/
|
||||
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fsyncSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
writeSync,
|
||||
} from "node:fs";
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type Server,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { dirname } from "node:path";
|
||||
import type {
|
||||
RelayDigestItem,
|
||||
RelayOutboundEnvelope,
|
||||
} from "../../src/domain/gateway/relay-outbound.js";
|
||||
import type { RelayInboundOrdering } from "../../src/domain/gateway/relay-inbound.js";
|
||||
|
||||
export type { RelayDigestItem, RelayOutboundEnvelope, RelayInboundOrdering };
|
||||
|
||||
export interface BridgeCustodyRecord {
|
||||
deliveryId: string;
|
||||
payloadSha256: string;
|
||||
rawBytesSha256: string;
|
||||
receivedAt: string;
|
||||
envelope: RelayOutboundEnvelope;
|
||||
}
|
||||
|
||||
export interface BridgeSanitizedStats {
|
||||
relayPostCount: number;
|
||||
acceptedCount: number;
|
||||
deduplicatedCount: number;
|
||||
conflictCount: number;
|
||||
unauthorizedCount: number;
|
||||
lostAckCount: number;
|
||||
ignoredProbeCount: number;
|
||||
custodyCount: number;
|
||||
custodyDigestSha256: string;
|
||||
}
|
||||
|
||||
export interface RelayInboundReplyInput {
|
||||
eventId: string;
|
||||
sender: {
|
||||
handle: string;
|
||||
};
|
||||
replyRef: string;
|
||||
text: string;
|
||||
ordering?: RelayInboundOrdering;
|
||||
}
|
||||
|
||||
export interface SendInboundReplyOptions {
|
||||
/** Override Bearer token (`null` omits the Authorization header). */
|
||||
tokenOverride?: string | null;
|
||||
}
|
||||
|
||||
export interface SendInboundReplyResult {
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RecordingRelayOptions {
|
||||
/** Expected Bearer token on `POST /outbound` from OpenRig. Held in memory only. */
|
||||
outboundToken: string;
|
||||
/** Bearer token sent on `POST /api/gateway/relay/inbound` to OpenRig. Held in memory only. */
|
||||
inboundToken: string;
|
||||
/** Path to durable JSONL custody store file. */
|
||||
custodyFilePath: string;
|
||||
/** Optional TCP port (defaults to 0 for OS-assigned loopback port). */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export interface RecordingRelayHandle {
|
||||
readonly baseUrl: string;
|
||||
readonly outboundUrl: string;
|
||||
readonly port: number;
|
||||
readonly custodyFilePath: string;
|
||||
setLoseNextAck(lose: boolean): void;
|
||||
getCustodyRecords(): BridgeCustodyRecord[];
|
||||
getSanitizedStats(): BridgeSanitizedStats;
|
||||
restart(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
sendInboundReply(
|
||||
daemonBaseUrl: string,
|
||||
payload: RelayInboundReplyInput,
|
||||
opts?: SendInboundReplyOptions,
|
||||
): Promise<SendInboundReplyResult>;
|
||||
}
|
||||
|
||||
function canonicalJsonStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => canonicalJsonStringify(item)).join(",")}]`;
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj)
|
||||
.filter((k) => obj[k] !== undefined)
|
||||
.sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJsonStringify(obj[k])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
export function computeCanonicalSha256(value: unknown): string {
|
||||
return createHash("sha256")
|
||||
.update(canonicalJsonStringify(value), "utf8")
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function constantTimeBearerCheck(
|
||||
authHeader: string | undefined,
|
||||
expectedToken: string,
|
||||
): boolean {
|
||||
if (!authHeader || typeof authHeader !== "string") return false;
|
||||
const match = /^Bearer\s+(\S+)$/.exec(authHeader.trim());
|
||||
if (!match?.[1] || !expectedToken) return false;
|
||||
const providedBuf = Buffer.from(match[1], "utf8");
|
||||
const expectedBuf = Buffer.from(expectedToken, "utf8");
|
||||
if (providedBuf.length !== expectedBuf.length) {
|
||||
timingSafeEqual(expectedBuf, expectedBuf);
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(providedBuf, expectedBuf);
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
const ALLOWED_ENVELOPE_KEYS = new Set([
|
||||
"v",
|
||||
"deliveryId",
|
||||
"recipient",
|
||||
"intent",
|
||||
"outcome",
|
||||
"summary",
|
||||
"body",
|
||||
"replyRef",
|
||||
"items",
|
||||
]);
|
||||
|
||||
const ALLOWED_ITEM_KEYS = new Set([
|
||||
"qitemId",
|
||||
"notificationKey",
|
||||
"summary",
|
||||
"body",
|
||||
"replyRef",
|
||||
]);
|
||||
|
||||
function validateOutboundEnvelope(
|
||||
parsed: unknown,
|
||||
): { ok: true; envelope: RelayOutboundEnvelope } | { ok: false; error: string } {
|
||||
if (!isPlainObject(parsed)) {
|
||||
return { ok: false, error: "Envelope must be a JSON object" };
|
||||
}
|
||||
for (const k of Object.keys(parsed)) {
|
||||
if (!ALLOWED_ENVELOPE_KEYS.has(k)) {
|
||||
return { ok: false, error: `Unknown envelope field: ${k}` };
|
||||
}
|
||||
}
|
||||
if (parsed.v !== 1) {
|
||||
return { ok: false, error: "Expected protocol version v: 1" };
|
||||
}
|
||||
if (typeof parsed.deliveryId !== "string" || !parsed.deliveryId.trim()) {
|
||||
return { ok: false, error: "Missing or empty deliveryId" };
|
||||
}
|
||||
if (
|
||||
!isPlainObject(parsed.recipient) ||
|
||||
typeof parsed.recipient.handle !== "string" ||
|
||||
!parsed.recipient.handle.trim() ||
|
||||
Object.keys(parsed.recipient).some((k) => k !== "handle")
|
||||
) {
|
||||
return { ok: false, error: "Invalid recipient.handle" };
|
||||
}
|
||||
if (parsed.intent !== "decision" && parsed.intent !== "update") {
|
||||
return { ok: false, error: "Invalid intent (expected decision | update)" };
|
||||
}
|
||||
if (
|
||||
parsed.outcome !== "interrupt" &&
|
||||
parsed.outcome !== "notify" &&
|
||||
parsed.outcome !== "digest"
|
||||
) {
|
||||
return { ok: false, error: "Invalid outcome (expected interrupt | notify | digest)" };
|
||||
}
|
||||
if (typeof parsed.summary !== "string" || typeof parsed.body !== "string") {
|
||||
return { ok: false, error: "Missing summary or body string" };
|
||||
}
|
||||
if (
|
||||
parsed.replyRef !== null &&
|
||||
(typeof parsed.replyRef !== "string" || !parsed.replyRef.trim())
|
||||
) {
|
||||
return { ok: false, error: "Invalid replyRef (expected string | null)" };
|
||||
}
|
||||
|
||||
let items: RelayDigestItem[] | undefined;
|
||||
if (parsed.items !== undefined) {
|
||||
if (!Array.isArray(parsed.items)) {
|
||||
return { ok: false, error: "Invalid items array" };
|
||||
}
|
||||
items = [];
|
||||
for (const item of parsed.items) {
|
||||
if (!isPlainObject(item)) {
|
||||
return { ok: false, error: "Digest item must be an object" };
|
||||
}
|
||||
for (const k of Object.keys(item)) {
|
||||
if (!ALLOWED_ITEM_KEYS.has(k)) {
|
||||
return { ok: false, error: `Unknown digest item field: ${k}` };
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof item.qitemId !== "string" ||
|
||||
typeof item.notificationKey !== "string" ||
|
||||
typeof item.summary !== "string" ||
|
||||
(item.body !== undefined && typeof item.body !== "string") ||
|
||||
(item.replyRef !== null && typeof item.replyRef !== "string")
|
||||
) {
|
||||
return { ok: false, error: "Malformed digest item fields" };
|
||||
}
|
||||
items.push({
|
||||
qitemId: item.qitemId,
|
||||
notificationKey: item.notificationKey,
|
||||
summary: item.summary,
|
||||
...(item.body !== undefined ? { body: item.body } : {}),
|
||||
replyRef: item.replyRef,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
envelope: {
|
||||
v: 1,
|
||||
deliveryId: parsed.deliveryId,
|
||||
recipient: { handle: parsed.recipient.handle },
|
||||
intent: parsed.intent,
|
||||
outcome: parsed.outcome,
|
||||
summary: parsed.summary,
|
||||
body: parsed.body,
|
||||
replyRef: parsed.replyRef,
|
||||
...(items !== undefined ? { items } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadCustodyRecordsStrict(custodyFilePath: string): BridgeCustodyRecord[] {
|
||||
if (!existsSync(custodyFilePath)) return [];
|
||||
const raw = readFileSync(custodyFilePath, "utf8");
|
||||
if (!raw.trim()) return [];
|
||||
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
|
||||
return lines.map((line, idx) => {
|
||||
const parsed = JSON.parse(line) as unknown;
|
||||
if (
|
||||
!isPlainObject(parsed) ||
|
||||
typeof parsed.deliveryId !== "string" ||
|
||||
typeof parsed.payloadSha256 !== "string" ||
|
||||
typeof parsed.rawBytesSha256 !== "string" ||
|
||||
typeof parsed.receivedAt !== "string" ||
|
||||
!isPlainObject(parsed.envelope)
|
||||
) {
|
||||
throw new Error(`Invalid custody record at line ${idx + 1}`);
|
||||
}
|
||||
return {
|
||||
deliveryId: parsed.deliveryId,
|
||||
payloadSha256: parsed.payloadSha256,
|
||||
rawBytesSha256: parsed.rawBytesSha256,
|
||||
receivedAt: parsed.receivedAt,
|
||||
envelope: parsed.envelope as unknown as RelayOutboundEnvelope,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function appendCustodyRecordDurable(
|
||||
custodyFilePath: string,
|
||||
record: BridgeCustodyRecord,
|
||||
): void {
|
||||
mkdirSync(dirname(custodyFilePath), { recursive: true });
|
||||
const fd = openSync(custodyFilePath, "a", 0o600);
|
||||
try {
|
||||
writeSync(fd, `${JSON.stringify(record)}\n`, undefined, "utf8");
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function readBody(
|
||||
req: IncomingMessage,
|
||||
): Promise<{ ok: true; text: string } | { ok: false; status: number; error: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let settled = false;
|
||||
req.on("data", (chunk: Buffer | string) => {
|
||||
if (settled) return;
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8");
|
||||
chunks.push(buf);
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve({ ok: true, text: Buffer.concat(chunks).toString("utf8") });
|
||||
}
|
||||
});
|
||||
req.on("error", () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve({ ok: false, status: 400, error: "stream_error" });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
payload: Record<string, unknown>,
|
||||
): void {
|
||||
const body = JSON.stringify(payload);
|
||||
res.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": String(Buffer.byteLength(body, "utf8")),
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
export async function startRecordingRelay(
|
||||
opts: RecordingRelayOptions,
|
||||
): Promise<RecordingRelayHandle> {
|
||||
// Secrets remain in closure memory only — never written to custody log or stats
|
||||
const expectedOutboundToken = opts.outboundToken;
|
||||
const defaultInboundToken = opts.inboundToken;
|
||||
const custodyFilePath = opts.custodyFilePath;
|
||||
const host = "127.0.0.1";
|
||||
|
||||
let loseNextAck = false;
|
||||
let relayPostCount = 0;
|
||||
let acceptedCount = 0;
|
||||
let deduplicatedCount = 0;
|
||||
let conflictCount = 0;
|
||||
let unauthorizedCount = 0;
|
||||
let lostAckCount = 0;
|
||||
let ignoredProbeCount = 0;
|
||||
|
||||
let server: Server | null = null;
|
||||
let boundPort = opts.port ?? 0;
|
||||
|
||||
const handleRequest = async (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> => {
|
||||
const method = (req.method ?? "GET").toUpperCase();
|
||||
const urlPath = (req.url ?? "/").split("?")[0];
|
||||
|
||||
// Ignore non-relay probes (such as host GET / checks) without counting as relay POSTs
|
||||
if (method !== "POST" || urlPath !== "/outbound") {
|
||||
ignoredProbeCount += 1;
|
||||
sendJson(res, 404, { ok: false, error: "not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
relayPostCount += 1;
|
||||
|
||||
const authHeader =
|
||||
typeof req.headers.authorization === "string"
|
||||
? req.headers.authorization
|
||||
: undefined;
|
||||
if (!constantTimeBearerCheck(authHeader, expectedOutboundToken)) {
|
||||
unauthorizedCount += 1;
|
||||
sendJson(res, 401, { ok: false, error: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyResult = await readBody(req);
|
||||
if (!bodyResult.ok) {
|
||||
sendJson(res, bodyResult.status, { ok: false, error: bodyResult.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBytesSha256 = createHash("sha256")
|
||||
.update(bodyResult.text, "utf8")
|
||||
.digest("hex");
|
||||
|
||||
let parsedJson: unknown;
|
||||
try {
|
||||
parsedJson = JSON.parse(bodyResult.text);
|
||||
} catch {
|
||||
sendJson(res, 400, { ok: false, error: "invalid_json" });
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validateOutboundEnvelope(parsedJson);
|
||||
if (!validation.ok) {
|
||||
sendJson(res, 400, { ok: false, error: validation.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const envelope = validation.envelope;
|
||||
const payloadSha256 = computeCanonicalSha256(envelope);
|
||||
|
||||
let existingRecords: BridgeCustodyRecord[];
|
||||
try {
|
||||
existingRecords = loadCustodyRecordsStrict(custodyFilePath);
|
||||
} catch {
|
||||
sendJson(res, 503, { ok: false, error: "custody_store_corrupted" });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = existingRecords.find(
|
||||
(r) => r.deliveryId === envelope.deliveryId,
|
||||
);
|
||||
if (existing) {
|
||||
if (existing.payloadSha256 === payloadSha256) {
|
||||
deduplicatedCount += 1;
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
deliveryId: envelope.deliveryId,
|
||||
deduplicated: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
conflictCount += 1;
|
||||
sendJson(res, 409, {
|
||||
ok: false,
|
||||
error: "delivery_id_payload_conflict",
|
||||
deliveryId: envelope.deliveryId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Durable custody commit + fsync BEFORE returning 2xx (or simulating lost ACK)
|
||||
const record: BridgeCustodyRecord = {
|
||||
deliveryId: envelope.deliveryId,
|
||||
payloadSha256,
|
||||
rawBytesSha256,
|
||||
receivedAt: new Date().toISOString(),
|
||||
envelope,
|
||||
};
|
||||
|
||||
try {
|
||||
appendCustodyRecordDurable(custodyFilePath, record);
|
||||
} catch {
|
||||
sendJson(res, 503, { ok: false, error: "custody_write_failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
acceptedCount += 1;
|
||||
|
||||
if (loseNextAck) {
|
||||
loseNextAck = false;
|
||||
lostAckCount += 1;
|
||||
sendJson(res, 502, {
|
||||
ok: false,
|
||||
error: "simulated_lost_ack_after_durable_commit",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
deliveryId: envelope.deliveryId,
|
||||
deduplicated: false,
|
||||
});
|
||||
};
|
||||
|
||||
const listenServer = async (portToBind: number): Promise<void> => {
|
||||
const srv = createServer((req, res) => {
|
||||
void handleRequest(req, res);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
srv.once("error", reject);
|
||||
srv.listen(portToBind, host, () => {
|
||||
srv.removeListener("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
boundPort = (srv.address() as AddressInfo).port;
|
||||
server = srv;
|
||||
};
|
||||
|
||||
const closeServer = async (): Promise<void> => {
|
||||
const srv = server;
|
||||
server = null;
|
||||
if (!srv) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
srv.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
};
|
||||
|
||||
await listenServer(boundPort);
|
||||
|
||||
return {
|
||||
get baseUrl(): string {
|
||||
return `http://${host}:${boundPort}`;
|
||||
},
|
||||
get outboundUrl(): string {
|
||||
return `http://${host}:${boundPort}/outbound`;
|
||||
},
|
||||
get port(): number {
|
||||
return boundPort;
|
||||
},
|
||||
get custodyFilePath(): string {
|
||||
return custodyFilePath;
|
||||
},
|
||||
setLoseNextAck(lose: boolean): void {
|
||||
loseNextAck = lose;
|
||||
},
|
||||
getCustodyRecords(): BridgeCustodyRecord[] {
|
||||
return loadCustodyRecordsStrict(custodyFilePath);
|
||||
},
|
||||
getSanitizedStats(): BridgeSanitizedStats {
|
||||
let records: BridgeCustodyRecord[] = [];
|
||||
try {
|
||||
records = loadCustodyRecordsStrict(custodyFilePath);
|
||||
} catch {
|
||||
records = [];
|
||||
}
|
||||
const digestInput = records
|
||||
.map((r) => `${r.deliveryId}:${r.payloadSha256}`)
|
||||
.join("\n");
|
||||
const custodyDigestSha256 = createHash("sha256")
|
||||
.update(digestInput, "utf8")
|
||||
.digest("hex");
|
||||
|
||||
return {
|
||||
relayPostCount,
|
||||
acceptedCount,
|
||||
deduplicatedCount,
|
||||
conflictCount,
|
||||
unauthorizedCount,
|
||||
lostAckCount,
|
||||
ignoredProbeCount,
|
||||
custodyCount: records.length,
|
||||
custodyDigestSha256,
|
||||
};
|
||||
},
|
||||
async restart(): Promise<void> {
|
||||
const portToRebind = boundPort;
|
||||
await closeServer();
|
||||
await listenServer(portToRebind);
|
||||
},
|
||||
async stop(): Promise<void> {
|
||||
await closeServer();
|
||||
},
|
||||
async sendInboundReply(
|
||||
daemonBaseUrl: string,
|
||||
payload: RelayInboundReplyInput,
|
||||
replyOpts?: SendInboundReplyOptions,
|
||||
): Promise<SendInboundReplyResult> {
|
||||
const targetUrl = `${daemonBaseUrl.replace(/\/+$/, "")}/api/gateway/relay/inbound`;
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
const tokenToUse =
|
||||
replyOpts && "tokenOverride" in replyOpts
|
||||
? replyOpts.tokenOverride
|
||||
: defaultInboundToken;
|
||||
if (tokenToUse !== null && tokenToUse !== undefined) {
|
||||
headers.authorization = `Bearer ${tokenToUse}`;
|
||||
}
|
||||
|
||||
const wirePayload = JSON.stringify({
|
||||
eventId: payload.eventId,
|
||||
sender: { handle: payload.sender.handle },
|
||||
replyRef: payload.replyRef,
|
||||
text: payload.text,
|
||||
ordering: payload.ordering ?? "after-question",
|
||||
});
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: wirePayload,
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
let parsedBody: Record<string, unknown> = {};
|
||||
try {
|
||||
const json = JSON.parse(rawText) as unknown;
|
||||
if (isPlainObject(json)) parsedBody = json;
|
||||
} catch {
|
||||
parsedBody = { rawText };
|
||||
}
|
||||
|
||||
return { status: response.status, body: parsedBody };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { SeenStore } from "../src/domain/gateway/slack/state-store.js";
|
||||
import { addHumanFragment, writeProjection } from "../src/domain/gateway/human-registry.js";
|
||||
import { setSharedChannel } from "../src/domain/gateway/participants.js";
|
||||
import { DispatchBuffer } from "../src/domain/gateway/dispatch-buffer.js";
|
||||
import { OUTBOUND_OP } from "../src/domain/gateway/slack/outbound-driver.js";
|
||||
|
||||
const homes: string[] = [];
|
||||
const databases: ReturnType<typeof createDb>[] = [];
|
||||
@@ -35,9 +38,9 @@ function fixture(opts: { registerHuman?: boolean } = {}) {
|
||||
const queueRepo = new QueueRepository(db, new EventBus(db));
|
||||
const prepare = vi.spyOn(db, "prepare");
|
||||
const selectionCount = () => prepare.mock.calls.filter(([sql]) => /SELECT qitem_id FROM queue_items/.test(sql)).length;
|
||||
const alert = (id: string) => {
|
||||
const alert = (id: string, destination = "alex@external") => {
|
||||
db.prepare(`INSERT INTO queue_items(qitem_id,ts_created,ts_updated,source_session,destination_session,state,body)
|
||||
VALUES (?, '2026-01-01', '2026-01-01', 'author@rig', 'alex@external', 'pending', 'fixture backlog')`).run(id);
|
||||
VALUES (?, '2026-01-01', '2026-01-01', 'author@rig', ?, 'pending', 'fixture backlog')`).run(id, destination);
|
||||
const result = db.prepare(`INSERT INTO queue_transitions(qitem_id,ts,state,actor_session,owner_notification_level,owner_notification_kind)
|
||||
VALUES (?, '2026-01-01', 'pending', 'author@rig', 'ALERT', 'human-required')`).run(id);
|
||||
return `${id}:${result.lastInsertRowid}`;
|
||||
@@ -168,4 +171,85 @@ describe("human channel lifecycle at the daemon door", () => {
|
||||
expect(loadConfig(f.home).enabled).toBe(false);
|
||||
expect(f.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seeds only Slack-primary humans and Slack shared channels on enable without seeding Relay-primary episodes or claiming Relay buffer entries as Slack posts", async () => {
|
||||
const f = fixture();
|
||||
expect(
|
||||
addHumanFragment(
|
||||
{
|
||||
entityId: "sam",
|
||||
class: "human",
|
||||
displayName: "Sam",
|
||||
address: "sam@external",
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "relay",
|
||||
connectorRef: "wa-main",
|
||||
secretsRef: `file:${join(f.home, "relay.env")}`,
|
||||
role: "primary",
|
||||
handle: "sam_wa",
|
||||
},
|
||||
],
|
||||
prefs: { deliveryClass: "B" },
|
||||
},
|
||||
f.home,
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
setSharedChannel(
|
||||
"ops-room",
|
||||
{
|
||||
displayName: "Ops Room",
|
||||
channel: {
|
||||
connector: "slack",
|
||||
connectorRef: "main",
|
||||
secretsRef: "env:private-pointer",
|
||||
channelId: "COPSROOM",
|
||||
},
|
||||
},
|
||||
{},
|
||||
f.home,
|
||||
).ok,
|
||||
).toBe(true);
|
||||
|
||||
const slackKey = f.alert("slack-backlog", "alex@external");
|
||||
const channelKey = f.alert("channel-backlog", "ops-room@external");
|
||||
const relayKey = f.alert("relay-backlog", "sam@external");
|
||||
|
||||
const buffer = new DispatchBuffer(f.home);
|
||||
buffer.enqueue({
|
||||
kind: "outbound_decision",
|
||||
decisionId: "relay-pending-1",
|
||||
op: OUTBOUND_OP,
|
||||
entityBindingRef: "sam@external",
|
||||
payload: {
|
||||
qitemId: "relay-backlog",
|
||||
notificationKey: relayKey,
|
||||
destinationSession: "sam@external",
|
||||
relayConnectorRef: "wa-main",
|
||||
},
|
||||
});
|
||||
buffer.enqueue({
|
||||
kind: "outbound_decision",
|
||||
decisionId: "slack-pending-1",
|
||||
op: OUTBOUND_OP,
|
||||
entityBindingRef: "alex@external",
|
||||
payload: {
|
||||
qitemId: "earlier-slack",
|
||||
notificationKey: "earlier-slack:1",
|
||||
destinationSession: "alex@external",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await f.post("enable");
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as { ok: boolean; seeded: number; onlineStatus: string };
|
||||
expect(body.seeded).toBe(2);
|
||||
expect(f.seen().sort()).toEqual([channelKey, slackKey].sort());
|
||||
expect(f.seen()).not.toContain(relayKey);
|
||||
// Scoped buffer read view counts only the 1 Slack decision, never claiming the Relay decision
|
||||
expect(body.onlineStatus).toContain("1 earlier undelivered post(s) wait in the replay buffer");
|
||||
// Both durable buffer entries remain intact on disk
|
||||
expect(buffer.pending().map((d) => d.decisionId)).toEqual(["relay-pending-1", "slack-pending-1"]);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
addHumanFragment,
|
||||
projectHumans,
|
||||
resolveSlackHandle,
|
||||
resolveRelayHandle,
|
||||
humansDir,
|
||||
type HumanFragment,
|
||||
} from "../src/domain/gateway/human-registry.js";
|
||||
@@ -31,6 +32,9 @@ function frag(entityId: string, bindings: Record<string, unknown>[]): Record<str
|
||||
const slack = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
kind: "slack", connectorRef: "slack-main", secretsRef: "vault://slack/x", role: "primary", ...over,
|
||||
});
|
||||
const relay = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
kind: "relay", connectorRef: "relay-main", secretsRef: "file:/etc/openrig/relay.env", role: "primary", ...over,
|
||||
});
|
||||
|
||||
describe("A6 v3 handle schema", () => {
|
||||
it("accepts an optional well-formed handle; a handle-less binding is still valid (outbound-only)", () => {
|
||||
@@ -57,6 +61,21 @@ describe("A6 v3 handle schema", () => {
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.error).toMatch(/duplicate .*handle|unique per kind/i);
|
||||
});
|
||||
|
||||
it("relay handle within-fragment uniqueness is scoped to connectorRef", () => {
|
||||
const dupSameRef = validateHumanFragment(frag("mike", [
|
||||
relay({ connectorRef: "relay-a", handle: "alice", role: "primary" }),
|
||||
relay({ connectorRef: "relay-a", handle: "alice", role: "secondary" }),
|
||||
]));
|
||||
expect(dupSameRef.ok).toBe(false);
|
||||
if (!dupSameRef.ok) expect(dupSameRef.error).toMatch(/duplicate .*handle/i);
|
||||
|
||||
const diffRefs = validateHumanFragment(frag("mike", [
|
||||
relay({ connectorRef: "relay-a", handle: "alice", role: "primary" }),
|
||||
relay({ connectorRef: "relay-b", handle: "alice", role: "secondary" }),
|
||||
]));
|
||||
expect(diffRefs.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("A6 v3 handle uniqueness across humans (pin-1 registry-level)", () => {
|
||||
@@ -88,6 +107,40 @@ describe("A6 v3 handle uniqueness across humans (pin-1 registry-level)", () => {
|
||||
const r = addHumanFragment(frag("mike", [slack({ handle: "U1", connectorRef: "slack-main" })]), home, { replace: true });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("relay handle uniqueness across humans is scoped by connectorRef (same ref collides, different ref allowed)", () => {
|
||||
expect(addHumanFragment(frag("mike", [relay({ connectorRef: "relay-a", handle: "op" })]), home).ok).toBe(true);
|
||||
const collision = addHumanFragment(frag("dana", [relay({ connectorRef: "relay-a", handle: "op" })]), home);
|
||||
expect(collision.ok).toBe(false);
|
||||
if (!collision.ok) expect(collision.error).toMatch(/already registered to human "mike"|registration conflict/i);
|
||||
|
||||
const differentRef = addHumanFragment(frag("dana", [relay({ connectorRef: "relay-b", handle: "op" })]), home);
|
||||
expect(differentRef.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("handles relay connectorRef containing colons unambiguously without false collision", () => {
|
||||
// If colon-concatenation were used ("relay:" + connectorRef + ":" + handle),
|
||||
// ("net:east", "worker") and ("net", "east:worker") could collide if handles permitted colons.
|
||||
// Even with handle restricted to HANDLE_PATTERN, distinct connectorRefs like "bridge:1" vs "bridge"
|
||||
// with handles "2" and "1:2" would be ambiguous if handles had colons.
|
||||
// With unambiguous JSON tuple [kind, connectorRef, handle], different connectorRefs with colons never falsely collide.
|
||||
expect(addHumanFragment(frag("mike", [relay({ connectorRef: "region:us-east:primary", handle: "admin" })]), home).ok).toBe(true);
|
||||
const otherRef = addHumanFragment(frag("dana", [relay({ connectorRef: "region:us-east:secondary", handle: "admin" })]), home);
|
||||
expect(otherRef.ok).toBe(true);
|
||||
|
||||
const sameColonRefCollision = addHumanFragment(frag("alex", [relay({ connectorRef: "region:us-east:primary", handle: "admin" })]), home);
|
||||
expect(sameColonRefCollision.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("projectHumans refuses two fragments colliding on the same relay connectorRef+handle", () => {
|
||||
const dir = humansDir(home);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "mike.yaml"), stringifyYaml(frag("mike", [relay({ connectorRef: "relay-a", handle: "op" })])));
|
||||
writeFileSync(join(dir, "dana.yaml"), stringifyYaml(frag("dana", [relay({ connectorRef: "relay-a", handle: "op" })])));
|
||||
const p = projectHumans(home);
|
||||
expect(p.ok).toBe(false);
|
||||
if (!p.ok) expect(p.error).toMatch(/claimed by both|registration conflict/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("A6 v3 resolveSlackHandle (pins 2+3)", () => {
|
||||
@@ -116,3 +169,47 @@ describe("A6 v3 resolveSlackHandle (pins 2+3)", () => {
|
||||
expect(r.kind).toBe("unregistered");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRelayHandle (scoped to connectorRef + handle)", () => {
|
||||
const mike = (validateHumanFragment(frag("mike", [relay({ connectorRef: "relay-a", handle: "founder" })])) as { ok: true; fragment: HumanFragment }).fragment;
|
||||
const dana = (validateHumanFragment(frag("dana", [relay({ connectorRef: "relay-a", handle: "partner" })])) as { ok: true; fragment: HumanFragment }).fragment;
|
||||
const otherRelay = (validateHumanFragment(frag("alex", [relay({ connectorRef: "relay-b", handle: "founder" })])) as { ok: true; fragment: HumanFragment }).fragment;
|
||||
const outboundRelay = (validateHumanFragment(frag("outbound", [relay({ connectorRef: "relay-a" })])) as { ok: true; fragment: HumanFragment }).fragment;
|
||||
const slackOnly = (validateHumanFragment(frag("slackperson", [slack({ connectorRef: "relay-a", handle: "slack-alias" })])) as { ok: true; fragment: HumanFragment }).fragment;
|
||||
const all = [mike, dana, otherRelay, outboundRelay, slackOnly];
|
||||
|
||||
it("resolves a registered relay connectorRef+handle to the exact person on that relay", () => {
|
||||
expect(resolveRelayHandle("relay-a", "founder", all)).toMatchObject({
|
||||
kind: "registered",
|
||||
entityId: "mike",
|
||||
address: "mike@external",
|
||||
connectorRef: "relay-a",
|
||||
handle: "founder",
|
||||
});
|
||||
expect(resolveRelayHandle("relay-a", "partner", all)).toMatchObject({
|
||||
kind: "registered",
|
||||
entityId: "dana",
|
||||
address: "dana@external",
|
||||
connectorRef: "relay-a",
|
||||
handle: "partner",
|
||||
});
|
||||
expect(resolveRelayHandle("relay-b", "founder", all)).toMatchObject({
|
||||
kind: "registered",
|
||||
entityId: "alex",
|
||||
address: "alex@external",
|
||||
connectorRef: "relay-b",
|
||||
handle: "founder",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects cross-profile handle assertions, slack handles, and handle-less relay bindings", () => {
|
||||
const wrongProfile = resolveRelayHandle("relay-b", "partner", all);
|
||||
expect(wrongProfile.kind).toBe("unregistered");
|
||||
if (wrongProfile.kind === "unregistered") {
|
||||
expect(wrongProfile.error).toMatch(/not a registered human/i);
|
||||
expect(wrongProfile.error).toMatch(/NOT landed as a fabricated human seat/i);
|
||||
}
|
||||
expect(resolveRelayHandle("relay-a", "slack-alias", all).kind).toBe("unregistered");
|
||||
expect(resolveRelayHandle("relay-a", "relay-a", all).kind).toBe("unregistered");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Hono } from "hono";
|
||||
import { resolveHumanDeliveryReadiness } from "../src/domain/gateway/human-readiness.js";
|
||||
import { loadRelayProfiles } from "../src/domain/gateway/relay-config.js";
|
||||
import { gatewayRoutes } from "../src/routes/gateway.js";
|
||||
import type { HumanFragment } from "../src/domain/gateway/human-registry.js";
|
||||
import {
|
||||
addHumanFragment,
|
||||
projectionPath,
|
||||
type HumanFragment,
|
||||
} from "../src/domain/gateway/human-registry.js";
|
||||
import type { SlackConnectorConfig } from "../src/domain/gateway/slack/config.js";
|
||||
|
||||
const human: HumanFragment = {
|
||||
@@ -149,3 +157,236 @@ describe("#192 readiness checks every channel the connector posts to", () => {
|
||||
expect(await run({ ok: false, isMember: false, error: "boom" })).toEqual({ asked: ["C1"], state: "indeterminate", reason: "channel membership verification unavailable: boom" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay primary-binding readiness", () => {
|
||||
const unconfiguredSlack: SlackConnectorConfig = {
|
||||
...config,
|
||||
enabled: false,
|
||||
channel: null,
|
||||
};
|
||||
|
||||
it("reports ready for a valid relay profile even when Slack is disabled and unconfigured", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "relay-ready-"));
|
||||
try {
|
||||
const envFile = join(dir, "relay.env");
|
||||
writeFileSync(
|
||||
envFile,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8787/outbound",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-1",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-1",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const relayHuman: HumanFragment = {
|
||||
...human,
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "relay",
|
||||
connectorRef: "bridge-main",
|
||||
secretsRef: `file:${envFile}`,
|
||||
role: "primary",
|
||||
handle: "founder",
|
||||
},
|
||||
],
|
||||
};
|
||||
const verifyScopes = vi.fn();
|
||||
const result = await resolveHumanDeliveryReadiness(
|
||||
{ human: relayHuman, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir },
|
||||
{ verifyScopes, entities: [relayHuman], now: () => new Date("2026-11-06T00:00:00.000Z") },
|
||||
);
|
||||
expect(verifyScopes).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
state: "ready",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
active: true,
|
||||
ready: true,
|
||||
connector: { kind: "relay", ref: "bridge-main" },
|
||||
nextAction: null,
|
||||
checkedAt: "2026-11-06T00:00:00.000Z",
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("out-secret-token-1");
|
||||
expect(JSON.stringify(result)).not.toContain("in-secret-token-1");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps relay profile ready with redacted warning when secret file has group/other bits or uses the same directional token", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "relay-warn-ready-"));
|
||||
try {
|
||||
const envFile = join(dir, "relay-warn.env");
|
||||
writeFileSync(
|
||||
envFile,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8787/outbound",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=shared-bidirectional-tok-99",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=shared-bidirectional-tok-99",
|
||||
].join("\n"),
|
||||
{ mode: 0o644 },
|
||||
);
|
||||
chmodSync(envFile, 0o644);
|
||||
const relayHuman: HumanFragment = {
|
||||
...human,
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "relay",
|
||||
connectorRef: "bridge-warn",
|
||||
secretsRef: `file:${envFile}`,
|
||||
role: "primary",
|
||||
handle: "founder",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await resolveHumanDeliveryReadiness(
|
||||
{ human: relayHuman, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir },
|
||||
{ entities: [relayHuman], now: () => new Date("2026-11-07T00:00:00.000Z") },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
state: "ready",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
active: true,
|
||||
ready: true,
|
||||
connector: { kind: "relay", ref: "bridge-warn" },
|
||||
nextAction: null,
|
||||
});
|
||||
expect(result.reason).toMatch(/warning/i);
|
||||
expect(result.reason).toMatch(/group\/other/i);
|
||||
expect(result.reason).toMatch(/OPENRIG_RELAY_OUTBOUND_TOKEN/);
|
||||
expect(JSON.stringify(result)).not.toContain("shared-bidirectional-tok-99");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reports not-ready with sanitized reason when relay credentials are invalid or ambiguous", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "relay-unready-"));
|
||||
try {
|
||||
const envFile1 = join(dir, "relay1.env");
|
||||
const envFile2 = join(dir, "relay2.env");
|
||||
writeFileSync(
|
||||
envFile1,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8787/out1",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=out-1",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=shared-inbound-secret",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
writeFileSync(
|
||||
envFile2,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8788/out2",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=out-2",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=shared-inbound-secret",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const h1: HumanFragment = {
|
||||
...human,
|
||||
entityId: "founder",
|
||||
address: "founder@external",
|
||||
connectorBindings: [{ kind: "relay", connectorRef: "r1", secretsRef: `file:${envFile1}`, role: "primary", handle: "f1" }],
|
||||
};
|
||||
const h2: HumanFragment = {
|
||||
...human,
|
||||
entityId: "partner",
|
||||
address: "partner@external",
|
||||
connectorBindings: [{ kind: "relay", connectorRef: "r2", secretsRef: `file:${envFile2}`, role: "primary", handle: "p1" }],
|
||||
};
|
||||
const relayProfiles = loadRelayProfiles(dir, { entities: [h1, h2] });
|
||||
const r1Readiness = await resolveHumanDeliveryReadiness({
|
||||
human: h1,
|
||||
config: unconfiguredSlack,
|
||||
gatewayState: "active",
|
||||
botToken: null,
|
||||
relayProfiles,
|
||||
});
|
||||
expect(r1Readiness).toMatchObject({
|
||||
state: "not-ready",
|
||||
configured: false,
|
||||
enabled: true,
|
||||
active: false,
|
||||
ready: false,
|
||||
connector: { kind: "relay", ref: "r1" },
|
||||
});
|
||||
expect(r1Readiness.reason).toMatch(/ambiguous.*OPENRIG_RELAY_INBOUND_TOKEN/i);
|
||||
expect(r1Readiness.reason).not.toContain("shared-inbound-secret");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("never falls back to single input.human when home registry is corrupt or human is unregistered, and preserves cross-profile ambiguity from home", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "relay-failclosed-"));
|
||||
try {
|
||||
const envFile1 = join(dir, "relay1.env");
|
||||
const envFile2 = join(dir, "relay2.env");
|
||||
writeFileSync(
|
||||
envFile1,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8787/out1",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=out-1",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=shared-inbound-secret",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
writeFileSync(
|
||||
envFile2,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8788/out2",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=out-2",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=shared-inbound-secret",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const h1: HumanFragment = {
|
||||
...human,
|
||||
entityId: "founder",
|
||||
address: "founder@external",
|
||||
connectorBindings: [{ kind: "relay", connectorRef: "r1", secretsRef: `file:${envFile1}`, role: "primary", handle: "f1" }],
|
||||
};
|
||||
const h2: HumanFragment = {
|
||||
...human,
|
||||
entityId: "partner",
|
||||
address: "partner@external",
|
||||
connectorBindings: [{ kind: "relay", connectorRef: "r2", secretsRef: `file:${envFile2}`, role: "primary", handle: "p1" }],
|
||||
};
|
||||
|
||||
// 1. Unregistered human in empty home must not fall back to single-human evaluation
|
||||
const verifyScopes = vi.fn();
|
||||
const unregisteredReady = await resolveHumanDeliveryReadiness(
|
||||
{ human: h1, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir },
|
||||
{ verifyScopes },
|
||||
);
|
||||
expect(unregisteredReady.ready).toBe(false);
|
||||
expect(unregisteredReady.state).toBe("not-ready");
|
||||
expect(verifyScopes).not.toHaveBeenCalled();
|
||||
|
||||
// 2. Full registry on disk with cross-profile ambiguous inbound token remains not-ready
|
||||
expect(addHumanFragment(h1 as unknown as Record<string, unknown>, dir).ok).toBe(true);
|
||||
expect(addHumanFragment(h2 as unknown as Record<string, unknown>, dir).ok).toBe(true);
|
||||
const fromHomeAmbiguous = await resolveHumanDeliveryReadiness(
|
||||
{ human: h1, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir },
|
||||
{ verifyScopes },
|
||||
);
|
||||
expect(fromHomeAmbiguous.ready).toBe(false);
|
||||
expect(fromHomeAmbiguous.state).toBe("not-ready");
|
||||
expect(fromHomeAmbiguous.reason).toMatch(/ambiguous.*OPENRIG_RELAY_INBOUND_TOKEN/i);
|
||||
|
||||
// 3. Corrupt registry projection fails closed (never ready)
|
||||
writeFileSync(projectionPath(dir), "corrupted: hand-edited\n");
|
||||
const corruptReady = await resolveHumanDeliveryReadiness(
|
||||
{ human: h1, config: unconfiguredSlack, gatewayState: "active", botToken: null, home: dir },
|
||||
{ verifyScopes },
|
||||
);
|
||||
expect(corruptReady.ready).toBe(false);
|
||||
expect(corruptReady.state).toBe("not-ready");
|
||||
expect(verifyScopes).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { stringify as stringifyYaml } from "yaml";
|
||||
import {
|
||||
validateHumanFragment,
|
||||
addHumanFragment,
|
||||
setHumanField,
|
||||
parseBindingSpec,
|
||||
projectHumans,
|
||||
writeProjection,
|
||||
loadHumanRegistry,
|
||||
@@ -50,6 +52,55 @@ describe("A3 human-fragment validation (add-time == load-time)", () => {
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a relay connector binding and preserves kind=relay", () => {
|
||||
const r = validateHumanFragment({
|
||||
...fragment(),
|
||||
connectorBindings: [
|
||||
{ kind: "relay", connectorRef: "bridge-main", secretsRef: "file:/etc/openrig/relay.env", role: "primary", handle: "mike" },
|
||||
],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.fragment.connectorBindings[0]).toEqual({
|
||||
kind: "relay",
|
||||
connectorRef: "bridge-main",
|
||||
secretsRef: "file:/etc/openrig/relay.env",
|
||||
role: "primary",
|
||||
handle: "mike",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("parseBindingSpec supports relay file:<absolute-env-file> syntax and rejects relative file: paths", () => {
|
||||
const ok = parseBindingSpec("relay:bridge-main:file:/etc/openrig/relay.env:primary:handle=mike");
|
||||
expect(ok).toEqual({
|
||||
ok: true,
|
||||
binding: {
|
||||
kind: "relay",
|
||||
connectorRef: "bridge-main",
|
||||
secretsRef: "file:/etc/openrig/relay.env",
|
||||
role: "primary",
|
||||
handle: "mike",
|
||||
},
|
||||
});
|
||||
expect(parseBindingSpec("relay:bridge-main:file:relative/relay.env:primary:handle=mike").ok).toBe(false);
|
||||
expect(parseBindingSpec("relay:bridge-main:file::primary:handle=mike").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("parseBindingSpec preserves Slack relative-file backward-compatibility", () => {
|
||||
const parsed = parseBindingSpec("slack:slack-main:file:relative/secrets.env:primary:handle=mike");
|
||||
expect(parsed).toEqual({
|
||||
ok: true,
|
||||
binding: {
|
||||
kind: "slack",
|
||||
connectorRef: "slack-main",
|
||||
secretsRef: "file:relative/secrets.env",
|
||||
role: "primary",
|
||||
handle: "mike",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a person with no connector binding, as an empty list or left out, and rejects a value that isn't a list", () => {
|
||||
expect(validateHumanFragment({ ...fragment(), connectorBindings: [] })).toMatchObject({ ok: true, fragment: { connectorBindings: [] } });
|
||||
const { connectorBindings: _none, ...withoutBindings } = fragment();
|
||||
@@ -238,6 +289,35 @@ describe("A3 proof-5 — fragments -> generated projection; re-project; hand-edi
|
||||
const loaded = loadHumanRegistry(home);
|
||||
expect(loaded.ok && loaded.entities[0]!.displayName).toBe("Founder Renamed");
|
||||
});
|
||||
|
||||
it("setHumanField updates binding.<n> to a relay file binding and enforces connectorRef+handle uniqueness", () => {
|
||||
expect(addHumanFragment(fragment({ entityId: "mike", address: "mike@external" }), home).ok).toBe(true);
|
||||
expect(addHumanFragment(fragment({ entityId: "founder", displayName: "Founder", address: "founder@external" }), home).ok).toBe(true);
|
||||
|
||||
const setMike = setHumanField(
|
||||
"mike",
|
||||
"binding.0",
|
||||
"relay:bridge-1:file:/etc/openrig/relay1.env:primary:handle=shared-alias",
|
||||
home,
|
||||
);
|
||||
expect(setMike.ok).toBe(true);
|
||||
|
||||
const conflictSameRelay = setHumanField(
|
||||
"founder",
|
||||
"binding.0",
|
||||
"relay:bridge-1:file:/etc/openrig/relay1.env:primary:handle=shared-alias",
|
||||
home,
|
||||
);
|
||||
expect(conflictSameRelay.ok).toBe(false);
|
||||
|
||||
const okOtherRelay = setHumanField(
|
||||
"founder",
|
||||
"binding.0",
|
||||
"relay:bridge-2:file:/etc/openrig/relay2.env:primary:handle=shared-alias",
|
||||
home,
|
||||
);
|
||||
expect(okOtherRelay.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("A3 pt2 — hardening (r1 pooled notes)", () => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -84,4 +84,107 @@ describe("S10 gateway subsystem activation (RED at tip: no boot-time subsystem c
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("activates relay-only outbound polling post-bind with no Slack credentials while keeping Slack status unconfigured", async () => {
|
||||
const { buildSlackGatewayWire } = await import("../src/domain/gateway/slack/slack-subsystem.js");
|
||||
const { createDb } = await import("../src/db/connection.js");
|
||||
const { migrate } = await import("../src/db/migrate.js");
|
||||
const { ALL_MIGRATIONS } = await import("../src/db/all-migrations.js");
|
||||
const { EventBus } = await import("../src/domain/event-bus.js");
|
||||
const { QueueRepository } = await import("../src/domain/queue-repository.js");
|
||||
const { resolveSlackHandle } = await import("../src/domain/gateway/human-registry.js");
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
|
||||
const home = mkdtempSync(join(tmpdir(), "s10-relay-only-"));
|
||||
const db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const secretsFile = join(home, "relay.env");
|
||||
writeFileSync(
|
||||
secretsFile,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:44100/outbound",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const registry = {
|
||||
ok: true as const,
|
||||
entities: [
|
||||
{
|
||||
entityId: "sam",
|
||||
class: "human" as const,
|
||||
displayName: "Sam",
|
||||
address: "sam@external",
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "relay" as const,
|
||||
connectorRef: "wa-main",
|
||||
secretsRef: `file:${secretsFile}`,
|
||||
role: "primary" as const,
|
||||
handle: "sam_wa",
|
||||
},
|
||||
],
|
||||
prefs: { deliveryClass: "A" as const, availability: "available" as const },
|
||||
},
|
||||
],
|
||||
};
|
||||
const repo = new QueueRepository(db, new EventBus(db), {
|
||||
validateRig: () => true,
|
||||
loadHumanRegistry: () => registry,
|
||||
});
|
||||
const row = await repo.create({
|
||||
sourceSession: "lead@rig",
|
||||
destinationSession: "orch@rig",
|
||||
body: "Please confirm.",
|
||||
nudge: false,
|
||||
});
|
||||
repo.update({
|
||||
qitemId: row.qitemId,
|
||||
actorSession: "orch@rig",
|
||||
state: "blocked",
|
||||
blockedOn: "sam@kernel",
|
||||
summary: "Approve deploy?",
|
||||
evidenceRef: "/proof/deploy.md",
|
||||
transitionNote: "parked",
|
||||
});
|
||||
|
||||
const deliveredEnvelopes: Array<Record<string, unknown>> = [];
|
||||
const wire = buildSlackGatewayWire({
|
||||
home,
|
||||
queueRepo: repo,
|
||||
registry: { loadHumanRegistry: () => registry, resolveSlackHandle },
|
||||
outboundIntervalMs: 15,
|
||||
fetchImpl: async (_url, init) => {
|
||||
deliveredEnvelopes.push(JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>);
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Composition alone must never start network polling or HTTP calls
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(deliveredEnvelopes).toHaveLength(0);
|
||||
expect(wire.status()).toMatchObject({
|
||||
outboundReady: false,
|
||||
inboundReady: false,
|
||||
});
|
||||
|
||||
// Post-bind startServices() starts outbound polling and delivers to the relay bridge
|
||||
wire.startServices?.();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
expect(deliveredEnvelopes).toHaveLength(1);
|
||||
expect(deliveredEnvelopes[0]).toMatchObject({
|
||||
v: 1,
|
||||
recipient: { handle: "sam_wa" },
|
||||
intent: "decision",
|
||||
});
|
||||
const episode = repo.currentHumanRequiredEpisode(row.qitemId)!;
|
||||
expect(repo.hasRelayOwnerNotificationReceipt(row.qitemId, episode.key)).toBe(true);
|
||||
} finally {
|
||||
wire.stop();
|
||||
db.close();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -112,4 +112,181 @@ describe("S10 in-process gateway wire — durability contract receipts", () => {
|
||||
expect(subsystem.status().state).toBe("active");
|
||||
expect(subsystem.dispatch("post_message", "mike#slack", {}).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("prepare(decision) runs synchronously BEFORE durable enqueue and replay sends the exact frozen payload without re-running prepare", async () => {
|
||||
let prepareCalls = 0;
|
||||
let bufferedDuringPrepare = -1;
|
||||
let releaseFirst: (o: SubsystemDeliveryOutcome) => void = () => {};
|
||||
const firstGate = new Promise<SubsystemDeliveryOutcome>((r) => { releaseFirst = r; });
|
||||
|
||||
const w1 = buildInProcessWire({
|
||||
home,
|
||||
ops: ["post_message"],
|
||||
prepare: (decision) => {
|
||||
prepareCalls++;
|
||||
bufferedDuringPrepare = new DispatchBuffer(home).pending().length;
|
||||
const p = (decision.payload ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
...decision,
|
||||
payload: {
|
||||
...p,
|
||||
relayEnvelope: {
|
||||
v: 1,
|
||||
deliveryId: decision.decisionId,
|
||||
replyRef: `frozen-ref-${prepareCalls}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
deliver: () => firstGate,
|
||||
});
|
||||
|
||||
const r1 = w1.dispatcher.dispatch("post_message", "sam@external", { qitemId: "q-freeze-1" }, { decisionId: "d-freeze-1" });
|
||||
expect(r1.ok).toBe(true);
|
||||
expect(prepareCalls).toBe(1);
|
||||
expect(bufferedDuringPrepare, "prepare must run BEFORE durable buffer.enqueue").toBe(0);
|
||||
|
||||
// Durable buffer immediately holds the frozen payload before delivery resolves.
|
||||
const pendingBefore = new DispatchBuffer(home).pending();
|
||||
expect(pendingBefore).toHaveLength(1);
|
||||
expect((pendingBefore[0]!.payload as { relayEnvelope?: { replyRef?: string } }).relayEnvelope?.replyRef).toBe("frozen-ref-1");
|
||||
|
||||
// Duplicate dispatch of same decisionId while pending does not re-run prepare or overwrite frozen payload.
|
||||
const dup = w1.dispatcher.dispatch("post_message", "sam@external", { qitemId: "q-freeze-1", mutated: true }, { decisionId: "d-freeze-1" });
|
||||
expect(dup.ok).toBe(true);
|
||||
expect(prepareCalls).toBe(1);
|
||||
|
||||
// Fail first delivery so it stays in the buffer for replay.
|
||||
releaseFirst({ ok: false, class: "transport", detail: "bridge down" });
|
||||
await tick();
|
||||
expect(new DispatchBuffer(home).pending()).toHaveLength(1);
|
||||
|
||||
// Run 2: replayPending sends the exact frozen payload from the buffer without calling prepare again.
|
||||
const replayed: OutboundDecision[] = [];
|
||||
const w2 = buildInProcessWire({
|
||||
home,
|
||||
ops: ["post_message"],
|
||||
prepare: (decision) => {
|
||||
prepareCalls++;
|
||||
return {
|
||||
...decision,
|
||||
payload: { ...(decision.payload as object), relayEnvelope: { v: 1, deliveryId: decision.decisionId, replyRef: "recomputed-ref" } },
|
||||
};
|
||||
},
|
||||
deliver: async (d) => {
|
||||
replayed.push(d);
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
w2.startServices?.();
|
||||
await tick();
|
||||
|
||||
expect(prepareCalls, "replayPending must not re-run prepare").toBe(1);
|
||||
expect(replayed).toHaveLength(1);
|
||||
expect(replayed[0]!.decisionId).toBe("d-freeze-1");
|
||||
expect((replayed[0]!.payload as { relayEnvelope?: { replyRef?: string } }).relayEnvelope?.replyRef).toBe("frozen-ref-1");
|
||||
expect(new DispatchBuffer(home).pending()).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("dispatcher prepare hook rails", () => {
|
||||
it("preserves caller's advertised op, entityBindingRef, kind, and decisionId when prepare mutates argument in place and returns void", async () => {
|
||||
let dispatchedDecision: OutboundDecision | undefined;
|
||||
const wire = buildInProcessWire({
|
||||
home,
|
||||
ops: ["post_message"],
|
||||
prepare: (decision) => {
|
||||
// Attempt malicious/accidental mutation of rails in place and return void
|
||||
(decision as any).op = "unadvertised_bypassed_op";
|
||||
(decision as any).entityBindingRef = "hacked#channel";
|
||||
(decision as any).kind = "forged_kind";
|
||||
(decision as any).decisionId = "hacked_decision_id";
|
||||
// Valid payload preparation:
|
||||
const p = (decision.payload ?? {}) as Record<string, unknown>;
|
||||
p.preparedPayloadField = "frozen_value";
|
||||
},
|
||||
deliver: async (d) => {
|
||||
dispatchedDecision = d;
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
const res = wire.dispatcher.dispatch("post_message", "alice@external", { orig: 1 }, { decisionId: "d-fixed-1" });
|
||||
expect(res.ok).toBe(true);
|
||||
expect((res as { decisionId: string }).decisionId).toBe("d-fixed-1");
|
||||
await tick();
|
||||
|
||||
expect(dispatchedDecision).toBeDefined();
|
||||
expect(dispatchedDecision!.op).toBe("post_message");
|
||||
expect(dispatchedDecision!.entityBindingRef).toBe("alice@external");
|
||||
expect(dispatchedDecision!.kind).toBe("outbound_decision");
|
||||
expect(dispatchedDecision!.decisionId).toBe("d-fixed-1");
|
||||
expect((dispatchedDecision!.payload as any).preparedPayloadField).toBe("frozen_value");
|
||||
expect((dispatchedDecision!.payload as any).orig).toBe(1);
|
||||
|
||||
// Verify what was durably persisted in the buffer matches the rails
|
||||
const bufferEntries = new DispatchBuffer(home).pending();
|
||||
expect(bufferEntries).toHaveLength(0); // Drained because delivery returned ok: true
|
||||
});
|
||||
|
||||
it("preserves caller's advertised op, entityBindingRef, kind, and decisionId when prepare returns a mutated decision object", async () => {
|
||||
let dispatchedDecision: OutboundDecision | undefined;
|
||||
const wire = buildInProcessWire({
|
||||
home,
|
||||
ops: ["post_message"],
|
||||
prepare: (decision) => {
|
||||
// Return new object attempting to alter op, entityBindingRef, kind, decisionId
|
||||
return {
|
||||
kind: "other_kind" as any,
|
||||
decisionId: "hijacked_id",
|
||||
op: "malicious_op",
|
||||
entityBindingRef: "attacker#ref",
|
||||
payload: {
|
||||
...(decision.payload as object),
|
||||
frozenPayload: "persisted",
|
||||
},
|
||||
};
|
||||
},
|
||||
deliver: async (d) => {
|
||||
dispatchedDecision = d;
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
const res = wire.dispatcher.dispatch("post_message", "bob@external", { count: 42 }, { decisionId: "d-fixed-2" });
|
||||
expect(res.ok).toBe(true);
|
||||
expect((res as { decisionId: string }).decisionId).toBe("d-fixed-2");
|
||||
await tick();
|
||||
|
||||
expect(dispatchedDecision).toBeDefined();
|
||||
expect(dispatchedDecision!.op).toBe("post_message");
|
||||
expect(dispatchedDecision!.entityBindingRef).toBe("bob@external");
|
||||
expect(dispatchedDecision!.kind).toBe("outbound_decision");
|
||||
expect(dispatchedDecision!.decisionId).toBe("d-fixed-2");
|
||||
expect((dispatchedDecision!.payload as any).frozenPayload).toBe("persisted");
|
||||
expect((dispatchedDecision!.payload as any).count).toBe(42);
|
||||
});
|
||||
|
||||
it("refuses unadvertised original op before prepare or send is ever executed", async () => {
|
||||
let prepareCalled = false;
|
||||
let sendCalled = false;
|
||||
const wire = buildInProcessWire({
|
||||
home,
|
||||
ops: ["post_message"],
|
||||
prepare: () => {
|
||||
prepareCalled = true;
|
||||
},
|
||||
deliver: async () => {
|
||||
sendCalled = true;
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
const res = wire.dispatcher.dispatch("unsupported_op", "alice@external", {});
|
||||
expect(res.ok).toBe(false);
|
||||
expect((res as { error: string }).error).toContain("not advertised");
|
||||
expect(prepareCalled).toBe(false);
|
||||
expect(sendCalled).toBe(false);
|
||||
expect(new DispatchBuffer(home).pending()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -214,4 +214,89 @@ describe("queue list: delivery ledgers", () => {
|
||||
expect(awaiting.length).toBeGreaterThan(0);
|
||||
expect(listed.map((item) => item.waiting)).toEqual(listed.map((item) => repo.waitingView(item.qitemId)));
|
||||
});
|
||||
|
||||
it("distinguishes relay-accepted asks and updates ('accepted by the bridge') from Slack-posted asks and updates ('posted to the human') in waitingView and getById", async () => {
|
||||
const relayAsk = await repo.create({
|
||||
sourceSession: "[email protected]",
|
||||
destinationSession: "human-founder@external",
|
||||
body: "Approve bridge migration?",
|
||||
summary: "Approve bridge migration?",
|
||||
humanIntent: "decision",
|
||||
nudge: false,
|
||||
});
|
||||
const relayAskKey = latestKey(relayAsk.qitemId);
|
||||
repo.recordRelayOwnerNotificationAccepted({
|
||||
qitemId: relayAsk.qitemId,
|
||||
notificationKey: relayAskKey,
|
||||
deliveryId: "dec-relay-ask-1",
|
||||
connectorRef: "wa-primary",
|
||||
replyRef: "rref-ask-1",
|
||||
revision: repo.decisionRevision(relayAsk),
|
||||
episodeKey: relayAskKey,
|
||||
level: "ALERT",
|
||||
kind: "human-required",
|
||||
});
|
||||
|
||||
const relayUpdate = await repo.create({
|
||||
sourceSession: "[email protected]",
|
||||
destinationSession: "human-founder@external",
|
||||
body: "Bridge migration complete.",
|
||||
summary: "Bridge migration complete",
|
||||
humanIntent: "update",
|
||||
nudge: false,
|
||||
});
|
||||
const relayUpdateKey = latestKey(relayUpdate.qitemId);
|
||||
repo.recordRelayOwnerNotificationAccepted({
|
||||
qitemId: relayUpdate.qitemId,
|
||||
notificationKey: relayUpdateKey,
|
||||
deliveryId: "dec-relay-update-1",
|
||||
connectorRef: "wa-primary",
|
||||
replyRef: null,
|
||||
episodeKey: relayUpdateKey,
|
||||
level: "NOTICE",
|
||||
kind: "human-update",
|
||||
closeUpdate: false,
|
||||
});
|
||||
|
||||
const slackAsk = await repo.create({
|
||||
sourceSession: "[email protected]",
|
||||
destinationSession: "human-founder@external",
|
||||
body: "Approve Slack migration?",
|
||||
summary: "Approve Slack migration?",
|
||||
humanIntent: "decision",
|
||||
nudge: false,
|
||||
});
|
||||
receipt(slackAsk.qitemId, "posted");
|
||||
|
||||
const slackUpdate = await repo.create({
|
||||
sourceSession: "[email protected]",
|
||||
destinationSession: "human-founder@external",
|
||||
body: "Slack migration complete.",
|
||||
summary: "Slack migration complete",
|
||||
humanIntent: "update",
|
||||
nudge: false,
|
||||
});
|
||||
receipt(slackUpdate.qitemId, "posted");
|
||||
|
||||
const expectedRelayAsk = "none (accepted by the bridge; awaiting their decision)";
|
||||
const expectedRelayUpdate = "none (accepted by the bridge; informational correspondence requires no claim)";
|
||||
const expectedSlackAsk = "none (posted to the human; awaiting their decision)";
|
||||
const expectedSlackUpdate = "none (posted to the human; informational correspondence requires no claim)";
|
||||
|
||||
expect(repo.waitingView(relayAsk.qitemId)?.nextBackstop.mechanism).toBe(expectedRelayAsk);
|
||||
expect(repo.getById(relayAsk.qitemId)?.waiting?.nextBackstop.mechanism).toBe(expectedRelayAsk);
|
||||
expect(repo.getById(relayAsk.qitemId)?.deliveryOutcome).toBe("accepted");
|
||||
|
||||
expect(repo.waitingView(relayUpdate.qitemId)?.nextBackstop.mechanism).toBe(expectedRelayUpdate);
|
||||
expect(repo.getById(relayUpdate.qitemId)?.waiting?.nextBackstop.mechanism).toBe(expectedRelayUpdate);
|
||||
expect(repo.getById(relayUpdate.qitemId)?.deliveryOutcome).toBe("accepted");
|
||||
|
||||
expect(repo.waitingView(slackAsk.qitemId)?.nextBackstop.mechanism).toBe(expectedSlackAsk);
|
||||
expect(repo.getById(slackAsk.qitemId)?.waiting?.nextBackstop.mechanism).toBe(expectedSlackAsk);
|
||||
expect(repo.getById(slackAsk.qitemId)?.deliveryOutcome).toBe("posted");
|
||||
|
||||
expect(repo.waitingView(slackUpdate.qitemId)?.nextBackstop.mechanism).toBe(expectedSlackUpdate);
|
||||
expect(repo.getById(slackUpdate.qitemId)?.waiting?.nextBackstop.mechanism).toBe(expectedSlackUpdate);
|
||||
expect(repo.getById(slackUpdate.qitemId)?.deliveryOutcome).toBe("posted");
|
||||
});
|
||||
});
|
||||
@@ -275,6 +275,25 @@ describe("S01 wake-or-escalate — retry ladder, named rungs, derived suspension
|
||||
expect(calls.some((c) => c.target === "worker@r")).toBe(false);
|
||||
});
|
||||
|
||||
it("DELIVERY MACHINERY IS NOT PICKUP: a relay-owner-notification-transport-failed note on an unconfirmed or held baton does not count as pickup evidence", async () => {
|
||||
const mod = await ladderMod();
|
||||
for (const [idx, result] of ["gateway-owned: virtual destination", "retained:typing_guard"].entries()) {
|
||||
const baton = await mkBaton(`worker-${idx}@r`);
|
||||
setNudgeResult(baton.qitemId, result, 45);
|
||||
ageCreated(baton.qitemId, 45);
|
||||
repo.transitionLog.append({
|
||||
qitemId: baton.qitemId,
|
||||
state: "pending",
|
||||
actorSession: "daemon@kernel",
|
||||
transitionNote: `relay-owner-notification-transport-failed notification_key=${baton.qitemId}:1 connector_ref=wa-main class=http-503 error=http 503`,
|
||||
});
|
||||
expect(mod.readWakeLadderBackstop(db, baton.qitemId)).not.toBeNull();
|
||||
await tick();
|
||||
expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX)).toHaveLength(1);
|
||||
expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX)[0]).toMatch(/orchestrator/);
|
||||
}
|
||||
});
|
||||
|
||||
// ── G4: F2 — SUSPENSION DERIVES FROM THE SWAP STATE ──────────────────────────
|
||||
|
||||
it("SWAP SUSPENDS THE LADDER: a destination inside the post-swap grace gets ZERO wake attempts and one recorded suspend marker (the stale-wake-burst specimen)", async () => {
|
||||
@@ -507,6 +526,62 @@ describe("S01 wake-or-escalate — retry ladder, named rungs, derived suspension
|
||||
expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX).some((r) => /dispatched-to-engine/.test(r))).toBe(true);
|
||||
});
|
||||
|
||||
it("OPERATOR RUNG RELAY RECEIPT: pending dispatch resolves on matching post-marker relay-owner-notification-accepted", async () => {
|
||||
const baton = await exhaustToSelfSkip();
|
||||
const notificationKey = "notif-key-123";
|
||||
let dispatchCount = 0;
|
||||
const pendingEngine = {
|
||||
dispatchEscalation: async (row: QueueItem) => {
|
||||
dispatchCount++;
|
||||
return { decision: "post", resolved: false, dispatched: true, notificationKey };
|
||||
},
|
||||
};
|
||||
|
||||
// Pre-marker receipt in history (simulates receipt from a previous episode before dispatch):
|
||||
// Must NOT resolve the upcoming dispatch.
|
||||
repo.transitionLog.append({
|
||||
qitemId: baton.qitemId,
|
||||
state: "pending",
|
||||
actorSession: "daemon@kernel",
|
||||
transitionNote: `relay-owner-notification-accepted notification_key=${notificationKey} delivery_id=d-pre`,
|
||||
});
|
||||
|
||||
// 1. Initial tick with pending deliveryEngine outcome (resolved: false).
|
||||
await tick({ resolveOrchestrator: () => null, deliveryEngine: pendingEngine });
|
||||
expect(dispatchCount).toBe(1);
|
||||
expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX).some((r) => /dispatched-to-engine.*notification_key=notif-key-123/.test(r))).toBe(true);
|
||||
// Pre-marker matching receipt did not resolve it; ladder not exhausted yet, underlying baton remains pending.
|
||||
expect(markersOf(baton.qitemId, LADDER_EXHAUSTED_PREFIX)).toHaveLength(0);
|
||||
expect(repo.getById(baton.qitemId)!.state).toBe("pending");
|
||||
|
||||
// 2. Wrong key post-marker relay receipt -> no resolution.
|
||||
repo.transitionLog.append({
|
||||
qitemId: baton.qitemId,
|
||||
state: "pending",
|
||||
actorSession: "daemon@kernel",
|
||||
transitionNote: "relay-owner-notification-accepted notification_key=wrong-key delivery_id=d-1",
|
||||
});
|
||||
ageMarkers(baton.qitemId, 30);
|
||||
await tick({ resolveOrchestrator: () => null, deliveryEngine: pendingEngine });
|
||||
expect(dispatchCount).toBe(1); // No second dispatch/resume!
|
||||
expect(markersOf(baton.qitemId, LADDER_EXHAUSTED_PREFIX)).toHaveLength(0);
|
||||
|
||||
// 3. Matching post-marker relay receipt -> exhausts operator rung without second dispatch and without closing pending work.
|
||||
repo.transitionLog.append({
|
||||
qitemId: baton.qitemId,
|
||||
state: "pending",
|
||||
actorSession: "daemon@kernel",
|
||||
transitionNote: `relay-owner-notification-accepted notification_key=${notificationKey} delivery_id=d-2`,
|
||||
});
|
||||
ageMarkers(baton.qitemId, 30);
|
||||
await tick({ resolveOrchestrator: () => null, deliveryEngine: pendingEngine });
|
||||
expect(dispatchCount).toBe(1); // Exactly-once dispatch preserved
|
||||
expect(markersOf(baton.qitemId, LADDER_EXHAUSTED_PREFIX)).toHaveLength(1);
|
||||
expect(markersOf(baton.qitemId, LADDER_EXHAUSTED_PREFIX)[0]).toContain("engine outcome resolved");
|
||||
// Underlying blocked/pending work is NOT closed/cancelled
|
||||
expect(repo.getById(baton.qitemId)!.state).toBe("pending");
|
||||
});
|
||||
|
||||
it("HUMAN RUNG EXPLICIT-ONLY: an agent-to-agent baton ends at the escalation row and nothing is posted to a person", async () => {
|
||||
const baton = await exhaustToSelfSkip();
|
||||
const posted: string[] = [];
|
||||
|
||||
@@ -38,17 +38,32 @@ describe("custody of asks held for quiet hours", () => {
|
||||
let failPosts = false;
|
||||
let rejectPosts = false; // the connector answers, but refuses the post
|
||||
const wires: Array<ReturnType<typeof buildSlackGatewayWire>> = [];
|
||||
let useRelayBinding = false;
|
||||
let relaySecretsPath = "";
|
||||
const registry = () => ({ ok: true as const, entities: [{
|
||||
entityId: "person", class: "human" as const, displayName: "Person", address: "person@external", prefs,
|
||||
connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UPERSON" }],
|
||||
connectorBindings: useRelayBinding
|
||||
? [{ kind: "relay" as const, connectorRef: "relay-primary", secretsRef: `file:${relaySecretsPath}`, role: "primary" as const, handle: "person_relay" }]
|
||||
: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UPERSON" }],
|
||||
}] });
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
prefs = { deliveryClass: "B", availability: "available" };
|
||||
useRelayBinding = false;
|
||||
repo = new QueueRepository(db, new EventBus(db), { validateRig: () => true, loadHumanRegistry: registry as never });
|
||||
home = mkdtempSync(join(tmpdir(), "quiet-custody-"));
|
||||
relaySecretsPath = join(home, "relay.env");
|
||||
writeFileSync(
|
||||
relaySecretsPath,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:44199/outbound",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
quiet = new HumanQuietStore(db);
|
||||
posts.length = 0;
|
||||
failPosts = false;
|
||||
@@ -69,6 +84,12 @@ describe("custody of asks held for quiet hours", () => {
|
||||
if (rejectPosts) return new Response(JSON.stringify({ ok: false, error: "channel_not_found" }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
return new Response(JSON.stringify({ ok: true, ts: `1724.${100 + posts.length}` }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
if (String(url).includes("127.0.0.1:44199/outbound")) {
|
||||
if (failPosts) throw new Error("relay bridge down before the post");
|
||||
posts.push(JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>);
|
||||
if (rejectPosts) return new Response(JSON.stringify({ ok: false, error: "bridge_rejected" }), { status: 503, headers: { "content-type": "application/json" } });
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true, messages: [] }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
},
|
||||
});
|
||||
@@ -438,4 +459,90 @@ describe("custody of asks held for quiet hours", () => {
|
||||
expect(findingFor(r, digest).length).toBeGreaterThan(0);
|
||||
expect(findingWhy(digest)).not.toMatch(/quiet hours/);
|
||||
});
|
||||
|
||||
it("Relay quiet release failure yields release-failed, named in waitingView and sweep, and the ladder takes it up", async () => {
|
||||
useRelayBinding = true;
|
||||
const failedRow = await held();
|
||||
asBaton(failedRow);
|
||||
quiet.end("person", { endedBy: "t", now: new Date() });
|
||||
rejectPosts = true;
|
||||
const w = wires[0]!;
|
||||
const [job] = releaseJobs();
|
||||
await runQuietRelease({
|
||||
queueRepo: repo,
|
||||
jobsRepo: new WatchdogJobsRepository(db),
|
||||
home,
|
||||
jobId: job!.jobId,
|
||||
registry: { loadHumanRegistry: registry as never },
|
||||
dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never),
|
||||
});
|
||||
await settle();
|
||||
expect(posts).toHaveLength(1);
|
||||
expect(
|
||||
repo.listTransitions(failedRow.qitemId).some((t) =>
|
||||
(t.transitionNote ?? "").startsWith("relay-owner-notification-transport-failed "),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(quietHoldCustody(db, failedRow.qitemId)).toMatchObject({
|
||||
state: "release-failed",
|
||||
failure: "http-503",
|
||||
});
|
||||
expect(String((repo.waitingView(failedRow.qitemId)!.nextBackstop as { note?: string }).note)).toMatch(
|
||||
/^Released when quiet ended, but its post failed \(http-503\); the release stays queued for redelivery\./,
|
||||
);
|
||||
const rFailed = await sweep();
|
||||
expect(findingFor(rFailed, failedRow)).toHaveLength(1);
|
||||
expect(findingFor(rFailed, failedRow)[0]!.kind).toBe("undelivered-human-notice");
|
||||
expect(findingWhy(failedRow)).toMatch(
|
||||
/released after quiet hours; its post failed \(http-503\); the release stays queued for redelivery/,
|
||||
);
|
||||
await ladder();
|
||||
expect(ladderTouched(failedRow)).toBe(true);
|
||||
});
|
||||
|
||||
it("Relay bridge acceptance resolves quiet-release ownership so the release job retires and custody ends without becoming orphaned", async () => {
|
||||
useRelayBinding = true;
|
||||
const acceptedRow = await held();
|
||||
quiet.end("person", { endedBy: "t", now: new Date() });
|
||||
const w = wires[0]!;
|
||||
const [job] = releaseJobs();
|
||||
// Pass 1 dispatches the release to the relay bridge, which accepts it
|
||||
expect(
|
||||
await runQuietRelease({
|
||||
queueRepo: repo,
|
||||
jobsRepo: new WatchdogJobsRepository(db),
|
||||
home,
|
||||
jobId: job!.jobId,
|
||||
registry: { loadHumanRegistry: registry as never },
|
||||
dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never),
|
||||
}),
|
||||
).toMatchObject({ state: "pending", released: 1 });
|
||||
await settle();
|
||||
expect(posts).toHaveLength(1);
|
||||
expect(
|
||||
repo.listTransitions(acceptedRow.qitemId).some((t) =>
|
||||
(t.transitionNote ?? "").startsWith("relay-owner-notification-accepted "),
|
||||
),
|
||||
).toBe(true);
|
||||
// Pass 2 sees the relay acceptance receipt via resolved(), retires the release job, and leaves custody null (never orphaned)
|
||||
expect(
|
||||
await runQuietRelease({
|
||||
queueRepo: repo,
|
||||
jobsRepo: new WatchdogJobsRepository(db),
|
||||
home,
|
||||
jobId: job!.jobId,
|
||||
registry: { loadHumanRegistry: registry as never },
|
||||
dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never),
|
||||
}),
|
||||
).toMatchObject({ state: "released", released: 0 });
|
||||
expect(releaseJobs()).toHaveLength(0);
|
||||
expect(new WatchdogJobsRepository(db).getById(job!.jobId)?.terminalReason).toBe(
|
||||
"quiet release complete: every held ask posted, accepted by its bridge, or given a disposition",
|
||||
);
|
||||
expect(quietHoldCustody(db, acceptedRow.qitemId)).toBeNull();
|
||||
expect(repo.waitingView(acceptedRow.qitemId)!.nextBackstop).toMatchObject({
|
||||
mechanism: "none (accepted by the bridge; awaiting their decision)",
|
||||
});
|
||||
expect(findingFor(await sweep(), acceptedRow)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1098,3 +1098,243 @@ describe("after review: the outbound sweep when a dispatch throws", () => {
|
||||
expect(buffer.pending().filter((d) => (d.payload as { qitemId?: string }).qitemId === "q-1")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Capability 3 — relay-bound human reuses quiet hold/release, digest, and deferral policies", () => {
|
||||
let db: Database.Database;
|
||||
let repo: QueueRepository;
|
||||
let home: string;
|
||||
let quiet: HumanQuietStore;
|
||||
let prefs: Record<string, unknown>;
|
||||
const relayPosts: Array<Record<string, unknown>> = [];
|
||||
|
||||
function relayRegistry(p: Record<string, unknown>, secretsFile: string) {
|
||||
return {
|
||||
ok: true as const,
|
||||
entities: [
|
||||
{
|
||||
entityId: HUMAN,
|
||||
class: "human" as const,
|
||||
displayName: "Founder",
|
||||
address: `${HUMAN}@external`,
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "relay" as const,
|
||||
connectorRef: "wa-founder",
|
||||
secretsRef: `file:${secretsFile}`,
|
||||
role: "primary" as const,
|
||||
handle: "founder_wa",
|
||||
},
|
||||
],
|
||||
prefs: p,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
home = mkdtempSync(join(tmpdir(), "quiet-relay-"));
|
||||
const secretsFile = join(home, "relay.env");
|
||||
writeFileSync(
|
||||
secretsFile,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:44102/outbound",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
prefs = { deliveryClass: "B", availability: "available" };
|
||||
repo = new QueueRepository(db, new EventBus(db), {
|
||||
validateRig: () => true,
|
||||
loadHumanRegistry: () => relayRegistry(prefs, secretsFile),
|
||||
});
|
||||
quiet = new HumanQuietStore(db);
|
||||
relayPosts.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("holds routine relay asks during quiet hours, releases them after quiet ends, and flushes digest asks with per-question replyRefs", async () => {
|
||||
const secretsFile = join(home, "relay.env");
|
||||
const w = buildSlackGatewayWire({
|
||||
home,
|
||||
queueRepo: repo,
|
||||
registry: { loadHumanRegistry: () => relayRegistry(prefs, secretsFile), resolveSlackHandle },
|
||||
outboundIntervalMs: 60_000,
|
||||
fetchImpl: async (_url, init) => {
|
||||
relayPosts.push(JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>);
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
w.startServices?.();
|
||||
const now = new Date();
|
||||
quiet.setUntil(HUMAN, { until: new Date(now.getTime() + 3_600_000), timezone: "UTC", setBy: "test", now });
|
||||
|
||||
const row = await repo.create({
|
||||
sourceSession: "dev-qa@r",
|
||||
destinationSession: "orch-lead@r",
|
||||
body: "Relay park body",
|
||||
nudge: false,
|
||||
});
|
||||
repo.update({
|
||||
qitemId: row.qitemId,
|
||||
actorSession: "orch-lead@r",
|
||||
state: "blocked",
|
||||
blockedOn: `${HUMAN}@kernel`,
|
||||
summary: "Approve relay park",
|
||||
evidenceRef: "/proof/relay.md",
|
||||
transitionNote: "parked",
|
||||
});
|
||||
|
||||
const ports = makeQueuePorts(repo, { loadHumanRegistry: () => relayRegistry(prefs, secretsFile) } as never);
|
||||
const alert = (await ports.listHumanAlerts({ minimumLevel: "NOTICE" })).find((a) => a.qitemId === row.qitemId)!;
|
||||
|
||||
w.dispatcher.dispatch(OUTBOUND_OP, alert.destinationSession!, alert);
|
||||
await settle();
|
||||
expect(relayPosts).toHaveLength(0);
|
||||
expect(repo.listTransitions(row.qitemId).some((t) => (t.transitionNote ?? "").startsWith("delivery-decision: quiet-hold "))).toBe(true);
|
||||
|
||||
// End quiet hours and run release -> delivers via relay transport and records relay-owner-notification-accepted
|
||||
quiet.end(HUMAN, { endedBy: "test", now: new Date() });
|
||||
const jobs = new WatchdogJobsRepository(db).listActive().filter((j) => j.policy === QUIET_RELEASE_POLICY);
|
||||
expect(jobs).toHaveLength(1);
|
||||
const rel = await runQuietRelease({
|
||||
queueRepo: repo,
|
||||
jobsRepo: new WatchdogJobsRepository(db),
|
||||
home,
|
||||
jobId: jobs[0]!.jobId,
|
||||
now: new Date(),
|
||||
registry: { loadHumanRegistry: () => relayRegistry(prefs, secretsFile) },
|
||||
dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never),
|
||||
});
|
||||
expect(rel).toMatchObject({ released: 1 });
|
||||
await settle();
|
||||
expect(relayPosts).toHaveLength(1);
|
||||
expect(relayPosts[0]).toMatchObject({
|
||||
v: 1,
|
||||
recipient: { handle: "founder_wa" },
|
||||
intent: "decision",
|
||||
outcome: "notify",
|
||||
});
|
||||
expect(typeof relayPosts[0]!.replyRef).toBe("string");
|
||||
expect(repo.hasRelayOwnerNotificationReceipt(row.qitemId, alert.notificationKey!)).toBe(true);
|
||||
} finally {
|
||||
w.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses digest flush (with distinct per-question replyRefs) and away-escalation deferral for a relay-bound human", async () => {
|
||||
const secretsFile = join(home, "relay.env");
|
||||
writeFileSync(
|
||||
secretsFile,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:44100/outbound",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
let prefs: Record<string, unknown> = { deliveryClass: "C", availability: "available" };
|
||||
repo = new QueueRepository(db, new EventBus(db), {
|
||||
validateRig: () => true,
|
||||
loadHumanRegistry: () => relayRegistry(prefs, secretsFile),
|
||||
});
|
||||
|
||||
const relayPosts: Array<Record<string, unknown>> = [];
|
||||
const w = buildSlackGatewayWire({
|
||||
home,
|
||||
queueRepo: repo,
|
||||
registry: { loadHumanRegistry: () => relayRegistry(prefs, secretsFile), resolveSlackHandle },
|
||||
outboundIntervalMs: 60_000,
|
||||
fetchImpl: async (_url, init) => {
|
||||
relayPosts.push(JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>);
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
w.startServices?.();
|
||||
const [r1, r2] = [
|
||||
await repo.create({ sourceSession: "dev@r", destinationSession: "orch@r", body: "Digest ask 1", nudge: false }),
|
||||
await repo.create({ sourceSession: "dev@r", destinationSession: "orch@r", body: "Digest ask 2", nudge: false }),
|
||||
];
|
||||
repo.update({ qitemId: r1.qitemId, actorSession: "orch@r", state: "blocked", blockedOn: `${HUMAN}@kernel`, summary: "Ask 1", evidenceRef: "/proof/1.md", transitionNote: "parked" });
|
||||
repo.update({ qitemId: r2.qitemId, actorSession: "orch@r", state: "blocked", blockedOn: `${HUMAN}@kernel`, summary: "Ask 2", evidenceRef: "/proof/2.md", transitionNote: "parked" });
|
||||
|
||||
const ports = makeQueuePorts(repo, { loadHumanRegistry: () => relayRegistry(prefs, secretsFile) } as never);
|
||||
const alerts = await ports.listHumanAlerts({ minimumLevel: "NOTICE" });
|
||||
const a1 = alerts.find((a) => a.qitemId === r1.qitemId)!;
|
||||
const a2 = alerts.find((a) => a.qitemId === r2.qitemId)!;
|
||||
|
||||
// Dispatch both as Class C available -> recorded into 4h digest, not posted immediately
|
||||
w.dispatcher.dispatch(OUTBOUND_OP, a1.destinationSession!, a1);
|
||||
w.dispatcher.dispatch(OUTBOUND_OP, a2.destinationSession!, a2);
|
||||
await settle();
|
||||
expect(relayPosts).toHaveLength(0);
|
||||
|
||||
// Flush 4h digest -> dispatches both asks as outcome="digest" with distinct per-question replyRefs
|
||||
const flushed = await runDeliveryDigestFlush({
|
||||
queueRepo: repo,
|
||||
registry: { loadHumanRegistry: () => relayRegistry(prefs, secretsFile) },
|
||||
home,
|
||||
window: "4h",
|
||||
dispatch: (op, ref, payload, opts) => w.dispatcher.dispatch(op, ref, payload, opts as never),
|
||||
});
|
||||
await settle();
|
||||
expect(flushed.dispatched).toBe(2);
|
||||
expect(relayPosts).toHaveLength(2);
|
||||
expect(relayPosts[0]).toMatchObject({
|
||||
v: 1,
|
||||
recipient: { handle: "founder_wa" },
|
||||
intent: "decision",
|
||||
outcome: "digest",
|
||||
});
|
||||
expect(relayPosts[1]).toMatchObject({
|
||||
v: 1,
|
||||
recipient: { handle: "founder_wa" },
|
||||
intent: "decision",
|
||||
outcome: "digest",
|
||||
});
|
||||
expect(typeof relayPosts[0]!.replyRef).toBe("string");
|
||||
expect(typeof relayPosts[1]!.replyRef).toBe("string");
|
||||
expect(relayPosts[0]!.replyRef).not.toBe(relayPosts[1]!.replyRef);
|
||||
expect(repo.hasRelayOwnerNotificationReceipt(r1.qitemId, a1.notificationKey!)).toBe(true);
|
||||
expect(repo.hasRelayOwnerNotificationReceipt(r2.qitemId, a2.notificationKey!)).toBe(true);
|
||||
|
||||
// Switch human to Class B + away, and dispatch an escalation -> deferred for 30 minutes
|
||||
prefs = { deliveryClass: "B", availability: "away" };
|
||||
const urgent = await repo.create({
|
||||
sourceSession: "dev@r",
|
||||
destinationSession: "orch@r",
|
||||
body: "Urgent away ask",
|
||||
tags: ["escalation"],
|
||||
nudge: false,
|
||||
});
|
||||
repo.update({
|
||||
qitemId: urgent.qitemId,
|
||||
actorSession: "orch@r",
|
||||
state: "blocked",
|
||||
blockedOn: `${HUMAN}@kernel`,
|
||||
summary: "Urgent ask",
|
||||
evidenceRef: "/proof/urgent.md",
|
||||
transitionNote: "parked",
|
||||
});
|
||||
const urgentAlert = (await ports.listHumanAlerts({ minimumLevel: "NOTICE" })).find((a) => a.qitemId === urgent.qitemId)!;
|
||||
w.dispatcher.dispatch(OUTBOUND_OP, urgentAlert.destinationSession!, urgentAlert);
|
||||
await settle();
|
||||
expect(relayPosts).toHaveLength(2);
|
||||
expect(
|
||||
new WatchdogJobsRepository(db).listActive().some((j) => JSON.stringify(j).includes(urgent.qitemId)),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
w.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { stringify as stringifyYaml } from "yaml";
|
||||
import {
|
||||
addHumanFragment,
|
||||
projectionPath,
|
||||
type HumanFragment,
|
||||
} from "../src/domain/gateway/human-registry.js";
|
||||
import {
|
||||
loadRelayProfiles,
|
||||
validateRelayUrl,
|
||||
parseRelaySecretsRef,
|
||||
authenticateRelayInboundToken,
|
||||
} from "../src/domain/gateway/relay-config.js";
|
||||
|
||||
function relayHuman(
|
||||
entityId: string,
|
||||
connectorRef: string,
|
||||
secretsRef: string,
|
||||
handle: string = entityId,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
entityId,
|
||||
class: "human",
|
||||
displayName: entityId,
|
||||
address: `${entityId}@external`,
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "relay",
|
||||
connectorRef,
|
||||
secretsRef,
|
||||
role: "primary",
|
||||
handle,
|
||||
},
|
||||
],
|
||||
prefs: { deliveryClass: "B" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("relay-config: URL and secretsRef validation", () => {
|
||||
it("accepts file:<absolute-path> and rejects relative or non-file secretsRef", () => {
|
||||
expect(parseRelaySecretsRef("file:/etc/openrig/relay.env")).toEqual({
|
||||
ok: true,
|
||||
envFile: "/etc/openrig/relay.env",
|
||||
});
|
||||
expect(parseRelaySecretsRef("/etc/openrig/relay.env").ok).toBe(false);
|
||||
expect(parseRelaySecretsRef("file:relative/relay.env").ok).toBe(false);
|
||||
expect(parseRelaySecretsRef("vault://slack/main").ok).toBe(false);
|
||||
expect(parseRelaySecretsRef("file://localhost/etc/relay.env").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("validates http loopback URLs (127.0.0.0/8, ::1, localhost) and rejects non-http, non-loopback, or embedded credentials without echoing secrets", () => {
|
||||
expect(validateRelayUrl("http://127.0.0.1:8787/relay").ok).toBe(true);
|
||||
expect(validateRelayUrl("http://127.255.0.1:9000/hook").ok).toBe(true);
|
||||
expect(validateRelayUrl("http://[::1]:8787/relay").ok).toBe(true);
|
||||
expect(validateRelayUrl("http://localhost:8787/relay").ok).toBe(true);
|
||||
|
||||
expect(validateRelayUrl("https://127.0.0.1:8787/relay").ok).toBe(false);
|
||||
expect(validateRelayUrl("http://0.0.0.0:8787/relay").ok).toBe(false);
|
||||
expect(validateRelayUrl("http://192.168.1.10:8787/relay").ok).toBe(false);
|
||||
expect(validateRelayUrl("http://example.com:8787/relay").ok).toBe(false);
|
||||
expect(validateRelayUrl("http://2130706433:8787/relay").ok).toBe(false);
|
||||
|
||||
const credUrl = validateRelayUrl("http://admin:[email protected]:8787/relay");
|
||||
expect(credUrl.ok).toBe(false);
|
||||
if (!credUrl.ok) {
|
||||
expect(credUrl.reason).toMatch(/credentials/i);
|
||||
expect(credUrl.reason).not.toContain("ultra-secret-pass");
|
||||
expect(credUrl.reason).not.toContain("admin");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay-config: loadRelayProfiles(home)", () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "relay-cfg-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("handles an empty human registry (noHumans) cleanly as ok with zero relay profiles", () => {
|
||||
const loaded = loadRelayProfiles(home);
|
||||
expect(loaded.ok).toBe(true);
|
||||
expect(loaded.registryError).toBeNull();
|
||||
expect(loaded.profiles.size).toBe(0);
|
||||
});
|
||||
|
||||
it("reports registryError when the human registry projection is corrupt", () => {
|
||||
const envFile = join(home, "relay.env");
|
||||
writeFileSync(
|
||||
envFile,
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=out-1\nOPENRIG_RELAY_INBOUND_TOKEN=in-1\n",
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
expect(addHumanFragment(relayHuman("mike", "bridge-1", `file:${envFile}`), home).ok).toBe(true);
|
||||
writeFileSync(projectionPath(home), "corrupted: hand-edited\n");
|
||||
|
||||
const loaded = loadRelayProfiles(home);
|
||||
expect(loaded.ok).toBe(false);
|
||||
expect(loaded.registryError).toMatch(/HAND-EDITED|DRIFT/i);
|
||||
});
|
||||
|
||||
it("loads a valid 0600 relay profile, exposes private credentials only via non-enumerable property, and never serializes secrets", () => {
|
||||
const envFile = join(home, "relay.env");
|
||||
writeFileSync(
|
||||
envFile,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8787/out",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=secret-outbound-token-xyz",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=secret-inbound-token-abc",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
expect(addHumanFragment(relayHuman("mike", "bridge-1", `file:${envFile}`, "mike-alias"), home).ok).toBe(true);
|
||||
|
||||
const loaded = loadRelayProfiles(home);
|
||||
expect(loaded.ok).toBe(true);
|
||||
const profile = loaded.get("bridge-1");
|
||||
expect(profile).toBeDefined();
|
||||
expect(profile!.ready).toBe(true);
|
||||
expect(profile!.state).toBe("ready");
|
||||
expect(profile!.configured).toBe(true);
|
||||
expect(profile!.endpointUrl).toBe("http://127.0.0.1:8787/out");
|
||||
expect(profile!.credentials).toEqual({
|
||||
url: "http://127.0.0.1:8787/out",
|
||||
outboundToken: "secret-outbound-token-xyz",
|
||||
inboundToken: "secret-inbound-token-abc",
|
||||
});
|
||||
|
||||
const json = JSON.stringify({ loaded, profile });
|
||||
const yaml = stringifyYaml(profile);
|
||||
expect(json).not.toContain("secret-outbound-token-xyz");
|
||||
expect(json).not.toContain("secret-inbound-token-abc");
|
||||
expect(yaml).not.toContain("secret-outbound-token-xyz");
|
||||
expect(yaml).not.toContain("secret-inbound-token-abc");
|
||||
expect(Object.keys(profile!)).not.toContain("credentials");
|
||||
|
||||
const authOk = authenticateRelayInboundToken(loaded, "secret-inbound-token-abc");
|
||||
expect(authOk.ok).toBe(true);
|
||||
if (authOk.ok) expect(authOk.connectorRef).toBe("bridge-1");
|
||||
|
||||
expect(authenticateRelayInboundToken(loaded, "secret-outbound-token-xyz").ok).toBe(false);
|
||||
expect(authenticateRelayInboundToken(loaded, "wrong").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("warns on group/world accessible secret file or identical directional tokens while keeping the profile ready (0600 and 0400 produce no warning), and still rejects missing keys/files without falling back to process.env", () => {
|
||||
const mode0400File = join(home, "mode0400.env");
|
||||
writeFileSync(
|
||||
mode0400File,
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=o-400\nOPENRIG_RELAY_INBOUND_TOKEN=i-400\n",
|
||||
{ mode: 0o400 },
|
||||
);
|
||||
chmodSync(mode0400File, 0o400);
|
||||
|
||||
const insecureFile = join(home, "insecure.env");
|
||||
writeFileSync(
|
||||
insecureFile,
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=o1-secret\nOPENRIG_RELAY_INBOUND_TOKEN=i1-secret\n",
|
||||
{ mode: 0o644 },
|
||||
);
|
||||
chmodSync(insecureFile, 0o644);
|
||||
|
||||
const equalTokensFile = join(home, "equal.env");
|
||||
writeFileSync(
|
||||
equalTokensFile,
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=same-secret-tok\nOPENRIG_RELAY_INBOUND_TOKEN=same-secret-tok\n",
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const missingKeyFile = join(home, "missing-key.env");
|
||||
writeFileSync(
|
||||
missingKeyFile,
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8787/out\nOPENRIG_RELAY_OUTBOUND_TOKEN=only-out\n",
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const savedUrl = process.env.OPENRIG_RELAY_URL;
|
||||
const savedOut = process.env.OPENRIG_RELAY_OUTBOUND_TOKEN;
|
||||
const savedIn = process.env.OPENRIG_RELAY_INBOUND_TOKEN;
|
||||
process.env.OPENRIG_RELAY_URL = "http://127.0.0.1:9999/env-fallback";
|
||||
process.env.OPENRIG_RELAY_OUTBOUND_TOKEN = "env-out-token";
|
||||
process.env.OPENRIG_RELAY_INBOUND_TOKEN = "env-in-token";
|
||||
|
||||
try {
|
||||
expect(addHumanFragment(relayHuman("u0", "r-0400", `file:${mode0400File}`), home).ok).toBe(true);
|
||||
expect(addHumanFragment(relayHuman("u1", "r-insecure", `file:${insecureFile}`), home).ok).toBe(true);
|
||||
expect(addHumanFragment(relayHuman("u2", "r-equal", `file:${equalTokensFile}`), home).ok).toBe(true);
|
||||
expect(addHumanFragment(relayHuman("u3", "r-missing-key", `file:${missingKeyFile}`), home).ok).toBe(true);
|
||||
expect(addHumanFragment(relayHuman("u4", "r-missing-file", `file:${join(home, "nope.env")}`), home).ok).toBe(true);
|
||||
|
||||
const loaded = loadRelayProfiles(home);
|
||||
|
||||
// 0400 has zero group/other bits -> ready with zero warnings
|
||||
const p0400 = loaded.get("r-0400")!;
|
||||
expect(p0400.ready).toBe(true);
|
||||
expect(p0400.warnings).toEqual([]);
|
||||
expect(p0400.reason).not.toMatch(/warning|insecure/i);
|
||||
|
||||
// 0644 has group/other bits -> warns like Slack, does NOT make profile unready
|
||||
const pInsecure = loaded.get("r-insecure")!;
|
||||
expect(pInsecure.ready).toBe(true);
|
||||
expect(pInsecure.state).toBe("ready");
|
||||
expect(pInsecure.credentials).toEqual({
|
||||
url: "http://127.0.0.1:8787/out",
|
||||
outboundToken: "o1-secret",
|
||||
inboundToken: "i1-secret",
|
||||
});
|
||||
expect(pInsecure.warnings.length).toBeGreaterThan(0);
|
||||
expect(pInsecure.warnings[0]).toMatch(/0644/);
|
||||
expect(pInsecure.warnings[0]).toMatch(/group\/other/i);
|
||||
expect(pInsecure.reason).toMatch(/warning.*0644/i);
|
||||
expect(pInsecure.reason).not.toContain("o1-secret");
|
||||
expect(pInsecure.reason).not.toContain("i1-secret");
|
||||
|
||||
// Same token in both directions -> allowed and ready, with a useful redacted warning
|
||||
const pEqual = loaded.get("r-equal")!;
|
||||
expect(pEqual.ready).toBe(true);
|
||||
expect(pEqual.state).toBe("ready");
|
||||
expect(pEqual.credentials).toEqual({
|
||||
url: "http://127.0.0.1:8787/out",
|
||||
outboundToken: "same-secret-tok",
|
||||
inboundToken: "same-secret-tok",
|
||||
});
|
||||
expect(pEqual.warnings.length).toBeGreaterThan(0);
|
||||
expect(pEqual.warnings[0]).toMatch(/OPENRIG_RELAY_OUTBOUND_TOKEN.*OPENRIG_RELAY_INBOUND_TOKEN|separate/i);
|
||||
expect(pEqual.reason).toMatch(/warning/i);
|
||||
expect(pEqual.reason).not.toContain("same-secret-tok");
|
||||
expect(authenticateRelayInboundToken(loaded, "same-secret-tok")).toMatchObject({
|
||||
ok: true,
|
||||
connectorRef: "r-equal",
|
||||
});
|
||||
|
||||
// Missing key or missing file still not-ready
|
||||
expect(loaded.get("r-missing-key")!.ready).toBe(false);
|
||||
expect(loaded.get("r-missing-key")!.reason).toMatch(/OPENRIG_RELAY_INBOUND_TOKEN/);
|
||||
expect(loaded.get("r-missing-key")!.credentials).toBeNull();
|
||||
|
||||
expect(loaded.get("r-missing-file")!.ready).toBe(false);
|
||||
expect(loaded.get("r-missing-file")!.reason).toMatch(/missing or unreadable/i);
|
||||
expect(loaded.get("r-missing-file")!.credentials).toBeNull();
|
||||
} finally {
|
||||
if (savedUrl === undefined) delete process.env.OPENRIG_RELAY_URL;
|
||||
else process.env.OPENRIG_RELAY_URL = savedUrl;
|
||||
if (savedOut === undefined) delete process.env.OPENRIG_RELAY_OUTBOUND_TOKEN;
|
||||
else process.env.OPENRIG_RELAY_OUTBOUND_TOKEN = savedOut;
|
||||
if (savedIn === undefined) delete process.env.OPENRIG_RELAY_INBOUND_TOKEN;
|
||||
else process.env.OPENRIG_RELAY_INBOUND_TOKEN = savedIn;
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces consistent shared profiles across people and rejects ambiguous reused inbound tokens across refs", () => {
|
||||
const fileA = join(home, "a.env");
|
||||
const fileB = join(home, "b.env");
|
||||
const fileC = join(home, "c.env");
|
||||
writeFileSync(
|
||||
fileA,
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8787/a\nOPENRIG_RELAY_OUTBOUND_TOKEN=out-a\nOPENRIG_RELAY_INBOUND_TOKEN=dup-in-secret\n",
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
writeFileSync(
|
||||
fileB,
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:8788/b\nOPENRIG_RELAY_OUTBOUND_TOKEN=out-b\nOPENRIG_RELAY_INBOUND_TOKEN=dup-in-secret\n",
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
writeFileSync(
|
||||
fileC,
|
||||
"OPENRIG_RELAY_URL=http://localhost:8789/c\nOPENRIG_RELAY_OUTBOUND_TOKEN=out-c\nOPENRIG_RELAY_INBOUND_TOKEN=unique-in-secret\n",
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const h1 = relayHuman("mike", "shared-inconsistent", `file:${fileA}`, "mike") as unknown as HumanFragment;
|
||||
const h2 = relayHuman("dana", "shared-inconsistent", `file:${fileB}`, "dana") as unknown as HumanFragment;
|
||||
const inconsistent = loadRelayProfiles(home, { entities: [h1, h2] });
|
||||
expect(inconsistent.get("shared-inconsistent")!.ready).toBe(false);
|
||||
expect(inconsistent.get("shared-inconsistent")!.reason).toMatch(/inconsistent provisioning/i);
|
||||
|
||||
const a1 = relayHuman("mike", "relay-a", `file:${fileA}`, "mike") as unknown as HumanFragment;
|
||||
const b1 = relayHuman("dana", "relay-b", `file:${fileB}`, "dana") as unknown as HumanFragment;
|
||||
const c1 = relayHuman("alex", "relay-c", `file:${fileC}`, "alex") as unknown as HumanFragment;
|
||||
const c2 = relayHuman("sam", "relay-c", `file:${fileC}`, "sam") as unknown as HumanFragment;
|
||||
const ambiguous = loadRelayProfiles(home, { entities: [a1, b1, c1, c2] });
|
||||
|
||||
expect(ambiguous.get("relay-a")!.ready).toBe(false);
|
||||
expect(ambiguous.get("relay-a")!.reason).toMatch(/ambiguous.*OPENRIG_RELAY_INBOUND_TOKEN/i);
|
||||
expect(ambiguous.get("relay-a")!.reason).not.toContain("dup-in-secret");
|
||||
|
||||
expect(ambiguous.get("relay-b")!.ready).toBe(false);
|
||||
expect(ambiguous.get("relay-b")!.reason).toMatch(/ambiguous.*OPENRIG_RELAY_INBOUND_TOKEN/i);
|
||||
|
||||
expect(ambiguous.get("relay-c")!.ready).toBe(true);
|
||||
expect(ambiguous.get("relay-c")!.humans.map((h) => h.entityId)).toEqual(["alex", "sam"]);
|
||||
expect(authenticateRelayInboundToken(ambiguous, "dup-in-secret").ok).toBe(false);
|
||||
expect(authenticateRelayInboundToken(ambiguous, "unique-in-secret")).toMatchObject({
|
||||
ok: true,
|
||||
connectorRef: "relay-c",
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,7 @@ import { OUTBOUND_OP, toPayload } from "../src/domain/gateway/slack/outbound-dri
|
||||
import { DispatchBuffer, dispatchBufferPath } from "../src/domain/gateway/dispatch-buffer.js";
|
||||
import { SeenStore } from "../src/domain/gateway/slack/state-store.js";
|
||||
import { resolveSlackHandle, addHumanFragment } from "../src/domain/gateway/human-registry.js";
|
||||
import { setSharedChannel } from "../src/domain/gateway/participants.js";
|
||||
import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.js";
|
||||
import { HumanQuietStore } from "../src/domain/gateway/human-quiet-windows.js";
|
||||
import { QUIET_RELEASE_POLICY, runQuietRelease } from "../src/domain/policies/quiet-release.js";
|
||||
@@ -331,6 +332,146 @@ describe("rig slack post-open-asks through the gateway", () => {
|
||||
expect(notes(superseded)).toContain(`slack-re-present-disposition notification_key=${items.get(superseded.qitemId)!.notificationKey} reason=superseded`);
|
||||
expect(notes(closed)).toContain(`slack-re-present-disposition notification_key=${items.get(closed.qitemId)!.notificationKey} reason=row-not-active`);
|
||||
});
|
||||
|
||||
it("ignores Relay-primary asks even if seen state marks them seeded, while re-presenting Slack-primary and shared-channel asks", async () => {
|
||||
expect(
|
||||
addHumanFragment(
|
||||
{
|
||||
entityId: HUMAN,
|
||||
class: "human",
|
||||
displayName: "Alex",
|
||||
address: `${HUMAN}@external`,
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "slack",
|
||||
connectorRef: "primary",
|
||||
secretsRef: "env:SLACK_BOT_TOKEN",
|
||||
role: "primary",
|
||||
handle: "UALEX",
|
||||
},
|
||||
],
|
||||
prefs: { deliveryClass: "B", availability: "available" },
|
||||
},
|
||||
home,
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
addHumanFragment(
|
||||
{
|
||||
entityId: "human-sam",
|
||||
class: "human",
|
||||
displayName: "Sam",
|
||||
address: "human-sam@external",
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "relay",
|
||||
connectorRef: "wa-main",
|
||||
secretsRef: `file:${join(home, "relay.env")}`,
|
||||
role: "primary",
|
||||
handle: "sam_wa",
|
||||
},
|
||||
],
|
||||
prefs: { deliveryClass: "B", availability: "available" },
|
||||
},
|
||||
home,
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
setSharedChannel(
|
||||
"ops-room",
|
||||
{
|
||||
displayName: "Ops Room",
|
||||
channel: {
|
||||
connector: "slack",
|
||||
connectorRef: "primary",
|
||||
secretsRef: "env:SLACK_BOT_TOKEN",
|
||||
channelId: "C-SHARED",
|
||||
},
|
||||
},
|
||||
{},
|
||||
home,
|
||||
).ok,
|
||||
).toBe(true);
|
||||
|
||||
registry = {
|
||||
ok: true as const,
|
||||
entities: [
|
||||
{
|
||||
entityId: HUMAN,
|
||||
class: "human" as const,
|
||||
displayName: "Alex",
|
||||
address: `${HUMAN}@external`,
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "slack" as const,
|
||||
connectorRef: "primary",
|
||||
secretsRef: "env:SLACK_BOT_TOKEN",
|
||||
role: "primary" as const,
|
||||
handle: "UALEX",
|
||||
},
|
||||
],
|
||||
prefs: { deliveryClass: "B", availability: "available" },
|
||||
},
|
||||
{
|
||||
entityId: "human-sam",
|
||||
class: "human" as const,
|
||||
displayName: "Sam",
|
||||
address: "human-sam@external",
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "relay" as const,
|
||||
connectorRef: "wa-main",
|
||||
secretsRef: `file:${join(home, "relay.env")}`,
|
||||
role: "primary" as const,
|
||||
handle: "sam_wa",
|
||||
},
|
||||
],
|
||||
prefs: { deliveryClass: "B", availability: "available" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const slackAsk = await park();
|
||||
const channelAsk = await repo.create({
|
||||
sourceSession: "dev-qa@r",
|
||||
destinationSession: "ops-room@external",
|
||||
summary: "Shared channel ask",
|
||||
body: "shared channel body",
|
||||
evidenceRef: "/proof/shared.md",
|
||||
nudge: false,
|
||||
});
|
||||
db.prepare(
|
||||
`INSERT INTO queue_transitions(qitem_id,ts,state,actor_session,owner_notification_level,owner_notification_kind)
|
||||
VALUES (?, '2026-01-01', 'pending', 'dev-qa@r', 'ALERT', 'human-required')`,
|
||||
).run(channelAsk.qitemId);
|
||||
const relayAsk = await repo.create({
|
||||
sourceSession: "dev-qa@r",
|
||||
destinationSession: "orch-lead@r",
|
||||
body: "relay decision body",
|
||||
nudge: false,
|
||||
});
|
||||
repo.update({
|
||||
qitemId: relayAsk.qitemId,
|
||||
actorSession: "orch-lead@r",
|
||||
state: "blocked",
|
||||
blockedOn: "human-sam@kernel",
|
||||
summary: "Relay choice",
|
||||
evidenceRef: "/proof/relay.md",
|
||||
transitionNote: "parked on relay human",
|
||||
});
|
||||
|
||||
// Even if all three episodes (including the Relay ask) are marked seeded-at-enable in seen store
|
||||
new SeenStore(seenPath()).seed([keyOf(slackAsk), keyOf(channelAsk), keyOf(relayAsk)], "seeded-at-enable");
|
||||
|
||||
const w = wire();
|
||||
const result = await run(w);
|
||||
expect(result.asks.map((a) => a.qitemId).sort()).toEqual([channelAsk.qitemId, slackAsk.qitemId].sort());
|
||||
expect(resultFor(result, relayAsk)).toBeUndefined();
|
||||
expect(resultFor(result, slackAsk)).toMatchObject({ outcome: "posted", reshown: true, recipient: `${HUMAN}@external` });
|
||||
expect(resultFor(result, channelAsk)).toMatchObject({ outcome: "posted", reshown: true, recipient: "ops-room@external" });
|
||||
expect(posts).toHaveLength(2);
|
||||
expect(notes(relayAsk).some((n) => n.includes("slack-re-present-"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /slack/post-open-asks", () => {
|
||||
|
||||
@@ -352,3 +352,413 @@ describe("P28 — ignore-path telemetry discriminates the rejection branch (unch
|
||||
expect(out).not.toContain("secret body text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Capability 3 — single-wire relay port isolation and attempted-replay bypass", () => {
|
||||
let home: string;
|
||||
beforeEach(() => { home = mkdtempSync(join(tmpdir(), "s10-relay-cut-")); });
|
||||
afterEach(() => { rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
it("never falls through to Slack when a human's primary binding is relay and the relay profile is unready, and bypasses re-decision on attempted replay", async () => {
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
const { createDb } = await import("../src/db/connection.js");
|
||||
const { migrate } = await import("../src/db/migrate.js");
|
||||
const { ALL_MIGRATIONS } = await import("../src/db/all-migrations.js");
|
||||
const { EventBus } = await import("../src/domain/event-bus.js");
|
||||
const { QueueRepository } = await import("../src/domain/queue-repository.js");
|
||||
const { DEFAULT_CONFIG, saveConfig } = await import("../src/domain/gateway/slack/config.js");
|
||||
const { buildSlackGatewayWire } = await import("../src/domain/gateway/slack/slack-subsystem.js");
|
||||
const { resolveSlackHandle } = await import("../src/domain/gateway/human-registry.js");
|
||||
const { DispatchBuffer } = await import("../src/domain/gateway/dispatch-buffer.js");
|
||||
const { HumanQuietStore } = await import("../src/domain/gateway/human-quiet-windows.js");
|
||||
|
||||
const db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
|
||||
// Configure Slack as READY so a fallthrough bug would hit Slack chat.postMessage
|
||||
const slackSecrets = join(home, "slack.env");
|
||||
writeFileSync(slackSecrets, "SLACK_BOT_TOKEN=xoxb-EXAMPLE-fake\n", { mode: 0o600 });
|
||||
saveConfig({ ...DEFAULT_CONFIG, enabled: true, channel: "C-FALLTHROUGH", secretsEnvFile: slackSecrets }, home);
|
||||
|
||||
const relaySecrets = join(home, "relay.env");
|
||||
// Start with missing relay file -> unready relay profile
|
||||
const registry = {
|
||||
ok: true as const,
|
||||
entities: [
|
||||
{
|
||||
entityId: "sam",
|
||||
class: "human" as const,
|
||||
displayName: "Sam",
|
||||
address: "sam@external",
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "relay" as const,
|
||||
connectorRef: "wa-main",
|
||||
secretsRef: `file:${relaySecrets}`,
|
||||
role: "primary" as const,
|
||||
handle: "sam_wa",
|
||||
},
|
||||
{
|
||||
kind: "slack" as const,
|
||||
connectorRef: "slack-secondary",
|
||||
secretsRef: `file:${slackSecrets}`,
|
||||
role: "secondary" as const,
|
||||
handle: "USAM",
|
||||
},
|
||||
],
|
||||
prefs: { deliveryClass: "A" as const, availability: "available" as const },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const repo = new QueueRepository(db, new EventBus(db), {
|
||||
validateRig: () => true,
|
||||
loadHumanRegistry: () => registry,
|
||||
});
|
||||
const ask = await repo.create({
|
||||
sourceSession: "lead@rig",
|
||||
destinationSession: "orch@rig",
|
||||
body: "Must never fall through to Slack",
|
||||
nudge: false,
|
||||
});
|
||||
repo.update({
|
||||
qitemId: ask.qitemId,
|
||||
actorSession: "orch@rig",
|
||||
state: "blocked",
|
||||
blockedOn: "sam@kernel",
|
||||
summary: "Relay-bound question",
|
||||
evidenceRef: "/proof/relay-ask.md",
|
||||
transitionNote: "parked",
|
||||
});
|
||||
const askEpisodeKey = `${ask.qitemId}:${repo.transitionLog.latestOwnerNotificationForQitem(ask.qitemId)!.transitionId}`;
|
||||
// Seed an earlier Slack-named transport-failed note for the same episode (as baseline 5d188a43b wrote before the fix);
|
||||
// a subsequent Relay failure for this episode must still record a relay-owner-notification-transport-failed note.
|
||||
repo.update({
|
||||
qitemId: ask.qitemId,
|
||||
actorSession: "daemon@kernel",
|
||||
transitionNote: `slack-owner-notification-transport-failed notification_key=${askEpisodeKey} class=relay-profile-unready error=legacy-slack-named-failure`,
|
||||
});
|
||||
|
||||
const slackCalls: string[] = [];
|
||||
const relayCalls: Array<Record<string, unknown>> = [];
|
||||
let failFirstRelayHttp = true;
|
||||
|
||||
const makeWire = () =>
|
||||
buildSlackGatewayWire({
|
||||
home,
|
||||
queueRepo: repo,
|
||||
registry: { loadHumanRegistry: () => registry, resolveSlackHandle },
|
||||
outboundIntervalMs: 15,
|
||||
fetchImpl: async (url, init) => {
|
||||
if (String(url).includes("slack.com")) {
|
||||
slackCalls.push(String(url));
|
||||
return new Response(JSON.stringify({ ok: true, ts: "1724.0001" }), { status: 200 });
|
||||
}
|
||||
relayCalls.push(JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>);
|
||||
if (failFirstRelayHttp) {
|
||||
failFirstRelayHttp = false;
|
||||
throw new Error("simulated connection drop after attempt");
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
const w1 = makeWire();
|
||||
try {
|
||||
w1.startServices?.();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// Unready relay profile is retained in DispatchBuffer and NEVER falls through to Slack
|
||||
expect(slackCalls).toHaveLength(0);
|
||||
expect(relayCalls).toHaveLength(0);
|
||||
expect(new DispatchBuffer(home).pending()).toHaveLength(1);
|
||||
const w1Notes = repo.listTransitions(ask.qitemId).map((t) => t.transitionNote ?? "");
|
||||
const relayFailureNotes = w1Notes.filter((n) => n.startsWith("relay-owner-notification-transport-failed "));
|
||||
expect(relayFailureNotes).toHaveLength(1);
|
||||
expect(relayFailureNotes[0]).toContain("connector_ref=wa-main");
|
||||
expect(relayFailureNotes[0]).toContain("class=relay-profile-unready");
|
||||
expect(repo.getById(ask.qitemId)).toMatchObject({
|
||||
deliveryOutcome: "transport-failed",
|
||||
deliveryFailureDetail: expect.stringContaining("relay-owner-notification-transport-failed"),
|
||||
});
|
||||
expect(repo.findUndelivered().map((q) => q.qitemId)).toContain(ask.qitemId);
|
||||
} finally {
|
||||
w1.stop();
|
||||
}
|
||||
|
||||
// If the relay connectorRef changes while still failing, it must record a new note naming the new connector rather than suppressing
|
||||
registry.entities[0]!.connectorBindings[0]!.connectorRef = "wa-failover";
|
||||
const originalPending = new DispatchBuffer(home).pending()[0]!;
|
||||
const {
|
||||
relayConnectorRef: _oldRef,
|
||||
relayEnvelope: _oldEnv,
|
||||
...unpreparedPayload
|
||||
} = (originalPending.payload ?? {}) as Record<string, unknown>;
|
||||
const w1b = makeWire();
|
||||
try {
|
||||
w1b.dispatcher.dispatch("post_message", "sam@kernel", unpreparedPayload, {
|
||||
decisionId: "failover-probe",
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const w1bRelayFailureNotes = repo
|
||||
.listTransitions(ask.qitemId)
|
||||
.map((t) => t.transitionNote ?? "")
|
||||
.filter((n) => n.startsWith("relay-owner-notification-transport-failed "));
|
||||
expect(w1bRelayFailureNotes).toHaveLength(2);
|
||||
expect(w1bRelayFailureNotes[1]).toContain("connector_ref=wa-failover");
|
||||
expect(repo.getById(ask.qitemId)?.deliveryFailureDetail).toContain("connector_ref=wa-failover");
|
||||
} finally {
|
||||
new DispatchBuffer(home).ack("failover-probe");
|
||||
w1b.stop();
|
||||
registry.entities[0]!.connectorBindings[0]!.connectorRef = "wa-main";
|
||||
}
|
||||
|
||||
// Write valid relay secrets and replay: first HTTP attempt drops after recording attempt metadata
|
||||
writeFileSync(
|
||||
relaySecrets,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:44101/outbound",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const w2 = makeWire();
|
||||
try {
|
||||
w2.startServices?.();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(slackCalls).toHaveLength(0);
|
||||
expect(relayCalls).toHaveLength(1);
|
||||
expect(new DispatchBuffer(home).pending()).toHaveLength(1);
|
||||
} finally {
|
||||
w2.stop();
|
||||
}
|
||||
|
||||
// Turn ON quiet hours for sam BEFORE the next replay: because the decision was already attempted,
|
||||
// replay must bypass policy re-decision (must NOT hold for quiet hours) and deliver the exact frozen envelope
|
||||
const now = new Date();
|
||||
new HumanQuietStore(db).setUntil("sam", {
|
||||
until: new Date(now.getTime() + 3_600_000),
|
||||
timezone: "UTC",
|
||||
setBy: "test",
|
||||
now,
|
||||
});
|
||||
|
||||
const w3 = makeWire();
|
||||
try {
|
||||
w3.startServices?.();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(slackCalls).toHaveLength(0);
|
||||
expect(relayCalls).toHaveLength(2);
|
||||
expect(relayCalls[1]).toEqual(relayCalls[0]);
|
||||
expect(new DispatchBuffer(home).pending()).toHaveLength(0);
|
||||
const episode = repo.currentHumanRequiredEpisode(ask.qitemId)!;
|
||||
expect(repo.hasRelayOwnerNotificationReceipt(ask.qitemId, episode.key)).toBe(true);
|
||||
} finally {
|
||||
w3.stop();
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("polls and delivers Relay-primary alerts when Slack is disabled without minting new Slack buffer decisions, then enables Slack without replaying unbuffered Slack backlog while preserving earlier durable Slack buffer entries", async () => {
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
const { createDb } = await import("../src/db/connection.js");
|
||||
const { migrate } = await import("../src/db/migrate.js");
|
||||
const { ALL_MIGRATIONS } = await import("../src/db/all-migrations.js");
|
||||
const { EventBus } = await import("../src/domain/event-bus.js");
|
||||
const { QueueRepository } = await import("../src/domain/queue-repository.js");
|
||||
const { DEFAULT_CONFIG, saveConfig } = await import("../src/domain/gateway/slack/config.js");
|
||||
const { buildSlackGatewayWire } = await import("../src/domain/gateway/slack/slack-subsystem.js");
|
||||
const { resolveSlackHandle } = await import("../src/domain/gateway/human-registry.js");
|
||||
const { DispatchBuffer } = await import("../src/domain/gateway/dispatch-buffer.js");
|
||||
const { makeQueuePorts, seedBacklogAsHistory } = await import("../src/domain/gateway/slack/queue-access.js");
|
||||
const { toPayload } = await import("../src/domain/gateway/slack/outbound-driver.js");
|
||||
|
||||
const db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
|
||||
const slackSecrets = join(home, "slack.env");
|
||||
writeFileSync(slackSecrets, "SLACK_BOT_TOKEN=xoxb-EXAMPLE-fake\n", { mode: 0o600 });
|
||||
// Slack starts DISABLED
|
||||
saveConfig({ ...DEFAULT_CONFIG, enabled: false, channel: "C-MIXED", secretsEnvFile: slackSecrets }, home);
|
||||
|
||||
const relaySecrets = join(home, "relay.env");
|
||||
writeFileSync(
|
||||
relaySecrets,
|
||||
[
|
||||
"OPENRIG_RELAY_URL=http://127.0.0.1:44102/outbound",
|
||||
"OPENRIG_RELAY_OUTBOUND_TOKEN=out-secret-token-123456",
|
||||
"OPENRIG_RELAY_INBOUND_TOKEN=in-secret-token-654321",
|
||||
].join("\n"),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const registry = {
|
||||
ok: true as const,
|
||||
entities: [
|
||||
{
|
||||
entityId: "alex",
|
||||
class: "human" as const,
|
||||
displayName: "Alex",
|
||||
address: "alex@external",
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "slack" as const,
|
||||
connectorRef: "slack-main",
|
||||
secretsRef: `file:${slackSecrets}`,
|
||||
role: "primary" as const,
|
||||
handle: "UALEX",
|
||||
},
|
||||
],
|
||||
prefs: { deliveryClass: "B" as const, availability: "available" as const },
|
||||
},
|
||||
{
|
||||
entityId: "sam",
|
||||
class: "human" as const,
|
||||
displayName: "Sam",
|
||||
address: "sam@external",
|
||||
connectorBindings: [
|
||||
{
|
||||
kind: "relay" as const,
|
||||
connectorRef: "wa-main",
|
||||
secretsRef: `file:${relaySecrets}`,
|
||||
role: "primary" as const,
|
||||
handle: "sam_wa",
|
||||
},
|
||||
],
|
||||
prefs: { deliveryClass: "B" as const, availability: "available" as const },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const repo = new QueueRepository(db, new EventBus(db), {
|
||||
validateRig: () => true,
|
||||
loadHumanRegistry: () => registry,
|
||||
});
|
||||
|
||||
// 1. Earlier durable Slack ask already in DispatchBuffer before Slack was disabled
|
||||
const earlierSlackRow = await repo.create({
|
||||
sourceSession: "lead@rig",
|
||||
destinationSession: "orch@rig",
|
||||
body: "Earlier durable Slack ask body",
|
||||
nudge: false,
|
||||
});
|
||||
repo.update({
|
||||
qitemId: earlierSlackRow.qitemId,
|
||||
actorSession: "orch@rig",
|
||||
state: "blocked",
|
||||
blockedOn: "alex@kernel",
|
||||
summary: "Earlier durable Slack ask",
|
||||
evidenceRef: "/proof/earlier-slack.md",
|
||||
transitionNote: "parked earlier",
|
||||
});
|
||||
const [earlierAlert] = await makeQueuePorts(repo, { loadHumanRegistry: () => registry }).listHumanAlerts({
|
||||
minimumLevel: "NOTICE",
|
||||
});
|
||||
new DispatchBuffer(home).enqueue({
|
||||
kind: "outbound_decision",
|
||||
decisionId: "earlier-durable-slack-1",
|
||||
op: OUTBOUND_OP,
|
||||
entityBindingRef: "alex@external",
|
||||
payload: toPayload(earlierAlert!),
|
||||
});
|
||||
|
||||
// 2. Fresh Slack-primary ask and fresh Relay-primary ask created while Slack is disabled
|
||||
const freshSlackRow = await repo.create({
|
||||
sourceSession: "lead@rig",
|
||||
destinationSession: "orch@rig",
|
||||
body: "Fresh Slack backlog while disabled",
|
||||
nudge: false,
|
||||
});
|
||||
repo.update({
|
||||
qitemId: freshSlackRow.qitemId,
|
||||
actorSession: "orch@rig",
|
||||
state: "blocked",
|
||||
blockedOn: "alex@kernel",
|
||||
summary: "Slack backlog ask",
|
||||
evidenceRef: "/proof/fresh-slack.md",
|
||||
transitionNote: "parked while slack disabled",
|
||||
});
|
||||
|
||||
const relayRow = await repo.create({
|
||||
sourceSession: "lead@rig",
|
||||
destinationSession: "orch@rig",
|
||||
body: "Fresh Relay ask while Slack disabled",
|
||||
nudge: false,
|
||||
});
|
||||
repo.update({
|
||||
qitemId: relayRow.qitemId,
|
||||
actorSession: "orch@rig",
|
||||
state: "blocked",
|
||||
blockedOn: "sam@kernel",
|
||||
summary: "Relay active ask",
|
||||
evidenceRef: "/proof/relay-active.md",
|
||||
transitionNote: "parked for relay",
|
||||
});
|
||||
|
||||
const slackPosts: Array<Record<string, unknown>> = [];
|
||||
const relayPosts: Array<Record<string, unknown>> = [];
|
||||
|
||||
const makeWire = () =>
|
||||
buildSlackGatewayWire({
|
||||
home,
|
||||
queueRepo: repo,
|
||||
registry: { loadHumanRegistry: () => registry, resolveSlackHandle },
|
||||
outboundIntervalMs: 15,
|
||||
fetchImpl: async (url, init) => {
|
||||
if (String(url).includes("slack.com")) {
|
||||
slackPosts.push(JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>);
|
||||
return new Response(JSON.stringify({ ok: true, ts: "1724.0002" }), { status: 200 });
|
||||
}
|
||||
relayPosts.push(JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>);
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
const w1 = makeWire();
|
||||
try {
|
||||
w1.startServices?.();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
// Relay ask delivered; Slack did not post
|
||||
expect(relayPosts).toHaveLength(1);
|
||||
expect(relayPosts[0]).toMatchObject({
|
||||
recipient: { handle: "sam_wa" },
|
||||
summary: "Relay active ask",
|
||||
});
|
||||
expect(slackPosts).toHaveLength(0);
|
||||
// Fresh Slack ask was NOT dispatched into the buffer while Slack was disabled;
|
||||
// only the pre-existing durable Slack decision remains in the buffer.
|
||||
expect(new DispatchBuffer(home).pending().map((d) => d.decisionId)).toEqual(["earlier-durable-slack-1"]);
|
||||
} finally {
|
||||
w1.stop();
|
||||
}
|
||||
|
||||
// Now enable Slack: seed Slack-primary backlog as history, flip enabled=true, and restart wire
|
||||
const slackOnlyRegistry = {
|
||||
...registry,
|
||||
entities: registry.entities.filter((e) =>
|
||||
e.connectorBindings.some((b) => b.role === "primary" && b.kind === "slack"),
|
||||
),
|
||||
};
|
||||
await seedBacklogAsHistory({
|
||||
queue: makeQueuePorts(repo, { loadHumanRegistry: () => slackOnlyRegistry }),
|
||||
seen: new SeenStore(join(home, "state", "slack-outbound-seen.jsonl")),
|
||||
filter: { minimumLevel: "NOTICE" },
|
||||
buffer: new DispatchBuffer(home),
|
||||
});
|
||||
saveConfig({ ...DEFAULT_CONFIG, enabled: true, channel: "C-MIXED", secretsEnvFile: slackSecrets }, home);
|
||||
|
||||
const w2 = makeWire();
|
||||
try {
|
||||
w2.startServices?.();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
// Only the pre-existing durable Slack decision replays and posts; the fresh Slack backlog ask was seeded and never buffered
|
||||
expect(slackPosts).toHaveLength(1);
|
||||
expect(JSON.stringify(slackPosts[0])).toContain("Earlier durable Slack ask");
|
||||
expect(JSON.stringify(slackPosts)).not.toContain("Fresh Slack backlog while disabled");
|
||||
expect(new DispatchBuffer(home).pending()).toHaveLength(0);
|
||||
} finally {
|
||||
w2.stop();
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -28,25 +28,68 @@ import { missionControlRoutes } from "../src/routes/mission-control.js";
|
||||
import { MissionControlWriteContract } from "../src/domain/mission-control/mission-control-write-contract.js";
|
||||
import { MissionControlActionLog } from "../src/domain/mission-control/mission-control-action-log.js";
|
||||
import { runWakeLadderTick } from "../src/domain/queue-wake-ladder.js";
|
||||
import type { AttentionRead } from "../src/attention-surface.js";
|
||||
import { makeQueuePorts } from "../src/domain/gateway/slack/queue-access.js";
|
||||
|
||||
const HUMAN = "human-founder";
|
||||
/** The people, with their Slack subscription's preset (absent = the default). */
|
||||
const registryWith = (preset?: "every-ask" | "urgent-only") => ({
|
||||
ok: true as const,
|
||||
entities: [{
|
||||
entityId: HUMAN, class: "human" as const, displayName: "Founder", address: `${HUMAN}@external`,
|
||||
connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UFOUNDER" }],
|
||||
prefs: { deliveryClass: "B" as const, availability: "available" as const },
|
||||
...(preset ? { subscriptions: [{ destination: { connector: "slack" as const }, filter: { preset } }] } : {}),
|
||||
}, {
|
||||
// A registered person addressed as a kernel alias, which no human-* name pattern matches.
|
||||
entityId: "operator-admin", class: "human" as const, displayName: "Operator", address: "operator-admin@external",
|
||||
connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UOPERATOR" }],
|
||||
prefs: { deliveryClass: "B" as const, availability: "available" as const },
|
||||
...(preset ? { subscriptions: [{ destination: { connector: "slack" as const }, filter: { preset } }] } : {}),
|
||||
}],
|
||||
});
|
||||
const registryWith = (
|
||||
preset?: "every-ask" | "urgent-only",
|
||||
options?: { primaryKind?: "slack" | "relay"; secondaryKind?: "slack" | "relay" },
|
||||
) => {
|
||||
const primaryKind = options?.primaryKind ?? "slack";
|
||||
const bindings: any[] = [];
|
||||
if (primaryKind === "relay") {
|
||||
bindings.push({
|
||||
kind: "relay" as const,
|
||||
connectorRef: "relay-main",
|
||||
secretsRef: "file:/tmp/relay.env",
|
||||
role: "primary" as const,
|
||||
handle: "founder-relay",
|
||||
});
|
||||
} else {
|
||||
bindings.push({
|
||||
kind: "slack" as const,
|
||||
connectorRef: "primary",
|
||||
secretsRef: "env:SLACK_BOT_TOKEN",
|
||||
role: "primary" as const,
|
||||
handle: "UFOUNDER",
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.secondaryKind === "relay") {
|
||||
bindings.push({
|
||||
kind: "relay" as const,
|
||||
connectorRef: "relay-sec",
|
||||
secretsRef: "file:/tmp/relay.env",
|
||||
role: "secondary" as const,
|
||||
handle: "founder-relay-sec",
|
||||
});
|
||||
} else if (options?.secondaryKind === "slack") {
|
||||
bindings.push({
|
||||
kind: "slack" as const,
|
||||
connectorRef: "slack-sec",
|
||||
secretsRef: "env:SLACK_BOT_TOKEN",
|
||||
role: "secondary" as const,
|
||||
handle: "UFOUNDER_SEC",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
entities: [{
|
||||
entityId: HUMAN, class: "human" as const, displayName: "Founder", address: `${HUMAN}@external`,
|
||||
connectorBindings: bindings,
|
||||
prefs: { deliveryClass: "B" as const, availability: "available" as const },
|
||||
...(preset ? { subscriptions: [{ destination: { connector: "slack" as const }, filter: { preset } }] } : {}),
|
||||
}, {
|
||||
// A registered person addressed as a kernel alias, which no human-* name pattern matches.
|
||||
entityId: "operator-admin", class: "human" as const, displayName: "Operator", address: "operator-admin@external",
|
||||
connectorBindings: [{ kind: "slack" as const, connectorRef: "primary", secretsRef: "env:SLACK_BOT_TOKEN", role: "primary" as const, handle: "UOPERATOR" }],
|
||||
prefs: { deliveryClass: "B" as const, availability: "available" as const },
|
||||
...(preset ? { subscriptions: [{ destination: { connector: "slack" as const }, filter: { preset } }] } : {}),
|
||||
}],
|
||||
};
|
||||
};
|
||||
// Most cases here are a person whose Slack subscription is urgent-only (plain asks wait on Steering); the default cases reset it.
|
||||
let registry = registryWith("urgent-only");
|
||||
const QUESTION = [{ id: "q1", question: "A or B?", options: [{ id: "a", label: "A" }, { id: "b", label: "B" }] }];
|
||||
@@ -545,4 +588,39 @@ describe("an ask to a person is on Steering and the TUI; Slack gets it by their
|
||||
expect(post).toBeDefined();
|
||||
expect(JSON.stringify(post)).not.toContain("<@UOPERATOR>");
|
||||
});
|
||||
|
||||
it("proves relay primary uses every-ask default independently of authored Slack urgent-only subscription", async () => {
|
||||
// 1. Relay primary with authored Slack urgent-only: plain ask must classify as ALERT and be eligible through real queue ports
|
||||
registry = registryWith("urgent-only", { primaryKind: "relay", secondaryKind: "slack" });
|
||||
const relayPlain = await ask();
|
||||
expect(owner(relayPlain)).toMatchObject({
|
||||
ownerNotificationKind: "human-required",
|
||||
ownerNotificationLevel: "ALERT",
|
||||
});
|
||||
const relayPorts = makeQueuePorts(repo, { loadHumanRegistry: () => registry });
|
||||
const relayAlerts = await relayPorts.listHumanAlerts({});
|
||||
expect(relayAlerts.some((item) => item.qitemId === relayPlain.qitemId)).toBe(true);
|
||||
|
||||
// 2. Slack primary with authored Slack urgent-only: plain ask stays RECORD
|
||||
registry = registryWith("urgent-only", { primaryKind: "slack" });
|
||||
const slackPlain = await ask();
|
||||
expect(owner(slackPlain)).toMatchObject({
|
||||
ownerNotificationKind: "human-required",
|
||||
ownerNotificationLevel: "RECORD",
|
||||
});
|
||||
const slackPorts = makeQueuePorts(repo, { loadHumanRegistry: () => registry });
|
||||
const slackAlerts = await slackPorts.listHumanAlerts({});
|
||||
expect(slackAlerts.some((item) => item.qitemId === slackPlain.qitemId)).toBe(false);
|
||||
|
||||
// 3. Slack primary with secondary Relay: does not change primary routing, retains Slack urgent-only behavior
|
||||
registry = registryWith("urgent-only", { primaryKind: "slack", secondaryKind: "relay" });
|
||||
const slackSecRelayPlain = await ask();
|
||||
expect(owner(slackSecRelayPlain)).toMatchObject({
|
||||
ownerNotificationKind: "human-required",
|
||||
ownerNotificationLevel: "RECORD",
|
||||
});
|
||||
const slackSecRelayPorts = makeQueuePorts(repo, { loadHumanRegistry: () => registry });
|
||||
const slackSecAlerts = await slackSecRelayPorts.listHumanAlerts({});
|
||||
expect(slackSecAlerts.some((item) => item.qitemId === slackSecRelayPlain.qitemId)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,34 +2,49 @@ import type { AttentionRead, AttentionItem } from "@openrig/daemon/attention";
|
||||
import type { Action, FleetSnapshot, ViewState } from "../types.js";
|
||||
import { listItem, wrapDetailLines, type ContentLine } from "../detail.js";
|
||||
|
||||
/** The bounded, confirmed-delivery projection served by S02; no client classification. */
|
||||
/** The bounded, confirmed-delivery or bridge-acceptance projection served by S02; no client classification. */
|
||||
export interface DeliveredHumanUpdates {
|
||||
items: Array<{ qitemId: string; summary: string | null; body: string; humanDetail: string | null;
|
||||
destinationSession: string; sourceSession: string; tags: string[] | null; evidenceRef: string | null;
|
||||
deliveredAt: string; deliveryReceipt: string }>;
|
||||
deliveredAt: string; deliveryReceipt: string; deliveryOutcome?: "posted" | "accepted" | null }>;
|
||||
limit: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
function isBridgeAcceptedUpdate(q: DeliveredHumanUpdates["items"][number]): boolean {
|
||||
return q.deliveryOutcome === "accepted" || q.deliveryReceipt.startsWith("relay-owner-notification-accepted");
|
||||
}
|
||||
|
||||
export function composeHumanUpdates(attention: AttentionRead | null, updates: DeliveredHumanUpdates | null, wanted?: string | null): AttentionRead | null {
|
||||
if (!attention && !updates) return null;
|
||||
const read: AttentionRead = attention ? { ...attention, items: [...attention.items], sources: [...attention.sources] } : {
|
||||
scope: "instance", readAt: new Date().toISOString(), items: [], detail: null, detailError: null,
|
||||
sources: [{ source: "queue", state: "unavailable", detail: "Human requests have not answered." }, { source: "outcomes and health", state: "unavailable", detail: "Feed source unavailable." }],
|
||||
};
|
||||
const hasAccepted = Boolean(updates?.items.some(isBridgeAcceptedUpdate));
|
||||
const hasPosted = Boolean(updates?.items.some(q => !isBridgeAcceptedUpdate(q)));
|
||||
const sourceDetail = !updates
|
||||
? "Delivered updates have not answered."
|
||||
: hasAccepted && !hasPosted
|
||||
? `Latest ${updates.limit} updates accepted by the bridge; retained receipt window${updates.truncated ? ", more omitted" : ""}.`
|
||||
: hasAccepted
|
||||
? `Latest ${updates.limit} confirmed delivered or accepted-by-bridge updates; retained receipt window${updates.truncated ? ", more omitted" : ""}.`
|
||||
: `Latest ${updates.limit} confirmed delivered updates; retained receipt window${updates.truncated ? ", more omitted" : ""}.`;
|
||||
read.sources.push({ source: "delivered updates", state: !updates ? "unavailable" : updates.truncated ? "partial" : "available",
|
||||
detail: updates ? `Latest ${updates.limit} confirmed delivered updates; retained receipt window${updates.truncated ? ", more omitted" : ""}.` : "Delivered updates have not answered." });
|
||||
detail: sourceDetail });
|
||||
for (const q of updates?.items ?? []) {
|
||||
const accepted = isBridgeAcceptedUpdate(q);
|
||||
const projects = [...new Set((q.tags ?? []).filter(t => t.startsWith("project:")).map(t => t.slice(8)))];
|
||||
const item: AttentionItem = { id: `human-update:${q.qitemId}`, kind: "update", summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || "Delivered update",
|
||||
const baseScope = projects.length === 1 ? `project ${projects[0]} (queue tag)` : "instance · project unknown";
|
||||
const item: AttentionItem = { id: `human-update:${q.qitemId}`, kind: "update", summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || (accepted ? "Accepted by bridge" : "Delivered update"),
|
||||
recipient: q.destinationSession, urgency: "update", unblocks: null, at: q.deliveredAt,
|
||||
scope: projects.length === 1 ? `project ${projects[0]} (queue tag)` : "instance · project unknown", project: null,
|
||||
scope: accepted ? `${baseScope} · accepted by bridge` : baseScope, project: null,
|
||||
source: `/api/queue/${encodeURIComponent(q.qitemId)}` };
|
||||
if (!read.items.some(current => current.id === `queue-update:${q.qitemId}`)) read.items.push(item);
|
||||
if (wanted === item.id && read.detail?.item.id !== item.id) {
|
||||
read.detailError = null;
|
||||
read.detail = { item, lines: [q.body, ...(q.humanDetail ? ["Supplemental detail:", q.humanDetail] : []),
|
||||
`From: ${q.sourceSession}`, `Delivered: ${q.deliveredAt}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`],
|
||||
`From: ${q.sourceSession}`, accepted ? `Accepted by bridge: ${q.deliveredAt}` : `Delivered: ${q.deliveredAt}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`],
|
||||
files: q.evidenceRef?.startsWith("/") ? [{ label: "Update evidence", path: q.evidenceRef }] : [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,3 +133,45 @@ it("leaves health episodes ordered by date when hydrating queue updates and deli
|
||||
expect(merged.items.filter(i => i.id.startsWith("health:")).map(i => i.id)).toEqual(["health:new-active", "health:old-cleared"]);
|
||||
expect(merged.items.filter(i => i.id.startsWith("queue-update:")).map(i => i.id)).toEqual(["queue-update:critical", "queue-update:urgent"]);
|
||||
});
|
||||
|
||||
it("labels relay bridge acceptance as accepted by the bridge rather than Delivered while preserving Slack delivery wording", () => {
|
||||
const bridgeUpdates: DeliveredHumanUpdates = {
|
||||
limit: 20,
|
||||
truncated: false,
|
||||
items: [
|
||||
{
|
||||
qitemId: "bridge-fyi",
|
||||
summary: "Bridge FYI ready",
|
||||
body: "Bridge accepted this update.",
|
||||
humanDetail: "Bridge detail",
|
||||
destinationSession: "human@kernel",
|
||||
sourceSession: "author@fixture",
|
||||
tags: ["project:relay"],
|
||||
evidenceRef: "/relay/proof.md",
|
||||
deliveredAt: "2026-10-08T14:00:00Z",
|
||||
deliveryReceipt: "relay-owner-notification-accepted notification_key=bridge-fyi:1 delivery_id=d-1 connector_ref=wa-main level=NOTICE kind=human-update",
|
||||
deliveryOutcome: "accepted",
|
||||
},
|
||||
],
|
||||
};
|
||||
const openedBridge = composeHumanUpdates(read([]), bridgeUpdates, "human-update:bridge-fyi")!;
|
||||
const bridgeDetailLines = openedBridge.detail?.lines.join("\n") ?? "";
|
||||
expect(bridgeDetailLines).toContain("Accepted by bridge: 2026-10-08T14:00:00Z");
|
||||
expect(bridgeDetailLines).not.toContain("Delivered:");
|
||||
expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).toContain("accepted by the bridge");
|
||||
expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).not.toContain("confirmed delivered updates");
|
||||
|
||||
const listState = { ...createViewState({ instanceId: "owned-fixture" }).get(), section: "needs" as const };
|
||||
const groupedText = attentionLines(listState, { ...emptySnapshot(), attentionRead: openedBridge }, 140).map(l => l.text).join("\n");
|
||||
expect(groupedText).toContain("accepted by bridge");
|
||||
expect(groupedText).not.toContain("confirmed delivered updates");
|
||||
|
||||
// Slack delivered wording remains unchanged
|
||||
const openedSlack = composeHumanUpdates(read([]), delivered("slack-fyi"), "human-update:slack-fyi")!;
|
||||
const slackDetailLines = openedSlack.detail?.lines.join("\n") ?? "";
|
||||
expect(slackDetailLines).toContain("Delivered: 2026-10-08T12:00:00Z");
|
||||
expect(slackDetailLines).not.toContain("Accepted by bridge:");
|
||||
expect(openedSlack.sources.find(s => s.source === "delivered updates")?.detail).toBe(
|
||||
"Latest 20 confirmed delivered updates; retained receipt window.",
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user