From 15f503c920380b2409c7e2a104b2ee6394bfa8ad Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 27 Jul 2026 12:38:30 +0800 Subject: [PATCH 1/4] feat: render collaboration fleets as one unit in the web session list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `collaborationRole` has been on the wire since #256 and the UI ignored it entirely, so an N-role collaboration rendered as N+1 unrelated sessions — the exact opposite of the mental model a collaborative session exists to create. Group them. `lib/fleet.ts` is a pure grouping/filtering module (no Solid, so the interesting logic is testable without a reactive root); the pane consumes it. What the rows now say: - Children nest under their orchestrator behind a continuous left rail, keyed by role (`review#2`) rather than by their daemon-generated `:-N` name, whose parent prefix is pure repetition once the row is already nested. Full name stays in the tooltip. - The orchestrator shows its goal, and folds its fleet shut from a toggle that is a SIBLING of the row button, not a child of it — nesting a button inside a button is invalid HTML. - Read-only roles are badged `ro`. That badge is §6's independence property made visible: a scout's leaf identity carries no `tools:write` at all. - Children always show their backend chip, INCLUDING "claude". Standalone rows suppress the default-backend chip, but which model sits behind which role is the entire point of a mixed fleet and must not be inferable only from a chip's absence. Two cases that are easy to get wrong, so both are pinned by tests: - An orphan child — `parentSessionId` naming a session not in the list, because the parent was destroyed while children drain or hasn't reached this client yet — is promoted to top level rather than dropped. A session that silently disappears from the sidebar is a far worse failure than one rendered without its group. It keeps its role badges but shows its full name, since no parent row is there to supply context. - Grouping happens BEFORE filtering. A query matching only a child keeps the orchestrator as (dimmed) context; a bare `reasoning` row with no indication of its goal is less useful than no filter at all. Web suite 319 → 341. --- web/src/components/SessionListPane.test.tsx | 113 ++++++++ web/src/components/SessionListPane.tsx | 298 +++++++++++++++++--- web/src/lib/fleet.test.ts | 172 +++++++++++ web/src/lib/fleet.ts | 149 ++++++++++ 4 files changed, 685 insertions(+), 47 deletions(-) create mode 100644 web/src/lib/fleet.test.ts create mode 100644 web/src/lib/fleet.ts diff --git a/web/src/components/SessionListPane.test.tsx b/web/src/components/SessionListPane.test.tsx index 63242524..5b8e7e01 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 a93bdb3a..ab1e2903 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/lib/fleet.test.ts b/web/src/lib/fleet.test.ts new file mode 100644 index 00000000..1a156963 --- /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 00000000..5440093b --- /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; +} From bc74d32b604ac7c97d8dc844a2ead9343ac68a01 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 27 Jul 2026 12:47:47 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20owner-facing=20goal=20blackboard=20?= =?UTF-8?q?verbs=20=E2=80=94=20blackboard.index=20+=20blackboard.read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blackboard is only reachable through the role-scoped MCP tools, which means the person who created the collaboration and is paying for it cannot see what their fleet produced. Add two read verbs for the human. The exemption, stated plainly because it looks like 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 goal's owner is not a participant. They can already read every child's transcript, so withholding the artifacts those transcripts produced protects nothing and only makes the fleet unobservable. That reasoning is load-bearing, so it is encoded in the type system rather than in a comment alone. `Blackboard#forOwner` returns a distinct `OwnerBlackboard` class, not a `RoleBlackboard` with a flag: a flag makes "unscoped" reachable by passing a boolean, whereas an explicit `forOwner()` is greppable and cannot be handed where a role handle is expected. It exposes no `write` — an owner write would land unattributed and unscoped on a board whose entire contract is attributable handoffs — and tenant scoping still applies in full. Ownership is checked before any of that, and `sessionId` may name either the orchestrator or one of its role-children. Clients focus children as often as parents, and a client that walked `parentSessionId` wrongly would get an EMPTY board rather than an error — the least debuggable outcome available. The parent is re-fetched through `#getOwnedSession` rather than trusted from the child's field, so the ownership check binds to the session whose artifacts are about to be read. Scopes, no new one minted: `index` takes `session:list` (metadata about a session the holder can already enumerate, no bodies) and `read` takes `session:watch` (a body is session content, the same class as streamed output). Both already sit in WATCHER_SCOPES and OPERATOR_SCOPES, so every token minted before this keeps working — a new scope would have 403'd them all. Also: an unwritten artifact returns `null`, not an error. A collaboration in flight legitimately has empty lanes, and a client can't render that as "pending" if the daemon calls it a failure. An orphaned child whose orchestrator was destroyed DOES error, because teardown drops the artifacts and "empty" would otherwise be indistinguishable from "gone". Daemon suite 2044 → 2063. The three security-relevant tests were mutation-checked — dropping the tenant check, swapping the two scope tiers, and letting a plain session through each fail exactly one test. --- packages/protocol/src/schemas.test.ts | 9 + packages/protocol/src/schemas.ts | 23 +++ packages/protocol/src/types.ts | 97 +++++++++++ src/daemon/blackboard/service.ts | 55 +++++++ src/daemon/server.ts | 1 + src/daemon/session-manager.ts | 145 +++++++++++++++- src/tests/blackboard.test.ts | 66 ++++++++ src/tests/collaboration.test.ts | 227 ++++++++++++++++++++++++++ src/tests/protocol.test.ts | 4 + 9 files changed, 626 insertions(+), 1 deletion(-) diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index f81a3510..2f92702a 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 84132a48..f8c13626 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 11698136..f4efbf79 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 7ed8a05b..2f0520e9 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 110b0542..8f066938 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 49af9d8f..b1233f70 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 f95fcf25..436a7d29 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 61857887..b6b84457 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 01cc3e00..250e8fae 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": From 86b60583ed42dfa16aed5241d0f808e7cc1bca39 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 27 Jul 2026 12:53:39 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20goal-blackboard=20drawer=20?= =?UTF-8?q?=E2=80=94=20read=20what=20a=20collaboration=20actually=20produc?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session list now shows the fleet; this shows its OUTPUT. §4 has the orchestrator hold an index and never the bodies — and so, until now, did the UI: every handoff between role-children happened entirely off-screen. Two panes. The index (kind · slot · version · author · size) on the left, one artifact body on the right, fetched only when picked. That split is the design's, not a layout preference: a `diff` can be 256 KB, and the index exists precisely so you can see what's on the board without paying to load it. The test asserts no `
    ` renders until you click.
    
    Details that carry meaning rather than decoration:
    
    - Rows sort in SDLC flow order (spec → research → adr → task-list → diff
      → findings), not alphabetically, so the board reads as the pipeline it
      is. Alphabetical buries `spec` under `diff`.
    - Every writer slot is shown. Two `findings` rows reading `review` and
      `review#2` is the visible form of MULTI_WRITER_KINDS; one row would
      mean a panel had silently collapsed into a single voice.
    - A `null` artifact renders as "not written yet", never as an error. The
      daemon returns null for exactly this reason — an empty lane is a normal
      state of a collaboration in flight.
    - Polls every 4s while open and tears the timer down on close. There's no
      push channel for the board, and a stale panel makes a working fleet
      look stalled; a timer surviving the close would hit the daemon for the
      life of the tab, so a test pins that too.
    
    The trigger is a `board` chip in SessionControls, shown for an
    orchestrator OR any role-child (the daemon resolves the hop, so both land
    on the same board) and gated on the daemon advertising the `blackboard`
    capability — against an older daemon the affordance doesn't appear rather
    than appearing and erroring on click.
    
    The state slice takes `goalSessionId` from the RESULT, not from the id it
    asked about, so focusing a child and focusing its orchestrator converge.
    Both async paths drop replies for a board or artifact the user has since
    navigated away from, and a refresh that no longer lists the selected
    artifact clears the body pane instead of stranding it.
    
    Web suite 341 → 370.
    ---
     web/src/components/BlackboardDrawer.test.tsx | 191 +++++++++++
     web/src/components/BlackboardDrawer.tsx      | 317 +++++++++++++++++++
     web/src/components/SessionControls.test.tsx  |  53 +++-
     web/src/components/SessionControls.tsx       |  31 ++
     web/src/components/Shell.tsx                 |   2 +
     web/src/state/blackboard.test.ts             | 241 ++++++++++++++
     web/src/state/blackboard.ts                  | Bin 0 -> 6081 bytes
     7 files changed, 834 insertions(+), 1 deletion(-)
     create mode 100644 web/src/components/BlackboardDrawer.test.tsx
     create mode 100644 web/src/components/BlackboardDrawer.tsx
     create mode 100644 web/src/state/blackboard.test.ts
     create mode 100644 web/src/state/blackboard.ts
    
    diff --git a/web/src/components/BlackboardDrawer.test.tsx b/web/src/components/BlackboardDrawer.test.tsx
    new file mode 100644
    index 00000000..aff89a03
    --- /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 00000000..f20b477f
    --- /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 aedcb726..7de9ab0e 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 068ba0fb..704cbf21 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/Shell.tsx b/web/src/components/Shell.tsx index 02c902f3..87109db8 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/state/blackboard.test.ts b/web/src/state/blackboard.test.ts new file mode 100644 index 00000000..222630b3 --- /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 0000000000000000000000000000000000000000..4e8cf298291638fedb95d4f2236f3b0f6f85e241 GIT binary patch literal 6081 zcmcIo+in}l5zVu{qAUcvLqVdPhX4U(S#Z{JAnbZ~At?v~!&tRAO|rvbH`6^7m(UW( zN8}6hB{@}nnIR=P2(rJ2Yn&&+tHAG9ZDwOCpO zTm;?dajWYg*7EZAN-vx}87u3o8LQ@)8Z+0#FTxCL;uY}+zx0SKZW%iz6R6hSJm{Jl zhu4}78>Cw}T#H5WI=JRR!#DD-(I?}XX=*qfbO>;t3_NyZ;QUf~ZCgB=A`!QLr&QB6boBmMR1| z&Dou}@hWgI$SgZ2Su8i%Gv|@Q(89L!v{;3f8f2O+A^h-n$`wClL)#at(>DXC|q9JQ| zG40T9mYN<9i2hiB^De>Rc<1gF-LkN`lwU5N9+Pujv=De0H!!c8i6Z7Ilz!+0%q5K_BWD8*Z%v z;3|0sG$yEZ#l?+qTnef(tOw#I$+Lpa>`-waKF_z=PvplVJ)>T^y25<=%u5=lYD6`H zQ|K3EvxjO#l7~Di`3eqovh`gP$^5_iJae_?OeU@p7AF?yL{F! z?Pqf zUuSCTtY>$h-YLNq<9TfgEpD-bGA`?O~ra%q_8=yF2j_Rs1`V=0L&~iJXBBR0IBokXrR%rpP+P0pi29 zM0i;Kyjw1D-vIDf%986KH*0TN_#@Up=O7O-ViX6^T+HW{oit`TL-Xzs7fQ#dJ3HWo z*{}b!mJ7^oN$t5}wO71rj5CWZWD$Fd)V9ye5&{7YvW{HG_I9L?^JHbJU{&no95pzT zNcT0L9htdh9x`qq1dv!x3o1hIuReUZyuSSYwhRLp7BnpLYe_1u^$FuRr&5F#nPk`I zS})_;1cS^RT>5~fL>KTt1hl~}1e{jTKyM+)GafHwo29b4VQaP1!G7HmI?c|>t&ks^ z#~(NlOP6%1;GSLU`mRc8zADm$26%9oa2WoGjEfUlplba}qsu8i7YY+0=xpFAh;)C) zapQvSZTa#JnMgV#v3H8|&uyBa2(v$8vNA!x;Q-!B1VRECh(KuxH>yq~#HtDdI)97E z5b|(`?&v8%uE$TrH&2OsrfAEmAj!t` zBaskhXcj1x2F=!+wklbc9|JZRi?9SuZ(1r7S~VD^IpIxL3MpTimflv$l6EMk-a~pufC#1 z_7~gmV#Iwx3mQpBOpMia-kYd33k~>x#E|lbpI*NfN#)&=dnrL?Le(xFUiFJRNNmr@ z5a^36CTviZBb36YXiraj#|A#t*Z=~`~DJe%atAG^8lO^Ns)o${J!7X%E zX+|;AGS3Wr0pz5s?)6uLL2@K=L^hjdxZ>^pb#HDfv_U}n<2Ge|I}iwOaT0_WQH7cf zQp^31px)=P2#pMi1sYvVb=)sI!4%J<8Dvg?A_Xc_jYwZs!tWg}fexrz@ASOs-ETa9uLY`32NKv9B$w=xCv?WM8nhr~S9lB6;L zVFv3ycSyIu(xg#AW-Pr}wj1gHdrUJ{z5#K^qhm2f6XM1XP;6&6x&{)8B$|W7P_Wb0 zPkJ@BFemB)OY>_0M?Ru;hfW3sh+{+|^5Sz|-|JjXOKOq-r)2MZ-}s_p^C@6ou#YKz zg+MywGy^S{&l-F!Cz@YR(0Th`LC=5p`!5T6u0%mKYdXr>Z;BTYPLckkq+Pv>@@Csv z`to%F#Ey{Wr+b(vsCbFJ^Lzho1c|!y>w8WW221X|Naatbb*=o32qLa#u zeCm5K!Ov01(+2xu)Ae$k_utE~bR7u1oDzDm^0+w1{9+MDIvqj2xfZKJUnPo%Kglbd h&|zR^lRMeo9aXzj=PZ1w3i(rx6sC8ClVpnA{Tl$(%MJhl literal 0 HcmV?d00001 From a553383c908361eb3f8660c66ac6dd7912cc81f6 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Mon, 27 Jul 2026 13:00:50 +0800 Subject: [PATCH 4/4] fix: a literal NUL in refKey made blackboard.ts invisible to code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The artifact-ref key separator was written as a raw control byte instead of an escape. Nothing that normally catches a mistake said a word: it typechecked, it linted, all 370 web tests passed. The only symptom was `git diff --stat` reporting `Bin 0 -> 6081 bytes` — git classifies a file containing a NUL as binary, so the file stopped producing diffs and would have reached the PR unreviewable. The separator itself was the right idea and stays, now as an explicit `\u0000` escape in a named constant. `kind` is an open namespace (`extra/`) and `slot` is daemon-generated, so any printable delimiter is one future naming choice away from collapsing two distinct artifacts onto one key — which would render the wrong body under the right row. Adds `src/tests/source-hygiene.test.ts` to make the class of mistake loud: it walks every source file in the repo and fails on a raw NUL. The walker asserts its own file count first, so a broken walk can't pass by finding nothing, and the guard is mutation-checked: planting a NUL in lib/fleet.ts fails it, removing it passes. Daemon suite 2063 to 2064. --- src/tests/source-hygiene.test.ts | 60 +++++++++++++++++++++++++++++++ web/src/state/blackboard.ts | Bin 6081 -> 6617 bytes 2 files changed, 60 insertions(+) create mode 100644 src/tests/source-hygiene.test.ts diff --git a/src/tests/source-hygiene.test.ts b/src/tests/source-hygiene.test.ts new file mode 100644 index 00000000..b844eefc --- /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/state/blackboard.ts b/web/src/state/blackboard.ts index 4e8cf298291638fedb95d4f2236f3b0f6f85e241..0d49d39f0787371cd5eaedcf7fb7b5cc23f28522 100644 GIT binary patch delta 566 zcmX|;&5G1O6otV>FM6Rq!eMaX&p0i(8-I2!iXwx`mfOWgm4u;D~oI+;2JX9Otn3%;&3^~uL?zZXAl{sC+w B!PNi& delta 26 icmcat