diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index f81a351..2f92702 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -106,6 +106,15 @@ const samples: { [T in ClientTypes]: Extract } = { "fs.read": { type: "fs.read", id: "r18", sessionId: "s1", path: "src/a.ts", maxBytes: 1024 }, "fs.browse_dir": { type: "fs.browse_dir", id: "r19", path: "/home" }, "claude.config": { type: "claude.config", id: "r20", sessionId: "s1" }, + "blackboard.index": { type: "blackboard.index", id: "r60", sessionId: "s1" }, + "blackboard.read": { + type: "blackboard.read", + id: "r61", + sessionId: "s1", + kind: "findings", + slot: "review#2", + version: 3, + }, "models.list": { type: "models.list", id: "r21", provider: "claude" }, "session.export": { type: "session.export", id: "r22", sessionId: "s1", includeMemory: true, toFile: false }, "session.import": { diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 84132a4..f8c1362 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -342,6 +342,27 @@ export const claudeConfigSchema = z.object({ sessionId: sessionIdField, }); +/** + * Goal-blackboard inspection. `kind` is a bounded string rather than an enum + * for the same reason the role scoping fields are: `extra/` is an open + * namespace, and the daemon's `isValidArtifactKind` gives a specific error + * naming the valid core kinds instead of an opaque schema rejection. + */ +export const blackboardIndexSchema = z.object({ + ...base, + type: z.literal("blackboard.index"), + sessionId: sessionIdField, +}); + +export const blackboardReadSchema = z.object({ + ...base, + type: z.literal("blackboard.read"), + sessionId: sessionIdField, + kind: z.string().min(1).max(64), + slot: z.string().min(1).max(128).nullish(), + version: z.number().int().positive().optional(), +}); + export const modelsListSchema = z.object({ ...base, type: z.literal("models.list"), @@ -587,6 +608,8 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ fsReadSchema, fsBrowseDirSchema, claudeConfigSchema, + blackboardIndexSchema, + blackboardReadSchema, modelsListSchema, sessionExportSchema, sessionImportSchema, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 1169813..f4efbf7 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -79,6 +79,13 @@ export const CAPABILITIES = { * ids, never tool args) when one of that user's sessions blocks on approval. */ PUSH: "push", + /** + * Goal-blackboard inspection (`blackboard.index` / `blackboard.read`). + * Declared by the daemon; clients feature-detect before offering an artifact + * panel, so an older daemon simply doesn't grow the affordance rather than + * showing one that errors on click. + */ + BLACKBOARD: "blackboard", } as const; export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES]; @@ -812,6 +819,8 @@ export type ClientMessage = | FsReadMsg | FsBrowseDirMsg | ClaudeConfigMsg + | BlackboardIndexMsg + | BlackboardReadMsg | ModelsListMsg | SessionExportMsg | SessionImportMsg @@ -1416,6 +1425,42 @@ export interface ClaudeConfigMsg extends BaseClientMsg { sessionId: string; } +/** + * List what a collaboration's goal blackboard holds — kind, slot, version, + * author, size — without any artifact bodies + * (docs/collaborative-session-design.md §4). + * + * `sessionId` may name either the orchestrator or any of its role-children; + * the daemon resolves a child to its parent's goal. Every client would + * otherwise have to duplicate that hop, and getting it wrong yields a + * confusing empty board rather than an error. + */ +export interface BlackboardIndexMsg extends BaseClientMsg { + type: "blackboard.index"; + sessionId: string; +} + +/** + * Read one artifact body from a collaboration's goal blackboard. + * + * Unlike the agent-facing `blackboard_read` MCP tool, this is NOT filtered by + * a role's read scope: role scoping exists to keep the agents independent of + * each other (§6 — a reviewer that can read its peers is an echo, not a + * panel), which says nothing about the human who owns the goal and can already + * read every child's transcript. + */ +export interface BlackboardReadMsg extends BaseClientMsg { + type: "blackboard.read"; + sessionId: string; + /** A core kind (`spec`, `research`, …) or `extra/`. */ + kind: string; + /** Multi-writer slot as reported by the index; omit for the singleton. */ + slot?: string | null; + /** A specific version; omit for the latest. Writes never overwrite, so + * older versions stay readable for auditing a handoff after the fact. */ + version?: number; +} + /** * Ask the daemon for the model catalog the Claude Code backend actually * supports (via the SDK's `supportedModels()`), rather than a hardcoded @@ -1624,6 +1669,56 @@ export interface ClaudeConfigResultMsg { hooks: ClaudeConfigHook[]; } +/** One index row: what exists, at what version, by whom — never a body. */ +export interface BlackboardIndexEntry { + /** A core kind (`spec`, `research`, …) or `extra/`. */ + kind: string; + /** Multi-writer discriminator (`review#2`); null = the singleton slot. */ + slot: string | null; + /** Latest version present. Writes append, so this only ever grows. */ + version: number; + /** ZeroID subject of the producing agent. */ + authorSub: string; + /** Collaboration role that wrote it, when written by a role-child. */ + authorRole: string | null; + /** Epoch ms of the latest version. */ + updatedAt: number; + /** Body size of the latest version, so a client can warn before fetching. */ + bytes: number; +} + +export interface BlackboardIndexResultMsg { + type: "blackboard.index.result"; + requestId: string; + /** The GOAL session — the orchestrator, even when a child was asked for. */ + sessionId: string; + /** The collaboration's goal text, for labelling the board. */ + goal: string; + entries: BlackboardIndexEntry[]; +} + +/** One artifact version, body included. */ +export interface BlackboardArtifact { + kind: string; + slot: string | null; + version: number; + content: string; + authorSub: string; + authorRole: string | null; + createdAt: number; +} + +export interface BlackboardReadResultMsg { + type: "blackboard.read.result"; + requestId: string; + /** The GOAL session — the orchestrator, even when a child was asked for. */ + sessionId: string; + /** Null when nothing has been written to that kind/slot/version yet. Not an + * error: "the searcher hasn't produced research yet" is a normal state of a + * collaboration in flight, and clients render it as pending. */ + artifact: BlackboardArtifact | null; +} + export interface ModelsListResultMsg { type: "models.list.result"; requestId: string; @@ -1993,6 +2088,8 @@ export type DaemonMessage = | FsReadResultMsg | FsBrowseDirResultMsg | ClaudeConfigResultMsg + | BlackboardIndexResultMsg + | BlackboardReadResultMsg | ModelsListResultMsg | SessionExportResultMsg | SessionImportResultMsg diff --git a/src/daemon/blackboard/service.ts b/src/daemon/blackboard/service.ts index 7ed8a05..2f0520e 100644 --- a/src/daemon/blackboard/service.ts +++ b/src/daemon/blackboard/service.ts @@ -205,6 +205,52 @@ export class RoleBlackboard { } } +/** + * The goal owner's view: the whole board, no role filter. + * + * This is the ONE handle that is not `reads`/`writes`-scoped, so it is a + * separate class rather than a flag on `RoleBlackboard` — a flag would make + * "unscoped" reachable by passing a boolean, and it must instead take an + * explicit `forOwner()` that grep finds. + * + * Why the exemption is sound rather than a hole in §6: role scoping keeps the + * AGENTS independent of each other (a reviewer that can read its peers is an + * echo, not a panel). The human who created the collaboration is not a + * participant in it — they can already read every child's transcript, so + * withholding the artifacts those transcripts produced protects nothing and + * only makes the fleet unobservable. Tenant scoping still applies in full; the + * caller must prove ownership of the goal session before getting here. + * + * Read-only by construction: there is no `write`. Owner writes would enter the + * board with no role attribution and no scope check, and nothing needs them. + */ +export class OwnerBlackboard { + #store: BlackboardStore; + #scope: GoalScope; + + constructor(store: BlackboardStore, scope: GoalScope) { + this.#store = store; + this.#scope = scope; + } + + /** Every artifact at its latest version — kind, slot, size. No bodies. */ + index(): ArtifactIndexEntry[] { + return this.#store.index(this.#scope); + } + + /** + * One artifact body. `version` omitted = latest; a specific version reads + * back a superseded one, which is the point of the append-only contract. + * `null` = never written (a normal state mid-collaboration), not an error. + */ + read(kind: string, slot?: string | null, version?: number): Artifact | null { + if (!isValidArtifactKind(kind)) return null; + return version === undefined + ? this.#store.latest(this.#scope, kind, slot ?? null) + : this.#store.version(this.#scope, kind, version, slot ?? null); + } +} + /** The daemon-owned blackboard: one store, many goal-and-role-scoped views. */ export class Blackboard { #store: BlackboardStore; @@ -213,6 +259,15 @@ export class Blackboard { this.#store = store; } + /** + * The goal owner's unscoped-by-role view. Callers must have already checked + * that the requester owns `scope.goalSessionId`; this class does not and + * cannot verify that itself. + */ + forOwner(scope: GoalScope): OwnerBlackboard { + return new OwnerBlackboard(this.#store, scope); + } + /** * A role's handle on one goal. * diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 110b054..8f06693 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -60,6 +60,7 @@ const SERVER_CAPABILITIES: string[] = [ CAPABILITIES.SEND_IDEMPOTENCY, CAPABILITIES.UI_DIALOGS, CAPABILITIES.DYNAMIC_COMMANDS, + CAPABILITIES.BLACKBOARD, ]; /** diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 49af9d8..b1233f7 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -38,7 +38,7 @@ import { resolveAgainstList, resolveModelIdForProvider, } from "./models.js"; -import { Blackboard } from "./blackboard/service.js"; +import { Blackboard, type OwnerBlackboard } from "./blackboard/service.js"; import { BlackboardStore } from "./blackboard/store.js"; import { BlackboardMcpHttp } from "./blackboard/mcp-http.js"; import { @@ -674,6 +674,10 @@ mcpHub: this.#mcpHub, return this.#fsBrowseDir(msg, auth); case "claude.config": return this.#claudeConfig(msg, auth); + case "blackboard.index": + return this.#blackboardIndex(msg, auth); + case "blackboard.read": + return this.#blackboardRead(msg, auth); case "models.list": return this.#modelsList(msg); case "session.export": @@ -1310,6 +1314,145 @@ mcpHub: this.#mcpHub, } } + // ---------- goal blackboard (owner-facing) ---------- + + /** + * Resolve a client-supplied session id to the collaboration goal it belongs + * to, or an error message naming why it doesn't belong to one. + * + * Accepts either the orchestrator or one of its role-children. Every client + * would otherwise have to walk `collaborationRole.parentSessionId` itself, + * and a client that got it wrong would see an EMPTY board rather than an + * error — the least debuggable possible outcome. + * + * The parent is re-fetched through `#getOwnedSession`, not trusted from the + * child's field: the ownership check must be made against the session whose + * artifacts are about to be read, not against the one that named it. + */ + #resolveGoalSession( + sessionId: string, + auth: AuthContext, + ): { ok: true; goal: Session } | { ok: false; error: string; code: "not_found" | "invalid_request" } { + const session = this.#getOwnedSession(sessionId, auth); + if (!session) return { ok: false, error: "Session not found", code: "not_found" }; + if (session.collaboration) return { ok: true, goal: session }; + + const parentId = session.collaborationRole?.parentSessionId; + if (!parentId) { + return { + ok: false, + error: `Session "${session.name}" is not part of a collaboration — it has no goal blackboard`, + code: "invalid_request", + }; + } + const parent = this.#getOwnedSession(parentId, auth); + if (!parent?.collaboration) { + // The orchestrator was destroyed but this child hasn't finished draining. + // Teardown drops the artifacts with the goal, so there is nothing to show. + return { + ok: false, + error: "The orchestrator for this role-child is gone; its blackboard was torn down with it", + code: "not_found", + }; + } + return { ok: true, goal: parent }; + } + + #ownerBlackboard(goal: Session): OwnerBlackboard { + return this.#goalBlackboard().forOwner({ + accountId: goal.accountId, + projectId: goal.projectId, + goalSessionId: goal.id, + }); + } + + /** + * The board's contents at a glance. Gated on `session:list` rather than a new + * scope: an index is metadata about a session the holder can already + * enumerate, it carries no bodies, and inventing a scope would 403 every + * token minted before this shipped. + */ + #blackboardIndex( + msg: Extract, + auth: AuthContext, + ): DaemonMessage { + if (!hasScope(auth.scopes as string[], SCOPES.SESSION_LIST)) { + return { + type: "response.error", + requestId: msg.id, + error: "Missing scope: session:list", + code: "forbidden", + }; + } + const resolved = this.#resolveGoalSession(msg.sessionId, auth); + if (!resolved.ok) { + return { + type: "response.error", + requestId: msg.id, + error: resolved.error, + code: resolved.code, + }; + } + return { + type: "blackboard.index.result", + requestId: msg.id, + sessionId: resolved.goal.id, + goal: resolved.goal.collaboration?.goal ?? "", + entries: this.#ownerBlackboard(resolved.goal).index(), + }; + } + + /** + * One artifact body. Gated on `session:watch` — a body is session CONTENT, + * the same class as the streamed output a watcher already receives, and a + * step above the `session:list` metadata the index exposes. + */ + #blackboardRead( + msg: Extract, + auth: AuthContext, + ): DaemonMessage { + if (!hasScope(auth.scopes as string[], SCOPES.SESSION_WATCH)) { + return { + type: "response.error", + requestId: msg.id, + error: "Missing scope: session:watch", + code: "forbidden", + }; + } + const resolved = this.#resolveGoalSession(msg.sessionId, auth); + if (!resolved.ok) { + return { + type: "response.error", + requestId: msg.id, + error: resolved.error, + code: resolved.code, + }; + } + const found = this.#ownerBlackboard(resolved.goal).read( + msg.kind, + msg.slot ?? null, + msg.version, + ); + return { + type: "blackboard.read.result", + requestId: msg.id, + sessionId: resolved.goal.id, + // `id`/`goalSessionId` are dropped: the id is an internal row key with no + // client use, and the goal is already on the envelope. + artifact: found + ? { + kind: found.kind, + slot: found.slot, + version: found.version, + content: found.content, + authorSub: found.authorSub, + authorRole: found.authorRole, + createdAt: found.createdAt, + } + : null, + }; + } + #fsErr(requestId: string, err: unknown): DaemonMessage { if (err instanceof FsAccessError) { return { type: "response.error", requestId, error: err.message, code: err.code }; diff --git a/src/tests/blackboard.test.ts b/src/tests/blackboard.test.ts index f95fcf2..436a7d2 100644 --- a/src/tests/blackboard.test.ts +++ b/src/tests/blackboard.test.ts @@ -316,3 +316,69 @@ describe("the default profile wires the §3 handoff chain", () => { expect(bb.forRole(GOAL, ident("review")).read("research").ok).toBe(false); }); }); + +// ── Service: the owner's view ─────────────────────────────────────────────── + +// The one handle that is not role-scoped. Its job is to be complete WITHOUT +// becoming a bypass: it must still be tenant-scoped, and it must not be able +// to write (an owner write would land with no role attribution and no scope +// check on a board whose whole contract is attributable handoffs). +describe("the goal owner's view", () => { + test("reads across every role's lane, which no single role can", () => { + bb.forRole(GOAL, ident("orchestrator")).write("spec", "SPEC"); + bb.forRole(GOAL, ident("search")).write("research", "RESEARCH"); + bb.forRole(GOAL, ident("reasoning")).write("diff", "DIFF"); + + const owner = bb.forOwner(GOAL); + expect(owner.read("spec")?.content).toBe("SPEC"); + expect(owner.read("research")?.content).toBe("RESEARCH"); + expect(owner.read("diff")?.content).toBe("DIFF"); + + // No role can do that: the reasoner reaches `diff` but never `research`. + expect(bb.forRole(GOAL, ident("reasoning")).read("research").ok).toBe(false); + }); + + test("still cannot cross a tenant or project boundary", () => { + bb.forRole(GOAL, ident("search")).write("research", "MINE"); + expect(bb.forOwner(OTHER_TENANT).read("research")).toBeNull(); + expect(bb.forOwner(OTHER_PROJECT).read("research")).toBeNull(); + expect(bb.forOwner(OTHER_TENANT).index()).toHaveLength(0); + }); + + test("reaches each reviewer's own slot separately", () => { + bb.forRole(GOAL, ident("review", 1)).write("findings", "FIRST"); + bb.forRole(GOAL, ident("review", 2)).write("findings", "SECOND"); + + const owner = bb.forOwner(GOAL); + expect(owner.read("findings", "review")?.content).toBe("FIRST"); + expect(owner.read("findings", "review#2")?.content).toBe("SECOND"); + }); + + test("reads a superseded version, so the append-only history is inspectable", () => { + const orch = bb.forRole(GOAL, ident("orchestrator")); + orch.write("spec", "v1"); + orch.write("spec", "v2"); + + const owner = bb.forOwner(GOAL); + expect(owner.read("spec")?.content).toBe("v2"); // latest by default + expect(owner.read("spec")?.version).toBe(2); + expect(owner.read("spec", null, 1)?.content).toBe("v1"); + expect(owner.read("spec", null, 99)).toBeNull(); + }); + + test("an unwritten kind reads null — a normal mid-flight state, not an error", () => { + expect(bb.forOwner(GOAL).read("findings")).toBeNull(); + }); + + test("a malformed kind reads null rather than reaching the store", () => { + expect(bb.forOwner(GOAL).read("Spec")).toBeNull(); + expect(bb.forOwner(GOAL).read("extra/")).toBeNull(); + expect(bb.forOwner(GOAL).read("../../etc/passwd")).toBeNull(); + }); + + test("exposes no write path at all", () => { + // Not a style assertion: `write` on this handle would be an unattributed, + // unscoped mutation of an append-only, attributable board. + expect("write" in bb.forOwner(GOAL)).toBe(false); + }); +}); diff --git a/src/tests/collaboration.test.ts b/src/tests/collaboration.test.ts index 6185788..b6b8445 100644 --- a/src/tests/collaboration.test.ts +++ b/src/tests/collaboration.test.ts @@ -30,6 +30,9 @@ import { import { MockSessionProvider, mockResult } from "../daemon/providers/mock/session-provider.js"; import { ProviderRegistry } from "../daemon/providers/registry.js"; import type { ProviderEvent } from "../daemon/providers/interface.js"; +import { Blackboard } from "../daemon/blackboard/service.js"; +import { BlackboardStore } from "../daemon/blackboard/store.js"; +import { parseClientMessage } from "@codeoid/protocol/schemas"; import { SessionManager } from "../daemon/session-manager.js"; import { Store } from "../daemon/store.js"; import { TranscriptStore } from "../daemon/transcript.js"; @@ -875,3 +878,227 @@ describe("the session is its orchestrator", () => { } }); }); + +// ── The owner-facing blackboard wire verbs ────────────────────────────────── + +// `blackboard.index` / `blackboard.read` are the only path by which a HUMAN +// sees what their fleet produced. The agent-facing MCP tools are role-scoped +// by design (§6); these are not, so what guards them is ownership of the goal +// session — and that has to hold for every way a caller can name one. +describe("blackboard.index / blackboard.read", () => { + const CONFIG: CollaborationConfig = { + goal: "Ship the blackboard panel", + roles: [ + { name: "orchestrator", providerId: "claude" }, + { name: "review", providerId: "gemini", count: 2 }, + ], + }; + + /** Same daemon, a different tenant — the isolation probe. */ + const OTHER_AUTH: AuthContext = { + ...AUTH, + sub: "user:other", + accountId: "acc-other", + projectId: "proj-other", + }; + + const runAs = (msg: ClientMessage, auth: AuthContext): Promise => + manager.handle(msg, auth, { id: "c2", auth, send: () => {} }); + + /** Write through the REAL role-scoped path so slots + attribution are the + * ones agents actually produce, then read back over the wire. */ + const board = () => new Blackboard(new BlackboardStore(store.database)); + const writeAs = (goalId: string, role: string, ordinal: number, kind: string, content: string) => + board() + .forRole( + { accountId: AUTH.accountId, projectId: AUTH.projectId, goalSessionId: goalId }, + { roleName: role, ordinal, authorSub: `agent:${goalId}:${role}#${ordinal}` }, + ) + .write(kind, content); + + const createCollab = async (id: string): Promise => { + manager.setBlackboardUrl("http://127.0.0.1:7400/mcp/blackboard"); + const resp = await run({ type: "session.create", id, name: id, workdir, collaboration: CONFIG }); + if (resp.type !== "response.ok") throw new Error(`create failed: ${JSON.stringify(resp)}`); + return resp.data as SessionInfo; + }; + + test("indexes what the roles wrote, with the goal text for labelling", async () => { + const parent = await createCollab("bb1"); + writeAs(parent.id, "orchestrator", 1, "spec", "SPEC BODY"); + writeAs(parent.id, "review", 1, "findings", "R1"); + writeAs(parent.id, "review", 2, "findings", "R2"); + + const resp = await run({ type: "blackboard.index", id: "i1", sessionId: parent.id }); + expect(resp.type).toBe("blackboard.index.result"); + if (resp.type !== "blackboard.index.result") return; + expect(resp.goal).toBe(CONFIG.goal); + expect(resp.sessionId).toBe(parent.id); + // Each reviewer occupies its own slot — a panel collapsed to one entry is + // the exact failure MULTI_WRITER_KINDS exists to prevent. + expect(resp.entries.map((e) => `${e.kind}:${e.slot ?? "-"}`).sort()).toEqual([ + "findings:review", + "findings:review#2", + "spec:-", + ]); + expect(resp.entries.find((e) => e.kind === "spec")?.bytes).toBe("SPEC BODY".length); + // Bodies never travel on the index. + expect(JSON.stringify(resp.entries)).not.toContain("SPEC BODY"); + }); + + test("a role-child resolves to its parent's goal, not to an empty board", async () => { + // Clients focus children as often as orchestrators; making each one walk + // parentSessionId itself means a client that gets it wrong sees an EMPTY + // board rather than an error. + const parent = await createCollab("bb2"); + writeAs(parent.id, "orchestrator", 1, "spec", "S"); + const kid = childrenOf(await allSessions(), parent.id)[0]!; + + const resp = await run({ type: "blackboard.index", id: "i2", sessionId: kid.id }); + expect(resp.type).toBe("blackboard.index.result"); + if (resp.type !== "blackboard.index.result") return; + expect(resp.sessionId).toBe(parent.id); + expect(resp.entries.map((e) => e.kind)).toEqual(["spec"]); + }); + + test("a plain session says it has no blackboard instead of returning nothing", async () => { + const resp0 = await run({ type: "session.create", id: "p1", name: "plain", workdir }); + const plain = (resp0 as { data: SessionInfo }).data; + const resp = await run({ type: "blackboard.index", id: "i3", sessionId: plain.id }); + expect(resp.type).toBe("response.error"); + if (resp.type !== "response.error") return; + expect(resp.code).toBe("invalid_request"); + expect(resp.error).toMatch(/not part of a collaboration/); + }); + + test("another tenant gets not_found, never someone else's board", async () => { + const parent = await createCollab("bb3"); + writeAs(parent.id, "orchestrator", 1, "spec", "SECRET"); + + const idx = await runAs( + { type: "blackboard.index", id: "i4", sessionId: parent.id }, + OTHER_AUTH, + ); + expect(idx.type).toBe("response.error"); + if (idx.type === "response.error") expect(idx.code).toBe("not_found"); + + const read = await runAs( + { type: "blackboard.read", id: "i5", sessionId: parent.id, kind: "spec" }, + OTHER_AUTH, + ); + expect(read.type).toBe("response.error"); + expect(JSON.stringify(read)).not.toContain("SECRET"); + }); + + test("index needs session:list and read needs session:watch", async () => { + const parent = await createCollab("bb4"); + writeAs(parent.id, "orchestrator", 1, "spec", "S"); + + // A token holding everything EXCEPT the one scope each verb requires. + const without = (drop: string): AuthContext => ({ + ...AUTH, + scopes: AUTH.scopes.filter((s) => s !== drop) as AuthContext["scopes"], + }); + + const idx = await runAs( + { type: "blackboard.index", id: "i6", sessionId: parent.id }, + without("session:list"), + ); + expect(idx.type).toBe("response.error"); + if (idx.type === "response.error") expect(idx.code).toBe("forbidden"); + + const read = await runAs( + { type: "blackboard.read", id: "i7", sessionId: parent.id, kind: "spec" }, + without("session:watch"), + ); + expect(read.type).toBe("response.error"); + if (read.type === "response.error") expect(read.code).toBe("forbidden"); + + // ...and the index still works for a watch-less token, since it carries + // no bodies. The two verbs are deliberately gated at different tiers. + const idxOk = await runAs( + { type: "blackboard.index", id: "i8", sessionId: parent.id }, + without("session:watch"), + ); + expect(idxOk.type).toBe("blackboard.index.result"); + }); + + test("reads a body, including a specific reviewer's slot", async () => { + const parent = await createCollab("bb5"); + writeAs(parent.id, "review", 1, "findings", "FIRST OPINION"); + writeAs(parent.id, "review", 2, "findings", "SECOND OPINION"); + + const first = await run({ + type: "blackboard.read", id: "r1", sessionId: parent.id, kind: "findings", slot: "review", + }); + expect(first.type).toBe("blackboard.read.result"); + if (first.type !== "blackboard.read.result") return; + expect(first.artifact?.content).toBe("FIRST OPINION"); + expect(first.artifact?.authorRole).toBe("review"); + + const second = await run({ + type: "blackboard.read", id: "r2", sessionId: parent.id, kind: "findings", slot: "review#2", + }); + if (second.type !== "blackboard.read.result") return; + expect(second.artifact?.content).toBe("SECOND OPINION"); + }); + + test("a superseded version is still readable — writes append, never overwrite", async () => { + const parent = await createCollab("bb6"); + writeAs(parent.id, "orchestrator", 1, "spec", "v1 text"); + writeAs(parent.id, "orchestrator", 1, "spec", "v2 text"); + + const latest = await run({ + type: "blackboard.read", id: "r3", sessionId: parent.id, kind: "spec", + }); + if (latest.type !== "blackboard.read.result") return; + expect(latest.artifact?.version).toBe(2); + expect(latest.artifact?.content).toBe("v2 text"); + + const old = await run({ + type: "blackboard.read", id: "r4", sessionId: parent.id, kind: "spec", version: 1, + }); + if (old.type !== "blackboard.read.result") return; + expect(old.artifact?.content).toBe("v1 text"); + }); + + test("an unwritten artifact is null, not an error", async () => { + // A collaboration in flight legitimately has empty lanes; a client renders + // that as pending, which it cannot do if the daemon returns an error. + const parent = await createCollab("bb7"); + const resp = await run({ + type: "blackboard.read", id: "r5", sessionId: parent.id, kind: "research", + }); + expect(resp.type).toBe("blackboard.read.result"); + if (resp.type !== "blackboard.read.result") return; + expect(resp.artifact).toBeNull(); + }); + + test("a malformed request is rejected at the schema, before any handler", () => { + const rejected = (over: Record) => + parseClientMessage({ + type: "blackboard.read", id: "r6", sessionId: "s", kind: "spec", ...over, + }).ok; + expect(rejected({ kind: "" })).toBe(false); + expect(rejected({ kind: "x".repeat(65) })).toBe(false); + expect(rejected({ version: 0 })).toBe(false); + expect(rejected({ version: -1 })).toBe(false); + expect(rejected({ slot: "s".repeat(129) })).toBe(false); + // `extra/` is an open namespace — the schema must let it through and + // leave validity to the daemon, which can name the valid core kinds back. + expect(rejected({ kind: "extra/bench-results" })).toBe(true); + }); + + test("an orphaned child reports the torn-down goal rather than an empty board", async () => { + const parent = await createCollab("bb8"); + const kid = childrenOf(await allSessions(), parent.id)[0]!; + // Teardown deletes the goal's artifacts, so "empty" and "gone" would be + // indistinguishable to a client if this returned a result. + await run({ type: "session.destroy", id: "d1", sessionId: parent.id }); + + const resp = await run({ type: "blackboard.index", id: "i9", sessionId: kid.id }); + expect(resp.type).toBe("response.error"); + if (resp.type !== "response.error") return; + expect(resp.code).toBe("not_found"); + }); +}); diff --git a/src/tests/protocol.test.ts b/src/tests/protocol.test.ts index 01cc3e0..250e8fa 100644 --- a/src/tests/protocol.test.ts +++ b/src/tests/protocol.test.ts @@ -432,6 +432,10 @@ describe("DaemonMessage routing", () => { case "fs.browse_dir.result": return `fs.browse:${msg.entries.length}`; case "claude.config.result": return `cc:${msg.agents.length}/${msg.skills.length}`; + case "blackboard.index.result": + return `bb.index:${msg.entries.length}`; + case "blackboard.read.result": + return `bb.read:${msg.artifact?.kind ?? "none"}`; case "models.list.result": return `models:${msg.models.length}`; case "session.export.result": diff --git a/src/tests/source-hygiene.test.ts b/src/tests/source-hygiene.test.ts new file mode 100644 index 0000000..b844eef --- /dev/null +++ b/src/tests/source-hygiene.test.ts @@ -0,0 +1,60 @@ +/** + * Repo hygiene — properties of the SOURCE FILES themselves, not of any code + * they contain. + * + * Currently one: no source file may contain a raw NUL byte. This is not + * hypothetical — a U+0000 key separator was once written as a literal + * control character, and the result was silent on every axis that normally + * catches a mistake. It typechecked, it linted, its tests passed, and the only + * symptom was `git diff` reporting `Bin 0 -> 6081 bytes`: git classifies a file + * with a NUL as binary, so the file stopped producing diffs and became + * invisible to code review. + * + * The byte is legitimate INSIDE a string; what's forbidden is writing it + * literally instead of as an escape. + */ + +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { extname, join, relative } from "node:path"; + +const ROOT = join(import.meta.dir, "..", ".."); +const SOURCE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".css", ".html"]); +const SKIP_DIR = new Set([ + "node_modules", + ".git", + "dist", + "build", + "coverage", + ".next", + "bun.lock", +]); + +function sourceFiles(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (SKIP_DIR.has(name)) continue; + const full = join(dir, name); + // Don't follow symlinks out of the repo. + const st = statSync(full, { throwIfNoEntry: false }); + if (!st) continue; + if (st.isDirectory()) sourceFiles(full, out); + else if (SOURCE_EXT.has(extname(name))) out.push(full); + } + return out; +} + +describe("source hygiene", () => { + test("no source file contains a raw NUL byte", () => { + const files = sourceFiles(ROOT); + // Guard the guard: a walker that silently found nothing would pass forever. + expect(files.length).toBeGreaterThan(300); + + const offenders: string[] = []; + for (const f of files) { + const buf = readFileSync(f); + const at = buf.indexOf(0); + if (at !== -1) offenders.push(`${relative(ROOT, f)} (byte ${at})`); + } + expect(offenders).toEqual([]); + }); +}); diff --git a/web/src/components/BlackboardDrawer.test.tsx b/web/src/components/BlackboardDrawer.test.tsx new file mode 100644 index 0000000..aff89a0 --- /dev/null +++ b/web/src/components/BlackboardDrawer.test.tsx @@ -0,0 +1,191 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { render, cleanup, fireEvent } from "@solidjs/testing-library"; + +const requestMock = vi.hoisted(() => + vi.fn<(msg: unknown, opts?: unknown) => Promise>(), +); +vi.mock("../state/connection", () => ({ + getClient: () => ({ request: requestMock }), + newRequestId: () => `r-${Math.random()}`, +})); + +import BlackboardDrawer from "./BlackboardDrawer"; +import { + closeBlackboard, + openBlackboard, + _resetBlackboardForTest, +} from "../state/blackboard"; +import type { BlackboardIndexEntry } from "../protocol/types"; + +function entry( + kind: string, + slot: string | null, + over: Partial = {}, +): BlackboardIndexEntry { + return { + kind, + slot, + version: 1, + authorSub: `agent:goal-1:${kind}`, + authorRole: slot?.split("#")[0] ?? kind, + updatedAt: Date.now() - 60_000, + bytes: 2048, + ...over, + }; +} + +const BOARD = [ + entry("findings", "review#2"), + entry("diff", null, { authorRole: "reasoning", bytes: 300_000, version: 3 }), + entry("spec", null, { authorRole: "orchestrator", bytes: 900 }), + entry("findings", "review"), + entry("research", null, { authorRole: "search" }), +]; + +const indexResult = (entries: BlackboardIndexEntry[]) => ({ + type: "blackboard.index.result" as const, + requestId: "x", + sessionId: "goal-1", + goal: "make auth boring again", + entries, +}); + +const readResult = (content: string | null, kind: string, slot: string | null) => ({ + type: "blackboard.read.result" as const, + requestId: "x", + sessionId: "goal-1", + artifact: + content === null + ? null + : { + kind, + slot, + version: 2, + content, + authorSub: "a", + authorRole: "review", + createdAt: Date.now() - 30_000, + }, +}); + +/** Open the drawer with a loaded board and wait for the first paint. */ +async function openWith(entries: BlackboardIndexEntry[]) { + requestMock.mockResolvedValue(indexResult(entries)); + const view = render(() => ); + openBlackboard("goal-1"); + await vi.waitFor(() => expect(view.queryByText(/make auth boring again/)).toBeTruthy()); + requestMock.mockReset(); + return view; +} + +beforeEach(() => _resetBlackboardForTest()); +afterEach(() => { + cleanup(); + closeBlackboard(); + requestMock.mockReset(); + _resetBlackboardForTest(); + vi.useRealTimers(); +}); + +describe("BlackboardDrawer", () => { + it("renders nothing until opened", () => { + const { container } = render(() => ); + expect(container.textContent).toBe(""); + }); + + it("lists artifacts in SDLC flow order, not alphabetically", async () => { + // Alphabetical would put `diff` first and bury `spec` — the board should + // read as the pipeline it is. + const { container } = await openWith(BOARD); + const kinds = [...container.querySelectorAll("nav button")].map( + (b) => b.querySelector("span")?.textContent, + ); + expect(kinds).toEqual(["spec", "research", "diff", "findings", "findings"]); + }); + + it("shows each writer's slot, so a panel never reads as one voice", async () => { + const { getAllByTitle } = await openWith(BOARD); + const slots = getAllByTitle("Writer slot").map((el) => el.textContent); + // Two `findings` rows, distinguishable. One row would mean the panel + // silently collapsed to a single opinion. + expect(slots).toEqual(["review", "review#2"]); + }); + + it("shows the goal and a human-readable size, and no bodies", async () => { + const { getByText, container } = await openWith(BOARD); + expect(getByText(/make auth boring again/)).toBeTruthy(); + expect(getByText("293 KB")).toBeTruthy(); // the 300_000-byte diff + expect(getByText("900 B")).toBeTruthy(); + expect(container.querySelector("pre")).toBeNull(); + }); + + it("fetches a body only when an artifact is picked", async () => { + const { getByText, container } = await openWith(BOARD); + expect(requestMock).not.toHaveBeenCalled(); + + requestMock.mockResolvedValueOnce(readResult("REVIEWER TWO SAYS NO", "findings", "review#2")); + fireEvent.click(getByText("review#2")); + await vi.waitFor(() => + expect(container.querySelector("pre")?.textContent).toBe("REVIEWER TWO SAYS NO"), + ); + expect(requestMock.mock.calls[0]![0]).toMatchObject({ + type: "blackboard.read", + kind: "findings", + slot: "review#2", + }); + }); + + it("renders an unwritten artifact as pending rather than as a failure", async () => { + const { getByText, container } = await openWith(BOARD); + requestMock.mockResolvedValueOnce(readResult(null, "research", null)); + fireEvent.click(getByText("research")); + await vi.waitFor(() => expect(getByText(/hasn't published a version/)).toBeTruthy()); + expect(container.querySelector("pre")).toBeNull(); + }); + + it("surfaces a read failure inline", async () => { + const { getByText } = await openWith(BOARD); + requestMock.mockRejectedValueOnce(new Error("Missing scope: session:watch")); + fireEvent.click(getByText("spec")); + await vi.waitFor(() => expect(getByText(/Missing scope: session:watch/)).toBeTruthy()); + }); + + it("explains an empty board instead of showing a blank pane", async () => { + const { getByText } = await openWith([]); + expect(getByText(/Nothing written yet/)).toBeTruthy(); + }); + + it("closes on Escape", async () => { + const { container } = await openWith(BOARD); + fireEvent.keyDown(window, { key: "Escape" }); + await vi.waitFor(() => expect(container.textContent).toBe("")); + }); + + it("refreshes on demand", async () => { + const { getByLabelText, getByText } = await openWith(BOARD); + requestMock.mockResolvedValueOnce( + indexResult([...BOARD, entry("adr", null, { authorRole: "architecture" })]), + ); + fireEvent.click(getByLabelText("Refresh blackboard")); + await vi.waitFor(() => expect(getByText("adr")).toBeTruthy()); + }); + + it("stops polling once closed", async () => { + vi.useFakeTimers(); + requestMock.mockResolvedValue(indexResult(BOARD)); + render(() => ); + openBlackboard("goal-1"); + await vi.waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1)); + + await vi.advanceTimersByTimeAsync(9_000); + const whileOpen = requestMock.mock.calls.length; + expect(whileOpen).toBeGreaterThan(1); // it does poll + + closeBlackboard(); + await vi.advanceTimersByTimeAsync(20_000); + // A timer left running against a drawer nobody is looking at would keep + // hitting the daemon for the life of the tab. + expect(requestMock.mock.calls.length).toBe(whileOpen); + }); +}); diff --git a/web/src/components/BlackboardDrawer.tsx b/web/src/components/BlackboardDrawer.tsx new file mode 100644 index 0000000..f20b477 --- /dev/null +++ b/web/src/components/BlackboardDrawer.tsx @@ -0,0 +1,317 @@ +/** + * Goal-blackboard drawer — what a collaboration has actually produced. + * + * The session list shows the fleet; this shows its OUTPUT. Without it a + * collaboration is a set of sessions whose handoffs happen entirely off-screen + * (docs/collaborative-session-design.md §4: the orchestrator holds an index, + * never the bodies — so neither did the UI). + * + * Two panes: the index on the left (kind · slot · version · author · size), + * one artifact body on the right. Bodies are fetched on demand, never with the + * index — a `diff` can be 256 KB and the whole point of the index is that you + * can see what exists without paying for it. + */ + +import { + Component, + For, + Show, + createEffect, + createMemo, + createSignal, + onCleanup, + onMount, +} from "solid-js"; + +import { relativeTime } from "../lib/format"; +import { + blackboard, + clearSelection, + closeBlackboard, + isBlackboardOpen, + refKey, + refreshBlackboard, + selectArtifact, + type ArtifactRef, +} from "../state/blackboard"; +import { nowTick } from "../state/clock"; +import type { BlackboardIndexEntry } from "../protocol/types"; + +/** + * Poll cadence while the drawer is open. A collaboration writes artifacts over + * minutes, and there is no push channel for the board — a stale panel would + * make a working fleet look stalled. Cheap: the index carries no bodies. + */ +const REFRESH_MS = 4_000; + +/** SDLC flow order (blackboard/types.ts CORE_ARTIFACT_KINDS), so the board + * reads as a pipeline rather than alphabetically. Unknown kinds sort last. */ +const KIND_ORDER = ["spec", "research", "adr", "task-list", "diff", "findings"]; + +function kindRank(kind: string): number { + const i = KIND_ORDER.indexOf(kind); + return i === -1 ? KIND_ORDER.length : i; +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10 * 1024 ? 1 : 0)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +const BlackboardDrawer: Component = () => { + onMount(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && isBlackboardOpen()) { + e.preventDefault(); + closeBlackboard(); + } + }; + window.addEventListener("keydown", onKey); + onCleanup(() => window.removeEventListener("keydown", onKey)); + }); + + // Poll only while open, and tear the timer down on close — a background + // interval against a drawer nobody is looking at is pure waste. + createEffect(() => { + if (!isBlackboardOpen()) return; + const t = setInterval(() => void refreshBlackboard(), REFRESH_MS); + onCleanup(() => clearInterval(t)); + }); + + const sorted = createMemo(() => + [...blackboard().entries].sort( + (a, b) => + kindRank(a.kind) - kindRank(b.kind) || + a.kind.localeCompare(b.kind) || + (a.slot ?? "").localeCompare(b.slot ?? ""), + ), + ); + + return ( + +
{ + if (e.target === e.currentTarget) closeBlackboard(); + }} + > + +
+
+ ); +}; + +const IndexPane: Component<{ entries: BlackboardIndexEntry[] }> = (props) => { + const selectedKey = () => { + const s = blackboard().selected; + return s ? refKey(s) : null; + }; + return ( + + ); +}; + +const BodyPane: Component = () => { + const [copied, setCopied] = createSignal(false); + return ( +
+ + Pick an artifact to read it. Bodies are fetched on demand — the index + deliberately carries only sizes, so opening the board never pulls a + 256 KB diff you didn't ask for. +

+ } + > + {(sel) => ( + <> +
+ + {sel().kind} + + {(slot) => {slot()}} + + + + {(a) => ( + + v{a().version} · {a().authorRole ?? "unknown role"} ·{" "} + {relativeTime(a().createdAt, nowTick())} + + )} + + + + {(a) => ( + + )} + + + +
+
+ + {(e) => ( +
+ {e()} +
+ )} +
+ +
loading…
+
+ }> + {(a) => ( +
+                    {a().content}
+                  
+ )} +
+
+ + )} +
+
+ ); +}; + +/** + * An artifact the index listed but that reads back empty. Normal mid-flight — + * the daemon returns `null` rather than an error precisely so this renders as + * pending instead of as a failure. + */ +const Unwritten: Component<{ loading: boolean }> = (props) => ( + +

+ Not written yet — the role responsible for it hasn't published a version. +

+
+); + +export default BlackboardDrawer; diff --git a/web/src/components/SessionControls.test.tsx b/web/src/components/SessionControls.test.tsx index aedcb72..7de9ab0 100644 --- a/web/src/components/SessionControls.test.tsx +++ b/web/src/components/SessionControls.test.tsx @@ -17,6 +17,8 @@ vi.mock("../state/connection", () => ({ const fetchModelsMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); vi.mock("../state/models", () => ({ fetchModels: fetchModelsMock, modelCatalog: () => [] })); vi.mock("./SessionExportModal", () => ({ openExportModal: vi.fn() })); +const openBlackboardMock = vi.hoisted(() => vi.fn()); +vi.mock("../state/blackboard", () => ({ openBlackboard: openBlackboardMock })); import SessionControls from "./SessionControls"; import { @@ -43,12 +45,13 @@ function sess(providerId?: string, status: SessionInfo["status"] = "idle"): Sess } as SessionInfo; } -function mockAuth(providers?: string[]): void { +function mockAuth(providers?: string[], capabilities?: string[]): void { authMock.mockReturnValue({ type: "auth.ok", identity: { sub: "u", type: "human" }, scopes: [], ...(providers ? { providers } : {}), + ...(capabilities ? { capabilities } : {}), }); } @@ -616,3 +619,51 @@ describe("ForkedFromChip — lineage", () => { expect(queryByText("· turn 4")).toBeNull(); }); }); + +describe("SessionControls — goal blackboard button", () => { + const orchestrator = () => + ({ + ...sess(), + collaboration: { goal: "g", roles: [{ name: "orchestrator", providerId: "claude" }] }, + }) as SessionInfo; + + const roleChild = () => + ({ + ...sess(), + collaborationRole: { parentSessionId: "p", roleName: "review", ordinal: 1, write: false }, + }) as SessionInfo; + + it("offers the board for an orchestrator when the daemon advertises it", () => { + mockAuth(undefined, ["blackboard"]); + ingestSessionList([orchestrator()]); + focusSession("s"); + const { getByText } = render(() => ); + fireEvent.click(getByText("board")); + expect(openBlackboardMock).toHaveBeenCalledWith("s"); + }); + + it("offers it for a role-child too — the daemon resolves the parent hop", () => { + mockAuth(undefined, ["blackboard"]); + ingestSessionList([roleChild()]); + focusSession("s"); + const { getByText } = render(() => ); + expect(getByText("board")).toBeTruthy(); + }); + + it("hides it on a daemon that doesn't advertise the capability", () => { + // An affordance that errors on click is worse than no affordance. + mockAuth(); + ingestSessionList([orchestrator()]); + focusSession("s"); + const { queryByText } = render(() => ); + expect(queryByText("board")).toBeNull(); + }); + + it("hides it for a session with no collaboration at all", () => { + mockAuth(undefined, ["blackboard"]); + ingestSessionList([sess()]); + focusSession("s"); + const { queryByText } = render(() => ); + expect(queryByText("board")).toBeNull(); + }); +}); diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index 068ba0f..704cbf2 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -30,7 +30,9 @@ import { } from "../state/sessions"; import { fetchModels, modelCatalog } from "../state/models"; import { effectiveMode } from "../lib/session-mode"; +import { CAPABILITIES } from "../protocol/types"; import type { ClientMessage, SessionInfo, SessionMode } from "../protocol/types"; +import { openBlackboard } from "../state/blackboard"; import { openExportModal } from "./SessionExportModal"; const MODE_OPTIONS: { value: SessionMode; label: string; hint: string }[] = [ @@ -89,6 +91,7 @@ const SessionControls: Component = () => {
+ @@ -108,6 +111,34 @@ const SessionControls: Component = () => { ); }; +/** + * Opens the goal blackboard for a session that is part of a collaboration — + * the orchestrator or any role-child, since the daemon resolves the hop. + * + * Capability-gated rather than always rendered: against a daemon that predates + * `blackboard.index` the affordance simply doesn't appear, instead of + * appearing and erroring on click. + */ +const BlackboardButton: Component<{ session: SessionInfo }> = (props) => { + const partOfCollab = () => + Boolean(props.session.collaboration ?? props.session.collaborationRole); + const supported = () => + authIdentity()?.capabilities?.includes(CAPABILITIES.BLACKBOARD) ?? false; + return ( + + + + ); +}; + /** Lineage chip for a forked session — "⑃ from · turn N". Clicking * focuses the parent when it's still in the list; otherwise it's a static * label (the parent may have been destroyed since). */ diff --git a/web/src/components/SessionListPane.test.tsx b/web/src/components/SessionListPane.test.tsx index 6324252..5b8e7e0 100644 --- a/web/src/components/SessionListPane.test.tsx +++ b/web/src/components/SessionListPane.test.tsx @@ -30,6 +30,31 @@ function sess(id: string, name: string, workdir = "/tmp"): SessionInfo { } as SessionInfo; } +/** An orchestrator: a session carrying a `collaboration` config. */ +function orchestrator(id: string, name: string, goal: string): SessionInfo { + return { + ...sess(id, name, "/repo/fleet"), + collaboration: { goal, roles: [{ name: "orchestrator", providerId: "claude" }] }, + } as SessionInfo; +} + +/** A role-child: a session carrying `collaborationRole` pointing at its parent. */ +function roleChild( + parentId: string, + parentName: string, + roleName: string, + ordinal: number, + write: boolean, + providerId = "claude", +): SessionInfo { + const suffix = ordinal > 1 ? `-${ordinal}` : ""; + return { + ...sess(`${parentId}:${roleName}${suffix}`, `${parentName}:${roleName}${suffix}`, "/repo/fleet"), + providerId, + collaborationRole: { parentSessionId: parentId, roleName, ordinal, write }, + } as SessionInfo; +} + afterEach(() => { cleanup(); _resetSessionsForTest(); @@ -82,3 +107,91 @@ describe("SessionListPane — session filter", () => { expect(getByText("beta")).toBeTruthy(); }); }); + +describe("SessionListPane — fleet rendering", () => { + const FLEET = [ + orchestrator("p", "refactor-auth", "make auth boring again"), + roleChild("p", "refactor-auth", "review", 2, false, "gemini"), + roleChild("p", "refactor-auth", "search", 1, false, "claude"), + roleChild("p", "refactor-auth", "review", 1, false, "gemini"), + roleChild("p", "refactor-auth", "reasoning", 1, true, "openai"), + sess("solo", "unrelated", "/tmp/other"), + ]; + + it("renders children by role label, not by their prefixed session name", () => { + ingestSessionList(FLEET); + const { getByText, queryByText } = render(() => ); + + expect(getByText("refactor-auth")).toBeTruthy(); + // `review` ×2 fan-out gets ordinals; singletons don't. + expect(getByText("review")).toBeTruthy(); + expect(getByText("review#2")).toBeTruthy(); + expect(getByText("search")).toBeTruthy(); + expect(getByText("reasoning")).toBeTruthy(); + // The daemon-generated name is in the tooltip, not the visible label. + expect(queryByText("refactor-auth:review-2")).toBeNull(); + }); + + it("shows the goal on the orchestrator row", () => { + ingestSessionList(FLEET); + const { getByText } = render(() => ); + expect(getByText(/make auth boring again/)).toBeTruthy(); + }); + + it("badges read-only roles and marks the writer differently", () => { + ingestSessionList(FLEET); + const { getAllByTitle } = render(() => ); + // search, review, review#2 are read-only; reasoning writes. + expect(getAllByTitle(/Read-only role/)).toHaveLength(3); + expect(getAllByTitle(/may write to the workspace/)).toHaveLength(1); + }); + + it("shows every child's backend, including claude", () => { + // A mixed fleet is the point; "claude" must be stated, not implied by the + // absence of a chip the way it is for standalone sessions. + ingestSessionList(FLEET); + const { getAllByTitle, queryAllByTitle } = render(() => ); + expect(getAllByTitle(/Backend: gemini/)).toHaveLength(2); + expect(getAllByTitle(/Backend: openai/)).toHaveLength(1); + expect(getAllByTitle(/Backend: claude/)).toHaveLength(1); + // ...but the standalone session still suppresses the default-backend chip. + expect(queryAllByTitle(/Backend: claude/)).toHaveLength(1); + }); + + it("collapses and re-expands the fleet from the group toggle", () => { + ingestSessionList(FLEET); + const { getByLabelText, queryByText, getByText } = render(() => ); + + fireEvent.click(getByLabelText(/Collapse fleet \(4 roles\)/)); + expect(queryByText("search")).toBeNull(); + expect(queryByText("review#2")).toBeNull(); + // The orchestrator itself stays put. + expect(getByText("refactor-auth")).toBeTruthy(); + + fireEvent.click(getByLabelText(/Expand fleet \(4 roles\)/)); + expect(getByText("search")).toBeTruthy(); + }); + + it("keeps the orchestrator visible as context when only a child matches", () => { + ingestSessionList(FLEET); + const { getByLabelText, getByText, queryByText } = render(() => ); + fireEvent.input(getByLabelText("Filter sessions by name"), { + target: { value: "reasoning" }, + }); + // A bare role row with no indication of its goal would be unreadable. + expect(getByText("refactor-auth")).toBeTruthy(); + expect(getByText("reasoning")).toBeTruthy(); + expect(queryByText("search")).toBeNull(); + expect(queryByText("unrelated")).toBeNull(); + }); + + it("keeps an orphan child visible, at top level, with its role badges intact", () => { + // Parent destroyed while the child drains — the child must not vanish. It + // shows its full name (no parent row above it to supply the context) but + // still reads as a role-child. + ingestSessionList([roleChild("ghost", "gone", "search", 1, false)]); + const { getByText, getByTitle } = render(() => ); + expect(getByText("gone:search")).toBeTruthy(); + expect(getByTitle(/Read-only role/)).toBeTruthy(); + }); +}); diff --git a/web/src/components/SessionListPane.tsx b/web/src/components/SessionListPane.tsx index a93bdb3..ab1e290 100644 --- a/web/src/components/SessionListPane.tsx +++ b/web/src/components/SessionListPane.tsx @@ -5,9 +5,16 @@ * chat area dominates the viewport. */ -import { Component, createSignal, For, Show } from "solid-js"; +import { Component, createMemo, createSignal, For, Show } from "solid-js"; import { formatCostUsd, formatTokens, relativeTime } from "../lib/format"; +import { + countVisible, + filterFleet, + groupFleet, + roleLabel, + type FilteredFleetGroup, +} from "../lib/fleet"; import { sessionAgentLabel, shortSub } from "../lib/identity"; import { nowTick } from "../state/clock"; import { @@ -43,19 +50,38 @@ function newSession(): void { openNewSessionModal(); } +/** + * Fleets the user has folded shut, by orchestrator id. Collapsed is the + * exception, so absence means expanded — a fleet that spawns while you're + * looking at it opens rather than hiding its own arrival. + * + * Module-level so the choice survives the pane unmounting (mobile drawer, + * sidebar collapse) — a fold that reopens itself every time you close the + * drawer isn't a fold. + */ +const [collapsedFleets, setCollapsedFleets] = createSignal>( + new Set(), +); + +function toggleFleet(parentId: string): void { + setCollapsedFleets((prev) => { + const next = new Set(prev); + if (!next.delete(parentId)) next.add(parentId); + return next; + }); +} + const SessionListPane: Component = () => { const [showAnalytics, setShowAnalytics] = createSignal(false); - // Instant client-side filter by session name/workdir. Complements the + // Instant client-side filter by session name/workdir/role. Complements the // semantic cross-session content search (Ctrl+K) — this is the fast // "find the session called X" pass, and it works without the memory engine. const [filter, setFilter] = createSignal(""); - const filtered = (): SessionInfo[] => { - const q = filter().trim().toLowerCase(); - if (!q) return sessionList(); - return sessionList().filter( - (s) => s.name.toLowerCase().includes(q) || s.workdir.toLowerCase().includes(q), - ); - }; + // Group BEFORE filtering: a filter that matches only a child still needs its + // orchestrator around to say which goal that child belongs to. + const groups = createMemo(() => + filterFleet(groupFleet(sessionList()), filter()), + ); return ( { ); +const RailButton: Component<{ + session: SessionInfo; + label: string; + title: string; +}> = (props) => ( + +); + const SectionHeader: Component<{ title: string; count: number; @@ -228,24 +285,100 @@ const NoMatch: Component<{ query: string }> = (props) => (
); -const SessionRow: Component<{ session: SessionInfo }> = (props) => { +/** + * One top-level session and, when it orchestrates a collaboration, its + * role-children indented beneath it. + * + * The children are real sessions you can focus and read — they are long-lived, + * not transient dispatch workers — so they stay clickable rows rather than a + * summary line. What changes is that they're visibly *of* the orchestrator. + */ +const FleetGroupRows: Component<{ group: FilteredFleetGroup }> = (props) => { + const collapsed = () => collapsedFleets().has(props.group.lead.id); + const hasChildren = () => props.group.children.length > 0; + + return ( + <> + toggleFleet(props.group.lead.id), + } + : undefined + } + /> + +
  • + {/* The rail is the grouping: one continuous line down the left of + the fleet, so a child is unmistakably subordinate even when the + orchestrator has scrolled out of view. */} +
      + + {(c) => } + +
    +
  • +
    + + ); +}; + +interface FleetLeadProps { + childCount: number; + collapsed: boolean; + onToggle: () => void; +} + +const SessionRow: Component<{ + session: SessionInfo; + /** + * Rendered indented under its orchestrator. Distinct from "is a role-child": + * an orphan child (parent destroyed or not yet delivered) is a role-child + * rendered at top level, and must keep its role badges while showing its + * full name — there's no parent row above it to supply the context. + */ + nested?: boolean; + /** Present when this session orchestrates a collaboration. */ + fleet?: FleetLeadProps; + /** Shown only to give matching children a parent — not a filter hit itself. */ + dimmed?: boolean; +}> = (props) => { const isActive = () => focusedSessionId() === props.session.id; + const role = () => props.session.collaborationRole; + // A child's daemon-generated name is `:[-N]`; under the parent + // that prefix is pure repetition, so a nested row leads with the role and + // keeps the full name in the tooltip. + const title = () => + (props.nested && roleLabel(props.session)) || props.session.name; return ( -
  • +
  • + {/* Sibling of the row, not nested inside it — a button inside a button is + invalid HTML and browsers resolve the click ambiguity differently. */} + + {(f) => ( + + )} +
  • ); }; +/** + * Whether a role-child may write. Read-only is the interesting state — it's the + * §6 independence property made visible (a scout's leaf identity carries no + * `tools:write` at all), so it gets the badge and write gets a quiet marker + * rather than both shouting equally. + */ +const WriteBadge: Component<{ write: boolean }> = (props) => ( + + ro + + } + > + + ✎ + + +); + const StatusDot: Component<{ status: SessionStatus }> = (props) => { const cls = () => { switch (props.status) { diff --git a/web/src/components/Shell.tsx b/web/src/components/Shell.tsx index 02c902f..87109db 100644 --- a/web/src/components/Shell.tsx +++ b/web/src/components/Shell.tsx @@ -17,6 +17,7 @@ import { Component, Show, createMemo } from "solid-js"; +import BlackboardDrawer from "./BlackboardDrawer"; import CapabilitiesDrawer from "./CapabilitiesDrawer"; import CenterPane from "./CenterPane"; import FileViewer from "./files/FileViewer"; @@ -60,6 +61,7 @@ const Shell: Component = () => { + diff --git a/web/src/lib/fleet.test.ts b/web/src/lib/fleet.test.ts new file mode 100644 index 0000000..1a15696 --- /dev/null +++ b/web/src/lib/fleet.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect } from "vitest"; + +import { countVisible, filterFleet, groupFleet, roleLabel } from "./fleet"; +import type { CollaborationConfig, SessionInfo } from "../protocol/types"; + +function session( + id: string, + over: Partial = {}, +): SessionInfo { + return { + id, + name: id, + workdir: `/repo/${id}`, + status: "idle", + createdAt: "2026-07-27T00:00:00.000Z", + ...over, + } as SessionInfo; +} + +const GOAL: CollaborationConfig = { + goal: "ship the thing", + roles: [{ name: "orchestrator", providerId: "claude" }], +}; + +function child( + id: string, + parentSessionId: string, + roleName: string, + ordinal = 1, + write = false, +): SessionInfo { + return session(id, { + collaborationRole: { parentSessionId, roleName, ordinal, write }, + }); +} + +describe("groupFleet", () => { + it("nests role-children under their orchestrator and keeps lead order", () => { + const parent = session("p", { collaboration: GOAL }); + const groups = groupFleet([ + session("standalone-b"), + parent, + child("c2", "p", "review", 2), + child("c1", "p", "review", 1), + session("standalone-a"), + ]); + + expect(groups.map((g) => g.lead.id)).toEqual([ + "standalone-b", + "p", + "standalone-a", + ]); + const fleet = groups[1]!; + expect(fleet.isFleet).toBe(true); + expect(fleet.children.map((c) => c.id)).toEqual(["c1", "c2"]); + }); + + it("orders children by role name then ordinal, not by list position", () => { + const parent = session("p", { collaboration: GOAL }); + const groups = groupFleet([ + parent, + child("r2", "p", "review", 2), + child("s1", "p", "search", 1), + child("r1", "p", "review", 1), + child("a1", "p", "architecture", 1), + ]); + expect(groups[0]!.children.map((c) => c.id)).toEqual(["a1", "r1", "r2", "s1"]); + }); + + it("marks a collaboration with no children yet as a fleet", () => { + // Between session.create and #spawnCollaborationChildren the parent exists + // alone; it must not render as a plain session and then become a fleet. + const groups = groupFleet([session("p", { collaboration: GOAL })]); + expect(groups).toHaveLength(1); + expect(groups[0]!.isFleet).toBe(true); + expect(groups[0]!.children).toEqual([]); + }); + + it("promotes an orphan child to a top-level group instead of dropping it", () => { + // Parent destroyed while the child drains, or not yet delivered to this + // client. Either way the child must stay visible. + const groups = groupFleet([child("c1", "gone", "search", 1)]); + expect(groups.map((g) => g.lead.id)).toEqual(["c1"]); + expect(groups[0]!.isFleet).toBe(false); + }); + + it("does not nest a child under a parent that is merely name-similar", () => { + const groups = groupFleet([ + session("p", { collaboration: GOAL }), + child("c1", "p-other", "search", 1), + ]); + expect(groups.map((g) => g.lead.id)).toEqual(["p", "c1"]); + expect(groups[0]!.children).toEqual([]); + }); + + it("returns an empty list for no sessions", () => { + expect(groupFleet([])).toEqual([]); + }); +}); + +describe("filterFleet", () => { + const parent = session("p", { name: "refactor-auth", collaboration: GOAL }); + const groups = groupFleet([ + parent, + child("c1", "p", "search", 1), + child("c2", "p", "review", 1), + session("unrelated", { name: "scratch", workdir: "/tmp/scratch" }), + ]); + + it("passes everything through for an empty query", () => { + const out = filterFleet(groups, " "); + expect(out).toHaveLength(2); + expect(out.every((g) => g.leadMatched)).toBe(true); + expect(out[0]!.children).toHaveLength(2); + }); + + it("keeps the whole fleet when the lead matches", () => { + const out = filterFleet(groups, "refactor"); + expect(out).toHaveLength(1); + expect(out[0]!.leadMatched).toBe(true); + expect(out[0]!.children.map((c) => c.id)).toEqual(["c2", "c1"]); + }); + + it("keeps the lead as unmatched context when only a child matches", () => { + const out = filterFleet(groups, "search"); + expect(out).toHaveLength(1); + expect(out[0]!.lead.id).toBe("p"); + expect(out[0]!.leadMatched).toBe(false); + expect(out[0]!.children.map((c) => c.id)).toEqual(["c1"]); + }); + + it("matches a child on its role name, which is not part of its session name", () => { + // The child's own `name` is daemon-generated; the role is what the user + // actually thinks in. + const out = filterFleet(groups, "review"); + expect(out).toHaveLength(1); + expect(out[0]!.children.map((c) => c.id)).toEqual(["c2"]); + }); + + it("matches on workdir", () => { + const out = filterFleet(groups, "/tmp/scratch"); + expect(out.map((g) => g.lead.id)).toEqual(["unrelated"]); + }); + + it("drops groups where nothing matches", () => { + expect(filterFleet(groups, "nothing-here")).toEqual([]); + }); +}); + +describe("countVisible", () => { + it("counts leads plus their children", () => { + const groups = groupFleet([ + session("p", { collaboration: GOAL }), + child("c1", "p", "search", 1), + child("c2", "p", "review", 1), + session("solo"), + ]); + expect(countVisible(groups)).toBe(4); + expect(countVisible([])).toBe(0); + }); +}); + +describe("roleLabel", () => { + it("omits the ordinal for a singleton role and shows it for a fan-out", () => { + expect(roleLabel(child("c", "p", "search", 1))).toBe("search"); + expect(roleLabel(child("c", "p", "review", 2))).toBe("review#2"); + }); + + it("returns null for a session that is not a role-child", () => { + expect(roleLabel(session("solo"))).toBeNull(); + }); +}); diff --git a/web/src/lib/fleet.ts b/web/src/lib/fleet.ts new file mode 100644 index 0000000..5440093 --- /dev/null +++ b/web/src/lib/fleet.ts @@ -0,0 +1,149 @@ +/** + * Fleet grouping — turning a flat session list into orchestrator + role-children. + * + * A collaborative session (docs/collaborative-session-design.md) is really N+1 + * sessions: the orchestrator the user created, and one long-lived role-child per + * role binding. The daemon already tells us how they relate — the parent carries + * `collaboration`, each child carries `collaborationRole` — but a flat list + * renders them as N+1 unrelated sessions, which is exactly the wrong mental + * model for something whose whole point is that it's ONE unit of work. + * + * Pure functions, no Solid: grouping is the part worth testing, and the tests + * shouldn't need a reactive root to run. + */ + +import type { SessionInfo } from "../protocol/types"; + +/** + * One top-level row in the session list, plus whatever hangs under it. + * + * A standalone session is a group of one — the list is uniformly groups, so the + * renderer never branches on "is this a fleet" to decide its outer shape. + */ +export interface FleetGroup { + /** The orchestrator, or the standalone session. */ + lead: SessionInfo; + /** Role-children of `lead`, role-then-ordinal ordered. Empty unless a fleet. */ + children: SessionInfo[]; + /** + * True when `lead` orchestrates a collaboration. Distinct from + * `children.length > 0`: between create and spawn a fleet legitimately has + * zero children, and it should still render as a fleet rather than blinking + * from plain session to fleet a second later. + */ + isFleet: boolean; +} + +/** + * Group sessions into fleets, preserving the caller's ordering for leads. + * + * Orphan children — `parentSessionId` names a session that isn't in the list — + * are promoted to their own top-level group rather than dropped. The parent can + * be legitimately absent (destroyed while children drain, or not yet delivered + * to this client), and a session that silently disappears from the sidebar is a + * far worse failure than one rendered without its group. + */ +export function groupFleet(sessions: readonly SessionInfo[]): FleetGroup[] { + const present = new Set(sessions.map((s) => s.id)); + const childrenByParent = new Map(); + + for (const s of sessions) { + const parentId = s.collaborationRole?.parentSessionId; + // A child whose parent is missing is treated as a lead below, so only + // bucket the ones that actually have somewhere to go. + if (!parentId || !present.has(parentId)) continue; + const bucket = childrenByParent.get(parentId); + if (bucket) bucket.push(s); + else childrenByParent.set(parentId, [s]); + } + + const groups: FleetGroup[] = []; + for (const s of sessions) { + const parentId = s.collaborationRole?.parentSessionId; + if (parentId && present.has(parentId)) continue; // rendered under its parent + const children = childrenByParent.get(s.id) ?? []; + children.sort(compareChildren); + groups.push({ lead: s, children, isFleet: Boolean(s.collaboration) }); + } + return groups; +} + +/** + * Children order: role name, then fan-out ordinal. Deliberately NOT createdAt — + * `review` ×3 spawn within milliseconds of each other, so creation order is + * effectively arbitrary and the list would reshuffle between renders. + */ +function compareChildren(a: SessionInfo, b: SessionInfo): number { + const ra = a.collaborationRole; + const rb = b.collaborationRole; + if (!ra || !rb) return 0; + if (ra.roleName !== rb.roleName) return ra.roleName < rb.roleName ? -1 : 1; + return ra.ordinal - rb.ordinal; +} + +/** + * Apply the sidebar's name/workdir filter across a grouped list. + * + * Matching is per-session, but visibility is per-group, and the two directions + * differ on purpose: + * + * - lead matches → keep the whole group. You searched for the fleet; you want + * the fleet, not a fleet with its members hidden. + * - child matches → keep the lead as context, with only the matching children. + * Showing a bare `search#1` row with no indication of which + * goal it belongs to is worse than not filtering at all. + * + * A lead kept only as context is reported via `leadMatched: false` so the + * renderer can de-emphasise it instead of implying it matched the query. + */ +export interface FilteredFleetGroup extends FleetGroup { + /** False when the lead is present only to give matching children a parent. */ + leadMatched: boolean; +} + +export function filterFleet( + groups: readonly FleetGroup[], + query: string, +): FilteredFleetGroup[] { + const q = query.trim().toLowerCase(); + if (!q) return groups.map((g) => ({ ...g, leadMatched: true })); + + const out: FilteredFleetGroup[] = []; + for (const g of groups) { + const leadMatched = matchesSession(g.lead, q); + if (leadMatched) { + out.push({ ...g, leadMatched: true }); + continue; + } + const children = g.children.filter((c) => matchesSession(c, q)); + if (children.length > 0) out.push({ ...g, children, leadMatched: false }); + } + return out; +} + +/** Name, workdir, or — for a child — its role name. */ +function matchesSession(s: SessionInfo, lowercaseQuery: string): boolean { + if (s.name.toLowerCase().includes(lowercaseQuery)) return true; + if (s.workdir.toLowerCase().includes(lowercaseQuery)) return true; + const role = s.collaborationRole?.roleName; + return role !== undefined && role.includes(lowercaseQuery); +} + +/** How many sessions a filtered group puts on screen — for the header count. */ +export function countVisible(groups: readonly FleetGroup[]): number { + let n = 0; + for (const g of groups) n += 1 + g.children.length; + return n; +} + +/** + * Display label for a role-child: `search` for a singleton, `review#2` for a + * member of a fan-out. Mirrors the blackboard's own slot naming + * (`RoleBlackboard#ownSlot`), so a `findings` slot in the artifact panel reads + * the same as the session that wrote it. + */ +export function roleLabel(s: SessionInfo): string | null { + const r = s.collaborationRole; + if (!r) return null; + return r.ordinal > 1 ? `${r.roleName}#${r.ordinal}` : r.roleName; +} diff --git a/web/src/state/blackboard.test.ts b/web/src/state/blackboard.test.ts new file mode 100644 index 0000000..222630b --- /dev/null +++ b/web/src/state/blackboard.test.ts @@ -0,0 +1,241 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; + +const requestMock = vi.hoisted(() => + vi.fn<(msg: unknown, opts?: unknown) => Promise>(), +); +vi.mock("./connection", () => ({ + getClient: () => ({ request: requestMock }), + newRequestId: () => `r-${Math.random()}`, +})); + +import { + blackboard, + clearSelection, + closeBlackboard, + fetchIndex, + isBlackboardOpen, + openBlackboard, + refKey, + refreshBlackboard, + selectArtifact, + _resetBlackboardForTest, +} from "./blackboard"; +import type { BlackboardIndexEntry } from "../protocol/types"; + +function entry(kind: string, slot: string | null = null): BlackboardIndexEntry { + return { + kind, + slot, + version: 1, + authorSub: `agent:goal:${kind}`, + authorRole: kind === "findings" ? "review" : "search", + updatedAt: 1_700_000_000_000, + bytes: 42, + }; +} + +const indexResult = (sessionId: string, entries: BlackboardIndexEntry[], goal = "ship it") => ({ + type: "blackboard.index.result" as const, + requestId: "x", + sessionId, + goal, + entries, +}); + +const readResult = (content: string | null, kind = "spec") => ({ + type: "blackboard.read.result" as const, + requestId: "x", + sessionId: "goal-1", + artifact: + content === null + ? null + : { + kind, + slot: null, + version: 2, + content, + authorSub: "agent:goal:orch", + authorRole: "orchestrator", + createdAt: 1_700_000_000_000, + }, +}); + +beforeEach(() => _resetBlackboardForTest()); +afterEach(() => { + requestMock.mockReset(); + _resetBlackboardForTest(); +}); + +describe("fetchIndex", () => { + it("adopts the daemon's goal session id, not the one it was asked about", async () => { + // Focusing a role-child must land on the same board as focusing its + // orchestrator; the daemon does the hop and the client must not re-derive it. + requestMock.mockResolvedValueOnce(indexResult("goal-1", [entry("spec")])); + await fetchIndex("child-7"); + + expect(requestMock.mock.calls[0]![0]).toMatchObject({ + type: "blackboard.index", + sessionId: "child-7", + }); + expect(blackboard().goalSessionId).toBe("goal-1"); + expect(blackboard().goal).toBe("ship it"); + expect(blackboard().entries).toHaveLength(1); + expect(blackboard().error).toBeNull(); + }); + + it("surfaces a daemon rejection instead of showing an empty board", async () => { + requestMock.mockRejectedValueOnce(new Error("Session not found")); + await fetchIndex("nope"); + expect(blackboard().error).toBe("Session not found"); + expect(blackboard().loading).toBe(false); + }); + + it("drops a slow reply for a board the user already navigated away from", async () => { + let releaseFirst: (v: unknown) => void = () => {}; + requestMock + .mockImplementationOnce(() => new Promise((res) => (releaseFirst = res))) + .mockResolvedValueOnce(indexResult("goal-2", [entry("diff")], "second goal")); + + const first = fetchIndex("goal-1"); + const second = fetchIndex("goal-2"); + await second; + releaseFirst(indexResult("goal-1", [entry("spec")], "first goal")); + await first; + + expect(blackboard().goal).toBe("second goal"); + expect(blackboard().entries.map((e) => e.kind)).toEqual(["diff"]); + }); + + it("keeps the previous board on screen while re-fetching the same one", async () => { + requestMock.mockResolvedValueOnce(indexResult("goal-1", [entry("spec")])); + await fetchIndex("goal-1"); + + let release: (v: unknown) => void = () => {}; + requestMock.mockImplementationOnce(() => new Promise((res) => (release = res))); + const pending = refreshBlackboard(); + // Mid-refresh the panel must not blank out — a fleet polling every few + // seconds would flicker on every tick. + expect(blackboard().loading).toBe(true); + expect(blackboard().entries).toHaveLength(1); + release(indexResult("goal-1", [entry("spec"), entry("diff")])); + await pending; + expect(blackboard().entries).toHaveLength(2); + }); + + it("clears a selection whose artifact left the board", async () => { + requestMock.mockResolvedValueOnce(indexResult("goal-1", [entry("spec")])); + await fetchIndex("goal-1"); + requestMock.mockResolvedValueOnce(readResult("SPEC")); + await selectArtifact({ kind: "spec", slot: null }); + expect(blackboard().artifact?.content).toBe("SPEC"); + + // Goal torn down and rebuilt: `spec` is gone. Leaving the body pane showing + // it would display an artifact nothing in the list points at. + requestMock.mockResolvedValueOnce(indexResult("goal-1", [entry("diff")])); + await refreshBlackboard(); + expect(blackboard().selected).toBeNull(); + expect(blackboard().artifact).toBeNull(); + }); + + it("keeps a selection that is still on the board", async () => { + requestMock.mockResolvedValueOnce(indexResult("goal-1", [entry("spec")])); + await fetchIndex("goal-1"); + requestMock.mockResolvedValueOnce(readResult("SPEC")); + await selectArtifact({ kind: "spec", slot: null }); + + requestMock.mockResolvedValueOnce(indexResult("goal-1", [entry("spec"), entry("diff")])); + await refreshBlackboard(); + expect(blackboard().selected).toEqual({ kind: "spec", slot: null }); + expect(blackboard().artifact?.content).toBe("SPEC"); + }); + + it("refreshBlackboard is a no-op with no board open", async () => { + await refreshBlackboard(); + expect(requestMock).not.toHaveBeenCalled(); + }); +}); + +describe("selectArtifact", () => { + beforeEach(async () => { + requestMock.mockResolvedValueOnce( + indexResult("goal-1", [entry("findings", "review"), entry("findings", "review#2")]), + ); + await fetchIndex("goal-1"); + requestMock.mockReset(); + }); + + it("requests the exact slot, so one reviewer is never served for another", async () => { + requestMock.mockResolvedValueOnce(readResult("SECOND", "findings")); + await selectArtifact({ kind: "findings", slot: "review#2" }); + expect(requestMock.mock.calls[0]![0]).toMatchObject({ + type: "blackboard.read", + sessionId: "goal-1", + kind: "findings", + slot: "review#2", + }); + expect(blackboard().artifact?.content).toBe("SECOND"); + }); + + it("treats a null artifact as pending, not as an error", async () => { + requestMock.mockResolvedValueOnce(readResult(null)); + await selectArtifact({ kind: "findings", slot: "review" }); + expect(blackboard().artifact).toBeNull(); + expect(blackboard().artifactError).toBeNull(); + expect(blackboard().artifactLoading).toBe(false); + }); + + it("drops a slow body for an artifact that is no longer selected", async () => { + let releaseFirst: (v: unknown) => void = () => {}; + requestMock + .mockImplementationOnce(() => new Promise((res) => (releaseFirst = res))) + .mockResolvedValueOnce(readResult("SECOND", "findings")); + + const first = selectArtifact({ kind: "findings", slot: "review" }); + const second = selectArtifact({ kind: "findings", slot: "review#2" }); + await second; + releaseFirst(readResult("FIRST", "findings")); + await first; + + expect(blackboard().selected).toEqual({ kind: "findings", slot: "review#2" }); + expect(blackboard().artifact?.content).toBe("SECOND"); + }); + + it("does nothing when no board is loaded", async () => { + _resetBlackboardForTest(); + await selectArtifact({ kind: "spec", slot: null }); + expect(requestMock).not.toHaveBeenCalled(); + }); + + it("clearSelection empties the body pane without touching the index", async () => { + requestMock.mockResolvedValueOnce(readResult("BODY", "findings")); + await selectArtifact({ kind: "findings", slot: "review" }); + clearSelection(); + expect(blackboard().selected).toBeNull(); + expect(blackboard().artifact).toBeNull(); + expect(blackboard().entries).toHaveLength(2); + }); +}); + +describe("open / close", () => { + it("openBlackboard opens and fetches; closeBlackboard leaves the data cached", async () => { + requestMock.mockResolvedValue(indexResult("goal-1", [entry("spec")])); + openBlackboard("goal-1"); + await vi.waitFor(() => expect(blackboard().entries).toHaveLength(1)); + expect(isBlackboardOpen()).toBe(true); + + closeBlackboard(); + expect(isBlackboardOpen()).toBe(false); + // Cached, so re-opening the same board doesn't flash empty. + expect(blackboard().entries).toHaveLength(1); + }); +}); + +describe("refKey", () => { + it("distinguishes slots within a kind and survives a null slot", () => { + expect(refKey({ kind: "findings", slot: "review" })).not.toBe( + refKey({ kind: "findings", slot: "review#2" }), + ); + expect(refKey({ kind: "spec", slot: null })).toBe(refKey({ kind: "spec", slot: null })); + }); +}); diff --git a/web/src/state/blackboard.ts b/web/src/state/blackboard.ts new file mode 100644 index 0000000..0d49d39 --- /dev/null +++ b/web/src/state/blackboard.ts @@ -0,0 +1,208 @@ +/** + * Goal-blackboard slice — the index of a collaboration's artifacts and the + * body of whichever one the user selected. + * + * Daemon-canonical, like every other slice here: nothing is derived locally. + * The daemon resolves a role-child's session id to its parent's goal, so + * `goalSessionId` is always taken from the RESULT rather than from whatever + * the caller passed — focusing a child and focusing its orchestrator must land + * on the same board. + */ + +import { createSignal } from "solid-js"; + +import { getClient, newRequestId } from "./connection"; +import type { + BlackboardArtifact, + BlackboardIndexEntry, + BlackboardIndexResultMsg, + BlackboardReadResultMsg, +} from "../protocol/types"; + +/** Identifies one artifact within a board. `slot: null` = the singleton. */ +export interface ArtifactRef { + kind: string; + slot: string | null; +} + +interface State { + /** Session id the board was requested for (the goal, per the daemon). */ + goalSessionId: string | null; + goal: string; + entries: BlackboardIndexEntry[]; + loading: boolean; + error: string | null; + fetchedAt: number; + selected: ArtifactRef | null; + artifact: BlackboardArtifact | null; + artifactLoading: boolean; + artifactError: string | null; +} + +const EMPTY: State = { + goalSessionId: null, + goal: "", + entries: [], + loading: false, + error: null, + fetchedAt: 0, + selected: null, + artifact: null, + artifactLoading: false, + artifactError: null, +}; + +const [state, setState] = createSignal(EMPTY); +const [openSignal, setOpenSignal] = createSignal(false); + +export const blackboard = state; +export const isBlackboardOpen = openSignal; + +/** + * Stable key for an artifact ref — also the row key in the index list. + * + * Separated by an explicit U+0000 escape rather than a printable character: + * `kind` is an open namespace (`extra/`) and `slot` is daemon-generated, + * so any visible delimiter is one future naming choice away from letting two + * distinct artifacts collide onto one key — which would silently show the + * wrong body under the right row. Written as `\u0000`, never as a literal + * control byte: a raw NUL in source is invisible and makes git treat the whole + * file as binary. + */ +const REF_KEY_SEP = "\u0000"; + +export function refKey(ref: ArtifactRef): string { + return `${ref.kind}${REF_KEY_SEP}${ref.slot ?? ""}`; +} + +/** + * Which request each async path is currently serving. Compared on arrival so a + * slow reply for a board (or artifact) the user has already navigated away + * from is dropped instead of overwriting the newer one. + */ +let indexInflight: string | null = null; +let artifactInflight: string | null = null; + +/** + * Open the drawer for a session that is part of a collaboration — either the + * orchestrator or any of its role-children; the daemon resolves the hop. + * + * Switching to a DIFFERENT session resets the board. Re-opening the same one + * keeps the current entries on screen and refreshes underneath, so the panel + * doesn't blank out on every open. + */ +export function openBlackboard(sessionId: string): void { + setOpenSignal(true); + void fetchIndex(sessionId); +} + +export function closeBlackboard(): void { + setOpenSignal(false); +} + +export async function fetchIndex(sessionId: string): Promise { + indexInflight = sessionId; + setState((s) => + s.goalSessionId === sessionId + ? { ...s, loading: true, error: null } + : { ...EMPTY, goalSessionId: sessionId, loading: true }, + ); + try { + const id = newRequestId(); + const result = await getClient().request( + { type: "blackboard.index", id, sessionId }, + { + waitForResult: (m) => + m.type === "blackboard.index.result" && m.requestId === id ? m : undefined, + timeoutMs: 8_000, + }, + ); + if (indexInflight !== sessionId) return; + setState((s) => ({ + ...s, + // The daemon's answer, not our question: a child id in, its parent out. + goalSessionId: result.sessionId, + goal: result.goal, + entries: result.entries, + loading: false, + error: null, + fetchedAt: Date.now(), + // Drop a selection whose artifact is no longer on the board (goal torn + // down and rebuilt) — otherwise the body pane shows a stale artifact + // with nothing in the list pointing at it. + ...(s.selected && !result.entries.some((e) => refKey(e) === refKey(s.selected!)) + ? { selected: null, artifact: null, artifactError: null } + : {}), + })); + } catch (err) { + if (indexInflight !== sessionId) return; + setState((s) => ({ ...s, loading: false, error: message(err) })); + } +} + +/** Re-fetch the currently-open board. No-op when nothing is open. */ +export function refreshBlackboard(): Promise { + const id = state().goalSessionId; + return id ? fetchIndex(id) : Promise.resolve(); +} + +/** Load one artifact's body into the detail pane. */ +export async function selectArtifact(ref: ArtifactRef): Promise { + const sessionId = state().goalSessionId; + if (!sessionId) return; + const key = refKey(ref); + artifactInflight = key; + setState((s) => ({ + ...s, + selected: ref, + // Keep the previous body visible while the new one loads rather than + // flashing empty — but never show it as if it were the new selection. + artifact: s.selected && refKey(s.selected) === key ? s.artifact : null, + artifactLoading: true, + artifactError: null, + })); + try { + const id = newRequestId(); + const result = await getClient().request( + { type: "blackboard.read", id, sessionId, kind: ref.kind, slot: ref.slot }, + { + waitForResult: (m) => + m.type === "blackboard.read.result" && m.requestId === id ? m : undefined, + timeoutMs: 15_000, + }, + ); + if (artifactInflight !== key) return; + setState((s) => ({ + ...s, + artifact: result.artifact, + artifactLoading: false, + artifactError: null, + })); + } catch (err) { + if (artifactInflight !== key) return; + setState((s) => ({ ...s, artifactLoading: false, artifactError: message(err) })); + } +} + +export function clearSelection(): void { + artifactInflight = null; + setState((s) => ({ + ...s, + selected: null, + artifact: null, + artifactLoading: false, + artifactError: null, + })); +} + +function message(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** Test hook — reset the slice between tests. */ +export function _resetBlackboardForTest(): void { + indexInflight = null; + artifactInflight = null; + setState(EMPTY); + setOpenSignal(false); +}