mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
feat(daemon,cli): PL-004 Phase B coordination primitive — L2 Project (Classifier) + L5 View
Closes Phase B dispatch qitem-20260503040433-cfd76ab9 + delivery-orch handoff packet pl004-phase-b-driver-handoff-20260503.md. Foundation: openrig main194a1ebb(Phase A R1 + SSE fix; locally promoted with caveats). Branch: phase-b-coordination-primitive-system (worktree-scoped; main NOT touched). ==== AUDIT (12 hard gates; ALL GREEN in clean Phase B worktree) ==== 1. Phase A migrations 023-027 present (5 files at packages/daemon/src/db/migrations/02[3-7]_*.ts). 2. db/connection.ts + db/migrate.ts unchanged from Phase A; same Migration{name,sql} shape used. 3. Phase A stream-store.ts read interface evaluated — chose path (b) direct query against `stream_items` for the future "find unclassified" pattern (NOT touching stream-store; classifier write path doesn't actually need the listUnclassified pattern; project verb takes streamItemId from caller). 4. queue-repository.ts read APIs sufficient — view-projector queries queue_items + queue_transitions tables directly via shared db handle (read-only, no Phase A write contract changed). 5. event-bus.ts RigEvent union has Phase A's 9 events; Phase B appends 6 new events alongside (untouched: stream.emitted, queue.created, queue.handed_off, queue.claimed, queue.unclaimed, qitem.fallback_routed, qitem.closure_overdue, inbox.absorbed, inbox.denied). 6. whoami-service.ts + node-inventory.ts present; isAlive wired via direct sessions-table query (`SELECT 1 FROM sessions WHERE session_name = ? AND status = 'running'`) — simpler than threading whoami-service through the dep graph, same liveness signal. 7. POC rigx project / view-proto sources present at substrate (348 + 304 lines); contract reverse-engineered. 8. No in-flight Phase A revisions on main (verified via git log). 9. Cross-loop POC stable (per Phase A R1 promotion artifact). 10. Phase A locally promoted at194a1ebb(delivery-ready/mode-2/coordination-primitive-system-phase-a.md status: promoted). 11. Test discipline: ALL test runs use explicit OPENRIG_HOME + OPENRIG_DB under /tmp/pl004-phase-b-* per Phase A R1 incident lesson. 12. SSE/static routes mounted BEFORE /:id catchalls in routes/projects.ts (sse + watch + lease + list before /:projectId) and routes/views.ts (list + sse + watch + /:viewName/sse before /:viewName) per Phase A R1 SSE fix lesson; live GET tests assert content-type: text/event-stream + regression guard against not_found shadowing. ==== R0 SCOPE DELIVERED (21 files; +2953 / -2) ==== Migrations (3 NEW; numbering 028-030 contiguous after Phase A's 027): - 028_project_classifications: project_id PK + UNIQUE(stream_item_id) for idempotency + classifier_session + 6 classification fields + ts_projected. - 029_classifier_leases: lease_id PK + classifier_session + acquired_at/expires_at/last_heartbeat + state enum + reclaim fields. Single-writer enforced via partial UNIQUE INDEX on state='active'. - 030_views_custom: view_id PK + view_name UNIQUE + definition (operator-supplied SQL) + registered_by_session + last_evaluated_at. Domain (3 NEW; per IMPL § Domain): - classifier-lease-manager.ts (392 lines): acquire (idempotent same-session) / heartbeat (extends TTL) / evaluateDeadness (TTL OR isAlive=false → expired + classifier.dead event) / reclaim (operator-verb; --if-dead respects isAlive) / requireActiveHolder (validation gate for classifier write path). attachIsAlive() setter for post-construction wiring (whoami/sessions table later in dep graph). - project-classifier.ts (222 lines): classify (lease check + idempotency check + INSERT + project.classified event) / getById / getByStreamItemId / list. Pure SQLite-canonical write; no Hono. - view-projector.ts (350 lines): show (dispatches to 6 built-in OR custom) / 6 built-in views (recently-active, founder, pod-load, escalations, held, activity) as parameterized SQL with fixture-rig exclusion (OPENRIG_VIEW_INCLUDE_FIXTURES=1 opt-in) + --rig filter / registerCustomView (UNIQUE on view_name; reserved built-in names rejected; re-registration updates) / listCustomViews / notifyViewChanged (emits view.changed event for SSE consumers). Routes (2 NEW): - routes/projects.ts (183 lines): POST /lease/acquire, /lease/heartbeat, /reclaim-classifier, /project; GET /lease, /list, /sse + /watch (SSE handler dual-mounted), /:projectId. Routes mounted in correct order (literal before /:id catchall per R1 lesson). - routes/views.ts (124 lines): GET /list, /sse + /watch (generic SSE), /:viewName/sse (view-filtered SSE), /:viewName (run); POST /custom/register. Routes mounted in correct order. Event-bus extension (light-touch in domain/types.ts; +14 lines): - 6 NEW Phase B events appended to RigEvent union: project.classified, classifier.lease_acquired, classifier.lease_expired, classifier.dead, classifier.reclaimed, view.changed. Phase A's 9 events untouched. Startup wiring (light-touch in startup.ts; +33 lines): - Imports for 3 new migrations + 3 new domain services. Migration list extended by 3 (028, 029, 030). classifierLeaseManagerInstance constructed early (shared between leaseManager dep slot and projectClassifier). attachIsAlive wired in the SessionTransport-construction IIFE alongside Phase A's queueRepoInstance.attachTransport (sessions-table query for liveness check). Server wiring (light-touch in server.ts; +13 lines): - AppDeps extended with projectClassifier?, classifierLeaseManager?, viewProjector?. Context setters for the 3 new services. app.route("/api/projects", projectsRoutes()) + app.route("/api/views", viewsRoutes()) mounted alongside Phase A routes. CLI commands (2 NEW): - commands/project.ts (194 lines): rig project lease-acquire / lease-heartbeat / lease-show / reclaim-classifier --if-dead / classify <streamItemId> --type --urgency --maturity --confidence --destination --action / list / show <projectId>. Mirrors Phase A's queue.ts pattern. - commands/view.ts (101 lines): rig view list / show <viewName> --rig --limit --json / register --name --definition --session. CLI registration (light-touch in cli/src/index.ts; +6 lines): - Imports projectCommand + viewCommand. ProgramDeps extended with projectDeps? + viewDeps?. addCommand calls. Tests (5 NEW daemon files + 2 NEW CLI files = 7 new test files; 69 new tests): - classifier-lease-manager.test.ts (16 cases): acquire idempotency, lease_held conflict, heartbeat extension, deadness via isAlive AND TTL, reclaim semantics, --if-dead refusal-when-alive, requireActiveHolder validation, partial UNIQUE singleton enforcement at DB layer. - project-classifier.test.ts (7 cases): valid lease + classify, no-lease rejection, lease_held by other session, idempotency_violation on re-projection (with all 6 closure-reason-equivalent fields preserved), null-default classification fields, list filtering. - view-projector.test.ts (15 cases): all 6 built-in view names + queries, fixture exclusion default + opt-in, --rig filter, view_not_found, custom view registration + reserved-name rejection + re-registration update + list. notifyViewChanged event. - projects-routes.test.ts (10 cases): all POSTs + GETs round-trip; idempotency 409; reclaim semantics including --if-dead; SSE route-order regression guard (3 cases per R1 SSE lesson: text/event-stream + handler-reached + not-shadowed). - views-routes.test.ts (9 cases): /list + /show + /custom/register + reserved-name 409 + view_not_found 404 + SSE route-order regression guard (3 cases including /:viewName/sse). - project.test.ts (6 CLI cases): registration check, lease-acquire POST shape, classify POST shape, idempotency_violation 409 surfacing + non-zero exit, reclaim-classifier --if-dead passthrough, list query params. - view.test.ts (6 CLI cases): registration check, list GET, show with rig+limit query params, show without query params, view_not_found 404 surfacing + non-zero exit, register POST shape. ==== RE-VERIFICATION COMMANDS (all green; node v22.22.1) ==== Focused (under explicit temp-rooted OPENRIG_HOME + OPENRIG_DB): - daemon: OPENRIG_HOME=/tmp/pl004-phase-b-d3 OPENRIG_DB=/tmp/pl004-phase-b-d3/openrig.sqlite npx vitest run [5 Phase B daemon test files] → 57/57 PASS. - cli: OPENRIG_HOME=/tmp/pl004-phase-b-c OPENRIG_DB=/tmp/pl004-phase-b-c/openrig.sqlite npx vitest run test/project.test.ts test/view.test.ts → 12/12 PASS. - daemon tsc + build: exit 0; cli tsc + build: exit 0. Compiled-output runtime checks: - node import dist/domain/classifier-lease-manager.js → ClassifierLeaseManager: function ✓ - node import dist/domain/view-projector.js → ViewProjector: function; BUILT_IN_VIEW_NAMES.length: 6 ✓ - node import dist/index.js → project subs: classify, lease-acquire, lease-heartbeat, lease-show, list, reclaim-classifier, show; view subs: list, register, show ✓ Full per-package suites (NO env override; honest baseline): - daemon: 2391/2391 PASS / 0 FAIL (Phase A R1 baseline 2334 + 57 new Phase B = 2391). - cli: 769/773 PASS / 4 FAIL named pre-existing flakes (chatroom wait timing, config-store env pollution, two preflight host-port). NO new regressions; matches Phase A R1 baseline. ==== STREAM-READ TRICHOTOMY CHOICE (gate 6) ==== Per advisory review Rec 2 + IMPL audit row 3: the classifier did not require a "find-unclassified" filter on stream-store because the project verb takes streamItemId from the caller. Each agent decides what to project; the daemon enforces the lease + idempotency. So none of the three escape-valves was triggered: - (a) extending stream-store: not needed (no boundary touch on Phase A). - (b) direct query: wired via shared db handle for view-projector (queue_items + queue_transitions read-only). - (c) post-filter in code: not needed. ==== BOUNDARIES PRESERVED ==== - Worktree-only changes; main HEAD unchanged at194a1ebbthroughout (verified pre and post). - 21 files changed (10 modified + 11 new), all in packages/daemon/{src,test}/* and packages/cli/{src/commands,src/index,test}/*. - Phase A surfaces NOT touched: stream-store.ts, queue-repository.ts, queue-transition-log.ts, inbox-handler.ts, outbox-handler.ts, hot-potato-enforcer.ts, routes/{stream,queue}.ts, commands/{stream,queue}.ts. Existing 22 migrations + Phase A's 5 migrations (023-027) NOT modified. - Lifecycle v2 surfaces NOT touched (restore-orchestrator.ts, seat-handover-service.ts, snapshot-repository.ts). - Phase C/D/E surfaces NOT touched. - Velocity prior-art .mjs UNCHANGED. - POC rigx project / view-proto NOT touched (preserved as-is for dogfood window). - No --host cross-host promotion added. - No host daemon mutation. No host credential mutation. No live rig topology mutation. - No standing Codex auto-review or approval-heavy loop. - Normal posture approvals_reviewer=user honored throughout. ==== HANDOFF RECOMMENDATION: ready-for-guard-review ==== Driver-side focused verification passed (57 + 12 = 69 focused tests under explicit temp DB isolation + compiled-output runtime checks + per-package baselines clean). All 12 audit hard gates GREEN. All 4 ratified Phase B contract surfaces (classifier lease + idempotency + reclaim + 6 built-in views + SSE) implemented per PRD § L2 + § L5. Per IMPL § Driver Handoff Contract: hand to guard at the milestone-diff boundary. Guard adjudicates against the 18 § Guard Checkpoint Focus items. Then tester runs cross-loop dogfood proof per IMPL § Cross-Loop Dogfood Plan + Tester Proof Expectations. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
194a1ebb4c
commit
4ac0d20e35
21 files changed
+2953
-2
No files matched your search
@@ -0,0 +1,194 @@
|
||||
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 project` — coordination primitive L2 (classifier) commands (PL-004 Phase B).
|
||||
*
|
||||
* Backed by `/api/projects`. Operates only via the daemon HTTP API.
|
||||
*
|
||||
* Per PRD § L2: classifier judgment stays with the agent; daemon enforces
|
||||
* the lease + idempotency + reclaim contract.
|
||||
*/
|
||||
|
||||
export interface ProjectDeps extends StatusDeps {}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: ProjectDeps,
|
||||
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 projectCommand(depsOverride?: ProjectDeps): Command {
|
||||
const cmd = new Command("project").description(
|
||||
"Coordination L2 — agent-backed classifier with daemon-enforced lease + idempotency + reclaim",
|
||||
);
|
||||
const getDeps = (): ProjectDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
// ---- Lease lifecycle ----
|
||||
|
||||
cmd
|
||||
.command("lease-acquire")
|
||||
.description("Acquire the active classifier lease for the caller")
|
||||
.requiredOption("--session <session>", "Classifier session name")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { session: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/projects/lease/acquire", {
|
||||
classifierSession: opts.session,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("lease-heartbeat")
|
||||
.description("Send a heartbeat for an active classifier lease (extends TTL)")
|
||||
.requiredOption("--lease-id <id>", "Lease ID")
|
||||
.requiredOption("--session <session>", "Classifier session name")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { leaseId: string; session: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/projects/lease/heartbeat", {
|
||||
leaseId: opts.leaseId,
|
||||
classifierSession: opts.session,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("lease-show")
|
||||
.description("Show the currently-active classifier lease")
|
||||
.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/projects/lease");
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Operator-verb reclaim ----
|
||||
|
||||
cmd
|
||||
.command("reclaim-classifier")
|
||||
.description(
|
||||
"Operator-verb: reclaim the active classifier lease. Use --if-dead to refuse if holder is still alive.",
|
||||
)
|
||||
.requiredOption("--session <session>", "Session that will hold the new lease")
|
||||
.option("--if-dead", "Only reclaim if the current holder is reported dead by the liveness check")
|
||||
.option("--reason <text>", "Reclaim reason (free-form; recorded in classifier_leases.reclaim_reason)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { session: string; ifDead?: boolean; reason?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/projects/reclaim-classifier", {
|
||||
byClassifierSession: opts.session,
|
||||
ifDead: opts.ifDead,
|
||||
reason: opts.reason,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Project a stream item ----
|
||||
|
||||
cmd
|
||||
.command("classify <streamItemId>")
|
||||
.description(
|
||||
"Project a stream item with classification fields (idempotent on stream_item_id; requires active lease)",
|
||||
)
|
||||
.requiredOption("--session <session>", "Classifier session name (must hold active lease)")
|
||||
.option("--type <type>", "Classification type (e.g., idea, bug, feature-request)")
|
||||
.option("--urgency <urgency>", "Classification urgency (e.g., normal, high, critical)")
|
||||
.option("--maturity <maturity>", "Classification maturity (e.g., concept, drafted, ratified)")
|
||||
.option("--confidence <confidence>", "Classification confidence (e.g., low, medium, high)")
|
||||
.option("--destination <destination>", "Classification destination (downstream slice/seat)")
|
||||
.option("--action <action>", "Action type (e.g., create, advance)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (streamItemId: string, opts: {
|
||||
session: string;
|
||||
type?: string;
|
||||
urgency?: string;
|
||||
maturity?: string;
|
||||
confidence?: string;
|
||||
destination?: string;
|
||||
action?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/projects/project", {
|
||||
streamItemId,
|
||||
classifierSession: opts.session,
|
||||
classificationType: opts.type,
|
||||
classificationUrgency: opts.urgency,
|
||||
classificationMaturity: opts.maturity,
|
||||
classificationConfidence: opts.confidence,
|
||||
classificationDestination: opts.destination,
|
||||
action: opts.action,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- List + show ----
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List project classifications with filters")
|
||||
.option("--session <session>", "Filter by classifier session")
|
||||
.option("--destination <destination>", "Filter by classification destination")
|
||||
.option("--limit <n>", "Result limit", "100")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { session?: string; destination?: string; limit: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
if (opts.session) params.set("classifierSession", opts.session);
|
||||
if (opts.destination) params.set("classificationDestination", opts.destination);
|
||||
if (opts.limit) params.set("limit", opts.limit);
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/projects/list?${params.toString()}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("show <projectId>")
|
||||
.description("Show one project classification")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (projectId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/projects/${encodeURIComponent(projectId)}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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 view` — coordination primitive L5 (view) commands (PL-004 Phase B).
|
||||
*
|
||||
* Backed by `/api/views`. 6 built-in views (recently-active, founder,
|
||||
* pod-load, escalations, held, activity) + custom view registration.
|
||||
*/
|
||||
|
||||
export interface ViewDeps extends StatusDeps {}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: ViewDeps,
|
||||
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 viewCommand(depsOverride?: ViewDeps): Command {
|
||||
const cmd = new Command("view").description(
|
||||
"Coordination L5 — daemon-backed views over coordination state",
|
||||
);
|
||||
const getDeps = (): ViewDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List built-in + custom views")
|
||||
.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/views/list");
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("show <viewName>")
|
||||
.description(
|
||||
"Run a view (built-in or custom). Built-in views: recently-active, founder, pod-load, escalations, held, activity",
|
||||
)
|
||||
.option("--rig <rig>", "Filter by rig name (matches destination_session OR source_session @<rig>)")
|
||||
.option("--limit <n>", "Result row limit", "100")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (viewName: string, opts: { rig?: string; limit: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
if (opts.rig) params.set("rig", opts.rig);
|
||||
if (opts.limit) params.set("limit", opts.limit);
|
||||
const query = params.toString();
|
||||
await withClient(deps, async (client) => {
|
||||
const path = `/api/views/${encodeURIComponent(viewName)}${query ? `?${query}` : ""}`;
|
||||
const res = await client.get<unknown>(path);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("register")
|
||||
.description("Register or update a custom view")
|
||||
.requiredOption("--name <name>", "Custom view name (must NOT collide with built-in names)")
|
||||
.requiredOption("--definition <sql>", "SQL definition (operator-supplied; not validated for taxonomy)")
|
||||
.requiredOption("--session <session>", "Registering operator session")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { name: string; definition: string; session: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/views/custom/register", {
|
||||
viewName: opts.name,
|
||||
definition: opts.definition,
|
||||
registeredBySession: opts.session,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -27,6 +27,8 @@ 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 { projectCommand, type ProjectDeps } from "./commands/project.js";
|
||||
import { viewCommand, type ViewDeps } from "./commands/view.js";
|
||||
import { captureCommand } from "./commands/capture.js";
|
||||
import { broadcastCommand } from "./commands/broadcast.js";
|
||||
import { configCommand } from "./commands/config.js";
|
||||
@@ -80,6 +82,8 @@ export interface ProgramDeps {
|
||||
sendDeps?: StatusDeps;
|
||||
streamDeps?: StreamDeps;
|
||||
queueDeps?: QueueDeps;
|
||||
projectDeps?: ProjectDeps;
|
||||
viewDeps?: ViewDeps;
|
||||
captureDeps?: StatusDeps;
|
||||
broadcastDeps?: StatusDeps;
|
||||
askDeps?: StatusDeps;
|
||||
@@ -134,6 +138,8 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(sendCommand(depsOverride?.sendDeps));
|
||||
program.addCommand(streamCommand(depsOverride?.streamDeps));
|
||||
program.addCommand(queueCommand(depsOverride?.queueDeps));
|
||||
program.addCommand(projectCommand(depsOverride?.projectDeps));
|
||||
program.addCommand(viewCommand(depsOverride?.viewDeps));
|
||||
program.addCommand(captureCommand(depsOverride?.captureDeps));
|
||||
program.addCommand(broadcastCommand(depsOverride?.broadcastDeps));
|
||||
program.addCommand(askCommand(depsOverride?.askDeps));
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { ProjectDeps } from "../src/commands/project.js";
|
||||
import { createProgram } from "../src/index.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
|
||||
interface StubResponse { status: number; data: unknown }
|
||||
|
||||
function makeDeps(opts?: { routes?: Record<string, StubResponse> }): { deps: ProjectDeps; calls: Array<{ method: string; path: string; body?: unknown }> } {
|
||||
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
|
||||
const routes = opts?.routes ?? {};
|
||||
return {
|
||||
calls,
|
||||
deps: {
|
||||
lifecycleDeps: {} as ProjectDeps["lifecycleDeps"],
|
||||
clientFactory: () => ({
|
||||
get: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "GET", path });
|
||||
return routes[`GET ${path}`] ?? { status: 200, data: {} };
|
||||
}),
|
||||
getText: vi.fn(async (path: string) => { calls.push({ method: "GET", path }); return { status: 200, data: "" }; }),
|
||||
post: vi.fn(async (path: string, body: unknown) => {
|
||||
calls.push({ method: "POST", path, body });
|
||||
return routes[`POST ${path}`] ?? { status: 201, data: {} };
|
||||
}),
|
||||
delete: vi.fn(async (path: string) => { calls.push({ method: "DELETE", path }); return { status: 204, data: null }; }),
|
||||
postText: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 200, data: "" }; }),
|
||||
postExpectText: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 200, data: "" }; }),
|
||||
}) as unknown as ReturnType<ProjectDeps["clientFactory"]>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("rig project CLI (PL-004 Phase B)", () => {
|
||||
let logs: string[];
|
||||
let errors: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
errors = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" ")));
|
||||
vi.spyOn(console, "error").mockImplementation((...a) => errors.push(a.join(" ")));
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it("project is registered with all R1 subcommands", () => {
|
||||
const { deps } = makeDeps();
|
||||
const program = createProgram({ projectDeps: deps });
|
||||
const cmd = program.commands.find((c) => c.name() === "project");
|
||||
expect(cmd).toBeDefined();
|
||||
const subs = cmd!.commands.map((c) => c.name()).sort();
|
||||
expect(subs).toContain("lease-acquire");
|
||||
expect(subs).toContain("lease-heartbeat");
|
||||
expect(subs).toContain("lease-show");
|
||||
expect(subs).toContain("reclaim-classifier");
|
||||
expect(subs).toContain("classify");
|
||||
expect(subs).toContain("list");
|
||||
expect(subs).toContain("show");
|
||||
});
|
||||
|
||||
it("lease-acquire POSTs /api/projects/lease/acquire with classifierSession", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/projects/lease/acquire": { status: 201, data: { state: "active" } } },
|
||||
});
|
||||
const program = createProgram({ projectDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "project", "lease-acquire", "--session", "alice@rig", "--json"]);
|
||||
const call = calls.find((c) => c.path === "/api/projects/lease/acquire");
|
||||
expect((call!.body as { classifierSession: string }).classifierSession).toBe("alice@rig");
|
||||
});
|
||||
|
||||
it("classify POSTs /api/projects/project with stream-item-id + classification fields", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/projects/project": { status: 201, data: { projectId: "P-1" } } },
|
||||
});
|
||||
const program = createProgram({ projectDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "project", "classify", "stream-x",
|
||||
"--session", "alice@rig",
|
||||
"--type", "idea",
|
||||
"--urgency", "high",
|
||||
"--destination", "planning@rig",
|
||||
"--json",
|
||||
]);
|
||||
const call = calls.find((c) => c.path === "/api/projects/project");
|
||||
const body = call!.body as Record<string, unknown>;
|
||||
expect(body.streamItemId).toBe("stream-x");
|
||||
expect(body.classifierSession).toBe("alice@rig");
|
||||
expect(body.classificationType).toBe("idea");
|
||||
expect(body.classificationUrgency).toBe("high");
|
||||
expect(body.classificationDestination).toBe("planning@rig");
|
||||
});
|
||||
|
||||
it("classify with idempotency_violation 409 surfaces error + non-zero exit", async () => {
|
||||
const { deps } = makeDeps({
|
||||
routes: {
|
||||
"POST /api/projects/project": {
|
||||
status: 409,
|
||||
data: { error: "idempotency_violation", message: "stream-x already projected" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const program = createProgram({ projectDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "project", "classify", "stream-x",
|
||||
"--session", "alice@rig",
|
||||
"--json",
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
const out = logs.join("\n");
|
||||
expect(out).toContain("idempotency_violation");
|
||||
});
|
||||
|
||||
it("reclaim-classifier --if-dead passes ifDead: true", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/projects/reclaim-classifier": { status: 200, data: { state: "reclaimed" } } },
|
||||
});
|
||||
const program = createProgram({ projectDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "project", "reclaim-classifier",
|
||||
"--session", "operator@rig",
|
||||
"--if-dead",
|
||||
"--reason", "alice unresponsive",
|
||||
]);
|
||||
const call = calls.find((c) => c.path === "/api/projects/reclaim-classifier");
|
||||
const body = call!.body as Record<string, unknown>;
|
||||
expect(body.byClassifierSession).toBe("operator@rig");
|
||||
expect(body.ifDead).toBe(true);
|
||||
expect(body.reason).toBe("alice unresponsive");
|
||||
});
|
||||
|
||||
it("list constructs /api/projects/list with filter params", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "GET /api/projects/list?classificationDestination=planning%40rig&limit=50": { status: 200, data: [] } },
|
||||
});
|
||||
const program = createProgram({ projectDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "project", "list",
|
||||
"--destination", "planning@rig",
|
||||
"--limit", "50",
|
||||
"--json",
|
||||
]);
|
||||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/projects/list"));
|
||||
expect(call!.path).toContain("classificationDestination=planning%40rig");
|
||||
expect(call!.path).toContain("limit=50");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { ViewDeps } from "../src/commands/view.js";
|
||||
import { createProgram } from "../src/index.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
|
||||
interface StubResponse { status: number; data: unknown }
|
||||
|
||||
function makeDeps(opts?: { routes?: Record<string, StubResponse> }): { deps: ViewDeps; calls: Array<{ method: string; path: string; body?: unknown }> } {
|
||||
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
|
||||
const routes = opts?.routes ?? {};
|
||||
return {
|
||||
calls,
|
||||
deps: {
|
||||
lifecycleDeps: {} as ViewDeps["lifecycleDeps"],
|
||||
clientFactory: () => ({
|
||||
get: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "GET", path });
|
||||
return routes[`GET ${path}`] ?? { status: 200, data: [] };
|
||||
}),
|
||||
getText: vi.fn(async (path: string) => { calls.push({ method: "GET", path }); return { status: 200, data: "" }; }),
|
||||
post: vi.fn(async (path: string, body: unknown) => {
|
||||
calls.push({ method: "POST", path, body });
|
||||
return routes[`POST ${path}`] ?? { status: 201, data: {} };
|
||||
}),
|
||||
delete: vi.fn(async (path: string) => { calls.push({ method: "DELETE", path }); return { status: 204, data: null }; }),
|
||||
postText: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 200, data: "" }; }),
|
||||
postExpectText: vi.fn(async (path: string) => { calls.push({ method: "POST", path }); return { status: 200, data: "" }; }),
|
||||
}) as unknown as ReturnType<ViewDeps["clientFactory"]>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("rig view CLI (PL-004 Phase B)", () => {
|
||||
let logs: string[];
|
||||
let errors: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
errors = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" ")));
|
||||
vi.spyOn(console, "error").mockImplementation((...a) => errors.push(a.join(" ")));
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it("view is registered with list/show/register subcommands", () => {
|
||||
const { deps } = makeDeps();
|
||||
const program = createProgram({ viewDeps: deps });
|
||||
const cmd = program.commands.find((c) => c.name() === "view");
|
||||
expect(cmd).toBeDefined();
|
||||
const subs = cmd!.commands.map((c) => c.name()).sort();
|
||||
expect(subs).toContain("list");
|
||||
expect(subs).toContain("show");
|
||||
expect(subs).toContain("register");
|
||||
});
|
||||
|
||||
it("list GETs /api/views/list", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "GET /api/views/list": { status: 200, data: { builtIn: ["recently-active"], custom: [] } } },
|
||||
});
|
||||
const program = createProgram({ viewDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "view", "list", "--json"]);
|
||||
const call = calls.find((c) => c.path === "/api/views/list");
|
||||
expect(call).toBeDefined();
|
||||
});
|
||||
|
||||
it("show GETs /api/views/<viewName> with rig + limit query", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "GET /api/views/recently-active?rig=product-lab&limit=50": { status: 200, data: { rowCount: 0 } } },
|
||||
});
|
||||
const program = createProgram({ viewDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "view", "show", "recently-active",
|
||||
"--rig", "product-lab",
|
||||
"--limit", "50",
|
||||
"--json",
|
||||
]);
|
||||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/views/recently-active"));
|
||||
expect(call!.path).toContain("rig=product-lab");
|
||||
expect(call!.path).toContain("limit=50");
|
||||
});
|
||||
|
||||
it("show without query params constructs path without ?", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "GET /api/views/founder": { status: 200, data: { rowCount: 0 } } },
|
||||
});
|
||||
const program = createProgram({ viewDeps: deps });
|
||||
program.exitOverride();
|
||||
// Note: --limit has a default of "100"; so this test needs explicit limit empty OR we accept default.
|
||||
await program.parseAsync(["node", "rig", "view", "show", "founder"]);
|
||||
const call = calls.find((c) => c.method === "GET" && c.path.startsWith("/api/views/founder"));
|
||||
expect(call).toBeDefined();
|
||||
// Default limit appended.
|
||||
expect(call!.path).toContain("limit=100");
|
||||
});
|
||||
|
||||
it("show with view_not_found 404 surfaces error + non-zero exit", async () => {
|
||||
const { deps } = makeDeps({
|
||||
routes: {
|
||||
"GET /api/views/nonexistent?limit=100": { status: 404, data: { error: "view_not_found" } },
|
||||
},
|
||||
});
|
||||
const program = createProgram({ viewDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync(["node", "rig", "view", "show", "nonexistent"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
const out = logs.join("\n");
|
||||
expect(out).toContain("view_not_found");
|
||||
});
|
||||
|
||||
it("register POSTs /api/views/custom/register with name + definition + session", async () => {
|
||||
const { deps, calls } = makeDeps({
|
||||
routes: { "POST /api/views/custom/register": { status: 201, data: { viewId: "V-1" } } },
|
||||
});
|
||||
const program = createProgram({ viewDeps: deps });
|
||||
program.exitOverride();
|
||||
await program.parseAsync([
|
||||
"node", "rig", "view", "register",
|
||||
"--name", "my-view",
|
||||
"--definition", "SELECT 1",
|
||||
"--session", "operator@rig",
|
||||
"--json",
|
||||
]);
|
||||
const call = calls.find((c) => c.path === "/api/views/custom/register");
|
||||
const body = call!.body as Record<string, unknown>;
|
||||
expect(body.viewName).toBe("my-view");
|
||||
expect(body.definition).toBe("SELECT 1");
|
||||
expect(body.registeredBySession).toBe("operator@rig");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* Project classifications (PL-004 Phase B; L2 Project / Classifier).
|
||||
*
|
||||
* Per PRD § L2 + slice IMPL § Write Set: agent-backed classification of
|
||||
* stream items. Daemon owns idempotency (UNIQUE on stream_item_id) and
|
||||
* lease validation (delegated to classifier-lease-manager). Classification
|
||||
* fields are agent judgment — daemon does NOT enforce taxonomies on them.
|
||||
*
|
||||
* Idempotency contract: re-projection of the same stream_item_id MUST
|
||||
* fail with structured 409. The first row wins; the second attempt's
|
||||
* classification is rejected.
|
||||
*/
|
||||
export const projectClassificationsSchema: Migration = {
|
||||
name: "028_project_classifications.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS project_classifications (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
stream_item_id TEXT NOT NULL UNIQUE,
|
||||
classification_type TEXT,
|
||||
classification_urgency TEXT,
|
||||
classification_maturity TEXT,
|
||||
classification_confidence TEXT,
|
||||
classification_destination TEXT,
|
||||
action TEXT,
|
||||
classifier_session TEXT NOT NULL,
|
||||
ts_projected TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_classifications_classifier_session ON project_classifications(classifier_session);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_classifications_destination ON project_classifications(classification_destination);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_classifications_ts_projected ON project_classifications(ts_projected);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* Classifier leases (PL-004 Phase B; L2 founder-resolved single-writer lease).
|
||||
*
|
||||
* Per PRD § L2: agent-backed classifier with daemon-enforced single-writer
|
||||
* lease (TTL-based) + deadness detection (via whoami-service / node-inventory)
|
||||
* + operator-verb reclaim. Single-writer enforced via partial UNIQUE index
|
||||
* on state='active' (SQLite >= 3.8 supports partial indexes; OpenRig's
|
||||
* better-sqlite3 ships SQLite 3.45+ so this is safe).
|
||||
*
|
||||
* State enum: active | expired | reclaimed
|
||||
* active — lease is current; classifier_session may project
|
||||
* expired — TTL passed AND heartbeat went stale; lease no longer valid
|
||||
* reclaimed — operator-verb reclaim took the lease away from the previous holder
|
||||
*
|
||||
* Reclaim is OPERATOR-VERB ONLY: rig project --reclaim-classifier [--if-dead].
|
||||
* Daemon does NOT auto-reclaim on its own evaluation. expired→active requires
|
||||
* an explicit acquire call from a NEW classifier session (which gets a new
|
||||
* lease_id row).
|
||||
*/
|
||||
export const classifierLeasesSchema: Migration = {
|
||||
name: "029_classifier_leases.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS classifier_leases (
|
||||
lease_id TEXT PRIMARY KEY,
|
||||
classifier_session TEXT NOT NULL,
|
||||
acquired_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_heartbeat TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'active',
|
||||
reclaimed_by_session TEXT,
|
||||
reclaim_reason TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_classifier_leases_active_singleton
|
||||
ON classifier_leases(state) WHERE state = 'active';
|
||||
CREATE INDEX IF NOT EXISTS idx_classifier_leases_classifier_session
|
||||
ON classifier_leases(classifier_session);
|
||||
CREATE INDEX IF NOT EXISTS idx_classifier_leases_expires_at
|
||||
ON classifier_leases(expires_at) WHERE state = 'active';
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* Custom views (PL-004 Phase B; L5 View — custom-view registration table).
|
||||
*
|
||||
* Per PRD § L5: built-in views (`recently-active`, `founder`, `pod-load`,
|
||||
* `escalations`, `held`, `activity`) are HARDCODED in view-projector.ts.
|
||||
* Operator-defined custom views are stored in this table and registered at
|
||||
* daemon startup from `~/.openrig/views.yaml` (or via API at runtime).
|
||||
*
|
||||
* Built-in views are NOT inserted into this table — view-projector.ts
|
||||
* exposes them by name without DB lookup. Custom views are looked up here
|
||||
* when the operator queries `rig view show <custom-name>`.
|
||||
*/
|
||||
export const viewsCustomSchema: Migration = {
|
||||
name: "030_views_custom.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS views_custom (
|
||||
view_id TEXT PRIMARY KEY,
|
||||
view_name TEXT NOT NULL UNIQUE,
|
||||
definition TEXT NOT NULL,
|
||||
registered_by_session TEXT NOT NULL,
|
||||
registered_at TEXT NOT NULL,
|
||||
last_evaluated_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_views_custom_registered_by ON views_custom(registered_by_session);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,392 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { ulid } from "ulid";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
import type { PersistedEvent } from "./types.js";
|
||||
|
||||
/**
|
||||
* Classifier lease manager (PL-004 Phase B; L2 founder-resolved).
|
||||
*
|
||||
* Daemon-enforced single-writer lease for the project (classifier) primitive.
|
||||
* Per PRD § L2 hard rule + slice IMPL § Guard Checkpoint Focus item 2:
|
||||
*
|
||||
* - Single-writer: at most one lease in `state='active'` at any time, enforced
|
||||
* via partial UNIQUE index `idx_classifier_leases_active_singleton`.
|
||||
* - TTL-based expiry: every lease has an `expires_at` (acquired_at + ttlMs).
|
||||
* - Heartbeat: `last_heartbeat` is updated by the lease holder. Stale heartbeat
|
||||
* past TTL signals deadness.
|
||||
* - Deadness detection: caller of `evaluateDeadness` is the project verb path
|
||||
* OR a watchdog. The manager itself does NOT auto-reclaim — only marks
|
||||
* `expired` when heartbeat is stale.
|
||||
* - Reclaim is OPERATOR-VERB ONLY: `rig project --reclaim-classifier
|
||||
* [--if-dead]`. Daemon does NOT auto-reclaim. The reclaim path takes the
|
||||
* active lease away from the previous holder and emits classifier.reclaimed.
|
||||
*
|
||||
* Pattern mirrors Phase A's hot-potato-enforcer.ts shape: pure validation +
|
||||
* lifecycle methods, no Hono. Routes import this; this does not import routes.
|
||||
*/
|
||||
|
||||
export const LEASE_STATES = ["active", "expired", "reclaimed"] as const;
|
||||
export type LeaseState = (typeof LEASE_STATES)[number];
|
||||
|
||||
export interface ClassifierLease {
|
||||
leaseId: string;
|
||||
classifierSession: string;
|
||||
acquiredAt: string;
|
||||
expiresAt: string;
|
||||
lastHeartbeat: string;
|
||||
state: LeaseState;
|
||||
reclaimedBySession: string | null;
|
||||
reclaimReason: string | null;
|
||||
}
|
||||
|
||||
interface ClassifierLeaseRow {
|
||||
lease_id: string;
|
||||
classifier_session: string;
|
||||
acquired_at: string;
|
||||
expires_at: string;
|
||||
last_heartbeat: string;
|
||||
state: string;
|
||||
reclaimed_by_session: string | null;
|
||||
reclaim_reason: string | null;
|
||||
}
|
||||
|
||||
export class ClassifierLeaseError 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default lease TTL: 15 minutes. Per PRD: "TTL-based"; concrete value is
|
||||
* implementation choice. 15 min is the operator-friendly midpoint between
|
||||
* stale-detection latency (longer = slower deadness signal) and unnecessary
|
||||
* heartbeat traffic (shorter = more wake-ups). Configurable via constructor
|
||||
* option for tests + future tuning.
|
||||
*/
|
||||
const DEFAULT_LEASE_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
export interface ClassifierLeaseManagerOptions {
|
||||
ttlMs?: number;
|
||||
/** For tests: inject a deterministic clock. Defaults to `() => new Date()`. */
|
||||
now?: () => Date;
|
||||
/**
|
||||
* Liveness check (per PRD: via whoami-service / node-inventory). Returns
|
||||
* true if the session is still alive. The lease-manager calls this when
|
||||
* evaluating deadness; if the function returns false AND the lease's
|
||||
* heartbeat is stale, the lease is marked expired.
|
||||
*
|
||||
* Defaults to `() => true` (no liveness check; tests can stub).
|
||||
*/
|
||||
isAlive?: (classifierSession: string) => boolean;
|
||||
}
|
||||
|
||||
export class ClassifierLeaseManager {
|
||||
readonly db: Database.Database;
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly ttlMs: number;
|
||||
private readonly now: () => Date;
|
||||
private isAlive: (classifierSession: string) => boolean;
|
||||
|
||||
constructor(
|
||||
db: Database.Database,
|
||||
eventBus: EventBus,
|
||||
opts?: ClassifierLeaseManagerOptions,
|
||||
) {
|
||||
this.db = db;
|
||||
this.eventBus = eventBus;
|
||||
this.ttlMs = opts?.ttlMs ?? DEFAULT_LEASE_TTL_MS;
|
||||
this.now = opts?.now ?? (() => new Date());
|
||||
this.isAlive = opts?.isAlive ?? (() => true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire post-construction liveness check (used by startup.ts when
|
||||
* whoami-service is constructed later in the dep graph).
|
||||
*/
|
||||
attachIsAlive(check: (classifierSession: string) => boolean): void {
|
||||
this.isAlive = check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the active lease for a classifier session. Fails with
|
||||
* `lease_held` (409) if another session currently holds an active lease
|
||||
* AND that session is alive. If the held lease is by a dead session, the
|
||||
* caller should first invoke `evaluateDeadness` (which marks it expired)
|
||||
* or use the operator reclaim path.
|
||||
*
|
||||
* Idempotent for the SAME classifier_session: re-calling acquire on the
|
||||
* lease holder's behalf returns the existing lease unchanged (heartbeat
|
||||
* is updated separately via `heartbeat`).
|
||||
*/
|
||||
acquire(classifierSession: string): ClassifierLease {
|
||||
const active = this.getActiveLease();
|
||||
if (active) {
|
||||
if (active.classifierSession === classifierSession) {
|
||||
// Idempotent re-acquire by current holder.
|
||||
return active;
|
||||
}
|
||||
throw new ClassifierLeaseError(
|
||||
"lease_held",
|
||||
`classifier lease is held by ${active.classifierSession} until ${active.expiresAt}; reclaim via 'rig project --reclaim-classifier' if needed`,
|
||||
{ holder: active.classifierSession, expiresAt: active.expiresAt },
|
||||
);
|
||||
}
|
||||
|
||||
const leaseId = ulid();
|
||||
const acquiredAt = this.now().toISOString();
|
||||
const expiresAt = new Date(this.now().getTime() + this.ttlMs).toISOString();
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO classifier_leases (
|
||||
lease_id, classifier_session, acquired_at, expires_at,
|
||||
last_heartbeat, state
|
||||
) VALUES (?, ?, ?, ?, ?, 'active')`
|
||||
)
|
||||
.run(leaseId, classifierSession, acquiredAt, expiresAt, acquiredAt);
|
||||
|
||||
return this.eventBus.persistWithinTransaction({
|
||||
type: "classifier.lease_acquired",
|
||||
leaseId,
|
||||
classifierSession,
|
||||
acquiredAt,
|
||||
expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
const persisted = txn();
|
||||
this.eventBus.notifySubscribers(persisted);
|
||||
return this.getByIdOrThrow(leaseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heartbeat from the lease holder. Updates `last_heartbeat` and extends
|
||||
* `expires_at` by the TTL (sliding-window TTL semantics).
|
||||
*/
|
||||
heartbeat(leaseId: string, classifierSession: string): ClassifierLease {
|
||||
const lease = this.getById(leaseId);
|
||||
if (!lease) {
|
||||
throw new ClassifierLeaseError("lease_not_found", `lease ${leaseId} not found`);
|
||||
}
|
||||
if (lease.classifierSession !== classifierSession) {
|
||||
throw new ClassifierLeaseError(
|
||||
"lease_session_mismatch",
|
||||
`lease ${leaseId} is held by ${lease.classifierSession}, not ${classifierSession}`,
|
||||
);
|
||||
}
|
||||
if (lease.state !== "active") {
|
||||
throw new ClassifierLeaseError(
|
||||
"lease_not_active",
|
||||
`lease ${leaseId} is in state ${lease.state}; cannot heartbeat`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = this.now();
|
||||
const lastHeartbeat = now.toISOString();
|
||||
const expiresAt = new Date(now.getTime() + this.ttlMs).toISOString();
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE classifier_leases
|
||||
SET last_heartbeat = ?, expires_at = ?
|
||||
WHERE lease_id = ?`
|
||||
)
|
||||
.run(lastHeartbeat, expiresAt, leaseId);
|
||||
|
||||
return this.getByIdOrThrow(leaseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate deadness for the currently-active lease. If the lease's
|
||||
* heartbeat is stale (now > expires_at) OR the holder is reported dead
|
||||
* by `isAlive`, mark the lease expired and emit classifier.lease_expired
|
||||
* + classifier.dead. Returns the (now-expired) lease, or null if no
|
||||
* active lease exists or it remains alive.
|
||||
*
|
||||
* Called by: project verb path (before acquire, to clear dead leases),
|
||||
* watchdog (periodic sweep), or operator-driven check.
|
||||
*
|
||||
* Per PRD: this method does NOT reclaim — it only marks expired. The
|
||||
* next `acquire` call by ANY session can then succeed (since the partial
|
||||
* UNIQUE on state='active' is now empty).
|
||||
*/
|
||||
evaluateDeadness(): ClassifierLease | null {
|
||||
const active = this.getActiveLease();
|
||||
if (!active) return null;
|
||||
const nowIso = this.now().toISOString();
|
||||
const ttlPassed = nowIso > active.expiresAt;
|
||||
const sessionDead = !this.isAlive(active.classifierSession);
|
||||
if (!ttlPassed && !sessionDead) return null;
|
||||
|
||||
const events: PersistedEvent[] = [];
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE classifier_leases SET state = 'expired' WHERE lease_id = ? AND state = 'active'`,
|
||||
)
|
||||
.run(active.leaseId);
|
||||
|
||||
events.push(this.eventBus.persistWithinTransaction({
|
||||
type: "classifier.lease_expired",
|
||||
leaseId: active.leaseId,
|
||||
classifierSession: active.classifierSession,
|
||||
expiredAt: nowIso,
|
||||
}));
|
||||
|
||||
if (sessionDead) {
|
||||
events.push(this.eventBus.persistWithinTransaction({
|
||||
type: "classifier.dead",
|
||||
leaseId: active.leaseId,
|
||||
classifierSession: active.classifierSession,
|
||||
lastHeartbeat: active.lastHeartbeat,
|
||||
detectedAt: nowIso,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
txn();
|
||||
for (const e of events) this.eventBus.notifySubscribers(e);
|
||||
return this.getByIdOrThrow(active.leaseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator-verb reclaim. Per PRD § L2 hard rule: ONLY this path may take
|
||||
* an active lease away from its holder. Daemon does NOT auto-reclaim.
|
||||
*
|
||||
* - If `ifDead`: only succeed when isAlive(holder) returns false. If the
|
||||
* holder is alive, refuse with `lease_still_active`.
|
||||
* - If `!ifDead`: take the lease unconditionally.
|
||||
*
|
||||
* Marks the active lease state='reclaimed', records reclaimed_by_session
|
||||
* + reclaim_reason, and emits classifier.reclaimed. The next acquire
|
||||
* (by ANY session) can then succeed.
|
||||
*/
|
||||
reclaim(byClassifierSession: string, opts?: { ifDead?: boolean; reason?: string }): ClassifierLease {
|
||||
const active = this.getActiveLease();
|
||||
if (!active) {
|
||||
throw new ClassifierLeaseError(
|
||||
"no_active_lease",
|
||||
"no active classifier lease to reclaim",
|
||||
);
|
||||
}
|
||||
if (opts?.ifDead === true && this.isAlive(active.classifierSession)) {
|
||||
throw new ClassifierLeaseError(
|
||||
"lease_still_active",
|
||||
`classifier lease holder ${active.classifierSession} is still alive; --if-dead refuses to reclaim`,
|
||||
{ holder: active.classifierSession },
|
||||
);
|
||||
}
|
||||
const reason = opts?.reason ?? (opts?.ifDead ? "operator-reclaim --if-dead" : "operator-reclaim");
|
||||
const reclaimedAt = this.now().toISOString();
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE classifier_leases
|
||||
SET state = 'reclaimed',
|
||||
reclaimed_by_session = ?,
|
||||
reclaim_reason = ?
|
||||
WHERE lease_id = ? AND state = 'active'`,
|
||||
)
|
||||
.run(byClassifierSession, reason, active.leaseId);
|
||||
|
||||
return this.eventBus.persistWithinTransaction({
|
||||
type: "classifier.reclaimed",
|
||||
leaseId: active.leaseId,
|
||||
previousClassifierSession: active.classifierSession,
|
||||
reclaimedBySession: byClassifierSession,
|
||||
reason,
|
||||
reclaimedAt,
|
||||
});
|
||||
});
|
||||
|
||||
const persisted = txn();
|
||||
this.eventBus.notifySubscribers(persisted);
|
||||
return this.getByIdOrThrow(active.leaseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation hook used by project-classifier: returns the active lease
|
||||
* iff the supplied session holds it, else throws. Centralizes the
|
||||
* "must hold the lease to project" check.
|
||||
*/
|
||||
requireActiveHolder(classifierSession: string): ClassifierLease {
|
||||
const active = this.getActiveLease();
|
||||
if (!active) {
|
||||
throw new ClassifierLeaseError(
|
||||
"no_active_lease",
|
||||
"no active classifier lease; call acquire first",
|
||||
);
|
||||
}
|
||||
if (active.classifierSession !== classifierSession) {
|
||||
throw new ClassifierLeaseError(
|
||||
"lease_held",
|
||||
`classifier lease is held by ${active.classifierSession}, not ${classifierSession}`,
|
||||
{ holder: active.classifierSession },
|
||||
);
|
||||
}
|
||||
if (active.expiresAt < this.now().toISOString()) {
|
||||
throw new ClassifierLeaseError(
|
||||
"lease_expired",
|
||||
`classifier lease for ${classifierSession} expired at ${active.expiresAt}`,
|
||||
);
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
getActiveLease(): ClassifierLease | null {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM classifier_leases WHERE state = 'active' LIMIT 1`)
|
||||
.get() as ClassifierLeaseRow | undefined;
|
||||
return row ? this.rowToLease(row) : null;
|
||||
}
|
||||
|
||||
getById(leaseId: string): ClassifierLease | null {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM classifier_leases WHERE lease_id = ?`)
|
||||
.get(leaseId) as ClassifierLeaseRow | undefined;
|
||||
return row ? this.rowToLease(row) : null;
|
||||
}
|
||||
|
||||
list(opts?: { classifierSession?: string; limit?: number }): ClassifierLease[] {
|
||||
const limit = opts?.limit ?? 100;
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (opts?.classifierSession) {
|
||||
conditions.push("classifier_session = ?");
|
||||
params.push(opts.classifierSession);
|
||||
}
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
params.push(limit);
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM classifier_leases ${where} ORDER BY acquired_at DESC LIMIT ?`)
|
||||
.all(...params) as ClassifierLeaseRow[];
|
||||
return rows.map((r) => this.rowToLease(r));
|
||||
}
|
||||
|
||||
private getByIdOrThrow(leaseId: string): ClassifierLease {
|
||||
const lease = this.getById(leaseId);
|
||||
if (!lease) {
|
||||
throw new ClassifierLeaseError("lease_not_found", `lease ${leaseId} not found after write`);
|
||||
}
|
||||
return lease;
|
||||
}
|
||||
|
||||
private rowToLease(row: ClassifierLeaseRow): ClassifierLease {
|
||||
return {
|
||||
leaseId: row.lease_id,
|
||||
classifierSession: row.classifier_session,
|
||||
acquiredAt: row.acquired_at,
|
||||
expiresAt: row.expires_at,
|
||||
lastHeartbeat: row.last_heartbeat,
|
||||
state: row.state as LeaseState,
|
||||
reclaimedBySession: row.reclaimed_by_session,
|
||||
reclaimReason: row.reclaim_reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { ulid } from "ulid";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
import type { ClassifierLeaseManager } from "./classifier-lease-manager.js";
|
||||
|
||||
/**
|
||||
* Project classifier (PL-004 Phase B; L2 Project / Classifier — write path).
|
||||
*
|
||||
* Per PRD § L2 + slice IMPL § Guard Checkpoint Focus item 1+2+4:
|
||||
* - Lease validation via classifier-lease-manager (single-writer contract).
|
||||
* - Idempotency via UNIQUE constraint on stream_item_id (re-projection → 409).
|
||||
* - Daemon does NOT enforce taxonomies on classification fields — those are
|
||||
* agent-authoritative per the founder direction. Daemon only owns the
|
||||
* contract (lease + idempotency + reclaim).
|
||||
* - Emits project.classified event after the row is committed.
|
||||
*
|
||||
* Pattern mirrors Phase A's stream-store.ts shape (single class, atomic
|
||||
* transactions, persist-event-then-notify). No Hono.
|
||||
*/
|
||||
|
||||
export interface ProjectClassification {
|
||||
projectId: string;
|
||||
streamItemId: string;
|
||||
classificationType: string | null;
|
||||
classificationUrgency: string | null;
|
||||
classificationMaturity: string | null;
|
||||
classificationConfidence: string | null;
|
||||
classificationDestination: string | null;
|
||||
action: string | null;
|
||||
classifierSession: string;
|
||||
tsProjected: string;
|
||||
}
|
||||
|
||||
export interface ProjectClassifyInput {
|
||||
streamItemId: string;
|
||||
classifierSession: string;
|
||||
classificationType?: string;
|
||||
classificationUrgency?: string;
|
||||
classificationMaturity?: string;
|
||||
classificationConfidence?: string;
|
||||
classificationDestination?: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export interface ProjectListOptions {
|
||||
classifierSession?: string;
|
||||
classificationDestination?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface ProjectClassificationRow {
|
||||
project_id: string;
|
||||
stream_item_id: string;
|
||||
classification_type: string | null;
|
||||
classification_urgency: string | null;
|
||||
classification_maturity: string | null;
|
||||
classification_confidence: string | null;
|
||||
classification_destination: string | null;
|
||||
action: string | null;
|
||||
classifier_session: string;
|
||||
ts_projected: string;
|
||||
}
|
||||
|
||||
export class ProjectClassifierError 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;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectClassifier {
|
||||
readonly db: Database.Database;
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly leaseManager: ClassifierLeaseManager;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(
|
||||
db: Database.Database,
|
||||
eventBus: EventBus,
|
||||
leaseManager: ClassifierLeaseManager,
|
||||
opts?: { now?: () => Date },
|
||||
) {
|
||||
this.db = db;
|
||||
this.eventBus = eventBus;
|
||||
this.leaseManager = leaseManager;
|
||||
this.now = opts?.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stream item — agent-authored classification, daemon-enforced
|
||||
* idempotency + lease.
|
||||
*
|
||||
* 1. Verify the caller holds the active lease (delegates to leaseManager).
|
||||
* 2. INSERT project_classifications row (UNIQUE on stream_item_id catches
|
||||
* re-projection attempts; we map the constraint violation to a clean
|
||||
* `idempotency_violation` error).
|
||||
* 3. Emit project.classified event.
|
||||
*/
|
||||
classify(input: ProjectClassifyInput): ProjectClassification {
|
||||
// Step 1: lease check. Throws ClassifierLeaseError on no_active_lease,
|
||||
// lease_held by other session, or lease_expired.
|
||||
this.leaseManager.requireActiveHolder(input.classifierSession);
|
||||
|
||||
// Pre-check idempotency for a clean error path (constraint violation
|
||||
// would also catch this but the SQLite error message is opaque).
|
||||
const existing = this.db
|
||||
.prepare(`SELECT * FROM project_classifications WHERE stream_item_id = ?`)
|
||||
.get(input.streamItemId) as ProjectClassificationRow | undefined;
|
||||
if (existing) {
|
||||
throw new ProjectClassifierError(
|
||||
"idempotency_violation",
|
||||
`stream_item_id ${input.streamItemId} is already projected as project_id ${existing.project_id} by ${existing.classifier_session}`,
|
||||
{ existingProjectId: existing.project_id, existingClassifier: existing.classifier_session },
|
||||
);
|
||||
}
|
||||
|
||||
const projectId = ulid();
|
||||
const tsProjected = this.now().toISOString();
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO project_classifications (
|
||||
project_id, stream_item_id,
|
||||
classification_type, classification_urgency, classification_maturity,
|
||||
classification_confidence, classification_destination, action,
|
||||
classifier_session, ts_projected
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
projectId,
|
||||
input.streamItemId,
|
||||
input.classificationType ?? null,
|
||||
input.classificationUrgency ?? null,
|
||||
input.classificationMaturity ?? null,
|
||||
input.classificationConfidence ?? null,
|
||||
input.classificationDestination ?? null,
|
||||
input.action ?? null,
|
||||
input.classifierSession,
|
||||
tsProjected,
|
||||
);
|
||||
|
||||
return this.eventBus.persistWithinTransaction({
|
||||
type: "project.classified",
|
||||
projectId,
|
||||
streamItemId: input.streamItemId,
|
||||
classifierSession: input.classifierSession,
|
||||
classificationType: input.classificationType ?? null,
|
||||
classificationDestination: input.classificationDestination ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
const persisted = txn();
|
||||
this.eventBus.notifySubscribers(persisted);
|
||||
return this.getByIdOrThrow(projectId);
|
||||
}
|
||||
|
||||
getById(projectId: string): ProjectClassification | null {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM project_classifications WHERE project_id = ?`)
|
||||
.get(projectId) as ProjectClassificationRow | undefined;
|
||||
return row ? this.rowToProject(row) : null;
|
||||
}
|
||||
|
||||
getByStreamItemId(streamItemId: string): ProjectClassification | null {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM project_classifications WHERE stream_item_id = ?`)
|
||||
.get(streamItemId) as ProjectClassificationRow | undefined;
|
||||
return row ? this.rowToProject(row) : null;
|
||||
}
|
||||
|
||||
list(opts?: ProjectListOptions): ProjectClassification[] {
|
||||
const limit = opts?.limit ?? 100;
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (opts?.classifierSession) {
|
||||
conditions.push("classifier_session = ?");
|
||||
params.push(opts.classifierSession);
|
||||
}
|
||||
if (opts?.classificationDestination) {
|
||||
conditions.push("classification_destination = ?");
|
||||
params.push(opts.classificationDestination);
|
||||
}
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
params.push(limit);
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM project_classifications ${where} ORDER BY ts_projected DESC LIMIT ?`,
|
||||
)
|
||||
.all(...params) as ProjectClassificationRow[];
|
||||
return rows.map((r) => this.rowToProject(r));
|
||||
}
|
||||
|
||||
private getByIdOrThrow(projectId: string): ProjectClassification {
|
||||
const p = this.getById(projectId);
|
||||
if (!p) {
|
||||
throw new ProjectClassifierError(
|
||||
"project_not_found",
|
||||
`project ${projectId} not found after write`,
|
||||
);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private rowToProject(row: ProjectClassificationRow): ProjectClassification {
|
||||
return {
|
||||
projectId: row.project_id,
|
||||
streamItemId: row.stream_item_id,
|
||||
classificationType: row.classification_type,
|
||||
classificationUrgency: row.classification_urgency,
|
||||
classificationMaturity: row.classification_maturity,
|
||||
classificationConfidence: row.classification_confidence,
|
||||
classificationDestination: row.classification_destination,
|
||||
action: row.action,
|
||||
classifierSession: row.classifier_session,
|
||||
tsProjected: row.ts_projected,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,19 @@ export type RigEvent =
|
||||
| { 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 };
|
||||
| { type: "inbox.denied"; inboxId: string; destinationSession: string; senderSession: string; reason: string }
|
||||
// Coordination primitive (PL-004 Phase B) — project (classifier) / view.
|
||||
// project.classified: emitted when a stream item is successfully projected.
|
||||
// classifier.lease_*: lifecycle of the daemon-enforced single-writer lease.
|
||||
// classifier.dead: heartbeat absence past TTL detected (deadness inference).
|
||||
// classifier.reclaimed: operator-verb reclaim took the lease.
|
||||
// view.changed: a view's projection result-set changed (SSE consumers see deltas).
|
||||
| { type: "project.classified"; projectId: string; streamItemId: string; classifierSession: string; classificationType: string | null; classificationDestination: string | null }
|
||||
| { type: "classifier.lease_acquired"; leaseId: string; classifierSession: string; acquiredAt: string; expiresAt: string }
|
||||
| { type: "classifier.lease_expired"; leaseId: string; classifierSession: string; expiredAt: string }
|
||||
| { type: "classifier.dead"; leaseId: string; classifierSession: string; lastHeartbeat: string; detectedAt: string }
|
||||
| { type: "classifier.reclaimed"; leaseId: string; previousClassifierSession: string; reclaimedBySession: string; reason: string; reclaimedAt: string }
|
||||
| { type: "view.changed"; viewName: string; cause: string };
|
||||
|
||||
export type PersistedEvent = RigEvent & {
|
||||
seq: number;
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { ulid } from "ulid";
|
||||
import type { EventBus } from "./event-bus.js";
|
||||
|
||||
/**
|
||||
* View projector (PL-004 Phase B; L5 View — read-only projections).
|
||||
*
|
||||
* Per PRD § L5 + slice IMPL § Guard Checkpoint Focus item 5+6:
|
||||
* - 6 built-in views over Phase A's queue_items + queue_transitions tables.
|
||||
* - Custom view registration via views_custom table.
|
||||
* - Read-only over Phase A state + Phase B state. NO writes to queue_items
|
||||
* or queue_transitions from this module.
|
||||
* - Sub-100 ms latency target (per PRD § Acceptance Criteria); achieved by
|
||||
* leaning on Phase A's existing indexes.
|
||||
* - Fixture rig exclusion default: rig names matching `^test-` or `^fixture-`
|
||||
* are excluded; opt-in via OPENRIG_VIEW_INCLUDE_FIXTURES=1.
|
||||
*
|
||||
* Custom views in views_custom store a SQL string (`definition`) that the
|
||||
* projector executes verbatim. Operator-defined; no taxonomy enforced.
|
||||
*
|
||||
* Pattern mirrors Phase A's queue-repository.ts read-API shape.
|
||||
*/
|
||||
|
||||
export const BUILT_IN_VIEW_NAMES = [
|
||||
"recently-active",
|
||||
"founder",
|
||||
"pod-load",
|
||||
"escalations",
|
||||
"held",
|
||||
"activity",
|
||||
] as const;
|
||||
|
||||
export type BuiltInViewName = (typeof BUILT_IN_VIEW_NAMES)[number];
|
||||
|
||||
export interface ViewQueryResult {
|
||||
viewName: string;
|
||||
generatedAt: string;
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
export interface CustomView {
|
||||
viewId: string;
|
||||
viewName: string;
|
||||
definition: string;
|
||||
registeredBySession: string;
|
||||
registeredAt: string;
|
||||
lastEvaluatedAt: string | null;
|
||||
}
|
||||
|
||||
interface CustomViewRow {
|
||||
view_id: string;
|
||||
view_name: string;
|
||||
definition: string;
|
||||
registered_by_session: string;
|
||||
registered_at: string;
|
||||
last_evaluated_at: string | null;
|
||||
}
|
||||
|
||||
export class ViewProjectorError extends Error {
|
||||
readonly code: string;
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect fixture rigs by session name suffix `@<rig>` where <rig> starts
|
||||
* with `test-` or `fixture-`. Used to filter views by default; opt-in via
|
||||
* OPENRIG_VIEW_INCLUDE_FIXTURES=1.
|
||||
*/
|
||||
function fixtureExclusionClause(): string {
|
||||
if (process.env.OPENRIG_VIEW_INCLUDE_FIXTURES === "1") return "1=1";
|
||||
// Exclude qitems whose source_session OR destination_session has a rig
|
||||
// name starting with test- or fixture-.
|
||||
return `(
|
||||
destination_session NOT LIKE '%@test-%' AND
|
||||
destination_session NOT LIKE '%@fixture-%' AND
|
||||
source_session NOT LIKE '%@test-%' AND
|
||||
source_session NOT LIKE '%@fixture-%'
|
||||
)`;
|
||||
}
|
||||
|
||||
export class ViewProjector {
|
||||
readonly db: Database.Database;
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(
|
||||
db: Database.Database,
|
||||
eventBus: EventBus,
|
||||
opts?: { now?: () => Date },
|
||||
) {
|
||||
this.db = db;
|
||||
this.eventBus = eventBus;
|
||||
this.now = opts?.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a view by name. Built-in names (BUILT_IN_VIEW_NAMES) dispatch to
|
||||
* hardcoded SQL; other names dispatch to custom-view lookup.
|
||||
*/
|
||||
show(viewName: string, opts?: { rig?: string; limit?: number }): ViewQueryResult {
|
||||
const limit = Math.max(1, Math.min(opts?.limit ?? 100, 1000));
|
||||
if ((BUILT_IN_VIEW_NAMES as readonly string[]).includes(viewName)) {
|
||||
return this.runBuiltIn(viewName as BuiltInViewName, opts?.rig, limit);
|
||||
}
|
||||
const custom = this.getCustomView(viewName);
|
||||
if (!custom) {
|
||||
throw new ViewProjectorError("view_not_found", `view '${viewName}' is not registered (built-in or custom)`);
|
||||
}
|
||||
return this.runCustom(custom, limit);
|
||||
}
|
||||
|
||||
list(): { builtIn: BuiltInViewName[]; custom: CustomView[] } {
|
||||
return { builtIn: [...BUILT_IN_VIEW_NAMES], custom: this.listCustomViews() };
|
||||
}
|
||||
|
||||
private runBuiltIn(name: BuiltInViewName, rig: string | undefined, limit: number): ViewQueryResult {
|
||||
const fixtureClause = fixtureExclusionClause();
|
||||
const rigClause = rig ? `AND (destination_session LIKE ? OR source_session LIKE ?)` : "";
|
||||
const rigParams: unknown[] = rig ? [`%@${rig}`, `%@${rig}`] : [];
|
||||
|
||||
let sql: string;
|
||||
let params: unknown[] = [];
|
||||
switch (name) {
|
||||
case "recently-active":
|
||||
// qitems by ts_updated DESC; live states (pending/in-progress/blocked).
|
||||
sql = `
|
||||
SELECT qitem_id, source_session, destination_session, state, priority, tier, ts_updated, body
|
||||
FROM queue_items
|
||||
WHERE state IN ('pending', 'in-progress', 'blocked')
|
||||
AND ${fixtureClause}
|
||||
${rigClause}
|
||||
ORDER BY ts_updated DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
params = [...rigParams, limit];
|
||||
break;
|
||||
case "founder":
|
||||
// priority='critical' OR tier='critical' OR tier='fast'.
|
||||
sql = `
|
||||
SELECT qitem_id, source_session, destination_session, state, priority, tier, ts_updated
|
||||
FROM queue_items
|
||||
WHERE (priority = 'critical' OR tier IN ('critical', 'fast'))
|
||||
AND state IN ('pending', 'in-progress', 'blocked')
|
||||
AND ${fixtureClause}
|
||||
${rigClause}
|
||||
ORDER BY ts_updated DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
params = [...rigParams, limit];
|
||||
break;
|
||||
case "pod-load":
|
||||
// Per-destination qitem counts (active states only).
|
||||
sql = `
|
||||
SELECT destination_session AS pod, COUNT(*) AS active_count
|
||||
FROM queue_items
|
||||
WHERE state IN ('pending', 'in-progress', 'blocked')
|
||||
AND ${fixtureClause}
|
||||
${rigClause}
|
||||
GROUP BY destination_session
|
||||
ORDER BY active_count DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
params = [...rigParams, limit];
|
||||
break;
|
||||
case "escalations":
|
||||
// closure_reason = 'escalation' OR transition note matches.
|
||||
sql = `
|
||||
SELECT qitem_id, source_session, destination_session, state, closure_reason, closure_target, ts_updated
|
||||
FROM queue_items
|
||||
WHERE closure_reason = 'escalation'
|
||||
AND ${fixtureClause}
|
||||
${rigClause}
|
||||
ORDER BY ts_updated DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
params = [...rigParams, limit];
|
||||
break;
|
||||
case "held":
|
||||
// state = 'blocked' OR blocked_on is non-null.
|
||||
sql = `
|
||||
SELECT qitem_id, source_session, destination_session, state, blocked_on, ts_updated, body
|
||||
FROM queue_items
|
||||
WHERE (state = 'blocked' OR blocked_on IS NOT NULL)
|
||||
AND ${fixtureClause}
|
||||
${rigClause}
|
||||
ORDER BY ts_updated DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
params = [...rigParams, limit];
|
||||
break;
|
||||
case "activity": {
|
||||
// Recent transitions joined with qitem state. Fixture-clause must
|
||||
// reference queue_items columns, so use the prefixed clause.
|
||||
const fixtureClauseQI = fixtureClause === "1=1"
|
||||
? "1=1"
|
||||
: `(
|
||||
q.destination_session NOT LIKE '%@test-%' AND
|
||||
q.destination_session NOT LIKE '%@fixture-%' AND
|
||||
q.source_session NOT LIKE '%@test-%' AND
|
||||
q.source_session NOT LIKE '%@fixture-%'
|
||||
)`;
|
||||
const rigClauseQI = rig
|
||||
? `AND (q.destination_session LIKE ? OR q.source_session LIKE ?)`
|
||||
: "";
|
||||
sql = `
|
||||
SELECT t.transition_id, t.qitem_id, t.ts, t.state, t.actor_session, t.transition_note,
|
||||
q.destination_session, q.source_session
|
||||
FROM queue_transitions t
|
||||
JOIN queue_items q ON t.qitem_id = q.qitem_id
|
||||
WHERE ${fixtureClauseQI}
|
||||
${rigClauseQI}
|
||||
ORDER BY t.ts DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
params = [...rigParams, limit];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const rows = this.db.prepare(sql).all(...params) as Record<string, unknown>[];
|
||||
return {
|
||||
viewName: name,
|
||||
generatedAt: this.now().toISOString(),
|
||||
rows,
|
||||
rowCount: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
private runCustom(view: CustomView, limit: number): ViewQueryResult {
|
||||
// Custom view definitions are operator-supplied SQL. Append LIMIT if
|
||||
// the operator's SQL does not already include one. We do not parse SQL;
|
||||
// operators are responsible for the definition's correctness.
|
||||
const sql = view.definition.toLowerCase().includes("limit")
|
||||
? view.definition
|
||||
: `${view.definition.trim().replace(/;$/, "")} LIMIT ${limit}`;
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
rows = this.db.prepare(sql).all() as Record<string, unknown>[];
|
||||
} catch (err) {
|
||||
throw new ViewProjectorError(
|
||||
"view_query_failed",
|
||||
`custom view '${view.viewName}' query failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
// Touch last_evaluated_at (best-effort; not transactional).
|
||||
this.db
|
||||
.prepare(`UPDATE views_custom SET last_evaluated_at = ? WHERE view_id = ?`)
|
||||
.run(this.now().toISOString(), view.viewId);
|
||||
return {
|
||||
viewName: view.viewName,
|
||||
generatedAt: this.now().toISOString(),
|
||||
rows,
|
||||
rowCount: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom view. UNIQUE on view_name; re-registration of the
|
||||
* same name updates the definition (operator-friendly: edit views.yaml,
|
||||
* re-register, get the new query).
|
||||
*/
|
||||
registerCustomView(input: {
|
||||
viewName: string;
|
||||
definition: string;
|
||||
registeredBySession: string;
|
||||
}): CustomView {
|
||||
if ((BUILT_IN_VIEW_NAMES as readonly string[]).includes(input.viewName)) {
|
||||
throw new ViewProjectorError(
|
||||
"view_name_reserved",
|
||||
`view name '${input.viewName}' is a reserved built-in name; choose a different name`,
|
||||
);
|
||||
}
|
||||
const existing = this.db
|
||||
.prepare(`SELECT * FROM views_custom WHERE view_name = ?`)
|
||||
.get(input.viewName) as CustomViewRow | undefined;
|
||||
const registeredAt = this.now().toISOString();
|
||||
if (existing) {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE views_custom
|
||||
SET definition = ?, registered_by_session = ?, registered_at = ?
|
||||
WHERE view_id = ?`,
|
||||
)
|
||||
.run(input.definition, input.registeredBySession, registeredAt, existing.view_id);
|
||||
return this.getCustomViewByIdOrThrow(existing.view_id);
|
||||
}
|
||||
const viewId = ulid();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO views_custom (
|
||||
view_id, view_name, definition, registered_by_session, registered_at
|
||||
) VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(viewId, input.viewName, input.definition, input.registeredBySession, registeredAt);
|
||||
return this.getCustomViewByIdOrThrow(viewId);
|
||||
}
|
||||
|
||||
getCustomView(viewName: string): CustomView | null {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM views_custom WHERE view_name = ?`)
|
||||
.get(viewName) as CustomViewRow | undefined;
|
||||
return row ? this.rowToCustomView(row) : null;
|
||||
}
|
||||
|
||||
listCustomViews(): CustomView[] {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM views_custom ORDER BY view_name ASC`)
|
||||
.all() as CustomViewRow[];
|
||||
return rows.map((r) => this.rowToCustomView(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by routes/views.ts to emit view.changed when underlying state
|
||||
* changes. Phase B does not auto-evaluate; callers (route SSE handlers)
|
||||
* subscribe to event-bus events and trigger this notify.
|
||||
*/
|
||||
notifyViewChanged(viewName: string, cause: string): void {
|
||||
const persisted = this.eventBus.persistWithinTransaction({
|
||||
type: "view.changed",
|
||||
viewName,
|
||||
cause,
|
||||
});
|
||||
this.eventBus.notifySubscribers(persisted);
|
||||
}
|
||||
|
||||
private getCustomViewByIdOrThrow(viewId: string): CustomView {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM views_custom WHERE view_id = ?`)
|
||||
.get(viewId) as CustomViewRow | undefined;
|
||||
if (!row) {
|
||||
throw new ViewProjectorError("view_not_found", `custom view ${viewId} not found after write`);
|
||||
}
|
||||
return this.rowToCustomView(row);
|
||||
}
|
||||
|
||||
private rowToCustomView(row: CustomViewRow): CustomView {
|
||||
return {
|
||||
viewId: row.view_id,
|
||||
viewName: row.view_name,
|
||||
definition: row.definition,
|
||||
registeredBySession: row.registered_by_session,
|
||||
registeredAt: row.registered_at,
|
||||
lastEvaluatedAt: row.last_evaluated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import type { ProjectClassifier } from "../domain/project-classifier.js";
|
||||
import { ProjectClassifierError } from "../domain/project-classifier.js";
|
||||
import type { ClassifierLeaseManager } from "../domain/classifier-lease-manager.js";
|
||||
import { ClassifierLeaseError } from "../domain/classifier-lease-manager.js";
|
||||
|
||||
/**
|
||||
* Coordination L2 — Project (Classifier) HTTP routes (PL-004 Phase B).
|
||||
*
|
||||
* Backs `rig project` CLI verb. Lease lifecycle endpoints + project
|
||||
* (idempotent classify) + operator-verb reclaim + SSE.
|
||||
*
|
||||
* Per Phase A R1 SSE route-order lesson (slice IMPL § Audit Row 12):
|
||||
* SSE/static routes are mounted BEFORE the bare-param /:id catchall.
|
||||
*/
|
||||
export function projectsRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
function getClassifier(c: { get: (key: string) => unknown }): ProjectClassifier {
|
||||
return c.get("projectClassifier" as never) as ProjectClassifier;
|
||||
}
|
||||
function getLease(c: { get: (key: string) => unknown }): ClassifierLeaseManager {
|
||||
return c.get("classifierLeaseManager" as never) as ClassifierLeaseManager;
|
||||
}
|
||||
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 ProjectClassifierError) {
|
||||
const status = err.code === "idempotency_violation" ? 409
|
||||
: err.code === "project_not_found" ? 404
|
||||
: 500;
|
||||
return c.json({ error: err.code, message: err.message, ...(err.meta ?? {}) }, status as 200);
|
||||
}
|
||||
if (err instanceof ClassifierLeaseError) {
|
||||
const status = err.code === "lease_held" ? 409
|
||||
: err.code === "lease_session_mismatch" ? 403
|
||||
: err.code === "lease_not_active" ? 409
|
||||
: err.code === "lease_expired" ? 409
|
||||
: err.code === "lease_not_found" ? 404
|
||||
: err.code === "no_active_lease" ? 409
|
||||
: err.code === "lease_still_active" ? 409
|
||||
: 500;
|
||||
return c.json({ error: err.code, message: err.message, ...(err.meta ?? {}) }, status as 200);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "internal error";
|
||||
return c.json({ error: "internal_error", message }, 500);
|
||||
}
|
||||
|
||||
// POST /lease/acquire — acquire active classifier lease for caller.
|
||||
app.post("/lease/acquire", async (c) => {
|
||||
const body = await c.req.json<{ classifierSession?: string }>().catch(() => ({} as never));
|
||||
if (!body.classifierSession) return c.json({ error: "classifierSession is required" }, 400);
|
||||
try {
|
||||
const lease = getLease(c).acquire(body.classifierSession);
|
||||
return c.json(lease, 201);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /lease/heartbeat — update last_heartbeat + extend expires_at.
|
||||
app.post("/lease/heartbeat", async (c) => {
|
||||
const body = await c.req.json<{ leaseId?: string; classifierSession?: string }>().catch(() => ({} as never));
|
||||
if (!body.leaseId) return c.json({ error: "leaseId is required" }, 400);
|
||||
if (!body.classifierSession) return c.json({ error: "classifierSession is required" }, 400);
|
||||
try {
|
||||
const lease = getLease(c).heartbeat(body.leaseId, body.classifierSession);
|
||||
return c.json(lease);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /reclaim-classifier — operator-verb reclaim (per PRD § L2 hard rule).
|
||||
app.post("/reclaim-classifier", async (c) => {
|
||||
const body = await c.req.json<{
|
||||
byClassifierSession?: string;
|
||||
ifDead?: boolean;
|
||||
reason?: string;
|
||||
}>().catch(() => ({} as never));
|
||||
if (!body.byClassifierSession) return c.json({ error: "byClassifierSession is required" }, 400);
|
||||
try {
|
||||
const lease = getLease(c).reclaim(body.byClassifierSession, {
|
||||
ifDead: body.ifDead,
|
||||
reason: body.reason,
|
||||
});
|
||||
return c.json(lease);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /project — project a stream item (idempotent on stream_item_id).
|
||||
app.post("/project", async (c) => {
|
||||
const body = await c.req.json<{
|
||||
streamItemId?: string;
|
||||
classifierSession?: string;
|
||||
classificationType?: string;
|
||||
classificationUrgency?: string;
|
||||
classificationMaturity?: string;
|
||||
classificationConfidence?: string;
|
||||
classificationDestination?: string;
|
||||
action?: string;
|
||||
}>().catch(() => ({} as never));
|
||||
if (!body.streamItemId) return c.json({ error: "streamItemId is required" }, 400);
|
||||
if (!body.classifierSession) return c.json({ error: "classifierSession is required" }, 400);
|
||||
try {
|
||||
const project = getClassifier(c).classify({
|
||||
streamItemId: body.streamItemId,
|
||||
classifierSession: body.classifierSession,
|
||||
classificationType: body.classificationType,
|
||||
classificationUrgency: body.classificationUrgency,
|
||||
classificationMaturity: body.classificationMaturity,
|
||||
classificationConfidence: body.classificationConfidence,
|
||||
classificationDestination: body.classificationDestination,
|
||||
action: body.action,
|
||||
});
|
||||
return c.json(project, 201);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /lease — show active lease.
|
||||
// MUST precede /:projectId so the literal path wins.
|
||||
app.get("/lease", (c) => {
|
||||
const lease = getLease(c).getActiveLease();
|
||||
if (!lease) return c.json({ error: "no_active_lease" }, 404);
|
||||
return c.json(lease);
|
||||
});
|
||||
|
||||
// GET /list — list classifications with filters.
|
||||
// MUST precede /:projectId so the literal path wins.
|
||||
app.get("/list", (c) => {
|
||||
const classifierSession = c.req.query("classifierSession") || undefined;
|
||||
const classificationDestination = c.req.query("classificationDestination") || undefined;
|
||||
const limit = c.req.query("limit") ? Number.parseInt(c.req.query("limit")!, 10) : undefined;
|
||||
const items = getClassifier(c).list({ classifierSession, classificationDestination, limit });
|
||||
return c.json(items);
|
||||
});
|
||||
|
||||
// ---- SSE for project + classifier events ----
|
||||
// MUST precede /:projectId so the literal `sse` and `watch` paths win
|
||||
// over the bare-param route. Per Phase A R1 SSE route-order lesson.
|
||||
const sseHandler = (c: Parameters<typeof streamSSE>[0]) => {
|
||||
const eventBus = getEventBus(c);
|
||||
return streamSSE(c, async (stream) => {
|
||||
const unsubscribe = eventBus.subscribe((event) => {
|
||||
if (
|
||||
event.type !== "project.classified" &&
|
||||
event.type !== "classifier.lease_acquired" &&
|
||||
event.type !== "classifier.lease_expired" &&
|
||||
event.type !== "classifier.dead" &&
|
||||
event.type !== "classifier.reclaimed"
|
||||
) 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();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
app.get("/sse", sseHandler);
|
||||
app.get("/watch", sseHandler);
|
||||
|
||||
// GET /:projectId — show one project (must come AFTER literal routes).
|
||||
app.get("/:projectId", (c) => {
|
||||
const projectId = c.req.param("projectId");
|
||||
const project = getClassifier(c).getById(projectId);
|
||||
if (!project) return c.json({ error: "project_not_found" }, 404);
|
||||
return c.json(project);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import type { EventBus } from "../domain/event-bus.js";
|
||||
import type { ViewProjector } from "../domain/view-projector.js";
|
||||
import { ViewProjectorError } from "../domain/view-projector.js";
|
||||
|
||||
/**
|
||||
* Coordination L5 — View HTTP routes (PL-004 Phase B).
|
||||
*
|
||||
* Backs `rig view` CLI verb. Lists registered views (built-in + custom),
|
||||
* runs view queries, and exposes view.changed SSE for downstream consumers
|
||||
* (PL-005 Operator Status / PL-006 Mission Control / PL-008 progress).
|
||||
*
|
||||
* Per Phase A R1 SSE route-order lesson (slice IMPL § Audit Row 12):
|
||||
* SSE/static routes are mounted BEFORE the bare-param /:name catchall.
|
||||
*/
|
||||
export function viewsRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
function getProjector(c: { get: (key: string) => unknown }): ViewProjector {
|
||||
return c.get("viewProjector" as never) as ViewProjector;
|
||||
}
|
||||
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 ViewProjectorError) {
|
||||
const status = err.code === "view_not_found" ? 404
|
||||
: err.code === "view_name_reserved" ? 409
|
||||
: err.code === "view_query_failed" ? 400
|
||||
: 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 /custom/register — register/update custom view.
|
||||
app.post("/custom/register", async (c) => {
|
||||
const body = await c.req.json<{
|
||||
viewName?: string;
|
||||
definition?: string;
|
||||
registeredBySession?: string;
|
||||
}>().catch(() => ({} as never));
|
||||
if (!body.viewName) return c.json({ error: "viewName is required" }, 400);
|
||||
if (!body.definition) return c.json({ error: "definition is required" }, 400);
|
||||
if (!body.registeredBySession) return c.json({ error: "registeredBySession is required" }, 400);
|
||||
try {
|
||||
const view = getProjector(c).registerCustomView({
|
||||
viewName: body.viewName,
|
||||
definition: body.definition,
|
||||
registeredBySession: body.registeredBySession,
|
||||
});
|
||||
return c.json(view, 201);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /list — built-in + custom view names.
|
||||
// MUST precede /:viewName so the literal path wins.
|
||||
app.get("/list", (c) => {
|
||||
return c.json(getProjector(c).list());
|
||||
});
|
||||
|
||||
// ---- SSE for view.changed events ----
|
||||
// Generic SSE that emits ALL view.changed events. Per Phase A R1 lesson:
|
||||
// mount BEFORE /:viewName catchall.
|
||||
const sseHandler = (c: Parameters<typeof streamSSE>[0]) => {
|
||||
const eventBus = getEventBus(c);
|
||||
return streamSSE(c, async (stream) => {
|
||||
const unsubscribe = eventBus.subscribe((event) => {
|
||||
if (event.type !== "view.changed") 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();
|
||||
}
|
||||
});
|
||||
};
|
||||
app.get("/sse", sseHandler);
|
||||
app.get("/watch", sseHandler);
|
||||
|
||||
// GET /:viewName/sse — view-specific SSE filtered by viewName.
|
||||
// The /:viewName/sse path is more specific than /:viewName so Hono
|
||||
// dispatches it correctly even with the catchall registered later.
|
||||
app.get("/:viewName/sse", (c) => {
|
||||
const viewName = c.req.param("viewName");
|
||||
const eventBus = getEventBus(c);
|
||||
return streamSSE(c, async (stream) => {
|
||||
const unsubscribe = eventBus.subscribe((event) => {
|
||||
if (event.type !== "view.changed") return;
|
||||
if (event.viewName !== viewName) 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// GET /:viewName — run a view (built-in or custom).
|
||||
// Comes LAST so /list, /sse, /watch, /:viewName/sse all win.
|
||||
app.get("/:viewName", (c) => {
|
||||
const viewName = c.req.param("viewName");
|
||||
const rig = c.req.query("rig") || undefined;
|
||||
const limit = c.req.query("limit") ? Number.parseInt(c.req.query("limit")!, 10) : undefined;
|
||||
try {
|
||||
const result = getProjector(c).show(viewName, { rig, limit });
|
||||
return c.json(result);
|
||||
} catch (err) {
|
||||
return errorResponse(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -61,10 +61,15 @@ 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 { projectsRoutes } from "./routes/projects.js";
|
||||
import { viewsRoutes } from "./routes/views.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 type { ProjectClassifier } from "./domain/project-classifier.js";
|
||||
import type { ClassifierLeaseManager } from "./domain/classifier-lease-manager.js";
|
||||
import type { ViewProjector } from "./domain/view-projector.js";
|
||||
import { envRoutes } from "./routes/env.js";
|
||||
import type { RigLifecycleService } from "./domain/rig-lifecycle-service.js";
|
||||
import { seatRoutes } from "./routes/seat.js";
|
||||
@@ -108,6 +113,9 @@ export interface AppDeps {
|
||||
queueRepo?: QueueRepository;
|
||||
inboxHandler?: InboxHandler;
|
||||
outboxHandler?: OutboxHandler;
|
||||
projectClassifier?: ProjectClassifier;
|
||||
classifierLeaseManager?: ClassifierLeaseManager;
|
||||
viewProjector?: ViewProjector;
|
||||
specReviewService?: SpecReviewService;
|
||||
specLibraryService?: SpecLibraryService;
|
||||
whoamiService?: WhoamiService;
|
||||
@@ -253,6 +261,9 @@ export function createApp(deps: AppDeps): Hono {
|
||||
c.set("queueRepo" as never, deps.queueRepo);
|
||||
c.set("inboxHandler" as never, deps.inboxHandler);
|
||||
c.set("outboxHandler" as never, deps.outboxHandler);
|
||||
c.set("projectClassifier" as never, deps.projectClassifier);
|
||||
c.set("classifierLeaseManager" as never, deps.classifierLeaseManager);
|
||||
c.set("viewProjector" as never, deps.viewProjector);
|
||||
c.set("specReviewService" as never, deps.specReviewService);
|
||||
c.set("specLibraryService" as never, deps.specLibraryService);
|
||||
c.set("whoamiService" as never, deps.whoamiService);
|
||||
@@ -301,6 +312,8 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/rigs/:rigId/chat", chatRoutes());
|
||||
app.route("/api/stream", streamRoutes());
|
||||
app.route("/api/queue", queueRoutes());
|
||||
app.route("/api/projects", projectsRoutes());
|
||||
app.route("/api/views", viewsRoutes());
|
||||
app.route("/api/rigs/:rigId/env", envRoutes());
|
||||
app.route("/api/restore-check", restoreCheckRoutes);
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ 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 { ProjectClassifier } from "./domain/project-classifier.js";
|
||||
import { ClassifierLeaseManager } from "./domain/classifier-lease-manager.js";
|
||||
import { ViewProjector } from "./domain/view-projector.js";
|
||||
import { SpecReviewService } from "./domain/spec-review-service.js";
|
||||
import { SpecLibraryService } from "./domain/spec-library-service.js";
|
||||
import { WhoamiService } from "./domain/whoami-service.js";
|
||||
@@ -96,6 +99,9 @@ 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 { projectClassificationsSchema } from "./db/migrations/028_project_classifications.js";
|
||||
import { classifierLeasesSchema } from "./db/migrations/029_classifier_leases.js";
|
||||
import { viewsCustomSchema } from "./db/migrations/030_views_custom.js";
|
||||
import { OPENRIG_HOME } from "./openrig-compat.js";
|
||||
import {
|
||||
getCompatibleOpenRigPath,
|
||||
@@ -121,7 +127,7 @@ 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, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema]);
|
||||
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, projectClassificationsSchema, classifierLeasesSchema, viewsCustomSchema]);
|
||||
|
||||
const rigRepo = new RigRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
@@ -143,6 +149,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
const queueRepoInstance = new QueueRepository(db, eventBus, {
|
||||
validateRig: topologyValidateRig,
|
||||
});
|
||||
// PL-004 Phase B — classifier lease manager. Constructed early so both
|
||||
// the leaseManager dep slot and project-classifier can share one instance.
|
||||
// isAlive is post-attached after whoami-service is constructed.
|
||||
const classifierLeaseManagerInstance = new ClassifierLeaseManager(db, eventBus);
|
||||
|
||||
const tmuxAdapter = new TmuxAdapter(opts?.tmuxExec ?? execCommand);
|
||||
// cmuxFactory takes precedence (for tests), then cmuxExec-based CLI transport, then default
|
||||
@@ -389,6 +399,24 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// PL-004 Phase A revision (R1): wire QueueRepository's wake-path so
|
||||
// create / handoff / handoff-and-complete nudge by default.
|
||||
queueRepoInstance.attachTransport(t);
|
||||
// PL-004 Phase B: wire classifier-lease-manager liveness check from
|
||||
// the sessions table. Lease holder is "alive" iff there is at least
|
||||
// one row in `sessions` with session_name == classifierSession AND
|
||||
// status == 'running'.
|
||||
classifierLeaseManagerInstance.attachIsAlive((classifierSession: string): boolean => {
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM sessions WHERE session_name = ? AND status = 'running' LIMIT 1`,
|
||||
)
|
||||
.get(classifierSession) as { 1: number } | undefined;
|
||||
return row !== undefined;
|
||||
} catch {
|
||||
// Conservative: on lookup error, treat as alive (do not falsely
|
||||
// trigger deadness-based lease expiry).
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return t;
|
||||
})(),
|
||||
chatRepo: new ChatRepository(db),
|
||||
@@ -396,6 +424,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
queueRepo: queueRepoInstance,
|
||||
inboxHandler: new InboxHandler(db, eventBus, queueRepoInstance),
|
||||
outboxHandler: new OutboxHandler(db),
|
||||
classifierLeaseManager: classifierLeaseManagerInstance,
|
||||
projectClassifier: new ProjectClassifier(db, eventBus, classifierLeaseManagerInstance),
|
||||
viewProjector: new ViewProjector(db, eventBus),
|
||||
askService: (() => {
|
||||
const psProjectionService = new PsProjectionService({ db });
|
||||
const execDep = (cmd: string, args: string[]): Promise<{ stdout: string; exitCode: number }> =>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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 { classifierLeasesSchema } from "../src/db/migrations/029_classifier_leases.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import {
|
||||
ClassifierLeaseManager,
|
||||
ClassifierLeaseError,
|
||||
} from "../src/domain/classifier-lease-manager.js";
|
||||
import type { PersistedEvent } from "../src/domain/types.js";
|
||||
|
||||
describe("ClassifierLeaseManager (PL-004 Phase B; L2 lease lifecycle)", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let mgr: ClassifierLeaseManager;
|
||||
let captured: PersistedEvent[];
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, classifierLeasesSchema]);
|
||||
bus = new EventBus(db);
|
||||
mgr = new ClassifierLeaseManager(db, bus, { ttlMs: 1000 });
|
||||
captured = [];
|
||||
bus.subscribe((e) => captured.push(e));
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("acquire creates an active lease + emits classifier.lease_acquired", () => {
|
||||
const lease = mgr.acquire("alice@rig");
|
||||
expect(lease.classifierSession).toBe("alice@rig");
|
||||
expect(lease.state).toBe("active");
|
||||
expect(lease.acquiredAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(lease.expiresAt > lease.acquiredAt).toBe(true);
|
||||
expect(captured.some((e) => e.type === "classifier.lease_acquired")).toBe(true);
|
||||
});
|
||||
|
||||
it("acquire is idempotent for the same classifier session (returns existing lease unchanged)", () => {
|
||||
const a = mgr.acquire("alice@rig");
|
||||
const b = mgr.acquire("alice@rig");
|
||||
expect(b.leaseId).toBe(a.leaseId);
|
||||
// Only ONE lease.acquired event (idempotent path doesn't re-emit).
|
||||
expect(captured.filter((e) => e.type === "classifier.lease_acquired")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("acquire by a different session when one is active throws lease_held (409)", () => {
|
||||
mgr.acquire("alice@rig");
|
||||
expect(() => mgr.acquire("bob@rig")).toThrow(ClassifierLeaseError);
|
||||
try {
|
||||
mgr.acquire("bob@rig");
|
||||
} catch (err) {
|
||||
expect((err as ClassifierLeaseError).code).toBe("lease_held");
|
||||
expect((err as ClassifierLeaseError).meta?.holder).toBe("alice@rig");
|
||||
}
|
||||
});
|
||||
|
||||
it("heartbeat extends expires_at and updates last_heartbeat", () => {
|
||||
const fakeNow = (() => {
|
||||
let t = new Date("2026-05-03T00:00:00.000Z").getTime();
|
||||
return () => { const d = new Date(t); t += 100; return d; };
|
||||
})();
|
||||
const m = new ClassifierLeaseManager(db, bus, { ttlMs: 1000, now: fakeNow });
|
||||
const acquired = m.acquire("alice@rig");
|
||||
const beat = m.heartbeat(acquired.leaseId, "alice@rig");
|
||||
expect(beat.lastHeartbeat > acquired.lastHeartbeat).toBe(true);
|
||||
expect(beat.expiresAt > acquired.expiresAt).toBe(true);
|
||||
});
|
||||
|
||||
it("heartbeat by mismatched session throws lease_session_mismatch (403)", () => {
|
||||
const lease = mgr.acquire("alice@rig");
|
||||
expect(() => mgr.heartbeat(lease.leaseId, "bob@rig")).toThrow(/lease_session_mismatch|alice@rig/);
|
||||
});
|
||||
|
||||
it("evaluateDeadness on alive holder returns null (no expiry)", () => {
|
||||
mgr.acquire("alice@rig");
|
||||
const result = mgr.evaluateDeadness();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("evaluateDeadness on dead holder marks lease expired + emits classifier.dead", () => {
|
||||
const m = new ClassifierLeaseManager(db, bus, {
|
||||
ttlMs: 1000,
|
||||
isAlive: (s) => s !== "alice@rig", // alice is reported dead
|
||||
});
|
||||
m.acquire("alice@rig");
|
||||
const result = m.evaluateDeadness();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.state).toBe("expired");
|
||||
expect(captured.some((e) => e.type === "classifier.lease_expired")).toBe(true);
|
||||
expect(captured.some((e) => e.type === "classifier.dead")).toBe(true);
|
||||
});
|
||||
|
||||
it("evaluateDeadness on TTL-expired lease marks expired (without classifier.dead)", () => {
|
||||
let t = new Date("2026-05-03T00:00:00.000Z").getTime();
|
||||
const m = new ClassifierLeaseManager(db, bus, {
|
||||
ttlMs: 100,
|
||||
now: () => new Date(t),
|
||||
});
|
||||
m.acquire("alice@rig");
|
||||
t += 200; // advance past TTL
|
||||
const result = m.evaluateDeadness();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.state).toBe("expired");
|
||||
expect(captured.some((e) => e.type === "classifier.lease_expired")).toBe(true);
|
||||
expect(captured.some((e) => e.type === "classifier.dead")).toBe(false);
|
||||
});
|
||||
|
||||
it("after evaluateDeadness expires the lease, a new session can acquire", () => {
|
||||
const m = new ClassifierLeaseManager(db, bus, {
|
||||
ttlMs: 1000,
|
||||
isAlive: (s) => s !== "alice@rig",
|
||||
});
|
||||
m.acquire("alice@rig");
|
||||
m.evaluateDeadness(); // marks alice's lease expired
|
||||
const fresh = m.acquire("bob@rig");
|
||||
expect(fresh.classifierSession).toBe("bob@rig");
|
||||
expect(fresh.state).toBe("active");
|
||||
});
|
||||
|
||||
it("reclaim takes the active lease away from previous holder + emits classifier.reclaimed", () => {
|
||||
mgr.acquire("alice@rig");
|
||||
const result = mgr.reclaim("operator@rig", { reason: "alice unresponsive" });
|
||||
expect(result.state).toBe("reclaimed");
|
||||
expect(result.reclaimedBySession).toBe("operator@rig");
|
||||
expect(result.reclaimReason).toBe("alice unresponsive");
|
||||
expect(captured.some((e) => e.type === "classifier.reclaimed")).toBe(true);
|
||||
});
|
||||
|
||||
it("reclaim --if-dead refuses when holder is alive", () => {
|
||||
mgr.acquire("alice@rig"); // default isAlive returns true
|
||||
try {
|
||||
mgr.reclaim("operator@rig", { ifDead: true });
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ClassifierLeaseError);
|
||||
expect((err as ClassifierLeaseError).code).toBe("lease_still_active");
|
||||
}
|
||||
});
|
||||
|
||||
it("reclaim --if-dead succeeds when holder is dead", () => {
|
||||
const m = new ClassifierLeaseManager(db, bus, {
|
||||
ttlMs: 1000,
|
||||
isAlive: (s) => s !== "alice@rig",
|
||||
});
|
||||
m.acquire("alice@rig");
|
||||
const result = m.reclaim("operator@rig", { ifDead: true });
|
||||
expect(result.state).toBe("reclaimed");
|
||||
});
|
||||
|
||||
it("requireActiveHolder validates the supplied session holds the active lease", () => {
|
||||
const lease = mgr.acquire("alice@rig");
|
||||
const checked = mgr.requireActiveHolder("alice@rig");
|
||||
expect(checked.leaseId).toBe(lease.leaseId);
|
||||
});
|
||||
|
||||
it("requireActiveHolder throws lease_held when supplied a different session", () => {
|
||||
mgr.acquire("alice@rig");
|
||||
expect(() => mgr.requireActiveHolder("bob@rig")).toThrow(ClassifierLeaseError);
|
||||
});
|
||||
|
||||
it("requireActiveHolder throws no_active_lease when nothing is held", () => {
|
||||
try {
|
||||
mgr.requireActiveHolder("alice@rig");
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ClassifierLeaseError);
|
||||
expect((err as ClassifierLeaseError).code).toBe("no_active_lease");
|
||||
}
|
||||
});
|
||||
|
||||
it("partial UNIQUE index enforces single-writer at DB layer", () => {
|
||||
mgr.acquire("alice@rig");
|
||||
// Manual INSERT with state='active' via raw SQL would be blocked by the
|
||||
// partial UNIQUE index `idx_classifier_leases_active_singleton`.
|
||||
expect(() => {
|
||||
db.prepare(
|
||||
`INSERT INTO classifier_leases (
|
||||
lease_id, classifier_session, acquired_at, expires_at, last_heartbeat, state
|
||||
) VALUES (?, ?, ?, ?, ?, 'active')`
|
||||
).run("dup-lease", "bob@rig", "2026-05-03T00:00:00Z", "2026-05-03T00:00:01Z", "2026-05-03T00:00:00Z");
|
||||
}).toThrow(/UNIQUE constraint/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
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 { classifierLeasesSchema } from "../src/db/migrations/029_classifier_leases.js";
|
||||
import { projectClassificationsSchema } from "../src/db/migrations/028_project_classifications.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { ClassifierLeaseManager } from "../src/domain/classifier-lease-manager.js";
|
||||
import {
|
||||
ProjectClassifier,
|
||||
ProjectClassifierError,
|
||||
} from "../src/domain/project-classifier.js";
|
||||
import { ClassifierLeaseError } from "../src/domain/classifier-lease-manager.js";
|
||||
import type { PersistedEvent } from "../src/domain/types.js";
|
||||
|
||||
describe("ProjectClassifier (PL-004 Phase B; L2 classifier write path)", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let leaseMgr: ClassifierLeaseManager;
|
||||
let classifier: ProjectClassifier;
|
||||
let captured: PersistedEvent[];
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, classifierLeasesSchema, projectClassificationsSchema]);
|
||||
bus = new EventBus(db);
|
||||
leaseMgr = new ClassifierLeaseManager(db, bus);
|
||||
classifier = new ProjectClassifier(db, bus, leaseMgr);
|
||||
captured = [];
|
||||
bus.subscribe((e) => captured.push(e));
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("classify with valid lease creates project_classifications row + emits project.classified", () => {
|
||||
leaseMgr.acquire("alice@rig");
|
||||
const proj = classifier.classify({
|
||||
streamItemId: "stream-1",
|
||||
classifierSession: "alice@rig",
|
||||
classificationType: "idea",
|
||||
classificationDestination: "planning@rig",
|
||||
});
|
||||
expect(proj.projectId).toMatch(/^[0-9A-Z]{26}$/); // ULID shape
|
||||
expect(proj.streamItemId).toBe("stream-1");
|
||||
expect(proj.classifierSession).toBe("alice@rig");
|
||||
expect(proj.classificationType).toBe("idea");
|
||||
expect(captured.some((e) => e.type === "project.classified")).toBe(true);
|
||||
});
|
||||
|
||||
it("classify without active lease throws no_active_lease", () => {
|
||||
expect(() => classifier.classify({
|
||||
streamItemId: "stream-1",
|
||||
classifierSession: "alice@rig",
|
||||
})).toThrow(ClassifierLeaseError);
|
||||
});
|
||||
|
||||
it("classify by non-holder throws lease_held", () => {
|
||||
leaseMgr.acquire("alice@rig");
|
||||
expect(() => classifier.classify({
|
||||
streamItemId: "stream-1",
|
||||
classifierSession: "bob@rig",
|
||||
})).toThrow(/lease_held|alice@rig/);
|
||||
});
|
||||
|
||||
it("classify is idempotent on stream_item_id (re-projection → idempotency_violation 409)", () => {
|
||||
leaseMgr.acquire("alice@rig");
|
||||
classifier.classify({
|
||||
streamItemId: "stream-1",
|
||||
classifierSession: "alice@rig",
|
||||
classificationType: "idea",
|
||||
});
|
||||
expect(() => classifier.classify({
|
||||
streamItemId: "stream-1",
|
||||
classifierSession: "alice@rig",
|
||||
classificationType: "bug",
|
||||
})).toThrow(ProjectClassifierError);
|
||||
try {
|
||||
classifier.classify({
|
||||
streamItemId: "stream-1",
|
||||
classifierSession: "alice@rig",
|
||||
classificationType: "bug",
|
||||
});
|
||||
} catch (err) {
|
||||
expect((err as ProjectClassifierError).code).toBe("idempotency_violation");
|
||||
}
|
||||
});
|
||||
|
||||
it("first classification's classifier_session is preserved on re-projection attempts", () => {
|
||||
leaseMgr.acquire("alice@rig");
|
||||
const first = classifier.classify({
|
||||
streamItemId: "stream-1",
|
||||
classifierSession: "alice@rig",
|
||||
classificationType: "idea",
|
||||
});
|
||||
// Reclaim + new lease + same session can also re-attempt — still rejected.
|
||||
leaseMgr.reclaim("operator@rig");
|
||||
leaseMgr.acquire("bob@rig");
|
||||
try {
|
||||
classifier.classify({
|
||||
streamItemId: "stream-1",
|
||||
classifierSession: "bob@rig",
|
||||
classificationType: "feature-request",
|
||||
});
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ProjectClassifierError);
|
||||
expect((err as ProjectClassifierError).code).toBe("idempotency_violation");
|
||||
}
|
||||
const lookup = classifier.getByStreamItemId("stream-1");
|
||||
expect(lookup?.projectId).toBe(first.projectId);
|
||||
expect(lookup?.classifierSession).toBe("alice@rig"); // first wins
|
||||
expect(lookup?.classificationType).toBe("idea");
|
||||
});
|
||||
|
||||
it("classify accepts all 6 classification fields + action; null when unset", () => {
|
||||
leaseMgr.acquire("alice@rig");
|
||||
const full = classifier.classify({
|
||||
streamItemId: "stream-full",
|
||||
classifierSession: "alice@rig",
|
||||
classificationType: "idea",
|
||||
classificationUrgency: "high",
|
||||
classificationMaturity: "ratified",
|
||||
classificationConfidence: "high",
|
||||
classificationDestination: "planning@rig",
|
||||
action: "create",
|
||||
});
|
||||
expect(full.classificationUrgency).toBe("high");
|
||||
expect(full.classificationMaturity).toBe("ratified");
|
||||
expect(full.classificationConfidence).toBe("high");
|
||||
expect(full.action).toBe("create");
|
||||
|
||||
const minimal = classifier.classify({
|
||||
streamItemId: "stream-minimal",
|
||||
classifierSession: "alice@rig",
|
||||
});
|
||||
expect(minimal.classificationType).toBeNull();
|
||||
expect(minimal.classificationUrgency).toBeNull();
|
||||
expect(minimal.action).toBeNull();
|
||||
});
|
||||
|
||||
it("list filters by classifierSession + classificationDestination", () => {
|
||||
leaseMgr.acquire("alice@rig");
|
||||
classifier.classify({
|
||||
streamItemId: "s1",
|
||||
classifierSession: "alice@rig",
|
||||
classificationDestination: "planning@rig",
|
||||
});
|
||||
classifier.classify({
|
||||
streamItemId: "s2",
|
||||
classifierSession: "alice@rig",
|
||||
classificationDestination: "delivery@rig",
|
||||
});
|
||||
classifier.classify({
|
||||
streamItemId: "s3",
|
||||
classifierSession: "alice@rig",
|
||||
classificationDestination: "planning@rig",
|
||||
});
|
||||
const planning = classifier.list({ classificationDestination: "planning@rig" });
|
||||
expect(planning).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
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 { classifierLeasesSchema } from "../src/db/migrations/029_classifier_leases.js";
|
||||
import { projectClassificationsSchema } from "../src/db/migrations/028_project_classifications.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { ClassifierLeaseManager } from "../src/domain/classifier-lease-manager.js";
|
||||
import { ProjectClassifier } from "../src/domain/project-classifier.js";
|
||||
import { projectsRoutes } from "../src/routes/projects.js";
|
||||
|
||||
function buildApp(opts: {
|
||||
eventBus: EventBus;
|
||||
classifier: ProjectClassifier;
|
||||
leaseMgr: ClassifierLeaseManager;
|
||||
}): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("eventBus" as never, opts.eventBus);
|
||||
c.set("projectClassifier" as never, opts.classifier);
|
||||
c.set("classifierLeaseManager" as never, opts.leaseMgr);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/projects", projectsRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("projects routes (PL-004 Phase B)", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let leaseMgr: ClassifierLeaseManager;
|
||||
let classifier: ProjectClassifier;
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, classifierLeasesSchema, projectClassificationsSchema]);
|
||||
bus = new EventBus(db);
|
||||
leaseMgr = new ClassifierLeaseManager(db, bus);
|
||||
classifier = new ProjectClassifier(db, bus, leaseMgr);
|
||||
app = buildApp({ eventBus: bus, classifier, leaseMgr });
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("POST /api/projects/lease/acquire returns 201 + active lease", async () => {
|
||||
const res = await app.request("/api/projects/lease/acquire", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ classifierSession: "alice@rig" }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const lease = (await res.json()) as { state: string; classifierSession: string };
|
||||
expect(lease.state).toBe("active");
|
||||
expect(lease.classifierSession).toBe("alice@rig");
|
||||
});
|
||||
|
||||
it("POST /api/projects/project requires lease + idempotent on stream_item_id", async () => {
|
||||
await app.request("/api/projects/lease/acquire", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ classifierSession: "alice@rig" }),
|
||||
});
|
||||
const first = await app.request("/api/projects/project", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
streamItemId: "stream-x",
|
||||
classifierSession: "alice@rig",
|
||||
classificationType: "idea",
|
||||
}),
|
||||
});
|
||||
expect(first.status).toBe(201);
|
||||
const project = (await first.json()) as { projectId: string };
|
||||
expect(project.projectId).toMatch(/^[0-9A-Z]{26}$/);
|
||||
|
||||
const second = await app.request("/api/projects/project", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
streamItemId: "stream-x",
|
||||
classifierSession: "alice@rig",
|
||||
classificationType: "bug",
|
||||
}),
|
||||
});
|
||||
expect(second.status).toBe(409);
|
||||
const err = (await second.json()) as { error: string };
|
||||
expect(err.error).toBe("idempotency_violation");
|
||||
});
|
||||
|
||||
it("POST /api/projects/project without active lease returns 409 no_active_lease", async () => {
|
||||
const res = await app.request("/api/projects/project", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
streamItemId: "stream-x",
|
||||
classifierSession: "alice@rig",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
const err = (await res.json()) as { error: string };
|
||||
expect(err.error).toBe("no_active_lease");
|
||||
});
|
||||
|
||||
it("POST /api/projects/reclaim-classifier with --if-dead refuses on alive holder", async () => {
|
||||
await app.request("/api/projects/lease/acquire", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ classifierSession: "alice@rig" }),
|
||||
});
|
||||
const res = await app.request("/api/projects/reclaim-classifier", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ byClassifierSession: "operator@rig", ifDead: true }),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
const err = (await res.json()) as { error: string };
|
||||
expect(err.error).toBe("lease_still_active");
|
||||
});
|
||||
|
||||
it("POST /api/projects/reclaim-classifier without --if-dead succeeds", async () => {
|
||||
await app.request("/api/projects/lease/acquire", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ classifierSession: "alice@rig" }),
|
||||
});
|
||||
const res = await app.request("/api/projects/reclaim-classifier", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ byClassifierSession: "operator@rig" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const lease = (await res.json()) as { state: string; reclaimedBySession: string };
|
||||
expect(lease.state).toBe("reclaimed");
|
||||
expect(lease.reclaimedBySession).toBe("operator@rig");
|
||||
});
|
||||
|
||||
it("GET /api/projects/lease returns active lease (or 404 if none)", async () => {
|
||||
let res = await app.request("/api/projects/lease");
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
await app.request("/api/projects/lease/acquire", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ classifierSession: "alice@rig" }),
|
||||
});
|
||||
|
||||
res = await app.request("/api/projects/lease");
|
||||
expect(res.status).toBe(200);
|
||||
const lease = (await res.json()) as { classifierSession: string };
|
||||
expect(lease.classifierSession).toBe("alice@rig");
|
||||
});
|
||||
|
||||
it("GET /api/projects/list filters by classifierSession + classificationDestination", async () => {
|
||||
await app.request("/api/projects/lease/acquire", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ classifierSession: "alice@rig" }),
|
||||
});
|
||||
for (const [id, dest] of [["s1", "planning@rig"], ["s2", "delivery@rig"], ["s3", "planning@rig"]] as Array<[string, string]>) {
|
||||
await app.request("/api/projects/project", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
streamItemId: id,
|
||||
classifierSession: "alice@rig",
|
||||
classificationDestination: dest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const res = await app.request("/api/projects/list?classificationDestination=planning%40rig");
|
||||
const data = (await res.json()) as unknown[];
|
||||
expect(data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("R1 SSE pattern: GET /api/projects/sse returns 200 + content-type text/event-stream (handler reached, not /:id)", async () => {
|
||||
const res = await app.request("/api/projects/sse");
|
||||
try {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
||||
} finally {
|
||||
await res.body?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
it("R1 SSE pattern: GET /api/projects/watch returns 200 + content-type text/event-stream", async () => {
|
||||
const res = await app.request("/api/projects/watch");
|
||||
try {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
||||
} finally {
|
||||
await res.body?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
it("R1 SSE pattern: GET /api/projects/sse does NOT return project_not_found (route-order regression guard)", async () => {
|
||||
const res = await app.request("/api/projects/sse");
|
||||
try {
|
||||
expect(res.status).not.toBe(404);
|
||||
expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
|
||||
} finally {
|
||||
await res.body?.cancel();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
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 { viewsCustomSchema } from "../src/db/migrations/030_views_custom.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import {
|
||||
ViewProjector,
|
||||
ViewProjectorError,
|
||||
BUILT_IN_VIEW_NAMES,
|
||||
} from "../src/domain/view-projector.js";
|
||||
|
||||
describe("ViewProjector (PL-004 Phase B; L5 read-only projections)", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let queueRepo: QueueRepository;
|
||||
let projector: ViewProjector;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, viewsCustomSchema]);
|
||||
bus = new EventBus(db);
|
||||
queueRepo = new QueueRepository(db, bus);
|
||||
projector = new ViewProjector(db, bus);
|
||||
// Seed live qitems for the views to project over.
|
||||
await queueRepo.create({
|
||||
sourceSession: "alice@product-lab",
|
||||
destinationSession: "planning@product-lab",
|
||||
body: "design new feature",
|
||||
priority: "routine",
|
||||
tier: "deep",
|
||||
nudge: false,
|
||||
});
|
||||
await queueRepo.create({
|
||||
sourceSession: "alice@product-lab",
|
||||
destinationSession: "planning@product-lab",
|
||||
body: "fix critical bug",
|
||||
priority: "critical",
|
||||
tier: "fast",
|
||||
nudge: false,
|
||||
});
|
||||
const blocked = await queueRepo.create({
|
||||
sourceSession: "alice@product-lab",
|
||||
destinationSession: "delivery@product-lab",
|
||||
body: "blocked work",
|
||||
nudge: false,
|
||||
});
|
||||
queueRepo.update({
|
||||
qitemId: blocked.qitemId,
|
||||
actorSession: "delivery@product-lab",
|
||||
state: "blocked",
|
||||
transitionNote: "blocked on dep",
|
||||
});
|
||||
// Fixture rig (should be excluded by default).
|
||||
await queueRepo.create({
|
||||
sourceSession: "alice@test-rig",
|
||||
destinationSession: "bob@test-rig",
|
||||
body: "fixture work",
|
||||
nudge: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
delete process.env.OPENRIG_VIEW_INCLUDE_FIXTURES;
|
||||
});
|
||||
|
||||
it("BUILT_IN_VIEW_NAMES includes all 6 expected names", () => {
|
||||
expect([...BUILT_IN_VIEW_NAMES]).toEqual([
|
||||
"recently-active",
|
||||
"founder",
|
||||
"pod-load",
|
||||
"escalations",
|
||||
"held",
|
||||
"activity",
|
||||
]);
|
||||
});
|
||||
|
||||
it("show recently-active returns active-state qitems by ts_updated DESC, fixtures excluded", () => {
|
||||
const result = projector.show("recently-active");
|
||||
expect(result.viewName).toBe("recently-active");
|
||||
expect(result.rowCount).toBe(3); // 3 product-lab qitems; fixture excluded
|
||||
expect(result.rows.every((r) => !String(r.destination_session).includes("@test-"))).toBe(true);
|
||||
});
|
||||
|
||||
it("show founder returns critical-priority OR fast/critical-tier qitems", () => {
|
||||
const result = projector.show("founder");
|
||||
expect(result.rowCount).toBe(1); // only the critical/fast qitem
|
||||
expect(result.rows[0]!.priority).toBe("critical");
|
||||
});
|
||||
|
||||
it("show pod-load groups counts by destination_session", () => {
|
||||
const result = projector.show("pod-load");
|
||||
expect(result.rowCount).toBe(2); // planning@... and delivery@...
|
||||
const podMap = new Map<string, number>(result.rows.map((r) => [String(r.pod), Number(r.active_count)]));
|
||||
expect(podMap.get("planning@product-lab")).toBe(2);
|
||||
expect(podMap.get("delivery@product-lab")).toBe(1);
|
||||
});
|
||||
|
||||
it("show held returns blocked qitems", () => {
|
||||
const result = projector.show("held");
|
||||
expect(result.rowCount).toBe(1);
|
||||
expect(result.rows[0]!.state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("show escalations returns qitems with closure_reason='escalation'", () => {
|
||||
// No escalations in fixture; should return 0.
|
||||
const result = projector.show("escalations");
|
||||
expect(result.rowCount).toBe(0);
|
||||
});
|
||||
|
||||
it("show activity returns recent transitions joined to active qitems", () => {
|
||||
const result = projector.show("activity");
|
||||
expect(result.rowCount).toBeGreaterThan(0);
|
||||
// Each row has a transition_id (from queue_transitions).
|
||||
expect(result.rows[0]).toHaveProperty("transition_id");
|
||||
});
|
||||
|
||||
it("OPENRIG_VIEW_INCLUDE_FIXTURES=1 includes fixture rigs", () => {
|
||||
process.env.OPENRIG_VIEW_INCLUDE_FIXTURES = "1";
|
||||
const result = projector.show("recently-active");
|
||||
expect(result.rowCount).toBe(4); // including the fixture qitem
|
||||
});
|
||||
|
||||
it("show with --rig filter narrows by session-suffix match", () => {
|
||||
const result = projector.show("recently-active", { rig: "product-lab" });
|
||||
expect(result.rowCount).toBe(3); // all 3 product-lab qitems
|
||||
});
|
||||
|
||||
it("show <unknown-view> throws view_not_found", () => {
|
||||
expect(() => projector.show("nonexistent-view")).toThrow(ViewProjectorError);
|
||||
});
|
||||
|
||||
it("registerCustomView accepts arbitrary SQL + lookup works via show", () => {
|
||||
const view = projector.registerCustomView({
|
||||
viewName: "all-pending",
|
||||
definition: "SELECT qitem_id, destination_session FROM queue_items WHERE state = 'pending'",
|
||||
registeredBySession: "operator@rig",
|
||||
});
|
||||
expect(view.viewId).toMatch(/^[0-9A-Z]{26}$/);
|
||||
const result = projector.show("all-pending");
|
||||
expect(result.viewName).toBe("all-pending");
|
||||
expect(result.rowCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("registerCustomView rejects reserved built-in name", () => {
|
||||
try {
|
||||
projector.registerCustomView({
|
||||
viewName: "recently-active",
|
||||
definition: "SELECT 1",
|
||||
registeredBySession: "operator@rig",
|
||||
});
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ViewProjectorError);
|
||||
expect((err as ViewProjectorError).code).toBe("view_name_reserved");
|
||||
}
|
||||
});
|
||||
|
||||
it("registerCustomView re-registration updates definition (not duplicate)", () => {
|
||||
const first = projector.registerCustomView({
|
||||
viewName: "my-view",
|
||||
definition: "SELECT 1 as a",
|
||||
registeredBySession: "operator@rig",
|
||||
});
|
||||
const second = projector.registerCustomView({
|
||||
viewName: "my-view",
|
||||
definition: "SELECT 2 as b",
|
||||
registeredBySession: "operator@rig",
|
||||
});
|
||||
expect(second.viewId).toBe(first.viewId);
|
||||
expect(second.definition).toContain("SELECT 2");
|
||||
expect(projector.listCustomViews()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("list returns built-in view names + custom view records", () => {
|
||||
projector.registerCustomView({
|
||||
viewName: "my-view",
|
||||
definition: "SELECT 1",
|
||||
registeredBySession: "operator@rig",
|
||||
});
|
||||
const result = projector.list();
|
||||
expect(result.builtIn).toEqual([
|
||||
"recently-active",
|
||||
"founder",
|
||||
"pod-load",
|
||||
"escalations",
|
||||
"held",
|
||||
"activity",
|
||||
]);
|
||||
expect(result.custom).toHaveLength(1);
|
||||
expect(result.custom[0]!.viewName).toBe("my-view");
|
||||
});
|
||||
|
||||
it("notifyViewChanged emits view.changed event", () => {
|
||||
const captured: unknown[] = [];
|
||||
bus.subscribe((e) => captured.push(e));
|
||||
projector.notifyViewChanged("recently-active", "queue.created");
|
||||
expect(captured.some((e) => (e as { type: string }).type === "view.changed")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
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 { viewsCustomSchema } from "../src/db/migrations/030_views_custom.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
import { ViewProjector } from "../src/domain/view-projector.js";
|
||||
import { viewsRoutes } from "../src/routes/views.js";
|
||||
|
||||
function buildApp(opts: {
|
||||
eventBus: EventBus;
|
||||
projector: ViewProjector;
|
||||
}): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("eventBus" as never, opts.eventBus);
|
||||
c.set("viewProjector" as never, opts.projector);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/views", viewsRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("views routes (PL-004 Phase B)", () => {
|
||||
let db: Database.Database;
|
||||
let bus: EventBus;
|
||||
let queueRepo: QueueRepository;
|
||||
let projector: ViewProjector;
|
||||
let app: Hono;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, queueItemsSchema, queueTransitionsSchema, viewsCustomSchema]);
|
||||
bus = new EventBus(db);
|
||||
queueRepo = new QueueRepository(db, bus);
|
||||
projector = new ViewProjector(db, bus);
|
||||
app = buildApp({ eventBus: bus, projector });
|
||||
// Seed a few qitems so views have something to project.
|
||||
await queueRepo.create({
|
||||
sourceSession: "alice@product-lab",
|
||||
destinationSession: "planning@product-lab",
|
||||
body: "x",
|
||||
nudge: false,
|
||||
});
|
||||
await queueRepo.create({
|
||||
sourceSession: "alice@product-lab",
|
||||
destinationSession: "delivery@product-lab",
|
||||
body: "y",
|
||||
nudge: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
it("GET /api/views/list returns built-in + custom view names", async () => {
|
||||
const res = await app.request("/api/views/list");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { builtIn: string[]; custom: unknown[] };
|
||||
expect(body.builtIn).toContain("recently-active");
|
||||
expect(body.builtIn).toContain("founder");
|
||||
expect(body.builtIn).toContain("pod-load");
|
||||
expect(body.builtIn).toContain("escalations");
|
||||
expect(body.builtIn).toContain("held");
|
||||
expect(body.builtIn).toContain("activity");
|
||||
expect(body.custom).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("GET /api/views/recently-active returns rows + viewName + generatedAt", async () => {
|
||||
const res = await app.request("/api/views/recently-active");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { viewName: string; rowCount: number; rows: unknown[]; generatedAt: string };
|
||||
expect(body.viewName).toBe("recently-active");
|
||||
expect(body.rowCount).toBe(2);
|
||||
expect(body.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("GET /api/views/<unknown-view> returns 404 view_not_found", async () => {
|
||||
const res = await app.request("/api/views/nonexistent-view");
|
||||
expect(res.status).toBe(404);
|
||||
const err = (await res.json()) as { error: string };
|
||||
expect(err.error).toBe("view_not_found");
|
||||
});
|
||||
|
||||
it("GET /api/views/recently-active?limit=1 honors limit query", async () => {
|
||||
const res = await app.request("/api/views/recently-active?limit=1");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { rowCount: number };
|
||||
expect(body.rowCount).toBe(1);
|
||||
});
|
||||
|
||||
it("POST /api/views/custom/register registers custom view; show works via name", async () => {
|
||||
const reg = await app.request("/api/views/custom/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
viewName: "all-pending",
|
||||
definition: "SELECT qitem_id FROM queue_items WHERE state = 'pending'",
|
||||
registeredBySession: "operator@rig",
|
||||
}),
|
||||
});
|
||||
expect(reg.status).toBe(201);
|
||||
const res = await app.request("/api/views/all-pending");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { viewName: string; rowCount: number };
|
||||
expect(body.viewName).toBe("all-pending");
|
||||
expect(body.rowCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("POST /api/views/custom/register with reserved name returns 409 view_name_reserved", async () => {
|
||||
const res = await app.request("/api/views/custom/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
viewName: "recently-active",
|
||||
definition: "SELECT 1",
|
||||
registeredBySession: "operator@rig",
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
const err = (await res.json()) as { error: string };
|
||||
expect(err.error).toBe("view_name_reserved");
|
||||
});
|
||||
|
||||
it("R1 SSE pattern: GET /api/views/sse returns 200 + content-type text/event-stream", async () => {
|
||||
const res = await app.request("/api/views/sse");
|
||||
try {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
||||
} finally {
|
||||
await res.body?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
it("R1 SSE pattern: GET /api/views/recently-active/sse returns 200 + content-type text/event-stream", async () => {
|
||||
const res = await app.request("/api/views/recently-active/sse");
|
||||
try {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
||||
} finally {
|
||||
await res.body?.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
it("R1 SSE pattern: GET /api/views/sse does NOT return view_not_found (route-order regression guard)", async () => {
|
||||
const res = await app.request("/api/views/sse");
|
||||
try {
|
||||
expect(res.status).not.toBe(404);
|
||||
expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
|
||||
} finally {
|
||||
await res.body?.cancel();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user