diff --git a/web/src/state/connection.ts b/web/src/state/connection.ts index 5d7a500..7a2e600 100644 --- a/web/src/state/connection.ts +++ b/web/src/state/connection.ts @@ -40,6 +40,7 @@ import { } from "./messages"; import { noteLiveSeq, noteReplayFrame } from "./resume"; import { addUiRequest, removeUiRequest } from "./ui-requests"; +import { ingestFleetDelta } from "./fleet"; // Resolve the daemon WebSocket URL: // 1. explicit VITE_CODEOID_URL build override, else @@ -361,6 +362,9 @@ function routeBroadcast(msg: DaemonMessage): void { case "session.ui_request": addUiRequest(msg); return; + case "fleet.update": + ingestFleetDelta(msg.delta); + return; case "session.ui_resolved": // Authoritative dismiss — fires whether WE answered, another client // did, the request timed out, or the turn was interrupted. @@ -374,6 +378,9 @@ function routeBroadcast(msg: DaemonMessage): void { // frame to onMessage handlers even after resolving the pending request, // so ingesting here again double-applied every refresh. case "session.list.result": + // Solicited: the reply to `fleet.subscribe`, already ingested on the + // request path by subscribeFleet. + case "fleet.snapshot.result": case "auth.ok": case "response.ok": case "response.error": diff --git a/web/src/state/fleet.test.ts b/web/src/state/fleet.test.ts new file mode 100644 index 0000000..cd9ba52 --- /dev/null +++ b/web/src/state/fleet.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from "vitest"; + +import { + applyDelta, + applySnapshot, + EMPTY_FLEET, + EMPTY_USAGE, + FLEET_EVENT_LIMIT, + FLEET_TASK_LIMIT, + taskSession, + upsertEvent, + upsertTask, + type FleetState, +} from "./fleet"; +import type { + FleetEventWire, + FleetSnapshot, + FleetTaskWire, + FleetUsage, + SessionInfo, +} from "../protocol/types"; + +function task(id: string, createdAt: number, over: Partial = {}): FleetTaskWire { + return { + id, + kind: "spawn", + shape: "scout", + status: "queued", + attempts: 0, + createdAt, + createdBy: "agent:conductor", + ...over, + }; +} + +function event(id: number, over: Partial = {}): FleetEventWire { + return { id, taskId: "t1", type: "task_done", digest: "d", createdAt: 1_000, ...over }; +} + +const usage = (over: Partial = {}): FleetUsage => ({ ...EMPTY_USAGE, ...over }); + +describe("upsertTask", () => { + it("replaces IN PLACE so a status change does not move the row", () => { + // createdAt never changes, so a running→done transition must not make the + // row you are reading jump position. + const rows = [task("c", 300), task("b", 200), task("a", 100)]; + const next = upsertTask(rows, task("b", 200, { status: "done" })); + expect(next.map((t) => t.id)).toEqual(["c", "b", "a"]); + expect(next[1]!.status).toBe("done"); + expect(rows[1]!.status).toBe("queued"); // input untouched + }); + + it("inserts by createdAt rather than assuming deltas arrive newest-last", () => { + // "Newest arrives last" is an assumption about the network, not a + // guarantee — a reconnect can replay and a burst can interleave. + const rows = [task("c", 300), task("a", 100)]; + expect(upsertTask(rows, task("b", 200)).map((t) => t.id)).toEqual(["c", "b", "a"]); + expect(upsertTask(rows, task("d", 400)).map((t) => t.id)).toEqual(["d", "c", "a"]); + expect(upsertTask(rows, task("z", 50)).map((t) => t.id)).toEqual(["c", "a", "z"]); + }); + + it("caps the list by dropping the OLDEST rows", () => { + const rows = Array.from({ length: FLEET_TASK_LIMIT }, (_, i) => + task(`t${i}`, 10_000 - i), + ); + const next = upsertTask(rows, task("newest", 99_999)); + expect(next).toHaveLength(FLEET_TASK_LIMIT); + expect(next[0]!.id).toBe("newest"); + expect(next.some((t) => t.id === `t${FLEET_TASK_LIMIT - 1}`)).toBe(false); + }); + + it("does not grow past the cap when replacing an existing row", () => { + const rows = Array.from({ length: FLEET_TASK_LIMIT }, (_, i) => task(`t${i}`, 10_000 - i)); + const next = upsertTask(rows, task("t5", 9_995, { status: "done" })); + expect(next).toHaveLength(FLEET_TASK_LIMIT); + }); +}); + +describe("upsertEvent", () => { + it("orders by autoincrement id, not timestamp", () => { + // A dispatcher tick settles several events in the SAME millisecond; + // ordering those by createdAt would shuffle them between renders. + const rows = [event(3, { createdAt: 5 }), event(1, { createdAt: 5 })]; + expect(upsertEvent(rows, event(2, { createdAt: 5 })).map((e) => e.id)).toEqual([3, 2, 1]); + }); + + it("caps at the event limit", () => { + const rows = Array.from({ length: FLEET_EVENT_LIMIT }, (_, i) => event(FLEET_EVENT_LIMIT - i)); + const next = upsertEvent(rows, event(9_999)); + expect(next).toHaveLength(FLEET_EVENT_LIMIT); + expect(next[0]!.id).toBe(9_999); + }); +}); + +describe("applyDelta", () => { + it("replaces agg wholesale rather than recomputing from the capped list", () => { + // The rollup counts tasks that may have aged off this board, so a locally + // derived count would drift low on a long-lived session. + const next = applyDelta(EMPTY_FLEET, { + kind: "task", + task: task("a", 1), + agg: usage({ activeTasks: 7, blockedTasks: 2, totalCostUsd: 1.5 }), + }); + expect(next.agg.activeTasks).toBe(7); + expect(next.agg.blockedTasks).toBe(2); + expect(next.tasks.map((t) => t.id)).toEqual(["a"]); + }); + + it("applies an event delta without disturbing tasks", () => { + const withTask = applyDelta(EMPTY_FLEET, { kind: "task", task: task("a", 1), agg: usage() }); + const next = applyDelta(withTask, { kind: "event", event: event(1), agg: usage({ activeTasks: 1 }) }); + expect(next.tasks.map((t) => t.id)).toEqual(["a"]); + expect(next.events.map((e) => e.id)).toEqual([1]); + expect(next.agg.activeTasks).toBe(1); + }); + + it("is idempotent — a redelivered delta does not duplicate a row", () => { + // The daemon's watermark is exactly-once by design, but a reconnect + // re-snapshot plus an in-flight delta can still repeat one. + const d = { kind: "task", task: task("a", 1), agg: usage() } as const; + const once = applyDelta(EMPTY_FLEET, d); + const twice = applyDelta(once, d); + expect(twice.tasks).toHaveLength(1); + }); +}); + +describe("applySnapshot", () => { + const snapshot = (over: Partial = {}): FleetSnapshot => ({ + workers: [], + tasks: [], + events: [], + agg: usage(), + ...over, + }); + + it("takes the daemon's ordering and stamps fetchedAt", () => { + const next = applySnapshot( + EMPTY_FLEET, + snapshot({ tasks: [task("b", 2), task("a", 1)], agg: usage({ activeTasks: 3 }) }), + 1_234, + ); + expect(next.tasks.map((t) => t.id)).toEqual(["b", "a"]); + expect(next.agg.activeTasks).toBe(3); + expect(next.fetchedAt).toBe(1_234); + expect(next.loading).toBe(false); + expect(next.error).toBeNull(); + }); + + it("treats a missing conductor as a valid state, not an error", () => { + expect(applySnapshot(EMPTY_FLEET, snapshot(), 1).conductor).toBeNull(); + }); + + it("clears a previous error so a recovered subscribe does not keep showing it", () => { + const failed: FleetState = { ...EMPTY_FLEET, error: "boom", loading: true }; + const next = applySnapshot(failed, snapshot(), 1); + expect(next.error).toBeNull(); + expect(next.loading).toBe(false); + }); + + it("caps an over-large snapshot rather than trusting the page size", () => { + // A future daemon raising its limit must not raise this client's ceiling. + const next = applySnapshot( + EMPTY_FLEET, + snapshot({ + tasks: Array.from({ length: FLEET_TASK_LIMIT + 25 }, (_, i) => task(`t${i}`, 9_999 - i)), + events: Array.from({ length: FLEET_EVENT_LIMIT + 10 }, (_, i) => event(9_999 - i)), + }), + 1, + ); + expect(next.tasks).toHaveLength(FLEET_TASK_LIMIT); + expect(next.events).toHaveLength(FLEET_EVENT_LIMIT); + }); +}); + +describe("taskSession", () => { + const worker = { id: "w1", name: "worker-scout-abc" } as SessionInfo; + const base: FleetState = { ...EMPTY_FLEET, workers: [worker] }; + + it("resolves a spawn worker and a send target through the same join", () => { + expect(taskSession(base, task("a", 1, { workerSessionId: "w1" }))?.id).toBe("w1"); + expect(taskSession(base, task("b", 1, { targetSession: "w1" }))?.id).toBe("w1"); + }); + + it("returns null for an unjoinable task rather than inventing a row", () => { + // A queued spawn has no worker yet, and a target can be destroyed while + // its task is still on the board. + expect(taskSession(base, task("c", 1))).toBeNull(); + expect(taskSession(base, task("d", 1, { workerSessionId: "gone" }))).toBeNull(); + }); +}); diff --git a/web/src/state/fleet.ts b/web/src/state/fleet.ts new file mode 100644 index 0000000..04d2134 --- /dev/null +++ b/web/src/state/fleet.ts @@ -0,0 +1,225 @@ +/** + * Fleet board slice — the conductor's task board, live. + * + * `fleet.subscribe` replies with a snapshot and then streams `fleet.update` + * deltas (P5.0). Daemon-canonical like every other slice here: nothing is + * derived locally, and a delta is applied verbatim rather than reconciled + * against a guess. + * + * The reducers are exported as PURE functions and hold all the ordering and + * bounding rules, so the part worth testing needs no reactive root — the same + * split `lib/fleet.ts` uses for grouping. The signal layer below is a thin + * shell over them. + */ + +import { createSignal } from "solid-js"; + +import { getClient, newRequestId, send } from "./connection"; +import type { + FleetDelta, + FleetEventWire, + FleetSnapshot, + FleetSnapshotResultMsg, + FleetTaskWire, + FleetUsage, + SessionInfo, +} from "../protocol/types"; + +/** + * Client-side caps, matching the daemon's own board page sizes + * (`FLEET_TASK_LIMIT` / `FLEET_EVENT_LIMIT` in session-manager.ts). + * + * The snapshot is bounded, but the delta stream is not: a board left open for a + * day would otherwise grow without limit, since every task transition and every + * digest appends. Capping to the same window the daemon would have sent keeps a + * long-lived board the same size as a freshly-subscribed one — which also means + * a reconnect cannot silently change how much history is on screen. + */ +export const FLEET_TASK_LIMIT = 100; +export const FLEET_EVENT_LIMIT = 50; + +export const EMPTY_USAGE: FleetUsage = { + activeTasks: 0, + blockedTasks: 0, + inputTokens: 0, + outputTokens: 0, + totalCostUsd: 0, +}; + +export interface FleetState { + /** True between a successful subscribe and an explicit unsubscribe. */ + subscribed: boolean; + loading: boolean; + error: string | null; + /** Absent when the tenant has no conductor — a valid, common state. */ + conductor: SessionInfo | null; + /** Spawned workers AND existing sessions dispatched to. Join target for task ids. */ + workers: SessionInfo[]; + /** Newest first. */ + tasks: FleetTaskWire[]; + /** Newest first. */ + events: FleetEventWire[]; + agg: FleetUsage; + /** Epoch ms of the last snapshot; 0 = never loaded. */ + fetchedAt: number; +} + +export const EMPTY_FLEET: FleetState = { + subscribed: false, + loading: false, + error: null, + conductor: null, + workers: [], + tasks: [], + events: [], + agg: EMPTY_USAGE, + fetchedAt: 0, +}; + +// ── pure reducers ──────────────────────────────────────────────────────────── + +/** + * Insert or replace `row` in a list held in DESCENDING `key` order. + * + * Replace is IN PLACE: a task's `createdAt` never changes, so a status + * transition must not make the row jump while you are looking at it. Insert + * finds the position by key rather than unshifting, because "deltas arrive + * newest-last" is an assumption about the network, not a guarantee — a + * reconnect can replay, and a burst can interleave. + */ +function upsertDesc(rows: readonly T[], row: T, id: (r: T) => string | number, key: (r: T) => number, cap: number): T[] { + const rowId = id(row); + const at = rows.findIndex((r) => id(r) === rowId); + if (at >= 0) { + const next = rows.slice(); + next[at] = row; + return next; + } + const k = key(row); + const insertAt = rows.findIndex((r) => key(r) < k); + const next = rows.slice(); + next.splice(insertAt === -1 ? next.length : insertAt, 0, row); + // Drop from the OLD end: the newest rows are the ones a board is for. + return next.length > cap ? next.slice(0, cap) : next; +} + +export function upsertTask(tasks: readonly FleetTaskWire[], task: FleetTaskWire): FleetTaskWire[] { + return upsertDesc(tasks, task, (t) => t.id, (t) => t.createdAt, FLEET_TASK_LIMIT); +} + +export function upsertEvent(events: readonly FleetEventWire[], event: FleetEventWire): FleetEventWire[] { + // Keyed on the autoincrement id, not createdAt: several events routinely + // land in the same millisecond (one dispatcher tick settling a group), and + // ordering those by time would shuffle them arbitrarily between renders. + return upsertDesc(events, event, (e) => e.id, (e) => e.id, FLEET_EVENT_LIMIT); +} + +/** Fold one delta into the board. Pure — the whole ordering contract lives here. */ +export function applyDelta(state: FleetState, delta: FleetDelta): FleetState { + // `agg` rides on every delta and is a daemon-computed rollup, so it is + // replaced wholesale rather than recomputed from the (capped) task list — + // the counts must stay true even for tasks that have aged off this board. + if (delta.kind === "task") { + return { ...state, tasks: upsertTask(state.tasks, delta.task), agg: delta.agg }; + } + return { ...state, events: upsertEvent(state.events, delta.event), agg: delta.agg }; +} + +/** Replace the board from a snapshot, trusting the daemon's ordering. */ +export function applySnapshot(state: FleetState, fleet: FleetSnapshot, now: number): FleetState { + return { + ...state, + loading: false, + error: null, + conductor: fleet.conductor ?? null, + workers: fleet.workers, + // Defensive slice: a future daemon that raises its page size must not + // silently raise this client's memory ceiling too. + tasks: fleet.tasks.slice(0, FLEET_TASK_LIMIT), + events: fleet.events.slice(0, FLEET_EVENT_LIMIT), + agg: fleet.agg, + fetchedAt: now, + }; +} + +/** The session a task points at, resolved against the board's own worker list. */ +export function taskSession(state: FleetState, task: FleetTaskWire): SessionInfo | null { + const id = task.workerSessionId ?? task.targetSession; + if (!id) return null; + return state.workers.find((w) => w.id === id) ?? null; +} + +// ── signal layer ───────────────────────────────────────────────────────────── + +const [state, setState] = createSignal(EMPTY_FLEET); + +export const fleetBoard = state; + +/** + * Which subscription each async reply belongs to. + * + * Bumped on every subscribe and unsubscribe, and compared on arrival, so a + * snapshot for a subscription the user has already left cannot overwrite a + * newer board — the same guard `blackboard.ts` uses for its index fetch. + */ +let generation = 0; + +/** + * Subscribe to the tenant's board: snapshot now, deltas after. + * + * Safe to call when already subscribed — the daemon treats a second + * `fleet.subscribe` as a re-snapshot, which is also how a reconnecting client + * resynchronises after missing deltas. + */ +export async function subscribeFleet(): Promise { + const gen = ++generation; + setState((s) => ({ ...s, loading: true, error: null })); + try { + const id = newRequestId(); + const result = await getClient().request( + { type: "fleet.subscribe", id, scope: "tenant" }, + { + waitForResult: (m) => + m.type === "fleet.snapshot.result" && m.requestId === id ? m : undefined, + timeoutMs: 8_000, + }, + ); + if (gen !== generation) return; + setState((s) => ({ ...applySnapshot(s, result.fleet, Date.now()), subscribed: true })); + } catch (err) { + if (gen !== generation) return; + setState((s) => ({ ...s, loading: false, subscribed: false, error: message(err) })); + } +} + +/** + * Stop the delta stream. + * + * The board is deliberately KEPT. Leaving the conductor pane should not blank + * what you just read, and the next subscribe re-snapshots anyway — clearing + * here would only produce a flash of empty state on every visit. + */ +export function unsubscribeFleet(): void { + generation++; + if (!state().subscribed) return; + send({ type: "fleet.unsubscribe", id: newRequestId() }); + setState((s) => ({ ...s, subscribed: false })); +} + +/** Route a `fleet.update` broadcast into the board. */ +export function ingestFleetDelta(delta: FleetDelta): void { + // A delta arriving while unsubscribed is not an error: the daemon may still + // have one in flight from just before the unsubscribe. Applying it is + // harmless and keeps the retained board (see unsubscribeFleet) accurate. + setState((s) => applyDelta(s, delta)); +} + +/** Drop everything — used on sign-out, where the next user's board must not inherit this one. */ +export function resetFleet(): void { + generation++; + setState(EMPTY_FLEET); +} + +function message(err: unknown): string { + return err instanceof Error ? err.message : String(err); +}