mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
The merged tree is main plus this branch's own change (7409ee21over338c97b5).
This commit is contained in:
commit
5b02a01415
112 files changed
+15385
-420
No files matched your search
@@ -414,6 +414,7 @@ do not establish connectivity to a live peer.
|
||||
|---|---|---|
|
||||
| `rig acp` | — | — |
|
||||
| `rig acp run <qitemId> <agent...>` | — | `--cwd <dir>`<br>`--permissions-to <session>`<br>`--load-session <id>`<br>`--poll-seconds <n>` |
|
||||
| `rig acp serve` | — | `--to <member@rig>`<br>`--as <address>`<br>`--poll-seconds <n>` |
|
||||
| `rig acp cancel <runId>` | — | — |
|
||||
| `rig acp stop <runId> [stopCommand...]` | — | — |
|
||||
|
||||
|
||||
@@ -181,3 +181,52 @@ rig exec cancel <runId>
|
||||
- One run is one turn for one row.
|
||||
- The client offers the agent no file-system or terminal methods; the agent works in its own environment.
|
||||
- Spend is whatever the agent's provider charges. OpenRig doesn't measure it; it records only what the agent reports.
|
||||
|
||||
## OpenRig as an agent in ACP apps
|
||||
|
||||
The other direction: `rig acp serve`. An app that speaks ACP and can launch a custom agent command can offer your OpenRig team as its agent. The app starts
|
||||
`rig acp serve` and talks to it on stdin and stdout:
|
||||
|
||||
```sh
|
||||
rig acp serve --to <member@rig>
|
||||
```
|
||||
|
||||
- **A prompt** becomes a queue row from you to `--to`, the seat that takes your team's new work. The row's body is your
|
||||
prompt, plus a line saying which app sent it and that its closing resolution is your answer.
|
||||
- **Progress:** each row the work reaches shows in the app as a step (`Sent to …`, then `Handed to …` at each handoff),
|
||||
with the row's notes as they're written, each with the seat that wrote it.
|
||||
- **The reply** is the closing resolution, with who closed the row and how (`done`, `done (canceled)`, `denied`, …). The
|
||||
turn ending says only that the turn ended: read the reply for how the work ended.
|
||||
- **A follow-up** in the same session goes to `--to` as a new row whose body names the earlier rows and their states,
|
||||
and the app first shows any earlier row that's still open.
|
||||
- **A question the team asks you** while a turn is open comes to the app as a permission request, with the ask's own
|
||||
choices, when its row is tagged for that chat (`acp-session:<id>`; the task's row tells the team how). An untagged
|
||||
question from a seat the work reached is named in the chat with where to answer it, because it could be another
|
||||
chat's. Your choice is recorded as your answer, exactly as a click in Slack or the TUI would be, and the seat that
|
||||
asked gets it. Dismissing it, or cancelling the turn, records nothing: the question stays open in the TUI and Slack.
|
||||
A question that wants an answer in words is shown with where to answer it.
|
||||
- **Sessions are kept in the queue:** the app can list your earlier sessions (`session/list`) and reopen one
|
||||
(`session/load`). Reopening replays each prompt, its work and the reply as they stand now, and a new prompt continues it.
|
||||
- **Cancel** stops `rig acp serve` waiting. The team's work carries on, and the reply says so.
|
||||
- **Work handed to a seat on another machine** can't be followed from here; the reply says where it went.
|
||||
|
||||
**Who you are:** `--as <address>`, else `OPENRIG_SESSION_NAME`, else this machine's default person
|
||||
(`rig gateway default <id>`; the address is `<id>@external`). The app's sign-in step, "OpenRig on this machine", checks
|
||||
that the daemon answers and that you resolve to an address; until then, signing in and new sessions fail with the
|
||||
reason and the fix. The agent talks only to the daemon on this computer, so start it first (`rig daemon start`).
|
||||
|
||||
**In Zed**, add the agent to `settings.json`:
|
||||
|
||||
```json
|
||||
"agent_servers": {
|
||||
"OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] }
|
||||
}
|
||||
```
|
||||
|
||||
Other apps take the same command and arguments wherever they configure a custom ACP agent.
|
||||
|
||||
**Limits:**
|
||||
- One receiver per agent command; the app has no team picker.
|
||||
- Questions show only while a turn is open; one asked while no turn is open waits in your usual places (the TUI, Slack).
|
||||
- Prompts are text. Links and embedded text files are passed on as written; images and audio aren't accepted.
|
||||
- Progress is read every 2 seconds while a turn is open (`--poll-seconds`).
|
||||
@@ -65,8 +65,9 @@ or else from the file `$OPENRIG_HOME/terminal-token`, which the person writes; t
|
||||
|
||||
A wrong or missing token gets HTTP 401. The routes that check `OPENRIG_AUTH_BEARER_TOKEN`, and `/api/transport`
|
||||
and `/api/compaction`, answer `{ "error": "unauthorized", "message", "what_failed", "why_it_matters", "what_to_do" }`.
|
||||
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A token
|
||||
doesn't get past the browser boundary's 403 refusals, which [Browser access](browser-access.md) describes.
|
||||
The `GET /api/terminal/<name>` routes, the websocket included, answer `{ "error": "unauthorized", "hint" }`. A valid configured bearer token in the Authorization header can pass the browser boundary's target-name check
|
||||
for a well-formed but unaccepted Host. It cannot pass a malformed Host or a refused Origin; see
|
||||
[Browser access](browser-access.md).
|
||||
|
||||
## Event streams
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -404,6 +404,22 @@ ask rules; help can prompt in that fallback. A helper that disappears or times o
|
||||
after launch cannot supply a decision, leaving Claude's remaining native rules
|
||||
in effect. The hook is a convenience policy, not a containment boundary.
|
||||
|
||||
Claude sessions launched with a named `team` or `kernel` hook policy read the
|
||||
installed built-in lists on each Bash hook call. Replacing those files at the
|
||||
same path changes the hook's lists on its next call; native inline allowances
|
||||
remain as launched. Sessions launched with an encoded policy keep their launch
|
||||
lists. A session retaining a versioned runtime path continues to use the files
|
||||
at that path, so installing another version elsewhere does not change its hook.
|
||||
This does not reload all permission settings in a running session.
|
||||
|
||||
An in-place rollback to a hook from before named policies has a different effect:
|
||||
that helper cannot read `team` or `kernel` and returns no hook decision. Lifecycle
|
||||
asks can then disappear under the native `Bash(rig:*)` allowance. Relaunch affected
|
||||
Claude sessions before relying on those asks after such a rollback. Native
|
||||
personal, project and managed restrictions are not removed. This applies when
|
||||
the rollback replaces the hook at the path a running named-policy session uses;
|
||||
it does not describe every rollback or sessions using a retained versioned path.
|
||||
|
||||
Codex keeps `workspace-write` with its existing approval policy and receives the
|
||||
configured OpenRig workspace root plus its pod's shared state directory as
|
||||
additional writable directories. The team workspace preparer creates missing
|
||||
@@ -548,7 +564,7 @@ or local settings. Managed settings aren't inspected. `rig export` writes the se
|
||||
with an account home or an API-key selection (select those again after import), so a later switch shows there, and `rig provider switch --stored-login` removes it. Only
|
||||
`claude-code` members and legacy flat nodes accept it, and there's no rig-level default.
|
||||
|
||||
**Switching to an isolated account home.** A running Codex or Claude Code seat can change its native account home
|
||||
**Switching to an isolated account home.** A running Codex, Claude Code or Pi seat can change its account home
|
||||
while staying in its rig:
|
||||
|
||||
```sh
|
||||
@@ -560,7 +576,20 @@ rig seat set-runtime dev-impl@my-rig --runtime claude-code \
|
||||
|
||||
The directory must already exist and contain the runtime's login or API settings. You choose the account and budget;
|
||||
OpenRig does not choose or create credentials. `--account-home` sets `CODEX_HOME` for Codex or `CLAUDE_CONFIG_DIR` for
|
||||
Claude Code before the successor starts.
|
||||
Claude Code before the successor starts. For Pi, the account directory must contain a readable `auth.json`; OpenRig links
|
||||
that file into a separate managed successor directory. The existing Pi process keeps its credentials and runner state
|
||||
until the successor confirms. Pi account changes keep the current model and effort unless explicitly overridden.
|
||||
|
||||
```sh
|
||||
rig provider switch --seat pi-worker@my-rig --to-account work \
|
||||
--account-home /home/me/accounts/pi-work --reason "Use the work account" --dry-run
|
||||
# Run again without --dry-run to stage the successor and request its confirmation.
|
||||
rig provider switch --seat pi-worker@my-rig --clear-account-home --reason "Return to configured login"
|
||||
```
|
||||
|
||||
Pi account changes work with or without `launch_wrapper`. The wrapper receives the successor's managed Pi directory;
|
||||
it must preserve the selected credentials if you want OpenRig's account choice to apply. Switching a seat between Pi
|
||||
and another runtime is not supported by this account-home feature.
|
||||
|
||||
This switch starts a fresh conversation with a handover, even when the runtime stays the same. The existing
|
||||
subscription-login switch preserves the conversation. Once the successor confirms, OpenRig commits its home, runtime,
|
||||
@@ -904,7 +933,7 @@ members:
|
||||
|
||||
- **Scope and literal argv:** Supported only on local built-in `runtime: pi` and `runtime: codex` members (and legacy flat `pi`/`codex` nodes); other runtimes (`claude-code`, `omp`, `terminal`, `stub` and community harnesses) reject the field, including `[]`. There is no rig-level, pod-level or fork inheritance: a fork uses its target member's own `launch_wrapper`. Every element must be a string without NUL, CR or LF characters and is quoted literally with `shellQuote`, preserving argument order, spaces, quotes, shell metacharacters and empty non-executable arguments without shell or template expansion.
|
||||
- **Executable resolution and `exec` contract:** `launch_wrapper[0]` must be a non-empty bare command filename on the daemon's launch `PATH` or an absolute path (no relative paths with slashes such as `./x` or `../x`, no `/` in a bare name, and no shell aliases or functions). Preflight and launch resolve `launch_wrapper[0]` to an executable file using the daemon's launch `PATH` and daemon working directory (before the seat's `rig` bin directory is prepended). For `codex`, the prefix appears immediately before the resolved Codex entry (`'<abs node>' '<abs codex.js>' …` or `'<abs codex>' …`) inside the public seat environment envelope, preserving the session's TTY stdio. For `pi`, OpenRig's host runner stays host-side and unwrapped (`node <pi-runner.js> …`), preserving its RPC lifecycle, sidecars, and status reporting; the wrapper prefixes the resolved Pi child executable (`pi --mode rpc`) inside that runner, and the child's stdio pipes preserve RPC stdin, stdout, and stderr (not a TTY). The wrapper must prepare its environment and `exec` the target command in place so the child process preserves working directory, child stdio pipes (for Pi) or TTY stdio (for Codex), OpenRig session/identity environment, process lineage, exit status and signals; supervising wrappers that keep a parent process are unsupported.
|
||||
- **Fail-closed (never unwraps):** When a wrapper is selected, OpenRig applies it across fresh launch, relaunch, rebuild, native resume, fork, restore, handover successor and `pi`↔`codex` runtime switches, and never falls back to bare launch. For `codex`, wrapper resolution and launch environment assembly occur within the shell prefix: if the pane shell is non-POSIX (`nu`, `fish`, etc.), OpenRig session identity is missing or mismatched, `SeatLaunchEnvironment` is unavailable, or `launch_wrapper[0]` is missing or unexecutable, Codex launch fails closed (`OpenRig: launch_wrapper unavailable; no agent started.`). For `pi`, wrapper lookup resolves against the daemon launch `PATH` and daemon working directory before state creation or process dispatch, failing closed before pending launch state if `launch_wrapper[0]` cannot be resolved or executed; runtime child wrapper failures are subsequently reported via the host runner sidecars and markers. If Pi encounters a long launch command (`launch_path_too_long`), it retries execution using the raw host runner command; because that command carries the exact same child wrapper arguments, the Pi child executable never runs unwrapped. In both runtimes, malformed stored wrapper metadata fails closed during reads.
|
||||
- **Fail-closed (never unwraps):** When a wrapper is selected, OpenRig applies it across fresh launch, relaunch, rebuild, native resume, fork, restore, handover successor and supported runtime switches, and never falls back to bare launch. For `codex`, wrapper resolution and launch environment assembly occur within the shell prefix: if the pane shell is non-POSIX (`nu`, `fish`, etc.), OpenRig session identity is missing or mismatched, `SeatLaunchEnvironment` is unavailable, or `launch_wrapper[0]` is missing or unexecutable, Codex launch fails closed (`OpenRig: launch_wrapper unavailable; no agent started.`). For `pi`, wrapper lookup resolves against the daemon launch `PATH` and daemon working directory before state creation or process dispatch, failing closed before pending launch state if `launch_wrapper[0]` cannot be resolved or executed; runtime child wrapper failures are subsequently reported via the host runner sidecars and markers. If Pi encounters a long launch command (`launch_path_too_long`), it retries execution using the raw host runner command; because that command carries the exact same child wrapper arguments, the Pi child executable never runs unwrapped. In both runtimes, malformed stored wrapper metadata fails closed during reads.
|
||||
- **Persistence, `[]` clear vs absent retain, and recreation:** On a new seat, omitting `launch_wrapper` or declaring `launch_wrapper: []` stores no wrapper (`NULL`). When an authored spec is applied to an existing seat, omitting `launch_wrapper` retains the seat's stored wrapper, `launch_wrapper: []` explicitly clears it, and a non-empty array replaces it. A rig that is recreated or imported, including a replacement import, creates new nodes from the incoming spec and uses only what that spec declares, so omitting the field gives the new seat no wrapper. Stored wrapper definitions remain strictly validated; corrupted wrapper rows fail closed during reads without silent unwrap. Restoring an older snapshot that predates `launch_wrapper` preserves the seat's current stored wrapper. First-start retry (`rig launch <rig> <member>`) compares the retained declaration with the node row and refuses to add, change or remove a wrapper on retry. Switching a wrapped seat to an unsupported runtime (`claude-code`, `omp`, etc.) is refused before side effects. There is no live `rig seat` edit verb or management route for `launch_wrapper`; to change or clear a wrapper, edit the `RigSpec` (use `launch_wrapper: []` to clear) and use the supported create/recreate/apply flow. Export (`rig export`) and cached node inventory / `rig ps --nodes --json` (including `--full` and `--fields launchWrapper`) include `launch_wrapper` / `launchWrapper` when configured and omit it when absent.
|
||||
- **Boundary and limits:** A `launch_wrapper` is only an argv prefix. Configuring or observing a prefix is **not** proof of sandbox isolation, tooling availability, memory wiring or governance enforcement; verify the wrapper's own guarantees and the runtime independently.
|
||||
|
||||
@@ -912,10 +941,14 @@ members:
|
||||
|
||||
`runtime: pi` launches the Pi coding agent (`pi --mode rpc`) through OpenRig's RPC runner.
|
||||
|
||||
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`.
|
||||
- **State:** Each seat uses `$OPENRIG_HOME/state/pi/<session>/agent` as its Pi agent directory, with `sessions/` beside it, instead of your default `~/.pi/agent`. The runner sets `PI_CODING_AGENT_DIR` to that directory. `<session>` is the seat's session name, unchanged; for a pod member it is `{podId}-{memberId}@{rigName}`. `$OPENRIG_HOME` defaults to `~/.openrig`. OpenRig creates these directories at launch and does not copy anything from `~/.pi/agent`. A confirmed account switch moves the seat into an isolated `state/pi/account-switches/<generation>/<session>/` directory; subsequent launches and resumes use that committed directory.
|
||||
- **Thinking level:** A Pi seat's effort (the choice saved with `rig seat set-effort`, else the spec's `effort`) is sent to Pi as `--thinking <level>` on every managed launch and resume: fresh launch, successor and restore. Pi accepts `off`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`; OpenRig passes the value as written and does not translate it, so Codex's `none` is not mapped to `off`. Only a value of the shape `set-effort` accepts (a letter, then up to 31 letters, digits, `-` or `_`) is sent; any other spec value, such as one with a space or one that starts with `--`, is left out, and `rig seat effort` says so. An unfamiliar level gets an advisory (a warning from `set-effort`), never a refusal, and Pi itself ignores an unknown level with a warning and keeps the level it would use without the flag: a model suffix, the resumed session's level, or its default. With no effort resolved, no flag is sent. The effort wins over a model suffix, and the model string is never changed. `rig seat effort` reports the level sent at launch and, on Pi 0.99.0 or later, the `thinkingLevel` Pi records on this launch's latest successful assistant turn (error and aborted turns are skipped); that is the session's level after Pi's own cap, not proof the provider applied it, and it is shown apart from the level sent when they differ. On older Pi it is NOT VERIFIED. Without an effort, you can still set the level as a model suffix, for example `model: openrouter/x-ai/grok-4:high`. Pi first looks for a model whose id is the whole pin, and uses it when there is one, suffix included. Only when there is none does Pi read the suffix as the level and record the model without it. Any other suffix, such as OpenRouter's `:free`, stays part of the model id. The model-divergence check reads the pin literally when the seat's `models.json` defines a model with that exact id; otherwise it accepts the model with or without the suffix. If the seat's `models.json` can't be read, a pin with a suffix stays unchecked, with the usual warning, when the recorded model matches the whole pin or the pin without its suffix. A record that matches neither is reported.
|
||||
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file. A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
- **Custom models:** Pi reads `models.json` from its agent directory, so a seat reads `$OPENRIG_HOME/state/pi/<session>/agent/models.json`, not `~/.pi/agent/models.json`. Custom provider and model definitions for a seat go in that file (or the corresponding committed account-switch directory after a switch). A seat that uses only providers Pi already includes does not need one. OpenRig does not create or write `models.json`.
|
||||
- **Credentials:** The Pi process receives only `PATH`, `HOME`, `USER`, `LOGNAME`, `SSH_AUTH_SOCK`, `TERM`, `LANG`, `LC_ALL`, `SHELL`, `TMPDIR`, a fixed set of OpenRig seat and instance variables, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `NODE_OPTIONS` for the selected-account Node preload, and at most one provider key. That key is `OPENROUTER_API_KEY`, `ZAI_API_KEY`, `KIMI_API_KEY` or `MINIMAX_API_KEY`, passed only when it is set in the seat's environment and the seat's `model` is written as `openrouter/<id>`, `zai/<id>`, `kimi-coding/<id>` or `minimax/<id>`. Naming that variable in `recovery.provider_auth_env_allowlist` (empty by default) makes the daemon add it, when set in the daemon's environment, to the seat's launch environment. No other variable reaches Pi, including other providers' keys and a custom provider's own key variable.
|
||||
|
||||
- **Account homes:** Select an account with `rig provider switch --account-home`, or declare an absolute `PI_CODING_AGENT_DIR` in the seat’s `env:` as its configured account home; the persisted selection takes precedence. `~` is not expanded. A relative value is ignored for managed account selection (and reported by status), leaving the managed seat login in use when no persisted account is selected. The chosen directory must already contain a readable `auth.json`; env-only credentials and wrappers that create credentials later are outside this prepared-account path. Launches with neither a persisted selection nor a declared absolute account home retain their existing credential behaviour. The runner links that file into its managed agent directory before starting Pi, including launches through `launch_wrapper`; settings, models, extensions and sessions remain seat-local. Selected accounts use a Node preload shim that redirects only their auth-lock filesystem operations to the chosen account’s `auth.json.lock` pathname, matching direct Pi use even when the source `auth.json` is a symlink. The preload removes its own `NODE_OPTIONS` entry before Pi starts tools, preserving other Node options; descendants and unrelated agent directories keep their normal filesystem behaviour. This requires a Node-hosted Pi executable and a wrapper that preserves `NODE_OPTIONS`; compiled executables and wrappers that discard the preload are not covered. Removing the selection removes only the managed auth link, not the source account file. A missing account file stops the launch. Direct launch refuses to replace an existing seat-local login or an unmanaged auth link; use a confirmed account switch to preserve that login. An ambient pane-shell `PI_CODING_AGENT_DIR` does not select an account; only the resolved seat configuration or persisted account selection does. Persisted account selections survive resume and cannot be bypassed by the long-command fallback; explicit selections clear inherited provider-key overrides. Use `rig provider switch --account-home` or `--clear-account-home` to change an existing Pi seat through a confirmed successor with a fresh conversation and handover. The account reference and committed state directory survive daemon restart and resume; unconfirmed successors leave the old account and state intact. Clearing returns to the configured home, or the original managed seat login if no home was configured. Status reports a configured account home when present; without one, its home is null because Pi uses the managed seat directory rather than the operator's default login.
|
||||
|
||||
**Upgrade note for prepared accounts:** An existing built-in Pi seat that declares an absolute `PI_CODING_AGENT_DIR` now selects that home on fresh launch, restart, resume and restore, even without a stored account override. Before upgrading, either prepare a readable `auth.json` there or remove that declaration to keep the managed seat login. Env-only accounts and wrappers that create the file after Pi starts are not supported by this path. Migration 151 preserves switch history; it does not create or copy credentials, or rewrite existing seat declarations. Relative values remain available to community harnesses and are not globally removed.
|
||||
|
||||
- **Extension dialogs:** The RPC runner shows extension `select`, `confirm`, `input` and `editor` requests in the pane and cancels them because the managed pane cannot answer them. The seat needs attention through the end of the turn, until a new agent run starts. Review the cancelled request and send an instruction the extension can complete without a dialog. Extension `notify` text is shown without raising attention or sending a response.
|
||||
|
||||
|
||||
Generated
+37
@@ -4260,6 +4260,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/graphlib": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
|
||||
@@ -5230,6 +5237,18 @@
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"retry": "^0.12.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||
@@ -5531,6 +5550,16 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
@@ -5839,6 +5868,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
|
||||
@@ -7317,6 +7353,7 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/tar": "^6.1.13",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { JsonRpcPeer } from "./json-rpc-stdio.js";
|
||||
|
||||
// OpenRig as an ACP agent (rig acp serve): an ACP app's prompt becomes a queue row to the team's receiver, the row's
|
||||
// transitions and handoffs come back as progress, and its closing resolution is the reply. A question the team asks the
|
||||
// caller while it works comes to the app as a permission request, and the choice goes back through the person's answer
|
||||
// path. The queue is the only work store: a session is the rows tagged with its id, so it can be listed and reopened.
|
||||
// A turn's end_turn says only that the turn ended: the reply says whether the team closed the item, and a cancel stops
|
||||
// this process waiting, never the team.
|
||||
|
||||
export const ACP_PROTOCOL_VERSION = 1;
|
||||
export const AUTH_METHOD_ID = "openrig-local";
|
||||
export const SESSION_TAG = "acp-session:";
|
||||
export const CWD_TAG = "acp-cwd:";
|
||||
/** Where the caller's prompt ends in a row's body; the rest is the note rig acp serve adds for the team. */
|
||||
const FOOTER = "\n\n---\nSent by ";
|
||||
|
||||
/** One queue row as this adapter reads it. */
|
||||
export interface TeamRow {
|
||||
qitemId: string;
|
||||
state: string;
|
||||
destinationSession: string;
|
||||
handedOffTo: string | null;
|
||||
closureReason: string | null;
|
||||
closureTarget: string | null;
|
||||
resolution: string | null;
|
||||
/** Who closed the row, from its closing transition (a note written after the close doesn't change it); null while open. */
|
||||
closer: string | null;
|
||||
}
|
||||
|
||||
export interface TeamTransition {
|
||||
transitionId: number;
|
||||
ts: string;
|
||||
state: string;
|
||||
transitionNote: string | null;
|
||||
actorSession: string;
|
||||
}
|
||||
|
||||
/** An open row to the caller that asks for a decision, with the choices it offers (none: words only). */
|
||||
export interface TeamAsk {
|
||||
qitemId: string;
|
||||
sourceSession: string;
|
||||
tsCreated: string;
|
||||
summary: string | null;
|
||||
body: string;
|
||||
tags: string[];
|
||||
questions: Array<{ id: string; question: string; options: Array<{ id: string; label: string; recommended?: boolean }> }>;
|
||||
answered: string[];
|
||||
}
|
||||
|
||||
/** A row the caller sent through rig acp serve. */
|
||||
export interface SentRow {
|
||||
qitemId: string;
|
||||
tsCreated: string;
|
||||
tsUpdated: string;
|
||||
summary: string | null;
|
||||
body: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** The daemon answered a create and refused it, so nothing was written. Any other failure of a create leaves it unknown. */
|
||||
export class TeamRefused extends Error {}
|
||||
|
||||
/** The queue verbs the adapter uses, acting as the caller. */
|
||||
export interface TeamQueue {
|
||||
/** Create the row under the id the adapter chose, so a create whose answer is lost can be read back by that id. Throws
|
||||
* TeamRefused only for a refusal the daemon answered with. */
|
||||
create(row: { qitemId: string; destination: string; body: string; summary: string; tags: string[] }): Promise<void>;
|
||||
show(qitemId: string): Promise<TeamRow>;
|
||||
/** The row, or null when this machine has no such row; throws when it can't tell. */
|
||||
find(qitemId: string): Promise<TeamRow | null>;
|
||||
transitions(qitemId: string): Promise<TeamTransition[]>;
|
||||
/** The row a handoff of `qitemId` created on this machine, if any. */
|
||||
successor(qitemId: string): Promise<string | undefined>;
|
||||
/** Open asks addressed to `person`. */
|
||||
openAsks(person: string): Promise<TeamAsk[]>;
|
||||
/** Record the caller's choice on an ask, through the person's answer path; a refusal comes back with its reason. */
|
||||
answer(qitemId: string, questionId: string, optionId: string): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
/** Rows the caller sent through rig acp serve. */
|
||||
sent(caller: string): Promise<SentRow[]>;
|
||||
}
|
||||
|
||||
/** What a session works through: the queue as the caller, and the seat that receives the caller's tasks (when set). */
|
||||
export interface TeamConnection {
|
||||
queue: TeamQueue;
|
||||
caller: string;
|
||||
receiver: string | undefined;
|
||||
}
|
||||
|
||||
export interface AcpAgentOptions {
|
||||
/** Reaches the local daemon and resolves the caller; returns guidance when it can't. Called lazily, never at initialize. */
|
||||
connect: () => Promise<TeamConnection | { error: string }>;
|
||||
version: string;
|
||||
pollMs: number;
|
||||
log?: (line: string) => void;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** Settles when the app's connection ends: open turns stop following their work (nothing could carry a reply). */
|
||||
closed?: Promise<void>;
|
||||
}
|
||||
|
||||
const ACTIVE_STATES = new Set(["pending", "in-progress", "blocked"]);
|
||||
/** How long a cancel waits for the app to settle questions it was showing (ACP has the client answer them cancelled). */
|
||||
const QUESTION_SETTLE_MS = 2000;
|
||||
const AUTH_REQUIRED = -32000;
|
||||
const INVALID_PARAMS = -32602;
|
||||
|
||||
class AcpError extends Error {
|
||||
constructor(readonly code: number, message: string, readonly data?: unknown) { super(message); }
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
cwd: string;
|
||||
/** The first row each prompt created, oldest first, with the last row its work reached. */
|
||||
sent: Array<{ root: string; tail: string }>;
|
||||
turn?: { cancel: () => void };
|
||||
}
|
||||
|
||||
type ContentBlock = { type: string; text?: string; uri?: string; name?: string; resource?: { uri?: string; text?: string } };
|
||||
|
||||
/** The questions one turn has put to the app. Once the turn has ended, nothing from them reaches the app or the queue. */
|
||||
interface TurnQuestions {
|
||||
finished: boolean;
|
||||
/** Questions the app is still showing, by tool call id. */
|
||||
outstanding: Set<string>;
|
||||
tasks: Set<Promise<void>>;
|
||||
}
|
||||
type Update = Record<string, unknown>;
|
||||
|
||||
/** The prompt as plain text for the row's body: text as written, links and embedded text resources named. */
|
||||
export function promptText(prompt: ContentBlock[]): string {
|
||||
const parts: string[] = [];
|
||||
for (const block of prompt) {
|
||||
if (block.type === "text" && block.text) parts.push(block.text);
|
||||
else if (block.type === "resource_link" && block.uri) parts.push(`[${block.name ?? "link"}](${block.uri})`);
|
||||
else if (block.type === "resource" && block.resource?.uri) {
|
||||
parts.push(block.resource.text !== undefined ? `${block.resource.uri}:\n\`\`\`\n${block.resource.text}\n\`\`\`` : block.resource.uri);
|
||||
}
|
||||
}
|
||||
return parts.join("\n\n").trim();
|
||||
}
|
||||
|
||||
/** A queue row id in the form the CLI mints (`qitem-<UTC yyyymmddhhmmss>-<16 hex>`). */
|
||||
export const newQitemId = () => `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`;
|
||||
|
||||
function oneLine(text: string, max: number): string {
|
||||
const line = text.split("\n").find((l) => l.trim())?.trim() ?? "";
|
||||
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
||||
}
|
||||
|
||||
const toolStatus = (state: string) =>
|
||||
state === "pending" ? "pending" : ACTIVE_STATES.has(state) ? "in_progress" : state === "done" || state === "handed-off" ? "completed" : "failed";
|
||||
|
||||
const transitionLine = (t: TeamTransition) =>
|
||||
`${t.ts.slice(11, 19)}Z ${t.actorSession} · ${t.state}${t.transitionNote ? `: ${t.transitionNote}` : ""}`;
|
||||
|
||||
const textContent = (text: string) => [{ type: "content", content: { type: "text", text } }];
|
||||
|
||||
/** Where one prompt's work stands after a read of its rows. */
|
||||
type ChainState = { kind: "open"; tail: TeamRow } | { kind: "ended"; tail: TeamRow; reply: string };
|
||||
|
||||
/** Follows one prompt's work through its rows: each row is a step, and its new notes are emitted as they appear. */
|
||||
class ChainFollower {
|
||||
private readonly chain: string[];
|
||||
private readonly seen = new Map<string, { lines: string[]; lastId: number; status: string }>();
|
||||
/** The seats the work has reached; a question from one of them during the turn belongs to it. */
|
||||
readonly seats = new Set<string>();
|
||||
|
||||
constructor(private readonly queue: TeamQueue, private readonly emit: (update: Update) => void, readonly root: string) {
|
||||
this.chain = [root];
|
||||
}
|
||||
|
||||
async read(): Promise<ChainState> {
|
||||
for (;;) {
|
||||
let tail!: TeamRow;
|
||||
for (const qitemId of this.chain) {
|
||||
const row = await this.queue.show(qitemId);
|
||||
const transitions = await this.queue.transitions(qitemId);
|
||||
tail = row;
|
||||
this.seats.add(row.destinationSession);
|
||||
const status = toolStatus(row.state);
|
||||
let entry = this.seen.get(qitemId);
|
||||
if (!entry) {
|
||||
entry = { lines: [], lastId: 0, status };
|
||||
this.seen.set(qitemId, entry);
|
||||
this.emit({
|
||||
sessionUpdate: "tool_call", toolCallId: qitemId, kind: "other", status,
|
||||
title: qitemId === this.root ? `Sent to ${row.destinationSession} (${qitemId})` : `Handed to ${row.destinationSession} (${qitemId})`,
|
||||
rawInput: { qitemId, destination: row.destinationSession },
|
||||
});
|
||||
}
|
||||
const fresh = transitions.filter((t) => t.transitionId > entry!.lastId);
|
||||
if (fresh.length > 0 || status !== entry.status) {
|
||||
for (const t of fresh) { entry.lines.push(transitionLine(t)); entry.lastId = Math.max(entry.lastId, t.transitionId); }
|
||||
entry.status = status;
|
||||
this.emit({ sessionUpdate: "tool_call_update", toolCallId: qitemId, status, ...(entry.lines.length > 0 ? { content: textContent(entry.lines.join("\n")) } : {}) });
|
||||
}
|
||||
}
|
||||
// Both handoff forms pass the work on: `handoff` leaves the row handed-off, `handoff-and-complete` closes it done
|
||||
// with closure reason handed_off_to. Either way the answer is the successor's, not this row's.
|
||||
if (tail.state === "handed-off" || (tail.state === "done" && tail.closureReason === "handed_off_to")) {
|
||||
const next = await this.queue.successor(tail.qitemId);
|
||||
if (next) { this.chain.push(next); continue; }
|
||||
return { kind: "ended", tail, reply: `${tail.qitemId} was handed to ${tail.handedOffTo ?? tail.closureTarget ?? "another seat"}, whose row isn't on this machine, so this app can't follow it further. Ask your team for its result.` };
|
||||
}
|
||||
if (ACTIVE_STATES.has(tail.state)) return { kind: "open", tail };
|
||||
// A done row's closure reason says how it ended (canceled is a done close too); the ordinary one goes unsaid.
|
||||
const reason = tail.closureReason && tail.closureReason !== "no-follow-on" ? ` (${tail.closureReason})` : "";
|
||||
const how = tail.state === "done" ? `done${reason}` : `as ${tail.state}${reason}`;
|
||||
return { kind: "ended", tail, reply: `${tail.closer ?? tail.destinationSession} closed ${tail.qitemId} ${how}.\n\n${tail.resolution?.trim() || "(No resolution was written.)"}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void {
|
||||
const sessions = new Map<string, Session>();
|
||||
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
||||
const log = options.log ?? (() => {});
|
||||
let clientName = "an ACP app";
|
||||
let connection: TeamConnection | undefined;
|
||||
void options.closed?.then(() => { for (const session of sessions.values()) session.turn?.cancel(); });
|
||||
|
||||
const connected = async (): Promise<TeamConnection> => {
|
||||
if (connection) return connection;
|
||||
const result = await options.connect();
|
||||
if ("error" in result) throw new AcpError(AUTH_REQUIRED, result.error);
|
||||
connection = result;
|
||||
return result;
|
||||
};
|
||||
|
||||
const update = (sessionId: string, body: Update) => peer.notify("session/update", { sessionId, update: body });
|
||||
const say = (sessionId: string, text: string) => update(sessionId, { sessionUpdate: "agent_message_chunk", content: { type: "text", text } });
|
||||
const newSessionId = () => `acps-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(4).toString("hex")}`;
|
||||
|
||||
/** Each of the caller's prompts in a session, oldest first, by the rows tagged with its id. */
|
||||
const sessionRoots = async (team: TeamConnection, sessionId: string) =>
|
||||
(await team.queue.sent(team.caller)).filter((row) => row.tags.includes(`${SESSION_TAG}${sessionId}`))
|
||||
.sort((a, b) => a.tsCreated.localeCompare(b.tsCreated));
|
||||
|
||||
/** Put one ask's open questions to the person as permission requests, one at a time, and record each choice. A reply
|
||||
* that arrives after the turn ended answers nothing here: the question stays open in the TUI and Slack. */
|
||||
const askPerson = async (team: TeamConnection, sessionId: string, ask: TeamAsk, turn: TurnQuestions) => {
|
||||
const emit = (body: Update) => { if (!turn.finished) update(sessionId, body); };
|
||||
if (ask.questions.length === 0) {
|
||||
emit({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: `\n\n${ask.sourceSession} asks you: ${ask.summary ?? oneLine(ask.body, 200)} (${ask.qitemId}). It wants an answer in words, which this app can't send: answer it in the OpenRig TUI or Slack.\n\n` } });
|
||||
return;
|
||||
}
|
||||
for (const q of ask.questions.filter((question) => !ask.answered.includes(question.id))) {
|
||||
if (turn.finished) return;
|
||||
const toolCallId = `${ask.qitemId}#${q.id}`;
|
||||
emit({ sessionUpdate: "tool_call", toolCallId, kind: "other", status: "pending", title: `${ask.sourceSession} asks: ${q.question}`, content: textContent(ask.body) });
|
||||
let outcome: { outcome?: string; optionId?: string } | undefined;
|
||||
turn.outstanding.add(toolCallId);
|
||||
try {
|
||||
const result = (await peer.request("session/request_permission", {
|
||||
sessionId,
|
||||
toolCall: { toolCallId, title: `${ask.sourceSession} asks: ${q.question}`, kind: "other", status: "pending" },
|
||||
options: q.options.map((o) => ({ optionId: o.id, name: o.recommended ? `${o.label} (recommended)` : o.label, kind: "allow_once" })),
|
||||
})) as { outcome?: { outcome?: string; optionId?: string } } | undefined;
|
||||
outcome = result?.outcome;
|
||||
} catch (error) {
|
||||
emit({ sessionUpdate: "tool_call_update", toolCallId, content: textContent(`This app couldn't show the question (${(error as Error).message}). It's still open: answer it in the OpenRig TUI or Slack.`) });
|
||||
return;
|
||||
} finally {
|
||||
turn.outstanding.delete(toolCallId);
|
||||
}
|
||||
if (turn.finished) return;
|
||||
const chosen = outcome?.outcome === "selected" ? q.options.find((o) => o.id === outcome?.optionId) : undefined;
|
||||
if (!chosen) {
|
||||
emit({ sessionUpdate: "tool_call_update", toolCallId, content: textContent("Not answered here. The question is still open in the OpenRig TUI and Slack.") });
|
||||
return;
|
||||
}
|
||||
const recorded = await team.queue.answer(ask.qitemId, q.id, chosen.id).catch((error: Error) => ({ ok: false as const, reason: error.message }));
|
||||
emit(recorded.ok
|
||||
? { sessionUpdate: "tool_call_update", toolCallId, status: "completed", content: textContent(`You answered: ${chosen.label}. ${ask.sourceSession} gets it as your answer.`) }
|
||||
: { sessionUpdate: "tool_call_update", toolCallId, status: "failed", content: textContent(`Your choice (${chosen.label}) wasn't recorded: ${recorded.reason}`) });
|
||||
log(`session ${sessionId}: ${recorded.ok ? "answered" : "couldn't answer"} ${ask.qitemId} ${q.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
peer.onRequest("initialize", async (params) => {
|
||||
const p = (params ?? {}) as { clientInfo?: { name?: string; title?: string } };
|
||||
clientName = p.clientInfo?.title ?? p.clientInfo?.name ?? clientName;
|
||||
return {
|
||||
protocolVersion: ACP_PROTOCOL_VERSION,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: { image: false, audio: false, embeddedContext: true },
|
||||
mcpCapabilities: { http: false, sse: false },
|
||||
sessionCapabilities: { list: {} },
|
||||
},
|
||||
authMethods: [{
|
||||
id: AUTH_METHOD_ID,
|
||||
name: "OpenRig on this machine",
|
||||
description: "Sends your work to your OpenRig team through the OpenRig daemon running on this computer, as you; this agent talks only to that daemon. Start it with `rig daemon start`.",
|
||||
}],
|
||||
agentInfo: { name: "openrig", title: "OpenRig", version: options.version },
|
||||
};
|
||||
});
|
||||
|
||||
peer.onRequest("authenticate", async (params) => {
|
||||
const methodId = (params as { methodId?: unknown } | undefined)?.methodId;
|
||||
if (methodId !== AUTH_METHOD_ID) throw new AcpError(INVALID_PARAMS, `Unknown auth method ${String(methodId)}; this agent offers ${AUTH_METHOD_ID}`);
|
||||
const team = await connected();
|
||||
log(`authenticated as ${team.caller}${team.receiver ? `, sending to ${team.receiver}` : ", with no receiver set"}`);
|
||||
return {};
|
||||
});
|
||||
|
||||
peer.onRequest("session/new", async (params) => {
|
||||
await connected();
|
||||
const id = newSessionId();
|
||||
sessions.set(id, { id, cwd: String((params as { cwd?: unknown } | undefined)?.cwd ?? ""), sent: [] });
|
||||
return { sessionId: id };
|
||||
});
|
||||
|
||||
peer.onRequest("session/list", async (params) => {
|
||||
const team = await connected();
|
||||
const cwd = (params as { cwd?: unknown } | undefined)?.cwd;
|
||||
const byId = new Map<string, { sessionId: string; cwd: string; title: string; updatedAt: string }>();
|
||||
for (const row of (await team.queue.sent(team.caller)).sort((a, b) => a.tsCreated.localeCompare(b.tsCreated))) {
|
||||
const sessionId = row.tags.find((t) => t.startsWith(SESSION_TAG))?.slice(SESSION_TAG.length);
|
||||
if (!sessionId) continue;
|
||||
const known = byId.get(sessionId);
|
||||
if (known) { if (row.tsUpdated > known.updatedAt) known.updatedAt = row.tsUpdated; continue; }
|
||||
byId.set(sessionId, {
|
||||
sessionId, cwd: row.tags.find((t) => t.startsWith(CWD_TAG))?.slice(CWD_TAG.length) ?? "",
|
||||
title: oneLine(row.body.split(FOOTER)[0] ?? "", 100), updatedAt: row.tsUpdated,
|
||||
});
|
||||
}
|
||||
const listed = [...byId.values()].filter((s) => typeof cwd !== "string" || s.cwd === cwd).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
return { sessions: listed };
|
||||
});
|
||||
|
||||
peer.onRequest("session/load", async (params) => {
|
||||
const p = (params ?? {}) as { sessionId?: string; cwd?: string };
|
||||
const team = await connected();
|
||||
const sessionId = String(p.sessionId ?? "");
|
||||
const roots = await sessionRoots(team, sessionId);
|
||||
if (roots.length === 0) throw new AcpError(INVALID_PARAMS, `No session ${sessionId} from ${team.caller} on this machine`);
|
||||
const session: Session = { id: sessionId, cwd: String(p.cwd ?? ""), sent: [] };
|
||||
// Replay: each prompt as the caller wrote it, then its work as it stands now.
|
||||
for (const row of roots) {
|
||||
update(sessionId, { sessionUpdate: "user_message_chunk", content: { type: "text", text: row.body.split(FOOTER)[0] ?? "" } });
|
||||
const follower = new ChainFollower(team.queue, (u) => update(sessionId, u), row.qitemId);
|
||||
const state = await follower.read();
|
||||
session.sent.push({ root: row.qitemId, tail: state.tail.qitemId });
|
||||
say(sessionId, state.kind === "ended" ? state.reply : `${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}.`);
|
||||
}
|
||||
sessions.set(sessionId, session);
|
||||
return {};
|
||||
});
|
||||
|
||||
peer.onNotification("session/cancel", (params) => {
|
||||
const sessionId = (params as { sessionId?: string } | undefined)?.sessionId;
|
||||
if (sessionId) sessions.get(sessionId)?.turn?.cancel();
|
||||
});
|
||||
|
||||
peer.onRequest("session/prompt", async (params) => {
|
||||
const p = (params ?? {}) as { sessionId?: string; prompt?: ContentBlock[] };
|
||||
const session = p.sessionId ? sessions.get(p.sessionId) : undefined;
|
||||
if (!session) throw new AcpError(INVALID_PARAMS, `No session ${String(p.sessionId)} in this agent; start one with session/new or session/load`);
|
||||
if (session.turn) throw new AcpError(INVALID_PARAMS, "This session already has a turn running; wait for it or cancel it first");
|
||||
const team = await connected();
|
||||
const text = promptText(Array.isArray(p.prompt) ? p.prompt : []);
|
||||
if (!text) {
|
||||
say(session.id, "Nothing to send: the prompt had no text.");
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
if (!team.receiver) {
|
||||
say(session.id, "This OpenRig agent has no team receiver, so nothing was sent. Add `--to <member@rig>` (the seat that takes your team's new work) to the agent's command in your app's settings.");
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let wake: () => void = () => {};
|
||||
const questions: TurnQuestions = { finished: false, outstanding: new Set(), tasks: new Set() };
|
||||
session.turn = { cancel: () => { cancelled = true; wake(); } };
|
||||
let root: string | undefined;
|
||||
try {
|
||||
// Each earlier item as it stands; one whose create was never confirmed may not exist, or can't be read: say which.
|
||||
const earlier = await Promise.all(session.sent.map(async (s) => {
|
||||
const row = await team.queue.find(s.tail).catch(() => undefined);
|
||||
return { ...s, row, state: row === undefined ? "state unknown" : row === null ? "not found here" : row.state };
|
||||
}));
|
||||
const open = earlier.filter((s) => s.row && ACTIVE_STATES.has(s.row.state));
|
||||
if (open.length > 0) say(session.id, `${open.map((s) => `${s.row!.qitemId} is still ${s.row!.state} with ${s.row!.destinationSession}`).join("; ")}. Sending this as a follow-up.\n\n`);
|
||||
const lineage = earlier.length === 0 ? "" : `\nFollows up on ${earlier.map((s) => `${s.root} (${s.tail === s.root ? "" : `reached ${s.tail}, `}${s.state})`).join(", ")}.`;
|
||||
const body = `${text}${FOOTER}${team.caller} from ${clientName} through rig acp serve (session ${session.id}).${lineage}\n` +
|
||||
`The app shows this item's notes and handoffs as progress, and its closing resolution as the answer: close it with a resolution written for ${team.caller}. ` +
|
||||
`A question with choices you ask ${team.caller} while this is open, tagged for this chat, appears in the app as a choice: ` +
|
||||
`rig queue create --destination ${team.caller} --human-questions-file … --tags ${SESSION_TAG}${session.id}.`;
|
||||
const turnStarted = new Date().toISOString();
|
||||
const qitemId = newQitemId();
|
||||
try {
|
||||
await team.queue.create({
|
||||
qitemId, destination: team.receiver, body, summary: `ACP: ${oneLine(text, 100)}`,
|
||||
tags: ["acp-serve", `${SESSION_TAG}${session.id}`, ...(session.cwd ? [`${CWD_TAG}${session.cwd}`] : [])],
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = (error as Error).message;
|
||||
// A refusal the daemon answered with wrote nothing, so it can be said plainly.
|
||||
if (error instanceof TeamRefused) throw new AcpError(-32603, `The team's queue refused the task: ${reason}`);
|
||||
// No answer: the request may still land after any readback, so only a row that reads back settles it.
|
||||
const landed = await team.queue.find(qitemId).catch(() => undefined);
|
||||
if (!landed) {
|
||||
session.sent.push({ root: qitemId, tail: qitemId });
|
||||
say(session.id, `Sending ${qitemId} to ${team.receiver} got no answer (${reason}), and ${landed === null ? "it wasn't found on the immediate readback" : "it couldn't be read back"}: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${qitemId}\` shows it if it landed.`);
|
||||
throw new AcpError(-32603, `Couldn't confirm ${qitemId}: ${reason}`);
|
||||
}
|
||||
log(`session ${session.id}: ${qitemId}'s create got no answer, but it reads back`);
|
||||
}
|
||||
root = qitemId;
|
||||
const sent = { root, tail: root };
|
||||
session.sent.push(sent);
|
||||
log(`session ${session.id}: sent ${root} to ${team.receiver}`);
|
||||
|
||||
// ponytail: polls the local daemon for the chain's rows, their transitions and the caller's open asks; the
|
||||
// daemon's event stream is the upgrade path if a poll per open turn ever costs too much.
|
||||
const follower = new ChainFollower(team.queue, (u) => update(session.id, u), root);
|
||||
const seen = new Set<string>();
|
||||
/** Close this turn's questions: any the app still shows are named as open elsewhere, then nothing more comes from them. */
|
||||
const finishQuestions = (why: string) => {
|
||||
for (const toolCallId of questions.outstanding) {
|
||||
update(session.id, { sessionUpdate: "tool_call_update", toolCallId, content: textContent(`${why} The question is still open in the OpenRig TUI and Slack.`) });
|
||||
}
|
||||
questions.finished = true;
|
||||
};
|
||||
for (;;) {
|
||||
const state = await follower.read();
|
||||
sent.tail = state.tail.qitemId;
|
||||
if (state.kind === "ended") {
|
||||
finishQuestions("The work closed before this was answered here.");
|
||||
say(session.id, state.reply);
|
||||
return { stopReason: "end_turn" };
|
||||
}
|
||||
for (const ask of await team.queue.openAsks(team.caller)) {
|
||||
if (seen.has(ask.qitemId)) continue;
|
||||
// An ask tagged for a chat belongs to that chat alone. An untagged one from a seat this work reached might be
|
||||
// another chat's, so it isn't asked here; it's named, with where to answer it.
|
||||
const chats = ask.tags.filter((tag) => tag.startsWith(SESSION_TAG));
|
||||
if (chats.includes(`${SESSION_TAG}${session.id}`)) {
|
||||
seen.add(ask.qitemId);
|
||||
const task: Promise<void> = askPerson(team, session.id, ask, questions)
|
||||
.catch((error: Error) => log(`session ${session.id}: asking about ${ask.qitemId} failed: ${error.message}`))
|
||||
.finally(() => questions.tasks.delete(task));
|
||||
questions.tasks.add(task);
|
||||
} else if (chats.length === 0 && follower.seats.has(ask.sourceSession) && ask.tsCreated >= turnStarted) {
|
||||
seen.add(ask.qitemId);
|
||||
say(session.id, `\n\n${ask.sourceSession}, which this work reached, has a question for you that isn't tagged for this chat (${ask.qitemId}: ${ask.summary ?? oneLine(ask.body, 120)}). Answer it in the OpenRig TUI or Slack.\n\n`);
|
||||
}
|
||||
}
|
||||
if (cancelled) {
|
||||
// ACP has the app answer the questions it was showing as cancelled; let those answers land before the turn ends.
|
||||
if (questions.tasks.size > 0) await Promise.race([Promise.allSettled([...questions.tasks]), sleep(QUESTION_SETTLE_MS)]);
|
||||
finishQuestions("Not answered here: the turn was cancelled.");
|
||||
say(session.id, `Stopped waiting. ${state.tail.qitemId} is still ${state.tail.state} with ${state.tail.destinationSession}: cancelling here doesn't stop the team's work. A new prompt in this session goes to the team as a follow-up and shows where this stands.`);
|
||||
return { stopReason: "cancelled" };
|
||||
}
|
||||
await Promise.race([sleep(options.pollMs), new Promise<void>((resolve) => { wake = resolve; })]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AcpError || !root) throw error;
|
||||
say(session.id, `Lost track of ${root}: ${(error as Error).message}. The item stays with the team; its state is unknown from here.`);
|
||||
throw new AcpError(-32603, `Couldn't follow ${root}: ${(error as Error).message}`);
|
||||
} finally {
|
||||
questions.finished = true;
|
||||
session.turn = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what an OpenRig client
|
||||
// needs (a few outgoing requests, incoming requests and notifications); the official SDK would add a dependency for
|
||||
// a handful of methods, so swap to it only if the method set grows.
|
||||
// ACP v1 is JSON-RPC 2.0 over newline-delimited JSON on the agent's stdio. This peer covers what OpenRig needs on either
|
||||
// side (a few outgoing requests, incoming requests and notifications): StdioJsonRpcPeer is the client end of an agent
|
||||
// process OpenRig launched, and JsonRpcPeer over this process's own stdin and stdout is the agent end (rig acp serve).
|
||||
// The official SDK would add a dependency for a handful of methods, so swap to it only if the method set grows.
|
||||
|
||||
export interface JsonRpcErrorObject {
|
||||
code: number;
|
||||
@@ -33,27 +34,18 @@ export type NotificationHandler = (params: unknown) => void;
|
||||
/** Every packet in both directions, in order; lines that aren't JSON arrive as `in-text`. */
|
||||
export type PacketRecorder = (direction: "out" | "in" | "in-text", packet: unknown) => void;
|
||||
|
||||
export class StdioJsonRpcPeer {
|
||||
/** One JSON-RPC connection over a pair of streams: incoming lines on `input`, outgoing packets written to `output`. */
|
||||
export class JsonRpcPeer {
|
||||
private nextId = 0;
|
||||
private readonly pending = new Map<JsonRpcId, { method: string; resolve: (value: unknown) => void; reject: (error: Error) => void }>();
|
||||
private readonly requestHandlers = new Map<string, IncomingRequestHandler>();
|
||||
private readonly notificationHandlers = new Map<string, NotificationHandler>();
|
||||
private ended = false;
|
||||
private readonly lines: ReturnType<typeof createInterface>;
|
||||
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
|
||||
protected readonly lines: ReturnType<typeof createInterface>;
|
||||
|
||||
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly record: PacketRecorder = () => {}) {
|
||||
this.lines = createInterface({ input: child.stdout });
|
||||
constructor(input: NodeJS.ReadableStream, private readonly output: NodeJS.WritableStream, private readonly record: PacketRecorder = () => {}) {
|
||||
this.lines = createInterface({ input });
|
||||
this.lines.on("line", (line) => this.receive(line));
|
||||
this.exited = new Promise((resolve) => child.once("close", (code, signal) => {
|
||||
this.ended = true;
|
||||
for (const [id, request] of this.pending) {
|
||||
this.pending.delete(id);
|
||||
request.reject(new JsonRpcTransportClosed(request.method));
|
||||
}
|
||||
resolve({ code, signal });
|
||||
}));
|
||||
child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ });
|
||||
}
|
||||
|
||||
request(method: string, params: unknown): Promise<unknown> {
|
||||
@@ -77,25 +69,18 @@ export class StdioJsonRpcPeer {
|
||||
this.notificationHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
/** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */
|
||||
async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> {
|
||||
this.child.stdin.end();
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); });
|
||||
try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
/** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that
|
||||
* keeps running doesn't keep the caller's process alive. */
|
||||
release(): void {
|
||||
this.lines.close();
|
||||
for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy();
|
||||
this.child.unref();
|
||||
/** The connection is gone: nothing more is sent, and every request still waiting rejects as closed. */
|
||||
protected close(): void {
|
||||
this.ended = true;
|
||||
for (const [id, request] of this.pending) {
|
||||
this.pending.delete(id);
|
||||
request.reject(new JsonRpcTransportClosed(request.method));
|
||||
}
|
||||
}
|
||||
|
||||
private send(packet: Record<string, unknown>): void {
|
||||
this.record("out", packet);
|
||||
this.child.stdin.write(`${JSON.stringify(packet)}\n`);
|
||||
this.output.write(`${JSON.stringify(packet)}\n`);
|
||||
}
|
||||
|
||||
private receive(line: string): void {
|
||||
@@ -127,7 +112,7 @@ export class StdioJsonRpcPeer {
|
||||
private async answer(method: string, id: JsonRpcId, params: unknown): Promise<void> {
|
||||
const handler = this.requestHandlers.get(method);
|
||||
if (!handler) {
|
||||
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Client does not handle ${method}` } });
|
||||
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -135,7 +120,48 @@ export class StdioJsonRpcPeer {
|
||||
if (!this.ended) this.send({ jsonrpc: "2.0", id, result });
|
||||
} catch (error) {
|
||||
const code = typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : -32603;
|
||||
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message } });
|
||||
const data = (error as { data?: unknown }).data;
|
||||
if (!this.ended) this.send({ jsonrpc: "2.0", id, error: { code, message: (error as Error).message, ...(data !== undefined ? { data } : {}) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The client end of an ACP agent process OpenRig launched. */
|
||||
export class StdioJsonRpcPeer extends JsonRpcPeer {
|
||||
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
|
||||
|
||||
constructor(private readonly child: ChildProcessWithoutNullStreams, record: PacketRecorder = () => {}) {
|
||||
super(child.stdout, child.stdin, record);
|
||||
this.exited = new Promise((resolve) => child.once("close", (code, signal) => {
|
||||
this.close();
|
||||
resolve({ code, signal });
|
||||
}));
|
||||
child.stdin.on("error", () => { /* EPIPE after the agent exits; pending requests reject on close */ });
|
||||
}
|
||||
|
||||
/** Close the agent's stdin (detach) and wait for the process to exit, up to `timeoutMs`. */
|
||||
async end(timeoutMs: number): Promise<{ code: number | null; signal: NodeJS.Signals | null } | "still-running"> {
|
||||
this.child.stdin.end();
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<"still-running">((resolve) => { timer = setTimeout(() => resolve("still-running"), timeoutMs); });
|
||||
try { return await Promise.race([this.exited, timeout]); } finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
/** Drop this side's hold on the agent (its pipes and process handle) without signalling it, so a detached agent that
|
||||
* keeps running doesn't keep the caller's process alive. */
|
||||
release(): void {
|
||||
this.lines.close();
|
||||
for (const stream of [this.child.stdin, this.child.stdout, this.child.stderr]) stream.destroy();
|
||||
this.child.unref();
|
||||
}
|
||||
}
|
||||
|
||||
/** The agent end over this process's stdin and stdout (or any stream pair): `closed` settles when the input ends. */
|
||||
export class StreamJsonRpcPeer extends JsonRpcPeer {
|
||||
readonly closed: Promise<void>;
|
||||
|
||||
constructor(input: NodeJS.ReadableStream, output: NodeJS.WritableStream, record: PacketRecorder = () => {}) {
|
||||
super(input, output, record);
|
||||
this.closed = new Promise((resolve) => this.lines.once("close", () => { this.close(); resolve(); }));
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ const REEXEC_GUARD_ENV = "OPENRIG_BIN_REEXEC";
|
||||
// Each `rig` is a new process: Node's on-disk compile cache lets the next call skip compiling the modules
|
||||
// again. The cache dates from Node v22.1.0, but this JavaScript API from v22.8.0: before it, `?.()` does
|
||||
// nothing. NODE_COMPILE_CACHE and NODE_DISABLE_COMPILE_CACHE still control it.
|
||||
module.enableCompileCache?.();
|
||||
// A piped bare invocation only prints usage; cold-cache writes can otherwise delay its exit.
|
||||
if (process.argv.length > 2 || (process.stdin.isTTY && process.stdout.isTTY)) module.enableCompileCache?.();
|
||||
|
||||
export function resolveBinEntry(invokedPath = process.argv[1], moduleUrl = import.meta.url): string {
|
||||
const wrapperPath = invokedPath ? realpathSync(invokedPath) : realpathSync(fileURLToPath(moduleUrl));
|
||||
|
||||
@@ -4,11 +4,14 @@ import { randomBytes } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { DaemonClient, type DaemonResponse } from "../client.js";
|
||||
import { daemonStatusGuard, getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { daemonMayAnswer, daemonStatusGuard, getDaemonStatus, getDaemonUrl, statusGuardMessage } from "../daemon-lifecycle.js";
|
||||
import { processIdentity } from "../local-daemon-owner.js";
|
||||
import { getOpenRigHome, readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { answerCommand, runAcpAssignment, type AcpQueue } from "../acp/connector.js";
|
||||
import { findRunProcesses } from "../acp/run-processes.js";
|
||||
import { serveAcpAgent, TeamRefused, type TeamAsk, type TeamConnection, type TeamQueue } from "../acp/agent-server.js";
|
||||
import { StreamJsonRpcPeer } from "../acp/json-rpc-stdio.js";
|
||||
import { CLI_VERSION } from "../version.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
@@ -44,6 +47,92 @@ export function daemonAcpQueue(client: DaemonClient): AcpQueue {
|
||||
};
|
||||
}
|
||||
|
||||
/** rig acp serve's queue verbs over the daemon API, acting as the caller the client was built for. */
|
||||
export function daemonTeamQueue(client: DaemonClient): TeamQueue {
|
||||
const ok = <T>(response: DaemonResponse<T>, what: string): T => {
|
||||
if (response.status >= 400) throw new Error(`${what} returned HTTP ${response.status}: ${JSON.stringify(response.data)}`);
|
||||
return response.data;
|
||||
};
|
||||
const face = (data: Record<string, unknown>) => {
|
||||
const row = (data.item ?? data) as Record<string, unknown>;
|
||||
const text = (value: unknown) => (typeof value === "string" ? value : null);
|
||||
return {
|
||||
qitemId: String(row.qitemId), state: String(row.state), destinationSession: String(row.destinationSession),
|
||||
handedOffTo: text(row.handedOffTo), closureReason: text(row.closureReason), closureTarget: text(row.closureTarget), resolution: text(row.resolution),
|
||||
closer: text((row.closingTransition as { actorSession?: unknown } | null | undefined)?.actorSession),
|
||||
};
|
||||
};
|
||||
const list = async (query: string, what: string): Promise<Array<Record<string, unknown>>> => {
|
||||
const data = ok(await client.get<unknown>(`/api/queue/list?${query}`), what);
|
||||
return (Array.isArray(data) ? data : ((data as { items?: unknown[] }).items ?? [])) as Array<Record<string, unknown>>;
|
||||
};
|
||||
return {
|
||||
async create(row) {
|
||||
const response = await client.post("/api/queue/create", { qitemId: row.qitemId, destinationSession: row.destination, body: row.body, summary: row.summary, tags: row.tags });
|
||||
// A 4xx is the daemon's answered refusal, with nothing written; a 5xx leaves the write unknown, like no answer.
|
||||
if (response.status >= 400 && response.status < 500) throw new TeamRefused(`HTTP ${response.status}: ${JSON.stringify(response.data)}`);
|
||||
ok(response, `queue create for ${row.destination}`);
|
||||
},
|
||||
async show(qitemId) {
|
||||
return face(ok(await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`), `queue show ${qitemId}`));
|
||||
},
|
||||
async find(qitemId) {
|
||||
const response = await client.get<Record<string, unknown>>(`/api/queue/${encodeURIComponent(qitemId)}`);
|
||||
if (response.status === 404) return null;
|
||||
return face(ok(response, `queue show ${qitemId}`));
|
||||
},
|
||||
async transitions(qitemId) {
|
||||
const data = ok(await client.get<Array<Record<string, unknown>>>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`), `queue transitions ${qitemId}`);
|
||||
return data.map((t) => ({
|
||||
transitionId: Number(t.transitionId), ts: String(t.ts), state: String(t.state),
|
||||
transitionNote: typeof t.transitionNote === "string" ? t.transitionNote : null, actorSession: String(t.actorSession),
|
||||
}));
|
||||
},
|
||||
async successor(qitemId) {
|
||||
return (await list(`handedOffFrom=${encodeURIComponent(qitemId)}&limit=1`, `queue successor of ${qitemId}`))[0]?.qitemId as string | undefined;
|
||||
},
|
||||
async openAsks(person) {
|
||||
const rows = await list(`destinationSession=${encodeURIComponent(person)}&state=pending&limit=500`, `open asks to ${person}`);
|
||||
return rows.filter((row) => row.humanIntent !== "update" && (Array.isArray(row.humanQuestions) || row.humanIntent === "decision")).map((row) => ({
|
||||
qitemId: String(row.qitemId), sourceSession: String(row.sourceSession), tsCreated: String(row.tsCreated),
|
||||
summary: typeof row.summary === "string" ? row.summary : null, body: typeof row.body === "string" ? row.body : "",
|
||||
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
|
||||
questions: Array.isArray(row.humanQuestions) ? (row.humanQuestions as TeamAsk["questions"]) : [],
|
||||
answered: Object.keys((row.humanAnswers as Record<string, unknown> | null) ?? {}),
|
||||
}));
|
||||
},
|
||||
async answer(qitemId, questionId, optionId) {
|
||||
// The person's answer path, as the TUI records one: the daemon takes the actor from this client's caller.
|
||||
const bearer = process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
const response = await client.post<Record<string, unknown>>("/api/mission-control/action", { verb: "answer", qitemId, questionId, optionId },
|
||||
bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : undefined);
|
||||
if (response.status < 400) return { ok: true };
|
||||
const data = (response.data ?? {}) as { message?: unknown; error?: unknown };
|
||||
return { ok: false, reason: String(data.message ?? data.error ?? `HTTP ${response.status}`) };
|
||||
},
|
||||
async sent(caller) {
|
||||
const rows = await list(`sourceSession=${encodeURIComponent(caller)}&limit=2000`, `rows from ${caller}`);
|
||||
return rows.filter((row) => Array.isArray(row.tags) && (row.tags as string[]).includes("acp-serve")).map((row) => ({
|
||||
qitemId: String(row.qitemId), tsCreated: String(row.tsCreated), tsUpdated: String(row.tsUpdated),
|
||||
summary: typeof row.summary === "string" ? row.summary : null, body: typeof row.body === "string" ? row.body : "", tags: row.tags as string[],
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The person this machine's OpenRig names as its default (`<id>@external`), or why there isn't one. */
|
||||
async function defaultPersonAddress(): Promise<{ address: string } | { reason: string }> {
|
||||
try {
|
||||
const registry = await import("@openrig/daemon/gateway-human-registry");
|
||||
const people = registry.projectHumans();
|
||||
if (!people.ok) return { reason: people.error };
|
||||
const chosen = registry.resolveDefaultPerson(people.entities);
|
||||
return chosen.person ? { address: chosen.person.address } : { reason: chosen.source === "none" ? chosen.reason : "no default person" };
|
||||
} catch (error) {
|
||||
return { reason: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
function readRun(runId: string): RunFile | undefined {
|
||||
try { return JSON.parse(readFileSync(join(runsDir(), `${runId}.json`), "utf8")) as RunFile; } catch { return undefined; }
|
||||
}
|
||||
@@ -62,7 +151,7 @@ function liveConnector(run: RunFile): boolean {
|
||||
}
|
||||
|
||||
export function acpCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("acp").description("Hand a queue row's task to an ACP (Agent Client Protocol) agent and keep its outcome on the row");
|
||||
const cmd = new Command("acp").description("ACP (Agent Client Protocol): hand a queue row's task to an ACP agent, or serve OpenRig as an agent to ACP apps");
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
const connect = async (): Promise<DaemonClient | undefined> => {
|
||||
const deps = getDeps();
|
||||
@@ -135,6 +224,56 @@ Guide: rig context get reference/acp-agents.md`)
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("serve")
|
||||
.description("Serve OpenRig as an ACP agent on stdio, for an ACP app (Zed, JetBrains and others) to launch: its prompts go to your team as queue rows")
|
||||
.option("--to <member@rig>", "The seat that receives your tasks: your team's entry point (required to send anything)")
|
||||
.option("--as <address>", "Who the tasks come from (default: OPENRIG_SESSION_NAME, else this machine's default person)")
|
||||
.option("--poll-seconds <n>", "How often to read the work's progress while a turn is open", "2")
|
||||
.addHelpText("after", `
|
||||
An ACP app launches this command and talks to it on stdin and stdout. Each prompt becomes a queue row from you to
|
||||
--to. The app shows the row's notes and handoffs as progress, and the closing resolution as the reply. A later
|
||||
prompt in the same session goes to --to as a follow-up that names the earlier items. A question the working seats
|
||||
ask you meanwhile comes to the app as a choice, recorded as your answer. The app can list and reopen your sessions.
|
||||
|
||||
The turn ends when the team closes the item, or when you cancel. Cancelling stops this process waiting; the team's
|
||||
work carries on. A turn that ended doesn't mean the work was accepted: the reply says how the item was closed.
|
||||
|
||||
It uses the OpenRig daemon on this computer, started with rig daemon start. The app's sign-in step ("OpenRig on this
|
||||
machine") checks that the daemon answers and that you resolve to an address: --as, OPENRIG_SESSION_NAME, or the
|
||||
default person (rig gateway default <id>).
|
||||
|
||||
In Zed's settings.json:
|
||||
"agent_servers": { "OpenRig": { "type": "custom", "command": "rig", "args": ["acp", "serve", "--to", "lead@my-team"] } }
|
||||
|
||||
Guide: rig context get reference/acp-agents.md#openrig-as-an-agent-in-acp-apps`)
|
||||
.action(async (opts: { to?: string; as?: string; pollSeconds: string }) => {
|
||||
// Only ACP messages may reach stdout: anything a helper prints goes to stderr instead.
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
process.stdout.on("error", () => { /* EPIPE once the app has gone; the input closing ends the server */ });
|
||||
const connectTeam = async (): Promise<TeamConnection | { error: string }> => {
|
||||
const status = await getDaemonStatus(getDeps().lifecycleDeps);
|
||||
if (!daemonMayAnswer(status)) {
|
||||
const err = statusGuardMessage(status);
|
||||
return { error: `${err.fact} ${err.action}` };
|
||||
}
|
||||
let caller = opts.as?.trim() || self()?.trim();
|
||||
if (!caller) {
|
||||
const person = await defaultPersonAddress();
|
||||
if ("reason" in person) return { error: `OpenRig doesn't know who you are on this machine (${person.reason}). Choose the default person with rig gateway default <id>, or add --as <address> to the agent's command.` };
|
||||
caller = person.address;
|
||||
}
|
||||
return { queue: daemonTeamQueue(new DaemonClient(getDaemonUrl(status), { sessionName: caller })), caller, receiver: opts.to?.trim() || undefined };
|
||||
};
|
||||
const peer = new StreamJsonRpcPeer(process.stdin, process.stdout);
|
||||
serveAcpAgent(peer, {
|
||||
connect: connectTeam, version: CLI_VERSION, pollMs: Math.max(1, Number(opts.pollSeconds) || 2) * 1000,
|
||||
log: (line) => console.error(`[rig acp serve] ${line}`), closed: peer.closed,
|
||||
});
|
||||
await peer.closed;
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("cancel <runId>")
|
||||
.description("Ask a running rig acp run to cancel its agent's turn (the same as Ctrl-C there)")
|
||||
|
||||
@@ -62,7 +62,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--member <name>", "Member name for pod attach mode")
|
||||
.option("--runtime <runtime>", "Runtime for pod attach mode; optional guard for node mode")
|
||||
.option("--cwd <path>", "Working directory to record", process.cwd())
|
||||
.option("--display-name <name>", "Display label inside tmux; external session name outside tmux")
|
||||
.option("--display-name <name>", "Display label inside tmux; external session address outside tmux (new bare names gain @rig)")
|
||||
.option("--print-env", "Print shell exports for OPENRIG_NODE_ID and OPENRIG_SESSION_NAME")
|
||||
.option("--json", "JSON output, including the activity env the attached app needs. Its token is a credential: don't print or paste it, and use rig whoami for identity")
|
||||
.action(async (opts: {
|
||||
|
||||
@@ -130,6 +130,7 @@ Keys:
|
||||
usage.pi_openrouter_charge with usage.pi_cost_facts on, Pi seats look up OpenRouter's actual charge for their requests, shown beside the estimate (default off; changes take effect when a Pi seat next launches or resumes; earlier outcomes stay visible)
|
||||
transport.verify_input_box with --verify, a send to a Claude Code or Codex seat (also queue wakes) reads verified only when the seat was proven idle before the paste and its input box showed the exact text before Enter and was empty after it, on the same pane process; a busy or unknown seat, and anything else, reads unconfirmed, not failed. Reporting only (default off)
|
||||
transport.local_user_guard on a Mac, the CLI refuses a local daemon whose reported user, checked with ps, isn't yours; an explicit OPENRIG_URL skips the check. A mitigation for reaching the wrong daemon, not authentication (default off)
|
||||
transport.draft_delivery deliver (default): a rig send or work notice to a seat whose prompt holds a person's draft goes through: the message is pasted at the current cursor into the draft (normally its end), then Enter is sent to submit the combined input, and the send's result says whether that was confirmed. OpenRig can't read every prompt layout: where it doesn't detect the draft, the result doesn't mention it, and refuse doesn't stop the send. refuse: such a send is refused, and a work notice retried once the prompt is clear, where OpenRig detects the draft. Either way, menus and permission prompts are refused, a per-seat typing guard still holds, managed compaction waits for an empty prompt, and a send with --wait-for-idle still waits
|
||||
decisions.enabled typed decisions (OpenRouter + Jev) for the consumers in decisions.consumers, with OPENROUTER_API_KEY from the
|
||||
rig process that runs them; off makes no provider calls (default off). rig project decisions status shows the state
|
||||
decisions.consumers comma-separated consumers that may call (supported: stream; default none). An unknown name makes no calls
|
||||
@@ -200,10 +201,17 @@ Precedence: CLI flag > environment variable > config file > default`)
|
||||
.action(async (key: string, value: string) => {
|
||||
try {
|
||||
store.set(key, value);
|
||||
console.log(`${key} = ${store.get(key)}`);
|
||||
const resolved = store.resolveWithSource(key);
|
||||
console.log(resolved.source === "env"
|
||||
? `${key} = ${resolved.value} (source: env; file override saved but masked)`
|
||||
: resolved.source === "default"
|
||||
? `${key} file override saved; effective value ${resolved.value} (source: default)`
|
||||
: `${key} = ${resolved.value}`);
|
||||
// SWEEP-c: boot-only key + running daemon = stale-until-restart; say so loudly.
|
||||
if (isBootOnlyKey(key) && (await probeDaemonRunning())) {
|
||||
console.error(`note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; this takes effect on the next daemon restart (rig daemon stop && rig daemon start).`);
|
||||
console.error(resolved.source === "env"
|
||||
? `note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; the saved value remains masked after restart while the environment override is set.`
|
||||
: `note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; this takes effect on the next daemon restart (rig daemon stop && rig daemon start).`);
|
||||
if (key === "terminal.tmux_server") console.error("note: surviving sessions keep their previous server until deliberately stopped; rig terminal status shows the effective selection and any pending migration.");
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -219,9 +227,22 @@ Precedence: CLI flag > environment variable > config file > default`)
|
||||
try {
|
||||
store.reset(key);
|
||||
if (key) {
|
||||
console.log(`${key} reset to default (${store.get(key)}).`);
|
||||
const resolved = store.resolveWithSource(key);
|
||||
console.log(resolved.source === "env"
|
||||
? `${key} file override cleared; effective value ${resolved.value} (source: env; default: ${resolved.defaultValue}).`
|
||||
: `${key} reset to default (${resolved.value}).`);
|
||||
} else {
|
||||
console.log("Config reset to defaults.");
|
||||
let envKeys: string[] = [];
|
||||
try {
|
||||
envKeys = Object.entries(store.resolveAllWithSource())
|
||||
.filter(([, resolved]) => resolved.source === "env")
|
||||
.map(([name]) => name);
|
||||
} catch (err) {
|
||||
console.error(`note: could not read effective configuration after reset: ${(err as Error).message}`);
|
||||
}
|
||||
console.log(envKeys.length
|
||||
? `Config reset to defaults, except where the environment overrides them: ${envKeys.join(", ")}.`
|
||||
: "Config reset to defaults.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -603,6 +603,9 @@ API-key approval before the first turn. OpenRig does not enforce API budgets or
|
||||
Codex can select a saved API-key auth profile with --to-account just like a subscription profile. Its shared-home effects
|
||||
and native credential-store requirements are unchanged. No fresh account home or conversation is created.
|
||||
Do not combine --api-key-file with --account-home or --clear-account-home; select a home and switch credentials separately.
|
||||
Pi seats support --account-home with an existing readable auth.json, or --clear-account-home, through a fresh successor
|
||||
with a handover. Their current model and effort are retained unless overridden. Pi account changes work with or without
|
||||
a launch_wrapper; changing a seat between Pi and another runtime is not supported.
|
||||
--clear-account-home returns to the configured member/rig/daemon/native home through a fresh successor with a handover.
|
||||
Use it without --to-account, --stored-login, --account-home or --token-file. It removes an account-home token reference
|
||||
only after confirmation; it does not restore historical login bytes. Anthropic active/default profiles live outside
|
||||
@@ -638,7 +641,7 @@ CLAUDE_CONFIG_DIR and are not isolated by an account home; check them before rel
|
||||
try {
|
||||
const status = await client.get<{ runtime?: string }>(`/api/seat/status/${encodeURIComponent(opts.seat)}`);
|
||||
const runtime = status.data.runtime;
|
||||
if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code")) {
|
||||
if (status.status >= 400 || (runtime !== "codex" && runtime !== "claude-code" && runtime !== "pi")) {
|
||||
const refusal = { ok: false, code: "account_home_preflight_failed", message: "The seat runtime could not be read; no switch was requested." };
|
||||
if (opts.json) console.log(JSON.stringify(refusal)); else console.error(refusal.message);
|
||||
process.exitCode = 1; return;
|
||||
|
||||
@@ -199,6 +199,8 @@ export interface RiggedConfig {
|
||||
transport: {
|
||||
verifyInputBox: boolean;
|
||||
localUserGuard: boolean;
|
||||
// Whether an ordinary send or work notice reaches a seat whose prompt holds a person's draft (default deliver; refuse opts out).
|
||||
draftDelivery: "refuse" | "deliver";
|
||||
};
|
||||
// Slice 22 (OPR.0.7.2.22) — typed decisions (OpenRouter + Jev), off until enabled; read by the
|
||||
// invoking rig process. templateDir holds optional <name>.yaml templates. Lockstep with the daemon twin.
|
||||
@@ -387,6 +389,7 @@ const DEFAULTS = {
|
||||
transport: {
|
||||
verifyInputBox: false,
|
||||
localUserGuard: false,
|
||||
draftDelivery: "deliver",
|
||||
},
|
||||
// Slice 22 — typed decisions OFF, no consumers, shipped templates only.
|
||||
decisions: {
|
||||
@@ -536,6 +539,7 @@ export const VALID_KEYS = [
|
||||
"usage.pi_openrouter_charge",
|
||||
"transport.verify_input_box",
|
||||
"transport.local_user_guard",
|
||||
"transport.draft_delivery",
|
||||
// Slice 22 — typed decisions (OpenRouter + Jev), off by default; lockstep with the daemon twin.
|
||||
"decisions.enabled",
|
||||
"decisions.consumers",
|
||||
@@ -657,6 +661,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"usage.pi_openrouter_charge": { primary: "OPENRIG_USAGE_PI_OPENROUTER_CHARGE" },
|
||||
"transport.local_user_guard": { primary: "OPENRIG_TRANSPORT_LOCAL_USER_GUARD" },
|
||||
"transport.verify_input_box": { primary: "OPENRIG_TRANSPORT_VERIFY_INPUT_BOX" },
|
||||
"transport.draft_delivery": { primary: "OPENRIG_TRANSPORT_DRAFT_DELIVERY" },
|
||||
"decisions.enabled": { primary: "OPENRIG_DECISIONS_ENABLED" },
|
||||
"decisions.consumers": { primary: "OPENRIG_DECISIONS_CONSUMERS" },
|
||||
"decisions.template_dir": { primary: "OPENRIG_DECISIONS_TEMPLATE_DIR" },
|
||||
@@ -759,6 +764,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"usage.pi_openrouter_charge": ["usage", "piOpenRouterCharge"],
|
||||
"transport.local_user_guard": ["transport", "localUserGuard"],
|
||||
"transport.verify_input_box": ["transport", "verifyInputBox"],
|
||||
"transport.draft_delivery": ["transport", "draftDelivery"],
|
||||
"decisions.enabled": ["decisions", "enabled"],
|
||||
"decisions.consumers": ["decisions", "consumers"],
|
||||
"decisions.template_dir": ["decisions", "templateDir"],
|
||||
@@ -983,6 +989,9 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
"daemon.db_conflict": (raw) => {
|
||||
if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop");
|
||||
},
|
||||
"transport.draft_delivery": (raw) => {
|
||||
if (raw !== "refuse" && raw !== "deliver") throw new Error(`Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`);
|
||||
},
|
||||
"transcripts.lines": (raw, value) => {
|
||||
if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 1_000_000) {
|
||||
throw new Error("Invalid transcripts.lines: must be an integer in [1, 1000000]");
|
||||
@@ -1386,6 +1395,7 @@ export class ConfigStore {
|
||||
transport: {
|
||||
localUserGuard: v("transport.local_user_guard") as boolean,
|
||||
verifyInputBox: v("transport.verify_input_box") as boolean,
|
||||
draftDelivery: v("transport.draft_delivery") as "refuse" | "deliver",
|
||||
},
|
||||
decisions: {
|
||||
enabled: v("decisions.enabled") as boolean,
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
// rig acp serve against the real queue: the daemon's queue and answer routes over a database with every migration, on
|
||||
// loopback, driven by a scripted ACP client over a stream pair. The team's moves go through the same HTTP routes a seat's
|
||||
// CLI uses, each as its own seat.
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../../daemon/src/db/connection.js";
|
||||
import { migrate } from "../../daemon/src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../../daemon/src/db/all-migrations.js";
|
||||
import { EventBus } from "../../daemon/src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../../daemon/src/domain/queue-repository.js";
|
||||
import { queueRoutes } from "../../daemon/src/routes/queue.js";
|
||||
import { missionControlRoutes } from "../../daemon/src/routes/mission-control.js";
|
||||
import { MissionControlWriteContract } from "../../daemon/src/domain/mission-control/mission-control-write-contract.js";
|
||||
import { MissionControlActionLog } from "../../daemon/src/domain/mission-control/mission-control-action-log.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { daemonTeamQueue } from "../src/commands/acp.js";
|
||||
import { AUTH_METHOD_ID, serveAcpAgent, type AcpAgentOptions, type TeamConnection, type TeamQueue } from "../src/acp/agent-server.js";
|
||||
import { JsonRpcRequestError, StreamJsonRpcPeer } from "../src/acp/json-rpc-stdio.js";
|
||||
|
||||
const CALLER = "human-ada@external";
|
||||
const LEAD = "lead@team";
|
||||
const IMPL = "impl@team";
|
||||
let db: Database.Database;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let server: any;
|
||||
let port = 0;
|
||||
const url = () => `http://127.0.0.1:${port}`;
|
||||
const ambient = ["OPENRIG_URL", "RIGGED_URL", "OPENRIG_PORT", "OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME"].map((key) => [key, process.env[key]] as const);
|
||||
|
||||
beforeAll(async () => {
|
||||
for (const [key] of ambient) delete process.env[key];
|
||||
db = createDb();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
const bus = new EventBus(db);
|
||||
// The caller is a registered person, so the team's asks reach them and their answers record as theirs.
|
||||
const registry = { ok: true as const, entities: [{ entityId: "human-ada", class: "human" as const, displayName: "Ada", address: CALLER, connectorBindings: [],
|
||||
prefs: { deliveryClass: "B" as const, availability: "available" as const } }] };
|
||||
const repo = new QueueRepository(db, bus, { validateRig: (session) => !session.endsWith("@gone-rig"), loadHumanRegistry: () => registry as never });
|
||||
const writeContract = new MissionControlWriteContract({ db, eventBus: bus, queueRepo: repo, actionLog: new MissionControlActionLog(db) });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("eventBus" as never, bus); c.set("queueRepo" as never, repo); c.set("missionControlWriteContract" as never, writeContract as never);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/queue", queueRoutes());
|
||||
app.route("/api/mission-control", missionControlRoutes());
|
||||
await new Promise<void>((resolve) => { server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }, (info: { port: number }) => { port = info.port; resolve(); }); });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (server) await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db?.close();
|
||||
for (const [key, value] of ambient) if (value !== undefined) process.env[key] = value;
|
||||
});
|
||||
|
||||
const ends: PassThrough[] = [];
|
||||
afterEach(() => { for (const end of ends.splice(0)) end.end(); });
|
||||
|
||||
/** A scripted ACP client wired to an in-process rig acp serve. */
|
||||
function connect(overrides: Partial<AcpAgentOptions> = {}, receiver: string | null = LEAD, wrap: (queue: TeamQueue) => TeamQueue = (q) => q) {
|
||||
const toAgent = new PassThrough();
|
||||
const fromAgent = new PassThrough();
|
||||
ends.push(toAgent);
|
||||
const out: string[] = [];
|
||||
fromAgent.on("data", (chunk: Buffer) => out.push(chunk.toString()));
|
||||
const team: TeamConnection = { queue: wrap(daemonTeamQueue(new DaemonClient(url(), { sessionName: CALLER }))), caller: CALLER, receiver: receiver ?? undefined };
|
||||
const agent = new StreamJsonRpcPeer(toAgent, fromAgent);
|
||||
serveAcpAgent(agent, { connect: async () => team, version: "9.9.9-test", pollMs: 10, closed: agent.closed, ...overrides });
|
||||
const inbound: Array<Record<string, any>> = [];
|
||||
const client = new StreamJsonRpcPeer(fromAgent, toAgent, (direction, packet) => { if (direction === "in") inbound.push(packet as Record<string, any>); });
|
||||
const updates: Array<Record<string, unknown>> = [];
|
||||
client.onNotification("session/update", (params) => updates.push((params as { update: Record<string, unknown> }).update));
|
||||
return { client, updates, out, inbound };
|
||||
}
|
||||
|
||||
async function open(client: StreamJsonRpcPeer): Promise<string> {
|
||||
await client.request("initialize", { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: "zed", title: "Zed" } });
|
||||
await client.request("authenticate", { methodId: AUTH_METHOD_ID });
|
||||
return ((await client.request("session/new", { cwd: "/tmp", mcpServers: [] })) as { sessionId: string }).sessionId;
|
||||
}
|
||||
|
||||
const prompt = (sessionId: string, text: string) => ({ sessionId, prompt: [{ type: "text", text }] });
|
||||
const seat = (session: string) => new DaemonClient(url(), { sessionName: session });
|
||||
/** A team seat's move through the queue routes; a refused move fails the test with the daemon's answer. */
|
||||
async function move(session: string, path: string, body: Record<string, unknown>): Promise<void> {
|
||||
const response = await seat(session).post(path, body);
|
||||
if (response.status >= 400) throw new Error(`${session} ${path}: HTTP ${response.status} ${JSON.stringify(response.data)}`);
|
||||
}
|
||||
const rowsTo = (destination: string) => db.prepare("SELECT qitem_id, source_session, state, body, tags FROM queue_items WHERE destination_session = ? ORDER BY rowid").all(destination) as Array<{ qitem_id: string; source_session: string; state: string; body: string; tags: string }>;
|
||||
const messages = (updates: Array<Record<string, unknown>>) => updates.filter((u) => u.sessionUpdate === "agent_message_chunk").map((u) => (u.content as { text: string }).text);
|
||||
|
||||
async function until<T>(read: () => T | undefined, what: string): Promise<T> {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const value = read();
|
||||
if (value !== undefined) return value;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`timed out waiting for ${what}`);
|
||||
}
|
||||
|
||||
describe("rig acp serve", () => {
|
||||
it("answers initialize without reaching OpenRig, and offers the one real sign-in method", async () => {
|
||||
let connected = 0;
|
||||
const { client, out } = connect({ connect: async () => { connected += 1; return { error: "unused" }; } });
|
||||
const init = (await client.request("initialize", { protocolVersion: 1, clientCapabilities: { terminal: true } })) as Record<string, any>;
|
||||
expect(init.protocolVersion).toBe(1);
|
||||
expect(init.authMethods).toEqual([expect.objectContaining({ id: AUTH_METHOD_ID, name: "OpenRig on this machine" })]);
|
||||
expect(init.authMethods[0].type).toBeUndefined();
|
||||
expect(init.agentInfo).toEqual({ name: "openrig", title: "OpenRig", version: "9.9.9-test" });
|
||||
expect(init.agentCapabilities.loadSession).toBe(true);
|
||||
expect(init.agentCapabilities.sessionCapabilities).toEqual({ list: {} });
|
||||
expect(connected).toBe(0);
|
||||
for (const line of out.join("").split("\n").filter(Boolean)) expect(JSON.parse(line).jsonrpc).toBe("2.0");
|
||||
});
|
||||
|
||||
it("sign-in and a new session fail with auth_required and the guidance when OpenRig can't be reached", async () => {
|
||||
const { client } = connect({ connect: async () => ({ error: "OpenRig daemon is not running. Start it with: rig daemon start" }) });
|
||||
await client.request("initialize", { protocolVersion: 1, clientCapabilities: {} });
|
||||
for (const [method, params] of [["authenticate", { methodId: AUTH_METHOD_ID }], ["session/new", { cwd: "/tmp", mcpServers: [] }]] as const) {
|
||||
const error = await client.request(method, params).then(() => undefined, (e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(JsonRpcRequestError);
|
||||
expect((error as JsonRpcRequestError).error).toEqual({ code: -32000, message: "OpenRig daemon is not running. Start it with: rig daemon start" });
|
||||
}
|
||||
const unknown = await client.request("authenticate", { methodId: "api-key" }).then(() => undefined, (e: unknown) => e);
|
||||
expect((unknown as JsonRpcRequestError).error.code).toBe(-32602);
|
||||
});
|
||||
|
||||
it("a prompt becomes the caller's row to the receiver; handoffs and notes come back attributed; the resolution is the reply", async () => {
|
||||
const { client, updates } = connect();
|
||||
const sessionId = await open(client);
|
||||
const turn = client.request("session/prompt", prompt(sessionId, "Please fix the login bug\nIt fails on Safari."));
|
||||
|
||||
const task = await until(() => rowsTo(LEAD)[0], "the row to the lead");
|
||||
expect(task.source_session).toBe(CALLER);
|
||||
expect(task.state).toBe("pending");
|
||||
expect(task.body).toMatch(/^Please fix the login bug\nIt fails on Safari\.\n\n---\nSent by human-ada@external from Zed through rig acp serve \(session acps-/);
|
||||
expect(task.body).toContain("close it with a resolution written for human-ada@external");
|
||||
expect(JSON.parse(task.tags)).toEqual(["acp-serve", `acp-session:${sessionId}`, "acp-cwd:/tmp"]);
|
||||
|
||||
await move(LEAD, `/api/queue/${task.qitem_id}/claim`, {});
|
||||
await move(LEAD, `/api/queue/${task.qitem_id}/update`, { state: "in-progress", transitionNote: "reproduced on Safari 19" });
|
||||
await move(LEAD, `/api/queue/${task.qitem_id}/handoff`, { toSession: IMPL, transitionNote: "impl owns the auth code", nudge: false });
|
||||
const next = await until(() => rowsTo(IMPL)[0], "the impl row");
|
||||
await move(IMPL, `/api/queue/${next.qitem_id}/claim`, {});
|
||||
await move(IMPL, `/api/queue/${next.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Fixed in PR 123: the cookie lacked SameSite=None." });
|
||||
|
||||
expect(await turn).toEqual({ stopReason: "end_turn" });
|
||||
const calls = updates.filter((u) => u.sessionUpdate === "tool_call");
|
||||
expect(calls.map((u) => [u.toolCallId, u.title])).toEqual([
|
||||
[task.qitem_id, `Sent to ${LEAD} (${task.qitem_id})`],
|
||||
[next.qitem_id, `Handed to ${IMPL} (${next.qitem_id})`],
|
||||
]);
|
||||
const lastOf = (id: string) => updates.filter((u) => u.sessionUpdate === "tool_call_update" && u.toolCallId === id).at(-1)!;
|
||||
expect(lastOf(task.qitem_id).status).toBe("completed");
|
||||
const leadLines = ((lastOf(task.qitem_id).content as Array<{ content: { text: string } }>)[0]!.content.text);
|
||||
expect(leadLines).toMatch(/Z lead@team · in-progress: reproduced on Safari 19/);
|
||||
expect(lastOf(next.qitem_id).status).toBe("completed");
|
||||
expect(messages(updates).at(-1)).toBe(`${IMPL} closed ${next.qitem_id} done.\n\nFixed in PR 123: the cookie lacked SameSite=None.`);
|
||||
});
|
||||
|
||||
it("a handoff-and-complete passes the work on: the reply is the successor's result, not the first row's done", async () => {
|
||||
const { client, updates } = connect({}, "front@team");
|
||||
const sessionId = await open(client);
|
||||
const turn = client.request("session/prompt", prompt(sessionId, "Upgrade the parser"));
|
||||
const task = await until(() => rowsTo("front@team")[0], "the front row");
|
||||
await move("front@team", `/api/queue/${task.qitem_id}/claim`, {});
|
||||
await move("front@team", `/api/queue/${task.qitem_id}/handoff-and-complete`, { toSession: "back@team", transitionNote: "back owns the parser", nudge: false });
|
||||
expect(db.prepare("SELECT state, closure_reason FROM queue_items WHERE qitem_id = ?").get(task.qitem_id)).toEqual({ state: "done", closure_reason: "handed_off_to" });
|
||||
const next = await until(() => rowsTo("back@team")[0], "the back row");
|
||||
await move("back@team", `/api/queue/${next.qitem_id}/claim`, {});
|
||||
await move("back@team", `/api/queue/${next.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Parser upgraded to 3.2." });
|
||||
expect(await turn).toEqual({ stopReason: "end_turn" });
|
||||
expect(updates.filter((u) => u.sessionUpdate === "tool_call").map((u) => u.toolCallId)).toEqual([task.qitem_id, next.qitem_id]);
|
||||
expect(messages(updates).at(-1)).toBe(`back@team closed ${next.qitem_id} done.\n\nParser upgraded to 3.2.`);
|
||||
});
|
||||
|
||||
it("the reply names the seat that closed the row: not its owner by default, nor one that wrote a note after the close", async () => {
|
||||
// A slow poll, so the close and the later note both land before the agent reads the row again.
|
||||
const { client, updates } = connect({ pollMs: 500 }, "closer@team");
|
||||
const sessionId = await open(client);
|
||||
const turn = client.request("session/prompt", prompt(sessionId, "Ship the hotfix"));
|
||||
const task = await until(() => rowsTo("closer@team")[0], "the row");
|
||||
// The lead closes its report's row: the closer is neither the row's owner nor the newest writer.
|
||||
await move("closer@team", `/api/queue/${task.qitem_id}/claim`, {});
|
||||
await move("lead@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Hotfix shipped." });
|
||||
await move("watcher@team", `/api/queue/${task.qitem_id}/update`, { transitionNote: "seen, thanks" });
|
||||
const notes = db.prepare("SELECT actor_session FROM queue_transitions WHERE qitem_id = ? ORDER BY rowid").all(task.qitem_id) as Array<{ actor_session: string }>;
|
||||
expect(notes.at(-1)!.actor_session).toBe("watcher@team");
|
||||
expect(await turn).toEqual({ stopReason: "end_turn" });
|
||||
expect(messages(updates).at(-1)).toBe(`lead@team closed ${task.qitem_id} done.\n\nHotfix shipped.`);
|
||||
});
|
||||
|
||||
it("a create whose answer is lost but whose row landed carries on with that row", async () => {
|
||||
const lostAck = (q: TeamQueue): TeamQueue => ({ ...q, create: async (row) => { await q.create(row); throw new Error("socket hang up"); } });
|
||||
const { client, updates } = connect({}, "acked@team", lostAck);
|
||||
const sessionId = await open(client);
|
||||
const turn = client.request("session/prompt", prompt(sessionId, "Rotate the logs"));
|
||||
const task = await until(() => rowsTo("acked@team")[0], "the row");
|
||||
await move("acked@team", `/api/queue/${task.qitem_id}/claim`, {});
|
||||
await move("acked@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Rotated." });
|
||||
expect(await turn).toEqual({ stopReason: "end_turn" });
|
||||
expect(rowsTo("acked@team")).toHaveLength(1);
|
||||
expect(messages(updates).at(-1)).toBe(`acked@team closed ${task.qitem_id} done.\n\nRotated.`);
|
||||
});
|
||||
|
||||
it("a create that can't be confirmed either way is named as unknown, and the next prompt names it", async () => {
|
||||
let readbacks = 0;
|
||||
const blind = (q: TeamQueue): TeamQueue => ({
|
||||
...q,
|
||||
create: async (row) => { await q.create(row); throw new Error("socket hang up"); },
|
||||
find: async (id) => { readbacks += 1; if (readbacks === 1) throw new Error("connect ECONNREFUSED"); return q.find(id); },
|
||||
});
|
||||
const { client, updates } = connect({}, "unsure@team", blind);
|
||||
const sessionId = await open(client);
|
||||
const error = await client.request("session/prompt", prompt(sessionId, "Archive March")).then(() => undefined, (e: unknown) => e);
|
||||
const first = rowsTo("unsure@team")[0]!;
|
||||
expect((error as JsonRpcRequestError).error).toMatchObject({ code: -32603, message: `Couldn't confirm ${first.qitem_id}: socket hang up` });
|
||||
expect(messages(updates).at(-1)).toBe(`Sending ${first.qitem_id} to unsure@team got no answer (socket hang up), and it couldn't be read back: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${first.qitem_id}\` shows it if it landed.`);
|
||||
|
||||
const second = client.request("session/prompt", prompt(sessionId, "Archive March, please"));
|
||||
const followUp = await until(() => rowsTo("unsure@team")[1], "the follow-up row");
|
||||
expect(followUp.body).toContain(`Follows up on ${first.qitem_id} (pending).`);
|
||||
expect(messages(updates)).toContain(`${first.qitem_id} is still pending with unsure@team. Sending this as a follow-up.\n\n`);
|
||||
client.notify("session/cancel", { sessionId });
|
||||
expect(await second).toEqual({ stopReason: "cancelled" });
|
||||
});
|
||||
|
||||
it("an unanswered create that isn't found on the immediate readback stays unconfirmed, and is named when it lands late", async () => {
|
||||
// The request is still in flight when the agent reads back: it lands only after the readback found nothing.
|
||||
let late: (() => Promise<void>) | undefined;
|
||||
const slow = (q: TeamQueue): TeamQueue => ({ ...q, create: async (row) => {
|
||||
if (late) return q.create(row);
|
||||
late = () => q.create(row);
|
||||
throw new Error("socket hang up");
|
||||
} });
|
||||
const { client, updates } = connect({}, "late@team", slow);
|
||||
const sessionId = await open(client);
|
||||
const error = await client.request("session/prompt", prompt(sessionId, "Tag the release")).then(() => undefined, (e: unknown) => e);
|
||||
expect(rowsTo("late@team")).toHaveLength(0);
|
||||
const id = /Couldn't confirm (qitem-\S+):/.exec((error as JsonRpcRequestError).error.message)![1]!;
|
||||
expect(messages(updates).at(-1)).toBe(`Sending ${id} to late@team got no answer (socket hang up), and it wasn't found on the immediate readback: delivery is unconfirmed, so it may still reach the team. A new prompt in this session names it, and \`rig queue show ${id}\` shows it if it landed.`);
|
||||
|
||||
await late!();
|
||||
expect(rowsTo("late@team")[0]!.qitem_id).toBe(id);
|
||||
const second = client.request("session/prompt", prompt(sessionId, "Tag the release, please"));
|
||||
const followUp = await until(() => rowsTo("late@team")[1], "the follow-up row");
|
||||
expect(followUp.body).toContain(`Follows up on ${id} (pending).`);
|
||||
client.notify("session/cancel", { sessionId });
|
||||
expect(await second).toEqual({ stopReason: "cancelled" });
|
||||
});
|
||||
|
||||
it("a create the daemon answers with a refusal is said plainly, and nothing is kept as sent", async () => {
|
||||
// A seat whose rig has left the topology can't be sent work: the queue answers with a refusal.
|
||||
const { client, updates } = connect({}, "lead@gone-rig");
|
||||
const sessionId = await open(client);
|
||||
const error = await client.request("session/prompt", prompt(sessionId, "Anything")).then(() => undefined, (e: unknown) => e);
|
||||
expect((error as JsonRpcRequestError).error.code).toBe(-32603);
|
||||
expect((error as JsonRpcRequestError).error.message).toMatch(/^The team's queue refused the task: HTTP 4\d\d: /);
|
||||
expect(rowsTo("lead@gone-rig")).toHaveLength(0);
|
||||
expect(messages(updates)).toEqual([]);
|
||||
});
|
||||
|
||||
it("cancel stops waiting, never the work; the next prompt is a follow-up that names the open item", async () => {
|
||||
const { client, updates } = connect({}, "solo@team");
|
||||
const sessionId = await open(client);
|
||||
const first = client.request("session/prompt", prompt(sessionId, "Draft the release notes"));
|
||||
const row = await until(() => rowsTo("solo@team")[0], "the first row");
|
||||
client.notify("session/cancel", { sessionId });
|
||||
expect(await first).toEqual({ stopReason: "cancelled" });
|
||||
expect(messages(updates).at(-1)).toBe(`Stopped waiting. ${row.qitem_id} is still pending with solo@team: cancelling here doesn't stop the team's work. A new prompt in this session goes to the team as a follow-up and shows where this stands.`);
|
||||
expect(rowsTo("solo@team")[0]!.state).toBe("pending");
|
||||
|
||||
const second = client.request("session/prompt", prompt(sessionId, "Also mention the Safari fix"));
|
||||
const followUp = await until(() => rowsTo("solo@team")[1], "the follow-up row");
|
||||
expect(followUp.body).toContain(`Follows up on ${row.qitem_id} (pending).`);
|
||||
expect(messages(updates)).toContain(`${row.qitem_id} is still pending with solo@team. Sending this as a follow-up.\n\n`);
|
||||
await move("solo@team", `/api/queue/${followUp.qitem_id}/claim`, {});
|
||||
await move("solo@team", `/api/queue/${followUp.qitem_id}/update`, { state: "done", closureReason: "canceled", resolution: "Needs the first item first." });
|
||||
expect(await second).toEqual({ stopReason: "end_turn" });
|
||||
expect(messages(updates).at(-1)).toBe(`solo@team closed ${followUp.qitem_id} done (canceled).\n\nNeeds the first item first.`);
|
||||
});
|
||||
|
||||
it("with no receiver configured, a prompt sends nothing and says how to set one", async () => {
|
||||
const { client, updates } = connect({}, null);
|
||||
const sessionId = await open(client);
|
||||
const before = (db.prepare("SELECT count(*) AS n FROM queue_items").get() as { n: number }).n;
|
||||
expect(await client.request("session/prompt", prompt(sessionId, "anything"))).toEqual({ stopReason: "end_turn" });
|
||||
expect((db.prepare("SELECT count(*) AS n FROM queue_items").get() as { n: number }).n).toBe(before);
|
||||
expect(messages(updates).at(-1)).toContain("Add `--to <member@rig>`");
|
||||
});
|
||||
|
||||
it("a question tagged for this chat comes as a permission request and the choice is recorded as the caller's answer; an untagged one is only named", async () => {
|
||||
const { client, updates } = connect({}, "asker@team");
|
||||
const asked: Array<Record<string, any>> = [];
|
||||
client.onRequest("session/request_permission", async (params) => {
|
||||
asked.push(params as Record<string, any>);
|
||||
return { outcome: { outcome: "selected", optionId: "b" } };
|
||||
});
|
||||
const sessionId = await open(client);
|
||||
// Asked before this turn by a seat the work never reaches: not this turn's question.
|
||||
await move("bystander@team", "/api/queue/create", { qitemId: "qitem-unrelated-ask", destinationSession: CALLER, body: "unrelated", summary: "Unrelated", nudge: false,
|
||||
humanQuestions: [{ id: "q1", question: "Unrelated?", options: [{ id: "y", label: "Yes" }, { id: "n", label: "No" }] }] });
|
||||
const turn = client.request("session/prompt", prompt(sessionId, "Pick a database for the cache"));
|
||||
const task = await until(() => rowsTo("asker@team")[0], "the work row");
|
||||
await move("asker@team", `/api/queue/${task.qitem_id}/claim`, {});
|
||||
// Untagged, from the seat working on it: it could be another chat's, so it's named here, never asked.
|
||||
await move("asker@team", "/api/queue/create", { qitemId: "qitem-ask-untagged", destinationSession: CALLER, body: "Which region?", summary: "Region?", nudge: false,
|
||||
humanQuestions: [{ id: "q1", question: "Which region?", options: [{ id: "eu", label: "EU" }, { id: "us", label: "US" }] }] });
|
||||
await move("asker@team", "/api/queue/create", { qitemId: "qitem-ask-db", destinationSession: CALLER, body: "Redis is faster; SQLite is already here.", summary: "Which store?", nudge: false,
|
||||
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which store?", options: [{ id: "a", label: "Redis" }, { id: "b", label: "SQLite", recommended: true }] }] });
|
||||
const answered = await until(() => {
|
||||
const row = db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-db'").get() as { human_answers: string | null };
|
||||
return row.human_answers ? JSON.parse(row.human_answers) : undefined;
|
||||
}, "the recorded answer");
|
||||
expect(Object.keys(answered)).toEqual(["q1"]);
|
||||
expect(JSON.stringify(answered)).toContain("b");
|
||||
await move("asker@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "SQLite, as you chose." });
|
||||
expect(await turn).toEqual({ stopReason: "end_turn" });
|
||||
|
||||
expect(asked).toHaveLength(1);
|
||||
expect(asked[0]!.toolCall).toMatchObject({ toolCallId: "qitem-ask-db#q1", title: "asker@team asks: Which store?" });
|
||||
expect(asked[0]!.options).toEqual([{ optionId: "a", name: "Redis", kind: "allow_once" }, { optionId: "b", name: "SQLite (recommended)", kind: "allow_once" }]);
|
||||
const result = updates.filter((u) => u.toolCallId === "qitem-ask-db#q1").at(-1)!;
|
||||
expect(result.status).toBe("completed");
|
||||
expect((result.content as Array<{ content: { text: string } }>)[0]!.content.text).toBe("You answered: SQLite. asker@team gets it as your answer.");
|
||||
// "asker@team gets it as your answer": the answer path's reply row, from the caller to the seat that asked.
|
||||
const replies = db.prepare("SELECT body FROM queue_items WHERE source_session = ? AND destination_session = 'asker@team' AND reply_to = 'qitem-ask-db'").all(CALLER) as Array<{ body: string }>;
|
||||
expect(replies).toHaveLength(1);
|
||||
expect(replies[0]!.body).toContain("SQLite");
|
||||
const unrelated = db.prepare("SELECT state, human_answers FROM queue_items WHERE qitem_id = 'qitem-unrelated-ask'").get() as { state: string; human_answers: string | null };
|
||||
expect(unrelated).toEqual({ state: "pending", human_answers: null });
|
||||
expect(messages(updates)).toContain("\n\nasker@team, which this work reached, has a question for you that isn't tagged for this chat (qitem-ask-untagged: Region?). Answer it in the OpenRig TUI or Slack.\n\n");
|
||||
expect(db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-untagged'").get()).toEqual({ human_answers: null });
|
||||
expect(task.body).toContain(`--tags acp-session:${sessionId}`);
|
||||
});
|
||||
|
||||
it("a question tagged for one chat is asked in that chat only, though another chat's work reached the same seat", async () => {
|
||||
const chatA = connect({}, "shared@team");
|
||||
const chatB = connect({}, "shared@team");
|
||||
const askedIn: Record<string, string[]> = { a: [], b: [] };
|
||||
chatA.client.onRequest("session/request_permission", async (params) => { askedIn.a.push((params as any).toolCall.toolCallId); return { outcome: { outcome: "cancelled" } }; });
|
||||
chatB.client.onRequest("session/request_permission", async (params) => { askedIn.b.push((params as any).toolCall.toolCallId); return { outcome: { outcome: "selected", optionId: "y" } }; });
|
||||
const a = await open(chatA.client);
|
||||
const b = await open(chatB.client);
|
||||
const turnA = chatA.client.request("session/prompt", prompt(a, "Chat A's task"));
|
||||
const turnB = chatB.client.request("session/prompt", prompt(b, "Chat B's task"));
|
||||
const [rowA, rowB] = await until(() => { const rows = rowsTo("shared@team"); return rows.length === 2 ? rows : undefined; }, "both rows");
|
||||
await move("shared@team", "/api/queue/create", { qitemId: "qitem-ask-for-b", destinationSession: CALLER, body: "For B only", summary: "B?", nudge: false,
|
||||
tags: [`acp-session:${b}`], humanQuestions: [{ id: "q1", question: "Ship B?", options: [{ id: "y", label: "Yes" }, { id: "n", label: "No" }] }] });
|
||||
await until(() => (askedIn.b.length > 0 ? true : undefined), "B's question");
|
||||
for (const row of [rowA!, rowB!]) {
|
||||
await move("shared@team", `/api/queue/${row.qitem_id}/claim`, {});
|
||||
await move("shared@team", `/api/queue/${row.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Done." });
|
||||
}
|
||||
await Promise.all([turnA, turnB]);
|
||||
expect(askedIn).toEqual({ a: [], b: ["qitem-ask-for-b#q1"] });
|
||||
expect(chatA.updates.some((u) => String(u.toolCallId ?? "").startsWith("qitem-ask-for-b"))).toBe(false);
|
||||
expect(messages(chatA.updates).some((m) => m.includes("qitem-ask-for-b"))).toBe(false);
|
||||
});
|
||||
|
||||
it("cancel waits for the app to settle the question it's showing, and nothing about it follows the cancelled answer", async () => {
|
||||
const { client, updates, inbound } = connect({}, "asker3@team");
|
||||
let answerCancelled!: () => void;
|
||||
client.onRequest("session/request_permission", () => new Promise((resolve) => {
|
||||
answerCancelled = () => setTimeout(() => resolve({ outcome: { outcome: "cancelled" } }), 50);
|
||||
}));
|
||||
const sessionId = await open(client);
|
||||
const turn = client.request("session/prompt", prompt(sessionId, "Choose a theme"));
|
||||
await until(() => rowsTo("asker3@team")[0], "the work row");
|
||||
await move("asker3@team", "/api/queue/create", { qitemId: "qitem-ask-theme", destinationSession: CALLER, body: "Light or dark", summary: "Theme?", nudge: false,
|
||||
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which theme?", options: [{ id: "l", label: "Light" }, { id: "d", label: "Dark" }] }] });
|
||||
await until(() => (answerCancelled ? true : undefined), "the question on screen");
|
||||
// As ACP has it: the app sends the cancel, then answers its open question as cancelled.
|
||||
client.notify("session/cancel", { sessionId });
|
||||
answerCancelled();
|
||||
expect(await turn).toEqual({ stopReason: "cancelled" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const end = inbound.findIndex((p) => p.result?.stopReason === "cancelled");
|
||||
const about = inbound.map((p, i) => ({ p, i })).filter(({ p }) => p.params?.update?.toolCallId === "qitem-ask-theme#q1");
|
||||
expect(about.at(-1)!.i).toBeLessThan(end);
|
||||
expect(about.at(-1)!.p.params.update.content[0].content.text).toBe("Not answered here. The question is still open in the OpenRig TUI and Slack.");
|
||||
expect(updates.filter((u) => u.toolCallId === "qitem-ask-theme#q1")).toHaveLength(2);
|
||||
expect(db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-theme'").get()).toEqual({ human_answers: null });
|
||||
});
|
||||
|
||||
it("when the work closes with a question still on screen, the app is told before the turn ends, and a later reply answers nothing", async () => {
|
||||
const { client, inbound } = connect({}, "asker4@team");
|
||||
let answerLate!: () => void;
|
||||
client.onRequest("session/request_permission", () => new Promise((resolve) => { answerLate = () => resolve({ outcome: { outcome: "selected", optionId: "x" } }); }));
|
||||
const sessionId = await open(client);
|
||||
const turn = client.request("session/prompt", prompt(sessionId, "Plan the offsite"));
|
||||
const task = await until(() => rowsTo("asker4@team")[0], "the work row");
|
||||
await move("asker4@team", "/api/queue/create", { qitemId: "qitem-ask-venue", destinationSession: CALLER, body: "Two venues", summary: "Venue?", nudge: false,
|
||||
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which venue?", options: [{ id: "x", label: "Lisbon" }, { id: "z", label: "Oslo" }] }] });
|
||||
await until(() => (answerLate ? true : undefined), "the question on screen");
|
||||
await move("asker4@team", `/api/queue/${task.qitem_id}/claim`, {});
|
||||
await move("asker4@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Booked without it." });
|
||||
expect(await turn).toEqual({ stopReason: "end_turn" });
|
||||
answerLate();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const end = inbound.findIndex((p) => p.result?.stopReason === "end_turn");
|
||||
const about = inbound.map((p, i) => ({ p, i })).filter(({ p }) => p.params?.update?.toolCallId === "qitem-ask-venue#q1");
|
||||
expect(about.at(-1)!.i).toBeLessThan(end);
|
||||
expect(about.at(-1)!.p.params.update.content[0].content.text).toBe("The work closed before this was answered here. The question is still open in the OpenRig TUI and Slack.");
|
||||
expect(db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-venue'").get()).toEqual({ human_answers: null });
|
||||
});
|
||||
|
||||
it("a question the caller dismisses stays open and unanswered", async () => {
|
||||
const { client, updates } = connect({}, "asker2@team");
|
||||
client.onRequest("session/request_permission", async () => ({ outcome: { outcome: "cancelled" } }));
|
||||
const sessionId = await open(client);
|
||||
const turn = client.request("session/prompt", prompt(sessionId, "Rename the service"));
|
||||
const task = await until(() => rowsTo("asker2@team")[0], "the work row");
|
||||
await move("asker2@team", "/api/queue/create", { qitemId: "qitem-ask-name", destinationSession: CALLER, body: "Two names", summary: "Name?", nudge: false,
|
||||
tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which name?", options: [{ id: "a", label: "Atlas" }, { id: "b", label: "Beacon" }] }] });
|
||||
await until(() => updates.find((u) => u.toolCallId === "qitem-ask-name#q1" && u.sessionUpdate === "tool_call_update"), "the dismissal");
|
||||
await move("asker2@team", `/api/queue/${task.qitem_id}/claim`, {});
|
||||
await move("asker2@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Kept the old name." });
|
||||
expect(await turn).toEqual({ stopReason: "end_turn" });
|
||||
const dismissed = updates.filter((u) => u.toolCallId === "qitem-ask-name#q1").at(-1)!;
|
||||
expect(dismissed.status).toBeUndefined();
|
||||
expect((dismissed.content as Array<{ content: { text: string } }>)[0]!.content.text).toBe("Not answered here. The question is still open in the OpenRig TUI and Slack.");
|
||||
expect(db.prepare("SELECT state, human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-name'").get()).toEqual({ state: "pending", human_answers: null });
|
||||
});
|
||||
|
||||
it("a session is listed and reopened from its rows: the prompt, its work and the reply replay, and a follow-up continues it", async () => {
|
||||
const first = connect({}, "keeper@team");
|
||||
const sessionId = await open(first.client);
|
||||
const turn = first.client.request("session/prompt", prompt(sessionId, "Summarize yesterday's incidents"));
|
||||
const task = await until(() => rowsTo("keeper@team")[0], "the row");
|
||||
await move("keeper@team", `/api/queue/${task.qitem_id}/claim`, {});
|
||||
await move("keeper@team", `/api/queue/${task.qitem_id}/update`, { state: "done", closureReason: "no-follow-on", resolution: "Two incidents, both closed." });
|
||||
await turn;
|
||||
|
||||
// A new process, as after the app restarts.
|
||||
const second = connect({}, "keeper@team");
|
||||
await second.client.request("initialize", { protocolVersion: 1, clientCapabilities: {} });
|
||||
const listed = (await second.client.request("session/list", {})) as { sessions: Array<Record<string, unknown>> };
|
||||
expect(listed.sessions.find((s) => s.sessionId === sessionId)).toMatchObject({ cwd: "/tmp", title: "Summarize yesterday's incidents" });
|
||||
expect(((await second.client.request("session/list", { cwd: "/elsewhere" })) as { sessions: unknown[] }).sessions.find((s) => (s as { sessionId: string }).sessionId === sessionId)).toBeUndefined();
|
||||
expect(await second.client.request("session/load", { sessionId, cwd: "/tmp", mcpServers: [] })).toEqual({});
|
||||
expect(second.updates.map((u) => u.sessionUpdate)).toEqual(["user_message_chunk", "tool_call", "tool_call_update", "agent_message_chunk"]);
|
||||
expect((second.updates[0]!.content as { text: string }).text).toBe("Summarize yesterday's incidents");
|
||||
expect(messages(second.updates).at(-1)).toBe(`keeper@team closed ${task.qitem_id} done.\n\nTwo incidents, both closed.`);
|
||||
|
||||
const followUp = second.client.request("session/prompt", prompt(sessionId, "And the week before?"));
|
||||
const next = await until(() => rowsTo("keeper@team")[1], "the follow-up row");
|
||||
expect(next.body).toContain(`Follows up on ${task.qitem_id} (done).`);
|
||||
expect(JSON.parse(next.tags)).toContain(`acp-session:${sessionId}`);
|
||||
second.client.notify("session/cancel", { sessionId });
|
||||
expect(await followUp).toEqual({ stopReason: "cancelled" });
|
||||
const missing = await second.client.request("session/load", { sessionId: "acps-nope", cwd: "/tmp", mcpServers: [] }).then(() => undefined, (e: unknown) => e);
|
||||
expect((missing as JsonRpcRequestError).error.code).toBe(-32602);
|
||||
});
|
||||
|
||||
it("an unknown method gets JSON-RPC's method-not-found", async () => {
|
||||
const { client } = connect();
|
||||
const error = await client.request("session/fork", { sessionId: "x" }).then(() => undefined, (e: unknown) => e);
|
||||
expect((error as JsonRpcRequestError).error).toEqual({ code: -32601, message: "Method not found: session/fork" });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import ts from "typescript";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import module from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { resolveBinEntry, resolveNodeReexecBinary, run } from "../src/bin-wrapper.js";
|
||||
@@ -15,6 +18,40 @@ describe("bin-wrapper", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(!module.getCompileCacheDir || !module.enableCompileCache).each([
|
||||
{ args: [], interactive: false, explicitCache: false, expected: false },
|
||||
{ args: ["--version"], interactive: false, explicitCache: false, expected: true },
|
||||
{ args: [], interactive: false, explicitCache: true, expected: true },
|
||||
{ args: [], interactive: true, explicitCache: false, expected: true },
|
||||
])("compile cache for args=$args, interactive=$interactive, explicitCache=$explicitCache", ({ args, interactive, explicitCache, expected }) => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-cache-"));
|
||||
const dist = path.join(tempDir, "dist");
|
||||
fs.mkdirSync(dist);
|
||||
fs.writeFileSync(path.join(tempDir, "package.json"), '{ "type": "module" }');
|
||||
const wrapper = path.join(dist, "bin-wrapper.js");
|
||||
const source = fs.readFileSync(new URL("../src/bin-wrapper.ts", import.meta.url), "utf8");
|
||||
const { outputText } = ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
|
||||
});
|
||||
fs.writeFileSync(wrapper, outputText);
|
||||
const report = `import { getCompileCacheDir } from "node:module";
|
||||
export async function runProgram() { console.log(JSON.stringify({ enabled: !!getCompileCacheDir() })); }
|
||||
`;
|
||||
fs.writeFileSync(path.join(dist, "index.js"), report + "export function createProgram() { return {}; }\n");
|
||||
fs.writeFileSync(path.join(dist, "lazy-program.js"), report + "export async function createLazyProgram() { return {}; }\n");
|
||||
const env = { ...process.env, TMPDIR: tempDir };
|
||||
delete env.NODE_DISABLE_COMPILE_CACHE;
|
||||
delete env.NODE_COMPILE_CACHE;
|
||||
if (explicitCache) env.NODE_COMPILE_CACHE = path.join(tempDir, "explicit-cache");
|
||||
// Set stream flags before the wrapper loads; front-door.test.ts covers a real PTY launch.
|
||||
const nodeArgs = interactive
|
||||
? ["--import", "data:text/javascript,process.stdin.isTTY=true;process.stdout.isTTY=true"]
|
||||
: [];
|
||||
const result = spawnSync(process.execPath, [...nodeArgs, wrapper, ...args], { env, encoding: "utf8", timeout: 5000 });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ enabled: expected });
|
||||
});
|
||||
|
||||
it("resolves the real dist entry from a symlinked wrapper path", () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-bin-wrapper-"));
|
||||
const wrapperSource = fileURLToPath(new URL("../src/bin-wrapper.ts", import.meta.url));
|
||||
|
||||
@@ -5,10 +5,10 @@ import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { configCommand } from "../src/commands/config.js";
|
||||
import { ENV_MAP } from "../src/config-store.js";
|
||||
|
||||
let dir: string;
|
||||
let configPath: string;
|
||||
let savedPort: string | undefined;
|
||||
|
||||
async function run(args: string[]): Promise<string> {
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
@@ -25,13 +25,14 @@ describe("rig config get --with-source", () => {
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "config-get-with-source-"));
|
||||
configPath = path.join(dir, "config.json");
|
||||
savedPort = process.env["OPENRIG_PORT"];
|
||||
delete process.env["OPENRIG_PORT"];
|
||||
for (const { primary, legacy } of Object.values(ENV_MAP)) {
|
||||
vi.stubEnv(primary, undefined);
|
||||
if (legacy) vi.stubEnv(legacy, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
|
||||
else process.env["OPENRIG_PORT"] = savedPort;
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -72,4 +73,55 @@ describe("rig config get --with-source", () => {
|
||||
const json = JSON.parse(await run(["get", "daemon.port", "--json"]));
|
||||
expect(json).toMatchObject({ value: 7555, source: "file" });
|
||||
});
|
||||
|
||||
it("reports the active environment override when setting or resetting a file value", async () => {
|
||||
process.env["OPENRIG_PORT"] = "7666";
|
||||
expect(await run(["set", "daemon.port", "7555"])).toContain("7666 (source: env; file override saved but masked)");
|
||||
expect(JSON.parse(fs.readFileSync(configPath, "utf8")).daemon.port).toBe(7555);
|
||||
expect(await run(["reset", "daemon.port"])).toContain("effective value 7666 (source: env; default: 7433)");
|
||||
expect(await run(["reset"])).toContain("environment overrides them: daemon.port.");
|
||||
});
|
||||
|
||||
it("retains the default reset message when no environment override remains", async () => {
|
||||
expect(await run(["reset"])).toBe("Config reset to defaults.");
|
||||
});
|
||||
|
||||
it("reports a successful reset when reading effective configuration fails", async () => {
|
||||
fs.writeFileSync(configPath, JSON.stringify({ daemon: { port: 7555 } }));
|
||||
vi.stubEnv("OPENRIG_CONTEXT_PACKS_ROOT", "/old-context");
|
||||
const previousExitCode = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
expect(await run(["reset"])).toBe("Config reset to defaults.");
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
expect(errorSpy.mock.calls.flat().join(" ")).toContain("OPENRIG_CONTEXT_PACKS_ROOT was removed");
|
||||
expect(process.exitCode).toBe(0);
|
||||
} finally {
|
||||
process.exitCode = previousExitCode;
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not promise a masked boot setting will take effect on restart", async () => {
|
||||
process.env["OPENRIG_PORT"] = "7666";
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
await configCommand(configPath, { probeDaemonRunning: async () => true })
|
||||
.parseAsync(["node", "rig", "set", "daemon.port", "7555"]);
|
||||
expect(errorSpy.mock.calls.flat().join(" ")).toContain("remains masked after restart");
|
||||
} finally { errorSpy.mockRestore(); }
|
||||
});
|
||||
|
||||
it("distinguishes the saved threshold from the default used after an invalid environment pair", async () => {
|
||||
vi.stubEnv("OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT", "98");
|
||||
vi.stubEnv("OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT", undefined);
|
||||
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
try {
|
||||
expect(await run(["set", "health.context_pressure.critical_percent", "97"]))
|
||||
.toContain("file override saved; effective value 99 (source: default)");
|
||||
expect(JSON.parse(fs.readFileSync(configPath, "utf8")).health.contextPressure.criticalPercent).toBe(97);
|
||||
expect(stderr).toHaveBeenCalled();
|
||||
} finally { stderr.mockRestore(); }
|
||||
});
|
||||
});
|
||||
@@ -202,6 +202,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"usage.pi_openrouter_charge",
|
||||
"transport.verify_input_box",
|
||||
"transport.local_user_guard",
|
||||
"transport.draft_delivery",
|
||||
// Slice 22 (OPR.0.7.2.22) — typed decisions, off by default.
|
||||
"decisions.enabled",
|
||||
"decisions.consumers",
|
||||
@@ -329,11 +330,11 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
it("transport.verify_input_box defaults off, resolves its env twin, and appears in the resolved config", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
expect(store.resolveWithSource("transport.verify_input_box")).toMatchObject({ value: false, source: "default", defaultValue: false });
|
||||
expect(store.resolve().transport).toEqual({ verifyInputBox: false, localUserGuard: false });
|
||||
expect(store.resolve().transport).toEqual({ verifyInputBox: false, localUserGuard: false, draftDelivery: "deliver" });
|
||||
try {
|
||||
process.env.OPENRIG_TRANSPORT_VERIFY_INPUT_BOX = "1";
|
||||
expect(store.resolveWithSource("transport.verify_input_box")).toMatchObject({ value: true, source: "env" });
|
||||
expect(store.resolve().transport).toEqual({ verifyInputBox: true, localUserGuard: false });
|
||||
expect(store.resolve().transport).toEqual({ verifyInputBox: true, localUserGuard: false, draftDelivery: "deliver" });
|
||||
} finally {
|
||||
delete process.env.OPENRIG_TRANSPORT_VERIFY_INPUT_BOX;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -90,6 +90,15 @@ describe("rig provider CLI grammar (daemon-backed)", () => {
|
||||
expect(seen.length).toBe(before);
|
||||
});
|
||||
|
||||
it.each([false, true])("preflights and submits a Pi account change (clear=%s)", async clear => {
|
||||
const before = seen.length;
|
||||
const result = await run(["switch", "--seat", "pi-seat@rig", ...(clear ? ["--clear-account-home"] : ["--to-account", "work", "--account-home", "/accounts/pi"]), "--reason", "Pi budget", "--json"], runningDeps(port));
|
||||
expect(result.exitCode ?? 0).toBe(0);
|
||||
const calls = seen.slice(before);
|
||||
expect(JSON.parse(calls.find(c => c.url.startsWith("/api/seat/set-runtime/"))!.body)).toMatchObject({ runtime: "pi", dryRun: true });
|
||||
expect(JSON.parse(calls.find(c => c.url === "/api/provider/switch")!.body)).toMatchObject(clear ? { clearAccountHome: true } : { accountHome: "/accounts/pi" });
|
||||
});
|
||||
|
||||
it("switches to a selected account home without sending credential values", async () => {
|
||||
const result = await run(["switch", "--seat", "dev-a@rig", "--to-account", "api-account", "--account-home", "/accounts/api",
|
||||
"--reason", "Plan limit", "--model", "gpt-5.5", "--json"], runningDeps(port));
|
||||
@@ -195,7 +204,7 @@ describe("rig provider CLI grammar (daemon-backed)", () => {
|
||||
seen.push({ url: req.url ?? "", method: req.method ?? "", body });
|
||||
const send = (code: number, obj: unknown) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); };
|
||||
const path = (req.url ?? "").split("?")[0];
|
||||
if (path?.startsWith("/api/seat/status/")) return send(200, { runtime: "codex" });
|
||||
if (path?.startsWith("/api/seat/status/")) return send(200, { runtime: path.includes("pi-seat") ? "pi" : "codex" });
|
||||
if (path?.startsWith("/api/seat/set-runtime/")) {
|
||||
const request = JSON.parse(body);
|
||||
if (path.includes("old-daemon")) return send(200, { ok: true, dryRun: true });
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use strict";
|
||||
|
||||
// Command families used for rig lifecycle, substrate inspection, remote operations and upgrades.
|
||||
// These are tool allowances, not containment: node/npm/python/ssh can themselves run arbitrary code.
|
||||
const KERNEL_CLAUDE_ALLOW = [
|
||||
"Skill", "Read", "Edit", "Write", "Glob", "Grep", "WebFetch",
|
||||
...["rig", "tmux", "node", "npm", "git", "ssh", "scp", "rsync", "curl",
|
||||
"uname", "ps", "pgrep", "lsof", "df", "du", "ls", "cat", "head", "tail", "rg", "grep",
|
||||
"find", "stat", "date", "sleep", "mkdir", "cp", "mv", "chmod", "tar", "shasum",
|
||||
"cd", "pwd", "echo", "printf", "wc", "sort", "python", "python3"]
|
||||
.map(command => `Bash(${command}:*)`),
|
||||
"Bash(command -v:*)",
|
||||
// Read-only provider login checks, so the operator can check a chosen team's provider without a prompt.
|
||||
"Bash(claude auth status:*)", "Bash(codex login status:*)",
|
||||
"Read(~/**)",
|
||||
];
|
||||
|
||||
// Team defaults are command allowances, not containment. A project's test runner can run code.
|
||||
// Native deny/ask rules remain in force. The session hook owns generated lifecycle
|
||||
// asks so that a help invocation is not caught by an unconditional ask prefix.
|
||||
// Team agents run most rig commands without a prompt. The commands that start, stop or remove teams or
|
||||
// agents ask, and so do six kept for a person: import (its --instantiate mode launches nodes, and a
|
||||
// prefix can't tell its modes apart), seat set-permissions and policy apply under both spellings (an
|
||||
// explicit permission choice stays with a person), setup (its --policy option records one, and a prefix
|
||||
// can't tell that from ordinary setup), and compact (it replaces a peer's working context). Other rig
|
||||
// commands (archive, attach, config set, seat set-model and the like) fall under the Bash(rig:*) allowance.
|
||||
const TEAM_CLAUDE_ALLOW = [
|
||||
"Skill", "Read(./**)", "Glob", "Grep", "Bash(rig:*)",
|
||||
...["pwd", "ls", "cat", "head", "tail", "rg", "grep", "find"].map(command => `Bash(${command}:*)`),
|
||||
...["npm test", "npm run test", "pnpm test", "pnpm run test", "yarn test", "yarn run test",
|
||||
"bun test", "bun run test", "node --test", "npx vitest", "npx jest", "pytest", "python -m pytest",
|
||||
"python3 -m pytest", "go test", "cargo test", "make test"].map(command => `Bash(${command}:*)`),
|
||||
];
|
||||
const TEAM_CLAUDE_ASK = [
|
||||
"up", "down", "start", "launch", "restore", "fork", "bootstrap", "create", "add", "remove",
|
||||
"grow", "shrink", "expand", "destroy", "handover", "env down",
|
||||
"daemon start", "daemon stop", "bundle install", "seat launch", "seat stop", "seat handover", "seat restart",
|
||||
"import", "seat set-permissions", "policy apply", "policy permissions apply", "setup", "compact",
|
||||
].map(command => `Bash(rig ${command}:*)`);
|
||||
|
||||
module.exports = { KERNEL_CLAUDE_ALLOW, TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK };
|
||||
@@ -0,0 +1,3 @@
|
||||
export const KERNEL_CLAUDE_ALLOW: string[];
|
||||
export const TEAM_CLAUDE_ALLOW: string[];
|
||||
export const TEAM_CLAUDE_ASK: string[];
|
||||
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
// Session-only Claude PreToolUse helper. The launcher supplies the kernel or team
|
||||
// lists; this file never reads or writes a person's permission settings.
|
||||
// Session-only Claude PreToolUse helper. The launcher selects the kernel or team
|
||||
// policy; this file never reads or writes a person's permission settings.
|
||||
// This is command convenience, not containment (test runners can execute code).
|
||||
const { basename, isAbsolute } = require("node:path");
|
||||
|
||||
@@ -249,7 +249,17 @@ if (require.main === module) {
|
||||
try {
|
||||
const input = JSON.parse(raw);
|
||||
if (input.tool_name !== "Bash" || typeof input.tool_input?.command !== "string") return;
|
||||
const policy = JSON.parse(Buffer.from(process.argv[2], "base64").toString("utf8"));
|
||||
const kind = process.argv[2];
|
||||
let policy;
|
||||
if (kind === "kernel" || kind === "team") {
|
||||
// Each invocation reads the installed lists; native inline allowances remain as launched.
|
||||
const { KERNEL_CLAUDE_ALLOW, TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK } = require("./claude-permission-policies.cjs");
|
||||
policy = kind === "kernel" ? { allow: KERNEL_CLAUDE_ALLOW, ask: [] }
|
||||
: { allow: TEAM_CLAUDE_ALLOW, ask: TEAM_CLAUDE_ASK };
|
||||
} else {
|
||||
// Existing running sessions still carry their encoded launch policy.
|
||||
policy = JSON.parse(Buffer.from(kind, "base64").toString("utf8"));
|
||||
}
|
||||
const decision = decide(input.tool_input.command, policy);
|
||||
if (decision) process.stdout.write(JSON.stringify({ hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse", permissionDecision: decision,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Pi uses proper-lockfile with realpath:false. Redirect only the managed auth
|
||||
// link's lock operations, so seat aliases and the account owner use one lock.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { syncBuiltinESMExports } = require('node:module');
|
||||
// The runner injects this exact option. Consume only our preload before Pi can
|
||||
// launch tools, preserving any other Node options supplied by its wrapper.
|
||||
const preload = `--require=${JSON.stringify(__filename)}`;
|
||||
if (process.env.NODE_OPTIONS) {
|
||||
const remaining = process.env.NODE_OPTIONS.replace(preload, '').trim();
|
||||
if (remaining) process.env.NODE_OPTIONS = remaining;
|
||||
else delete process.env.NODE_OPTIONS;
|
||||
}
|
||||
let localLock, sharedLock;
|
||||
try {
|
||||
const agentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
if (!agentDir) return;
|
||||
const auth = path.join(agentDir, 'auth.json');
|
||||
const account = fs.readFileSync(path.join(agentDir, '.openrig-account'), 'utf8');
|
||||
const source = path.resolve(path.dirname(auth), fs.readlinkSync(auth));
|
||||
if (source !== path.resolve(account, 'auth.json')) return;
|
||||
localLock = path.resolve(auth + '.lock');
|
||||
// Match direct Pi's realpath:false pathname, even when the source is a link.
|
||||
sharedLock = source + '.lock';
|
||||
} catch {
|
||||
// Preparation validates selected credentials. Unrelated processes are untouched.
|
||||
return;
|
||||
}
|
||||
/** Map only the managed auth lock to the shared target; leave all other filesystem paths unchanged. */
|
||||
function lockPath(value) {
|
||||
return typeof value === 'string' && path.resolve(value) === localLock ? sharedLock : value;
|
||||
}
|
||||
for (const name of ['mkdir', 'rmdir', 'stat', 'utimes']) {
|
||||
for (const method of [name, name + 'Sync']) {
|
||||
const original = fs[method];
|
||||
fs[method] = function (file, ...args) { return original.call(this, lockPath(file), ...args); };
|
||||
}
|
||||
}
|
||||
syncBuiltinESMExports();
|
||||
@@ -115,6 +115,7 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/tar": "^6.1.13",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
|
||||
@@ -7,43 +7,8 @@ import { shellQuote } from "./shell-quote.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
// Command families used for rig lifecycle, substrate inspection, remote operations and upgrades.
|
||||
// These are tool allowances, not containment: node/npm/python/ssh can themselves run arbitrary code.
|
||||
export const KERNEL_CLAUDE_ALLOW = [
|
||||
"Skill", "Read", "Edit", "Write", "Glob", "Grep", "WebFetch",
|
||||
...["rig", "tmux", "node", "npm", "git", "ssh", "scp", "rsync", "curl",
|
||||
"uname", "ps", "pgrep", "lsof", "df", "du", "ls", "cat", "head", "tail", "rg", "grep",
|
||||
"find", "stat", "date", "sleep", "mkdir", "cp", "mv", "chmod", "tar", "shasum",
|
||||
"cd", "pwd", "echo", "printf", "wc", "sort", "python", "python3"]
|
||||
.map(command => `Bash(${command}:*)`),
|
||||
"Bash(command -v:*)",
|
||||
// Read-only provider login checks, so the operator can check a chosen team's provider without a prompt.
|
||||
"Bash(claude auth status:*)", "Bash(codex login status:*)",
|
||||
"Read(~/**)",
|
||||
];
|
||||
|
||||
// Team defaults are command allowances, not containment. A project's test runner can run code.
|
||||
// Native deny/ask rules remain in force. The session hook owns generated lifecycle
|
||||
// asks so that a help invocation is not caught by an unconditional ask prefix.
|
||||
// Team agents run most rig commands without a prompt. The commands that start, stop or remove teams or
|
||||
// agents ask, and so do six kept for a person: import (its --instantiate mode launches nodes, and a
|
||||
// prefix can't tell its modes apart), seat set-permissions and policy apply under both spellings (an
|
||||
// explicit permission choice stays with a person), setup (its --policy option records one, and a prefix
|
||||
// can't tell that from ordinary setup), and compact (it replaces a peer's working context). Other rig
|
||||
// commands (archive, attach, config set, seat set-model and the like) fall under the Bash(rig:*) allowance.
|
||||
export const TEAM_CLAUDE_ALLOW = [
|
||||
"Skill", "Read(./**)", "Glob", "Grep", "Bash(rig:*)",
|
||||
...["pwd", "ls", "cat", "head", "tail", "rg", "grep", "find"].map(command => `Bash(${command}:*)`),
|
||||
...["npm test", "npm run test", "pnpm test", "pnpm run test", "yarn test", "yarn run test",
|
||||
"bun test", "bun run test", "node --test", "npx vitest", "npx jest", "pytest", "python -m pytest",
|
||||
"python3 -m pytest", "go test", "cargo test", "make test"].map(command => `Bash(${command}:*)`),
|
||||
];
|
||||
export const TEAM_CLAUDE_ASK = [
|
||||
"up", "down", "start", "launch", "restore", "fork", "bootstrap", "create", "add", "remove",
|
||||
"grow", "shrink", "expand", "destroy", "handover", "env down",
|
||||
"daemon start", "daemon stop", "bundle install", "seat launch", "seat stop", "seat handover", "seat restart",
|
||||
"import", "seat set-permissions", "policy apply", "policy permissions apply", "setup", "compact",
|
||||
].map(command => `Bash(rig ${command}:*)`);
|
||||
import { KERNEL_CLAUDE_ALLOW, TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK } from "../../assets/claude-permission-policies.cjs";
|
||||
export { KERNEL_CLAUDE_ALLOW, TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK };
|
||||
|
||||
type LaunchChoice = Pick<NodeBinding, "kernelAuthority" | "teamPermissionDefault" | "nonInterruptive" | "launchPosture" | "permissionMode" | "claudePermissions"> & {
|
||||
/** Whether OpenRig's managed compaction acts on this Claude launch; read from the policy when absent. */
|
||||
@@ -72,12 +37,14 @@ function withIdleCompactionOff(args: string[], on: boolean): string[] {
|
||||
return [...args.slice(0, at + 1), JSON.stringify({ ...settings, idleCompaction: false }), ...args.slice(at + 2)];
|
||||
}
|
||||
|
||||
function claudeCommandSettings(allow: string[], ask: string[] = []): string[] {
|
||||
/** Selects a built-in hook policy; keeps native allow/ask settings when the optional hook is absent. */
|
||||
function claudeCommandSettings(kind: "kernel" | "team"): string[] {
|
||||
const allow = kind === "kernel" ? KERNEL_CLAUDE_ALLOW : TEAM_CLAUDE_ALLOW;
|
||||
const ask = kind === "kernel" ? [] : TEAM_CLAUDE_ASK;
|
||||
const permissions = { allow, ...(ask.length ? { ask } : {}) };
|
||||
const hook = fileURLToPath(new URL("../../assets/claude-team-permissions.cjs", import.meta.url));
|
||||
if (!existsSync(hook)) return ["--settings", JSON.stringify({ permissions })];
|
||||
const policy = Buffer.from(JSON.stringify({ allow, ask })).toString("base64");
|
||||
const command = [process.execPath, hook, policy].map(shellQuote).join(" ");
|
||||
const command = [process.execPath, hook, kind].map(shellQuote).join(" ");
|
||||
return ["--settings", JSON.stringify({
|
||||
permissions: { allow },
|
||||
hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command, timeout: 5 }] }] },
|
||||
@@ -106,10 +73,11 @@ export function operationalLaunchArgs(runtime: string, choice: LaunchChoice): st
|
||||
return choice.kernelAuthority ? [] : nonInterruptiveArgs(runtime, choice);
|
||||
}
|
||||
|
||||
/** Applies kernel or eligible team defaults before falling back to the explicit launch posture. */
|
||||
function claudeOperationalArgs(choice: LaunchChoice): string[] {
|
||||
if (choice.kernelAuthority) return claudeCommandSettings(KERNEL_CLAUDE_ALLOW);
|
||||
if (choice.kernelAuthority) return claudeCommandSettings("kernel");
|
||||
if (choice.teamPermissionDefault && !choice.permissionMode && (!choice.launchPosture || choice.launchPosture === "floor")) {
|
||||
return claudeCommandSettings(TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK);
|
||||
return claudeCommandSettings("team");
|
||||
}
|
||||
return nonInterruptiveArgs("claude-code", choice);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface PiResumeFsOps {
|
||||
}
|
||||
|
||||
interface PiResumeOptions {
|
||||
/** Resolve the persisted/configured account without relying on live tmux metadata. */
|
||||
accountHomeForSession?: (session: string) => string | undefined;
|
||||
stateRootForSession?: (session: string) => string;
|
||||
seatLaunchEnvironment?: SeatLaunchEnvironment;
|
||||
pollMs?: number;
|
||||
maxWaitMs?: number;
|
||||
@@ -93,7 +96,7 @@ export class PiResumeAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
const seat = piSeatPaths(this.paths.stateRoot, tmuxSessionName);
|
||||
const seat = piSeatPaths(this.options.stateRootForSession?.(tmuxSessionName) ?? this.paths.stateRoot, tmuxSessionName);
|
||||
this.fs.mkdirp(seat.agentDir);
|
||||
this.fs.mkdirp(seat.sessionsDir);
|
||||
|
||||
@@ -114,11 +117,23 @@ export class PiResumeAdapter {
|
||||
const appliedLaunch = this.runtime === "omp"
|
||||
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
|
||||
: observePiResourceTrust(trust);
|
||||
let nodeId: string | undefined;
|
||||
if (this.runtime === "pi" && this.options.seatLaunchEnvironment) {
|
||||
try { nodeId = await this.tmux.getSessionEnv(tmuxSessionName, "OPENRIG_NODE_ID") ?? undefined; }
|
||||
catch { /* Account-free resume retains best-effort identity correction. */ }
|
||||
}
|
||||
const accountHome = this.runtime === "pi" ? this.options.accountHomeForSession?.(tmuxSessionName)
|
||||
?? (nodeId ? this.options.seatLaunchEnvironment?.accountHome(nodeId, this.runtime) : undefined) : undefined;
|
||||
if (!nodeId && this.runtime === "pi" && (accountHome || this.fs.exists(`${seat.agentDir}/.openrig-account`))) {
|
||||
return { ok: false, code: "resume_failed", message: "Pi resume needs seat identity to preserve its selected account; restore identity and retry resume." };
|
||||
}
|
||||
if (!nodeId && this.runtime === "pi" && this.options.seatLaunchEnvironment) console.warn("[pi-resume] Seat identity unavailable; account-free resume continues with best-effort routing.");
|
||||
const cmd = buildPiRunnerCommand({
|
||||
accountHome,
|
||||
runtime: this.runtime,
|
||||
runnerEntryPath: this.paths.runnerEntryPath,
|
||||
sessionName: tmuxSessionName,
|
||||
stateRoot: this.paths.stateRoot,
|
||||
stateRoot: this.options.stateRootForSession?.(tmuxSessionName) ?? this.paths.stateRoot,
|
||||
cwd,
|
||||
// The model declaration must survive resume: the runner's provider-key
|
||||
// allowlist keys off the declared provider (VM leg finding).
|
||||
@@ -134,14 +149,14 @@ export class PiResumeAdapter {
|
||||
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
|
||||
});
|
||||
|
||||
const fallback = cmd;
|
||||
const command = this.runtime === "pi" && this.options.seatLaunchEnvironment
|
||||
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: this.runtime })
|
||||
: cmd;
|
||||
? await this.options.seatLaunchEnvironment.command(tmuxSessionName, cmd, { runtime: this.runtime, nodeId, accountHome })
|
||||
: fallback;
|
||||
// Short commands retain the direct path; long commands exec from a private script.
|
||||
let textResult = await this.tmux.sendShellCommand(tmuxSessionName, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
// This refusal precedes any input: retain the bare-command fallback when routing cannot fit.
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== cmd) {
|
||||
textResult = await this.tmux.sendShellCommand(tmuxSessionName, cmd, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== fallback && accountHome === undefined) {
|
||||
textResult = await this.tmux.sendShellCommand(tmuxSessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, code: "resume_failed", message: textResult.message };
|
||||
@@ -162,7 +177,7 @@ export class PiResumeAdapter {
|
||||
const attempts = Math.max(1, Math.floor(maxWaitMs / Math.max(pollMs, 1)) + 1);
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const { runnerStatePath } = piSeatPaths(this.paths.stateRoot, tmuxSessionName);
|
||||
const { runnerStatePath } = piSeatPaths(this.options.stateRootForSession?.(tmuxSessionName) ?? this.paths.stateRoot, tmuxSessionName);
|
||||
const state = this.fs.exists(runnerStatePath) ? parsePiRunnerState(this.readSafe(runnerStatePath)) : null;
|
||||
|
||||
if (state?.launchId === launchId) {
|
||||
|
||||
@@ -242,6 +242,8 @@ export function buildPiChildEnv(
|
||||
// ── Command construction ─────────────────────────────────────────────────────
|
||||
|
||||
export interface PiRunnerLaunchOpts {
|
||||
/** Explicit prepared account, never inferred from the shell environment. */
|
||||
accountHome?: string;
|
||||
/** Explicit runtime; omitted keeps the original Pi command byte-for-byte. */
|
||||
runtime?: RunnerRuntime;
|
||||
/** Absolute path to the compiled runner entry (daemon dist). */
|
||||
@@ -313,6 +315,7 @@ export function buildPiRunnerCommand(opts: PiRunnerLaunchOpts): string {
|
||||
parts.push("--usage-facts");
|
||||
if (opts.openRouterCharge) parts.push("--openrouter-charge");
|
||||
}
|
||||
if (opts.accountHome && opts.runtime !== "omp") parts.push("--account-home", shellQuote(opts.accountHome));
|
||||
if (opts.launchWrapper?.length && opts.runtime !== "omp") {
|
||||
for (const arg of opts.launchWrapper) {
|
||||
parts.push("--launch-wrapper-arg", shellQuote(arg));
|
||||
|
||||
@@ -38,6 +38,34 @@ import {
|
||||
type PiRunnerState, type RunnerRuntime,
|
||||
} from "./pi-runner-protocol.js";
|
||||
|
||||
/** Bind the selected account's credentials while retaining the seat's own Pi resources. */
|
||||
export function preparePiAccount(agentDir: string, accountHome: string | undefined): void {
|
||||
fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 });
|
||||
const marker = nodePath.join(agentDir, ".openrig-account");
|
||||
const auth = nodePath.join(agentDir, "auth.json");
|
||||
// Validate every explicit home, including the seat's own directory, before changing any link.
|
||||
if (accountHome) {
|
||||
const source = nodePath.join(accountHome, "auth.json");
|
||||
fs.accessSync(source, fs.constants.R_OK);
|
||||
if (!fs.statSync(source).isFile()) throw new Error("Selected Pi auth must be a file");
|
||||
}
|
||||
if (accountHome && fs.realpathSync(accountHome) !== fs.realpathSync(agentDir)) {
|
||||
const source = nodePath.join(accountHome, "auth.json");
|
||||
const current = fs.lstatSync(auth, { throwIfNoEntry: false });
|
||||
if (current && (!current.isSymbolicLink() || !fs.existsSync(marker))) {
|
||||
throw new Error("Existing Pi login is not a managed account link; use a confirmed account switch");
|
||||
}
|
||||
fs.rmSync(auth, { force: true });
|
||||
// Record ownership before linking so a crash between these writes is retryable.
|
||||
fs.writeFileSync(marker, accountHome, { mode: 0o600 });
|
||||
fs.symlinkSync(source, auth);
|
||||
} else if (!accountHome && fs.existsSync(marker)) {
|
||||
// A Pi login may have replaced the link since it was installed.
|
||||
if (fs.lstatSync(auth, { throwIfNoEntry: false })?.isSymbolicLink()) fs.rmSync(auth);
|
||||
fs.rmSync(marker);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submitted input boundaries ─────────────────────────────────────────────
|
||||
// Canonical TTY buffers can overflow before Node sees even the paste terminator.
|
||||
// Use Node's line editor in raw mode, with only paste framing handled here.
|
||||
@@ -974,6 +1002,7 @@ export class RunnerCore {
|
||||
// ── CLI entry ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RunnerArgs {
|
||||
accountHome?: string;
|
||||
runtime?: RunnerRuntime;
|
||||
sessionName: string;
|
||||
stateRoot: string;
|
||||
@@ -1011,6 +1040,7 @@ export function parseRunnerArgs(argv: string[]): RunnerArgs {
|
||||
args.runtime = runtime;
|
||||
break;
|
||||
}
|
||||
case "--account-home": args.accountHome = next(); break;
|
||||
case "--state-root": args.stateRoot = next(); break;
|
||||
case "--cwd": args.cwd = next(); break;
|
||||
case "--launch-id": args.launchId = next(); break;
|
||||
@@ -1048,6 +1078,9 @@ export function parseRunnerArgs(argv: string[]): RunnerArgs {
|
||||
throw new Error("an explicit trust flag is required: --approve or --no-approve");
|
||||
}
|
||||
if (args.sessionFile && args.forkRef) throw new Error("--session and --fork are mutually exclusive");
|
||||
if (args.accountHome !== undefined && ((args.runtime ?? "pi") !== "pi" || !nodePath.isAbsolute(args.accountHome) || /[\0\r\n]/.test(args.accountHome))) {
|
||||
throw new Error("--account-home requires an absolute Pi account directory");
|
||||
}
|
||||
if (args.launchWrapper) {
|
||||
if (args.runtime === "omp") {
|
||||
throw new Error("--launch-wrapper-arg is only supported for the Pi runtime");
|
||||
@@ -1229,6 +1262,14 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
const paths = piSeatPaths(args.stateRoot, args.sessionName);
|
||||
fs.mkdirSync(paths.agentDir, { recursive: true });
|
||||
fs.mkdirSync(paths.sessionsDir, { recursive: true });
|
||||
if (runtime === "pi") {
|
||||
try { preparePiAccount(paths.agentDir, args.accountHome); }
|
||||
catch {
|
||||
console.error(`${PI_RUNNER_ERROR_MARKER} Pi account preparation failed for ${JSON.stringify(nodePath.join(args.accountHome ?? paths.agentDir, "auth.json"))}; provide a readable prepared account file and keep any existing seat login intact, then retry the account operation.`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { catchUpSince } = prepareRunnerSidecar(
|
||||
{
|
||||
@@ -1253,6 +1294,10 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
openrigHome: process.env.OPENRIG_HOME,
|
||||
openrigUrl: endpoint?.baseUrl ?? process.env.OPENRIG_URL,
|
||||
});
|
||||
if (runtime === "pi" && args.accountHome && fs.lstatSync(nodePath.join(paths.agentDir, "auth.json"), { throwIfNoEntry: false })?.isSymbolicLink()) {
|
||||
// Only selected accounts load the Node compatibility shim; unrelated launches are unchanged.
|
||||
childEnv.NODE_OPTIONS = `--require=${JSON.stringify(nodePath.resolve(import.meta.dirname, "../../assets/pi-account-lock.cjs"))}`;
|
||||
}
|
||||
const childArgs = buildPiChildArgs({
|
||||
sessionsDir: paths.sessionsDir,
|
||||
sessionName: args.sessionName,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { piAccountSwitchStateRoot } from "../domain/pi-account-state.js";
|
||||
// OPR.0.4.6.PI1 — the Pi runtime adapter (RPC-first, runner-in-a-pane).
|
||||
//
|
||||
// The adapter launches the OpenRig-owned pi-runner inside the seat's normal
|
||||
@@ -53,6 +54,7 @@ export interface PiRuntimeAdapterDeps {
|
||||
/** Root under which every Pi seat gets its isolated state dir (FR-7).
|
||||
* Typically <OPENRIG_HOME>/state/pi. */
|
||||
stateRoot: string;
|
||||
stateRootForSession?: (session: string) => string;
|
||||
/** Absolute path to the compiled pi-runner entry in the daemon dist. */
|
||||
runnerEntryPath: string;
|
||||
/** Trust posture for managed launches (BR-5 — always explicit; ambient
|
||||
@@ -78,6 +80,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
private seatLaunchEnvironment?: SeatLaunchEnvironment;
|
||||
private fs: PiAdapterFsOps;
|
||||
protected stateRoot: string;
|
||||
private stateRootForSession: (session: string) => string;
|
||||
private runnerEntryPath: string;
|
||||
private trustPosture: "approve" | "no-approve";
|
||||
private sleep: (ms: number) => Promise<void>;
|
||||
@@ -94,18 +97,33 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
this.seatLaunchEnvironment = deps.seatLaunchEnvironment;
|
||||
this.fs = deps.fsOps;
|
||||
this.stateRoot = deps.stateRoot;
|
||||
this.stateRootForSession = deps.stateRootForSession ?? (() => this.stateRoot);
|
||||
this.runnerEntryPath = deps.runnerEntryPath;
|
||||
this.trustPosture = deps.trustPosture ?? "no-approve";
|
||||
this.sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
this.newLaunchId = deps.newLaunchId ?? (() => randomUUID());
|
||||
}
|
||||
|
||||
/** Resolve canonical identity and isolated state for a staged or committed occupant. */
|
||||
private runnerLocation(binding: NodeBinding): { sessionName: string; stateRoot: string } {
|
||||
const staged = this.runtime === "pi" && binding.runtimeSwitchFrom;
|
||||
const sessionName = staged ? staged.session : binding.tmuxSession!;
|
||||
return { sessionName, stateRoot: staged && binding.launchGeneration
|
||||
? piAccountSwitchStateRoot(this.stateRoot, binding.launchGeneration) : this.stateRootForSession(sessionName) };
|
||||
}
|
||||
|
||||
/** Resolve files in the occupant’s state directory rather than its temporary staging pane. */
|
||||
private bindingPaths(binding: NodeBinding) {
|
||||
const { sessionName, stateRoot } = this.runnerLocation(binding);
|
||||
return piSeatPaths(stateRoot, sessionName);
|
||||
}
|
||||
|
||||
/** The pi-runner sidecar reader shape resume-token-capture consumes
|
||||
* (deriveResumeToken's piRunnerStateStore dep). */
|
||||
readSessionFile(sessionName: string): { ok: true; sessionFile: string } | { ok: false; reason: string } {
|
||||
const state = this.readRunnerState(sessionName);
|
||||
if (state === null) {
|
||||
const { runnerStatePath } = piSeatPaths(this.stateRoot, sessionName);
|
||||
const { runnerStatePath } = piSeatPaths(this.stateRootForSession(sessionName), sessionName);
|
||||
return this.fs.exists(runnerStatePath)
|
||||
? { ok: false, reason: "parse_error" }
|
||||
: { ok: false, reason: "missing_sidecar" };
|
||||
@@ -123,9 +141,9 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
readRunnerSidecar(sessionName: string): { state: PiRunnerState | null; sessionsDir: string; entryPath: string; stateRoot: string } {
|
||||
return {
|
||||
state: this.readRunnerState(sessionName),
|
||||
sessionsDir: piSeatPaths(this.stateRoot, sessionName).sessionsDir,
|
||||
sessionsDir: piSeatPaths(this.stateRootForSession(sessionName), sessionName).sessionsDir,
|
||||
entryPath: this.runnerEntryPath,
|
||||
stateRoot: this.stateRoot,
|
||||
stateRoot: this.stateRootForSession(sessionName),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,7 +151,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
const results: InstalledResource[] = [];
|
||||
const sessionName = binding.tmuxSession;
|
||||
if (!sessionName) return results;
|
||||
const { agentDir } = piSeatPaths(this.stateRoot, sessionName);
|
||||
const { agentDir } = this.bindingPaths(binding);
|
||||
const skillsDir = nodePath.join(agentDir, "skills");
|
||||
if (this.fs.exists(skillsDir) && this.fs.listFiles) {
|
||||
for (const file of this.fs.listFiles(skillsDir)) {
|
||||
@@ -146,7 +164,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
/** Conflict detection must inspect this seat's copy, not a Claude sibling. */
|
||||
skillTargetPath(tmuxSession: string | null, effectiveId: string): string | null {
|
||||
if (!tmuxSession) return null;
|
||||
return nodePath.join(piSeatPaths(this.stateRoot, tmuxSession).agentDir, "skills", effectiveId, "SKILL.md");
|
||||
return nodePath.join(piSeatPaths(this.stateRootForSession(tmuxSession), tmuxSession).agentDir, "skills", effectiveId, "SKILL.md");
|
||||
}
|
||||
|
||||
async project(plan: ProjectionPlan, binding: NodeBinding): Promise<ProjectionResult> {
|
||||
@@ -201,7 +219,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
case "skill_install": {
|
||||
if (!binding.tmuxSession) throw new Error("No tmux session bound — cannot resolve the Pi seat state dir");
|
||||
const { agentDir } = piSeatPaths(this.stateRoot, binding.tmuxSession);
|
||||
const { agentDir } = this.bindingPaths(binding);
|
||||
const targetDir = nodePath.join(agentDir, "skills", nodePath.basename(nodePath.dirname(file.absolutePath)));
|
||||
this.fs.mkdirp(targetDir);
|
||||
this.fs.writeFile(nodePath.join(targetDir, nodePath.basename(file.path)), content);
|
||||
@@ -282,7 +300,8 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
}
|
||||
|
||||
const sessionName = binding.tmuxSession;
|
||||
const paths = piSeatPaths(this.stateRoot, sessionName);
|
||||
const location = this.runnerLocation(binding);
|
||||
const paths = this.bindingPaths(binding);
|
||||
this.fs.mkdirp(paths.agentDir);
|
||||
this.fs.mkdirp(paths.sessionsDir);
|
||||
|
||||
@@ -292,7 +311,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
// The prior record is read FIRST so the durable catch-up cursor
|
||||
// (lastEntryId, FR-5) survives the reset.
|
||||
const launchId = this.newLaunchId();
|
||||
const prior = this.readRunnerState(sessionName);
|
||||
const prior = this.readRunnerState(location.sessionName, location.stateRoot);
|
||||
this.fs.writeFile(
|
||||
paths.runnerStatePath,
|
||||
JSON.stringify(buildPendingRunnerState(launchId, new Date().toISOString(), prior)),
|
||||
@@ -304,11 +323,16 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
const appliedLaunch = this.runtime === "omp"
|
||||
? observeOmpApprovalMode(`--approval-mode ${trust === "approve" ? "yolo" : "always-ask"}`)
|
||||
: observePiResourceTrust(trust);
|
||||
const accountHome = binding.accountHome !== undefined ? binding.accountHome
|
||||
: binding.nodeId ? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime) : undefined;
|
||||
const preparedHome = accountHome === null && binding.nodeId
|
||||
? this.seatLaunchEnvironment?.accountHome(binding.nodeId, this.runtime, true) : accountHome;
|
||||
const cmd = buildPiRunnerCommand({
|
||||
accountHome: preparedHome ?? undefined,
|
||||
runtime: this.runtime,
|
||||
runnerEntryPath: this.runnerEntryPath,
|
||||
sessionName,
|
||||
stateRoot: this.stateRoot,
|
||||
sessionName: location.sessionName,
|
||||
stateRoot: location.stateRoot,
|
||||
cwd: binding.cwd,
|
||||
model: binding.model,
|
||||
// The seat's resolved effort, sent as Pi's --thinking; OMP unchanged.
|
||||
@@ -324,14 +348,14 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
...(resolvedWrapper ? { launchWrapper: resolvedWrapper } : {}),
|
||||
});
|
||||
|
||||
const fallback = cmd;
|
||||
const command = this.runtime === "pi" && this.seatLaunchEnvironment
|
||||
? await this.seatLaunchEnvironment.command(sessionName, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime })
|
||||
: cmd;
|
||||
? await this.seatLaunchEnvironment.command(sessionName, cmd, { nodeId: binding.nodeId, generation: binding.launchGeneration, runtime: this.runtime, accountHome })
|
||||
: fallback;
|
||||
// Stage long commands without leaving a shell above the live runner.
|
||||
let textResult = await this.tmux.sendShellCommand(sessionName, command, undefined, { stageIfLong: true, execInScript: true });
|
||||
// This refusal precedes any input: retain the bare-command fallback when routing cannot fit.
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== cmd) {
|
||||
textResult = await this.tmux.sendShellCommand(sessionName, cmd, undefined, { stageIfLong: true, execInScript: true });
|
||||
if (!textResult.ok && textResult.code === "launch_path_too_long" && command !== fallback && accountHome === undefined) {
|
||||
textResult = await this.tmux.sendShellCommand(sessionName, fallback, undefined, { stageIfLong: true, execInScript: true });
|
||||
}
|
||||
if (!textResult.ok) {
|
||||
return { ok: false, error: `Failed to send launch command: ${textResult.message}` };
|
||||
@@ -339,7 +363,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
// The runner writes the sidecar after its first successful get_state;
|
||||
// that sidecar (not pane content) is the token source of truth (FR-5).
|
||||
const state = await this.waitForRunnerReady(sessionName, launchId);
|
||||
const state = await this.waitForRunnerReady(location.sessionName, launchId, location.stateRoot);
|
||||
if (!state.ok) return state.failure;
|
||||
|
||||
const sessionFile = state.value.sessionFile;
|
||||
@@ -383,7 +407,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
// make a stopped seat ready).
|
||||
const paneCommand = (await this.tmux.getPaneCommand(binding.tmuxSession)) ?? "";
|
||||
const atShell = SHELL_COMMANDS.has(paneCommand);
|
||||
const state = this.readRunnerState(binding.tmuxSession);
|
||||
const state = this.readRunnerState(this.runnerLocation(binding).sessionName, this.runnerLocation(binding).stateRoot);
|
||||
if (state?.exited) {
|
||||
return { ready: false, reason: `${this.runtime}-runner exited (code ${state.exited.code ?? "unknown"})`, code: "runner_exited" };
|
||||
}
|
||||
@@ -414,8 +438,8 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
// ── internals ──────────────────────────────────────────────────────────────
|
||||
|
||||
private readRunnerState(sessionName: string): PiRunnerState | null {
|
||||
const { runnerStatePath } = piSeatPaths(this.stateRoot, sessionName);
|
||||
private readRunnerState(sessionName: string, stateRoot = this.stateRootForSession(sessionName)): PiRunnerState | null {
|
||||
const { runnerStatePath } = piSeatPaths(stateRoot, sessionName);
|
||||
if (!this.fs.exists(runnerStatePath)) return null;
|
||||
try {
|
||||
return parsePiRunnerState(this.fs.readFile(runnerStatePath));
|
||||
@@ -427,11 +451,12 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
private async waitForRunnerReady(
|
||||
sessionName: string,
|
||||
launchId: string,
|
||||
stateRoot = this.stateRootForSession(sessionName),
|
||||
): Promise<{ ok: true; value: PiRunnerState } | { ok: false; failure: HarnessLaunchResult }> {
|
||||
const pollMs = 250;
|
||||
const attempts = 60; // ~15s: runner boot + pi spawn + first get_state
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const state = this.readRunnerState(sessionName);
|
||||
const state = this.readRunnerState(sessionName, stateRoot);
|
||||
// Launch-attempt scoping: only THIS attempt's sidecar states count.
|
||||
// Stale ready/exited records from prior instances are ignored, and
|
||||
// stale pane markers in scrollback are never consulted here — the
|
||||
@@ -470,7 +495,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
* (a person's own Pi home, a shared file) would read this rig's guidance. */
|
||||
guidanceFileFor(binding: NodeBinding): { file: string | null; note?: string } {
|
||||
if (!binding.tmuxSession) return { file: null };
|
||||
const file = nodePath.join(piSeatPaths(this.stateRoot, binding.tmuxSession).agentDir, "AGENTS.md");
|
||||
const file = nodePath.join(this.bindingPaths(binding).agentDir, "AGENTS.md");
|
||||
const linked = guidanceFileSharedByLink(file, (path) => this.fs.linkInfo?.(path) ?? null);
|
||||
return linked ? { file: null, note: `${this.runtime}: ${linked} is a link, so this seat's managed guidance stays in its repository AGENTS.md` } : { file };
|
||||
}
|
||||
@@ -496,7 +521,7 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
|
||||
|
||||
if (entry.category === "skill") {
|
||||
if (!binding.tmuxSession) return false;
|
||||
const { agentDir } = piSeatPaths(this.stateRoot, binding.tmuxSession);
|
||||
const { agentDir } = this.bindingPaths(binding);
|
||||
const targetDir = nodePath.join(agentDir, "skills", entry.effectiveId);
|
||||
this.fs.mkdirp(targetDir);
|
||||
let isDir = false;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { piAccountSwitchesSchema } from "./migrations/151_pi_account_switches.js";
|
||||
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
|
||||
@@ -303,4 +304,5 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
outboxDispatchedIndexSchema,
|
||||
streamIntakeCursorSchema,
|
||||
nodeLaunchWrapperSchema,
|
||||
piAccountSwitchesSchema,
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** Widen runtime switches to Pi while retaining history and interrupted cutovers. */
|
||||
export const piAccountSwitchesSchema: Migration = {
|
||||
name: "151_pi_account_switches.sql",
|
||||
sql: `
|
||||
CREATE TABLE seat_runtime_switches_pi (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
canonical_session TEXT NOT NULL,
|
||||
staging_session TEXT NOT NULL UNIQUE,
|
||||
predecessor_pane TEXT NOT NULL,
|
||||
successor_pane TEXT,
|
||||
predecessor_generation TEXT NOT NULL,
|
||||
successor_generation TEXT NOT NULL UNIQUE,
|
||||
from_runtime TEXT NOT NULL CHECK (from_runtime IN ('codex', 'claude-code', 'pi')),
|
||||
to_runtime TEXT NOT NULL CHECK (to_runtime IN ('codex', 'claude-code', 'pi')),
|
||||
phase TEXT NOT NULL DEFAULT 'preparing'
|
||||
CHECK (phase IN ('preparing', 'confirmed', 'committed', 'finished', 'abandoned')),
|
||||
reason TEXT NOT NULL,
|
||||
requested_by TEXT,
|
||||
plan_json TEXT NOT NULL,
|
||||
confirmation_note TEXT,
|
||||
confirmed_at TEXT,
|
||||
successor_resume_type TEXT,
|
||||
successor_resume_token TEXT,
|
||||
committed_at TEXT,
|
||||
-- When the switch should have finished (its successor's start plus the confirmation timeout); a waiting send says so.
|
||||
deadline_at TEXT NOT NULL,
|
||||
outcome TEXT,
|
||||
attention TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
CHECK ((phase IN ('committed', 'finished')) = (committed_at IS NOT NULL))
|
||||
);
|
||||
|
||||
INSERT INTO seat_runtime_switches_pi SELECT * FROM seat_runtime_switches;
|
||||
DROP TABLE seat_runtime_switches;
|
||||
ALTER TABLE seat_runtime_switches_pi RENAME TO seat_runtime_switches;
|
||||
CREATE UNIQUE INDEX idx_seat_runtime_switches_open ON seat_runtime_switches(node_id)
|
||||
WHERE phase IN ('preparing', 'confirmed', 'committed');
|
||||
CREATE INDEX idx_seat_runtime_switches_predecessor ON seat_runtime_switches(node_id, predecessor_generation)
|
||||
WHERE committed_at IS NOT NULL;
|
||||
`,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
/** A staged Pi occupant must not change the current occupant's credentials or sidecar. */
|
||||
export function piAccountSwitchStateRoot(root: string, generation: string): string {
|
||||
return path.join(root, "account-switches", generation);
|
||||
}
|
||||
|
||||
/** The committed switch ledger keeps the chosen state directory stable across daemon restarts. */
|
||||
export function piAccountStateRoot(db: Database.Database, root: string, session: string): string {
|
||||
const row = db.prepare(`SELECT s.successor_generation FROM seat_runtime_switches s
|
||||
JOIN bindings b ON b.node_id = s.node_id JOIN nodes n ON n.id = s.node_id
|
||||
WHERE b.tmux_session = ? AND n.runtime = 'pi' AND s.to_runtime = 'pi' AND s.committed_at IS NOT NULL
|
||||
ORDER BY s.rowid DESC LIMIT 1`).get(session) as { successor_generation: string } | undefined;
|
||||
return row ? piAccountSwitchStateRoot(root, row.successor_generation) : root;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -469,7 +469,7 @@ export class RigRepository {
|
||||
.get(nodeId) as { rig_id: string; env_json: string | null } | undefined;
|
||||
if (!row) return {};
|
||||
const env = withoutIgnoredRuntimeHomes(mergeSeatEnv(this.getRigEnv(row.rig_id), parseStoredSeatEnv(row.env_json)));
|
||||
for (const runtime of ignoreAccountHome ? [] : ["codex", "claude-code"] as const) {
|
||||
for (const runtime of ignoreAccountHome ? [] : ["codex", "claude-code", "pi"] as const) {
|
||||
const accountHome = seatAccountHome(this.db, nodeId, runtime);
|
||||
if (accountHome) env[RUNTIME_HOME_VAR[runtime]] = accountHome;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { seatSkillsPointerStartupFile } from "./seat-skills-pointer.js";
|
||||
import { readSeatEffort } from "./seat-effort.js";
|
||||
import type { ClaudeManagedBlockFile } from "./managed-blocks.js";
|
||||
|
||||
export type SwitchRuntime = "codex" | "claude-code";
|
||||
export type SwitchRuntime = "codex" | "claude-code" | "pi";
|
||||
|
||||
/** Where an effective target setting came from. `native_default` means OpenRig passes no flag. */
|
||||
export type RuntimeSwitchSettingSource = "explicit" | "agent_spec" | "native_default";
|
||||
@@ -133,7 +133,7 @@ export function planRuntimeSwitchMaterials(input: RuntimeSwitchMaterialsInput):
|
||||
|
||||
// Saved startup files are runtime-neutral except the seat-skills pointer and a Codex seat's catalog; those are rebuilt
|
||||
// for the target. Managed guidance the target projects isn't also delivered as a file (the launch's own rule).
|
||||
const sourceSeat = node.runtime === "codex" || node.runtime === "claude-code"
|
||||
const sourceSeat = node.runtime === "codex" || node.runtime === "claude-code" || node.runtime === "pi"
|
||||
? seatSkillPaths({ rigId: node.rigId, logicalId: node.logicalId, runtime: node.runtime }, input.openrigHome) : undefined;
|
||||
const pointer = seatSkillsPointerStartupFile();
|
||||
const projectedGuidance = new Set(plan.entries
|
||||
@@ -186,7 +186,7 @@ export function planRuntimeSwitchMaterials(input: RuntimeSwitchMaterialsInput):
|
||||
async prepare(binding, adapter) {
|
||||
const notes: string[] = [];
|
||||
if (runtime === "claude-code") notes.push(...ensureSeatSkillPlugin(seatSkills));
|
||||
if (skillLoadout) {
|
||||
if (skillLoadout && runtime !== "pi") {
|
||||
const reconcile = (selected: SkillLoadout) => reconcileSkillLoadout({ loadout: selected, runtime, cwd, apply: true,
|
||||
topologyOwner: input.canonicalSession, location: seatSkillLocation(seatSkills, runtime) });
|
||||
let projection = reconcile(skillLoadout);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ACCOUNT_HOME_PROVIDER_ENV, RUNTIME_HOME_VAR, type HomeRuntime } from "./seat-runtime-home.js";
|
||||
import { ACCOUNT_HOME_PROVIDER_ENV, PI_ACCOUNT_HOME_PROVIDER_ENV, RUNTIME_HOME_VAR, type HomeRuntime } from "./seat-runtime-home.js";
|
||||
import { isLaunchWrapperRuntime, validateLaunchWrapper } from "./launch-wrapper.js";
|
||||
import { accessSync, closeSync, constants, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, readSync, realpathSync, statSync, symlinkSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -100,6 +100,11 @@ export class SeatLaunchEnvironment {
|
||||
return resolveLaunchWrapperArgv(wrapper, this.sessionEnv.PATH ?? "", this.daemonCwd);
|
||||
}
|
||||
|
||||
/** The persisted account selection, also used to decide whether a bare launch fallback is safe. */
|
||||
accountHome(nodeId: string, runtime: string, ignoreAccountHome = false): string | undefined {
|
||||
return this.selectedAccountHome?.(nodeId, runtime, ignoreAccountHome);
|
||||
}
|
||||
|
||||
private rigBin(): string {
|
||||
const cli = realpathSync(this.cliPath ?? pairedCli());
|
||||
accessSync(cli, constants.X_OK);
|
||||
@@ -126,7 +131,7 @@ export class SeatLaunchEnvironment {
|
||||
/** A Claude seat's own token step (claude-seat-token.ts), run before Claude in the staged subshell. A POSIX pane only:
|
||||
* a caller whose command comes back without it knows the token wasn't applied. */
|
||||
claudeTokenStep?: string } = {}): Promise<string> {
|
||||
const accountHome = target.accountHome !== undefined ? target.accountHome : (target.nodeId && target.runtime ? this.selectedAccountHome?.(target.nodeId, target.runtime) : undefined);
|
||||
const accountHome = target.accountHome !== undefined ? target.accountHome : (target.nodeId && target.runtime ? this.accountHome(target.nodeId, target.runtime) : undefined);
|
||||
const clearing = target.accountHome === null;
|
||||
const wrapped = target.launchWrapper?.length ? target.launchWrapper : undefined;
|
||||
const configuredHome = clearing && target.nodeId && target.runtime ? this.selectedAccountHome?.(target.nodeId, target.runtime, true) : undefined;
|
||||
@@ -134,7 +139,9 @@ export class SeatLaunchEnvironment {
|
||||
const clearStep = clearing && homeVar ? `unset ${homeVar}; ${configuredHome !== undefined ? `export ${homeVar}=${shellQuote(configuredHome)}; ` : ""}` : "";
|
||||
const codexHome = (target.runtime === "codex" ? accountHome : undefined) ?? target.codexHome ?? this.codexHome;
|
||||
const searchPath = this.sessionEnv.PATH;
|
||||
const accountStep = clearStep + (accountHome ? `unset ${ACCOUNT_HOME_PROVIDER_ENV.join(" ")}; ` : "");
|
||||
const selectedPiHome = target.runtime === "pi" ? accountHome ?? configuredHome : undefined;
|
||||
const scrub = target.runtime === "pi" ? [...ACCOUNT_HOME_PROVIDER_ENV, ...PI_ACCOUNT_HOME_PROVIDER_ENV] : ACCOUNT_HOME_PROVIDER_ENV;
|
||||
const accountStep = clearStep + (accountHome || selectedPiHome ? `unset ${scrub.join(" ")}; ` : "");
|
||||
const codexEnv = target.codexCwd !== undefined
|
||||
? [searchPath ? `PATH=${shellQuote(searchPath)}` : "", codexHome ? `CODEX_HOME=${shellQuote(codexHome)}` : ""].filter(Boolean)
|
||||
: [];
|
||||
@@ -178,6 +185,9 @@ export class SeatLaunchEnvironment {
|
||||
const env = publicSeatEnvironment({ OPENRIG_TRANSCRIPTS_LINES: "", OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS: "", ...this.sessionEnv, ...identity });
|
||||
if (target.codexCwd !== undefined && codexHome) env.CODEX_HOME = codexHome;
|
||||
if (accountHome && target.runtime === "claude-code") env.CLAUDE_CONFIG_DIR = accountHome;
|
||||
if (target.runtime === "pi") {
|
||||
if (selectedPiHome) env.PI_CODING_AGENT_DIR = selectedPiHome;
|
||||
}
|
||||
// Classic Claude may be an rc alias or function, not a PATH executable.
|
||||
// Its caller sources the staged command in a pane-shell subshell. Leading
|
||||
// assignments preserve shell lookup; /usr/bin/env would bypass it.
|
||||
|
||||
@@ -5,13 +5,16 @@ import type Database from "better-sqlite3";
|
||||
import { parseStoredSeatEnv, type SeatEnv } from "./seat-env.js";
|
||||
|
||||
/** The native variable that selects each runtime's home, and with it the account that home is logged in to. */
|
||||
export const RUNTIME_HOME_VAR = { "claude-code": "CLAUDE_CONFIG_DIR", codex: "CODEX_HOME" } as const;
|
||||
export const RUNTIME_HOME_VAR = { "claude-code": "CLAUDE_CONFIG_DIR", codex: "CODEX_HOME", pi: "PI_CODING_AGENT_DIR" } as const;
|
||||
export type HomeRuntime = keyof typeof RUNTIME_HOME_VAR;
|
||||
|
||||
/** Provider environment excluded by an explicit isolated account-home selection. */
|
||||
export const ACCOUNT_HOME_PROVIDER_ENV = [...OUTRANKING_TOKEN_ENV, ...ANTHROPIC_PROFILE_ENV, "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL",
|
||||
"OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_ACCESS_TOKEN", "OPENAI_FEDERATION_RULE_ID", "OPENAI_IDENTITY_TOKEN_FILE", "OPENAI_BASE_URL", "OPENAI_ORG_ID", "OPENAI_ORGANIZATION", "OPENAI_PROJECT", "OPENAI_PROJECT_ID", "AWS_BEARER_TOKEN_BEDROCK"] as const;
|
||||
|
||||
/** Additional provider keys excluded only when selecting a Pi account. */
|
||||
export const PI_ACCOUNT_HOME_PROVIDER_ENV = ["OPENROUTER_API_KEY", "ZAI_API_KEY", "KIMI_API_KEY", "MINIMAX_API_KEY", "MISTRAL_API_KEY", "GEMINI_API_KEY"] as const;
|
||||
|
||||
export interface SeatRuntimeHome {
|
||||
/** The seat's own home: an absolute path its member, else its rig, declares in `env:`. */
|
||||
home?: string;
|
||||
@@ -59,10 +62,10 @@ export function seatRuntimeHome(db: Database.Database, nodeId: string | null | u
|
||||
}
|
||||
|
||||
/** The home a seat's launches and OpenRig's readers use: its own, else the daemon's explicit value. Undefined means the
|
||||
* runtime's native default. */
|
||||
* runtime's native default, except Pi which keeps its managed seat login and never inherits the daemon home. */
|
||||
export function effectiveRuntimeHome(db: Database.Database, nodeId: string | null | undefined, runtime: HomeRuntime,
|
||||
daemonHome: string | undefined, ignoreAccountHome = false): string | undefined {
|
||||
return seatRuntimeHome(db, nodeId, runtime, ignoreAccountHome).home ?? daemonHome;
|
||||
return seatRuntimeHome(db, nodeId, runtime, ignoreAccountHome).home ?? (runtime === "pi" ? undefined : daemonHome);
|
||||
}
|
||||
|
||||
/** Every distinct home seats declare for a runtime (absolute values only), for readers that find a record by an id that is
|
||||
@@ -90,7 +93,7 @@ export function declaredRuntimeHomes(db: Database.Database, runtime: HomeRuntime
|
||||
* ignored, so the launch leaves it out too and the runtime resolves as if the seat declared none. */
|
||||
export function withoutIgnoredRuntimeHomes(env: SeatEnv): SeatEnv {
|
||||
const out = { ...env };
|
||||
for (const name of Object.values(RUNTIME_HOME_VAR)) {
|
||||
for (const name of [RUNTIME_HOME_VAR.codex, RUNTIME_HOME_VAR["claude-code"]]) {
|
||||
const value = out[name];
|
||||
if (value !== undefined && !path.isAbsolute(value)) delete out[name];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { piAccountStateRoot, piAccountSwitchStateRoot } from "./pi-account-state.js";
|
||||
import { piSeatPaths } from "../adapters/pi-runner-protocol.js";
|
||||
import { readSeatEffort } from "./seat-effort.js";
|
||||
import { claudeSeatTokenRefProblem, claudeSeatTokenFileProblem, type ClaudeSeatTokenRef } from "./claude-seat-token.js";
|
||||
import { isolatedClaudeAccountProblem } from "./claude-credential-precedence.js";
|
||||
import nodePath from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { RUNTIME_HOME_VAR } from "./seat-runtime-home.js";
|
||||
import { RUNTIME_HOME_VAR, seatRuntimeHome } from "./seat-runtime-home.js";
|
||||
import { ulid } from "ulid";
|
||||
import type Database from "better-sqlite3";
|
||||
import type { RigRepository } from "./rig-repository.js";
|
||||
@@ -152,7 +156,17 @@ const COSTS = [
|
||||
"A second process runs on the target's account until cutover.",
|
||||
"Messages sent to the seat while it switches are held, not typed; rig send says the seat is switching and until when, and they need sending again afterwards.",
|
||||
"A failed switch can leave the target runtime's prepared files (its seat storage and projections).",
|
||||
"Codex and Claude Code only, single-pane seats only.",
|
||||
"Codex and Claude Code runtime switches, or Pi account-home changes; single-pane seats only.",
|
||||
];
|
||||
|
||||
const PI_ACCOUNT_COSTS = [
|
||||
"A new conversation: only the handover carries over; the previous conversation stays with the predecessor.",
|
||||
"Same address, different terminal: the seat's session and address stay, its pane and scrollback change.",
|
||||
"The runtime stays Pi. The seat keeps its model and saved effort choice unless explicitly changed; its launch wrapper is retained.",
|
||||
"A second process runs on the selected account until cutover.",
|
||||
"Messages sent to the seat while it switches are held, not typed; they need sending again afterwards.",
|
||||
"A failed switch can leave the successor's prepared state directory under account-switches.",
|
||||
"Pi account-home changes; single-pane seats only.",
|
||||
];
|
||||
|
||||
export class SeatRuntimeSwitchService {
|
||||
@@ -179,8 +193,8 @@ export class SeatRuntimeSwitchService {
|
||||
/** Validate and plan a successor-first runtime/account move, executing it under the seat's lifecycle guard unless dry-run. */
|
||||
async setRuntime(input: SetRuntimeInput): Promise<SetRuntimeResult> {
|
||||
const refuse = (code: string, message: string, guidance?: string): RuntimeSwitchRefusal => ({ ok: false, code, message, ...(guidance ? { guidance } : {}) });
|
||||
if (input.runtime !== "codex" && input.runtime !== "claude-code") {
|
||||
return refuse("invalid_runtime_settings", "Choose --runtime codex or --runtime claude-code.");
|
||||
if (input.runtime !== "codex" && input.runtime !== "claude-code" && input.runtime !== "pi") {
|
||||
return refuse("invalid_runtime_settings", "Choose --runtime codex or --runtime claude-code; Pi supports account-home changes on an existing Pi seat.");
|
||||
}
|
||||
const target: SwitchRuntime = input.runtime;
|
||||
if (input.clearAccountHome !== undefined && typeof input.clearAccountHome !== "boolean") return refuse("invalid_runtime_settings", "clearAccountHome must be true or false.");
|
||||
@@ -235,8 +249,18 @@ export class SeatRuntimeSwitchService {
|
||||
: open.phase === "abandoned" ? "whose staged successor hasn't been retired" : "in progress"} (${open.from_runtime} → ${open.to_runtime}, switch ${open.id}).`,
|
||||
`Inspect it with rig seat status ${seatRef}; finish an interrupted one with rig seat set-runtime ${seatRef} --recover.`), switchId: open.id };
|
||||
}
|
||||
if (node.runtime !== "codex" && node.runtime !== "claude-code") {
|
||||
return refuse("runtime_switch_unsupported", `${seatRef} runs ${node.runtime ?? "an unknown runtime"}; only Codex and Claude Code seats can switch.`);
|
||||
if (node.runtime !== "codex" && node.runtime !== "claude-code" && node.runtime !== "pi") {
|
||||
return refuse("runtime_switch_unsupported", `${seatRef} runs ${node.runtime ?? "an unknown runtime"}; only Codex and Claude Code seats can change runtime; Pi supports account-home changes.`);
|
||||
}
|
||||
if ((target === "pi" || node.runtime === "pi") && target !== node.runtime) {
|
||||
return refuse("runtime_switch_unsupported", "Pi supports account-home changes on an existing Pi seat; changing to or from Pi is not supported.");
|
||||
}
|
||||
if (target === "pi" && input.accountHome !== undefined) {
|
||||
try {
|
||||
const auth = nodePath.join(input.accountHome, "auth.json");
|
||||
if (!fs.statSync(auth).isFile()) throw new Error();
|
||||
fs.accessSync(auth, fs.constants.R_OK);
|
||||
} catch { return refuse("invalid_runtime_settings", `Pi account file ${JSON.stringify(nodePath.join(input.accountHome, "auth.json"))} must be readable; sign in to that home first, then retry. Nothing changed.`); }
|
||||
}
|
||||
const binding = sessionRegistry.getBindingForNode(node.id);
|
||||
const sessionName = binding?.tmuxSession ?? null;
|
||||
@@ -252,6 +276,11 @@ export class SeatRuntimeSwitchService {
|
||||
const problem = isolatedClaudeAccountProblem(input.accountHome, node.cwd, !!input.accountToken, input.accountToken?.kind === "api-key");
|
||||
if (problem) return refuse("invalid_runtime_settings", problem);
|
||||
}
|
||||
// An account change keeps this Pi seat's current model and effort unless explicitly changed.
|
||||
if (target === "pi") {
|
||||
explicit.model ??= node.model ?? undefined;
|
||||
explicit.effort ??= readSeatEffort(db, node.id)?.effective ?? node.effort ?? undefined;
|
||||
}
|
||||
const predecessorGeneration = sessionRegistry.currentOccupantTenure(node.id)?.generationUuid;
|
||||
const running = sessionRegistry.getSessionsForRig(node.rigId).filter((session) => session.nodeId === node.id && session.status === "running");
|
||||
const unsupported = !sessionName || !binding?.tmuxPane ? NO_PANE
|
||||
@@ -310,7 +339,7 @@ export class SeatRuntimeSwitchService {
|
||||
settings: materials.settings,
|
||||
permission: selection ? { mode: selection.mode, source: "explicit" } : { mode: "inherit", source: "member_or_rig_policy" },
|
||||
dropped,
|
||||
costs: COSTS,
|
||||
costs: target === "pi" ? PI_ACCOUNT_COSTS : COSTS,
|
||||
notRun: materials.notRun.map((command) => `startup slash command not run by a switch: ${command}`),
|
||||
warnings: materials.warnings,
|
||||
};
|
||||
@@ -416,7 +445,22 @@ export class SeatRuntimeSwitchService {
|
||||
successorPane = pane;
|
||||
db.prepare("UPDATE seat_runtime_switches SET successor_pane = ?, updated_at = ? WHERE id = ?").run(pane, stamp(), id);
|
||||
},
|
||||
staged: { prepare: (binding, adapter) => materials.prepare(binding, adapter),
|
||||
staged: { prepare: (binding, adapter) => {
|
||||
if (target === "pi") {
|
||||
const base = nodePath.join(this.deps.openrigHome ?? getOpenRigHome(), "state", "pi");
|
||||
const source = piSeatPaths(piAccountStateRoot(db, base, sessionName), sessionName).agentDir;
|
||||
const destination = piSeatPaths(piAccountSwitchStateRoot(base, successorGeneration), sessionName).agentDir;
|
||||
if (fs.existsSync(source)) fs.cpSync(source, destination, { recursive: true, verbatimSymlinks: true,
|
||||
filter: file => file !== nodePath.join(source, "auth.json") && file !== nodePath.join(source, ".openrig-account") });
|
||||
// Clearing without a configured home returns to the original seat-local login, if it had one.
|
||||
const baseline = piSeatPaths(base, sessionName).agentDir;
|
||||
if (ctx.input.clearAccountHome && !seatRuntimeHome(db, node.id, "pi", true).home && !fs.existsSync(nodePath.join(baseline, ".openrig-account")) && fs.existsSync(nodePath.join(baseline, "auth.json"))) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
fs.copyFileSync(nodePath.join(baseline, "auth.json"), nodePath.join(destination, "auth.json"));
|
||||
}
|
||||
}
|
||||
return materials.prepare(binding, adapter);
|
||||
},
|
||||
binding: { ...materials.bindingAdditions(), ...(ctx.input.accountHome !== undefined ? { accountHome: ctx.input.accountHome, accountToken: ctx.input.accountToken ?? null }
|
||||
: ctx.input.clearAccountHome || node.accountHome ? { accountHome: null, ...(node.accountHome ? { accountToken: null } : {}) } : {}), runtimeSwitchFrom: { runtime: node.runtime!, session: sessionName, pane: ctx.predecessorPane, generation: ctx.predecessorGeneration } } },
|
||||
});
|
||||
@@ -532,10 +576,11 @@ export class SeatRuntimeSwitchService {
|
||||
}
|
||||
const at = this.now().toISOString();
|
||||
const spec = materials.resolvedSpec;
|
||||
db.prepare(`UPDATE nodes SET runtime = ?, model = ?, effort = ?, effort_choice = NULL, advisor = ?, codex_config_profile = ?,
|
||||
db.prepare(`UPDATE nodes SET runtime = ?, model = ?, effort = ?, effort_choice = ?, advisor = ?, codex_config_profile = ?,
|
||||
${spec ? "resolved_spec_name = ?, resolved_spec_version = ?, resolved_spec_hash = ?, restore_policy = ?," : ""}
|
||||
occupant_lifecycle = 'active', continuity_outcome = 'fresh', handover_result = 'complete', previous_occupant = ?, handover_at = ? WHERE id = ?`)
|
||||
.run(target, materials.settings.model.value, materials.settings.effort.value, materials.settings.advisor.value, materials.settings.codexConfigProfile.value,
|
||||
.run(target, materials.settings.model.value, target === "pi" && ctx.input.effort === undefined ? node.effort ?? null : materials.settings.effort.value,
|
||||
target === "pi" && ctx.input.effort === undefined ? readSeatEffort(db, node.id)?.choice ?? null : null, materials.settings.advisor.value, materials.settings.codexConfigProfile.value,
|
||||
...(spec ? [spec.name, spec.version, spec.hash, spec.restorePolicy] : []), ctx.sessionName, at, node.id);
|
||||
db.prepare("UPDATE nodes SET account_home = ?, claude_token_ref = CASE WHEN ? THEN ? ELSE claude_token_ref END WHERE id = ?")
|
||||
.run(ctx.input.accountHome ?? null, ctx.input.accountHome !== undefined || node.accountHome ? 1 : 0,
|
||||
|
||||
@@ -44,10 +44,10 @@ export interface SeatStatus {
|
||||
previous_occupant: string | null;
|
||||
handover_at: string | null;
|
||||
restore_outcome: NodeInventoryEntry["restoreOutcome"];
|
||||
/** Claude and Codex seats: the home (and with it the login) the seat's launches use. Configured, not proof of
|
||||
/** Claude, Codex and Pi seats: the home (and with it the login) the seat's launches use. Configured, not proof of
|
||||
* which login a running process holds. `ignored` is a declared value that isn't an absolute path. */
|
||||
runtime_home?: {
|
||||
variable: "CLAUDE_CONFIG_DIR" | "CODEX_HOME";
|
||||
variable: "CLAUDE_CONFIG_DIR" | "CODEX_HOME" | "PI_CODING_AGENT_DIR";
|
||||
home: string | null;
|
||||
source: "member" | "rig" | "account-switch" | "daemon" | "native_default";
|
||||
ignored?: { source: "member" | "rig" | "account-switch"; value: string };
|
||||
@@ -215,10 +215,11 @@ export class SeatStatusService {
|
||||
const ignored = own.ignored ? { ignored: own.ignored } : {};
|
||||
if (own.home && own.source) return { runtime_home: { variable, home: own.home, source: own.source } };
|
||||
// The daemon's explicit value, as its launches pass it on; else the runtime's native default under HOME.
|
||||
const daemon = this.env[variable];
|
||||
const daemon = runtime === "pi" ? undefined : this.env[variable];
|
||||
if (daemon) return { runtime_home: { variable, home: daemon, source: "daemon", ...ignored } };
|
||||
const home = this.env.HOME;
|
||||
const native = home ? path.join(home, runtime === "codex" ? ".codex" : ".claude") : null;
|
||||
// Unconfigured Pi uses its managed seat directory, not the operator's ~/.pi/agent login.
|
||||
const native = home && runtime !== "pi" ? path.join(home, runtime === "codex" ? ".codex" : ".claude") : null;
|
||||
return { runtime_home: { variable, home: native, source: "native_default", ...ignored } };
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ export class SelfAttachService {
|
||||
runtime: node.runtime ?? opts.runtime,
|
||||
cwd: node.cwd ?? opts.cwd,
|
||||
displayName: opts.displayName,
|
||||
context: this.resolveContext(node.logicalId, rig.rig.name, opts.displayName, opts.context),
|
||||
context: this.resolveContext(node.id, node.logicalId, rig.rig.name, opts.displayName, opts.context),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ export class SelfAttachService {
|
||||
nodeId: node.id,
|
||||
logicalId,
|
||||
displayName: opts.displayName,
|
||||
context: this.resolveContext(logicalId, rig.rig.name, opts.displayName, opts.context),
|
||||
context: this.resolveContext(node.id, logicalId, rig.rig.name, opts.displayName, opts.context),
|
||||
});
|
||||
return attached;
|
||||
});
|
||||
@@ -334,6 +334,7 @@ export class SelfAttachService {
|
||||
}
|
||||
|
||||
private resolveContext(
|
||||
nodeId: string,
|
||||
logicalId: string,
|
||||
rigName: string,
|
||||
displayName?: string,
|
||||
@@ -343,9 +344,16 @@ export class SelfAttachService {
|
||||
return context;
|
||||
}
|
||||
const explicit = context?.attachmentType === "external_cli" ? context.sessionName?.trim() : displayName?.trim();
|
||||
let sessionName = `${logicalId.replace(/\./g, "-")}@${rigName}`;
|
||||
if (explicit) {
|
||||
// Release keeps history; an ordinary re-attach is not an identity repair.
|
||||
const savedName = this.db.prepare("SELECT 1 FROM sessions WHERE node_id=? AND session_name=? LIMIT 1")
|
||||
.get(nodeId, explicit);
|
||||
sessionName = explicit.includes("@") || savedName ? explicit : `${explicit}@${rigName}`;
|
||||
}
|
||||
return {
|
||||
attachmentType: "external_cli",
|
||||
sessionName: explicit || `${logicalId.replace(/\./g, "-")}@${rigName}`,
|
||||
sessionName,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -256,8 +256,9 @@ const CLAUDE_STATUS_WARNINGS = [
|
||||
/^Update available! Run: \S.*$/,
|
||||
// The context hint, as Claude prints it; reported as the last line below the mode bar on one seat.
|
||||
/^new task\? \/clear to save \d+(?:\.\d+)?k tokens$/,
|
||||
// After Claude installs an update: "/restart to apply" (a Claude seat, 2026-10-30) or "Restart to apply".
|
||||
/^✔ Update installed · (?:\/restart|Restart) to apply$/,
|
||||
// After Claude installs an update: "/restart to apply" (a Claude seat, 2026-10-30), "Restart to apply", or
|
||||
// "Restart to update" on its own row (Claude 2.1.319, #2605).
|
||||
/^✔ Update installed · (?:\/restart to apply|Restart to (?:apply|update))$/,
|
||||
];
|
||||
// Claude can draw noninteractive UI rows after its mode footer. They do not
|
||||
// replace the composer and should only be skipped when they match a known row.
|
||||
@@ -1047,6 +1048,21 @@ function safeRecord(sink: CaptureObserverSink, input: ObservationInput): void {
|
||||
try { sink.record(input); } catch { /* observer failure never reaches the caller */ }
|
||||
}
|
||||
|
||||
/** transport.draft_delivery as the daemon reads it. deliver is the product default, so only a readable `refuse` opts out:
|
||||
* an unset, unreadable or invalid value follows the default, as other settings fall back to theirs. */
|
||||
export function draftDeliveryAllows(read: () => unknown): boolean {
|
||||
try { return read() !== "refuse"; } catch { return true; }
|
||||
}
|
||||
|
||||
/** The setting's value, taking a valid OPENRIG_TRANSPORT_DRAFT_DELIVERY first: the environment outranks the file, and the
|
||||
* settings store reads the file before its environment override, so an unreadable config.json would otherwise turn an
|
||||
* explicit environment refuse into the default. Valid means exactly what the store accepts (no trimming); anything else
|
||||
* comes from the store, which drops an invalid environment value for the file's. */
|
||||
export function readDraftDeliverySetting(resolve: () => unknown, env: NodeJS.ProcessEnv = process.env): unknown {
|
||||
const fromEnv = env.OPENRIG_TRANSPORT_DRAFT_DELIVERY;
|
||||
return fromEnv === "refuse" || fromEnv === "deliver" ? fromEnv : resolve();
|
||||
}
|
||||
|
||||
export function mapPaneState(state: PaneActivityClassification["state"]): AgentActivity["state"] {
|
||||
if (state === "agent_active") return "running";
|
||||
if (state === "attention") return "needs_input";
|
||||
@@ -2069,6 +2085,9 @@ interface SessionTransportDeps {
|
||||
runtimeAdapters?: Record<string, TransportReadinessChecker>;
|
||||
/** transport.verify_input_box, read per verified Claude Code or Codex send. Absent means off. */
|
||||
verifyInputBox?: () => boolean;
|
||||
/** Whether transport.draft_delivery lets this send through a person's draft, read per send that meets one. The daemon
|
||||
* wires draftDeliveryAllows (deliver by default); a transport built without it keeps refusing. */
|
||||
deliverOverDraft?: () => boolean;
|
||||
}
|
||||
|
||||
interface SessionRow { node_id: string; session_name: string; }
|
||||
@@ -2122,6 +2141,7 @@ export class SessionTransport {
|
||||
private runtimeAdapters: Record<string, TransportReadinessChecker>;
|
||||
private inFlightReconciliations = new Map<string, Promise<boolean>>();
|
||||
private verifyInputBox?: () => boolean;
|
||||
private deliverOverDraft?: () => boolean;
|
||||
|
||||
constructor(deps: SessionTransportDeps) {
|
||||
this.contextUsageStore = deps.contextUsageStore;
|
||||
@@ -2141,6 +2161,7 @@ export class SessionTransport {
|
||||
this.listProcesses = deps.listProcesses;
|
||||
this.runtimeAdapters = deps.runtimeAdapters ?? {};
|
||||
this.verifyInputBox = deps.verifyInputBox;
|
||||
this.deliverOverDraft = deps.deliverOverDraft;
|
||||
const guard = this.tmuxAdapter.deliveryGuard;
|
||||
if (guard && (this.db.prepare("PRAGMA table_info(outbox_entries)").all() as Array<{ name: string }>).some(c => c.name === "guard_delivery")) this.guardedDelivery = new DraftAwareDelivery(this.db, guard,
|
||||
(session, text, opts) => this.sendUnguarded(session, text, opts));
|
||||
@@ -2904,7 +2925,10 @@ export class SessionTransport {
|
||||
let failure = "";
|
||||
const failed = (message: string) => { failure = ` Leaving it failed: ${message.trim().replace(/\s+/g, " ").replace(/\.$/, "")}.`; };
|
||||
try {
|
||||
const left = typeof this.tmuxAdapter.cancelPaneMode === "function" ? await this.tmuxAdapter.cancelPaneMode(readTarget) : undefined;
|
||||
// Leave through the seat's address, as the text and Enter writes do; the guard writes to its bound pane. A raw
|
||||
// pane id can match a stale binding elsewhere (tmux reuses ids after a server restart), and the guard refuses an
|
||||
// ambiguous target before writing anything, so the mode was never left.
|
||||
const left = typeof this.tmuxAdapter.cancelPaneMode === "function" ? await this.tmuxAdapter.cancelPaneMode(sessionName) : undefined;
|
||||
if (left && !left.ok) failed(left.message);
|
||||
} catch (error) { failed(error instanceof Error ? error.message : String(error)); }
|
||||
if (await readPaneInMode() !== true) return null;
|
||||
@@ -3316,7 +3340,9 @@ export class SessionTransport {
|
||||
if (!audit.ok) sendAdvisory = "prompt-override: audit unavailable (" + audit.reason + "); input sent anyway.";
|
||||
}
|
||||
if (readiness.state === "needs_input") {
|
||||
if (!promptOverride) {
|
||||
if (!promptOverride && this.deliversOverDraft(readiness, opts)) {
|
||||
sendAdvisory = [sendAdvisory, `'${sessionName}' had a draft at its prompt and transport.draft_delivery is deliver: this message was pasted at the current cursor into that draft (normally its end), then Enter was sent to submit the combined input. The delivery result says whether that was confirmed.`].filter(Boolean).join(" ");
|
||||
} else if (!promptOverride) {
|
||||
return {
|
||||
ok: false,
|
||||
sessionName,
|
||||
@@ -3803,6 +3829,15 @@ export class SessionTransport {
|
||||
return paneCommand.replace(/^-/, "");
|
||||
}
|
||||
|
||||
/** transport.draft_delivery = deliver (the default): a person's draft at the prompt is the one needs_input an ordinary send or work
|
||||
* notice goes through. Menus, permission prompts and hook-reported requests still refuse, and so does a send that must
|
||||
* not join a draft (preserveInput: managed compaction). A send that waits for idle keeps waiting. */
|
||||
private deliversOverDraft(readiness: AgentActivity, opts?: SendOpts): boolean {
|
||||
if (readiness.state !== "needs_input" || readiness.reason !== "prompt_draft" || readiness.evidenceSource !== "pane_heuristic") return false;
|
||||
if (opts?.preserveInput) return false;
|
||||
try { return this.deliverOverDraft?.() === true; } catch { return false; }
|
||||
}
|
||||
|
||||
private async classifySendReadiness(input: {
|
||||
sessionName: string;
|
||||
runtime: string | null;
|
||||
|
||||
@@ -206,6 +206,8 @@ export const SETTINGS_VALID_KEYS = [
|
||||
// A verified Claude Code or Codex send reports on input-box evidence; daemon-read, twin of the CLI key.
|
||||
"transport.verify_input_box",
|
||||
"transport.local_user_guard",
|
||||
// Whether an ordinary send or work notice reaches a seat whose prompt holds a person's draft; daemon-read, twin of the CLI key.
|
||||
"transport.draft_delivery",
|
||||
// Slice 22 (OPR.0.7.2.22) — typed decisions (OpenRouter + Jev), off by default; read by the invoking
|
||||
// rig process, never by the daemon. Twin of the CLI keys.
|
||||
"decisions.enabled",
|
||||
@@ -335,6 +337,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
|
||||
"usage.pi_openrouter_charge": { primary: "OPENRIG_USAGE_PI_OPENROUTER_CHARGE" },
|
||||
"transport.local_user_guard": { primary: "OPENRIG_TRANSPORT_LOCAL_USER_GUARD" },
|
||||
"transport.verify_input_box": { primary: "OPENRIG_TRANSPORT_VERIFY_INPUT_BOX" },
|
||||
"transport.draft_delivery": { primary: "OPENRIG_TRANSPORT_DRAFT_DELIVERY" },
|
||||
"decisions.enabled": { primary: "OPENRIG_DECISIONS_ENABLED" },
|
||||
"decisions.consumers": { primary: "OPENRIG_DECISIONS_CONSUMERS" },
|
||||
"decisions.template_dir": { primary: "OPENRIG_DECISIONS_TEMPLATE_DIR" },
|
||||
@@ -436,6 +439,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
|
||||
"usage.pi_openrouter_charge": ["usage", "piOpenRouterCharge"],
|
||||
"transport.local_user_guard": ["transport", "localUserGuard"],
|
||||
"transport.verify_input_box": ["transport", "verifyInputBox"],
|
||||
"transport.draft_delivery": ["transport", "draftDelivery"],
|
||||
"decisions.enabled": ["decisions", "enabled"],
|
||||
"decisions.consumers": ["decisions", "consumers"],
|
||||
"decisions.template_dir": ["decisions", "templateDir"],
|
||||
@@ -749,6 +753,8 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
|
||||
// Input-box evidence for verified Claude Code and Codex sends; off until the switch is on.
|
||||
case "transport.local_user_guard": return false;
|
||||
case "transport.verify_input_box": return false;
|
||||
// Ordinary sends reach a seat with a draft at its prompt unless the setting says refuse.
|
||||
case "transport.draft_delivery": return "deliver";
|
||||
// Slice 22 — typed decisions off, no consumers, shipped templates only.
|
||||
case "decisions.enabled": return false;
|
||||
case "decisions.consumers": return "";
|
||||
@@ -831,6 +837,9 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
|
||||
"daemon.db_conflict": (raw) => {
|
||||
if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop");
|
||||
},
|
||||
"transport.draft_delivery": (raw) => {
|
||||
if (raw !== "refuse" && raw !== "deliver") throw new Error(`Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`);
|
||||
},
|
||||
"transcripts.lines": (raw, value) => {
|
||||
if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 1_000_000) {
|
||||
throw new Error("Invalid transcripts.lines: must be an integer in [1, 1000000]");
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { piAccountStateRoot } from "./domain/pi-account-state.js";
|
||||
import { EnvironmentStartup } from "./domain/environment-startup.js";
|
||||
import { EnvironmentPlatform } from "./domain/environment-platform.js";
|
||||
import { OpenShellDriver } from "./adapters/openshell-driver.js";
|
||||
import { isBoundTerminalSeat } from "./domain/terminal-seat.js";
|
||||
import { seatAccountHome, effectiveRuntimeHome, type HomeRuntime } from "./domain/seat-runtime-home.js";
|
||||
import { seatRuntimeHomeBySession, seatAccountHome, effectiveRuntimeHome, type HomeRuntime } from "./domain/seat-runtime-home.js";
|
||||
import { reportRestoreDeliveryFailure } from "./domain/compaction-restore-notice.js";
|
||||
import { ClaudeTokenUsage, claudeTokenUsageReader } from "./domain/provider/claude-token-usage.js";
|
||||
import type { ClaudeTokenIdentity } from "./domain/provider/provider-types.js";
|
||||
@@ -117,7 +118,7 @@ import { ResumeMetadataRefresher } from "./domain/resume-metadata-refresher.js";
|
||||
import { observeClaudePaneStartedAt } from "./domain/native-process-lineage.js";
|
||||
import { TranscriptStore } from "./domain/transcript-store.js";
|
||||
import { resumeRunningTranscriptCaptures, wireTranscriptCaptureStops } from "./domain/transcript-capture.js";
|
||||
import { probeSessionActivity, SessionTransport, inspectComposerInput } from "./domain/session-transport.js";
|
||||
import { probeSessionActivity, SessionTransport, inspectComposerInput, draftDeliveryAllows, readDraftDeliverySetting } from "./domain/session-transport.js";
|
||||
import { AgentActivityStore } from "./domain/agent-activity-store.js";
|
||||
import { hookOrPaneActivity, nodeExecutorConfirmedStopped } from "./domain/node-inventory.js";
|
||||
import { HistoryQuery } from "./domain/history-query.js";
|
||||
@@ -210,6 +211,7 @@ import { managedBlockRecords } from "./domain/managed-block-records.js";
|
||||
import {
|
||||
getCompatibleOpenRigPath,
|
||||
getDefaultOpenRigPath,
|
||||
getOpenRigHome,
|
||||
readOpenRigEnv,
|
||||
} from "./openrig-compat.js";
|
||||
|
||||
@@ -723,7 +725,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
});
|
||||
const { SeatLaunchEnvironment } = await import("./domain/seat-launch-environment.js");
|
||||
const seatLaunchEnvironment = new SeatLaunchEnvironment(tmuxAdapter, launchSessionEnv, process.cwd(), undefined, configuredCodexHome,
|
||||
(nodeId, runtime, ignoreAccountHome) => ignoreAccountHome
|
||||
(nodeId, runtime, ignoreAccountHome) => runtime === "pi"
|
||||
? effectiveRuntimeHome(db, nodeId, "pi", undefined, ignoreAccountHome)
|
||||
: ignoreAccountHome
|
||||
? effectiveRuntimeHome(db, nodeId, runtime as HomeRuntime, runtime === "codex" ? configuredCodexHome : configuredClaudeHome, true)
|
||||
: seatAccountHome(db, nodeId, runtime));
|
||||
const nodeLauncher = new NodeLauncher({
|
||||
@@ -768,6 +772,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// dist). Shared by the Pi runtime adapter, the resume adapter, and the
|
||||
// resume-token capture sidecar reader.
|
||||
const piStateRoot = nodePath.join(OPENRIG_HOME, "state", "pi");
|
||||
const piStateRootForSession = (session: string) => piAccountStateRoot(db, piStateRoot, session);
|
||||
const ompStateRoot = nodePath.join(OPENRIG_HOME, "state", "omp");
|
||||
const piRunnerEntryPath = nodePath.resolve(import.meta.dirname, "./adapters/pi-runner.js");
|
||||
// OPR.0.7.0.2: Pi seats report cost facts from persisted entries only while usage.pi_cost_facts is on;
|
||||
@@ -784,7 +789,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
tmuxAdapter,
|
||||
{ readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }) },
|
||||
{ stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath },
|
||||
{ seatLaunchEnvironment, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled },
|
||||
{ accountHomeForSession: session => seatRuntimeHomeBySession(db, session, "pi"), stateRootForSession: piStateRootForSession, seatLaunchEnvironment, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled },
|
||||
);
|
||||
const ompResume = new OmpResumeAdapter(
|
||||
tmuxAdapter,
|
||||
@@ -985,7 +990,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
} });
|
||||
// OPR.0.4.6.PI1 — the RPC-first Pi adapter (runner-in-a-pane). Same fsOps
|
||||
// shape as the Codex adapter; seat isolation roots under piStateRoot.
|
||||
const piAdapter = new PiRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
|
||||
const piAdapter = new PiRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRootForSession: piStateRootForSession, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
|
||||
const ompAdapter = new OmpRuntimeAdapter({ managedBlockRecords: guidanceBlockRecords, tmux: tmuxAdapter, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; } }, stateRoot: ompStateRoot, runnerEntryPath: piRunnerEntryPath });
|
||||
// OPR.0.5.1.1 — the stub runtime adapter (Pi-shaped node-script runner in a pane).
|
||||
// Same fsOps shape as Pi; the compiled runner entry lives in the daemon dist.
|
||||
@@ -1578,6 +1583,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
verifyInputBox: () => {
|
||||
try { return new ContextPackSettingsStore().resolveOne("transport.verify_input_box").value === true; } catch { return false; }
|
||||
},
|
||||
// transport.draft_delivery, read at each send that meets a person's draft: only a readable refuse opts out.
|
||||
deliverOverDraft: () => draftDeliveryAllows(() =>
|
||||
readDraftDeliverySetting(() => new ContextPackSettingsStore().resolveOne("transport.draft_delivery").value)),
|
||||
});
|
||||
// PL-004 Phase A revision (R1): wire QueueRepository's wake-path so
|
||||
// create / handoff / handoff-and-complete nudge by default.
|
||||
@@ -2760,18 +2768,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);
|
||||
|
||||
|
||||
@@ -71,11 +71,11 @@ export interface BrokerTmux {
|
||||
stopPipePane(name: string): Promise<TmuxResult>;
|
||||
sendKeys(name: string, keys: string[]): Promise<TmuxResult>;
|
||||
sendText(name: string, text: string): Promise<TmuxResult>;
|
||||
capturePaneScreen(name: string): Promise<string | null>;
|
||||
capturePaneScreen(name: string, opts?: { escapeSequences?: boolean }): Promise<string | null>;
|
||||
getPaneCursorPosition(name: string): Promise<TmuxCursorPosition | null>;
|
||||
/** Capture the last `lines` lines INCLUDING scrollback history (tmux
|
||||
* capture-pane -S -lines). Used for the per-subscriber scroll-back window. */
|
||||
capturePaneContent(name: string, lines: number): Promise<string | null>;
|
||||
capturePaneContent(name: string, lines: number, opts?: { escapeSequences?: boolean }): Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface BrokerOptions {
|
||||
@@ -109,26 +109,25 @@ export function cursorPositionEscape(x: number, y: number): string {
|
||||
* exact same rows tmux has it - never a relative append that drifts as content
|
||||
* scrolls. Normalizes CRLF, drops one trailing print newline, and (when a pane
|
||||
* height is known and the capture is taller) keeps only the last `height` rows.
|
||||
* Lifted from the FR-4 seed work.
|
||||
* SGR carries across captured rows. Replay skipped rows' SGR, and reset only at the paint boundaries.
|
||||
*/
|
||||
export function screenSnapshotEscape(
|
||||
snapshot: string,
|
||||
cursor: { x: number; y: number; height?: number } | null,
|
||||
priorRows = "",
|
||||
): string {
|
||||
const normalized = snapshot.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const withoutTrailingPrintNewline = normalized.endsWith("\n")
|
||||
? normalized.slice(0, -1)
|
||||
: normalized;
|
||||
const rows = withoutTrailingPrintNewline.split("\n");
|
||||
const visibleRows = cursor?.height && rows.length > cursor.height
|
||||
? rows.slice(rows.length - cursor.height)
|
||||
: rows;
|
||||
|
||||
const paintedRows = visibleRows
|
||||
const firstRow = cursor?.height ? Math.max(0, rows.length - cursor.height) : 0;
|
||||
const priorStyle = `${priorRows}\n${rows.slice(0, firstRow).join("\n")}`.match(/\x1b\[[0-9;:]*m/g)?.join("") ?? "";
|
||||
const paintedRows = rows.slice(firstRow)
|
||||
.map((row, index) => `\x1b[${index + 1};1H${row}`)
|
||||
.join("");
|
||||
|
||||
return `\x1b[2J${paintedRows}${cursor ? cursorPositionEscape(cursor.x, cursor.y) : "\x1b[H"}`;
|
||||
return `\x1b[0m\x1b[2J${priorStyle}${paintedRows}\x1b[0m${cursor ? cursorPositionEscape(cursor.x, cursor.y) : "\x1b[H"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,7 +339,7 @@ export class TerminalSessionBroker {
|
||||
// the TOP `rows` would jump a whole extra screen up on the first wheel notch.
|
||||
let content: string | null = null;
|
||||
try {
|
||||
content = await this.tmux.capturePaneContent(this.sessionName, clamped + this.rows);
|
||||
content = await this.tmux.capturePaneContent(this.sessionName, clamped + this.rows, { escapeSequences: true });
|
||||
} catch {
|
||||
content = null;
|
||||
}
|
||||
@@ -350,11 +349,10 @@ export class TerminalSessionBroker {
|
||||
// element is the true live-bottom row and the offset stays honest.
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
const bottom = lines.length - clamped; // exclusive end: the window sits `offset` up
|
||||
const window = bottom <= this.rows
|
||||
? lines.slice(0, this.rows) // scrolled at/past the top of history: oldest screenful
|
||||
: lines.slice(bottom - this.rows, bottom);
|
||||
const firstRow = Math.max(0, bottom - this.rows);
|
||||
const window = lines.slice(firstRow, firstRow + this.rows);
|
||||
try {
|
||||
sub.send(screenSnapshotEscape(window.join("\n"), null));
|
||||
sub.send(screenSnapshotEscape(window.join("\n"), null, lines.slice(0, firstRow).join("\n")));
|
||||
} catch { /* dead subscriber */ }
|
||||
}
|
||||
|
||||
@@ -362,7 +360,7 @@ export class TerminalSessionBroker {
|
||||
private async repaintScreen(sub: TerminalSubscriber): Promise<void> {
|
||||
let snapshot: string | null = null;
|
||||
let cursor: TmuxCursorPosition | null = null;
|
||||
try { snapshot = await this.tmux.capturePaneScreen(this.sessionName); } catch { snapshot = null; }
|
||||
try { snapshot = await this.tmux.capturePaneScreen(this.sessionName, { escapeSequences: true }); } catch { snapshot = null; }
|
||||
try { cursor = await this.tmux.getPaneCursorPosition(this.sessionName); } catch { cursor = null; }
|
||||
if (snapshot !== null) {
|
||||
try { sub.send(screenSnapshotEscape(snapshot, cursor)); } catch { /* dead */ }
|
||||
@@ -475,7 +473,7 @@ export class TerminalSessionBroker {
|
||||
let snapshot: string | null = null;
|
||||
let cursor: TmuxCursorPosition | null = null;
|
||||
try {
|
||||
snapshot = await this.tmux.capturePaneScreen(this.sessionName);
|
||||
snapshot = await this.tmux.capturePaneScreen(this.sessionName, { escapeSequences: true });
|
||||
} catch {
|
||||
snapshot = null;
|
||||
}
|
||||
|
||||
@@ -54,3 +54,35 @@ printf '%s|%s' "$CLAUDE_CONFIG_DIR" "$token"
|
||||
db.close(); rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("Pi down and existing-rig up retain the selected account home", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "pi-account-snapshot-"));
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
const adapter = {
|
||||
runtime: "pi", launchHarness: async () => ({ ok: true }), checkReady: async () => ({ ready: true }),
|
||||
project: async () => ({ projected: [], skipped: [], failed: [] }),
|
||||
deliverStartup: async () => ({ delivered: [], failed: [] }),
|
||||
} as unknown as import("../src/domain/runtime-adapter.js").RuntimeAdapter;
|
||||
const { app, rigRepo, sessionRegistry, snapshotRepo } = createTestApp(db, { adapters: { pi: adapter } });
|
||||
const rig = rigRepo.createRig("pi-account-snapshot");
|
||||
const node = rigRepo.addNode(rig.id, "worker", { runtime: "pi", cwd: root });
|
||||
db.prepare("UPDATE nodes SET account_home = ? WHERE id = ?").run(root, node.id);
|
||||
const session = sessionRegistry.registerSession(node.id, "worker@pi-account-snapshot");
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
db.prepare("UPDATE sessions SET restore_policy = 'relaunch_fresh' WHERE id = ?").run(session.id);
|
||||
const down = await app.request("/api/down", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rigId: rig.id, snapshot: true, force: true }) });
|
||||
expect(down.status).toBe(200);
|
||||
const stopped = await down.json();
|
||||
expect(stopped.errors).toEqual([]);
|
||||
expect(snapshotRepo.getSnapshot(stopped.snapshotId)!.data.nodes[0]).toMatchObject({ accountHome: root, runtime: "pi" });
|
||||
const up = await app.request("/api/up", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceRef: rig.name, existing: true }) });
|
||||
expect(up.status).toBe(200);
|
||||
expect((await up.json()).status).toBe("restored");
|
||||
expect(rigRepo.getEffectiveSeatEnv(node.id).PI_CODING_AGENT_DIR).toBe(root);
|
||||
const status = await (await app.request("/api/seat/status/worker%40pi-account-snapshot")).json();
|
||||
expect(status.runtime_home).toMatchObject({ home: root, source: "account-switch" });
|
||||
} finally { db.close(); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,25 @@ describe("Claude kernel inspection permissions", () => {
|
||||
} });
|
||||
});
|
||||
|
||||
it.each(["kernel", "team"])("passes only the %s policy name and preserves legacy decisions", kind => {
|
||||
const settings = JSON.parse(operationalLaunchArgs("claude-code", kind === "kernel"
|
||||
? { kernelAuthority: true } : { teamPermissionDefault: true })[1]);
|
||||
const hook = settings.hooks.PreToolUse[0].hooks[0];
|
||||
expect(hook.command).toBe([process.execPath, asset, kind].map(shellQuote).join(" "));
|
||||
const legacy = Buffer.from(JSON.stringify(kind === "kernel"
|
||||
? { allow: ["Bash(rig:*)"], ask: [] } : { allow: ["Bash(rig:*)"], ask: ["Bash(rig down:*)"] })).toString("base64");
|
||||
for (const [command, expected] of [["rig down demo", kind === "kernel" ? "allow" : "ask"],
|
||||
["rig down --help", "allow"], ["unknown-command", undefined]]) {
|
||||
for (const argument of [kind, legacy]) {
|
||||
const result = spawnSync(process.execPath, [asset, argument], {
|
||||
input: JSON.stringify({ tool_name: "Bash", tool_input: { command } }), encoding: "utf8",
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout ? JSON.parse(result.stdout).hookSpecificOutput.permissionDecision : undefined).toBe(expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps kernel lifecycle authority distinct from team asks, with no blanket shell grant", () => {
|
||||
const kernel = { allow: KERNEL_CLAUDE_ALLOW, ask: [] };
|
||||
expect(decide("rig down demo", kernel)).toBe("allow");
|
||||
@@ -178,12 +197,13 @@ describe("Claude team command permissions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("is self-contained under an install path with spaces and quotes", () => {
|
||||
it.each(["kernel", "team", "legacy"])("loads %s policy under an install path with spaces and quotes", kind => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "claude-hook 'path "));
|
||||
try {
|
||||
const copy = join(dir, "hook.cjs"); copyFileSync(asset, copy);
|
||||
const encoded = Buffer.from(JSON.stringify(policy)).toString("base64");
|
||||
const result = spawnSync("/bin/sh", ["-c", [process.execPath, copy, encoded].map(shellQuote).join(" ")], {
|
||||
if (kind !== "legacy") copyFileSync(new URL("../assets/claude-permission-policies.cjs", import.meta.url), join(dir, "claude-permission-policies.cjs"));
|
||||
const argument = kind === "legacy" ? Buffer.from(JSON.stringify({ allow: ["Bash(rig:*)"], ask: ["Bash(rig down:*)"] })).toString("base64") : kind;
|
||||
const result = spawnSync("/bin/sh", ["-c", [process.execPath, copy, argument].map(shellQuote).join(" ")], {
|
||||
input: JSON.stringify({ tool_name: "Bash", tool_input: { command: "rig down --help" } }), encoding: "utf8",
|
||||
});
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
|
||||
@@ -214,8 +214,10 @@ const clearHint = "new task? /clear to save 324.1k tokens";
|
||||
// (2026-10-30) and "Restart to apply" in startup-submit-confirmation's screen.
|
||||
const installedSlash = "✔ Update installed · /restart to apply";
|
||||
const installedWord = "✔ Update installed · Restart to apply";
|
||||
// Claude 2.1.319's wording, on its own row under the footer in a 49-column pane (#2605).
|
||||
const installedUpdate = "✔ Update installed · Restart to update";
|
||||
const warningSets = [[update], [focus], [weekly], [update, focus], [updateBanner], [clearHint], [update, updateBanner],
|
||||
[installedSlash], [installedWord], [update, installedSlash]];
|
||||
[installedSlash], [installedWord], [installedUpdate], [update, installedSlash]];
|
||||
// Native, from a Claude seat running a hook mid-turn (2026-10-05): the timer follows a hook label.
|
||||
const hookRow = "✽ Sketching… (running PostToolUse hook · 3m 12s · ↓ 7.9k tokens)";
|
||||
function modePane(hint: string, trailers: string[], composer = "❯\u00a0", before = "● Ready.\n\n✻ Crunched for 2s") {
|
||||
@@ -286,7 +288,7 @@ describe("#808: Claude permission-mode footers below status warnings", () => {
|
||||
|
||||
// The installed-update footer last, as a Claude seat showed it (2026-10-30): an empty prompt under it read unknown,
|
||||
// so managed compaction never saw the idle seat. Only the exact line is a status line; a near miss stays unknown.
|
||||
it.each([installedSlash, installedWord])("reads an empty prompt under the installed-update footer as idle: %s", footer => {
|
||||
it.each([installedSlash, installedWord, installedUpdate])("reads an empty prompt under the installed-update footer as idle: %s", footer => {
|
||||
const content = modePane("⏵⏵ auto mode on (shift+tab to cycle)", [footer]);
|
||||
for (const options of [{ runtime: "claude-code" }, {}]) {
|
||||
expect(classifyPaneActivity(content, options)).toMatchObject({ state: "agent_idle", reason: "idle_prompt", evidence: "❯" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,21 @@ describe("cursor-bound composer input", () => {
|
||||
expect(inspectComposerInput(frame, "codex").state).toBe("unknown");
|
||||
});
|
||||
|
||||
// #2605: Claude 2.1.319 prints "✔ Update installed · Restart to update" on its own row under the footer in a narrow
|
||||
// pane. The draft-aware guard read every idle prompt there as unknown and held each wake as draft_input_unknown.
|
||||
it("reads the input under Claude's own-row Restart to update banner, and nothing like it (#2605)", () => {
|
||||
const banner = (body: string, row = "✔ Update installed · Restart to update") => {
|
||||
const frame = claude(body);
|
||||
frame.screen = frame.screen.replace("? for shortcuts\n", `? for shortcuts\n${row}\n`);
|
||||
return frame;
|
||||
};
|
||||
expect(inspectComposerInput(banner(""), "claude-code").state).toBe("empty");
|
||||
expect(inspectComposerInput(banner("a real draft"), "claude-code").state).toBe("text");
|
||||
for (const nearMiss of ["✔ Update installed · Restart to update now", "Update installed · Restart to update", "✔ Update downloaded · Restart to update"]) {
|
||||
expect(inspectComposerInput(banner("", nearMiss), "claude-code")).toMatchObject({ state: "unknown", unknownReason: "unrecognized_composer" });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps nested prompt examples inside the current draft, including when its head is clipped", () => {
|
||||
const body = "intro\n────────────────────\n❯ \n────────────────────\n? for shortcuts\n────────────────────\n❯ ";
|
||||
const snapshot = claude(body, { x: 4, y: 8 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { piAccountSwitchesSchema } from "../../src/db/migrations/151_pi_account_switches.js";
|
||||
import { activityGrantsSchema } from "../../src/db/migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "../../src/db/migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "../../src/db/migrations/145_node_permissions_to.js";
|
||||
@@ -160,7 +161,7 @@ import fs from "node:fs";
|
||||
|
||||
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
|
||||
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema];
|
||||
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema];
|
||||
|
||||
/**
|
||||
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
|
||||
|
||||
@@ -566,7 +566,7 @@ describe("GF2 — the COMPLETE production-altitude launch/restore matrix", () =>
|
||||
try {
|
||||
const { PiRuntimeAdapter } = await import("../src/adapters/pi-runtime-adapter.js");
|
||||
const { PiResumeAdapter } = await import("../src/adapters/pi-resume.js");
|
||||
const piFs = { readFile: () => "{}", writeFile: () => {}, exists: () => true, mkdirp: () => {}, listFiles: () => [] };
|
||||
const piFs = { readFile: () => "{}", writeFile: () => {}, exists: (file: string) => !file.endsWith("/.openrig-account"), mkdirp: () => {}, listFiles: () => [] };
|
||||
for (const [posture, expectedTrust] of [["full_bypass", "approve"], ["floor", "no-approve"]] as const) {
|
||||
const tmux = mockTmux();
|
||||
const adapter = new PiRuntimeAdapter({ tmux, fsOps: piFs as never, stateRoot: "/tmp/pi-state", runnerEntryPath: "/tmp/pi-runner.js", sleep: async () => {} });
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { preparePiAccount } from "../src/adapters/pi-runner.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => roots.splice(0).forEach(root => fs.rmSync(root, { recursive: true, force: true })));
|
||||
|
||||
it("links only the selected account auth and removes its managed link when cleared", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-"));
|
||||
roots.push(root);
|
||||
const agent = path.join(root, "seat", "agent"), account = path.join(root, "account");
|
||||
fs.mkdirSync(account);
|
||||
fs.writeFileSync(path.join(account, "auth.json"), JSON.stringify({ mistral: { type: "api_key", key: "test-key" } }));
|
||||
preparePiAccount(agent, account);
|
||||
expect(fs.realpathSync(path.join(agent, "auth.json"))).toBe(fs.realpathSync(path.join(account, "auth.json")));
|
||||
preparePiAccount(agent, undefined);
|
||||
expect(fs.existsSync(path.join(agent, "auth.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(account, "auth.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([undefined, ["wrapper", "arg with spaces", "--"]])("preserves the selected account and wrapper when resuming a Pi seat (%j)", async wrapper => {
|
||||
const { PiResumeAdapter } = await import("../src/adapters/pi-resume.js");
|
||||
const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-resume-account-"));
|
||||
roots.push(root);
|
||||
const cli = path.join(root, "rig"), sessionFile = path.join(root, "session.jsonl"), account = path.join(root, "account");
|
||||
fs.writeFileSync(path.join(root, "wrapper"), "#!/bin/sh\nexit 0\n", { mode: 0o700 });
|
||||
fs.writeFileSync(cli, "#!/bin/sh\nexit 0\n", { mode: 0o700 });
|
||||
fs.writeFileSync(sessionFile, "{}");
|
||||
let launched = "";
|
||||
const identity: Record<string, string> = { OPENRIG_NODE_ID: "pi-node", OPENRIG_SESSION_NAME: "build@rig", OPENRIG_RUNTIME: "pi", OPENRIG_OCCUPANT_GENERATION: "generation" };
|
||||
const tmux = {
|
||||
getSessionEnv: async (_session: string, key: string) => identity[key],
|
||||
getPaneCommand: async () => "bash",
|
||||
sendShellCommand: async (_session: string, command: string) => {
|
||||
launched = command;
|
||||
fs.writeFileSync(path.join(root, "account-switches", "committed-generation", "build@rig/runner-state.json"), JSON.stringify({ ready: true, launchId: "attempt", sessionFile, updatedAt: "now" }));
|
||||
return { ok: true as const };
|
||||
},
|
||||
} as unknown as import("../src/adapters/tmux.js").TmuxAdapter;
|
||||
const resumedRoot = path.join(root, "account-switches", "committed-generation");
|
||||
const launch = new SeatLaunchEnvironment(tmux, { PATH: `${root}:/usr/bin`, OPENRIG_HOME: root }, root, cli, undefined,
|
||||
(node, runtime) => node === "pi-node" && runtime === "pi" ? account : undefined);
|
||||
const adapter = new PiResumeAdapter(tmux, {
|
||||
readFile: file => fs.readFileSync(file, "utf8"), writeFile: (file, text) => fs.writeFileSync(file, text),
|
||||
exists: fs.existsSync, mkdirp: file => fs.mkdirSync(file, { recursive: true }),
|
||||
}, { stateRoot: root, runnerEntryPath: "/runner.js" }, { stateRootForSession: () => resumedRoot, seatLaunchEnvironment: launch, newLaunchId: () => "attempt" });
|
||||
expect(await adapter.resume("build@rig", "pi_session_file", sessionFile, root, "mistral/devstral-latest", undefined, undefined, wrapper)).toMatchObject({ ok: true });
|
||||
expect(launched).toContain(`--account-home '${account}'`);
|
||||
expect(launched).toContain(`--state-root '${resumedRoot}'`);
|
||||
if (wrapper) expect(launched).toContain(`--launch-wrapper-arg '${root}/wrapper' --launch-wrapper-arg 'arg with spaces' --launch-wrapper-arg '--'`);
|
||||
let attempts = 0;
|
||||
tmux.sendShellCommand = async () => { attempts++; return { ok: false, code: "launch_path_too_long", message: "fixture bound" }; };
|
||||
expect(await adapter.resume("build@rig", "pi_session_file", sessionFile, root, "mistral/devstral-latest", undefined, undefined, wrapper))
|
||||
.toMatchObject({ ok: false, code: "resume_failed" });
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the previous credentials when a selected account cannot be read, and leaves unselected seat auth alone", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-"));
|
||||
roots.push(root);
|
||||
const agent = path.join(root, "agent"), account = path.join(root, "account");
|
||||
fs.mkdirSync(agent);
|
||||
fs.mkdirSync(account);
|
||||
const auth = path.join(agent, "auth.json");
|
||||
fs.writeFileSync(auth, "seat-auth");
|
||||
preparePiAccount(agent, undefined);
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("seat-auth");
|
||||
expect(() => preparePiAccount(agent, account)).toThrow();
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("seat-auth");
|
||||
preparePiAccount(agent, agent);
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("seat-auth");
|
||||
const alias = path.join(root, "alias");
|
||||
fs.symlinkSync(agent, alias, "junction");
|
||||
preparePiAccount(agent, alias);
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("seat-auth");
|
||||
});
|
||||
|
||||
it.each(["selected", "cleared"])("executes a %s Pi account environment without leaking inherited provider keys", async selection => {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-env-"));
|
||||
roots.push(root);
|
||||
const cli = path.join(root, "rig");
|
||||
fs.writeFileSync(cli, "#!/bin/sh\nexit 0\n", { mode: 0o700 });
|
||||
const tmux = {
|
||||
getPaneCommand: async () => "bash",
|
||||
getSessionEnv: async (_session: string, key: string) => key === "OPENRIG_NODE_ID" ? "pi-node" : "seat@rig",
|
||||
} as unknown as import("../src/adapters/tmux.js").TmuxAdapter;
|
||||
const launch = new SeatLaunchEnvironment(tmux, { PATH: "/usr/bin:/bin", OPENRIG_HOME: root }, root, cli, undefined,
|
||||
(_node, _runtime, clearing) => clearing ? "/pi/configured" : "/pi/selected");
|
||||
const command = await launch.command("seat@rig", "/usr/bin/env", {
|
||||
nodeId: "pi-node", runtime: "pi", ...(selection === "cleared" ? { accountHome: null } : {}),
|
||||
});
|
||||
const output = execFileSync("/bin/sh", ["-c", command], { encoding: "utf8", env: {
|
||||
PATH: "/usr/bin:/bin", PI_CODING_AGENT_DIR: "/pi/stale", MISTRAL_API_KEY: "fixture-mistral", GEMINI_API_KEY: "fixture-gemini", OPENROUTER_API_KEY: "fixture-openrouter", ZAI_API_KEY: "fixture-zai", KIMI_API_KEY: "fixture-kimi", MINIMAX_API_KEY: "fixture-minimax",
|
||||
} });
|
||||
expect(output).toContain(`PI_CODING_AGENT_DIR=/pi/${selection === "cleared" ? "configured" : "selected"}\n`);
|
||||
expect(output).not.toContain("fixture-");
|
||||
});
|
||||
|
||||
it("refuses to replace a seat login or unowned link, and preserves a login created after selection", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-owned-"));
|
||||
roots.push(root);
|
||||
const agent = path.join(root, "agent"), account = path.join(root, "account");
|
||||
fs.mkdirSync(agent); fs.mkdirSync(account);
|
||||
fs.writeFileSync(path.join(account, "auth.json"), "selected-auth");
|
||||
const auth = path.join(agent, "auth.json"), marker = path.join(agent, ".openrig-account");
|
||||
fs.writeFileSync(auth, "seat-login");
|
||||
expect(() => preparePiAccount(agent, account)).toThrow("not a managed account link");
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("seat-login");
|
||||
expect(fs.existsSync(marker)).toBe(false);
|
||||
fs.rmSync(auth); fs.symlinkSync(path.join(account, "auth.json"), auth);
|
||||
expect(() => preparePiAccount(agent, account)).toThrow("not a managed account link");
|
||||
expect(fs.lstatSync(auth).isSymbolicLink()).toBe(true);
|
||||
fs.rmSync(auth);
|
||||
preparePiAccount(agent, account);
|
||||
preparePiAccount(agent, account);
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("selected-auth");
|
||||
fs.rmSync(auth); fs.writeFileSync(auth, "new-seat-login");
|
||||
preparePiAccount(agent, undefined);
|
||||
expect(fs.readFileSync(auth, "utf8")).toBe("new-seat-login");
|
||||
expect(fs.existsSync(marker)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["pi", "codex", "claude-code"])("scopes extra provider-key scrubbing to Pi and resets ambient selection (%s)", async runtime => {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-scope-"));
|
||||
roots.push(root);
|
||||
const cli = path.join(root, "rig");
|
||||
fs.writeFileSync(cli, "#!/bin/sh\nexit 0\n", { mode: 0o700 });
|
||||
const tmux = {
|
||||
getPaneCommand: async () => "bash",
|
||||
getSessionEnv: async (_session: string, key: string) => key === "OPENRIG_NODE_ID" ? "node" : "seat@rig",
|
||||
} as unknown as import("../src/adapters/tmux.js").TmuxAdapter;
|
||||
const launch = new SeatLaunchEnvironment(tmux, { PATH: "/usr/bin:/bin", OPENRIG_HOME: root }, root, cli);
|
||||
const inherited = { PATH: "/usr/bin:/bin", OPENROUTER_API_KEY: "custom-provider-key", OPENAI_API_KEY: "existing-scrub", PI_CODING_AGENT_DIR: "/ambient", OPENRIG_PI_ACCOUNT_HOME: "/stale" };
|
||||
const command = await launch.command("seat@rig", "/usr/bin/env", { nodeId: "node", runtime, accountHome: "/selected" });
|
||||
const output = execFileSync("/bin/sh", ["-c", command], { encoding: "utf8", env: inherited });
|
||||
expect(output).not.toContain("existing-scrub");
|
||||
if (runtime === "pi") {
|
||||
expect(output).not.toContain("custom-provider-key");
|
||||
expect(output).toContain("OPENRIG_PI_ACCOUNT_HOME=/stale\n");
|
||||
const unselected = await launch.command("seat@rig", "/usr/bin/env", { nodeId: "node", runtime });
|
||||
const ambient = execFileSync("/bin/sh", ["-c", unselected], { encoding: "utf8", env: inherited });
|
||||
expect(ambient).toContain("OPENRIG_PI_ACCOUNT_HOME=/stale\n");
|
||||
} else expect(output).toContain("OPENROUTER_API_KEY=custom-provider-key\n");
|
||||
});
|
||||
|
||||
it.each(["nu", "fish", "bash"])("retains the short account-free fallback (%s)", async shell => {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js");
|
||||
const tmux = {
|
||||
getPaneCommand: async () => shell,
|
||||
getSessionEnv: async () => undefined,
|
||||
} as unknown as import("../src/adapters/tmux.js").TmuxAdapter;
|
||||
const launch = new SeatLaunchEnvironment(tmux, {}, "/tmp");
|
||||
const command = await launch.command("seat@rig", "/usr/bin/env", { runtime: "pi" });
|
||||
expect(command).toBe("/usr/bin/env");
|
||||
const output = execFileSync("/bin/sh", ["-c", command], { encoding: "utf8", env: { OPENRIG_PI_ACCOUNT_HOME: "/unselected" } });
|
||||
expect(output).toContain("OPENRIG_PI_ACCOUNT_HOME=/unselected\n"); // The runner ignores this obsolete variable.
|
||||
expect(await launch.command("seat@rig", "/usr/bin/env", { runtime: "pi", accountHome: "/selected" })).toContain("no agent started");
|
||||
});
|
||||
|
||||
it("retries an interrupted initial account link without treating it as a person's own link", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-account-interrupted-")); roots.push(root);
|
||||
const agent = path.join(root, "agent"), account = path.join(root, "account");
|
||||
fs.mkdirSync(account); fs.writeFileSync(path.join(account, "auth.json"), "synthetic-auth");
|
||||
const symlink = fs.symlinkSync;
|
||||
const interruption = vi.spyOn(fs, "symlinkSync").mockImplementation((...args) => {
|
||||
symlink(...args); throw new Error("simulated interruption immediately after linking");
|
||||
});
|
||||
try { expect(() => preparePiAccount(agent, account)).toThrow("simulated interruption"); }
|
||||
finally { interruption.mockRestore(); }
|
||||
preparePiAccount(agent, account);
|
||||
expect(fs.readFileSync(path.join(agent, "auth.json"), "utf8")).toBe("synthetic-auth");
|
||||
expect(fs.readFileSync(path.join(agent, ".openrig-account"), "utf8")).toBe(account);
|
||||
});
|
||||
|
||||
it.each([false, true])("resume tolerates unavailable tmux identity only without a chosen account (%s)", async chosen => {
|
||||
const { PiResumeAdapter } = await import("../src/adapters/pi-resume.js");
|
||||
const { SeatLaunchEnvironment } = await import("../src/domain/seat-launch-environment.js");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-resume-identity-")); roots.push(root);
|
||||
const sessionFile = path.join(root, "session.jsonl"); fs.writeFileSync(sessionFile, "{}");
|
||||
let dispatched = false;
|
||||
const tmux = {
|
||||
getSessionEnv: async () => { throw new Error("metadata unavailable"); },
|
||||
getPaneCommand: async () => "bash",
|
||||
sendShellCommand: async () => {
|
||||
dispatched = true;
|
||||
fs.writeFileSync(path.join(root, "seat@rig/runner-state.json"), JSON.stringify({ ready: true, launchId: "attempt", sessionFile, updatedAt: "now" }));
|
||||
return { ok: true as const };
|
||||
},
|
||||
} as unknown as import("../src/adapters/tmux.js").TmuxAdapter;
|
||||
const launch = new SeatLaunchEnvironment(tmux, {}, root);
|
||||
const adapter = new PiResumeAdapter(tmux, {
|
||||
readFile: file => fs.readFileSync(file, "utf8"), writeFile: fs.writeFileSync,
|
||||
exists: fs.existsSync, mkdirp: file => fs.mkdirSync(file, { recursive: true }),
|
||||
}, { stateRoot: root, runnerEntryPath: "/runner.js" }, {
|
||||
seatLaunchEnvironment: launch, accountHomeForSession: () => chosen ? "/chosen" : undefined, newLaunchId: () => "attempt",
|
||||
});
|
||||
expect(await adapter.resume("seat@rig", "pi_session_file", sessionFile, root)).toMatchObject({ ok: !chosen });
|
||||
expect(dispatched).toBe(!chosen);
|
||||
});
|
||||
|
||||
it("reports a missing prepared auth file in the seat's own home without an uncaught lstat error", async () => {
|
||||
const { main } = await import("../src/adapters/pi-runner.js");
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-own-account-")); roots.push(root);
|
||||
const agent = path.join(root, "seat", "agent");
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const exitCode = process.exitCode;
|
||||
try {
|
||||
await expect(main(["--session-name", "seat", "--state-root", root, "--cwd", root,
|
||||
"--launch-id", "attempt", "--no-approve", "--account-home", agent])).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(2);
|
||||
expect(error.mock.calls.flat().join("\n")).toContain(path.join(agent, "auth.json"));
|
||||
expect(error.mock.calls.flat().join("\n")).toContain("Pi account preparation failed");
|
||||
expect(fs.existsSync(path.join(agent, ".openrig-account"))).toBe(false);
|
||||
expect(() => preparePiAccount(agent, undefined)).not.toThrow();
|
||||
} finally { process.exitCode = exitCode; error.mockRestore(); }
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { preparePiAccount } from "../src/adapters/pi-runner.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const lockModule = require.resolve("proper-lockfile");
|
||||
const lockfile = require("proper-lockfile");
|
||||
const shim = path.resolve(import.meta.dirname, "../assets/pi-account-lock.cjs");
|
||||
const roots: string[] = [];
|
||||
afterEach(() => roots.splice(0).forEach(root => fs.rmSync(root, { recursive: true, force: true })));
|
||||
|
||||
it.each([[false, false], [true, false], [true, true]])("Pi's realpath:false lock across account aliases (shim: %s, linked source: %s)", async (useShim, linkedSource) => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pi-lock-"))); roots.push(root);
|
||||
const account = path.join(root, "account"), a = path.join(root, "a"), b = path.join(root, "b");
|
||||
fs.mkdirSync(account); const source = path.join(account, "auth.json");
|
||||
if (linkedSource) {
|
||||
fs.writeFileSync(path.join(root, "credentials.json"), "{}");
|
||||
fs.symlinkSync("../credentials.json", source);
|
||||
} else fs.writeFileSync(source, "{}");
|
||||
preparePiAccount(a, account); preparePiAccount(b, account);
|
||||
const env = (agentDir: string) => ({ ...process.env, NODE_OPTIONS: useShim ? `--require=${JSON.stringify(shim)}` : "", PI_CODING_AGENT_DIR: agentDir });
|
||||
const child = spawn(process.execPath, ["-e", `
|
||||
const fs = require('node:fs'), lock = require(${JSON.stringify(lockModule)});
|
||||
const auth = ${JSON.stringify(path.join(a, "auth.json"))};
|
||||
const release = lock.lockSync(auth, {realpath:false});
|
||||
fs.writeFileSync(auth, JSON.stringify({first:'refreshed'}));
|
||||
process.send('locked');
|
||||
process.on('message', () => { release(); process.disconnect(); });
|
||||
`], { env: env(a), stdio: ["ignore", "pipe", "pipe", "ipc"] });
|
||||
try {
|
||||
await Promise.race([once(child, "message"), once(child, "exit").then(() => { throw new Error("lock holder exited early"); })]);
|
||||
const probe = execFileSync(process.execPath, ["-e", `
|
||||
const lock = require(${JSON.stringify(lockModule)});
|
||||
(async () => {
|
||||
try { const release = await lock.lock(${JSON.stringify(path.join(b, "auth.json"))}, {realpath:false}); await release(); console.log('acquired'); }
|
||||
catch (e) { if(e.code !== 'ELOCKED') throw e; console.log('blocked'); }
|
||||
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||
`], { env: env(b), encoding: "utf8" });
|
||||
expect(probe.trim()).toBe(useShim ? "blocked" : "acquired");
|
||||
// Native direct use of the chosen account contends with the same lock too.
|
||||
if (useShim) expect(() => lockfile.lockSync(source, { realpath: false })).toThrow(/already being held/);
|
||||
else lockfile.lockSync(source, { realpath: false })();
|
||||
const exited = once(child, "exit"); child.send("release"); await exited;
|
||||
execFileSync(process.execPath, ["-e", `
|
||||
const fs = require('node:fs'), lock = require(${JSON.stringify(lockModule)});
|
||||
const auth = ${JSON.stringify(path.join(b, "auth.json"))};
|
||||
const release = lock.lockSync(auth, {realpath:false});
|
||||
const state = JSON.parse(fs.readFileSync(auth)); state.second = 'refreshed';
|
||||
fs.writeFileSync(auth, JSON.stringify(state)); release();
|
||||
`], { env: env(b) });
|
||||
expect(JSON.parse(fs.readFileSync(source, "utf8"))).toEqual({ first: "refreshed", second: "refreshed" });
|
||||
expect(fs.existsSync(source + ".lock")).toBe(false);
|
||||
expect(fs.existsSync(path.join(a, "auth.json.lock"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(b, "auth.json.lock"))).toBe(false);
|
||||
} finally { child.kill(); }
|
||||
}, 10000);
|
||||
|
||||
// Exercise the process boundary used by Pi's bash tool without provider access.
|
||||
it("consumes only its preload and leaves Node tools and nested unselected Pi untouched", () => {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pi-child-"))); roots.push(root);
|
||||
const account = path.join(root, "account"), agent = path.join(root, "agent");
|
||||
fs.mkdirSync(account); fs.writeFileSync(path.join(account, "auth.json"), "{}");
|
||||
preparePiAccount(agent, account);
|
||||
const nested = path.join(root, "nested");
|
||||
fs.mkdirSync(nested); fs.writeFileSync(path.join(nested, "auth.json"), "{}");
|
||||
const probe = `
|
||||
const fs = require('node:fs');
|
||||
const native = Function.prototype.toString.call(fs.mkdirSync);
|
||||
if (native.includes('lockPath')) throw new Error('filesystem was patched');
|
||||
if (process.env.NODE_OPTIONS !== '--no-warnings') throw new Error('other options lost');
|
||||
const lock = require(${JSON.stringify(lockModule)});
|
||||
lock.lockSync(${JSON.stringify(path.join(nested, "auth.json"))}, {realpath:false})();
|
||||
`;
|
||||
execFileSync(process.execPath, ["-e", `
|
||||
const {execFileSync} = require('node:child_process');
|
||||
for (const dir of [process.env.PI_CODING_AGENT_DIR, ${JSON.stringify(nested)}, '/missing/pi-home']) {
|
||||
execFileSync(process.execPath, ['-e', ${JSON.stringify(probe)}], {
|
||||
env: {...process.env, PI_CODING_AGENT_DIR: dir}
|
||||
});
|
||||
}
|
||||
`], { env: { ...process.env, PI_CODING_AGENT_DIR: agent,
|
||||
NODE_OPTIONS: `--no-warnings --require=${JSON.stringify(shim)}` } });
|
||||
// A wrapper may preload into an unrelated process itself: this must be harmless too.
|
||||
execFileSync(process.execPath, ["-e", probe], { env: { ...process.env,
|
||||
PI_CODING_AGENT_DIR: nested, NODE_OPTIONS: `--require=${JSON.stringify(shim)} --no-warnings` } });
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { piAccountSwitchesSchema } from "../src/db/migrations/151_pi_account_switches.js";
|
||||
|
||||
it("retains an interrupted runtime switch when upgrading to Pi-capable switches", () => {
|
||||
const db = createDb();
|
||||
try {
|
||||
migrate(db, ALL_MIGRATIONS.filter(migration => migration.name !== piAccountSwitchesSchema.name));
|
||||
db.exec("INSERT INTO rigs (id, name) VALUES ('rig', 'upgrade'); INSERT INTO nodes (id, rig_id, logical_id) VALUES ('seat', 'rig', 'dev.build');");
|
||||
const insert = db.prepare(`INSERT INTO seat_runtime_switches
|
||||
(id, node_id, canonical_session, staging_session, predecessor_pane, predecessor_generation, successor_generation, from_runtime, to_runtime, reason, plan_json, deadline_at)
|
||||
VALUES (?, 'seat', 'build@upgrade', ?, '%0', 'before', ?, 'codex', ?, 'allowance', '{}', '2026-11-03T03:00:00Z')`);
|
||||
insert.run("old", "staging-old", "after-old", "claude-code");
|
||||
const before = db.prepare("SELECT * FROM seat_runtime_switches WHERE id = 'old'").get();
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
expect(db.prepare("SELECT * FROM seat_runtime_switches WHERE id = 'old'").get()).toEqual(before);
|
||||
expect(() => insert.run("duplicate", "staging-new", "after-new", "pi")).toThrow();
|
||||
db.exec("UPDATE seat_runtime_switches SET phase = 'abandoned' WHERE id = 'old'");
|
||||
insert.run("pi", "staging-new", "after-new", "pi");
|
||||
expect(db.prepare("SELECT to_runtime FROM seat_runtime_switches WHERE id = 'pi'").get()).toEqual({ to_runtime: "pi" });
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
expect(db.pragma("foreign_key_check")).toEqual([]);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
@@ -54,7 +54,7 @@ else { process.stdin.resume(); console.error('Error: Unknown options: --name, --
|
||||
fileURLToPath(new URL("../src/adapters/pi-runner.ts", import.meta.url)),
|
||||
"--session-name", "fixture", "--state-root", stateRoot, "--cwd", temp,
|
||||
"--launch-id", "path-attempt", "--no-approve"], {
|
||||
env: { PATH: searchPath, HOME: temp, OPENRIG_HOME: path.join(temp, "home") },
|
||||
env: { OPENRIG_PI_ACCOUNT_HOME: "/ignored/ambient", PI_CODING_AGENT_DIR: "/ambient/must-not-be-used", PATH: searchPath, HOME: temp, OPENRIG_HOME: path.join(temp, "home") },
|
||||
detached: true, stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let output = "", ready = false, timedOut = false;
|
||||
@@ -93,6 +93,8 @@ else { process.stdin.resume(); console.error('Error: Unknown options: --name, --
|
||||
const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pi-wrap-child-")));
|
||||
const first = path.join(temp, "first"), second = path.join(temp, "second");
|
||||
fs.mkdirSync(first); fs.mkdirSync(second);
|
||||
const account = path.join(temp, "account"); fs.mkdirSync(account);
|
||||
fs.writeFileSync(path.join(account, "auth.json"), "synthetic-selected-account");
|
||||
const session = path.join(temp, "session.jsonl");
|
||||
fs.writeFileSync(session, "fixture\n");
|
||||
const piLogPath = path.join(temp, "pi-child-log.json");
|
||||
@@ -150,6 +152,7 @@ WRAPPER_SH_PID="$$" WRAPPER_PPID="$PPID" ${quote(process.execPath)} -e '
|
||||
wrapperPpid: Number(process.env.WRAPPER_PPID),
|
||||
wrapperArgs: argv.slice(0, sep),
|
||||
execArgv: argv.slice(sep + 1),
|
||||
auth: fs.readFileSync(require("node:path").join(process.env.PI_CODING_AGENT_DIR, "auth.json"), "utf8"),
|
||||
cwd: process.cwd(),
|
||||
tty: [0, 1, 2].map(fd => Boolean(tty.isatty(fd))),
|
||||
}));
|
||||
@@ -181,8 +184,9 @@ exec "$@"
|
||||
"--launch-wrapper-arg", "--",
|
||||
];
|
||||
|
||||
runnerArgv.push("--account-home", account);
|
||||
const child = spawn(process.execPath, runnerArgv, {
|
||||
env: { PATH: [first, second, "/usr/bin", "/bin"].join(path.delimiter), HOME: temp, OPENRIG_HOME: path.join(temp, "home"), OPENRIG_NODE_ID: "node-1" },
|
||||
env: { OPENRIG_PI_ACCOUNT_HOME: "/ignored/ambient", PI_CODING_AGENT_DIR: "/ambient/must-not-be-used", PATH: [first, second, "/usr/bin", "/bin"].join(path.delimiter), HOME: temp, OPENRIG_HOME: path.join(temp, "home"), OPENRIG_NODE_ID: "node-1" },
|
||||
detached: true, stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let output = "", ready = false, timedOut = false;
|
||||
@@ -205,6 +209,8 @@ exec "$@"
|
||||
|
||||
const wrapperRec = JSON.parse(fs.readFileSync(wrapperLogPath, "utf8"));
|
||||
const piRec = JSON.parse(fs.readFileSync(piLogPath, "utf8"));
|
||||
expect(wrapperRec.auth).toBe("synthetic-selected-account");
|
||||
expect(fs.realpathSync(path.join(seat.agentDir, "auth.json"))).toBe(fs.realpathSync(path.join(account, "auth.json")));
|
||||
const expectedPiPath = mode === "mise" ? fs.realpathSync(good) : fs.realpathSync(path.join(first, "pi"));
|
||||
const expectedChildArgs = buildPiChildArgs({
|
||||
sessionsDir: seat.sessionsDir,
|
||||
|
||||
@@ -42,6 +42,7 @@ function mockTmux(overrides?: {
|
||||
capturePaneContent: overrides?.capturePaneContent ?? vi.fn(async () => ""),
|
||||
hasSession: overrides?.hasSession ?? vi.fn(async () => true),
|
||||
getPaneCommand: overrides?.getPaneCommand ?? vi.fn(async () => "node"),
|
||||
getSessionEnv: async (_session: string, key: string) => key === "OPENRIG_NODE_ID" ? "node-pi" : undefined,
|
||||
createSession: async () => ({ ok: true as const }),
|
||||
killSession: async () => ({ ok: true as const }),
|
||||
listSessions: async () => [],
|
||||
@@ -841,6 +842,7 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => {
|
||||
const commandCalls: Array<{ session: string; cmd: string; target: unknown }> = [];
|
||||
const resolveLaunchWrapper = vi.fn((w: readonly string[]) => [`/resolved/bin/${w[0]}`, ...w.slice(1)]);
|
||||
const seatLaunchEnvironment = {
|
||||
accountHome: () => undefined,
|
||||
resolveLaunchWrapper,
|
||||
command: vi.fn(async (session: string, cmd: string, target: unknown) => {
|
||||
commandCalls.push({ session, cmd, target });
|
||||
@@ -947,6 +949,7 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => {
|
||||
|
||||
it("PiRuntimeAdapter retains launch_path_too_long bare runner retry when wrapped because the raw runner command carries the exact same --launch-wrapper-arg flags", async () => {
|
||||
const seatLaunchEnvironment = {
|
||||
accountHome: () => undefined,
|
||||
resolveLaunchWrapper: vi.fn((w: readonly string[]) => [`/abs/bin/${w[0]}`, ...w.slice(1)]),
|
||||
command: vi.fn(async (_s: string, cmd: string) => `/usr/bin/env 'OPENRIG_RUNTIME=pi' ${cmd}`),
|
||||
} as never;
|
||||
@@ -1008,6 +1011,7 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => {
|
||||
const commandCalls: Array<{ session: string; cmd: string; target: unknown }> = [];
|
||||
const resolveLaunchWrapper = vi.fn((w: readonly string[]) => [`/abs/bin/${w[0]}`, ...w.slice(1)]);
|
||||
const seatLaunchEnvironment = {
|
||||
accountHome: () => undefined,
|
||||
resolveLaunchWrapper,
|
||||
command: vi.fn(async (session: string, cmd: string, target: unknown) => {
|
||||
commandCalls.push({ session, cmd, target });
|
||||
@@ -1032,7 +1036,7 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => {
|
||||
expect(commandCalls).toEqual([{
|
||||
session: SESSION,
|
||||
cmd: expect.stringContaining("--approve --model 'zai/glm-5.2' --thinking 'high' --session"),
|
||||
target: { runtime: "pi" },
|
||||
target: { runtime: "pi", nodeId: "node-pi", accountHome: undefined },
|
||||
}]);
|
||||
const expectedFlags = "--launch-wrapper-arg '/abs/bin/my-wrapper' --launch-wrapper-arg '--opt' --launch-wrapper-arg '--'";
|
||||
expect(sendShellCommand.mock.calls[0]![1]).toContain("node '/daemon-dist/adapters/pi-runner.js'");
|
||||
@@ -1094,3 +1098,40 @@ describe("PiRuntimeAdapter and PiResumeAdapter launch_wrapper", () => {
|
||||
expect(tooLongSend.mock.calls[1]![1]).toContain(expectedFlags);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["selected", "cleared"])("does not bypass a %s Pi account when a wrapped launch cannot be staged", async selection => {
|
||||
const fs = memFs();
|
||||
const accountHome = selection === "cleared" ? null : "/accounts/pi";
|
||||
const sendShellCommand = vi.fn(async (): Promise<TmuxResult> => ({ ok: false, code: "launch_path_too_long", message: "fixture bound" }));
|
||||
const command = vi.fn(async (_session: string, cmd: string) => `/usr/bin/env ${cmd}`);
|
||||
const adapter = new PiRuntimeAdapter({
|
||||
tmux: mockTmux({ sendShellCommand }), fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER,
|
||||
seatLaunchEnvironment: {
|
||||
accountHome: () => "/accounts/previous", resolveLaunchWrapper: () => ["/wrapper", "--"], command,
|
||||
} as never,
|
||||
});
|
||||
const result = await adapter.launchHarness({ tmuxSession: SESSION, nodeId: "node-pi", cwd: "/work", accountHome, launchWrapper: ["wrapper", "--"] } as never, { name: SESSION });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(command.mock.calls[0]).toEqual([SESSION, expect.stringContaining("--launch-wrapper-arg '/wrapper'"), expect.objectContaining({ accountHome })]);
|
||||
expect(sendShellCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stages a Pi account successor with canonical identity and a separate state directory", async () => {
|
||||
const fs = memFs();
|
||||
const predecessorPath = piSeatPaths(STATE_ROOT, SESSION).runnerStatePath;
|
||||
fs.files[predecessorPath] = readyState(SESSION_FILE, "old-launch");
|
||||
const stagedRoot = `${STATE_ROOT}/account-switches/new-generation`;
|
||||
const stagedPath = piSeatPaths(stagedRoot, SESSION).runnerStatePath;
|
||||
const token = `${stagedRoot}/${SESSION}/sessions/new.jsonl`;
|
||||
const sendShellCommand = vi.fn(async (target: string, text: string) => {
|
||||
expect(target).toBe("staging-pane");
|
||||
expect(text).toContain(`--session-name '${SESSION}' --state-root '${stagedRoot}'`);
|
||||
fs.files[stagedPath] = readyState(token, launchIdFrom(text));
|
||||
return { ok: true as const };
|
||||
});
|
||||
const adapter = new PiRuntimeAdapter({ tmux: mockTmux({ sendShellCommand }), fsOps: fs, stateRoot: STATE_ROOT, runnerEntryPath: RUNNER });
|
||||
const binding = { tmuxSession: "staging-pane", launchGeneration: "new-generation", cwd: "/work", runtimeSwitchFrom: { runtime: "pi", session: SESSION, pane: "%0", generation: "old-generation" } } as never;
|
||||
expect(await adapter.launchHarness(binding, { name: SESSION })).toMatchObject({ ok: true, resumeToken: token });
|
||||
expect(await adapter.checkReady(binding)).toMatchObject({ ready: true });
|
||||
expect(fs.files[predecessorPath]).toBe(readyState(SESSION_FILE, "old-launch"));
|
||||
});
|
||||
@@ -30,7 +30,7 @@ describe.skipIf(!hasTmux || process.platform === "win32")("Pi launch through nat
|
||||
const launchMode = mode.split("-")[0];
|
||||
const longTmp = mode.endsWith("long-tmpdir");
|
||||
const routing = mode.includes("-routing");
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-shell-"));
|
||||
const temp = fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMUX_TMPDIR ?? (process.platform === "darwin" ? "/private/tmp" : os.tmpdir()), "pi-shell-")));
|
||||
const socket = path.join(temp, "tmux.sock");
|
||||
const session = "pi-fixture";
|
||||
const long = Array.from({ length: 14 }, () => "nested-directory-" + "x".repeat(35)).join(path.sep);
|
||||
@@ -181,7 +181,7 @@ setInterval(() => {}, 1000);
|
||||
|
||||
it.each(["fresh", "fork", "resume", "staged-long", "long-tmpdir-retry"] as const)("executes launch_wrapper in native tmux for Pi %s with real host runner and wrapped Pi child over pipe stdio", async mode => {
|
||||
const launchMode = mode === "staged-long" || mode === "long-tmpdir-retry" ? "fresh" : mode;
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-wrapper-native-"));
|
||||
const temp = fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMUX_TMPDIR ?? (process.platform === "darwin" ? "/private/tmp" : os.tmpdir()), "pi-w-")));
|
||||
const socket = path.join(temp, "tmux.sock");
|
||||
const session = "pi-wrapped";
|
||||
const cwd = path.join(temp, "work dir with 'quotes'");
|
||||
@@ -305,7 +305,7 @@ await main();
|
||||
const wrapper = ["generic-wrapper", "arg with spaces", "quote's", "$UNEXPANDED;false", "", "--"];
|
||||
const model =
|
||||
mode === "staged-long" ? "provider/model-" + "m".repeat(1200)
|
||||
: mode === "long-tmpdir-retry" ? "provider/model-" + "m".repeat(300)
|
||||
: mode === "long-tmpdir-retry" ? "provider/model-" + "m".repeat(100)
|
||||
: "provider/model-short";
|
||||
|
||||
try {
|
||||
@@ -414,7 +414,7 @@ await main();
|
||||
}, 15_000);
|
||||
|
||||
it.each(["missing-wrapper", "failing-wrapper-exit-7", "non-exec-zero-exit", "long-tmpdir-too-long"] as const)("fails closed in native tmux on %s without starting bare Pi", async failure => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "pi-wrapper-fail-"));
|
||||
const temp = fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMUX_TMPDIR ?? (process.platform === "darwin" ? "/private/tmp" : os.tmpdir()), "pi-f-")));
|
||||
const socket = path.join(temp, "tmux.sock");
|
||||
const session = "pi-fail";
|
||||
const cwd = path.join(temp, "work");
|
||||
@@ -535,7 +535,7 @@ await main();
|
||||
}, 15_000);
|
||||
|
||||
it.each(["fresh", "fork", "resume"] as const)("executes launch_wrapper in native tmux for Codex %s with resolved interpreter+entry, intact pid, cwd, tty, env and exit code", async mode => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "codex-wrapper-native-"));
|
||||
const temp = fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMUX_TMPDIR ?? (process.platform === "darwin" ? "/private/tmp" : os.tmpdir()), "cx-w-")));
|
||||
const socket = path.join(temp, "tmux.sock");
|
||||
const session = "codex-wrapped";
|
||||
const cwd = path.join(temp, "codex work 'quotes'");
|
||||
|
||||
@@ -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
@@ -123,7 +123,7 @@ it("export helper resolves executable from cwd when PATH has empty segments", ()
|
||||
});
|
||||
|
||||
|
||||
it.each(["nu", "nu.exe", "fish", "csh", "", "unknown"])("Pi preserves its existing command on an unsupported pane: %s", async shell => {
|
||||
it.each(["nu", "nu.exe", "fish", "csh", "", "unknown"])("Pi preserves its account-free command on an unsupported pane: %s", async shell => {
|
||||
const f = fixture(); f.tmux.getPaneCommand.mockResolvedValue(shell);
|
||||
const command = "node '/runner.js' --session '/history.jsonl'";
|
||||
expect(await f.launch.command("seat@rig", command, { runtime: "pi" })).toBe(command);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { claudeConfigRootFor, declaredRuntimeHomes, effectiveRuntimeHome, seatAccountHome, seatRuntimeHome, withoutRuntimeHomes } from "../src/domain/seat-runtime-home.js";
|
||||
import { claudeConfigRootFor, declaredRuntimeHomes, effectiveRuntimeHome, seatAccountHome, seatRuntimeHome, withoutRuntimeHomes, withoutIgnoredRuntimeHomes } from "../src/domain/seat-runtime-home.js";
|
||||
|
||||
// A seat's own runtime home comes from its stored `env:`: the member's value, else the rig's.
|
||||
const open: Database.Database[] = [];
|
||||
@@ -83,6 +83,16 @@ describe("withoutRuntimeHomes and claudeConfigRootFor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves Pi member homes and clears a stored selection back to the configured home", () => {
|
||||
const d = db({ rig: { PI_CODING_AGENT_DIR: "/pi/rig" }, members: { a: { PI_CODING_AGENT_DIR: "/pi/member" }, b: undefined } });
|
||||
d.exec("ALTER TABLE nodes ADD COLUMN account_home TEXT; ALTER TABLE nodes ADD COLUMN runtime TEXT");
|
||||
d.prepare("UPDATE nodes SET account_home = ?, runtime = 'pi' WHERE id = 'a'").run("/pi/account");
|
||||
expect(effectiveRuntimeHome(d, "a", "pi", "/pi/daemon")).toBe("/pi/account");
|
||||
expect(effectiveRuntimeHome(d, "a", "pi", "/pi/daemon", true)).toBe("/pi/member");
|
||||
expect(seatRuntimeHome(d, "b", "pi")).toEqual({ home: "/pi/rig", source: "rig" });
|
||||
expect(withoutRuntimeHomes({ PI_CODING_AGENT_DIR: "/pi/account", DEBUG: "1" })).toEqual({ DEBUG: "1" });
|
||||
});
|
||||
|
||||
describe("declaredRuntimeHomes", () => {
|
||||
it("lists each distinct absolute home declared on members and rigs, once", () => {
|
||||
const d = db({ rig: { CODEX_HOME: "/homes/shared" },
|
||||
@@ -91,3 +101,19 @@ describe("declaredRuntimeHomes", () => {
|
||||
expect(declaredRuntimeHomes(d, "claude-code")).toEqual(["/homes/claude"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("never selects an ambient daemon Pi home, including when clearing a stored selection", () => {
|
||||
const d = db({ members: { own: { PI_CODING_AGENT_DIR: "/pi/member" }, plain: undefined } });
|
||||
d.exec("ALTER TABLE nodes ADD COLUMN runtime TEXT; ALTER TABLE nodes ADD COLUMN account_home TEXT; UPDATE nodes SET runtime = 'pi', account_home = '/pi/selected';");
|
||||
expect(effectiveRuntimeHome(d, "own", "pi", "/pi/ambient")).toBe("/pi/selected");
|
||||
expect(effectiveRuntimeHome(d, "own", "pi", "/pi/ambient", true)).toBe("/pi/member");
|
||||
expect(effectiveRuntimeHome(d, "plain", "pi", "/pi/ambient", true)).toBeUndefined();
|
||||
d.exec("UPDATE nodes SET account_home = NULL;");
|
||||
expect(effectiveRuntimeHome(d, "own", "pi", "/pi/ambient")).toBe("/pi/member");
|
||||
expect(effectiveRuntimeHome(d, "plain", "pi", "/pi/ambient")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves relative Pi homes for community harnesses", () => {
|
||||
expect(withoutIgnoredRuntimeHomes({ PI_CODING_AGENT_DIR: "relative/pi-home", CODEX_HOME: "relative/codex", DEBUG: "1" }))
|
||||
.toEqual({ PI_CODING_AGENT_DIR: "relative/pi-home", DEBUG: "1" });
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
import { piAccountStateRoot, piAccountSwitchStateRoot } from "../src/domain/pi-account-state.js";
|
||||
import { piSeatPaths } from "../src/adapters/pi-runner-protocol.js";
|
||||
import { PiRuntimeAdapter } from "../src/adapters/pi-runtime-adapter.js";
|
||||
import { providerRoutes } from "../src/routes/provider.js";
|
||||
// Slice 16: a live seat moves between Codex and Claude Code, successor first. Real repositories on an in-memory
|
||||
// database; tmux and the runtime adapters are fakes that keep which pane each session holds, so the exchange, the
|
||||
@@ -87,7 +90,7 @@ describe("live seat runtime switch", () => {
|
||||
|
||||
const service = (overrides: Partial<ConstructorParameters<typeof SeatRuntimeSwitchService>[0]> = {}) => new SeatRuntimeSwitchService({
|
||||
db, rigRepo, sessionRegistry, discoveryRepo: new DiscoveryRepository(db), eventBus, tmuxAdapter: fakeTmux as unknown as TmuxAdapter, queueRepo,
|
||||
runtimeAdapters: { codex: adapter("codex"), "claude-code": adapter("claude-code") },
|
||||
runtimeAdapters: { codex: adapter("codex"), "claude-code": adapter("claude-code"), pi: adapter("pi") },
|
||||
occupantInvalidator: new DefaultOccupantInvalidator({ enforcer: { invalidateOccupant: vi.fn() }, contextUsage: { invalidateOccupantSidecar: vi.fn() }, queue: queueRepo }),
|
||||
activityOracle: { declareOccupantSwap }, openrigHome: home, readinessTimeoutMs: 1000, confirmPollMs: 1000,
|
||||
sleep: async (ms) => { clock += ms; await onSleep?.(); }, now: () => new Date(clock),
|
||||
@@ -132,6 +135,110 @@ describe("live seat runtime switch", () => {
|
||||
});
|
||||
afterEach(() => { db.close(); fs.rmSync(home, { recursive: true, force: true }); });
|
||||
|
||||
it("switches a Pi account only after confirmation, preserves its seat and selects durable isolated state", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi', model = 'mistral/devstral', effort_choice = 'max', codex_config_profile = NULL WHERE id = ?").run(nodeId);
|
||||
const base = path.join(home, "state", "pi");
|
||||
const original = piSeatPaths(base, SEAT).agentDir;
|
||||
fs.mkdirSync(original, { recursive: true });
|
||||
fs.writeFileSync(path.join(original, "auth.json"), "original-seat-login");
|
||||
fs.writeFileSync(path.join(original, "models.json"), "{\"providers\":{}}");
|
||||
const accountHome = path.join(home, "account"); fs.mkdirSync(accountHome);
|
||||
fs.writeFileSync(path.join(accountHome, "auth.json"), "selected-login");
|
||||
const item = await queueRepo.create({ sourceSession: "orch-lead@seat-rig", destinationSession: SEAT, body: "review" });
|
||||
queueRepo.claim({ qitemId: item.qitemId, destinationSession: SEAT });
|
||||
const target = adapter("pi");
|
||||
vi.mocked(target.launchHarness).mockImplementation(async binding => {
|
||||
expect(binding.accountHome).toBe(accountHome);
|
||||
expect(binding.launchWrapper).toEqual(["wrapper", "--"]);
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBeUndefined();
|
||||
expect(piAccountStateRoot(db, base, SEAT)).toBe(base);
|
||||
expect(fs.readFileSync(path.join(original, "auth.json"), "utf8")).toBe("original-seat-login");
|
||||
const successor = piSeatPaths(piAccountSwitchStateRoot(base, binding.launchGeneration!), SEAT);
|
||||
expect(fs.readFileSync(path.join(successor.agentDir, "models.json"), "utf8")).toBe("{\"providers\":{}}");
|
||||
expect(fs.existsSync(path.join(successor.agentDir, "auth.json"))).toBe(false);
|
||||
fs.mkdirSync(successor.sessionsDir, { recursive: true });
|
||||
const token = path.join(successor.sessionsDir, "conversation.jsonl");
|
||||
fs.writeFileSync(token, "{}");
|
||||
fs.writeFileSync(successor.runnerStatePath, JSON.stringify({ ready: true, sessionFile: token, launchId: "successor", updatedAt: "now" }));
|
||||
return { ok: true, resumeType: "pi_session_file", resumeToken: token };
|
||||
});
|
||||
db.prepare("UPDATE nodes SET launch_wrapper = ? WHERE id = ?").run(JSON.stringify(["wrapper", "--"]), nodeId);
|
||||
const svc = service({ runtimeAdapters: { pi: target } }); successorConfirms(svc);
|
||||
expect(await svc.setRuntime({ seatRef: SEAT, runtime: "pi", accountHome, reason: "Change budget" }))
|
||||
.toMatchObject({ ok: true, changed: true, from: "pi", to: "pi", claimsMoved: 1, recoveryPending: false });
|
||||
expect(nodeRow()).toMatchObject({ runtime: "pi", model: "mistral/devstral", effort: "high", effort_choice: "max" });
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe(accountHome);
|
||||
expect(piAccountStateRoot(db, base, SEAT)).toBe(piAccountSwitchStateRoot(base, openSwitch()!.successor_generation));
|
||||
expect(sessionRegistry.getBindingForNode(nodeId)).toMatchObject({ tmuxSession: SEAT, tmuxPane: "%5" });
|
||||
// A new adapter (as after a daemon restart) finds the committed occupant's sidecar, not the predecessor's.
|
||||
const reader = new PiRuntimeAdapter({ tmux: fakeTmux as unknown as TmuxAdapter, stateRoot: base,
|
||||
stateRootForSession: session => piAccountStateRoot(db, base, session), runnerEntryPath: "/runner.js",
|
||||
fsOps: { exists: fs.existsSync, readFile: p => fs.readFileSync(p, "utf8"), writeFile: fs.writeFileSync, mkdirp: p => fs.mkdirSync(p, { recursive: true }) } });
|
||||
expect(reader.readSessionFile(SEAT)).toMatchObject({ ok: true, sessionFile: expect.stringContaining("account-switches") });
|
||||
const rigId = (db.prepare("SELECT rig_id FROM nodes WHERE id = ?").get(nodeId) as { rig_id: string }).rig_id;
|
||||
const snapshot = new SnapshotRepository(db).findLatestRestoreUsable(rigId);
|
||||
expect(snapshot?.kind).toBe("runtime-switch");
|
||||
expect(snapshot?.data.nodes.find(node => node.id === nodeId)).toMatchObject({ accountHome, runtime: "pi" });
|
||||
expect(fs.readFileSync(path.join(original, "auth.json"), "utf8")).toBe("original-seat-login");
|
||||
});
|
||||
|
||||
it("an unconfirmed Pi switch preserves its old account and state root", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi', account_home = '/accounts/old' WHERE id = ?").run(nodeId);
|
||||
const accountHome = path.join(home, "account"); fs.mkdirSync(accountHome); fs.writeFileSync(path.join(accountHome, "auth.json"), "{}");
|
||||
expect(await service().setRuntime({ seatRef: SEAT, runtime: "pi", accountHome, reason: "Try account", confirmTimeoutMs: 1 }))
|
||||
.toMatchObject({ ok: false, committed: false });
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe("/accounts/old");
|
||||
expect(sessionRegistry.getBindingForNode(nodeId)).toMatchObject({ tmuxPane: "%0" });
|
||||
expect(piAccountStateRoot(db, home, SEAT)).toBe(home);
|
||||
});
|
||||
|
||||
it.each([true, false])("clears a Pi selection preserving source credentials and the original login (configured home: %s)", async configured => {
|
||||
const accountHome = path.join(home, "account"); fs.mkdirSync(accountHome); fs.writeFileSync(path.join(accountHome, "auth.json"), "selected-login");
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi', account_home = ?, env_json = ? WHERE id = ?")
|
||||
.run(accountHome, JSON.stringify(configured ? { PI_CODING_AGENT_DIR: "/accounts/configured" } : {}), nodeId);
|
||||
const base = path.join(home, "state", "pi");
|
||||
const original = piSeatPaths(base, SEAT).agentDir;
|
||||
fs.mkdirSync(original, { recursive: true });
|
||||
fs.writeFileSync(path.join(original, "auth.json"), "original-seat-login");
|
||||
const target = adapter("pi");
|
||||
vi.mocked(target.launchHarness).mockImplementation(async binding => {
|
||||
expect(binding.accountHome).toBeNull();
|
||||
const stagedAuth = path.join(piSeatPaths(piAccountSwitchStateRoot(base, binding.launchGeneration!), SEAT).agentDir, "auth.json");
|
||||
if (configured) expect(fs.existsSync(stagedAuth)).toBe(false);
|
||||
else expect(fs.readFileSync(stagedAuth, "utf8")).toBe("original-seat-login");
|
||||
expect(fs.readFileSync(path.join(original, "auth.json"), "utf8")).toBe("original-seat-login");
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe(accountHome);
|
||||
return { ok: true };
|
||||
});
|
||||
const svc = service({ runtimeAdapters: { pi: target } }); successorConfirms(svc);
|
||||
expect(await svc.setRuntime({ seatRef: SEAT, runtime: "pi", clearAccountHome: true, reason: "Back to configured" })).toMatchObject({ ok: true, changed: true, accountHome: null });
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe(configured ? "/accounts/configured" : undefined);
|
||||
expect(fs.readFileSync(path.join(accountHome, "auth.json"), "utf8")).toBe("selected-login");
|
||||
expect(await svc.setRuntime({ seatRef: SEAT, runtime: "pi", clearAccountHome: true, reason: "Already cleared" })).toMatchObject({ ok: true, changed: false });
|
||||
});
|
||||
|
||||
it("previews Pi account costs without claiming a runtime change or lost model and effort", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi', model = 'mistral/devstral', effort_choice = 'max' WHERE id = ?").run(nodeId);
|
||||
const accountHome = path.join(home, "account"); fs.mkdirSync(accountHome);
|
||||
fs.writeFileSync(path.join(accountHome, "auth.json"), "synthetic-auth");
|
||||
const result = await service({ runtimeAdapters: { pi: adapter("pi") } }).setRuntime({
|
||||
seatRef: SEAT, runtime: "pi", accountHome, reason: "Change account", dryRun: true,
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true, dryRun: true, from: "pi", to: "pi" });
|
||||
if (!result.ok || !result.dryRun) throw new Error("Expected switch preview");
|
||||
expect(result.costs.join("\n")).toContain("keeps its model and saved effort choice");
|
||||
expect(result.costs.join("\n")).not.toMatch(/change_runtime|settings are cleared/);
|
||||
expect(result.settings.model.value).toBe("mistral/devstral");
|
||||
expect(result.settings.effort.value).toBe("max");
|
||||
});
|
||||
|
||||
it("refuses Pi homes without credentials before staging, and keeps cross-runtime Pi switches out of scope", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(nodeId);
|
||||
expect(await service().setRuntime({ seatRef: SEAT, runtime: "pi", accountHome: home, reason: "No auth", dryRun: true })).toMatchObject({ ok: false, code: "invalid_runtime_settings" });
|
||||
expect(await service().setRuntime({ seatRef: SEAT, runtime: "codex", reason: "Change runtime", dryRun: true })).toMatchObject({ ok: false, code: "runtime_switch_unsupported" });
|
||||
expect(fakeTmux.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("switches a Codex seat to another account home on the same runtime, committing only after confirmation", async () => {
|
||||
const accountHome = path.join(home, "api-account");
|
||||
fs.mkdirSync(accountHome);
|
||||
@@ -244,19 +351,21 @@ describe("live seat runtime switch", () => {
|
||||
expect(nodeRow().runtime).toBe("codex");
|
||||
});
|
||||
|
||||
it("the provider-switch route plans the same-runtime account selection through the native switch", async () => {
|
||||
it.each(["codex", "pi"])("the provider-switch route plans a %s account selection and clear", async runtime => {
|
||||
db.prepare("UPDATE nodes SET runtime = ? WHERE id = ?").run(runtime, nodeId);
|
||||
const accountHome = path.join(home, "target-account"); fs.mkdirSync(accountHome);
|
||||
fs.writeFileSync(path.join(accountHome, "auth.json"), "{}");
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
for (const [key, value] of Object.entries({ db, rigRepo, sessionRegistry, eventBus, queueRepo,
|
||||
discoveryRepo: new DiscoveryRepository(db), tmuxAdapter: fakeTmux, runtimeAdapters: { codex: adapter("codex") } })) c.set(key as never, value as never);
|
||||
discoveryRepo: new DiscoveryRepository(db), tmuxAdapter: fakeTmux, runtimeAdapters: { [runtime]: adapter(runtime) } })) c.set(key as never, value as never);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/provider", providerRoutes());
|
||||
const response = await app.request("/api/provider/switch", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ seat: SEAT, toAccount: "api", accountHome, reason: "Use API budget", dryRun: true }) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ ok: true, dryRun: true, from: "codex", to: "codex", accountHome });
|
||||
expect(await response.json()).toMatchObject({ ok: true, dryRun: true, from: runtime, to: runtime, accountHome });
|
||||
expect(openSwitch()).toBeUndefined();
|
||||
const cleared = await app.request("/api/provider/switch", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ seat: SEAT, clearAccountHome: true, reason: "Return to configured login", dryRun: true }) });
|
||||
@@ -631,9 +740,9 @@ describe("live seat runtime switch", () => {
|
||||
guidance: expect.stringMatching(/start it first.*never started.*declare the other runtime.*rig spec.*doesn't carry this seat's edges, settings or queued work/s) });
|
||||
});
|
||||
it("a seat on another runtime", async () => {
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi' WHERE id = ?").run(nodeId);
|
||||
db.prepare("UPDATE nodes SET runtime = 'omp' WHERE id = ?").run(nodeId);
|
||||
expect(await service().setRuntime({ seatRef: SEAT, runtime: "claude-code", reason: "x", dryRun: true }))
|
||||
.toMatchObject({ ok: false, code: "runtime_switch_unsupported", message: expect.stringMatching(/only Codex and Claude Code seats can switch/) });
|
||||
.toMatchObject({ ok: false, code: "runtime_switch_unsupported", message: expect.stringMatching(/only Codex and Claude Code seats can change runtime/) });
|
||||
});
|
||||
it("a target this daemon can't start", async () => {
|
||||
expect(await service({ runtimeAdapters: { codex: adapter("codex") } }).setRuntime({ seatRef: SEAT, runtime: "claude-code", reason: "x", dryRun: true }))
|
||||
@@ -689,6 +798,18 @@ describe("live seat runtime switch", () => {
|
||||
expect(openSwitch()).toMatchObject({ phase: "finished" });
|
||||
});
|
||||
|
||||
it.each(["preparing", "confirmed", "committed"] as const)("recovers a Pi account switch in phase %s from its durable decision", async phase => {
|
||||
record(phase);
|
||||
db.prepare("UPDATE nodes SET runtime = 'pi', account_home = ? WHERE id = ?").run(phase === "committed" ? "/accounts/new" : "/accounts/old", nodeId);
|
||||
db.exec("UPDATE seat_runtime_switches SET from_runtime = 'pi', to_runtime = 'pi'");
|
||||
panes.set(SEAT, phase === "preparing" ? "%0" : "%5");
|
||||
panes.set("openrig-switch-sw1", phase === "preparing" ? "%5" : "%0");
|
||||
expect(await service().recover(nodeId)).toMatchObject({ ok: true });
|
||||
expect(panes.get(SEAT)).toBe(phase === "committed" ? "%5" : "%0");
|
||||
expect(rigRepo.getEffectiveSeatEnv(nodeId).PI_CODING_AGENT_DIR).toBe(phase === "committed" ? "/accounts/new" : "/accounts/old");
|
||||
expect(piAccountStateRoot(db, home, SEAT)).toBe(phase === "committed" ? piAccountSwitchStateRoot(home, "g-next") : home);
|
||||
});
|
||||
|
||||
it("panes that match neither side: nothing is touched, and the seat's status says why", async () => {
|
||||
record("preparing");
|
||||
panes.set(SEAT, "%9");
|
||||
|
||||
@@ -129,12 +129,13 @@ describe("SeatStatusService", () => {
|
||||
own: rigRepo.addNode(rig.id, "dev.own", { runtime: "codex" }),
|
||||
rel: rigRepo.addNode(rig.id, "dev.rel", { runtime: "codex" }),
|
||||
plain: rigRepo.addNode(rig.id, "dev.plain", { runtime: "claude-code" }),
|
||||
pi: rigRepo.addNode(rig.id, "dev.pi", { runtime: "pi" }),
|
||||
term: rigRepo.addNode(rig.id, "dev.term", { runtime: "terminal" }),
|
||||
};
|
||||
for (const [name, node] of Object.entries(nodes)) sessionRegistry.registerSession(node.id, `dev-${name}@home-rig`);
|
||||
db.prepare("UPDATE nodes SET env_json = ? WHERE id = ?").run(JSON.stringify({ CODEX_HOME: "/homes/own" }), nodes.own.id);
|
||||
db.prepare("UPDATE nodes SET env_json = ? WHERE id = ?").run(JSON.stringify({ CODEX_HOME: "rel/codex" }), nodes.rel.id);
|
||||
const svc = new SeatStatusService({ rigRepo, env: { HOME: "/users/op", CODEX_HOME: "/daemon/codex" } });
|
||||
const svc = new SeatStatusService({ rigRepo, env: { HOME: "/users/op", CODEX_HOME: "/daemon/codex", PI_CODING_AGENT_DIR: "/daemon/pi-ignored" } });
|
||||
const status = (ref: string) => {
|
||||
const result = svc.getStatus(ref);
|
||||
if (!result.ok) throw new Error(result.code);
|
||||
@@ -144,6 +145,7 @@ describe("SeatStatusService", () => {
|
||||
expect(status("dev-rel@home-rig").runtime_home).toEqual({ variable: "CODEX_HOME", home: "/daemon/codex", source: "daemon",
|
||||
ignored: { source: "member", value: "rel/codex" } });
|
||||
expect(status("dev-plain@home-rig").runtime_home).toEqual({ variable: "CLAUDE_CONFIG_DIR", home: "/users/op/.claude", source: "native_default" });
|
||||
expect(status("dev-pi@home-rig").runtime_home).toEqual({ variable: "PI_CODING_AGENT_DIR", home: null, source: "native_default" });
|
||||
expect(status("dev-term@home-rig")).not.toHaveProperty("runtime_home");
|
||||
});
|
||||
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user