diff --git a/docs/conductor-design.md b/docs/conductor-design.md index 669b421..5fee906 100644 --- a/docs/conductor-design.md +++ b/docs/conductor-design.md @@ -397,8 +397,10 @@ resolves against the owner's tenancy, so no ownership hack is needed. embedded, direct `SessionManager` access). The owner DMs the bot; the message is routed to the conductor session. No new channel needed for v1. (Web UI cockpit remains the visual view.) - **Status: designed, not built.** [conductor-frontends-design.md](./conductor-frontends-design.md) specifies both; neither is implemented — Telegram has no conductor routing and the web UI has no conductor pane, so the only front door today is `codeoid attach conductor` in a terminal. - This is the single largest gap between "the feature is implemented" and "the feature is usable", and it is also what generates the usage the rest of the design assumes. + **Status: the contract is built; the surfaces are not.** [conductor-frontends-design.md](./conductor-frontends-design.md) specifies both. + **P5.0 has landed**: `fleet.subscribe` → `fleet.snapshot.result` + streamed `fleet.update`, gated on the new `fleet:read` scope, advertised as the `fleet.board` capability, and mirrored in the Rust `codeoid-protocol` crate (which also gained the `SessionInfo.role` field it was missing). + Clients can now read and follow the board — but **no client draws it yet** (P5.1–P5.4): Telegram has no conductor routing and the web UI has no conductor pane, so the only front door today is still `codeoid attach conductor` in a terminal. + This remains the single largest gap between "the feature is implemented" and "the feature is usable", and it is also what generates the usage the rest of the design assumes. - **Wake model:** the conductor is event-driven. Wake sources: 1. owner message (Telegram/Web), 2. child-session completion (daemon emits an event → conductor turn), diff --git a/docs/conductor-frontends-design.md b/docs/conductor-frontends-design.md index 54288e7..af991fd 100644 --- a/docs/conductor-frontends-design.md +++ b/docs/conductor-frontends-design.md @@ -325,11 +325,21 @@ Each slice is a shippable PR that leaves both clients in a working state. Per [conductor-build-plan.md](./conductor-build-plan.md), `main` is ruleset-protected, so each lands as its own reviewable PR. User decision (2026-07-17): **docked-first** — skip the drawer/modal stepping-stone and go straight to the docked conductor surface. -**P5.0 — The contract.** +**P5.0 — The contract. ✅ SHIPPED.** Add `fleet.subscribe` / `fleet.snapshot.result` / `fleet.update` + the `fleet:read` scope to `@highflame/codeoid-protocol`; mirror in the Rust crate *and* add the missing `role` field. Daemon exposes the read surface from `dispatch_tasks` / `dispatch_events` + session population, pushing the *whole subtree* (not just active-session children). Ship the capability matrix as data. -Files: daemon `src/daemon/{fleet.ts,server.ts,store.ts}` · `packages/protocol` · `crates/codeoid-protocol`. +Files: daemon `src/daemon/{session-manager.ts,dispatch.ts,server.ts,store.ts}` · `packages/protocol` · `crates/codeoid-protocol`. + +As built, with three decisions the sketch above did not settle: + +- **`fleet.unsubscribe` was added.** Without it a client that navigates away from the Conductor home could only stop the delta stream by dropping its socket. +- **The board change signal is one hook, not fourteen.** `DispatcherHost.onBoardChange` fires once per entry path (enqueue, group enqueue, end of tick) rather than at each individual store mutation. Every mutation happens inside one of those paths, so coverage is complete by construction and a future mutation added inside the tick cannot be missed. +- **Deltas are exactly-once via a compound watermark.** `updated_at` is millisecond-granular and one tick routinely settles several tasks in the same millisecond, so a single cursor either drops tasks (`>`) or repeats them (`>=`). The watermark carries `taskUpdatedAt` *plus the ids already sent at exactly that millisecond*; the query stays inclusive and the id set suppresses the repeats. + +Two things deliberately NOT on the wire: the dispatch `prompt` and the worker `workdir`. The board renders lifecycle, and the prompt is the one task field carrying arbitrary user text to every subscribed client. `dependsOn` is present on the wire type but never populated, exactly as §11 specifies. + +The daemon advertises `fleet.board` (`CAPABILITIES.FLEET_BOARD`) so a client feature-detects before offering a conductor surface and an older daemon degrades to chat-only rather than showing an empty board. **P5.1 — Chat + legible fleet (zero-graph).** The conductor chat works the moment you can attach to it — the `role:"conductor"` session renders its transcript + prompt like any agent for free. diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index 47cae25..94b8998 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -173,6 +173,8 @@ const samples: { [T in ClientTypes]: Extract } = { "pipeline.pack.select": { type: "pipeline.pack.select", id: "r43", packId: "aif-sdlc" }, "push.register": { type: "push.register", id: "r44", token: "ExponentPushToken[abc]", platform: "ios" }, "push.unregister": { type: "push.unregister", id: "r45", token: "ExponentPushToken[abc]" }, + "fleet.subscribe": { type: "fleet.subscribe", id: "r46", scope: "tenant" }, + "fleet.unsubscribe": { type: "fleet.unsubscribe", id: "r47" }, }; describe("fidelity — valid samples round-trip unchanged", () => { diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 2c46328..3ccba0c 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -362,6 +362,22 @@ export const collaborationPanelsSchema = z.object({ sessionId: sessionIdField, }); +/** + * Fleet board subscribe/unsubscribe. `scope` is a closed literal rather than a + * free string so widening it later ("machine", "account") is an explicit, + * reviewable protocol change instead of something a client can just ask for. + */ +export const fleetSubscribeSchema = z.object({ + ...base, + type: z.literal("fleet.subscribe"), + scope: z.literal("tenant"), +}); + +export const fleetUnsubscribeSchema = z.object({ + ...base, + type: z.literal("fleet.unsubscribe"), +}); + export const blackboardIndexSchema = z.object({ ...base, type: z.literal("blackboard.index"), @@ -662,6 +678,8 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ pipelinePackSelectSchema, pushRegisterSchema, pushUnregisterSchema, + fleetSubscribeSchema, + fleetUnsubscribeSchema, ]); /** diff --git a/packages/protocol/src/scopes.ts b/packages/protocol/src/scopes.ts index 22b0cdc..dc70ccb 100644 --- a/packages/protocol/src/scopes.ts +++ b/packages/protocol/src/scopes.ts @@ -32,6 +32,18 @@ export const SCOPES = { * interrupt it, or spawn a disposable worker on the owner's behalf. */ SESSION_DISPATCH: "session:dispatch", + /** + * Subscribe a CLIENT to the fleet board — the conductor's dispatch tasks, + * lifecycle events, and aggregate usage (`fleet.subscribe`). + * + * Distinct from `session:read`/`session:dispatch`, which are ZeroID scopes + * delegated to the conductor's own AGENT identity to gate its `fleet_*` MCP + * tools. This one gates a human's client reading the board over the wire. + * Separate from `session:list` because the board exposes orchestration + * internals — what was dispatched, what failed, what it cost — beyond the + * session enumeration a watcher already gets. + */ + FLEET_READ: "fleet:read", /** Read files and list directories under a session's workdir */ FS_READ: "fs:read", /** Read the settings manifest + current (non-secret) daemon configuration */ @@ -78,6 +90,10 @@ export const OPERATOR_SCOPES: readonly Scope[] = [ SCOPES.SESSION_SEND, SCOPES.SESSION_INTERRUPT, SCOPES.SESSION_APPROVE, + // An operator drives the fleet, so the conductor board is part of their job. + // Deliberately NOT in WATCHER_SCOPES: a read-only watcher can already see + // sessions, but the board is orchestration state, not session output. + SCOPES.FLEET_READ, SCOPES.FS_READ, SCOPES.SETTINGS_READ, SCOPES.PIPELINE_CREATE, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index e24418d..6fbde56 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -98,6 +98,13 @@ export const CAPABILITIES = { * Expo token it sends for `PUSH`). */ PUSH_NATIVE: "push.native", + /** + * The fleet board read+subscribe surface (`fleet.subscribe` → + * `fleet.snapshot.result` + streamed `fleet.update`). Advertised by the + * DAEMON; a client feature-detects before offering a conductor surface, so an + * older daemon degrades to chat-only rather than showing an empty board. + */ + FLEET_BOARD: "fleet.board", } as const; export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES]; @@ -901,7 +908,9 @@ export type ClientMessage = | PipelinePackTrustMsg | PipelinePackSelectMsg | PushRegisterMsg - | PushUnregisterMsg; + | PushUnregisterMsg + | FleetSubscribeMsg + | FleetUnsubscribeMsg; interface BaseClientMsg { /** Request ID for correlating responses */ @@ -1796,6 +1805,130 @@ export interface CollaborationPanelsResultMsg { panels: CollaborationPanel[]; } +// ── Fleet board (conductor front doors — docs/conductor-frontends-design.md §11) ─ +// +// The conductor deliberately added ZERO client↔daemon wire types: it renders as +// an ordinary session, its `fleet_*` calls as tool cards. That is enough to CHAT +// with it; it is not enough to SEE the fleet, because the task board, worker +// lifecycle and audit trail live only in daemon SQLite (`dispatch_tasks` / +// `dispatch_events`). +// +// This is the one additive read+subscribe surface that closes that gap. It adds +// no dispatch semantics — every field below is already held by the daemon. + +/** + * A dispatch task, projected for a client. + * + * Named `*Wire` per the `PipelineWire` / `PackWire` convention, and to stay + * distinct from the daemon-internal `FleetTaskView` in `src/daemon/fleet.ts`, + * which is what the conductor's own `fleet_tasks` TOOL sees. The two are + * deliberately different shapes: the tool view is what an LLM should read, this + * is what a UI needs to draw a node (worker join key, provenance, cost). + */ +export interface FleetTaskWire { + id: string; + kind: "send" | "spawn"; + shape: "ship" | "scout"; + status: "queued" | "claimed" | "running" | "done" | "failed" | "blocked"; + attempts: number; + /** Epoch ms. */ + createdAt: number; + /** spawn: the worker session this task created. Join key into `workers`. */ + workerSessionId?: string; + /** send: the existing session this task was routed to. Join key into `workers`. */ + targetSession?: string; + /** Compressed result — never a raw transcript (the never-OOC guarantee). */ + resultDigest?: string; + error?: string; + /** Conductor WIMSE URI — who dispatched this. */ + createdBy: string; + /** Dispatch group (fan-out barrier); absent = a standalone task. */ + groupId?: string; + /** + * RESERVED and never populated in P5. The daemon has no dependency model — + * the conductor sequences in prose. Present so typed fan-in/join edges are a + * later non-breaking add rather than a wire break. + */ + dependsOn?: string[]; +} + +/** A dispatch lifecycle event — the audit trail behind the board. */ +export interface FleetEventWire { + id: number; + taskId: string; + type: string; + /** Compressed digest of what happened. */ + digest: string; + /** Epoch ms. */ + createdAt: number; +} + +/** Fleet-wide rollup, normalized across backends so one number spans vendors. */ +export interface FleetUsage { + /** Tasks not yet terminal (queued + claimed + running). */ + activeTasks: number; + /** Tasks in `blocked` — the anti-spin failure cap tripped; needs a human. */ + blockedTasks: number; + inputTokens: number; + outputTokens: number; + /** USD, summed across every backend that reports cost. */ + totalCostUsd: number; +} + +/** Everything a client needs to draw the fleet, in one payload. */ +export interface FleetSnapshot { + /** Absent when the tenant has no conductor session — a valid, common state. */ + conductor?: SessionInfo; + /** + * Sessions the board references: spawned workers AND existing sessions that + * were dispatched to. Full `SessionInfo`, so a node renders with status, + * usage and backend without duplicating those fields onto the task. + */ + workers: SessionInfo[]; + /** Newest first. */ + tasks: FleetTaskWire[]; + /** Newest first. */ + events: FleetEventWire[]; + agg: FleetUsage; +} + +/** + * An incremental board change. Carries the FULL task/event rather than a patch: + * a client that missed a delta still converges, and the payload is small. + */ +export type FleetDelta = + | { kind: "task"; task: FleetTaskWire; agg: FleetUsage } + | { kind: "event"; event: FleetEventWire; agg: FleetUsage }; + +/** Subscribe to the fleet board: replies with a snapshot, then streams deltas. */ +export interface FleetSubscribeMsg extends BaseClientMsg { + type: "fleet.subscribe"; + /** Only "tenant" today — the caller's own account+project board. */ + scope: "tenant"; +} + +/** + * Stop the delta stream. Not in the original §11 sketch, but without it a client + * that navigates away from the Conductor home can only stop the stream by + * dropping its socket. + */ +export interface FleetUnsubscribeMsg extends BaseClientMsg { + type: "fleet.unsubscribe"; +} + +/** Reply to fleet.subscribe. */ +export interface FleetSnapshotResultMsg { + type: "fleet.snapshot.result"; + requestId: string; + fleet: FleetSnapshot; +} + +/** Broadcast to subscribed clients in the tenant (mirrors session.status_change). */ +export interface FleetUpdateMsg { + type: "fleet.update"; + delta: FleetDelta; +} + /** One index row: what exists, at what version, by whom — never a body. */ export interface BlackboardIndexEntry { /** A core kind (`spec`, `research`, …) or `extra/`. */ @@ -2272,7 +2405,9 @@ export type DaemonMessage = | SettingsSetResultMsg | PipelineSnapshotMsg | PipelineListResultMsg - | PackListResultMsg; + | PackListResultMsg + | FleetSnapshotResultMsg + | FleetUpdateMsg; export interface AuthOkMsg { type: "auth.ok"; diff --git a/src/daemon/dispatch.ts b/src/daemon/dispatch.ts index 8b00669..de91225 100644 --- a/src/daemon/dispatch.ts +++ b/src/daemon/dispatch.ts @@ -97,6 +97,20 @@ export interface DispatcherHost { events: DispatchEventRow[], ): Promise; audit(action: string, detail: string): void; + /** + * The task board changed — fleet subscribers need deltas + * (docs/conductor-frontends-design.md §11). + * + * Signalled once per entry path (enqueue, group enqueue, end of tick) rather + * than at each of the ~14 individual store mutations. Every mutation happens + * inside one of those paths, so this is complete by construction and cannot + * be missed by a future mutation added inside the tick. The host derives the + * precise deltas from its own watermark; this only says "something moved". + * + * Optional: a host with no connected clients (tests, a Telegram-only daemon) + * simply omits it. + */ + onBoardChange?(): void; } /** @@ -239,6 +253,7 @@ export class Dispatcher { `task=${id} kind=${input.kind} shape=${input.shape} target=${input.targetSession ?? input.workdir ?? "-"}` + `${input.provider ? ` provider=${input.provider}` : ""}${input.model ? ` model=${input.model}` : ""}`, ); + this.#signalBoardChange(); return id; } @@ -306,6 +321,7 @@ export class Dispatcher { .join(",") .slice(0, 300)}`, ); + this.#signalBoardChange(); return { groupId, taskIds }; } @@ -328,6 +344,22 @@ export class Dispatcher { ); } finally { this.#ticking = false; + // In `finally`, and after `#ticking` is cleared: a tick that threw + // part-way through has still usually moved some tasks, and a board that + // silently stopped updating after one bad tick is worse than a delta the + // client can reconcile. Never allowed to throw into the tick. + this.#signalBoardChange(); + } + } + + /** Tell the host the board moved. Failure here must never break dispatch. */ + #signalBoardChange(): void { + try { + this.#host.onBoardChange?.(); + } catch (err) { + console.error( + `[codeoid/dispatch] board-change notify failed: ${err instanceof Error ? err.message : String(err)}`, + ); } } diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 16485da..1b288f5 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -61,6 +61,7 @@ const SERVER_CAPABILITIES: string[] = [ CAPABILITIES.UI_DIALOGS, CAPABILITIES.DYNAMIC_COMMANDS, CAPABILITIES.BLACKBOARD, + CAPABILITIES.FLEET_BOARD, ]; /** diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 998ff90..a54ab22 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -116,6 +116,9 @@ import type { CollaborationPanel, CollaborationRole, DaemonMessage, + FleetEventWire, + FleetTaskWire, + FleetUsage, McpServerStatus, ModelInfo, PipelinePhaseWire, @@ -204,6 +207,85 @@ function normalizeWorkdir(input: string): string | null { */ const PANEL_HISTORY_LIMIT = 5; +/** + * Board page sizes for `fleet.subscribe`. The snapshot is a UI payload, not an + * archive — deeper history is the dispatch-timeline lens (P5.4), which pages. + */ +const FLEET_TASK_LIMIT = 100; +const FLEET_EVENT_LIMIT = 50; + +/** Composite tenant key. Both halves are UUIDs, so ":" cannot collide. */ +function tenantKey(accountId: string, projectId: string): string { + return `${accountId}:${projectId}`; +} + +/** + * How far a tenant's board has been streamed to its subscribers. + * + * `taskUpdatedAt` alone is not enough to be exactly-once. `updated_at` is + * millisecond-granular and one dispatcher tick routinely settles several tasks + * inside the same millisecond, which leaves only bad options for a single + * cursor: an exclusive `>` bound DROPS every task that shares the boundary + * millisecond (a node stuck in a stale state until something unrelated moves + * it), and an inclusive `>=` bound RE-SENDS them on every subsequent flush. + * + * `sentAtWatermark` closes that gap: the ids already delivered at exactly + * `taskUpdatedAt`. The query stays inclusive so nothing is dropped, and this + * set suppresses the repeats. It only ever holds the tasks sharing one + * millisecond, so it stays small and is rebuilt on every advance. + */ +interface FleetWatermark { + taskUpdatedAt: number; + sentAtWatermark: Set; + eventId: number; +} + +/** Rebuild the watermark after delivering `tasks`. */ +function advanceWatermark(mark: FleetWatermark, tasks: readonly DispatchTaskRow[]): void { + for (const t of tasks) { + if (t.updatedAt > mark.taskUpdatedAt) { + mark.taskUpdatedAt = t.updatedAt; + mark.sentAtWatermark = new Set(); + } + if (t.updatedAt === mark.taskUpdatedAt) mark.sentAtWatermark.add(t.id); + } +} + +/** + * Project a stored dispatch row onto the wire. + * + * Drops `prompt`, `workdir`, `claimOwner`, `notBefore` and the lease fields: + * the board draws lifecycle, and the prompt is the one field here that can + * carry arbitrary user content into every subscribed client. `dependsOn` is + * intentionally never set — see FleetTaskWire. + */ +function toFleetTaskWire(row: DispatchTaskRow): FleetTaskWire { + return { + id: row.id, + kind: row.kind, + shape: row.shape, + status: row.status as FleetTaskWire["status"], + attempts: row.attempts, + createdAt: row.createdAt, + workerSessionId: row.workerSessionId ?? undefined, + targetSession: row.targetSession ?? undefined, + resultDigest: row.resultDigest ?? undefined, + error: row.error ?? undefined, + createdBy: row.createdBy, + groupId: row.groupId ?? undefined, + }; +} + +function toFleetEventWire(row: DispatchEventRow): FleetEventWire { + return { + id: row.id, + taskId: row.taskId, + type: row.type, + digest: row.digest, + createdAt: row.createdAt, + }; +} + const RESUME_MAX_SESSIONS = 50; const RESUME_DEADLINE_MS = 20_000; /** Per-session transcript read budget on resume. Scrollback keeps at most @@ -250,6 +332,16 @@ export class SessionManager { #config?: CodeoidConfig; #compressionRegistry?: CompressionRegistry; #dispatcher: Dispatcher; + /** + * clientId → fleet board subscription. Keyed by client, not by tenant, so a + * disconnect reaps in O(1) and one client can never hold two subscriptions. + */ + readonly #fleetSubscribers = new Map< + string, + { client: AttachedClient; accountId: string; projectId: string } + >(); + /** tenant key → how far the board has been streamed. See #flushFleetDeltas. */ + readonly #fleetWatermarks = new Map(); /** Content-blind push notifications — resolves a blocked session's owner to * their registered devices. Noop transport when push is disabled (default). */ #pushService: PushService; @@ -889,6 +981,10 @@ mcpHub: this.#mcpHub, return this.#blackboardRead(msg, auth); case "collaboration.panels": return this.#collaborationPanels(msg, auth); + case "fleet.subscribe": + return this.#fleetSubscribe(msg, auth, client); + case "fleet.unsubscribe": + return this.#fleetUnsubscribe(msg, client); case "models.list": return this.#modelsList(msg); case "session.export": @@ -1809,6 +1905,9 @@ mcpHub: this.#mcpHub, for (const session of this.#sessions.values()) { session.detach(clientId); } + // Also drop any fleet subscription — otherwise a dead socket keeps getting + // deltas pushed at it for the life of the daemon. + this.#fleetSubscribers.delete(clientId); } /** Get a session by name within the caller's tenant (for Telegram convenience). @@ -3826,9 +3925,186 @@ mcpHub: this.#mcpHub, audit: (action: string, detail: string): void => { this.#store.audit("system:dispatch", action, undefined, detail); }, + + onBoardChange: (): void => { + this.#flushFleetDeltas(); + }, }; } + // ── Fleet board (docs/conductor-frontends-design.md §11) ────────────────── + + /** + * Emit deltas to every fleet subscriber whose tenant board moved. + * + * Per-tenant watermarks rather than one global cursor: two tenants share a + * daemon and a dispatcher tick, so a global cursor would let one tenant's + * activity advance past another's unread rows and silently starve its board. + * + * Cheap when idle — with no subscribers it does not touch the database at + * all, which matters because the dispatcher calls this on every tick. + */ + #flushFleetDeltas(): void { + if (this.#fleetSubscribers.size === 0) return; + + // Distinct tenants currently being watched. + const tenants = new Map(); + for (const sub of this.#fleetSubscribers.values()) { + tenants.set(tenantKey(sub.accountId, sub.projectId), { + accountId: sub.accountId, + projectId: sub.projectId, + }); + } + + for (const [key, { accountId, projectId }] of tenants) { + const mark = this.#fleetWatermarks.get(key); + // No watermark means nobody has taken a snapshot for this tenant yet, so + // there is no baseline to diff against. Skip rather than replay the whole + // board as "changes". + if (!mark) continue; + + // Inclusive query (nothing dropped) minus what was already delivered at + // exactly the boundary millisecond (nothing repeated). See FleetWatermark. + const tasks = this.#store + .dispatchTasksUpdatedSince(accountId, projectId, mark.taskUpdatedAt) + .filter((t) => !(t.updatedAt === mark.taskUpdatedAt && mark.sentAtWatermark.has(t.id))); + const events = this.#store.dispatchEventsSince(accountId, projectId, mark.eventId); + if (tasks.length === 0 && events.length === 0) continue; + + // Recompute the rollup ONCE per tenant and attach it to each delta, so a + // client's counters stay consistent with the rows it just received + // without issuing a follow-up read. + const agg = this.#fleetUsage(accountId, projectId); + + for (const task of tasks) { + this.#sendToFleetSubscribers(accountId, projectId, { + type: "fleet.update", + delta: { kind: "task", task: toFleetTaskWire(task), agg }, + }); + } + advanceWatermark(mark, tasks); + for (const event of events) { + this.#sendToFleetSubscribers(accountId, projectId, { + type: "fleet.update", + delta: { kind: "event", event: toFleetEventWire(event), agg }, + }); + if (event.id > mark.eventId) mark.eventId = event.id; + } + } + } + + #sendToFleetSubscribers(accountId: string, projectId: string, msg: DaemonMessage): void { + for (const sub of this.#fleetSubscribers.values()) { + if (sub.accountId !== accountId || sub.projectId !== projectId) continue; + try { + sub.client.send(msg); + } catch { + /* client may have disconnected mid-broadcast; disconnectClient reaps it */ + } + } + } + + /** Task-status + cost rollup for the tenant's board. */ + #fleetUsage(accountId: string, projectId: string): FleetUsage { + const counts = this.#store.dispatchStatusCounts(accountId, projectId); + let inputTokens = 0; + let outputTokens = 0; + let totalCostUsd = 0; + for (const session of this.#sessions.values()) { + if (session.accountId !== accountId || session.projectId !== projectId) continue; + const usage = session.toInfo().usage; + if (!usage) continue; + inputTokens += usage.inputTokens ?? 0; + outputTokens += usage.outputTokens ?? 0; + totalCostUsd += usage.totalCostUsd ?? 0; + } + return { + activeTasks: counts.active, + blockedTasks: counts.blocked, + inputTokens, + outputTokens, + totalCostUsd, + }; + } + + /** + * Snapshot + subscribe. Replies with the whole board and starts streaming + * deltas; the watermark is taken from the snapshot so no change can slip + * through the gap between the two. + */ + #fleetSubscribe( + msg: Extract, + auth: AuthContext, + client: AttachedClient, + ): DaemonMessage { + if (!hasScope(auth.scopes as string[], SCOPES.FLEET_READ)) { + return { + type: "response.error", + requestId: msg.id, + error: "Missing scope: fleet:read", + code: "forbidden", + }; + } + + const { accountId, projectId } = auth; + const taskRows = this.#store.dispatchListForTenant(accountId, projectId, FLEET_TASK_LIMIT); + const eventRows = this.#store.dispatchEventsRecent(accountId, projectId, FLEET_EVENT_LIMIT); + + // Sessions the board references — spawned workers AND existing sessions a + // send was routed to — plus the conductor. Full SessionInfo so a node + // renders with status/usage/backend without duplicating them onto the task. + const referenced = new Set(); + for (const t of taskRows) { + if (t.workerSessionId) referenced.add(t.workerSessionId); + if (t.targetSession) referenced.add(t.targetSession); + } + let conductor: SessionInfo | undefined; + const workers: SessionInfo[] = []; + for (const session of this.#sessions.values()) { + if (session.accountId !== accountId || session.projectId !== projectId) continue; + const info = { ...session.toInfo(), attachedClients: session.attachedClientCount }; + if (info.role === "conductor") conductor = info; + if (referenced.has(session.id)) workers.push(info); + } + + // Watermark BEFORE registering: taken from the rows just read, so a task + // that moves between this read and the subscriber landing in the map is + // still picked up by the next flush rather than being skipped. + const mark: FleetWatermark = { + taskUpdatedAt: 0, + sentAtWatermark: new Set(), + eventId: eventRows.reduce((max, e) => (e.id > max ? e.id : max), 0), + }; + // Seeded from the very rows this snapshot ships, so the first delta flush + // neither replays them nor skips a task that shares their millisecond. + advanceWatermark(mark, taskRows); + this.#fleetWatermarks.set(tenantKey(accountId, projectId), mark); + this.#fleetSubscribers.set(client.id, { client, accountId, projectId }); + + return { + type: "fleet.snapshot.result", + requestId: msg.id, + fleet: { + conductor, + workers, + tasks: taskRows.map(toFleetTaskWire), + events: eventRows.map(toFleetEventWire), + agg: this.#fleetUsage(accountId, projectId), + }, + }; + } + + #fleetUnsubscribe( + msg: Extract, + client: AttachedClient, + ): DaemonMessage { + // Deliberately not scope-gated: dropping your own subscription is never a + // privileged act, and a token that lost fleet:read mid-connection must + // still be able to stop the stream. + this.#fleetSubscribers.delete(client.id); + return { type: "response.ok", requestId: msg.id }; + } + /** * Build the codeoid_fleet MCP server for a tenant's conductor. Tools close * over the manager, so the conductor always sees the LIVE session diff --git a/src/daemon/store.ts b/src/daemon/store.ts index 4726d5a..fb57e0c 100644 --- a/src/daemon/store.ts +++ b/src/daemon/store.ts @@ -1202,6 +1202,99 @@ export class Store { return rows.map(rowToDispatchTask); } + /** + * Recent dispatch events for the tenant, newest first — the fleet board's + * audit trail (`fleet.subscribe`). + * + * Deliberately NOT `dispatchEventsPending`: that one is the conductor's + * delivery queue and drains as events are consumed, so a client reading it + * would see an audit trail that empties itself. This reads the durable log + * regardless of delivery state. + */ + dispatchEventsRecent( + accountId: string, + projectId: string, + limit = 50, + ): DispatchEventRow[] { + return this.#db + .prepare( + `SELECT id, account_id AS accountId, project_id AS projectId, task_id AS taskId, + type, digest, created_at AS createdAt + FROM dispatch_events + WHERE account_id = ? AND project_id = ? + ORDER BY id DESC LIMIT ?`, + ) + .all(accountId, projectId, limit) as DispatchEventRow[]; + } + + /** + * Task-status rollup for the tenant — the board's headline counters. + * + * Computed in SQL over the WHOLE table rather than by counting the capped + * task list the snapshot ships: a tenant with more than `limit` tasks would + * otherwise report "3 active" purely because the older ones fell off the page. + */ + dispatchStatusCounts( + accountId: string, + projectId: string, + ): { active: number; blocked: number } { + const row = this.#db + .prepare( + `SELECT + SUM(CASE WHEN status IN ('queued','claimed','running') THEN 1 ELSE 0 END) AS active, + SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END) AS blocked + FROM dispatch_tasks + WHERE account_id = ? AND project_id = ?`, + ) + .get(accountId, projectId) as { active: number | null; blocked: number | null }; + // SUM over zero rows is NULL, not 0. + return { active: row.active ?? 0, blocked: row.blocked ?? 0 }; + } + + /** + * Tasks whose row changed at or after `since` — the delta feed behind + * `fleet.update`. + * + * Inclusive (`>=`) on purpose. `updated_at` is millisecond-granular and a + * single tick can settle several tasks within the same millisecond, so an + * exclusive bound would drop every task that shared a timestamp with the + * watermark. Re-sending a task the client already has is harmless — a delta + * carries the whole row and is idempotent by construction — whereas dropping + * one strands a node in a stale state until the next unrelated change. + */ + dispatchTasksUpdatedSince( + accountId: string, + projectId: string, + since: number, + ): DispatchTaskRow[] { + const rows = this.#db + .prepare( + `SELECT * FROM dispatch_tasks + WHERE account_id = ? AND project_id = ? AND updated_at >= ? + ORDER BY updated_at ASC`, + ) + .all(accountId, projectId, since) as RawDispatchRow[]; + return rows.map(rowToDispatchTask); + } + + /** Events with an id greater than `sinceId` — strictly increasing, so an + * exclusive bound is correct here (unlike the timestamp above). */ + dispatchEventsSince( + accountId: string, + projectId: string, + sinceId: number, + ): DispatchEventRow[] { + return this.#db + .prepare( + `SELECT id, account_id AS accountId, project_id AS projectId, task_id AS taskId, + type, digest, created_at AS createdAt + FROM dispatch_events + WHERE account_id = ? AND project_id = ? AND id > ? + ORDER BY id ASC`, + ) + .all(accountId, projectId, sinceId) as DispatchEventRow[]; + } + /** Count of live (claimed/running) spawn tasks — the concurrency-cap read. */ dispatchActiveSpawnCount(accountId: string, projectId: string): number { const row = this.#db diff --git a/src/tests/fleet-board.test.ts b/src/tests/fleet-board.test.ts new file mode 100644 index 0000000..b0d532c --- /dev/null +++ b/src/tests/fleet-board.test.ts @@ -0,0 +1,287 @@ +/** + * Fleet board — the P5.0 read+subscribe contract + * (docs/conductor-frontends-design.md §11). + * + * The conductor's dispatch state has lived only in daemon SQLite; this surface + * is the one additive wire change that lets a client SEE it. What these tests + * guard, in order of how much damage the failure would do: + * + * 1. Tenancy — a board is per-tenant. A leak here shows one customer another + * customer's work, and the delta stream makes it a live feed rather than a + * one-off read. + * 2. Scope — `fleet:read` actually gates the surface. + * 3. Prompt confidentiality — the wire projection drops `prompt`/`workdir`. + * Task rows carry raw user text; the board only needs lifecycle. + * 4. Delta liveness + watermarking — changes reach subscribers exactly once + * per change, and a subscription that is dropped stops costing anything. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SessionManager } from "../daemon/session-manager.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import { ALL_SCOPES, SCOPES } from "../protocol/scopes.js"; +import type { AuthContext, ClientMessage, DaemonMessage, FleetSnapshot } from "../protocol/types.js"; +import { parseClientMessage } from "@highflame/codeoid-protocol/schemas"; + +let tmp: string; +let store: Store; +let transcript: TranscriptStore; +let manager: SessionManager; + +const AUTH: AuthContext = { + sub: "user:fleet", + scopes: [...ALL_SCOPES] as AuthContext["scopes"], + delegationDepth: 0, + accountId: "acc-fleet", + projectId: "proj-fleet", +}; + +/** A second tenant on the same daemon — the isolation counterparty. */ +const OTHER_AUTH: AuthContext = { + ...AUTH, + sub: "user:other", + accountId: "acc-other", + projectId: "proj-other", +}; + +/** Collects everything the daemon pushed, so deltas can be asserted on. */ +function mkClient(id: string, auth: AuthContext = AUTH) { + const received: DaemonMessage[] = []; + return { + client: { id, auth, send: (m: DaemonMessage) => received.push(m) }, + received, + updates: () => received.filter((m) => m.type === "fleet.update"), + }; +} + +/** + * Enqueue through the REAL dispatcher, not the store. + * + * `Dispatcher.enqueue` is what fires the board-change signal, so going through + * it exercises the actual enqueue → signal → flush → client.send wiring instead + * of simulating the half of it under test. + */ +function enqueue( + auth: AuthContext, + over: Partial<{ kind: "send" | "spawn"; prompt: string; targetSession: string }> = {}, +): string { + return manager.dispatcher.enqueue({ + accountId: auth.accountId, + projectId: auth.projectId, + kind: over.kind ?? "spawn", + shape: "scout", + targetSession: over.targetSession, + workdir: over.kind === "send" ? undefined : "/tmp", + prompt: over.prompt ?? "investigate the flaky test", + createdBy: `wimse://conductor/${auth.accountId}`, + }); +} + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "codeoid-fleet-")); + store = new Store(join(tmp, "codeoid.db")); + transcript = new TranscriptStore(join(tmp, "transcripts")); + manager = new SessionManager(store, transcript); +}); + +afterEach(async () => { + try { + await manager.drain(3_000); + } catch {} + rmSync(tmp, { recursive: true, force: true }); +}); + +/** Round-trips through the real schema so a test can't assert on a shape the + * wire would have rejected. */ +function subscribeMsg(id: string): ClientMessage { + const parsed = parseClientMessage({ type: "fleet.subscribe", id, scope: "tenant" }); + if (!parsed.ok) throw new Error(`fleet.subscribe rejected by schema: ${parsed.error}`); + return parsed.value; +} + +/** Narrow a reply to the snapshot, failing loudly on an error response rather + * than casting past it. */ +function snapshotOf(resp: DaemonMessage): FleetSnapshot { + if (resp.type !== "fleet.snapshot.result") { + throw new Error(`expected fleet.snapshot.result, got ${resp.type}: ${JSON.stringify(resp)}`); + } + return resp.fleet; +} + +describe("fleet.subscribe — schema + scope", () => { + test("the wire schema accepts subscribe/unsubscribe and pins scope to 'tenant'", () => { + expect(parseClientMessage({ type: "fleet.subscribe", id: "1", scope: "tenant" }).ok).toBe(true); + expect(parseClientMessage({ type: "fleet.unsubscribe", id: "2" }).ok).toBe(true); + // A widened scope must be an explicit protocol change, not something a + // client can simply ask for. + expect(parseClientMessage({ type: "fleet.subscribe", id: "3", scope: "machine" }).ok).toBe(false); + }); + + test("without fleet:read the surface is forbidden", async () => { + const { client } = mkClient("c-noscope"); + const auth: AuthContext = { + ...AUTH, + scopes: ALL_SCOPES.filter((s) => s !== SCOPES.FLEET_READ) as AuthContext["scopes"], + }; + const resp = await manager.handle(subscribeMsg("1"), auth, client); + expect(resp.type).toBe("response.error"); + expect((resp as { error: string }).error).toContain("fleet:read"); + }); +}); + +describe("fleet.subscribe — snapshot", () => { + test("returns the tenant's tasks with lifecycle fields", async () => { + const taskId = enqueue(AUTH, { kind: "spawn" }); + const { client } = mkClient("c1"); + + const resp = await manager.handle(subscribeMsg("1"), AUTH, client); + expect(resp.type).toBe("fleet.snapshot.result"); + const fleet = snapshotOf(resp); + + expect(fleet.tasks).toHaveLength(1); + expect(fleet.tasks[0]).toMatchObject({ + id: taskId, + kind: "spawn", + shape: "scout", + status: "queued", + attempts: 0, + }); + }); + + test("NEVER puts the dispatch prompt or workdir on the wire", async () => { + enqueue(AUTH, { prompt: "SECRET-PROMPT-DO-NOT-LEAK" }); + const { client } = mkClient("c2"); + + const resp = await manager.handle(subscribeMsg("1"), AUTH, client); + const serialized = JSON.stringify(resp); + + // The board draws lifecycle. The prompt is the one field on a task row that + // carries arbitrary user content to every subscribed client. + expect(serialized).not.toContain("SECRET-PROMPT-DO-NOT-LEAK"); + const task = snapshotOf(resp).tasks[0]; + expect(task).not.toHaveProperty("prompt"); + expect(task).not.toHaveProperty("workdir"); + }); + + test("a tenant never sees another tenant's board", async () => { + enqueue(AUTH, { prompt: "mine" }); + enqueue(OTHER_AUTH, { prompt: "theirs" }); + const { client } = mkClient("c3"); + + const resp = await manager.handle(subscribeMsg("1"), AUTH, client); + const fleet = snapshotOf(resp); + + expect(fleet.tasks).toHaveLength(1); + expect(fleet.tasks[0].createdBy).toContain("acc-fleet"); + }); + + test("an empty board is a valid snapshot, not an error", async () => { + const { client } = mkClient("c4"); + const resp = await manager.handle(subscribeMsg("1"), AUTH, client); + + expect(resp.type).toBe("fleet.snapshot.result"); + const fleet = snapshotOf(resp); + expect(fleet.tasks).toEqual([]); + expect(fleet.workers).toEqual([]); + // No conductor session exists yet — a common, valid state, not a failure. + expect(fleet.conductor).toBeUndefined(); + expect(fleet.agg.activeTasks).toBe(0); + }); + + test("agg counts the WHOLE board, not just the page the snapshot ships", async () => { + // The rollup is computed in SQL over every row precisely so a tenant with + // more tasks than the page size doesn't under-report "active". + for (let i = 0; i < 12; i++) enqueue(AUTH); + const { client } = mkClient("c5"); + + const resp = await manager.handle(subscribeMsg("1"), AUTH, client); + const agg = snapshotOf(resp).agg; + expect(agg.activeTasks).toBe(12); + expect(store.dispatchStatusCounts(AUTH.accountId, AUTH.projectId).active).toBe(12); + }); +}); + +describe("fleet.update — the delta stream", () => { + test("a board change after subscribing is pushed to the subscriber", async () => { + const { client, updates } = mkClient("c6"); + await manager.handle(subscribeMsg("1"), AUTH, client); + expect(updates()).toHaveLength(0); + + // enqueue() signals the board change itself — this is the real path. + const taskId = enqueue(AUTH); + + const deltas = updates(); + expect(deltas.length).toBeGreaterThanOrEqual(1); + const task = deltas.find( + (d) => (d as { delta: { kind: string; task?: { id: string } } }).delta.task?.id === taskId, + ); + expect(task).toBeDefined(); + expect((task as { delta: { kind: string } }).delta.kind).toBe("task"); + }); + + test("a delta never crosses tenants", async () => { + const mine = mkClient("c7", AUTH); + const theirs = mkClient("c8", OTHER_AUTH); + await manager.handle(subscribeMsg("1"), AUTH, mine.client); + await manager.handle(subscribeMsg("2"), OTHER_AUTH, theirs.client); + + enqueue(AUTH, { prompt: "mine" }); + + expect(mine.updates().length).toBeGreaterThanOrEqual(1); + expect(theirs.updates()).toHaveLength(0); + }); + + test("the watermark advances — a later change re-sends only what moved", async () => { + const { client, updates } = mkClient("c9"); + await manager.handle(subscribeMsg("1"), AUTH, client); + + const first = enqueue(AUTH, { prompt: "first" }); + const sawFirst = updates().length; + expect(sawFirst).toBeGreaterThanOrEqual(1); + + // A second enqueue must push the SECOND task only. Without an advancing + // watermark every flush would re-broadcast the whole board, so this count + // would grow quadratically as the board fills. + const second = enqueue(AUTH, { prompt: "second" }); + const newDeltas = updates() + .slice(sawFirst) + .map((m) => (m as { delta: { task?: { id: string } } }).delta.task?.id) + .filter((id): id is string => id !== undefined); + + expect(newDeltas).toContain(second); + expect(newDeltas).not.toContain(first); + }); + + test("fleet.unsubscribe stops the stream", async () => { + const { client, updates } = mkClient("c10"); + await manager.handle(subscribeMsg("1"), AUTH, client); + + const unsub = parseClientMessage({ type: "fleet.unsubscribe", id: "2" }); + if (!unsub.ok) throw new Error("unsubscribe rejected"); + const resp = await manager.handle(unsub.value, AUTH, client); + expect(resp.type).toBe("response.ok"); + + enqueue(AUTH); + expect(updates()).toHaveLength(0); + }); + + test("a disconnected client is reaped, not pushed at forever", async () => { + const { client, updates } = mkClient("c11"); + await manager.handle(subscribeMsg("1"), AUTH, client); + + manager.disconnectClient(client.id); + + enqueue(AUTH); + expect(updates()).toHaveLength(0); + }); + + test("with no subscribers a board change costs nothing", () => { + // Must not throw and must not need a watermark — the dispatcher signals on + // EVERY tick, including on a daemon nobody is watching. + expect(() => enqueue(AUTH)).not.toThrow(); + }); +}); diff --git a/src/tests/protocol.test.ts b/src/tests/protocol.test.ts index 03d41fd..a3e532e 100644 --- a/src/tests/protocol.test.ts +++ b/src/tests/protocol.test.ts @@ -456,6 +456,10 @@ describe("DaemonMessage routing", () => { return `pipeline.list:${msg.pipelines.length}`; case "pipeline.pack.list.result": return `pack.list:${msg.installed.length}/${msg.available.length}`; + case "fleet.snapshot.result": + return `fleet:${msg.fleet.tasks.length}`; + case "fleet.update": + return `fleet.update:${msg.delta.kind}`; } }; diff --git a/src/tests/scopes.test.ts b/src/tests/scopes.test.ts index 2b2f2ae..d3750a2 100644 --- a/src/tests/scopes.test.ts +++ b/src/tests/scopes.test.ts @@ -15,8 +15,8 @@ import { } from "../protocol/scopes.js"; describe("SCOPES constants", () => { - test("all 17 scopes are defined", () => { - expect(Object.keys(SCOPES)).toHaveLength(17); + test("all 18 scopes are defined", () => { + expect(Object.keys(SCOPES)).toHaveLength(18); expect(SCOPES.SESSION_CREATE).toBe("session:create"); expect(SCOPES.SESSION_ATTACH).toBe("session:attach"); expect(SCOPES.SESSION_WATCH).toBe("session:watch"); @@ -27,6 +27,7 @@ describe("SCOPES constants", () => { expect(SCOPES.SESSION_LIST).toBe("session:list"); expect(SCOPES.SESSION_READ).toBe("session:read"); expect(SCOPES.SESSION_DISPATCH).toBe("session:dispatch"); + expect(SCOPES.FLEET_READ).toBe("fleet:read"); expect(SCOPES.FS_READ).toBe("fs:read"); expect(SCOPES.SETTINGS_READ).toBe("settings:read"); expect(SCOPES.SETTINGS_WRITE).toBe("settings:write"); @@ -36,8 +37,8 @@ describe("SCOPES constants", () => { expect(SCOPES.PIPELINE_MANAGE).toBe("pipeline:manage"); }); - test("ALL_SCOPES contains all 17", () => { - expect(ALL_SCOPES).toHaveLength(17); + test("ALL_SCOPES contains all 18", () => { + expect(ALL_SCOPES).toHaveLength(18); for (const scope of Object.values(SCOPES)) { expect(ALL_SCOPES).toContain(scope); } @@ -45,7 +46,7 @@ describe("SCOPES constants", () => { test("ALL_SCOPES_STRING is space-delimited", () => { const parts = ALL_SCOPES_STRING.split(" "); - expect(parts).toHaveLength(17); + expect(parts).toHaveLength(18); for (const scope of ALL_SCOPES) { expect(parts).toContain(scope); }