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:
mvschwarzandClaude Opus 4.7 committed 2026-05-02 18:31:59 -07:00
1 parent ab822eef53
commit d8d8ee91c8
30 files changed
+4198 -5

No files matched your search

+51 -3
View File
@@ -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`).
---
+53
View File
@@ -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:
+19
View File
@@ -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`.
+405
View File
@@ -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;
}
+141
View File
@@ -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;
}
+6
View File
@@ -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();
}
+283
View File
@@ -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,
};
}
}
+195
View File
@@ -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,
};
}
}
+13 -1
View File
@@ -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;
+377
View File
@@ -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;
}
+130
View File
@@ -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;
}
+16
View File
@@ -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);
+17 -1
View File
@@ -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");
});
});
+148
View File
@@ -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);
});
});
+238
View File
@@ -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);
});
});
+103
View File
@@ -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"]);
});
});