mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
feat(daemon,cli): PL-004 Phase A coordination primitive system — stream/queue/inbox/outbox + hot-potato strict-rejection
5 SQLite migrations (023-027): stream_items, queue_items, queue_transitions, inbox_entries, outbox_entries. Existing 22 migrations untouched; runner unchanged. 6 host-scoped domain services (Hono-free): - stream-store (L1 append-only intake; idempotent emit; soft archive) - queue-repository (L3 owned-work; transactional handoff; pod-fallback; overdue lookup; nudge/heartbeat tracking) - queue-transition-log (append-only audit; UPDATE/DELETE forbidden) - hot-potato-enforcer (load-bearing API contract: state=done requires closure_reason in 6-value enum; tier→SLA mapping for closure_required_at) - inbox-handler (mailbox; auth hook; idempotent absorb to queue_item) - outbox-handler (sender-side audit; symmetric to inbox) 2 routes wired into createApp: /api/stream, /api/queue (host-scoped, not rig-scoped). Hot-potato structured errors include validReasons enum so operators can self-correct. 9 RigEvent union members appended (stream.emitted, queue.created, queue.handed_off, queue.claimed, queue.unclaimed, qitem.fallback_routed, qitem.closure_overdue, inbox.absorbed, inbox.denied). Existing event types untouched. 2 top-level CLI commands (rig stream + 4 subs; rig queue + 16 subs) backed solely by the daemon HTTP API. POC rigx queue/stream filesystem path preserved untouched; no dual-authority. 68 new tests (8 files): hot-potato-enforcer (19), stream-store (7), queue-repository (12), inbox-handler (8), outbox-handler (6), stream-routes (5), queue-routes (8), coordination-integration (3). Daemon suite 2309/2309 PASS — zero regressions vs baseline. Docs: architecture.md (new Coordination Layer section under §3 schema, new service inventory under §5, event surface in §8, startup sequence in §9), cli-reference.md (new Coordination Primitive section), codemap.md (new migrations + domain services + routes + commands inventory). No-touch surfaces honored: restore-orchestrator, seat-handover-service, snapshot-repository unchanged. No --host promotion. Phase B/C/D/E remain out of scope per IMPL. Source artifact: shared-docs/openrig-work/missions/recursive-self-improvement-v2/work-discovery-and-slice-shaping/slices/coordination-primitive-system-phase-a/ Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
ab822eef53
commit
d8d8ee91c8
30 files changed
+4198
-5
No files matched your search
@@ -207,7 +207,7 @@ The UI is now explorer-first, with a shared drawer plus center-workspace route m
|
||||
|
||||
## 3. Database Schema
|
||||
|
||||
The daemon now has 20 migrations.
|
||||
The daemon now has 27 migrations (22 existing plus 5 added by PL-004 Phase A).
|
||||
|
||||
### Core state tables
|
||||
|
||||
@@ -322,6 +322,35 @@ Migration boundary:
|
||||
- `018_context_usage.ts` adds context-usage persistence.
|
||||
- `019_external_cli_attachment.ts` extends binding rows for external CLI attachment.
|
||||
- `020_rig_services.ts` adds rig-scoped services persistence.
|
||||
- `021_seat_handover_observability.ts` adds seat-handover observability fields.
|
||||
- `022_node_codex_config_profile.ts` adds Codex config-profile field on nodes.
|
||||
- `023_stream_items.ts` through `027_outbox_entries.ts` add the PL-004 Phase A coordination tables (see § Coordination Layer below).
|
||||
|
||||
### Coordination tables (PL-004 Phase A)
|
||||
|
||||
Five host-scoped tables back the coordination primitive:
|
||||
|
||||
**stream_items** (`023_stream_items.ts`)
|
||||
- L1 — append-only intake/audit root.
|
||||
- Columns: `stream_item_id` (ULID PK), `ts_emitted`, `stream_sort_key`, `source_session`, `body`, `format` (default `text`), `hint_type`, `hint_urgency`, `hint_destination`, `hint_tags` (JSON), `interrupt`, `archived_at`.
|
||||
- Items immutable after emit (only `archived_at` may be set).
|
||||
|
||||
**queue_items** (`024_queue_items.ts`)
|
||||
- L3 — owned-work queue.
|
||||
- Columns: `qitem_id` (TEXT PK preserving POC `qitem-YYYYMMDDHHMMSS-<hex>` shape), `ts_created`, `ts_updated`, `source_session`, `destination_session`, `state` (8-value enum), `priority`, `tier`, `tags` (JSON), `blocked_on`, `handed_off_to`, `handed_off_from`, `expires_at`, `chain_of_record` (JSON), `body`, `closure_reason`, `closure_target`, `closure_required_at`, `claimed_at`, `last_nudge_attempt`, `last_nudge_result`, `last_heartbeat`, `resolution`.
|
||||
- State enum: `pending | in-progress | done | blocked | failed | denied | canceled | handed-off`.
|
||||
|
||||
**queue_transitions** (`025_queue_transitions.ts`)
|
||||
- L3 — append-only transition log. Authoritative audit trail for queue state evolution. No UPDATE / DELETE from domain code.
|
||||
- Columns: `transition_id` (AUTOINCREMENT PK), `qitem_id`, `ts`, `state`, `transition_note`, `actor_session`, `closure_reason`, `closure_target`.
|
||||
|
||||
**inbox_entries** (`026_inbox_entries.ts`)
|
||||
- Mailbox-style asynchronous deposit; idempotent on `inbox_id`.
|
||||
- Columns: `inbox_id` (PK), `destination_session`, `sender_session` (authenticated), `body`, `tags` (JSON), `urgency`, `ts_dropped`, `state` (`pending | absorbed | denied`), `absorbed_at`, `absorbed_qitem_id`, `denied_at`, `denied_reason`, `audit_pointer`.
|
||||
|
||||
**outbox_entries** (`027_outbox_entries.ts`)
|
||||
- Sender-side audit; symmetric to inbox; idempotent on `outbox_id`.
|
||||
- Columns: `outbox_id` (PK), `sender_session`, `destination_session`, `body`, `tags` (JSON), `urgency`, `ts_dispatched`, `delivery_state` (`pending | delivered | failed`), `delivered_at`, `audit_pointer`.
|
||||
|
||||
---
|
||||
|
||||
@@ -532,6 +561,17 @@ Three runtime adapters implement the five-method contract: projection, startup d
|
||||
- `ask-service.ts`: context engineering evidence pack — gathers rig summary plus transcript excerpts, chat excerpts, insufficiency state, and guidance. Does NOT call an external LLM.
|
||||
- `chat-repository.ts`: durable rig-scoped chat — CRUD for chat_messages table, SSE-compatible event emission
|
||||
|
||||
### Coordination primitive (PL-004 Phase A)
|
||||
|
||||
Six host-scoped services implementing the SQLite-canonical coordination layer. POC `rigx queue` / `rigx stream` filesystem path remains untouched; daemon `rig queue` / `rig stream` writes only to SQLite. Routes import these; services are Hono-free.
|
||||
|
||||
- `stream-store.ts`: L1 stream — idempotent emit (on `stream_item_id`), chronological list with cursor pagination, archive (soft).
|
||||
- `queue-repository.ts`: L3 queue — create, claim/unclaim, update (general state mutator with hot-potato strict-rejection on `done`), transactional handoff (close source as `handed-off` plus create new owned qitem, single transaction), pod-fallback rerouting, overdue lookup, nudge/heartbeat tracking. Cross-rig validation hook exposed as `validateRig` constructor option.
|
||||
- `queue-transition-log.ts`: append-only state transition log. Used by `queue-repository.ts` and exposed read-only on `queue_repository.transitionLog`.
|
||||
- `hot-potato-enforcer.ts`: pure validator for the load-bearing API contract — `state=done` requires `closure_reason ∈ {handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation}`; `handed_off_to | blocked_on | escalation` additionally require `closure_target`. Tier→SLA mapping for `closure_required_at` lives here.
|
||||
- `inbox-handler.ts`: mailbox handler — authenticated drop (idempotent on `inbox_id`), absorb (promotes pending entry to a queue_item, idempotent), deny (records reason). Auth check is a pluggable constructor hook.
|
||||
- `outbox-handler.ts`: sender-side outbox; idempotent record, mark delivered/failed, list. No event-bus events emitted (pure audit).
|
||||
|
||||
### Resume honesty
|
||||
|
||||
- `native-resume-probe.ts`: honest assessment of whether a harness actually resumed vs fresh-launched — probes pane content for runtime-specific indicators
|
||||
@@ -859,6 +899,13 @@ Present in the union but not yet emitted by production code:
|
||||
- `pod.created`, `pod.deleted`
|
||||
- `continuity.sync`, `continuity.degraded`
|
||||
|
||||
PL-004 Phase A adds 9 coordination events emitted by domain services:
|
||||
- `stream.emitted` (StreamStore.emit)
|
||||
- `queue.created`, `queue.handed_off`, `queue.claimed`, `queue.unclaimed`, `qitem.fallback_routed`, `qitem.closure_overdue` (QueueRepository)
|
||||
- `inbox.absorbed`, `inbox.denied` (InboxHandler)
|
||||
|
||||
Two SSE surfaces stream coordination events: `/api/stream/watch` for new stream items, `/api/queue/watch` for queue/inbox events.
|
||||
|
||||
The event log remains append-only and SQLite-backed. The global SSE stream (`/api/events`) delivers all events; the chat SSE stream (`/api/rigs/:rigId/chat/watch`) delivers chat messages for one rig.
|
||||
|
||||
---
|
||||
@@ -867,7 +914,7 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
|
||||
|
||||
`createDaemon()` now does the following:
|
||||
|
||||
1. Open SQLite and run all 20 migrations.
|
||||
1. Open SQLite and run all 27 migrations (22 existing plus the 5 PL-004 Phase A coordination tables).
|
||||
2. Construct core repositories and legacy services.
|
||||
3. Construct package/bootstrap/discovery services.
|
||||
4. Construct rebooted startup/runtime services:
|
||||
@@ -892,7 +939,8 @@ The event log remains append-only and SQLite-backed. The global SSE stream (`/ap
|
||||
- `SpecLibraryService` (builtin + user roots)
|
||||
- `WhoamiService`
|
||||
8. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
|
||||
9. Build `AppDeps`, enforce shared-DB invariants in `createApp()`, and mount the full route tree including env, specs review/library, and `whoami`.
|
||||
9. Construct PL-004 Phase A coordination services from a shared `QueueRepository` instance (so `InboxHandler.absorb()` and `/api/queue` write to the same repo): `StreamStore`, `QueueRepository`, `InboxHandler`, `OutboxHandler`.
|
||||
10. Build `AppDeps`, enforce shared-DB invariants in `createApp()`, and mount the full route tree including env, specs review/library, `whoami`, plus the PL-004 coordination routes (`/api/stream`, `/api/queue`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -647,6 +647,59 @@ Notes:
|
||||
- `clear` is destructive and rig-scoped. Removes all messages for that rig.
|
||||
- `watch --tmux` starts a dedicated tmux watcher session.
|
||||
|
||||
## Coordination Primitive (PL-004 Phase A)
|
||||
|
||||
Two top-level commands back the SQLite-canonical coordination layer. They speak only to the daemon HTTP API; they do NOT touch the POC `rigx-stream-proto` / `rigx-queue-proto` filesystem state. POC and daemon coexist at OPERATOR level only.
|
||||
|
||||
### `rig stream`
|
||||
|
||||
Usage: `rig stream <subcommand>` — L1 append-only intake stream.
|
||||
|
||||
Subcommands:
|
||||
- `emit --source <session> --body <text> [--hint-destination <session>] [--hint-type <type>] [--hint-urgency <urgency>] [--hint-tags <csv>] [--interrupt] [--id <streamItemId>] [--json]`
|
||||
- `list [--source <session>] [--hint-destination <session>] [--limit <n>] [--after <sortKey>] [--include-archived] [--json]`
|
||||
- `show <streamItemId> [--json]`
|
||||
- `archive <streamItemId> [--json]`
|
||||
|
||||
Notes:
|
||||
- `--id` is for idempotency; same id returns the same row, body of subsequent calls is ignored.
|
||||
- `archive` is soft — the row remains for audit and is excluded from `list` unless `--include-archived` is passed.
|
||||
|
||||
### `rig queue`
|
||||
|
||||
Usage: `rig queue <subcommand>` — L3 owned-work queue plus inbox/outbox.
|
||||
|
||||
Subcommands:
|
||||
- `create --source <session> --destination <session> --body <text> [--priority <p>] [--tier <t>] [--tags <csv>] [--expires-at <iso>] [--id <qitemId>] [--json]`
|
||||
- `claim <qitemId> --destination <session> [--json]` — pending → in-progress; computes closure_required_at from tier
|
||||
- `unclaim <qitemId> --destination <session> [--reason <text>] [--json]` — in-progress → pending
|
||||
- `update <qitemId> --actor <session> --state <state> [--closure-reason <r>] [--closure-target <t>] [--note <text>] [--json]`
|
||||
- `handoff <qitemId> --from <session> --to <session> [--body <text>] [--note <text>] [--priority <p>] [--tier <t>] [--tags <csv>] [--json]` — transactional close-as-handed-off + create-new
|
||||
- `fallback <qitemId> --destination <session> [--reason <text>] [--json]` — reroute to fallback seat
|
||||
- `show <qitemId> [--json]`
|
||||
- `transitions <qitemId> [--json]` — append-only transition log
|
||||
- `list [--destination <session>] [--source <session>] [--state <csv>] [--limit <n>] [--json]`
|
||||
- `overdue [--json]` — in-progress qitems past closure_required_at
|
||||
- `inbox-drop <destinationSession> --sender <session> --body <text> [--tags <csv>] [--urgency <u>] [--audit <pointer>] [--id <inboxId>] [--json]`
|
||||
- `inbox-absorb <inboxId> --receiver <session> [--json]` — promote a pending inbox entry to a queue_item
|
||||
- `inbox-deny <inboxId> --receiver <session> --reason <text> [--json]`
|
||||
- `inbox-pending <destinationSession> [--json]`
|
||||
- `outbox-record --sender <session> --destination <session> --body <text> [--tags <csv>] [--urgency <u>] [--audit <pointer>] [--id <outboxId>] [--json]`
|
||||
- `outbox-list <senderSession> [--limit <n>] [--json]`
|
||||
|
||||
Hot-potato strict-rejection (load-bearing API contract):
|
||||
- `update --state done` REQUIRES `--closure-reason` from one of: `handed_off_to`, `blocked_on`, `denied`, `canceled`, `no-follow-on`, `escalation`. Missing or invalid reason → exit 1 with structured error naming the 6 valid values.
|
||||
- `closure-reason` of `handed_off_to`, `blocked_on`, or `escalation` additionally requires `--closure-target`.
|
||||
- All hot-potato enforcement happens at the daemon domain layer; every surface (CLI, future MCP, future UI) inherits the same guarantee.
|
||||
|
||||
Closure-reason semantics:
|
||||
- `handed_off_to` — work continues at a different seat (target = new owner). `handoff` subcommand is the preferred path; `update` accepts it for non-handoff terminal closures.
|
||||
- `blocked_on` — parked pending another qitem (target = blocker qitem_id).
|
||||
- `denied` — receiver rejected the work.
|
||||
- `canceled` — sender or receiver withdrew.
|
||||
- `no-follow-on` — terminal completion, nothing else needed.
|
||||
- `escalation` — kicked up to a higher tier (target = escalation target).
|
||||
|
||||
## Commands Not Present
|
||||
|
||||
These are not current top-level `rig` commands:
|
||||
|
||||
@@ -82,6 +82,13 @@ Fast lookup:
|
||||
- `packages/daemon/src/db/migrations/018_context_usage.ts`: Database migration for per-node context-usage persistence. Exports: contextUsageSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/019_external_cli_attachment.ts`: Database migration extending bindings for external CLI attachment metadata. Exports: externalCliAttachmentSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/020_rig_services.ts`: Database migration for rig-scoped services records and persisted env receipts. Exports: rigServicesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/021_seat_handover_observability.ts`: Database migration for seat-handover observability fields. Exports: seatHandoverObservabilitySchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/022_node_codex_config_profile.ts`: Database migration adding Codex config-profile field on nodes. Exports: nodeCodexConfigProfileSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/023_stream_items.ts`: PL-004 Phase A — L1 stream items table (append-only intake/audit root). Exports: streamItemsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/024_queue_items.ts`: PL-004 Phase A — L3 queue items table (owned-work queue with closure obligation). Exports: queueItemsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/025_queue_transitions.ts`: PL-004 Phase A — append-only queue state transition log. Exports: queueTransitionsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/026_inbox_entries.ts`: PL-004 Phase A — mailbox-style inbox for asynchronous deposit. Exports: inboxEntriesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/027_outbox_entries.ts`: PL-004 Phase A — sender-side outbox audit. Exports: outboxEntriesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
|
||||
### Domain: Core Runtime
|
||||
- `packages/daemon/src/domain/errors.ts`: Domain-specific error types, currently including `RigNotFoundError`. Exports: RigNotFoundError. Related: none.
|
||||
@@ -180,6 +187,14 @@ Fast lookup:
|
||||
- `packages/daemon/src/domain/ask-service.ts`: Context engineering evidence pack — gathers topology snapshot, transcript excerpts, chat history excerpts, restore metadata. Returns structured context for the agent to reason about. Does NOT call an external LLM. Exports: AskService, AskDeps. Related: `packages/daemon/src/domain/ps-projection.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/history-query.js`.
|
||||
- `packages/daemon/src/domain/chat-repository.ts`: Durable rig-scoped chat messages — CRUD for chat_messages table, monotonic ULID IDs, rig-scoped queries. Exports: ChatRepository, ChatMessage. Related: none (SQLite-only).
|
||||
|
||||
### Domain: Coordination Primitive (PL-004 Phase A)
|
||||
- `packages/daemon/src/domain/stream-store.ts`: L1 coordination stream — append-only intake/audit root. Idempotent emit on stream_item_id, chronological list with cursor pagination, soft archive. Exports: StreamStore, StreamItem, StreamEmitInput, StreamListOptions. Related: `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/queue-repository.ts`: L3 coordination queue — create, claim/unclaim, update with hot-potato strict-rejection, transactional handoff (close source + create new in one transaction), pod-fallback rerouting, overdue lookup. POC qitem_id format preserved. Exports: QueueRepository, QueueRepositoryError, QueueItem, QueueState, QueuePriority, QueueCreateInput, QueueUpdateInput, QueueHandoffInput. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/queue-transition-log.js`, `packages/daemon/src/domain/hot-potato-enforcer.js`.
|
||||
- `packages/daemon/src/domain/queue-transition-log.ts`: Append-only state transition log for queue_items. No UPDATE / DELETE from domain code. Exports: QueueTransitionLog, QueueTransition, QueueTransitionInput. Related: `packages/daemon/src/domain/hot-potato-enforcer.js`.
|
||||
- `packages/daemon/src/domain/hot-potato-enforcer.ts`: Pure validator for the load-bearing hot-potato API contract — state=done requires closure_reason from the 6-value enum. Tier→SLA mapping for closure_required_at. Exports: CLOSURE_REASONS, ClosureReason, validateClosure, isClosureReason, computeClosureRequiredAt. Related: none.
|
||||
- `packages/daemon/src/domain/inbox-handler.ts`: Mailbox handler — authenticated drop (idempotent on inbox_id), absorb (promote pending entry to a queue_item), deny (record reason). Authentication is a pluggable constructor hook. Exports: InboxHandler, InboxHandlerError, InboxEntry, InboxState, InboxDropInput. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/queue-repository.js`.
|
||||
- `packages/daemon/src/domain/outbox-handler.ts`: Sender-side outbox — symmetric to inbox; idempotent record, mark delivered/failed, list. No event-bus events emitted. Exports: OutboxHandler, OutboxHandlerError, OutboxEntry, OutboxDeliveryState, OutboxRecordInput. Related: none (SQLite-only).
|
||||
|
||||
### Routes
|
||||
- `packages/daemon/src/routes/adapters.ts`: Hono route module for adapters API endpoints. Exports: adaptersRoutes. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/routes/agents.ts`: Hono route module for AgentSpec validation. POST `/api/agents/validate` using `validateAgentSpecFromYaml`. Exports: agentsRoutes. Related: `packages/daemon/src/domain/spec-validation-service.js`.
|
||||
@@ -199,6 +214,8 @@ Fast lookup:
|
||||
- `packages/daemon/src/routes/transcripts.ts`: Hono route module for transcript access (`GET /:session/tail`, `/:session/grep`). Resolves session to rig/node, delegates to TranscriptStore. Exports: transcriptRoutes. Related: `packages/daemon/src/domain/transcript-store.js`, `packages/daemon/src/domain/rig-repository.js`.
|
||||
- `packages/daemon/src/routes/ask.ts`: Hono route module for context evidence packs (`POST /`). Delegates to AskService. Exports: askRoutes. Related: `packages/daemon/src/domain/ask-service.js`.
|
||||
- `packages/daemon/src/routes/chat.ts`: Hono route module for durable rig chat (`GET /watch` SSE stream, `POST /send`, `GET /history`, `POST /topic`). Delegates to ChatRepository, streams via SSE. Exports: chatRoutes. Related: `packages/daemon/src/domain/chat-repository.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/stream.ts`: Hono route module for the PL-004 Phase A coordination L1 stream (`POST /emit`, `GET /list`, `GET /:id`, `POST /:id/archive`, `GET /watch` SSE). Host-scoped (no rigId). Exports: streamRoutes. Related: `packages/daemon/src/domain/stream-store.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/queue.ts`: Hono route module for the PL-004 Phase A coordination L3 queue plus inbox/outbox (`POST /create`, `POST /:id/{claim,unclaim,update,handoff,fallback}`, `GET /list`, `GET /overdue`, `GET /:id`, `GET /:id/transitions`, inbox/outbox subroutes, `GET /watch` SSE). Surfaces hot-potato strict-rejection structured errors with validReasons enum. Exports: queueRoutes. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/inbox-handler.js`, `packages/daemon/src/domain/outbox-handler.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/spec-review.ts`: Hono route module for structured review over raw RigSpec / AgentSpec YAML. Accepts `sourceState` and returns review models or validation errors. Exports: specReviewRoutes. Related: `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/routes/spec-library.ts`: Hono route module for the filesystem-backed spec library (`list`, `get`, `review`, `sync`). Enriches service-backed reviews with best-effort compose preview data for managed-app UI surfaces. Exports: specLibraryRoutes. Related: `packages/daemon/src/domain/spec-library-service.js`, `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/routes/whoami.ts`: Hono route module for daemon-backed runtime identity lookup by `nodeId` or `sessionName`. Exports: whoamiRoutes. Related: `packages/daemon/src/domain/whoami-service.js`.
|
||||
@@ -243,6 +260,8 @@ Fast lookup:
|
||||
- `packages/cli/src/commands/capture.ts`: Commander command module for `capture` — capture agent pane content. Supports `--lines N` and `--json`. Exports: captureCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/broadcast.ts`: Commander command module for `broadcast` — send to all agents in a rig or pod. Reports per-session delivery status. Exports: broadcastCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/transcript.ts`: Commander command module for `transcript` — composable transcript access via `--tail N` and `--grep "pattern"`. Exports: transcriptCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/stream.ts`: Commander command module for `rig stream` (PL-004 Phase A) — coordination L1 intake stream. Subcommands: `emit`, `list`, `show`, `archive`. Speaks only to the daemon HTTP API; does not touch POC filesystem state. Exports: streamCommand, StreamDeps. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/queue.ts`: Commander command module for `rig queue` (PL-004 Phase A) — coordination L3 queue plus inbox/outbox. Subcommands: `create`, `claim`, `unclaim`, `update`, `handoff`, `fallback`, `show`, `transitions`, `list`, `overdue`, `inbox-drop`, `inbox-absorb`, `inbox-deny`, `inbox-pending`, `outbox-record`, `outbox-list`. Hot-potato strict-rejection enforced at daemon. Exports: queueCommand, QueueDeps. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/config.ts`: Commander command module for `config` — read/write/reset config keys. Supports `--json`. Exports: configCommand. Related: `packages/cli/src/config-store.js`.
|
||||
- `packages/cli/src/commands/preflight.ts`: Commander command module for `preflight` — system readiness checks. Supports `--json`. Exports: preflightCommand. Related: `packages/cli/src/system-preflight.js`, `packages/cli/src/config-store.js`.
|
||||
- `packages/cli/src/commands/ask.ts`: Commander command module for `ask` — context evidence pack over transcripts + topology + chat. Supports `--json`. Exports: askCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* `rig queue` — coordination primitive L3/inbox/outbox commands (PL-004 Phase A).
|
||||
*
|
||||
* Backed by `/api/queue`. Operates only via the daemon HTTP API.
|
||||
* Does NOT touch the POC `rigx-queue-proto` filesystem state.
|
||||
*
|
||||
* Hot-potato strict-rejection is enforced at the daemon; `update --state done`
|
||||
* without `--closure-reason` returns exit 1 with structured error naming the
|
||||
* 6 valid closure reasons.
|
||||
*/
|
||||
|
||||
export interface QueueDeps extends StatusDeps {}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: QueueDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(body));
|
||||
} else {
|
||||
console.log(JSON.stringify(body, null, 2));
|
||||
}
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
const cmd = new Command("queue").description("Coordination L3 — owned-work queue + inbox/outbox");
|
||||
const getDeps = (): QueueDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("create")
|
||||
.description("Create a new qitem")
|
||||
.requiredOption("--source <session>", "Source session")
|
||||
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
|
||||
.requiredOption("--body <text>", "Qitem body")
|
||||
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
|
||||
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
|
||||
.option("--tags <tags>", "Comma-separated tags")
|
||||
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
|
||||
.option("--id <qitemId>", "Idempotent qitem_id (skip if not provided)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
source: string;
|
||||
destination: string;
|
||||
body: string;
|
||||
priority: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
expiresAt?: string;
|
||||
id?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
|
||||
qitemId: opts.id,
|
||||
sourceSession: opts.source,
|
||||
destinationSession: opts.destination,
|
||||
body: opts.body,
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
expiresAt: opts.expiresAt,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("claim <qitemId>")
|
||||
.description("Claim a qitem (pending → in-progress); computes closure_required_at from tier")
|
||||
.requiredOption("--destination <session>", "Destination session claiming the qitem")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: { destination: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {
|
||||
destinationSession: opts.destination,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("unclaim <qitemId>")
|
||||
.description("Release a claimed qitem (in-progress → pending)")
|
||||
.requiredOption("--destination <session>", "Destination session releasing the qitem")
|
||||
.option("--reason <text>", "Reason for unclaim", "manual")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: { destination: string; reason: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/unclaim`, {
|
||||
destinationSession: opts.destination,
|
||||
reason: opts.reason,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("update <qitemId>")
|
||||
.description("Mutate qitem state. state=done REQUIRES --closure-reason (one of: handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation)")
|
||||
.requiredOption("--actor <session>", "Actor session performing the transition")
|
||||
.requiredOption("--state <state>", "New state: pending | in-progress | done | blocked | failed | denied | canceled | handed-off")
|
||||
.option("--closure-reason <reason>", "Required for state=done")
|
||||
.option("--closure-target <target>", "Required for handed_off_to, blocked_on, escalation")
|
||||
.option("--note <text>", "Transition note for the audit log")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
actor: string;
|
||||
state: string;
|
||||
closureReason?: string;
|
||||
closureTarget?: string;
|
||||
note?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
actorSession: opts.actor,
|
||||
state: opts.state,
|
||||
closureReason: opts.closureReason,
|
||||
closureTarget: opts.closureTarget,
|
||||
transitionNote: opts.note,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("handoff <qitemId>")
|
||||
.description("Transactional handoff: closes source as handed-off + creates new qitem owned by --to")
|
||||
.requiredOption("--from <session>", "Source seat handing off")
|
||||
.requiredOption("--to <session>", "Destination seat receiving the new qitem")
|
||||
.option("--body <text>", "New qitem body (defaults to source body)")
|
||||
.option("--note <text>", "Transition note")
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
from: string;
|
||||
to: string;
|
||||
body?: string;
|
||||
note?: string;
|
||||
priority?: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/handoff`, {
|
||||
fromSession: opts.from,
|
||||
toSession: opts.to,
|
||||
body: opts.body,
|
||||
transitionNote: opts.note,
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("fallback <qitemId>")
|
||||
.description("Reroute a qitem to a fallback destination (e.g. unreachable seat)")
|
||||
.requiredOption("--destination <session>", "Fallback destination seat")
|
||||
.option("--reason <text>", "Reason for fallback", "manual")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: { destination: string; reason: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/fallback`, {
|
||||
fallbackDestination: opts.destination,
|
||||
reason: opts.reason,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("show <qitemId>")
|
||||
.description("Show one qitem")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("transitions <qitemId>")
|
||||
.description("Show the append-only transition log for a qitem")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List qitems with filters")
|
||||
.option("--destination <session>", "Filter by destination session")
|
||||
.option("--source <session>", "Filter by source session")
|
||||
.option("--state <state>", "Filter by state (comma-separated for multiple)")
|
||||
.option("--limit <n>", "Result limit", "100")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
destination?: string;
|
||||
source?: string;
|
||||
state?: string;
|
||||
limit: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
if (opts.destination) params.set("destinationSession", opts.destination);
|
||||
if (opts.source) params.set("sourceSession", opts.source);
|
||||
if (opts.state) params.set("state", opts.state);
|
||||
if (opts.limit) params.set("limit", opts.limit);
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/list?${params.toString()}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("overdue")
|
||||
.description("List in-progress qitems past their closure_required_at deadline")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>("/api/queue/overdue");
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Inbox subcommands ----
|
||||
|
||||
cmd
|
||||
.command("inbox-drop <destinationSession>")
|
||||
.description("Drop a mailbox-style entry into a destination's inbox")
|
||||
.requiredOption("--sender <session>", "Sender session")
|
||||
.requiredOption("--body <text>", "Inbox body")
|
||||
.option("--tags <tags>", "Comma-separated tags")
|
||||
.option("--urgency <urgency>", "routine | urgent | critical", "routine")
|
||||
.option("--audit <pointer>", "Audit pointer reference")
|
||||
.option("--id <inboxId>", "Idempotent inbox_id")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (destinationSession: string, opts: {
|
||||
sender: string;
|
||||
body: string;
|
||||
tags?: string;
|
||||
urgency: string;
|
||||
audit?: string;
|
||||
id?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/queue/inbox/drop", {
|
||||
inboxId: opts.id,
|
||||
destinationSession,
|
||||
senderSession: opts.sender,
|
||||
body: opts.body,
|
||||
tags,
|
||||
urgency: opts.urgency,
|
||||
auditPointer: opts.audit,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("inbox-absorb <inboxId>")
|
||||
.description("Absorb a pending inbox entry into the receiver's main queue")
|
||||
.requiredOption("--receiver <session>", "Receiver session (must match destination)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (inboxId: string, opts: { receiver: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/inbox/${encodeURIComponent(inboxId)}/absorb`, {
|
||||
receiverSession: opts.receiver,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("inbox-deny <inboxId>")
|
||||
.description("Deny a pending inbox entry with a recorded reason")
|
||||
.requiredOption("--receiver <session>", "Receiver session (must match destination)")
|
||||
.requiredOption("--reason <text>", "Reason for denial")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (inboxId: string, opts: { receiver: string; reason: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/inbox/${encodeURIComponent(inboxId)}/deny`, {
|
||||
receiverSession: opts.receiver,
|
||||
reason: opts.reason,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("inbox-pending <destinationSession>")
|
||||
.description("List pending inbox entries for a destination seat")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (destinationSession: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams({ destinationSession });
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/inbox/pending?${params.toString()}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Outbox subcommands ----
|
||||
|
||||
cmd
|
||||
.command("outbox-record")
|
||||
.description("Record an outbound dispatch in the sender's outbox")
|
||||
.requiredOption("--sender <session>", "Sender session")
|
||||
.requiredOption("--destination <session>", "Destination session")
|
||||
.requiredOption("--body <text>", "Body")
|
||||
.option("--tags <tags>", "Comma-separated tags")
|
||||
.option("--urgency <urgency>", "routine | urgent | critical", "routine")
|
||||
.option("--audit <pointer>", "Audit pointer reference")
|
||||
.option("--id <outboxId>", "Idempotent outbox_id")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
sender: string;
|
||||
destination: string;
|
||||
body: string;
|
||||
tags?: string;
|
||||
urgency: string;
|
||||
audit?: string;
|
||||
id?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/queue/outbox/record", {
|
||||
outboxId: opts.id,
|
||||
senderSession: opts.sender,
|
||||
destinationSession: opts.destination,
|
||||
body: opts.body,
|
||||
tags,
|
||||
urgency: opts.urgency,
|
||||
auditPointer: opts.audit,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("outbox-list <senderSession>")
|
||||
.description("List outbox entries for a sender seat")
|
||||
.option("--limit <n>", "Result limit", "100")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (senderSession: string, opts: { limit: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams({ senderSession, limit: opts.limit });
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/outbox/list?${params.toString()}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* `rig stream` — coordination primitive L1 commands (PL-004 Phase A).
|
||||
*
|
||||
* Backed by `/api/stream`. Operates only via the daemon HTTP API.
|
||||
* Does NOT touch the POC `rigx-stream-proto` filesystem state.
|
||||
*/
|
||||
|
||||
export interface StreamDeps extends StatusDeps {}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: StreamDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(body));
|
||||
} else {
|
||||
console.log(JSON.stringify(body, null, 2));
|
||||
}
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
export function streamCommand(depsOverride?: StreamDeps): Command {
|
||||
const cmd = new Command("stream").description("Coordination L1 — append-only intake stream");
|
||||
const getDeps = (): StreamDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("emit")
|
||||
.description("Append a stream item")
|
||||
.requiredOption("--source <session>", "Source session (e.g. velocity-driver@openrig-velocity-claude)")
|
||||
.requiredOption("--body <text>", "Stream item body")
|
||||
.option("--hint-destination <session>", "Hint at intended destination seat")
|
||||
.option("--hint-type <type>", "Hint type (e.g. review, handoff, idea)")
|
||||
.option("--hint-urgency <urgency>", "Hint urgency (routine, urgent, critical)")
|
||||
.option("--hint-tags <tags>", "Comma-separated hint tags")
|
||||
.option("--interrupt", "Mark item as interrupting")
|
||||
.option("--id <streamItemId>", "Idempotent stream_item_id (skip if not provided)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
source: string;
|
||||
body: string;
|
||||
hintDestination?: string;
|
||||
hintType?: string;
|
||||
hintUrgency?: string;
|
||||
hintTags?: string;
|
||||
interrupt?: boolean;
|
||||
id?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const tags = opts.hintTags ? opts.hintTags.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<Record<string, unknown>>("/api/stream/emit", {
|
||||
streamItemId: opts.id,
|
||||
sourceSession: opts.source,
|
||||
body: opts.body,
|
||||
hintDestination: opts.hintDestination ?? null,
|
||||
hintType: opts.hintType ?? null,
|
||||
hintUrgency: opts.hintUrgency ?? null,
|
||||
hintTags: tags,
|
||||
interrupt: opts.interrupt ?? false,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List stream items chronologically")
|
||||
.option("--source <session>", "Filter by source session")
|
||||
.option("--hint-destination <session>", "Filter by hint destination")
|
||||
.option("--limit <n>", "Result limit", "100")
|
||||
.option("--after <sortKey>", "Cursor pagination — return items after this sort key")
|
||||
.option("--include-archived", "Include archived items")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
source?: string;
|
||||
hintDestination?: string;
|
||||
limit: string;
|
||||
after?: string;
|
||||
includeArchived?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
if (opts.source) params.set("sourceSession", opts.source);
|
||||
if (opts.hintDestination) params.set("hintDestination", opts.hintDestination);
|
||||
if (opts.limit) params.set("limit", opts.limit);
|
||||
if (opts.after) params.set("afterSortKey", opts.after);
|
||||
if (opts.includeArchived) params.set("includeArchived", "true");
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/stream/list?${params.toString()}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("show <streamItemId>")
|
||||
.description("Fetch one stream item by id")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (streamItemId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/stream/${encodeURIComponent(streamItemId)}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("archive <streamItemId>")
|
||||
.description("Soft-archive a stream item (audit row preserved)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (streamItemId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/stream/${encodeURIComponent(streamItemId)}/archive`, {});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import { agentCommand, type AgentDeps } from "./commands/agent.js";
|
||||
import { rigCommand, type RigDeps } from "./commands/rig.js";
|
||||
import { transcriptCommand } from "./commands/transcript.js";
|
||||
import { sendCommand } from "./commands/send.js";
|
||||
import { streamCommand, type StreamDeps } from "./commands/stream.js";
|
||||
import { queueCommand, type QueueDeps } from "./commands/queue.js";
|
||||
import { captureCommand } from "./commands/capture.js";
|
||||
import { broadcastCommand } from "./commands/broadcast.js";
|
||||
import { configCommand } from "./commands/config.js";
|
||||
@@ -76,6 +78,8 @@ export interface ProgramDeps {
|
||||
rigDeps?: RigDeps;
|
||||
transcriptDeps?: StatusDeps;
|
||||
sendDeps?: StatusDeps;
|
||||
streamDeps?: StreamDeps;
|
||||
queueDeps?: QueueDeps;
|
||||
captureDeps?: StatusDeps;
|
||||
broadcastDeps?: StatusDeps;
|
||||
askDeps?: StatusDeps;
|
||||
@@ -128,6 +132,8 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(rigCommand(depsOverride?.rigDeps));
|
||||
program.addCommand(transcriptCommand(depsOverride?.transcriptDeps));
|
||||
program.addCommand(sendCommand(depsOverride?.sendDeps));
|
||||
program.addCommand(streamCommand(depsOverride?.streamDeps));
|
||||
program.addCommand(queueCommand(depsOverride?.queueDeps));
|
||||
program.addCommand(captureCommand(depsOverride?.captureDeps));
|
||||
program.addCommand(broadcastCommand(depsOverride?.broadcastDeps));
|
||||
program.addCommand(askCommand(depsOverride?.askDeps));
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* L1 — Stream (PL-004 Phase A).
|
||||
*
|
||||
* Append-only intake/audit root for the coordination primitive. Items are
|
||||
* immutable after emit; ordered by `(ts_emitted, stream_sort_key)`. Hints
|
||||
* are advisory; classifier (L2, future Phase B) is authoritative on routing.
|
||||
*
|
||||
* Indexes:
|
||||
* - PRIMARY KEY on stream_item_id (ULID; monotonic per host)
|
||||
* - source-session lookup
|
||||
* - hint-destination lookup
|
||||
* - composite (ts_emitted, stream_sort_key) for chronological cursor pagination
|
||||
*/
|
||||
export const streamItemsSchema: Migration = {
|
||||
name: "023_stream_items.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS stream_items (
|
||||
stream_item_id TEXT PRIMARY KEY,
|
||||
ts_emitted TEXT NOT NULL,
|
||||
stream_sort_key TEXT NOT NULL,
|
||||
source_session TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
format TEXT NOT NULL DEFAULT 'text',
|
||||
hint_type TEXT,
|
||||
hint_urgency TEXT,
|
||||
hint_destination TEXT,
|
||||
hint_tags TEXT,
|
||||
interrupt INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stream_items_source ON stream_items(source_session);
|
||||
CREATE INDEX IF NOT EXISTS idx_stream_items_hint_destination ON stream_items(hint_destination);
|
||||
CREATE INDEX IF NOT EXISTS idx_stream_items_chronological ON stream_items(ts_emitted, stream_sort_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_stream_items_archived ON stream_items(archived_at);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* L3 — Queue (PL-004 Phase A).
|
||||
*
|
||||
* Owned work for a specific seat. Carries state, provenance, the closure
|
||||
* obligation, and the daemon-tracked nudge result. Live runtime state is
|
||||
* SQLite-canonical; markdown queue mirrors are read-only debug/export only.
|
||||
*
|
||||
* State enum (validated at the domain layer):
|
||||
* pending | in-progress | done | blocked | failed | denied | canceled | handed-off
|
||||
*
|
||||
* Closure-reason enum (required on `done` transitions; hot-potato strict-rejection):
|
||||
* handed_off_to | blocked_on | denied | canceled | no-follow-on | escalation
|
||||
*
|
||||
* `chain_of_record` and `tags` carry JSON arrays (TEXT-encoded) for forward
|
||||
* compat with structured query layers.
|
||||
*/
|
||||
export const queueItemsSchema: Migration = {
|
||||
name: "024_queue_items.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS queue_items (
|
||||
qitem_id TEXT PRIMARY KEY,
|
||||
ts_created TEXT NOT NULL,
|
||||
ts_updated TEXT NOT NULL,
|
||||
source_session TEXT NOT NULL,
|
||||
destination_session TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
priority TEXT NOT NULL DEFAULT 'routine',
|
||||
tier TEXT,
|
||||
tags TEXT,
|
||||
blocked_on TEXT,
|
||||
handed_off_to TEXT,
|
||||
handed_off_from TEXT,
|
||||
expires_at TEXT,
|
||||
chain_of_record TEXT,
|
||||
body TEXT NOT NULL,
|
||||
closure_reason TEXT,
|
||||
closure_target TEXT,
|
||||
closure_required_at TEXT,
|
||||
claimed_at TEXT,
|
||||
last_nudge_attempt TEXT,
|
||||
last_nudge_result TEXT,
|
||||
last_heartbeat TEXT,
|
||||
resolution TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_destination_state ON queue_items(destination_session, state);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_source ON queue_items(source_session);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_state ON queue_items(state);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_handed_off_to ON queue_items(handed_off_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_closure_overdue ON queue_items(state, closure_required_at) WHERE state = 'in-progress';
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* L3 — Queue transitions append-only log (PL-004 Phase A).
|
||||
*
|
||||
* Every state mutation on a queue_item is appended here; the queue_items.state
|
||||
* column is the latest transition's value. This log is the authoritative
|
||||
* audit trail for hot-potato closure reasoning, watchdog evaluation, and
|
||||
* future workflow-runtime transactional-scribe semantics (Phase D).
|
||||
*
|
||||
* Append-only: no UPDATE / DELETE on this table from domain code.
|
||||
*/
|
||||
export const queueTransitionsSchema: Migration = {
|
||||
name: "025_queue_transitions.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS queue_transitions (
|
||||
transition_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
qitem_id TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
transition_note TEXT,
|
||||
actor_session TEXT NOT NULL,
|
||||
closure_reason TEXT,
|
||||
closure_target TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_transitions_qitem ON queue_transitions(qitem_id, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_transitions_actor ON queue_transitions(actor_session);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* Inbox entries (PL-004 Phase A; mailbox-style asynchronous deposit).
|
||||
*
|
||||
* Per founder-resolved direction: inbox is the canonical async/bulk path,
|
||||
* NOT a contention fallback. Any authenticated sender may inbox-drop with
|
||||
* attribution + audit. Receiver chooses absorb (promote to main queue) or
|
||||
* deny (reject with reason). Idempotent on inbox_id.
|
||||
*
|
||||
* State enum: pending | absorbed | denied
|
||||
* `absorbed_qitem_id` records which queue_item the absorbed entry became.
|
||||
*/
|
||||
export const inboxEntriesSchema: Migration = {
|
||||
name: "026_inbox_entries.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS inbox_entries (
|
||||
inbox_id TEXT PRIMARY KEY,
|
||||
destination_session TEXT NOT NULL,
|
||||
sender_session TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
tags TEXT,
|
||||
urgency TEXT NOT NULL DEFAULT 'routine',
|
||||
ts_dropped TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
absorbed_at TEXT,
|
||||
absorbed_qitem_id TEXT,
|
||||
denied_at TEXT,
|
||||
denied_reason TEXT,
|
||||
audit_pointer TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_inbox_entries_destination_state ON inbox_entries(destination_session, state);
|
||||
CREATE INDEX IF NOT EXISTS idx_inbox_entries_sender ON inbox_entries(sender_session);
|
||||
CREATE INDEX IF NOT EXISTS idx_inbox_entries_state ON inbox_entries(state);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* Outbox entries (PL-004 Phase A; symmetric to inbox).
|
||||
*
|
||||
* Sender-side audit of dispatched items. Useful for senders that want a
|
||||
* record of sent items independent of receiver behavior. Daemon-managed
|
||||
* write; idempotent on outbox_id.
|
||||
*
|
||||
* delivery_state enum: pending | delivered | failed
|
||||
*/
|
||||
export const outboxEntriesSchema: Migration = {
|
||||
name: "027_outbox_entries.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS outbox_entries (
|
||||
outbox_id TEXT PRIMARY KEY,
|
||||
sender_session TEXT NOT NULL,
|
||||
destination_session TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
tags TEXT,
|
||||
urgency TEXT NOT NULL DEFAULT 'routine',
|
||||
ts_dispatched TEXT NOT NULL,
|
||||
delivery_state TEXT NOT NULL DEFAULT 'pending',
|
||||
delivered_at TEXT,
|
||||
audit_pointer TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_entries_sender ON outbox_entries(sender_session);
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_entries_destination ON outbox_entries(destination_session);
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_entries_delivery_state ON outbox_entries(delivery_state);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Hot-potato strict-rejection contract (PL-004 Phase A).
|
||||
*
|
||||
* Load-bearing API contract: any code path that transitions a queue_item to
|
||||
* `done` MUST pass a valid `closure_reason` from the 6-value enum below. This
|
||||
* is enforced at this layer (NOT at the route, NOT at the CLI) so every
|
||||
* surface that reaches the queue inherits the same guarantee.
|
||||
*
|
||||
* The 6 valid closure reasons:
|
||||
* - handed_off_to : work continues with a different seat (closure_target = new owner)
|
||||
* - blocked_on : work is parked pending another qitem (closure_target = blocker qitem_id)
|
||||
* - denied : receiver rejected the work (closure_target = reason text)
|
||||
* - canceled : sender or receiver withdrew (closure_target = note)
|
||||
* - no-follow-on : terminal completion, nothing else needed
|
||||
* - escalation : kicked up to higher tier (closure_target = escalation target)
|
||||
*/
|
||||
|
||||
export const CLOSURE_REASONS = [
|
||||
"handed_off_to",
|
||||
"blocked_on",
|
||||
"denied",
|
||||
"canceled",
|
||||
"no-follow-on",
|
||||
"escalation",
|
||||
] as const;
|
||||
|
||||
export type ClosureReason = (typeof CLOSURE_REASONS)[number];
|
||||
|
||||
export interface ClosureRequest {
|
||||
state: string;
|
||||
closureReason?: string | null;
|
||||
closureTarget?: string | null;
|
||||
}
|
||||
|
||||
export interface ClosureValidationOk {
|
||||
ok: true;
|
||||
closureReason: ClosureReason | null;
|
||||
closureTarget: string | null;
|
||||
}
|
||||
|
||||
export interface ClosureValidationErr {
|
||||
ok: false;
|
||||
code: "missing_closure_reason" | "invalid_closure_reason" | "missing_closure_target";
|
||||
message: string;
|
||||
validReasons?: readonly string[];
|
||||
}
|
||||
|
||||
export type ClosureValidation = ClosureValidationOk | ClosureValidationErr;
|
||||
|
||||
/**
|
||||
* Validate a state transition's closure obligation.
|
||||
* - If state !== "done": no closure_reason required; pass through any provided values.
|
||||
* - If state === "done": closure_reason REQUIRED, must be in CLOSURE_REASONS.
|
||||
* handed_off_to / blocked_on / escalation also require closure_target.
|
||||
*/
|
||||
export function validateClosure(req: ClosureRequest): ClosureValidation {
|
||||
if (req.state !== "done") {
|
||||
return {
|
||||
ok: true,
|
||||
closureReason: (req.closureReason ?? null) as ClosureReason | null,
|
||||
closureTarget: req.closureTarget ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!req.closureReason) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "missing_closure_reason",
|
||||
message: `state=done requires closure_reason; valid values: ${CLOSURE_REASONS.join(", ")}`,
|
||||
validReasons: CLOSURE_REASONS,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isClosureReason(req.closureReason)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "invalid_closure_reason",
|
||||
message: `closure_reason=${req.closureReason} is not valid; valid values: ${CLOSURE_REASONS.join(", ")}`,
|
||||
validReasons: CLOSURE_REASONS,
|
||||
};
|
||||
}
|
||||
|
||||
const requiresTarget = req.closureReason === "handed_off_to"
|
||||
|| req.closureReason === "blocked_on"
|
||||
|| req.closureReason === "escalation";
|
||||
|
||||
if (requiresTarget && !req.closureTarget) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "missing_closure_target",
|
||||
message: `closure_reason=${req.closureReason} requires closure_target`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
closureReason: req.closureReason,
|
||||
closureTarget: req.closureTarget ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isClosureReason(value: unknown): value is ClosureReason {
|
||||
return typeof value === "string" && (CLOSURE_REASONS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute closure_required_at given a claim time and a tier.
|
||||
* Tier policies are intentionally simple in Phase A; the structure exists so
|
||||
* Phase B/C can swap in operator-tunable SLAs without a contract change.
|
||||
*/
|
||||
const TIER_SLA_SECONDS: Record<string, number> = {
|
||||
fast: 30 * 60,
|
||||
routine: 4 * 60 * 60,
|
||||
deep: 24 * 60 * 60,
|
||||
critical: 15 * 60,
|
||||
};
|
||||
|
||||
export function computeClosureRequiredAt(claimedAt: string, tier: string | null): string | null {
|
||||
if (!tier) return null;
|
||||
const slaSeconds = TIER_SLA_SECONDS[tier];
|
||||
if (slaSeconds === undefined) return null;
|
||||
const claimed = new Date(claimedAt).getTime();
|
||||
return new Date(claimed + slaSeconds * 1000).toISOString();
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
import { QueueRepository } from "./queue-repository.js";
|
||||
|
||||
export const INBOX_STATES = ["pending", "absorbed", "denied"] as const;
|
||||
export type InboxState = (typeof INBOX_STATES)[number];
|
||||
|
||||
export interface InboxEntry {
|
||||
inboxId: string;
|
||||
destinationSession: string;
|
||||
senderSession: string;
|
||||
body: string;
|
||||
tags: string[] | null;
|
||||
urgency: string;
|
||||
tsDropped: string;
|
||||
state: InboxState;
|
||||
absorbedAt: string | null;
|
||||
absorbedQitemId: string | null;
|
||||
deniedAt: string | null;
|
||||
deniedReason: string | null;
|
||||
auditPointer: string | null;
|
||||
}
|
||||
|
||||
interface InboxEntryRow {
|
||||
inbox_id: string;
|
||||
destination_session: string;
|
||||
sender_session: string;
|
||||
body: string;
|
||||
tags: string | null;
|
||||
urgency: string;
|
||||
ts_dropped: string;
|
||||
state: string;
|
||||
absorbed_at: string | null;
|
||||
absorbed_qitem_id: string | null;
|
||||
denied_at: string | null;
|
||||
denied_reason: string | null;
|
||||
audit_pointer: string | null;
|
||||
}
|
||||
|
||||
export interface InboxDropInput {
|
||||
inboxId?: string;
|
||||
destinationSession: string;
|
||||
senderSession: string;
|
||||
body: string;
|
||||
tags?: string[];
|
||||
urgency?: string;
|
||||
auditPointer?: string;
|
||||
}
|
||||
|
||||
export class InboxHandlerError extends Error {
|
||||
readonly code: string;
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function newInboxId(): string {
|
||||
const ts = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
|
||||
const hex = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, "0");
|
||||
return `inbox-${ts}-${hex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox mailbox handler. Authenticated drop-and-go path.
|
||||
* Receiver chooses absorb (promote to queue_item) or deny (record reason).
|
||||
*
|
||||
* `authenticate` is the contract surface that lets routes plug in identity
|
||||
* verification. Default is permissive (accept any sender_session); routes
|
||||
* SHOULD verify against the calling principal.
|
||||
*/
|
||||
export class InboxHandler {
|
||||
readonly db: Database.Database;
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly queueRepo: QueueRepository;
|
||||
private readonly authenticate: (sender: string, claimed: string) => boolean;
|
||||
|
||||
constructor(
|
||||
db: Database.Database,
|
||||
eventBus: EventBus,
|
||||
queueRepo: QueueRepository,
|
||||
opts?: { authenticate?: (sender: string, claimed: string) => boolean }
|
||||
) {
|
||||
this.db = db;
|
||||
this.eventBus = eventBus;
|
||||
this.queueRepo = queueRepo;
|
||||
this.authenticate = opts?.authenticate ?? (() => true);
|
||||
}
|
||||
|
||||
drop(input: InboxDropInput, authenticatedSender?: string): InboxEntry {
|
||||
if (authenticatedSender !== undefined && !this.authenticate(authenticatedSender, input.senderSession)) {
|
||||
throw new InboxHandlerError(
|
||||
"auth_failed",
|
||||
`authenticated principal ${authenticatedSender} cannot send as ${input.senderSession}`
|
||||
);
|
||||
}
|
||||
|
||||
const id = input.inboxId ?? newInboxId();
|
||||
const existing = this.getByIdRaw(id);
|
||||
if (existing) return this.rowToEntry(existing);
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const tags = input.tags ? JSON.stringify(input.tags) : null;
|
||||
const urgency = input.urgency ?? "routine";
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO inbox_entries (
|
||||
inbox_id, destination_session, sender_session, body, tags, urgency, ts_dropped, audit_pointer
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
input.destinationSession,
|
||||
input.senderSession,
|
||||
input.body,
|
||||
tags,
|
||||
urgency,
|
||||
ts,
|
||||
input.auditPointer ?? null
|
||||
);
|
||||
|
||||
return this.getByIdOrThrow(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver absorbs a pending inbox entry into their main queue.
|
||||
* Idempotent on inbox_id: if already absorbed, returns the existing
|
||||
* `absorbed_qitem_id` rather than creating a duplicate.
|
||||
*/
|
||||
absorb(inboxId: string, receiverSession: string): { entry: InboxEntry; qitemId: string } {
|
||||
const entry = this.getById(inboxId);
|
||||
if (!entry) {
|
||||
throw new InboxHandlerError("inbox_not_found", `inbox ${inboxId} not found`);
|
||||
}
|
||||
if (entry.destinationSession !== receiverSession) {
|
||||
throw new InboxHandlerError(
|
||||
"absorb_destination_mismatch",
|
||||
`inbox ${inboxId} is destined for ${entry.destinationSession}, not ${receiverSession}`
|
||||
);
|
||||
}
|
||||
if (entry.state === "absorbed") {
|
||||
return { entry, qitemId: entry.absorbedQitemId! };
|
||||
}
|
||||
if (entry.state === "denied") {
|
||||
throw new InboxHandlerError(
|
||||
"inbox_already_denied",
|
||||
`inbox ${inboxId} was denied; cannot absorb`
|
||||
);
|
||||
}
|
||||
|
||||
const qitem = this.queueRepo.create({
|
||||
sourceSession: entry.senderSession,
|
||||
destinationSession: entry.destinationSession,
|
||||
body: entry.body,
|
||||
tags: entry.tags ?? undefined,
|
||||
priority: entry.urgency === "critical" ? "critical" : entry.urgency === "urgent" ? "urgent" : "routine",
|
||||
});
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE inbox_entries
|
||||
SET state = 'absorbed', absorbed_at = ?, absorbed_qitem_id = ?
|
||||
WHERE inbox_id = ?`
|
||||
)
|
||||
.run(ts, qitem.qitemId, inboxId);
|
||||
|
||||
return this.eventBus.persistWithinTransaction({
|
||||
type: "inbox.absorbed",
|
||||
inboxId,
|
||||
destinationSession: entry.destinationSession,
|
||||
senderSession: entry.senderSession,
|
||||
promotedQitemId: qitem.qitemId,
|
||||
});
|
||||
});
|
||||
|
||||
const persistedEvent = txn();
|
||||
this.eventBus.notifySubscribers(persistedEvent);
|
||||
|
||||
return { entry: this.getByIdOrThrow(inboxId), qitemId: qitem.qitemId };
|
||||
}
|
||||
|
||||
deny(inboxId: string, receiverSession: string, reason: string): InboxEntry {
|
||||
const entry = this.getById(inboxId);
|
||||
if (!entry) {
|
||||
throw new InboxHandlerError("inbox_not_found", `inbox ${inboxId} not found`);
|
||||
}
|
||||
if (entry.destinationSession !== receiverSession) {
|
||||
throw new InboxHandlerError(
|
||||
"deny_destination_mismatch",
|
||||
`inbox ${inboxId} is destined for ${entry.destinationSession}, not ${receiverSession}`
|
||||
);
|
||||
}
|
||||
if (entry.state !== "pending") {
|
||||
throw new InboxHandlerError(
|
||||
"inbox_not_pending",
|
||||
`inbox ${inboxId} is in state ${entry.state}; only pending entries can be denied`
|
||||
);
|
||||
}
|
||||
const ts = new Date().toISOString();
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE inbox_entries
|
||||
SET state = 'denied', denied_at = ?, denied_reason = ?
|
||||
WHERE inbox_id = ?`
|
||||
)
|
||||
.run(ts, reason, inboxId);
|
||||
|
||||
return this.eventBus.persistWithinTransaction({
|
||||
type: "inbox.denied",
|
||||
inboxId,
|
||||
destinationSession: entry.destinationSession,
|
||||
senderSession: entry.senderSession,
|
||||
reason,
|
||||
});
|
||||
});
|
||||
|
||||
const persistedEvent = txn();
|
||||
this.eventBus.notifySubscribers(persistedEvent);
|
||||
|
||||
return this.getByIdOrThrow(inboxId);
|
||||
}
|
||||
|
||||
getById(inboxId: string): InboxEntry | null {
|
||||
const row = this.getByIdRaw(inboxId);
|
||||
return row ? this.rowToEntry(row) : null;
|
||||
}
|
||||
|
||||
listPending(destinationSession: string): InboxEntry[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM inbox_entries
|
||||
WHERE destination_session = ? AND state = 'pending'
|
||||
ORDER BY ts_dropped ASC`
|
||||
)
|
||||
.all(destinationSession) as InboxEntryRow[];
|
||||
return rows.map((r) => this.rowToEntry(r));
|
||||
}
|
||||
|
||||
listForDestination(destinationSession: string, limit = 100): InboxEntry[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM inbox_entries WHERE destination_session = ? ORDER BY ts_dropped DESC, rowid DESC LIMIT ?`
|
||||
)
|
||||
.all(destinationSession, limit) as InboxEntryRow[];
|
||||
return rows.map((r) => this.rowToEntry(r));
|
||||
}
|
||||
|
||||
private getByIdRaw(inboxId: string): InboxEntryRow | undefined {
|
||||
return this.db
|
||||
.prepare("SELECT * FROM inbox_entries WHERE inbox_id = ?")
|
||||
.get(inboxId) as InboxEntryRow | undefined;
|
||||
}
|
||||
|
||||
private getByIdOrThrow(inboxId: string): InboxEntry {
|
||||
const entry = this.getById(inboxId);
|
||||
if (!entry) throw new InboxHandlerError("inbox_not_found", `inbox ${inboxId} not found after write`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private rowToEntry(row: InboxEntryRow): InboxEntry {
|
||||
return {
|
||||
inboxId: row.inbox_id,
|
||||
destinationSession: row.destination_session,
|
||||
senderSession: row.sender_session,
|
||||
body: row.body,
|
||||
tags: row.tags ? (JSON.parse(row.tags) as string[]) : null,
|
||||
urgency: row.urgency,
|
||||
tsDropped: row.ts_dropped,
|
||||
state: row.state as InboxState,
|
||||
absorbedAt: row.absorbed_at,
|
||||
absorbedQitemId: row.absorbed_qitem_id,
|
||||
deniedAt: row.denied_at,
|
||||
deniedReason: row.denied_reason,
|
||||
auditPointer: row.audit_pointer,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export const OUTBOX_DELIVERY_STATES = ["pending", "delivered", "failed"] as const;
|
||||
export type OutboxDeliveryState = (typeof OUTBOX_DELIVERY_STATES)[number];
|
||||
|
||||
export interface OutboxEntry {
|
||||
outboxId: string;
|
||||
senderSession: string;
|
||||
destinationSession: string;
|
||||
body: string;
|
||||
tags: string[] | null;
|
||||
urgency: string;
|
||||
tsDispatched: string;
|
||||
deliveryState: OutboxDeliveryState;
|
||||
deliveredAt: string | null;
|
||||
auditPointer: string | null;
|
||||
}
|
||||
|
||||
interface OutboxEntryRow {
|
||||
outbox_id: string;
|
||||
sender_session: string;
|
||||
destination_session: string;
|
||||
body: string;
|
||||
tags: string | null;
|
||||
urgency: string;
|
||||
ts_dispatched: string;
|
||||
delivery_state: string;
|
||||
delivered_at: string | null;
|
||||
audit_pointer: string | null;
|
||||
}
|
||||
|
||||
export interface OutboxRecordInput {
|
||||
outboxId?: string;
|
||||
senderSession: string;
|
||||
destinationSession: string;
|
||||
body: string;
|
||||
tags?: string[];
|
||||
urgency?: string;
|
||||
auditPointer?: string;
|
||||
}
|
||||
|
||||
export class OutboxHandlerError extends Error {
|
||||
readonly code: string;
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function newOutboxId(): string {
|
||||
const ts = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
|
||||
const hex = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, "0");
|
||||
return `outbox-${ts}-${hex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sender-side outbox. Symmetric to InboxHandler. Records what a sender
|
||||
* dispatched independent of receiver behavior. Idempotent on outbox_id.
|
||||
*
|
||||
* No event-bus events emitted in Phase A — outbox is pure audit. If a future
|
||||
* phase wants delivery-tracking events, add them through this surface.
|
||||
*/
|
||||
export class OutboxHandler {
|
||||
readonly db: Database.Database;
|
||||
|
||||
constructor(db: Database.Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
record(input: OutboxRecordInput): OutboxEntry {
|
||||
const id = input.outboxId ?? newOutboxId();
|
||||
const existing = this.getByIdRaw(id);
|
||||
if (existing) return this.rowToEntry(existing);
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const tags = input.tags ? JSON.stringify(input.tags) : null;
|
||||
const urgency = input.urgency ?? "routine";
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO outbox_entries (
|
||||
outbox_id, sender_session, destination_session, body, tags, urgency, ts_dispatched, audit_pointer
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
input.senderSession,
|
||||
input.destinationSession,
|
||||
input.body,
|
||||
tags,
|
||||
urgency,
|
||||
ts,
|
||||
input.auditPointer ?? null
|
||||
);
|
||||
|
||||
return this.getByIdOrThrow(id);
|
||||
}
|
||||
|
||||
markDelivered(outboxId: string): OutboxEntry {
|
||||
const ts = new Date().toISOString();
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE outbox_entries
|
||||
SET delivery_state = 'delivered', delivered_at = ?
|
||||
WHERE outbox_id = ? AND delivery_state = 'pending'`
|
||||
)
|
||||
.run(ts, outboxId);
|
||||
if (result.changes === 0) {
|
||||
const entry = this.getById(outboxId);
|
||||
if (!entry) throw new OutboxHandlerError("outbox_not_found", `outbox ${outboxId} not found`);
|
||||
return entry;
|
||||
}
|
||||
return this.getByIdOrThrow(outboxId);
|
||||
}
|
||||
|
||||
markFailed(outboxId: string): OutboxEntry {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE outbox_entries
|
||||
SET delivery_state = 'failed'
|
||||
WHERE outbox_id = ? AND delivery_state = 'pending'`
|
||||
)
|
||||
.run(outboxId);
|
||||
if (result.changes === 0) {
|
||||
const entry = this.getById(outboxId);
|
||||
if (!entry) throw new OutboxHandlerError("outbox_not_found", `outbox ${outboxId} not found`);
|
||||
return entry;
|
||||
}
|
||||
return this.getByIdOrThrow(outboxId);
|
||||
}
|
||||
|
||||
getById(outboxId: string): OutboxEntry | null {
|
||||
const row = this.getByIdRaw(outboxId);
|
||||
return row ? this.rowToEntry(row) : null;
|
||||
}
|
||||
|
||||
listForSender(senderSession: string, limit = 100): OutboxEntry[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM outbox_entries WHERE sender_session = ? ORDER BY ts_dispatched DESC, rowid DESC LIMIT ?`
|
||||
)
|
||||
.all(senderSession, limit) as OutboxEntryRow[];
|
||||
return rows.map((r) => this.rowToEntry(r));
|
||||
}
|
||||
|
||||
private getByIdRaw(outboxId: string): OutboxEntryRow | undefined {
|
||||
return this.db
|
||||
.prepare("SELECT * FROM outbox_entries WHERE outbox_id = ?")
|
||||
.get(outboxId) as OutboxEntryRow | undefined;
|
||||
}
|
||||
|
||||
private getByIdOrThrow(outboxId: string): OutboxEntry {
|
||||
const entry = this.getById(outboxId);
|
||||
if (!entry) throw new OutboxHandlerError("outbox_not_found", `outbox ${outboxId} not found after write`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private rowToEntry(row: OutboxEntryRow): OutboxEntry {
|
||||
return {
|
||||
outboxId: row.outbox_id,
|
||||
senderSession: row.sender_session,
|
||||
destinationSession: row.destination_session,
|
||||
body: row.body,
|
||||
tags: row.tags ? (JSON.parse(row.tags) as string[]) : null,
|
||||
urgency: row.urgency,
|
||||
tsDispatched: row.ts_dispatched,
|
||||
deliveryState: row.delivery_state as OutboxDeliveryState,
|
||||
deliveredAt: row.delivered_at,
|
||||
auditPointer: row.audit_pointer,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
import { QueueTransitionLog } from "./queue-transition-log.js";
|
||||
import {
|
||||
computeClosureRequiredAt,
|
||||
validateClosure,
|
||||
type ClosureReason,
|
||||
} from "./hot-potato-enforcer.js";
|
||||
|
||||
export const QUEUE_STATES = [
|
||||
"pending",
|
||||
"in-progress",
|
||||
"done",
|
||||
"blocked",
|
||||
"failed",
|
||||
"denied",
|
||||
"canceled",
|
||||
"handed-off",
|
||||
] as const;
|
||||
export type QueueState = (typeof QUEUE_STATES)[number];
|
||||
|
||||
export const QUEUE_PRIORITIES = ["routine", "urgent", "critical"] as const;
|
||||
export type QueuePriority = (typeof QUEUE_PRIORITIES)[number];
|
||||
|
||||
export interface QueueItem {
|
||||
qitemId: string;
|
||||
tsCreated: string;
|
||||
tsUpdated: string;
|
||||
sourceSession: string;
|
||||
destinationSession: string;
|
||||
state: QueueState;
|
||||
priority: QueuePriority;
|
||||
tier: string | null;
|
||||
tags: string[] | null;
|
||||
blockedOn: string | null;
|
||||
handedOffTo: string | null;
|
||||
handedOffFrom: string | null;
|
||||
expiresAt: string | null;
|
||||
chainOfRecord: string[] | null;
|
||||
body: string;
|
||||
closureReason: ClosureReason | null;
|
||||
closureTarget: string | null;
|
||||
closureRequiredAt: string | null;
|
||||
claimedAt: string | null;
|
||||
lastNudgeAttempt: string | null;
|
||||
lastNudgeResult: string | null;
|
||||
lastHeartbeat: string | null;
|
||||
resolution: string | null;
|
||||
}
|
||||
|
||||
interface QueueItemRow {
|
||||
qitem_id: string;
|
||||
ts_created: string;
|
||||
ts_updated: string;
|
||||
source_session: string;
|
||||
destination_session: string;
|
||||
state: string;
|
||||
priority: string;
|
||||
tier: string | null;
|
||||
tags: string | null;
|
||||
blocked_on: string | null;
|
||||
handed_off_to: string | null;
|
||||
handed_off_from: string | null;
|
||||
expires_at: string | null;
|
||||
chain_of_record: string | null;
|
||||
body: string;
|
||||
closure_reason: string | null;
|
||||
closure_target: string | null;
|
||||
closure_required_at: string | null;
|
||||
claimed_at: string | null;
|
||||
last_nudge_attempt: string | null;
|
||||
last_nudge_result: string | null;
|
||||
last_heartbeat: string | null;
|
||||
resolution: string | null;
|
||||
}
|
||||
|
||||
export interface QueueCreateInput {
|
||||
qitemId?: string;
|
||||
sourceSession: string;
|
||||
destinationSession: string;
|
||||
body: string;
|
||||
priority?: QueuePriority;
|
||||
tier?: string;
|
||||
tags?: string[];
|
||||
expiresAt?: string;
|
||||
chainOfRecord?: string[];
|
||||
}
|
||||
|
||||
export interface QueueUpdateInput {
|
||||
qitemId: string;
|
||||
actorSession: string;
|
||||
state: QueueState;
|
||||
transitionNote?: string;
|
||||
closureReason?: string;
|
||||
closureTarget?: string;
|
||||
}
|
||||
|
||||
export interface QueueHandoffInput {
|
||||
qitemId: string;
|
||||
fromSession: string;
|
||||
toSession: string;
|
||||
body?: string;
|
||||
transitionNote?: string;
|
||||
priority?: QueuePriority;
|
||||
tier?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface QueueClaimInput {
|
||||
qitemId: string;
|
||||
destinationSession: string;
|
||||
}
|
||||
|
||||
export interface QueueListOptions {
|
||||
destinationSession?: string;
|
||||
sourceSession?: string;
|
||||
state?: QueueState | QueueState[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class QueueRepositoryError extends Error {
|
||||
readonly code: string;
|
||||
readonly meta: Record<string, unknown> | undefined;
|
||||
constructor(code: string, message: string, meta?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.meta = meta;
|
||||
}
|
||||
}
|
||||
|
||||
function newQitemId(): string {
|
||||
const ts = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
|
||||
const hex = Math.floor(Math.random() * 0xffffffff)
|
||||
.toString(16)
|
||||
.padStart(8, "0");
|
||||
return `qitem-${ts}-${hex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* L3 — Queue repository. Owns CRUD over `queue_items` plus the wired-in
|
||||
* append-only transition log and hot-potato strict-rejection contract.
|
||||
*
|
||||
* Pattern mirrors `chat-repository.ts` (single class, atomic transactions,
|
||||
* persist-event-then-notify). Cross-rig validation hook is `validateRig` —
|
||||
* Phase A wires no-op; Phase B can plug in the rig registry to reject
|
||||
* phantom-rig destinations. POC compatibility: `qitem_id` shape preserved.
|
||||
*/
|
||||
export class QueueRepository {
|
||||
readonly db: Database.Database;
|
||||
readonly transitionLog: QueueTransitionLog;
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly validateRig: (sessionRef: string) => boolean;
|
||||
|
||||
constructor(
|
||||
db: Database.Database,
|
||||
eventBus: EventBus,
|
||||
opts?: { validateRig?: (sessionRef: string) => boolean }
|
||||
) {
|
||||
this.db = db;
|
||||
this.eventBus = eventBus;
|
||||
this.transitionLog = new QueueTransitionLog(db);
|
||||
this.validateRig = opts?.validateRig ?? (() => true);
|
||||
}
|
||||
|
||||
create(input: QueueCreateInput): QueueItem {
|
||||
if (!this.validateRig(input.destinationSession)) {
|
||||
throw new QueueRepositoryError(
|
||||
"unknown_destination_rig",
|
||||
`destination_session ${input.destinationSession} references an unknown rig`
|
||||
);
|
||||
}
|
||||
|
||||
const id = input.qitemId ?? newQitemId();
|
||||
const ts = new Date().toISOString();
|
||||
const priority = input.priority ?? "routine";
|
||||
const tier = input.tier ?? null;
|
||||
const tags = input.tags ? JSON.stringify(input.tags) : null;
|
||||
const chain = input.chainOfRecord ? JSON.stringify(input.chainOfRecord) : null;
|
||||
const expiresAt = input.expiresAt ?? null;
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_items (
|
||||
qitem_id, ts_created, ts_updated, source_session, destination_session,
|
||||
state, priority, tier, tags, expires_at, chain_of_record, body
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
ts,
|
||||
ts,
|
||||
input.sourceSession,
|
||||
input.destinationSession,
|
||||
priority,
|
||||
tier,
|
||||
tags,
|
||||
expiresAt,
|
||||
chain,
|
||||
input.body
|
||||
);
|
||||
|
||||
this.transitionLog.append({
|
||||
qitemId: id,
|
||||
state: "pending",
|
||||
actorSession: input.sourceSession,
|
||||
transitionNote: "created",
|
||||
});
|
||||
|
||||
return this.eventBus.persistWithinTransaction({
|
||||
type: "queue.created",
|
||||
qitemId: id,
|
||||
sourceSession: input.sourceSession,
|
||||
destinationSession: input.destinationSession,
|
||||
priority,
|
||||
tier,
|
||||
});
|
||||
});
|
||||
|
||||
const persistedEvent = txn();
|
||||
this.eventBus.notifySubscribers(persistedEvent);
|
||||
return this.getByIdOrThrow(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transactional handoff: close the source qitem (state=done,
|
||||
* closure_reason=handed_off_to) and create a new qitem owned by `toSession`,
|
||||
* with `handed_off_from` recording the chain. One atomic transaction.
|
||||
*/
|
||||
handoff(input: QueueHandoffInput): { closed: QueueItem; created: QueueItem } {
|
||||
const source = this.getById(input.qitemId);
|
||||
if (!source) {
|
||||
throw new QueueRepositoryError(
|
||||
"qitem_not_found",
|
||||
`qitem ${input.qitemId} not found`
|
||||
);
|
||||
}
|
||||
if (source.state === "done" || source.state === "handed-off") {
|
||||
throw new QueueRepositoryError(
|
||||
"qitem_already_terminal",
|
||||
`qitem ${input.qitemId} is already in terminal state ${source.state}`
|
||||
);
|
||||
}
|
||||
if (!this.validateRig(input.toSession)) {
|
||||
throw new QueueRepositoryError(
|
||||
"unknown_destination_rig",
|
||||
`to_session ${input.toSession} references an unknown rig`
|
||||
);
|
||||
}
|
||||
|
||||
const newId = newQitemId();
|
||||
const ts = new Date().toISOString();
|
||||
const body = input.body ?? source.body;
|
||||
const priority = input.priority ?? source.priority;
|
||||
const tier = input.tier ?? source.tier;
|
||||
const tags = input.tags ? JSON.stringify(input.tags) : (source.tags ? JSON.stringify(source.tags) : null);
|
||||
const chain = JSON.stringify([...(source.chainOfRecord ?? []), source.qitemId]);
|
||||
|
||||
const events: Array<{ name: string; payload: import("./types.js").RigEvent }> = [];
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE queue_items
|
||||
SET state = 'handed-off',
|
||||
ts_updated = ?,
|
||||
handed_off_to = ?,
|
||||
closure_reason = 'handed_off_to',
|
||||
closure_target = ?
|
||||
WHERE qitem_id = ?`
|
||||
)
|
||||
.run(ts, input.toSession, input.toSession, source.qitemId);
|
||||
|
||||
this.transitionLog.append({
|
||||
qitemId: source.qitemId,
|
||||
state: "handed-off",
|
||||
actorSession: input.fromSession,
|
||||
transitionNote: input.transitionNote ?? `handed off to ${input.toSession}`,
|
||||
closureReason: "handed_off_to",
|
||||
closureTarget: input.toSession,
|
||||
});
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_items (
|
||||
qitem_id, ts_created, ts_updated, source_session, destination_session,
|
||||
state, priority, tier, tags, handed_off_from, chain_of_record, body
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
newId,
|
||||
ts,
|
||||
ts,
|
||||
input.fromSession,
|
||||
input.toSession,
|
||||
priority,
|
||||
tier,
|
||||
tags,
|
||||
source.qitemId,
|
||||
chain,
|
||||
body
|
||||
);
|
||||
|
||||
this.transitionLog.append({
|
||||
qitemId: newId,
|
||||
state: "pending",
|
||||
actorSession: input.fromSession,
|
||||
transitionNote: `handoff from ${source.qitemId}`,
|
||||
});
|
||||
|
||||
const handoffEvent = this.eventBus.persistWithinTransaction({
|
||||
type: "queue.handed_off",
|
||||
qitemId: source.qitemId,
|
||||
fromSession: input.fromSession,
|
||||
toSession: input.toSession,
|
||||
closureReason: "handed_off_to",
|
||||
});
|
||||
events.push({ name: "queue.handed_off", payload: handoffEvent });
|
||||
|
||||
const createdEvent = this.eventBus.persistWithinTransaction({
|
||||
type: "queue.created",
|
||||
qitemId: newId,
|
||||
sourceSession: input.fromSession,
|
||||
destinationSession: input.toSession,
|
||||
priority,
|
||||
tier,
|
||||
});
|
||||
events.push({ name: "queue.created", payload: createdEvent });
|
||||
});
|
||||
|
||||
txn();
|
||||
for (const e of events) {
|
||||
this.eventBus.notifySubscribers(e.payload as import("./types.js").PersistedEvent);
|
||||
}
|
||||
|
||||
return {
|
||||
closed: this.getByIdOrThrow(source.qitemId),
|
||||
created: this.getByIdOrThrow(newId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a qitem `in-progress` (claim). Computes closure_required_at from tier.
|
||||
*/
|
||||
claim(input: QueueClaimInput): QueueItem {
|
||||
const qitem = this.getById(input.qitemId);
|
||||
if (!qitem) {
|
||||
throw new QueueRepositoryError(
|
||||
"qitem_not_found",
|
||||
`qitem ${input.qitemId} not found`
|
||||
);
|
||||
}
|
||||
if (qitem.destinationSession !== input.destinationSession) {
|
||||
throw new QueueRepositoryError(
|
||||
"claim_destination_mismatch",
|
||||
`qitem ${input.qitemId} destination is ${qitem.destinationSession}, not ${input.destinationSession}`
|
||||
);
|
||||
}
|
||||
if (qitem.state !== "pending" && qitem.state !== "blocked") {
|
||||
throw new QueueRepositoryError(
|
||||
"qitem_not_claimable",
|
||||
`qitem ${input.qitemId} is in state ${qitem.state}; only pending/blocked are claimable`
|
||||
);
|
||||
}
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const closureRequiredAt = computeClosureRequiredAt(ts, qitem.tier);
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE queue_items
|
||||
SET state = 'in-progress',
|
||||
ts_updated = ?,
|
||||
claimed_at = ?,
|
||||
closure_required_at = ?
|
||||
WHERE qitem_id = ?`
|
||||
)
|
||||
.run(ts, ts, closureRequiredAt, input.qitemId);
|
||||
|
||||
this.transitionLog.append({
|
||||
qitemId: input.qitemId,
|
||||
state: "in-progress",
|
||||
actorSession: input.destinationSession,
|
||||
transitionNote: "claimed",
|
||||
});
|
||||
|
||||
return this.eventBus.persistWithinTransaction({
|
||||
type: "queue.claimed",
|
||||
qitemId: input.qitemId,
|
||||
destinationSession: input.destinationSession,
|
||||
claimedAt: ts,
|
||||
closureRequiredAt,
|
||||
});
|
||||
});
|
||||
|
||||
const persistedEvent = txn();
|
||||
this.eventBus.notifySubscribers(persistedEvent);
|
||||
return this.getByIdOrThrow(input.qitemId);
|
||||
}
|
||||
|
||||
unclaim(qitemId: string, destinationSession: string, reason: string): QueueItem {
|
||||
const qitem = this.getById(qitemId);
|
||||
if (!qitem) {
|
||||
throw new QueueRepositoryError("qitem_not_found", `qitem ${qitemId} not found`);
|
||||
}
|
||||
if (qitem.state !== "in-progress") {
|
||||
throw new QueueRepositoryError(
|
||||
"qitem_not_in_progress",
|
||||
`qitem ${qitemId} is in state ${qitem.state}; only in-progress can be unclaimed`
|
||||
);
|
||||
}
|
||||
const ts = new Date().toISOString();
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE queue_items
|
||||
SET state = 'pending',
|
||||
ts_updated = ?,
|
||||
claimed_at = NULL,
|
||||
closure_required_at = NULL
|
||||
WHERE qitem_id = ?`
|
||||
)
|
||||
.run(ts, qitemId);
|
||||
|
||||
this.transitionLog.append({
|
||||
qitemId,
|
||||
state: "pending",
|
||||
actorSession: destinationSession,
|
||||
transitionNote: `unclaimed: ${reason}`,
|
||||
});
|
||||
|
||||
return this.eventBus.persistWithinTransaction({
|
||||
type: "queue.unclaimed",
|
||||
qitemId,
|
||||
destinationSession,
|
||||
reason,
|
||||
});
|
||||
});
|
||||
|
||||
const persistedEvent = txn();
|
||||
this.eventBus.notifySubscribers(persistedEvent);
|
||||
return this.getByIdOrThrow(qitemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* General state mutator. Routes through hot-potato strict-rejection on
|
||||
* `done` transitions. All transitions append to the log.
|
||||
*/
|
||||
update(input: QueueUpdateInput): QueueItem {
|
||||
const qitem = this.getById(input.qitemId);
|
||||
if (!qitem) {
|
||||
throw new QueueRepositoryError(
|
||||
"qitem_not_found",
|
||||
`qitem ${input.qitemId} not found`
|
||||
);
|
||||
}
|
||||
if (!isQueueState(input.state)) {
|
||||
throw new QueueRepositoryError(
|
||||
"invalid_state",
|
||||
`state=${input.state} not valid; valid: ${QUEUE_STATES.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
const validation = validateClosure({
|
||||
state: input.state,
|
||||
closureReason: input.closureReason ?? null,
|
||||
closureTarget: input.closureTarget ?? null,
|
||||
});
|
||||
if (!validation.ok) {
|
||||
throw new QueueRepositoryError(validation.code, validation.message, {
|
||||
validReasons: "validReasons" in validation ? validation.validReasons : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE queue_items
|
||||
SET state = ?,
|
||||
ts_updated = ?,
|
||||
closure_reason = COALESCE(?, closure_reason),
|
||||
closure_target = COALESCE(?, closure_target)
|
||||
WHERE qitem_id = ?`
|
||||
)
|
||||
.run(
|
||||
input.state,
|
||||
ts,
|
||||
validation.closureReason,
|
||||
validation.closureTarget,
|
||||
input.qitemId
|
||||
);
|
||||
|
||||
this.transitionLog.append({
|
||||
qitemId: input.qitemId,
|
||||
state: input.state,
|
||||
actorSession: input.actorSession,
|
||||
transitionNote: input.transitionNote,
|
||||
closureReason: validation.closureReason ?? undefined,
|
||||
closureTarget: validation.closureTarget ?? undefined,
|
||||
});
|
||||
});
|
||||
|
||||
txn();
|
||||
return this.getByIdOrThrow(input.qitemId);
|
||||
}
|
||||
|
||||
getById(qitemId: string): QueueItem | null {
|
||||
const row = this.db
|
||||
.prepare("SELECT * FROM queue_items WHERE qitem_id = ?")
|
||||
.get(qitemId) as QueueItemRow | undefined;
|
||||
return row ? this.rowToItem(row) : null;
|
||||
}
|
||||
|
||||
list(opts?: QueueListOptions): QueueItem[] {
|
||||
const limit = opts?.limit ?? 100;
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (opts?.destinationSession) {
|
||||
conditions.push("destination_session = ?");
|
||||
params.push(opts.destinationSession);
|
||||
}
|
||||
if (opts?.sourceSession) {
|
||||
conditions.push("source_session = ?");
|
||||
params.push(opts.sourceSession);
|
||||
}
|
||||
if (opts?.state) {
|
||||
const states = Array.isArray(opts.state) ? opts.state : [opts.state];
|
||||
const placeholders = states.map(() => "?").join(", ");
|
||||
conditions.push(`state IN (${placeholders})`);
|
||||
params.push(...states);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
params.push(limit);
|
||||
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM queue_items ${where} ORDER BY ts_created DESC LIMIT ?`
|
||||
)
|
||||
.all(...params) as QueueItemRow[];
|
||||
return rows.map((r) => this.rowToItem(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find qitems whose `closure_required_at` is past now. Used by watchdog;
|
||||
* does NOT itself emit events — callers decide whether to nudge or escalate.
|
||||
*/
|
||||
findOverdue(now?: string): QueueItem[] {
|
||||
const cutoff = now ?? new Date().toISOString();
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM queue_items
|
||||
WHERE state = 'in-progress'
|
||||
AND closure_required_at IS NOT NULL
|
||||
AND closure_required_at <= ?`
|
||||
)
|
||||
.all(cutoff) as QueueItemRow[];
|
||||
return rows.map((r) => this.rowToItem(r));
|
||||
}
|
||||
|
||||
recordNudgeAttempt(qitemId: string, result: string): void {
|
||||
const ts = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE queue_items
|
||||
SET last_nudge_attempt = ?, last_nudge_result = ?
|
||||
WHERE qitem_id = ?`
|
||||
)
|
||||
.run(ts, result, qitemId);
|
||||
}
|
||||
|
||||
recordHeartbeat(qitemId: string): void {
|
||||
const ts = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE queue_items SET last_heartbeat = ? WHERE qitem_id = ?`
|
||||
)
|
||||
.run(ts, qitemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pod-fallback: redirect qitem to a fallback destination (e.g., when a seat
|
||||
* is unreachable). Emits qitem.fallback_routed; preserves chain_of_record.
|
||||
*/
|
||||
routeToFallback(qitemId: string, fallbackDestination: string, reason: string): QueueItem {
|
||||
const qitem = this.getById(qitemId);
|
||||
if (!qitem) {
|
||||
throw new QueueRepositoryError("qitem_not_found", `qitem ${qitemId} not found`);
|
||||
}
|
||||
const ts = new Date().toISOString();
|
||||
const originalDestination = qitem.destinationSession;
|
||||
const newChain = JSON.stringify([...(qitem.chainOfRecord ?? []), `fallback-from:${originalDestination}`]);
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE queue_items
|
||||
SET destination_session = ?,
|
||||
ts_updated = ?,
|
||||
chain_of_record = ?,
|
||||
resolution = ?
|
||||
WHERE qitem_id = ?`
|
||||
)
|
||||
.run(fallbackDestination, ts, newChain, `fallback: ${reason}`, qitemId);
|
||||
|
||||
this.transitionLog.append({
|
||||
qitemId,
|
||||
state: qitem.state,
|
||||
actorSession: "system:queue-fallback",
|
||||
transitionNote: `fallback-routed: ${originalDestination} → ${fallbackDestination} (${reason})`,
|
||||
});
|
||||
|
||||
return this.eventBus.persistWithinTransaction({
|
||||
type: "qitem.fallback_routed",
|
||||
qitemId,
|
||||
originalDestination,
|
||||
rerouteDestination: fallbackDestination,
|
||||
reason,
|
||||
});
|
||||
});
|
||||
|
||||
const persistedEvent = txn();
|
||||
this.eventBus.notifySubscribers(persistedEvent);
|
||||
return this.getByIdOrThrow(qitemId);
|
||||
}
|
||||
|
||||
private getByIdOrThrow(qitemId: string): QueueItem {
|
||||
const item = this.getById(qitemId);
|
||||
if (!item) {
|
||||
throw new QueueRepositoryError("qitem_not_found", `qitem ${qitemId} not found after write`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
private rowToItem(row: QueueItemRow): QueueItem {
|
||||
return {
|
||||
qitemId: row.qitem_id,
|
||||
tsCreated: row.ts_created,
|
||||
tsUpdated: row.ts_updated,
|
||||
sourceSession: row.source_session,
|
||||
destinationSession: row.destination_session,
|
||||
state: row.state as QueueState,
|
||||
priority: row.priority as QueuePriority,
|
||||
tier: row.tier,
|
||||
tags: row.tags ? (JSON.parse(row.tags) as string[]) : null,
|
||||
blockedOn: row.blocked_on,
|
||||
handedOffTo: row.handed_off_to,
|
||||
handedOffFrom: row.handed_off_from,
|
||||
expiresAt: row.expires_at,
|
||||
chainOfRecord: row.chain_of_record ? (JSON.parse(row.chain_of_record) as string[]) : null,
|
||||
body: row.body,
|
||||
closureReason: row.closure_reason as ClosureReason | null,
|
||||
closureTarget: row.closure_target,
|
||||
closureRequiredAt: row.closure_required_at,
|
||||
claimedAt: row.claimed_at,
|
||||
lastNudgeAttempt: row.last_nudge_attempt,
|
||||
lastNudgeResult: row.last_nudge_result,
|
||||
lastHeartbeat: row.last_heartbeat,
|
||||
resolution: row.resolution,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isQueueState(value: string): value is QueueState {
|
||||
return (QUEUE_STATES as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { ClosureReason } from "./hot-potato-enforcer.js";
|
||||
|
||||
export interface QueueTransition {
|
||||
transitionId: number;
|
||||
qitemId: string;
|
||||
ts: string;
|
||||
state: string;
|
||||
transitionNote: string | null;
|
||||
actorSession: string;
|
||||
closureReason: ClosureReason | null;
|
||||
closureTarget: string | null;
|
||||
}
|
||||
|
||||
export interface QueueTransitionInput {
|
||||
qitemId: string;
|
||||
state: string;
|
||||
actorSession: string;
|
||||
transitionNote?: string;
|
||||
closureReason?: ClosureReason;
|
||||
closureTarget?: string;
|
||||
}
|
||||
|
||||
interface QueueTransitionRow {
|
||||
transition_id: number;
|
||||
qitem_id: string;
|
||||
ts: string;
|
||||
state: string;
|
||||
transition_note: string | null;
|
||||
actor_session: string;
|
||||
closure_reason: string | null;
|
||||
closure_target: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append-only transition log. Domain code MUST NOT update or delete rows here.
|
||||
* This log is the authoritative audit trail for queue state evolution.
|
||||
*/
|
||||
export class QueueTransitionLog {
|
||||
readonly db: Database.Database;
|
||||
|
||||
constructor(db: Database.Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a transition. Designed to be called inside an outer caller-managed
|
||||
* `db.transaction()` so the transition row is atomic with the queue_items
|
||||
* UPDATE that produced it.
|
||||
*/
|
||||
append(input: QueueTransitionInput): QueueTransition {
|
||||
const ts = new Date().toISOString();
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`INSERT INTO queue_transitions (
|
||||
qitem_id, ts, state, transition_note, actor_session, closure_reason, closure_target
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
input.qitemId,
|
||||
ts,
|
||||
input.state,
|
||||
input.transitionNote ?? null,
|
||||
input.actorSession,
|
||||
input.closureReason ?? null,
|
||||
input.closureTarget ?? null
|
||||
);
|
||||
|
||||
const row = this.db
|
||||
.prepare("SELECT * FROM queue_transitions WHERE transition_id = ?")
|
||||
.get(Number(result.lastInsertRowid)) as QueueTransitionRow;
|
||||
|
||||
return this.rowToTransition(row);
|
||||
}
|
||||
|
||||
listForQitem(qitemId: string): QueueTransition[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
"SELECT * FROM queue_transitions WHERE qitem_id = ? ORDER BY transition_id ASC"
|
||||
)
|
||||
.all(qitemId) as QueueTransitionRow[];
|
||||
return rows.map((r) => this.rowToTransition(r));
|
||||
}
|
||||
|
||||
listForActor(actorSession: string, limit = 100): QueueTransition[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
"SELECT * FROM queue_transitions WHERE actor_session = ? ORDER BY transition_id DESC LIMIT ?"
|
||||
)
|
||||
.all(actorSession, limit) as QueueTransitionRow[];
|
||||
return rows.map((r) => this.rowToTransition(r));
|
||||
}
|
||||
|
||||
private rowToTransition(row: QueueTransitionRow): QueueTransition {
|
||||
return {
|
||||
transitionId: row.transition_id,
|
||||
qitemId: row.qitem_id,
|
||||
ts: row.ts,
|
||||
state: row.state,
|
||||
transitionNote: row.transition_note,
|
||||
actorSession: row.actor_session,
|
||||
closureReason: row.closure_reason as ClosureReason | null,
|
||||
closureTarget: row.closure_target,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { monotonicFactory } from "ulid";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
|
||||
const ulid = monotonicFactory();
|
||||
|
||||
export interface StreamItem {
|
||||
streamItemId: string;
|
||||
tsEmitted: string;
|
||||
streamSortKey: string;
|
||||
sourceSession: string;
|
||||
body: string;
|
||||
format: string;
|
||||
hintType: string | null;
|
||||
hintUrgency: string | null;
|
||||
hintDestination: string | null;
|
||||
hintTags: string[] | null;
|
||||
interrupt: boolean;
|
||||
archivedAt: string | null;
|
||||
}
|
||||
|
||||
export interface StreamEmitInput {
|
||||
streamItemId?: string;
|
||||
sourceSession: string;
|
||||
body: string;
|
||||
format?: string;
|
||||
hintType?: string | null;
|
||||
hintUrgency?: string | null;
|
||||
hintDestination?: string | null;
|
||||
hintTags?: string[] | null;
|
||||
interrupt?: boolean;
|
||||
}
|
||||
|
||||
export interface StreamListOptions {
|
||||
limit?: number;
|
||||
afterSortKey?: string;
|
||||
sourceSession?: string;
|
||||
hintDestination?: string;
|
||||
includeArchived?: boolean;
|
||||
}
|
||||
|
||||
interface StreamItemRow {
|
||||
stream_item_id: string;
|
||||
ts_emitted: string;
|
||||
stream_sort_key: string;
|
||||
source_session: string;
|
||||
body: string;
|
||||
format: string;
|
||||
hint_type: string | null;
|
||||
hint_urgency: string | null;
|
||||
hint_destination: string | null;
|
||||
hint_tags: string | null;
|
||||
interrupt: number;
|
||||
archived_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* L1 — Stream store. Append-only intake/audit root.
|
||||
* Items are immutable after emit (only `archived_at` may be set).
|
||||
*/
|
||||
export class StreamStore {
|
||||
readonly db: Database.Database;
|
||||
private readonly eventBus: EventBus;
|
||||
|
||||
constructor(db: Database.Database, eventBus: EventBus) {
|
||||
this.db = db;
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent emit: same `streamItemId` returns the existing row.
|
||||
*/
|
||||
emit(input: StreamEmitInput): StreamItem {
|
||||
const tsEmitted = new Date().toISOString();
|
||||
const id = input.streamItemId ?? ulid();
|
||||
|
||||
const existing = this.getByIdRaw(id);
|
||||
if (existing) return this.rowToItem(existing);
|
||||
|
||||
const sortKey = ulid();
|
||||
const interrupt = input.interrupt ? 1 : 0;
|
||||
const tagsJson = input.hintTags ? JSON.stringify(input.hintTags) : null;
|
||||
|
||||
const persistTxn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO stream_items (
|
||||
stream_item_id, ts_emitted, stream_sort_key, source_session, body,
|
||||
format, hint_type, hint_urgency, hint_destination, hint_tags, interrupt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
tsEmitted,
|
||||
sortKey,
|
||||
input.sourceSession,
|
||||
input.body,
|
||||
input.format ?? "text",
|
||||
input.hintType ?? null,
|
||||
input.hintUrgency ?? null,
|
||||
input.hintDestination ?? null,
|
||||
tagsJson,
|
||||
interrupt
|
||||
);
|
||||
|
||||
return this.eventBus.persistWithinTransaction({
|
||||
type: "stream.emitted",
|
||||
streamItemId: id,
|
||||
sourceSession: input.sourceSession,
|
||||
hintDestination: input.hintDestination ?? null,
|
||||
hintType: input.hintType ?? null,
|
||||
hintUrgency: input.hintUrgency ?? null,
|
||||
interrupt: Boolean(input.interrupt),
|
||||
});
|
||||
});
|
||||
|
||||
const persistedEvent = persistTxn();
|
||||
this.eventBus.notifySubscribers(persistedEvent);
|
||||
|
||||
return this.rowToItem(this.getByIdRaw(id)!);
|
||||
}
|
||||
|
||||
getById(streamItemId: string): StreamItem | null {
|
||||
const row = this.getByIdRaw(streamItemId);
|
||||
return row ? this.rowToItem(row) : null;
|
||||
}
|
||||
|
||||
list(opts?: StreamListOptions): StreamItem[] {
|
||||
const limit = opts?.limit ?? 100;
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (!opts?.includeArchived) {
|
||||
conditions.push("archived_at IS NULL");
|
||||
}
|
||||
if (opts?.afterSortKey) {
|
||||
conditions.push("(ts_emitted, stream_sort_key) > (SELECT ts_emitted, stream_sort_key FROM stream_items WHERE stream_sort_key = ?)");
|
||||
params.push(opts.afterSortKey);
|
||||
}
|
||||
if (opts?.sourceSession) {
|
||||
conditions.push("source_session = ?");
|
||||
params.push(opts.sourceSession);
|
||||
}
|
||||
if (opts?.hintDestination) {
|
||||
conditions.push("hint_destination = ?");
|
||||
params.push(opts.hintDestination);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
params.push(limit);
|
||||
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM stream_items ${where} ORDER BY ts_emitted ASC, stream_sort_key ASC LIMIT ?`
|
||||
)
|
||||
.all(...params) as StreamItemRow[];
|
||||
|
||||
return rows.map((r) => this.rowToItem(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-archive — sets `archived_at`. Items remain in the table for audit.
|
||||
*/
|
||||
archive(streamItemId: string): boolean {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
"UPDATE stream_items SET archived_at = datetime('now') WHERE stream_item_id = ? AND archived_at IS NULL"
|
||||
)
|
||||
.run(streamItemId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
private getByIdRaw(streamItemId: string): StreamItemRow | undefined {
|
||||
return this.db
|
||||
.prepare("SELECT * FROM stream_items WHERE stream_item_id = ?")
|
||||
.get(streamItemId) as StreamItemRow | undefined;
|
||||
}
|
||||
|
||||
private rowToItem(row: StreamItemRow): StreamItem {
|
||||
return {
|
||||
streamItemId: row.stream_item_id,
|
||||
tsEmitted: row.ts_emitted,
|
||||
streamSortKey: row.stream_sort_key,
|
||||
sourceSession: row.source_session,
|
||||
body: row.body,
|
||||
format: row.format,
|
||||
hintType: row.hint_type,
|
||||
hintUrgency: row.hint_urgency,
|
||||
hintDestination: row.hint_destination,
|
||||
hintTags: row.hint_tags ? (JSON.parse(row.hint_tags) as string[]) : null,
|
||||
interrupt: row.interrupt === 1,
|
||||
archivedAt: row.archived_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,19 @@ export type RigEvent =
|
||||
// Chat events
|
||||
| { type: "chat.message"; rigId: string; messageId: string; sender: string; kind: string; body: string; topic?: string }
|
||||
// Expansion events
|
||||
| { type: "rig.expanded"; rigId: string; podId: string; podNamespace: string; nodes: Array<{ logicalId: string; status: string }>; status: string };
|
||||
| { type: "rig.expanded"; rigId: string; podId: string; podNamespace: string; nodes: Array<{ logicalId: string; status: string }>; status: string }
|
||||
// Coordination primitive (PL-004 Phase A) — stream / queue / inbox.
|
||||
// Host-scoped; rigId is left null because items reference seats by string
|
||||
// (`<member>@<rig>`) and can cross rigs.
|
||||
| { type: "stream.emitted"; streamItemId: string; sourceSession: string; hintDestination: string | null; hintType: string | null; hintUrgency: string | null; interrupt: boolean }
|
||||
| { type: "queue.created"; qitemId: string; sourceSession: string; destinationSession: string; priority: string; tier: string | null }
|
||||
| { type: "queue.handed_off"; qitemId: string; fromSession: string; toSession: string; closureReason: "handed_off_to" }
|
||||
| { type: "queue.claimed"; qitemId: string; destinationSession: string; claimedAt: string; closureRequiredAt: string | null }
|
||||
| { type: "queue.unclaimed"; qitemId: string; destinationSession: string; reason: string }
|
||||
| { type: "qitem.fallback_routed"; qitemId: string; originalDestination: string; rerouteDestination: string; reason: string }
|
||||
| { type: "qitem.closure_overdue"; qitemId: string; destinationSession: string; closureRequiredAt: string; overdueSince: string }
|
||||
| { type: "inbox.absorbed"; inboxId: string; destinationSession: string; senderSession: string; promotedQitemId: string }
|
||||
| { type: "inbox.denied"; inboxId: string; destinationSession: string; senderSession: string; reason: string };
|
||||
|
||||
export type PersistedEvent = RigEvent & {
|
||||
seq: number;
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import type {
|
||||
QueueRepository,
|
||||
QueuePriority,
|
||||
QueueState,
|
||||
} from "../domain/queue-repository.js";
|
||||
import { QueueRepositoryError } from "../domain/queue-repository.js";
|
||||
import type { InboxHandler } from "../domain/inbox-handler.js";
|
||||
import { InboxHandlerError } from "../domain/inbox-handler.js";
|
||||
import type { OutboxHandler } from "../domain/outbox-handler.js";
|
||||
|
||||
/**
|
||||
* Coordination L3 — Queue HTTP routes (PL-004 Phase A).
|
||||
*
|
||||
* Host-scoped. Backs `rig queue create|claim|update|handoff|show|list|inbox-*`.
|
||||
* Hot-potato strict-rejection happens in the domain layer; routes surface
|
||||
* structured errors with the validReasons enum so CLIs can render help.
|
||||
*/
|
||||
export function queueRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
function getRepo(c: { get: (key: string) => unknown }): QueueRepository {
|
||||
return c.get("queueRepo" as never) as QueueRepository;
|
||||
}
|
||||
function getInbox(c: { get: (key: string) => unknown }): InboxHandler {
|
||||
return c.get("inboxHandler" as never) as InboxHandler;
|
||||
}
|
||||
function getOutbox(c: { get: (key: string) => unknown }): OutboxHandler {
|
||||
return c.get("outboxHandler" as never) as OutboxHandler;
|
||||
}
|
||||
function getEventBus(c: { get: (key: string) => unknown }): EventBus {
|
||||
return c.get("eventBus" as never) as EventBus;
|
||||
}
|
||||
|
||||
function errorResponse(c: { json: (body: unknown, status?: number) => Response }, err: unknown): Response {
|
||||
if (err instanceof QueueRepositoryError) {
|
||||
const status = err.code === "qitem_not_found" ? 404
|
||||
: err.code === "missing_closure_reason" ? 400
|
||||
: err.code === "invalid_closure_reason" ? 400
|
||||
: err.code === "missing_closure_target" ? 400
|
||||
: err.code === "invalid_state" ? 400
|
||||
: err.code === "claim_destination_mismatch" ? 403
|
||||
: err.code === "qitem_not_claimable" ? 409
|
||||
: err.code === "qitem_not_in_progress" ? 409
|
||||
: err.code === "qitem_already_terminal" ? 409
|
||||
: err.code === "unknown_destination_rig" ? 400
|
||||
: 500;
|
||||
return c.json({ error: err.code, message: err.message, ...(err.meta ?? {}) }, status as 200);
|
||||
}
|
||||
if (err instanceof InboxHandlerError) {
|
||||
const status = err.code === "inbox_not_found" ? 404
|
||||
: err.code === "auth_failed" ? 401
|
||||
: err.code === "absorb_destination_mismatch" ? 403
|
||||
: err.code === "deny_destination_mismatch" ? 403
|
||||
: err.code === "inbox_already_denied" ? 409
|
||||
: err.code === "inbox_not_pending" ? 409
|
||||
: 500;
|
||||
return c.json({ error: err.code, message: err.message }, status as 200);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "internal error";
|
||||
return c.json({ error: "internal_error", message }, 500);
|
||||
}
|
||||
|
||||
// POST /create
|
||||
app.post("/create", async (c) => {
|
||||
const body = await c.req.json<{
|
||||
qitemId?: string;
|
||||
sourceSession?: string;
|
||||
destinationSession?: string;
|
||||
body?: string;
|
||||
priority?: QueuePriority;
|
||||
tier?: string;
|
||||
tags?: string[];
|
||||
expiresAt?: string;
|
||||
chainOfRecord?: string[];
|
||||
}>().catch(() => ({} as never));
|
||||
|
||||
if (!body.sourceSession) return c.json({ error: "sourceSession is required" }, 400);
|
||||
if (!body.destinationSession) return c.json({ error: "destinationSession is required" }, 400);
|
||||
if (!body.body) return c.json({ error: "body is required" }, 400);
|
||||
|
||||
try {
|
||||
const item = getRepo(c).create({
|
||||
qitemId: body.qitemId,
|
||||
sourceSession: body.sourceSession,
|
||||
destinationSession: body.destinationSession,
|
||||
body: body.body,
|
||||
priority: body.priority,
|
||||
tier: body.tier,
|
||||
tags: body.tags,
|
||||
expiresAt: body.expiresAt,
|
||||
chainOfRecord: body.chainOfRecord,
|
||||
});
|
||||
return c.json(item, 201);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:qitemId/claim
|
||||
app.post("/:qitemId/claim", async (c) => {
|
||||
const qitemId = c.req.param("qitemId");
|
||||
const body = await c.req.json<{ destinationSession?: string }>().catch(() => ({} as never));
|
||||
if (!body.destinationSession) return c.json({ error: "destinationSession is required" }, 400);
|
||||
try {
|
||||
const item = getRepo(c).claim({ qitemId, destinationSession: body.destinationSession });
|
||||
return c.json(item);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:qitemId/unclaim
|
||||
app.post("/:qitemId/unclaim", async (c) => {
|
||||
const qitemId = c.req.param("qitemId");
|
||||
const body = await c.req.json<{ destinationSession?: string; reason?: string }>().catch(() => ({} as never));
|
||||
if (!body.destinationSession) return c.json({ error: "destinationSession is required" }, 400);
|
||||
try {
|
||||
const item = getRepo(c).unclaim(qitemId, body.destinationSession, body.reason ?? "manual");
|
||||
return c.json(item);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:qitemId/update — general state mutator (incl. done)
|
||||
app.post("/:qitemId/update", async (c) => {
|
||||
const qitemId = c.req.param("qitemId");
|
||||
const body = await c.req.json<{
|
||||
actorSession?: string;
|
||||
state?: QueueState;
|
||||
transitionNote?: string;
|
||||
closureReason?: string;
|
||||
closureTarget?: string;
|
||||
}>().catch(() => ({} as never));
|
||||
if (!body.actorSession) return c.json({ error: "actorSession is required" }, 400);
|
||||
if (!body.state) return c.json({ error: "state is required" }, 400);
|
||||
|
||||
try {
|
||||
const item = getRepo(c).update({
|
||||
qitemId,
|
||||
actorSession: body.actorSession,
|
||||
state: body.state,
|
||||
transitionNote: body.transitionNote,
|
||||
closureReason: body.closureReason,
|
||||
closureTarget: body.closureTarget,
|
||||
});
|
||||
return c.json(item);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:qitemId/handoff — transactional close+create
|
||||
app.post("/:qitemId/handoff", async (c) => {
|
||||
const qitemId = c.req.param("qitemId");
|
||||
const body = await c.req.json<{
|
||||
fromSession?: string;
|
||||
toSession?: string;
|
||||
body?: string;
|
||||
transitionNote?: string;
|
||||
priority?: QueuePriority;
|
||||
tier?: string;
|
||||
tags?: string[];
|
||||
}>().catch(() => ({} as never));
|
||||
if (!body.fromSession) return c.json({ error: "fromSession is required" }, 400);
|
||||
if (!body.toSession) return c.json({ error: "toSession is required" }, 400);
|
||||
|
||||
try {
|
||||
const result = getRepo(c).handoff({
|
||||
qitemId,
|
||||
fromSession: body.fromSession,
|
||||
toSession: body.toSession,
|
||||
body: body.body,
|
||||
transitionNote: body.transitionNote,
|
||||
priority: body.priority,
|
||||
tier: body.tier,
|
||||
tags: body.tags,
|
||||
});
|
||||
return c.json(result, 201);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:qitemId/fallback
|
||||
app.post("/:qitemId/fallback", async (c) => {
|
||||
const qitemId = c.req.param("qitemId");
|
||||
const body = await c.req.json<{ fallbackDestination?: string; reason?: string }>().catch(() => ({} as never));
|
||||
if (!body.fallbackDestination) return c.json({ error: "fallbackDestination is required" }, 400);
|
||||
try {
|
||||
const item = getRepo(c).routeToFallback(qitemId, body.fallbackDestination, body.reason ?? "manual");
|
||||
return c.json(item);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /list — list with filters. MUST precede /:qitemId so the literal path wins.
|
||||
app.get("/list", (c) => {
|
||||
const destinationSession = c.req.query("destinationSession") || undefined;
|
||||
const sourceSession = c.req.query("sourceSession") || undefined;
|
||||
const stateRaw = c.req.query("state") || undefined;
|
||||
const limit = c.req.query("limit") ? Number.parseInt(c.req.query("limit")!, 10) : undefined;
|
||||
const state = stateRaw ? (stateRaw.split(",") as QueueState[]) : undefined;
|
||||
const items = getRepo(c).list({ destinationSession, sourceSession, state, limit });
|
||||
return c.json(items);
|
||||
});
|
||||
|
||||
// GET /overdue — surfaces in-progress qitems past closure_required_at.
|
||||
// MUST precede /:qitemId.
|
||||
app.get("/overdue", (c) => {
|
||||
const items = getRepo(c).findOverdue();
|
||||
return c.json(items);
|
||||
});
|
||||
|
||||
// GET /:qitemId/transitions — registered before /:qitemId so the literal
|
||||
// suffix wins over the bare param route.
|
||||
app.get("/:qitemId/transitions", (c) => {
|
||||
const qitemId = c.req.param("qitemId");
|
||||
const repo = getRepo(c);
|
||||
if (!repo.getById(qitemId)) return c.json({ error: "qitem_not_found" }, 404);
|
||||
return c.json(repo.transitionLog.listForQitem(qitemId));
|
||||
});
|
||||
|
||||
// GET /:qitemId — show one
|
||||
app.get("/:qitemId", (c) => {
|
||||
const qitemId = c.req.param("qitemId");
|
||||
const item = getRepo(c).getById(qitemId);
|
||||
if (!item) return c.json({ error: "qitem_not_found" }, 404);
|
||||
return c.json(item);
|
||||
});
|
||||
|
||||
// ---- Inbox routes (mailbox) ----
|
||||
|
||||
app.post("/inbox/drop", async (c) => {
|
||||
const body = await c.req.json<{
|
||||
inboxId?: string;
|
||||
destinationSession?: string;
|
||||
senderSession?: string;
|
||||
body?: string;
|
||||
tags?: string[];
|
||||
urgency?: string;
|
||||
auditPointer?: string;
|
||||
authenticatedSender?: string;
|
||||
}>().catch(() => ({} as never));
|
||||
if (!body.destinationSession) return c.json({ error: "destinationSession is required" }, 400);
|
||||
if (!body.senderSession) return c.json({ error: "senderSession is required" }, 400);
|
||||
if (!body.body) return c.json({ error: "body is required" }, 400);
|
||||
|
||||
try {
|
||||
const entry = getInbox(c).drop(
|
||||
{
|
||||
inboxId: body.inboxId,
|
||||
destinationSession: body.destinationSession,
|
||||
senderSession: body.senderSession,
|
||||
body: body.body,
|
||||
tags: body.tags,
|
||||
urgency: body.urgency,
|
||||
auditPointer: body.auditPointer,
|
||||
},
|
||||
body.authenticatedSender
|
||||
);
|
||||
return c.json(entry, 201);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/inbox/:inboxId/absorb", async (c) => {
|
||||
const inboxId = c.req.param("inboxId");
|
||||
const body = await c.req.json<{ receiverSession?: string }>().catch(() => ({} as never));
|
||||
if (!body.receiverSession) return c.json({ error: "receiverSession is required" }, 400);
|
||||
try {
|
||||
const result = getInbox(c).absorb(inboxId, body.receiverSession);
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/inbox/:inboxId/deny", async (c) => {
|
||||
const inboxId = c.req.param("inboxId");
|
||||
const body = await c.req.json<{ receiverSession?: string; reason?: string }>().catch(() => ({} as never));
|
||||
if (!body.receiverSession) return c.json({ error: "receiverSession is required" }, 400);
|
||||
if (!body.reason) return c.json({ error: "reason is required" }, 400);
|
||||
try {
|
||||
const entry = getInbox(c).deny(inboxId, body.receiverSession, body.reason);
|
||||
return c.json(entry);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/inbox/pending", (c) => {
|
||||
const destinationSession = c.req.query("destinationSession");
|
||||
if (!destinationSession) return c.json({ error: "destinationSession is required" }, 400);
|
||||
return c.json(getInbox(c).listPending(destinationSession));
|
||||
});
|
||||
|
||||
app.get("/inbox/list", (c) => {
|
||||
const destinationSession = c.req.query("destinationSession");
|
||||
if (!destinationSession) return c.json({ error: "destinationSession is required" }, 400);
|
||||
const limit = c.req.query("limit") ? Number.parseInt(c.req.query("limit")!, 10) : undefined;
|
||||
return c.json(getInbox(c).listForDestination(destinationSession, limit));
|
||||
});
|
||||
|
||||
// ---- Outbox routes ----
|
||||
|
||||
app.post("/outbox/record", async (c) => {
|
||||
const body = await c.req.json<{
|
||||
outboxId?: string;
|
||||
senderSession?: string;
|
||||
destinationSession?: string;
|
||||
body?: string;
|
||||
tags?: string[];
|
||||
urgency?: string;
|
||||
auditPointer?: string;
|
||||
}>().catch(() => ({} as never));
|
||||
if (!body.senderSession) return c.json({ error: "senderSession is required" }, 400);
|
||||
if (!body.destinationSession) return c.json({ error: "destinationSession is required" }, 400);
|
||||
if (!body.body) return c.json({ error: "body is required" }, 400);
|
||||
|
||||
const entry = getOutbox(c).record({
|
||||
outboxId: body.outboxId,
|
||||
senderSession: body.senderSession,
|
||||
destinationSession: body.destinationSession,
|
||||
body: body.body,
|
||||
tags: body.tags,
|
||||
urgency: body.urgency,
|
||||
auditPointer: body.auditPointer,
|
||||
});
|
||||
return c.json(entry, 201);
|
||||
});
|
||||
|
||||
app.get("/outbox/list", (c) => {
|
||||
const senderSession = c.req.query("senderSession");
|
||||
if (!senderSession) return c.json({ error: "senderSession is required" }, 400);
|
||||
const limit = c.req.query("limit") ? Number.parseInt(c.req.query("limit")!, 10) : undefined;
|
||||
return c.json(getOutbox(c).listForSender(senderSession, limit));
|
||||
});
|
||||
|
||||
// ---- SSE watch over coordination events ----
|
||||
|
||||
app.get("/watch", (c) => {
|
||||
const eventBus = getEventBus(c);
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const unsubscribe = eventBus.subscribe((event) => {
|
||||
if (
|
||||
event.type !== "queue.created" &&
|
||||
event.type !== "queue.handed_off" &&
|
||||
event.type !== "queue.claimed" &&
|
||||
event.type !== "queue.unclaimed" &&
|
||||
event.type !== "qitem.fallback_routed" &&
|
||||
event.type !== "qitem.closure_overdue" &&
|
||||
event.type !== "inbox.absorbed" &&
|
||||
event.type !== "inbox.denied"
|
||||
) return;
|
||||
const sse = { id: String(event.seq), data: JSON.stringify(event) };
|
||||
stream.writeSSE(sse).catch(() => {});
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import type { StreamStore } from "../domain/stream-store.js";
|
||||
|
||||
/**
|
||||
* Coordination L1 — Stream HTTP routes (PL-004 Phase A).
|
||||
*
|
||||
* Host-scoped (no rigId in path). Append-only intake/audit root for the
|
||||
* coordination primitive. CLI `rig stream` calls these endpoints.
|
||||
*/
|
||||
export function streamRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
function getStore(c: { get: (key: string) => unknown }): StreamStore {
|
||||
return c.get("streamStore" as never) as StreamStore;
|
||||
}
|
||||
|
||||
function getEventBus(c: { get: (key: string) => unknown }): EventBus {
|
||||
return c.get("eventBus" as never) as EventBus;
|
||||
}
|
||||
|
||||
// POST /emit — append a stream item
|
||||
app.post("/emit", async (c) => {
|
||||
const body = await c.req.json<{
|
||||
streamItemId?: string;
|
||||
sourceSession?: string;
|
||||
body?: string;
|
||||
format?: string;
|
||||
hintType?: string | null;
|
||||
hintUrgency?: string | null;
|
||||
hintDestination?: string | null;
|
||||
hintTags?: string[] | null;
|
||||
interrupt?: boolean;
|
||||
}>().catch(() => ({} as never));
|
||||
|
||||
if (!body.sourceSession) return c.json({ error: "sourceSession is required" }, 400);
|
||||
if (!body.body) return c.json({ error: "body is required" }, 400);
|
||||
|
||||
const store = getStore(c);
|
||||
const item = store.emit({
|
||||
streamItemId: body.streamItemId,
|
||||
sourceSession: body.sourceSession,
|
||||
body: body.body,
|
||||
format: body.format,
|
||||
hintType: body.hintType ?? null,
|
||||
hintUrgency: body.hintUrgency ?? null,
|
||||
hintDestination: body.hintDestination ?? null,
|
||||
hintTags: body.hintTags ?? null,
|
||||
interrupt: body.interrupt,
|
||||
});
|
||||
return c.json(item, 201);
|
||||
});
|
||||
|
||||
// GET /list — paginated list with filters
|
||||
app.get("/list", (c) => {
|
||||
const limit = c.req.query("limit") ? Number.parseInt(c.req.query("limit")!, 10) : undefined;
|
||||
const afterSortKey = c.req.query("afterSortKey") || undefined;
|
||||
const sourceSession = c.req.query("sourceSession") || undefined;
|
||||
const hintDestination = c.req.query("hintDestination") || undefined;
|
||||
const includeArchived = c.req.query("includeArchived") === "true";
|
||||
|
||||
const store = getStore(c);
|
||||
const items = store.list({ limit, afterSortKey, sourceSession, hintDestination, includeArchived });
|
||||
return c.json(items);
|
||||
});
|
||||
|
||||
// GET /:streamItemId — fetch one
|
||||
app.get("/:streamItemId", (c) => {
|
||||
const id = c.req.param("streamItemId");
|
||||
const store = getStore(c);
|
||||
const item = store.getById(id);
|
||||
if (!item) return c.json({ error: "stream item not found" }, 404);
|
||||
return c.json(item);
|
||||
});
|
||||
|
||||
// POST /:streamItemId/archive
|
||||
app.post("/:streamItemId/archive", (c) => {
|
||||
const id = c.req.param("streamItemId");
|
||||
const store = getStore(c);
|
||||
const ok = store.archive(id);
|
||||
if (!ok) return c.json({ error: "stream item not found or already archived" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /watch — SSE for new stream.emitted events
|
||||
app.get("/watch", (c) => {
|
||||
const eventBus = getEventBus(c);
|
||||
const store = getStore(c);
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const initialDone = { value: false };
|
||||
const pending: Array<{ id: string; data: string }> = [];
|
||||
|
||||
const unsubscribe = eventBus.subscribe((event) => {
|
||||
if (event.type !== "stream.emitted") return;
|
||||
const item = store.getById(event.streamItemId);
|
||||
if (!item) return;
|
||||
const sse = { id: item.streamItemId, data: JSON.stringify(item) };
|
||||
if (initialDone.value) {
|
||||
stream.writeSSE(sse).catch(() => {});
|
||||
} else {
|
||||
pending.push(sse);
|
||||
}
|
||||
});
|
||||
|
||||
const initial = store.list({ limit: 50 });
|
||||
const sentIds = new Set<string>();
|
||||
for (const item of initial) {
|
||||
await stream.writeSSE({ id: item.streamItemId, data: JSON.stringify(item) });
|
||||
sentIds.add(item.streamItemId);
|
||||
}
|
||||
|
||||
initialDone.value = true;
|
||||
for (const p of pending) {
|
||||
if (!sentIds.has(p.id)) await stream.writeSSE(p);
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -59,6 +59,12 @@ import type { ChatRepository } from "./domain/chat-repository.js";
|
||||
import { whoamiRoutes } from "./routes/whoami.js";
|
||||
import type { WhoamiService } from "./domain/whoami-service.js";
|
||||
import { chatRoutes } from "./routes/chat.js";
|
||||
import { streamRoutes } from "./routes/stream.js";
|
||||
import { queueRoutes } from "./routes/queue.js";
|
||||
import type { StreamStore } from "./domain/stream-store.js";
|
||||
import type { QueueRepository } from "./domain/queue-repository.js";
|
||||
import type { InboxHandler } from "./domain/inbox-handler.js";
|
||||
import type { OutboxHandler } from "./domain/outbox-handler.js";
|
||||
import { envRoutes } from "./routes/env.js";
|
||||
import type { RigLifecycleService } from "./domain/rig-lifecycle-service.js";
|
||||
import { seatRoutes } from "./routes/seat.js";
|
||||
@@ -98,6 +104,10 @@ export interface AppDeps {
|
||||
sessionTransport?: SessionTransport;
|
||||
askService?: AskService;
|
||||
chatRepo?: ChatRepository;
|
||||
streamStore?: StreamStore;
|
||||
queueRepo?: QueueRepository;
|
||||
inboxHandler?: InboxHandler;
|
||||
outboxHandler?: OutboxHandler;
|
||||
specReviewService?: SpecReviewService;
|
||||
specLibraryService?: SpecLibraryService;
|
||||
whoamiService?: WhoamiService;
|
||||
@@ -239,6 +249,10 @@ export function createApp(deps: AppDeps): Hono {
|
||||
c.set("sessionTransport" as never, deps.sessionTransport);
|
||||
c.set("askService" as never, deps.askService);
|
||||
c.set("chatRepo" as never, deps.chatRepo);
|
||||
c.set("streamStore" as never, deps.streamStore);
|
||||
c.set("queueRepo" as never, deps.queueRepo);
|
||||
c.set("inboxHandler" as never, deps.inboxHandler);
|
||||
c.set("outboxHandler" as never, deps.outboxHandler);
|
||||
c.set("specReviewService" as never, deps.specReviewService);
|
||||
c.set("specLibraryService" as never, deps.specLibraryService);
|
||||
c.set("whoamiService" as never, deps.whoamiService);
|
||||
@@ -285,6 +299,8 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/whoami", whoamiRoutes());
|
||||
app.route("/api/seat", seatRoutes);
|
||||
app.route("/api/rigs/:rigId/chat", chatRoutes());
|
||||
app.route("/api/stream", streamRoutes());
|
||||
app.route("/api/queue", queueRoutes());
|
||||
app.route("/api/rigs/:rigId/env", envRoutes());
|
||||
app.route("/api/restore-check", restoreCheckRoutes);
|
||||
|
||||
|
||||
@@ -59,6 +59,10 @@ import { AgentActivityStore } from "./domain/agent-activity-store.js";
|
||||
import { HistoryQuery } from "./domain/history-query.js";
|
||||
import { AskService } from "./domain/ask-service.js";
|
||||
import { ChatRepository } from "./domain/chat-repository.js";
|
||||
import { StreamStore } from "./domain/stream-store.js";
|
||||
import { QueueRepository } from "./domain/queue-repository.js";
|
||||
import { InboxHandler } from "./domain/inbox-handler.js";
|
||||
import { OutboxHandler } from "./domain/outbox-handler.js";
|
||||
import { SpecReviewService } from "./domain/spec-review-service.js";
|
||||
import { SpecLibraryService } from "./domain/spec-library-service.js";
|
||||
import { WhoamiService } from "./domain/whoami-service.js";
|
||||
@@ -87,6 +91,11 @@ import { externalCliAttachmentSchema } from "./db/migrations/019_external_cli_at
|
||||
import { rigServicesSchema } from "./db/migrations/020_rig_services.js";
|
||||
import { seatHandoverObservabilitySchema } from "./db/migrations/021_seat_handover_observability.js";
|
||||
import { nodeCodexConfigProfileSchema } from "./db/migrations/022_node_codex_config_profile.js";
|
||||
import { streamItemsSchema } from "./db/migrations/023_stream_items.js";
|
||||
import { queueItemsSchema } from "./db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "./db/migrations/025_queue_transitions.js";
|
||||
import { inboxEntriesSchema } from "./db/migrations/026_inbox_entries.js";
|
||||
import { outboxEntriesSchema } from "./db/migrations/027_outbox_entries.js";
|
||||
import { OPENRIG_HOME } from "./openrig-compat.js";
|
||||
import {
|
||||
getCompatibleOpenRigPath,
|
||||
@@ -112,11 +121,14 @@ interface DaemonResult {
|
||||
export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult> {
|
||||
const dbPath = opts?.dbPath ?? ":memory:";
|
||||
const db = createDb(dbPath);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema]);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema]);
|
||||
|
||||
const rigRepo = new RigRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
const eventBus = new EventBus(db);
|
||||
// PL-004 Phase A — shared coordination services. Constructed early so
|
||||
// both the queueRepo dep slot and inboxHandler can share one instance.
|
||||
const queueRepoInstance = new QueueRepository(db, eventBus);
|
||||
|
||||
const tmuxAdapter = new TmuxAdapter(opts?.tmuxExec ?? execCommand);
|
||||
// cmuxFactory takes precedence (for tests), then cmuxExec-based CLI transport, then default
|
||||
@@ -360,6 +372,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
transcriptStore,
|
||||
sessionTransport: new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter, agentActivityStore }),
|
||||
chatRepo: new ChatRepository(db),
|
||||
streamStore: new StreamStore(db, eventBus),
|
||||
queueRepo: queueRepoInstance,
|
||||
inboxHandler: new InboxHandler(db, eventBus, queueRepoInstance),
|
||||
outboxHandler: new OutboxHandler(db),
|
||||
askService: (() => {
|
||||
const psProjectionService = new PsProjectionService({ db });
|
||||
const execDep = (cmd: string, args: string[]): Promise<{ stdout: string; exitCode: number }> =>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { inboxEntriesSchema } from "../src/db/migrations/026_inbox_entries.js";
|
||||
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { StreamStore } from "../src/domain/stream-store.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { InboxHandler } from "../src/domain/inbox-handler.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
import { streamRoutes } from "../src/routes/stream.js";
|
||||
import { queueRoutes } from "../src/routes/queue.js";
|
||||
|
||||
/**
|
||||
* Integration tests across the full coordination stack:
|
||||
* stream emit → hint → inbox drop → absorb → claim → handoff → terminal close.
|
||||
*
|
||||
* Mirrors the cross-loop POC scenarios used by RSI v2: the same event
|
||||
* timeline is reproducible via the daemon path (HTTP) so the upcoming
|
||||
* dogfood window can compare apples-to-apples against the filesystem POC.
|
||||
*/
|
||||
|
||||
function buildApp(deps: {
|
||||
bus: EventBus;
|
||||
store: StreamStore;
|
||||
repo: QueueRepository;
|
||||
inbox: InboxHandler;
|
||||
outbox: OutboxHandler;
|
||||
}): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("eventBus" as never, deps.bus);
|
||||
c.set("streamStore" as never, deps.store);
|
||||
c.set("queueRepo" as never, deps.repo);
|
||||
c.set("inboxHandler" as never, deps.inbox);
|
||||
c.set("outboxHandler" as never, deps.outbox);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/stream", streamRoutes());
|
||||
app.route("/api/queue", queueRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("coordination integration — stream → queue → inbox handoff chain", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let store: StreamStore;
|
||||
let repo: QueueRepository;
|
||||
let inbox: InboxHandler;
|
||||
let outbox: OutboxHandler;
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema,
|
||||
eventsSchema,
|
||||
streamItemsSchema,
|
||||
queueItemsSchema,
|
||||
queueTransitionsSchema,
|
||||
inboxEntriesSchema,
|
||||
outboxEntriesSchema,
|
||||
]);
|
||||
bus = new EventBus(db);
|
||||
store = new StreamStore(db, bus);
|
||||
repo = new QueueRepository(db, bus);
|
||||
inbox = new InboxHandler(db, bus, repo);
|
||||
outbox = new OutboxHandler(db);
|
||||
app = buildApp({ bus, store, repo, inbox, outbox });
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("full cross-loop chain: stream-emit → inbox-drop → absorb → claim → handoff → close", async () => {
|
||||
// 1. Stream emit (intake / audit root)
|
||||
const streamRes = await app.request("/api/stream/emit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceSession: "loop-discovery@rsi-v2",
|
||||
body: "found regression in product lab handoff",
|
||||
hintDestination: "loop-product-lab@rsi-v2",
|
||||
hintType: "review",
|
||||
hintUrgency: "urgent",
|
||||
}),
|
||||
});
|
||||
expect(streamRes.status).toBe(201);
|
||||
|
||||
// 2. Drop into product-lab's inbox (mailbox path; not direct queue write)
|
||||
const dropRes = await app.request("/api/queue/inbox/drop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
destinationSession: "loop-product-lab@rsi-v2",
|
||||
senderSession: "loop-discovery@rsi-v2",
|
||||
body: "investigate regression — see stream item",
|
||||
urgency: "urgent",
|
||||
}),
|
||||
});
|
||||
expect(dropRes.status).toBe(201);
|
||||
const inboxEntry = (await dropRes.json()) as { inboxId: string };
|
||||
|
||||
// 3. Receiver absorbs into main queue
|
||||
const absorbRes = await app.request(`/api/queue/inbox/${inboxEntry.inboxId}/absorb`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ receiverSession: "loop-product-lab@rsi-v2" }),
|
||||
});
|
||||
expect(absorbRes.status).toBe(200);
|
||||
const absorbed = (await absorbRes.json()) as { qitemId: string };
|
||||
|
||||
// 4. Claim
|
||||
const claimRes = await app.request(`/api/queue/${absorbed.qitemId}/claim`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ destinationSession: "loop-product-lab@rsi-v2" }),
|
||||
});
|
||||
expect(claimRes.status).toBe(200);
|
||||
const claimed = (await claimRes.json()) as { state: string };
|
||||
expect(claimed.state).toBe("in-progress");
|
||||
|
||||
// 5. Handoff to delivery loop (transactional close + create)
|
||||
const handoffRes = await app.request(`/api/queue/${absorbed.qitemId}/handoff`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
fromSession: "loop-product-lab@rsi-v2",
|
||||
toSession: "loop-delivery@rsi-v2",
|
||||
transitionNote: "spec ready for delivery",
|
||||
}),
|
||||
});
|
||||
expect(handoffRes.status).toBe(201);
|
||||
const handoff = (await handoffRes.json()) as {
|
||||
closed: { state: string; closureReason: string };
|
||||
created: { qitemId: string; destinationSession: string };
|
||||
};
|
||||
expect(handoff.closed.state).toBe("handed-off");
|
||||
expect(handoff.closed.closureReason).toBe("handed_off_to");
|
||||
|
||||
// 6. Delivery loop claims + closes terminally
|
||||
const newQitem = handoff.created.qitemId;
|
||||
await app.request(`/api/queue/${newQitem}/claim`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ destinationSession: "loop-delivery@rsi-v2" }),
|
||||
});
|
||||
const closeRes = await app.request(`/api/queue/${newQitem}/update`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
actorSession: "loop-delivery@rsi-v2",
|
||||
state: "done",
|
||||
closureReason: "no-follow-on",
|
||||
}),
|
||||
});
|
||||
expect(closeRes.status).toBe(200);
|
||||
|
||||
// 7. Verify the audit trail spans the whole chain via transitions
|
||||
const transitionsRes = await app.request(`/api/queue/${absorbed.qitemId}/transitions`);
|
||||
const transitions = (await transitionsRes.json()) as Array<{ state: string }>;
|
||||
expect(transitions.map((t) => t.state)).toEqual(["pending", "in-progress", "handed-off"]);
|
||||
|
||||
const newTransitionsRes = await app.request(`/api/queue/${newQitem}/transitions`);
|
||||
const newTransitions = (await newTransitionsRes.json()) as Array<{ state: string }>;
|
||||
expect(newTransitions.map((t) => t.state)).toEqual(["pending", "in-progress", "done"]);
|
||||
});
|
||||
|
||||
it("hot-potato strict-rejection blocks done without closure_reason at every layer", async () => {
|
||||
const create = await app.request("/api/queue/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceSession: "a@r", destinationSession: "b@r", body: "x" }),
|
||||
});
|
||||
const item = (await create.json()) as { qitemId: string };
|
||||
|
||||
const update = await app.request(`/api/queue/${item.qitemId}/update`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ actorSession: "b@r", state: "done" }),
|
||||
});
|
||||
expect(update.status).toBe(400);
|
||||
const data = (await update.json()) as { error: string; validReasons: string[] };
|
||||
expect(data.error).toBe("missing_closure_reason");
|
||||
expect(data.validReasons).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("event-bus emits the full coordination event sequence", async () => {
|
||||
const captured: Array<{ type: string }> = [];
|
||||
bus.subscribe((e) => captured.push({ type: e.type }));
|
||||
|
||||
await app.request("/api/stream/emit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceSession: "a@r", body: "x" }),
|
||||
});
|
||||
const create = await app.request("/api/queue/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceSession: "a@r", destinationSession: "b@r", body: "y" }),
|
||||
});
|
||||
const item = (await create.json()) as { qitemId: string };
|
||||
await app.request(`/api/queue/${item.qitemId}/claim`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ destinationSession: "b@r" }),
|
||||
});
|
||||
await app.request(`/api/queue/${item.qitemId}/handoff`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fromSession: "b@r", toSession: "c@r" }),
|
||||
});
|
||||
|
||||
const types = captured.map((e) => e.type);
|
||||
expect(types).toContain("stream.emitted");
|
||||
expect(types).toContain("queue.created");
|
||||
expect(types).toContain("queue.claimed");
|
||||
expect(types).toContain("queue.handed_off");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CLOSURE_REASONS,
|
||||
computeClosureRequiredAt,
|
||||
isClosureReason,
|
||||
validateClosure,
|
||||
} from "../src/domain/hot-potato-enforcer.js";
|
||||
|
||||
describe("hot-potato-enforcer / validateClosure", () => {
|
||||
it("non-done state passes through without closure reason", () => {
|
||||
const r = validateClosure({ state: "in-progress" });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.closureReason).toBeNull();
|
||||
expect(r.closureTarget).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects state=done without closure_reason", () => {
|
||||
const r = validateClosure({ state: "done" });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.code).toBe("missing_closure_reason");
|
||||
expect(r.validReasons).toEqual(CLOSURE_REASONS);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects state=done with bogus closure_reason", () => {
|
||||
const r = validateClosure({ state: "done", closureReason: "made-up-reason" });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.code).toBe("invalid_closure_reason");
|
||||
});
|
||||
|
||||
it.each(CLOSURE_REASONS)("accepts state=done with valid closure_reason=%s", (reason) => {
|
||||
const target = (reason === "handed_off_to" || reason === "blocked_on" || reason === "escalation")
|
||||
? "downstream-target"
|
||||
: null;
|
||||
const r = validateClosure({ state: "done", closureReason: reason, closureTarget: target });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.closureReason).toBe(reason);
|
||||
});
|
||||
|
||||
it("rejects handed_off_to without closure_target", () => {
|
||||
const r = validateClosure({ state: "done", closureReason: "handed_off_to" });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.code).toBe("missing_closure_target");
|
||||
});
|
||||
|
||||
it("rejects blocked_on without closure_target", () => {
|
||||
const r = validateClosure({ state: "done", closureReason: "blocked_on" });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.code).toBe("missing_closure_target");
|
||||
});
|
||||
|
||||
it("rejects escalation without closure_target", () => {
|
||||
const r = validateClosure({ state: "done", closureReason: "escalation" });
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.code).toBe("missing_closure_target");
|
||||
});
|
||||
|
||||
it("no-follow-on / canceled / denied accepted without target", () => {
|
||||
for (const reason of ["no-follow-on", "canceled", "denied"] as const) {
|
||||
const r = validateClosure({ state: "done", closureReason: reason });
|
||||
expect(r.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hot-potato-enforcer / isClosureReason", () => {
|
||||
it("recognizes all 6 enum values", () => {
|
||||
for (const r of CLOSURE_REASONS) expect(isClosureReason(r)).toBe(true);
|
||||
});
|
||||
it("rejects unknown values", () => {
|
||||
expect(isClosureReason("nope")).toBe(false);
|
||||
expect(isClosureReason(null)).toBe(false);
|
||||
expect(isClosureReason(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hot-potato-enforcer / computeClosureRequiredAt", () => {
|
||||
it("returns null for null tier", () => {
|
||||
expect(computeClosureRequiredAt(new Date().toISOString(), null)).toBeNull();
|
||||
});
|
||||
it("returns null for unknown tier", () => {
|
||||
expect(computeClosureRequiredAt(new Date().toISOString(), "made-up-tier")).toBeNull();
|
||||
});
|
||||
it("computes fast tier as claimed + 30min", () => {
|
||||
const claimed = "2026-04-28T00:00:00.000Z";
|
||||
const required = computeClosureRequiredAt(claimed, "fast");
|
||||
expect(required).toBe("2026-04-28T00:30:00.000Z");
|
||||
});
|
||||
it("computes routine tier as claimed + 4h", () => {
|
||||
const claimed = "2026-04-28T00:00:00.000Z";
|
||||
const required = computeClosureRequiredAt(claimed, "routine");
|
||||
expect(required).toBe("2026-04-28T04:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { inboxEntriesSchema } from "../src/db/migrations/026_inbox_entries.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { InboxHandler, InboxHandlerError } from "../src/domain/inbox-handler.js";
|
||||
import type { PersistedEvent } from "../src/domain/types.js";
|
||||
|
||||
describe("InboxHandler", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let queueRepo: QueueRepository;
|
||||
let inbox: InboxHandler;
|
||||
let captured: PersistedEvent[];
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema,
|
||||
eventsSchema,
|
||||
queueItemsSchema,
|
||||
queueTransitionsSchema,
|
||||
inboxEntriesSchema,
|
||||
]);
|
||||
bus = new EventBus(db);
|
||||
queueRepo = new QueueRepository(db, bus);
|
||||
inbox = new InboxHandler(db, bus, queueRepo);
|
||||
captured = [];
|
||||
bus.subscribe((e) => captured.push(e));
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("drop records sender + tags + audit_pointer", () => {
|
||||
const e = inbox.drop({
|
||||
destinationSession: "bob@rig",
|
||||
senderSession: "alice@rig",
|
||||
body: "async work",
|
||||
tags: ["batch", "low-prio"],
|
||||
auditPointer: "audit/2026/04/28/x.md",
|
||||
});
|
||||
expect(e.inboxId).toMatch(/^inbox-\d{14}-[a-f0-9]{8}$/);
|
||||
expect(e.senderSession).toBe("alice@rig");
|
||||
expect(e.tags).toEqual(["batch", "low-prio"]);
|
||||
expect(e.state).toBe("pending");
|
||||
expect(e.auditPointer).toBe("audit/2026/04/28/x.md");
|
||||
});
|
||||
|
||||
it("drop is idempotent on inbox_id", () => {
|
||||
const id = "inbox-fixed-test-id-0001";
|
||||
const a = inbox.drop({
|
||||
inboxId: id,
|
||||
destinationSession: "bob@rig",
|
||||
senderSession: "alice@rig",
|
||||
body: "first",
|
||||
});
|
||||
const b = inbox.drop({
|
||||
inboxId: id,
|
||||
destinationSession: "bob@rig",
|
||||
senderSession: "alice@rig",
|
||||
body: "second-ignored",
|
||||
});
|
||||
expect(a.inboxId).toBe(b.inboxId);
|
||||
expect(b.body).toBe("first");
|
||||
});
|
||||
|
||||
it("auth check rejects spoofed sender_session", () => {
|
||||
const strictInbox = new InboxHandler(db, bus, queueRepo, {
|
||||
authenticate: (sender, claimed) => sender === claimed,
|
||||
});
|
||||
expect(() =>
|
||||
strictInbox.drop(
|
||||
{ destinationSession: "bob@rig", senderSession: "alice@rig", body: "x" },
|
||||
"mallory@rig"
|
||||
)
|
||||
).toThrow(InboxHandlerError);
|
||||
});
|
||||
|
||||
it("absorb promotes pending entry to a queue_item, emits inbox.absorbed", () => {
|
||||
const entry = inbox.drop({
|
||||
destinationSession: "bob@rig",
|
||||
senderSession: "alice@rig",
|
||||
body: "review this",
|
||||
urgency: "urgent",
|
||||
});
|
||||
const result = inbox.absorb(entry.inboxId, "bob@rig");
|
||||
expect(result.entry.state).toBe("absorbed");
|
||||
expect(result.entry.absorbedQitemId).toBe(result.qitemId);
|
||||
|
||||
const qitem = queueRepo.getById(result.qitemId)!;
|
||||
expect(qitem.body).toBe("review this");
|
||||
expect(qitem.priority).toBe("urgent");
|
||||
expect(qitem.sourceSession).toBe("alice@rig");
|
||||
expect(qitem.destinationSession).toBe("bob@rig");
|
||||
|
||||
expect(captured.some((e) => e.type === "inbox.absorbed")).toBe(true);
|
||||
});
|
||||
|
||||
it("absorb is idempotent — second call returns same qitem_id", () => {
|
||||
const entry = inbox.drop({
|
||||
destinationSession: "bob@rig",
|
||||
senderSession: "alice@rig",
|
||||
body: "x",
|
||||
});
|
||||
const a = inbox.absorb(entry.inboxId, "bob@rig");
|
||||
const b = inbox.absorb(entry.inboxId, "bob@rig");
|
||||
expect(a.qitemId).toBe(b.qitemId);
|
||||
});
|
||||
|
||||
it("absorb refuses if destination doesn't match", () => {
|
||||
const entry = inbox.drop({
|
||||
destinationSession: "bob@rig",
|
||||
senderSession: "alice@rig",
|
||||
body: "x",
|
||||
});
|
||||
expect(() => inbox.absorb(entry.inboxId, "carol@rig")).toThrow(/destined for/);
|
||||
});
|
||||
|
||||
it("deny records reason + emits inbox.denied; cannot subsequently absorb", () => {
|
||||
const entry = inbox.drop({
|
||||
destinationSession: "bob@rig",
|
||||
senderSession: "alice@rig",
|
||||
body: "off-topic",
|
||||
});
|
||||
const denied = inbox.deny(entry.inboxId, "bob@rig", "off-topic-for-this-rig");
|
||||
expect(denied.state).toBe("denied");
|
||||
expect(denied.deniedReason).toBe("off-topic-for-this-rig");
|
||||
expect(captured.some((e) => e.type === "inbox.denied")).toBe(true);
|
||||
expect(() => inbox.absorb(entry.inboxId, "bob@rig")).toThrow(/denied/);
|
||||
});
|
||||
|
||||
it("listPending returns only pending entries for the given destination", () => {
|
||||
inbox.drop({ destinationSession: "bob@rig", senderSession: "a@r", body: "1" });
|
||||
const e2 = inbox.drop({ destinationSession: "bob@rig", senderSession: "a@r", body: "2" });
|
||||
inbox.drop({ destinationSession: "carol@rig", senderSession: "a@r", body: "3" });
|
||||
inbox.deny(e2.inboxId, "bob@rig", "no");
|
||||
|
||||
const pending = inbox.listPending("bob@rig");
|
||||
expect(pending).toHaveLength(1);
|
||||
expect(pending[0]!.body).toBe("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
|
||||
describe("OutboxHandler", () => {
|
||||
let db: Database.Database;
|
||||
let outbox: OutboxHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [outboxEntriesSchema]);
|
||||
outbox = new OutboxHandler(db);
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("record creates entry in pending state", () => {
|
||||
const e = outbox.record({
|
||||
senderSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "fyi",
|
||||
tags: ["info"],
|
||||
});
|
||||
expect(e.outboxId).toMatch(/^outbox-\d{14}-[a-f0-9]{8}$/);
|
||||
expect(e.deliveryState).toBe("pending");
|
||||
expect(e.tags).toEqual(["info"]);
|
||||
});
|
||||
|
||||
it("record is idempotent on outbox_id", () => {
|
||||
const id = "outbox-fixed-id-0001";
|
||||
const a = outbox.record({
|
||||
outboxId: id,
|
||||
senderSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "first",
|
||||
});
|
||||
const b = outbox.record({
|
||||
outboxId: id,
|
||||
senderSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "second-ignored",
|
||||
});
|
||||
expect(a.outboxId).toBe(b.outboxId);
|
||||
expect(b.body).toBe("first");
|
||||
});
|
||||
|
||||
it("markDelivered updates state and timestamp", () => {
|
||||
const e = outbox.record({
|
||||
senderSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "x",
|
||||
});
|
||||
const delivered = outbox.markDelivered(e.outboxId);
|
||||
expect(delivered.deliveryState).toBe("delivered");
|
||||
expect(delivered.deliveredAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("markDelivered on already-delivered is a no-op (returns existing)", () => {
|
||||
const e = outbox.record({
|
||||
senderSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "x",
|
||||
});
|
||||
outbox.markDelivered(e.outboxId);
|
||||
const second = outbox.markDelivered(e.outboxId);
|
||||
expect(second.deliveryState).toBe("delivered");
|
||||
});
|
||||
|
||||
it("markFailed transitions pending → failed", () => {
|
||||
const e = outbox.record({
|
||||
senderSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "x",
|
||||
});
|
||||
const failed = outbox.markFailed(e.outboxId);
|
||||
expect(failed.deliveryState).toBe("failed");
|
||||
});
|
||||
|
||||
it("listForSender returns reverse-chronological", () => {
|
||||
outbox.record({ senderSession: "a@r", destinationSession: "b@r", body: "1" });
|
||||
outbox.record({ senderSession: "a@r", destinationSession: "b@r", body: "2" });
|
||||
outbox.record({ senderSession: "x@r", destinationSession: "b@r", body: "3" });
|
||||
const list = outbox.listForSender("a@r");
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0]!.body).toBe("2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import {
|
||||
QueueRepository,
|
||||
QueueRepositoryError,
|
||||
} from "../src/domain/queue-repository.js";
|
||||
import { CLOSURE_REASONS } from "../src/domain/hot-potato-enforcer.js";
|
||||
import type { PersistedEvent } from "../src/domain/types.js";
|
||||
|
||||
describe("QueueRepository", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let repo: QueueRepository;
|
||||
let captured: PersistedEvent[];
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema]);
|
||||
bus = new EventBus(db);
|
||||
repo = new QueueRepository(db, bus);
|
||||
captured = [];
|
||||
bus.subscribe((e) => captured.push(e));
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("create stamps qitem_id + transition + queue.created event", () => {
|
||||
const item = repo.create({
|
||||
sourceSession: "alice@rig-a",
|
||||
destinationSession: "bob@rig-b",
|
||||
body: "do the thing",
|
||||
});
|
||||
expect(item.qitemId).toMatch(/^qitem-\d{14}-[a-f0-9]{8}$/);
|
||||
expect(item.state).toBe("pending");
|
||||
expect(item.priority).toBe("routine");
|
||||
expect(captured.some((e) => e.type === "queue.created")).toBe(true);
|
||||
const transitions = repo.transitionLog.listForQitem(item.qitemId);
|
||||
expect(transitions).toHaveLength(1);
|
||||
expect(transitions[0]!.state).toBe("pending");
|
||||
});
|
||||
|
||||
it("create rejects unknown rig when validateRig denies", () => {
|
||||
const strictRepo = new QueueRepository(db, bus, {
|
||||
validateRig: (s) => s.endsWith("@known-rig"),
|
||||
});
|
||||
expect(() =>
|
||||
strictRepo.create({
|
||||
sourceSession: "alice@known-rig",
|
||||
destinationSession: "bob@phantom-rig",
|
||||
body: "x",
|
||||
})
|
||||
).toThrow(/unknown rig/);
|
||||
});
|
||||
|
||||
it("claim transitions pending → in-progress and computes closure_required_at from tier", () => {
|
||||
const item = repo.create({
|
||||
sourceSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "x",
|
||||
tier: "fast",
|
||||
});
|
||||
const claimed = repo.claim({ qitemId: item.qitemId, destinationSession: "bob@rig" });
|
||||
expect(claimed.state).toBe("in-progress");
|
||||
expect(claimed.claimedAt).toBeTruthy();
|
||||
expect(claimed.closureRequiredAt).toBeTruthy();
|
||||
expect(captured.some((e) => e.type === "queue.claimed")).toBe(true);
|
||||
});
|
||||
|
||||
it("claim rejects mismatched destination", () => {
|
||||
const item = repo.create({
|
||||
sourceSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "x",
|
||||
});
|
||||
expect(() => repo.claim({ qitemId: item.qitemId, destinationSession: "carol@rig" })).toThrow(
|
||||
/destination/
|
||||
);
|
||||
});
|
||||
|
||||
it("update state=done WITHOUT closure_reason rejected with missing_closure_reason", () => {
|
||||
const item = repo.create({
|
||||
sourceSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "x",
|
||||
});
|
||||
repo.claim({ qitemId: item.qitemId, destinationSession: "bob@rig" });
|
||||
try {
|
||||
repo.update({ qitemId: item.qitemId, actorSession: "bob@rig", state: "done" });
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(QueueRepositoryError);
|
||||
const e = err as QueueRepositoryError;
|
||||
expect(e.code).toBe("missing_closure_reason");
|
||||
expect((e.meta?.validReasons as readonly string[])).toEqual(CLOSURE_REASONS);
|
||||
}
|
||||
});
|
||||
|
||||
it("update accepts each of the 6 valid closure reasons", () => {
|
||||
for (const reason of CLOSURE_REASONS) {
|
||||
const item = repo.create({
|
||||
sourceSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: `for ${reason}`,
|
||||
});
|
||||
repo.claim({ qitemId: item.qitemId, destinationSession: "bob@rig" });
|
||||
const requiresTarget = reason === "handed_off_to" || reason === "blocked_on" || reason === "escalation";
|
||||
const closed = repo.update({
|
||||
qitemId: item.qitemId,
|
||||
actorSession: "bob@rig",
|
||||
state: "done",
|
||||
closureReason: reason,
|
||||
closureTarget: requiresTarget ? "downstream-target" : undefined,
|
||||
});
|
||||
expect(closed.state).toBe("done");
|
||||
expect(closed.closureReason).toBe(reason);
|
||||
}
|
||||
});
|
||||
|
||||
it("handoff is transactional: closes source as handed-off + creates new qitem", () => {
|
||||
const item = repo.create({
|
||||
sourceSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "do it",
|
||||
});
|
||||
const result = repo.handoff({
|
||||
qitemId: item.qitemId,
|
||||
fromSession: "bob@rig",
|
||||
toSession: "carol@rig",
|
||||
transitionNote: "specialty needed",
|
||||
});
|
||||
expect(result.closed.state).toBe("handed-off");
|
||||
expect(result.closed.closureReason).toBe("handed_off_to");
|
||||
expect(result.closed.handedOffTo).toBe("carol@rig");
|
||||
expect(result.created.state).toBe("pending");
|
||||
expect(result.created.handedOffFrom).toBe(item.qitemId);
|
||||
expect(result.created.destinationSession).toBe("carol@rig");
|
||||
expect(result.created.chainOfRecord).toEqual([item.qitemId]);
|
||||
|
||||
expect(captured.filter((e) => e.type === "queue.handed_off")).toHaveLength(1);
|
||||
expect(captured.filter((e) => e.type === "queue.created")).toHaveLength(2); // create + handoff-create
|
||||
});
|
||||
|
||||
it("handoff refuses on already-terminal qitem", () => {
|
||||
const item = repo.create({
|
||||
sourceSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "x",
|
||||
});
|
||||
repo.claim({ qitemId: item.qitemId, destinationSession: "bob@rig" });
|
||||
repo.update({
|
||||
qitemId: item.qitemId,
|
||||
actorSession: "bob@rig",
|
||||
state: "done",
|
||||
closureReason: "no-follow-on",
|
||||
});
|
||||
expect(() =>
|
||||
repo.handoff({
|
||||
qitemId: item.qitemId,
|
||||
fromSession: "bob@rig",
|
||||
toSession: "carol@rig",
|
||||
})
|
||||
).toThrow(/terminal/);
|
||||
});
|
||||
|
||||
it("transitions are append-only — every state change adds a row", () => {
|
||||
const item = repo.create({
|
||||
sourceSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "x",
|
||||
});
|
||||
repo.claim({ qitemId: item.qitemId, destinationSession: "bob@rig" });
|
||||
repo.unclaim(item.qitemId, "bob@rig", "lunch");
|
||||
repo.claim({ qitemId: item.qitemId, destinationSession: "bob@rig" });
|
||||
repo.update({
|
||||
qitemId: item.qitemId,
|
||||
actorSession: "bob@rig",
|
||||
state: "done",
|
||||
closureReason: "no-follow-on",
|
||||
});
|
||||
const transitions = repo.transitionLog.listForQitem(item.qitemId);
|
||||
expect(transitions.map((t) => t.state)).toEqual([
|
||||
"pending",
|
||||
"in-progress",
|
||||
"pending",
|
||||
"in-progress",
|
||||
"done",
|
||||
]);
|
||||
});
|
||||
|
||||
it("findOverdue surfaces in-progress qitems past closure_required_at", () => {
|
||||
const item = repo.create({
|
||||
sourceSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "x",
|
||||
tier: "fast",
|
||||
});
|
||||
repo.claim({ qitemId: item.qitemId, destinationSession: "bob@rig" });
|
||||
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const overdue = repo.findOverdue(future);
|
||||
expect(overdue.map((q) => q.qitemId)).toContain(item.qitemId);
|
||||
});
|
||||
|
||||
it("routeToFallback emits qitem.fallback_routed and rewrites destination", () => {
|
||||
const item = repo.create({
|
||||
sourceSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "x",
|
||||
});
|
||||
const rerouted = repo.routeToFallback(item.qitemId, "pod-fallback@rig", "seat-unreachable");
|
||||
expect(rerouted.destinationSession).toBe("pod-fallback@rig");
|
||||
expect(rerouted.chainOfRecord).toEqual(["fallback-from:bob@rig"]);
|
||||
expect(captured.some((e) => e.type === "qitem.fallback_routed")).toBe(true);
|
||||
});
|
||||
|
||||
it("list filters by destination + state", () => {
|
||||
const a = repo.create({ sourceSession: "x@r", destinationSession: "bob@r", body: "1" });
|
||||
repo.create({ sourceSession: "x@r", destinationSession: "carol@r", body: "2" });
|
||||
repo.create({ sourceSession: "x@r", destinationSession: "bob@r", body: "3" });
|
||||
repo.claim({ qitemId: a.qitemId, destinationSession: "bob@r" });
|
||||
|
||||
expect(repo.list({ destinationSession: "bob@r" })).toHaveLength(2);
|
||||
expect(repo.list({ destinationSession: "bob@r", state: "in-progress" })).toHaveLength(1);
|
||||
expect(repo.list({ destinationSession: "bob@r", state: ["pending", "in-progress"] })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
|
||||
import { inboxEntriesSchema } from "../src/db/migrations/026_inbox_entries.js";
|
||||
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { InboxHandler } from "../src/domain/inbox-handler.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
import { CLOSURE_REASONS } from "../src/domain/hot-potato-enforcer.js";
|
||||
import { queueRoutes } from "../src/routes/queue.js";
|
||||
|
||||
function buildApp(opts: {
|
||||
eventBus: EventBus;
|
||||
queueRepo: QueueRepository;
|
||||
inboxHandler: InboxHandler;
|
||||
outboxHandler: OutboxHandler;
|
||||
}): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("eventBus" as never, opts.eventBus);
|
||||
c.set("queueRepo" as never, opts.queueRepo);
|
||||
c.set("inboxHandler" as never, opts.inboxHandler);
|
||||
c.set("outboxHandler" as never, opts.outboxHandler);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/queue", queueRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("queue routes", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let queueRepo: QueueRepository;
|
||||
let inbox: InboxHandler;
|
||||
let outbox: OutboxHandler;
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [
|
||||
coreSchema,
|
||||
eventsSchema,
|
||||
queueItemsSchema,
|
||||
queueTransitionsSchema,
|
||||
inboxEntriesSchema,
|
||||
outboxEntriesSchema,
|
||||
]);
|
||||
bus = new EventBus(db);
|
||||
queueRepo = new QueueRepository(db, bus);
|
||||
inbox = new InboxHandler(db, bus, queueRepo);
|
||||
outbox = new OutboxHandler(db);
|
||||
app = buildApp({ eventBus: bus, queueRepo, inboxHandler: inbox, outboxHandler: outbox });
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("POST /api/queue/create creates a qitem", async () => {
|
||||
const res = await app.request("/api/queue/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceSession: "alice@rig",
|
||||
destinationSession: "bob@rig",
|
||||
body: "do thing",
|
||||
priority: "urgent",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const data = (await res.json()) as { qitemId: string; state: string; priority: string };
|
||||
expect(data.state).toBe("pending");
|
||||
expect(data.priority).toBe("urgent");
|
||||
});
|
||||
|
||||
it("POST /api/queue/:id/update with state=done WITHOUT closure_reason returns 400 with validReasons", async () => {
|
||||
const create = await app.request("/api/queue/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceSession: "a@r", destinationSession: "b@r", body: "x" }),
|
||||
});
|
||||
const item = (await create.json()) as { qitemId: string };
|
||||
|
||||
const update = await app.request(`/api/queue/${item.qitemId}/update`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ actorSession: "b@r", state: "done" }),
|
||||
});
|
||||
expect(update.status).toBe(400);
|
||||
const data = (await update.json()) as { error: string; validReasons: string[] };
|
||||
expect(data.error).toBe("missing_closure_reason");
|
||||
expect(data.validReasons).toEqual(CLOSURE_REASONS);
|
||||
});
|
||||
|
||||
it("POST /api/queue/:id/update accepts each valid closure reason", async () => {
|
||||
for (const reason of CLOSURE_REASONS) {
|
||||
const create = await app.request("/api/queue/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceSession: "a@r", destinationSession: "b@r", body: `for-${reason}` }),
|
||||
});
|
||||
const item = (await create.json()) as { qitemId: string };
|
||||
|
||||
const requiresTarget = reason === "handed_off_to" || reason === "blocked_on" || reason === "escalation";
|
||||
const update = await app.request(`/api/queue/${item.qitemId}/update`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
actorSession: "b@r",
|
||||
state: "done",
|
||||
closureReason: reason,
|
||||
...(requiresTarget ? { closureTarget: "downstream-target" } : {}),
|
||||
}),
|
||||
});
|
||||
expect(update.status).toBe(200);
|
||||
const data = (await update.json()) as { state: string; closureReason: string };
|
||||
expect(data.state).toBe("done");
|
||||
expect(data.closureReason).toBe(reason);
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/queue/:id/handoff returns closed + created in one transaction", async () => {
|
||||
const create = await app.request("/api/queue/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceSession: "a@r", destinationSession: "b@r", body: "x" }),
|
||||
});
|
||||
const item = (await create.json()) as { qitemId: string };
|
||||
|
||||
const handoff = await app.request(`/api/queue/${item.qitemId}/handoff`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fromSession: "b@r", toSession: "c@r", transitionNote: "specialty" }),
|
||||
});
|
||||
expect(handoff.status).toBe(201);
|
||||
const data = (await handoff.json()) as {
|
||||
closed: { state: string; closureReason: string; handedOffTo: string };
|
||||
created: { state: string; destinationSession: string; handedOffFrom: string };
|
||||
};
|
||||
expect(data.closed.state).toBe("handed-off");
|
||||
expect(data.closed.closureReason).toBe("handed_off_to");
|
||||
expect(data.closed.handedOffTo).toBe("c@r");
|
||||
expect(data.created.state).toBe("pending");
|
||||
expect(data.created.destinationSession).toBe("c@r");
|
||||
expect(data.created.handedOffFrom).toBe(item.qitemId);
|
||||
});
|
||||
|
||||
it("GET /api/queue/:id returns the qitem; transitions endpoint returns the log", async () => {
|
||||
const create = await app.request("/api/queue/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceSession: "a@r", destinationSession: "b@r", body: "x" }),
|
||||
});
|
||||
const item = (await create.json()) as { qitemId: string };
|
||||
|
||||
const get = await app.request(`/api/queue/${item.qitemId}`);
|
||||
expect(get.status).toBe(200);
|
||||
|
||||
const transitions = await app.request(`/api/queue/${item.qitemId}/transitions`);
|
||||
expect(transitions.status).toBe(200);
|
||||
const tlist = (await transitions.json()) as Array<{ state: string }>;
|
||||
expect(tlist).toHaveLength(1);
|
||||
expect(tlist[0]!.state).toBe("pending");
|
||||
});
|
||||
|
||||
it("inbox drop / absorb / deny round-trip", async () => {
|
||||
const drop = await app.request("/api/queue/inbox/drop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
destinationSession: "b@r",
|
||||
senderSession: "a@r",
|
||||
body: "async",
|
||||
}),
|
||||
});
|
||||
expect(drop.status).toBe(201);
|
||||
const entry = (await drop.json()) as { inboxId: string };
|
||||
|
||||
const absorb = await app.request(`/api/queue/inbox/${entry.inboxId}/absorb`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ receiverSession: "b@r" }),
|
||||
});
|
||||
expect(absorb.status).toBe(200);
|
||||
const absorbed = (await absorb.json()) as { qitemId: string };
|
||||
expect(absorbed.qitemId).toMatch(/^qitem-/);
|
||||
|
||||
// Second drop + deny path
|
||||
const drop2 = await app.request("/api/queue/inbox/drop", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ destinationSession: "b@r", senderSession: "a@r", body: "skip" }),
|
||||
});
|
||||
const entry2 = (await drop2.json()) as { inboxId: string };
|
||||
const deny = await app.request(`/api/queue/inbox/${entry2.inboxId}/deny`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ receiverSession: "b@r", reason: "off-topic" }),
|
||||
});
|
||||
expect(deny.status).toBe(200);
|
||||
});
|
||||
|
||||
it("outbox record + list round-trip", async () => {
|
||||
const record = await app.request("/api/queue/outbox/record", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ senderSession: "a@r", destinationSession: "b@r", body: "fyi" }),
|
||||
});
|
||||
expect(record.status).toBe(201);
|
||||
|
||||
const list = await app.request("/api/queue/outbox/list?senderSession=a@r");
|
||||
expect(list.status).toBe(200);
|
||||
const data = (await list.json()) as Array<{ body: string }>;
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0]!.body).toBe("fyi");
|
||||
});
|
||||
|
||||
it("GET /api/queue/list filters by destination + state", async () => {
|
||||
await app.request("/api/queue/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceSession: "a@r", destinationSession: "b@r", body: "1" }),
|
||||
});
|
||||
await app.request("/api/queue/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceSession: "a@r", destinationSession: "c@r", body: "2" }),
|
||||
});
|
||||
const res = await app.request("/api/queue/list?destinationSession=b@r");
|
||||
const data = (await res.json()) as unknown[];
|
||||
expect(data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { StreamStore } from "../src/domain/stream-store.js";
|
||||
import { streamRoutes } from "../src/routes/stream.js";
|
||||
|
||||
function buildApp(opts: { eventBus: EventBus; streamStore: StreamStore }): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("eventBus" as never, opts.eventBus);
|
||||
c.set("streamStore" as never, opts.streamStore);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/stream", streamRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("stream routes", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let store: StreamStore;
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, streamItemsSchema]);
|
||||
bus = new EventBus(db);
|
||||
store = new StreamStore(db, bus);
|
||||
app = buildApp({ eventBus: bus, streamStore: store });
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("POST /api/stream/emit creates and returns the item", async () => {
|
||||
const res = await app.request("/api/stream/emit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceSession: "alice@rig",
|
||||
body: "hello",
|
||||
hintDestination: "bob@rig",
|
||||
interrupt: true,
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const data = (await res.json()) as { streamItemId: string; body: string; interrupt: boolean };
|
||||
expect(data.body).toBe("hello");
|
||||
expect(data.interrupt).toBe(true);
|
||||
expect(data.streamItemId).toMatch(/^[0-9A-Z]{26}$/);
|
||||
});
|
||||
|
||||
it("POST /api/stream/emit rejects missing required fields", async () => {
|
||||
const res = await app.request("/api/stream/emit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceSession: "alice@rig" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const data = (await res.json()) as { error: string };
|
||||
expect(data.error).toMatch(/body/);
|
||||
});
|
||||
|
||||
it("GET /api/stream/list returns chronological items with filters", async () => {
|
||||
store.emit({ sourceSession: "alice@rig", body: "1", hintDestination: "bob@rig" });
|
||||
store.emit({ sourceSession: "carol@rig", body: "2", hintDestination: "bob@rig" });
|
||||
store.emit({ sourceSession: "alice@rig", body: "3" });
|
||||
|
||||
const res = await app.request("/api/stream/list?sourceSession=alice@rig");
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as Array<{ body: string }>;
|
||||
expect(data).toHaveLength(2);
|
||||
expect(data.map((i) => i.body)).toEqual(["1", "3"]);
|
||||
|
||||
const filtered = await app.request("/api/stream/list?hintDestination=bob@rig");
|
||||
const filteredData = (await filtered.json()) as Array<{ body: string }>;
|
||||
expect(filteredData).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("GET /api/stream/:id returns 404 on unknown id", async () => {
|
||||
const res = await app.request("/api/stream/nonexistent");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /api/stream/:id/archive succeeds and excludes from default list", async () => {
|
||||
const item = store.emit({ sourceSession: "alice@rig", body: "x" });
|
||||
const res = await app.request(`/api/stream/${item.streamItemId}/archive`, { method: "POST" });
|
||||
expect(res.status).toBe(200);
|
||||
const list = await app.request("/api/stream/list");
|
||||
const data = (await list.json()) as unknown[];
|
||||
expect(data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createDb } from "../src/db/connection.js";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
|
||||
import { eventsSchema } from "../src/db/migrations/003_events.js";
|
||||
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { StreamStore } from "../src/domain/stream-store.js";
|
||||
import type { PersistedEvent } from "../src/domain/types.js";
|
||||
|
||||
describe("StreamStore", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let store: StreamStore;
|
||||
let captured: PersistedEvent[];
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, streamItemsSchema]);
|
||||
bus = new EventBus(db);
|
||||
store = new StreamStore(db, bus);
|
||||
captured = [];
|
||||
bus.subscribe((e) => captured.push(e));
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("emit assigns ULID + sort key when not provided", () => {
|
||||
const item = store.emit({ sourceSession: "alice@rig", body: "hello" });
|
||||
expect(item.streamItemId).toMatch(/^[0-9A-Z]{26}$/);
|
||||
expect(item.streamSortKey).toMatch(/^[0-9A-Z]{26}$/);
|
||||
expect(item.body).toBe("hello");
|
||||
expect(item.format).toBe("text");
|
||||
expect(item.interrupt).toBe(false);
|
||||
});
|
||||
|
||||
it("emit is idempotent on stream_item_id", () => {
|
||||
const id = "01HXYZ_FIXED_ID_FOR_TEST_AB";
|
||||
const a = store.emit({ streamItemId: id, sourceSession: "alice@rig", body: "first" });
|
||||
const b = store.emit({ streamItemId: id, sourceSession: "alice@rig", body: "second-ignored" });
|
||||
expect(a.streamItemId).toBe(b.streamItemId);
|
||||
expect(b.body).toBe("first");
|
||||
});
|
||||
|
||||
it("emit fires stream.emitted event with hint metadata", () => {
|
||||
store.emit({
|
||||
sourceSession: "alice@rig",
|
||||
body: "tagged",
|
||||
hintDestination: "bob@rig",
|
||||
hintType: "review",
|
||||
hintUrgency: "urgent",
|
||||
interrupt: true,
|
||||
});
|
||||
expect(captured).toHaveLength(1);
|
||||
const ev = captured[0]!;
|
||||
expect(ev.type).toBe("stream.emitted");
|
||||
if (ev.type === "stream.emitted") {
|
||||
expect(ev.hintDestination).toBe("bob@rig");
|
||||
expect(ev.hintType).toBe("review");
|
||||
expect(ev.hintUrgency).toBe("urgent");
|
||||
expect(ev.interrupt).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("list returns chronological order, excludes archived by default", () => {
|
||||
const a = store.emit({ sourceSession: "alice@rig", body: "first" });
|
||||
store.emit({ sourceSession: "alice@rig", body: "second" });
|
||||
store.emit({ sourceSession: "alice@rig", body: "third" });
|
||||
store.archive(a.streamItemId);
|
||||
|
||||
const items = store.list();
|
||||
expect(items.map((i) => i.body)).toEqual(["second", "third"]);
|
||||
|
||||
const withArchived = store.list({ includeArchived: true });
|
||||
expect(withArchived).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("list filters by sourceSession + hintDestination", () => {
|
||||
store.emit({ sourceSession: "alice@rig", body: "a-msg", hintDestination: "bob@rig" });
|
||||
store.emit({ sourceSession: "carol@rig", body: "c-msg", hintDestination: "bob@rig" });
|
||||
store.emit({ sourceSession: "alice@rig", body: "a2", hintDestination: "dave@rig" });
|
||||
|
||||
expect(store.list({ sourceSession: "alice@rig" })).toHaveLength(2);
|
||||
expect(store.list({ hintDestination: "bob@rig" })).toHaveLength(2);
|
||||
expect(store.list({ sourceSession: "alice@rig", hintDestination: "bob@rig" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("getById returns null for unknown id", () => {
|
||||
expect(store.getById("nonexistent")).toBeNull();
|
||||
});
|
||||
|
||||
it("hint_tags JSON-roundtrip", () => {
|
||||
const item = store.emit({
|
||||
sourceSession: "alice@rig",
|
||||
body: "tagged",
|
||||
hintTags: ["urgent", "review", "phase-a"],
|
||||
});
|
||||
expect(item.hintTags).toEqual(["urgent", "review", "phase-a"]);
|
||||
const fetched = store.getById(item.streamItemId);
|
||||
expect(fetched?.hintTags).toEqual(["urgent", "review", "phase-a"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user