diff --git a/.claude/skills/verify-berthd/features/README.md b/.claude/skills/verify-berthd/features/README.md index 11a07f637..0e18ec609 100644 --- a/.claude/skills/verify-berthd/features/README.md +++ b/.claude/skills/verify-berthd/features/README.md @@ -16,6 +16,7 @@ The maintained list of what a person can do in the app, and how to prove each on | Back and forward: the window strip's arrows, ⌘[ and ⌘], the mouse's buttons; archiving or removing the worktree in front goes back, not Home; the dashboard keeps its scroll | [navigation-history.md](navigation-history.md) | `navigation-history`; `app/e2e/back-forward.spec.ts` asserts it | | Shortcuts in the keyboard's layout (⌘Å and ⌘¨ for Back and Forward on Norwegian) in tooltips, the Keyboard shortcuts sheet, Settings and ⌘K; the sheet's descriptions fit their lines | [navigation-history.md](navigation-history.md) (`history-layout`) | `keyboard-layout`, `keyboard-layout-us`; `app/e2e/back-forward.spec.ts` asserts it | | Stopping agents from the Agent Dashboard: one, the picked ones, a column, Clean up; archiving their worktrees where it's safe | [dashboard-stop.md](dashboard-stop.md) | `dashboard-stop-archive`; `app/e2e/dashboard-stop-archive.spec.ts` asserts it | +| The board's columns and counts, and Needs you's sub-states: the preset, the editor, a card tagged not sorted, an agent's edit spreading to a second box, a real form moving when its answer arrives, a wait already open sorted when the set turns on | [dashboard-sub-states.md](dashboard-sub-states.md) | `dashboard-sub-states`; `dashboard-sub-states-live` and `dashboard-sub-states-open-waits` (real boxes, `--agent`); `app/e2e/dashboard-sub-states.spec.ts` asserts it | | Plugins: built-in and your own, what they add (screens, sidebar items, widgets, panels) | [plugins.md](plugins.md) | `plugin-nav-badges` (sidebar items); `plugin-attach-live` (`api.attach`, a real box, `--agent`) | | Home's widget grid | [home-widgets.md](home-widgets.md) | none yet; `app/e2e/home-widgets.spec.ts` asserts it | | Questions in chat: an agent's form of questions as a card, answered on a real box; kept with Retry and Copy when it can't be | [chat-questions.md](chat-questions.md) | `chat-questions-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` on the mock | diff --git a/.claude/skills/verify-berthd/features/dashboard-sub-states.md b/.claude/skills/verify-berthd/features/dashboard-sub-states.md new file mode 100644 index 000000000..4e8a3042c --- /dev/null +++ b/.claude/skills/verify-berthd/features/dashboard-sub-states.md @@ -0,0 +1,45 @@ +# The board's columns, and the sub-states of Needs you + +The Agent Dashboard (`app/src/views/dashboard/dashboard-view.tsx`) has four columns: Needs you, Working, Done and Idle, each with a title, a count and an empty message, oldest in its state first. An optional set of sub-states (`internal/box/substates.go`, `app/src/lib/substates.ts`) replaces Needs you with one amber column each, in the set's order. `agent_state` stays `waiting`: only `agent_sub_state` says which. With no set, nothing changes anywhere. + +## Sub-features + +- `columns`: the four columns and their counts, with no set. The grid wraps on a narrow window. +- `preset`: Settings → Agents → **Needs you columns** → **Use Needs a decision and Needs you to act**. The board then has both, amber, with their own counts. +- `not-sorted`: a waiting agent with no sub-state, or one the set doesn't have, sits in the first column with a small **Not sorted** tag (`data-testid=not-sorted`). The board passes the tag to the card as a line of its own (`children`), so any card style that takes children shows it. `placeSession` decides, unit tested in `app/src/lib/substates.test.ts`. +- `editor`: add, rename, move up and down, remove, then Save. Nothing half made saves. At most 6. Removing all restores Needs you. +- `warnings`: an older box ("runs an older berthd"), a box with turn check off, and who changed what last. +- `spread`: an edit on one box reaches the others through the app (`lib/substates-sync.ts`), gated on the `substates` capability. A box keeps only a newer stamped set. +- `placing`: end of turn: the verdict's request as ever, then with a waiting verdict a second one for the sub-state, so descriptions never reach the verdict. A form: one request on its words, default until the answer, then `agent.substate`. A permission prompt: the default by rule, no request, no tag. Waits already open when the set or turn check changes, or that the box never tried before it started, are sorted then, one request each (`sortOpenWaits`, `TestWaitsOpenBeforeASetAreSortedWhenItIsTurnedOn`): a form by its words, any other wait by the agent's last message. +- `surfaces`: the field reads the same through `GET sessions`, `berthd sessions --json` and MCP's `berth_sessions` (`sub_state`). Edits work over `/v1/substates`, `berthd substates` and MCP's `berth_substates`, including from a box's own agent; `/v1/turncheck` stays closed to it. + +## How to get to it (user POV) + +- ⌘J opens the Agent Dashboard. Settings (the gear) → Agents, under Turn check, has **Needs you columns**. + +## Driving it with drive.mjs + +- Mock: `node .claude/skills/verify-berthd/scripts/drive.mjs dashboard-sub-states --port $PORT` checks the four columns, turns on the preset, checks both columns' counts and the tagged card from the older box (`gpu`), removes the set and checks the four come back. `app/e2e/dashboard-sub-states.spec.ts` asserts it and the editor. +- Real boxes, with a real Claude Code and the real provider: + + ```sh + export VERIFY_BOX_DIR=/tmp/vb-NNNN + .claude/skills/verify-berthd/scripts/sub-states-box.sh up # two boxes (devq, devr), turn check on devq, a structured Claude + eval "$(.claude/skills/verify-berthd/scripts/dev-box.sh env)" + node .claude/skills/verify-berthd/scripts/drive.mjs dashboard-sub-states-live --port $PORT --agent http://127.0.0.1:9378 + .claude/skills/verify-berthd/scripts/sub-states-box.sh down + ``` + + `dashboard-sub-states-open-waits` (after a fresh `up`, since it starts from no set) has the Claude ask for a key with no set, waits past the form window, turns on the preset in Settings, and checks the wait lands in Needs you to act, untagged, with one `agent.substate` and no second `agent.waiting`. + + In `dashboard-sub-states-live`, an agent's edit over MCP shows in Settings with who made it and spreads to `devr`; a real turn that asks for a choice lands in `decision`; a real form reads `decision → act`, with one `agent.waiting` and one `agent.substate`; the field agrees on the API, the CLI and MCP; clearing the set restores four columns on both boxes. The gateway key comes from `VERIFY_TURNCHECK_KEY_FILE` (default the box's own `turncheck.key`) and goes over stdin, never a command line. +- The classifier: `go test ./internal/box -run TestSubStateReplays` replays recorded gateway answers (`testdata/turncheck/substate-*.json`). Re-record with `BERTH_TURNCHECK_RECORD_KEY_FILE=`, then grep the fixtures for the key. + +## Gotchas + +- Live runs need `labs=0` in the scenario's params. With Labs on, `nav-home` is New task, and Home's Pull requests widget shows the signed-in person's real repositories, so never put that screen in a public PR. +- Unset every `BERTH_*` before running these from inside an agent session, or the worker's `BERTH_DRIVER=structured` turns the dev box's hooks off. +- Claude Code's terminal reports a form as a `permission` wait with tool `AskUserQuestion`, then again about 6 seconds later as a notification with no tool. It's one wait: `sub.From` keeps the first Seq, and the repeat says where the wait is. The structured driver says `question` once. +- A sync that fails retries on its own every 15 seconds, and a hook's refusal (403) isn't retried until the set or the box's info changes. +- A form's real answer can come back under 0.65 and go to Not sorted: the live scenario's question is worded to be a clear task. That's a model's answer, not a bug. +- The mock's only capable box is `devl`, so its `checkout-fix` card is placed and `gpu`'s waiting card is tagged. diff --git a/.claude/skills/verify-berthd/scripts/scenarios/dashboard-sub-states-live.mjs b/.claude/skills/verify-berthd/scripts/scenarios/dashboard-sub-states-live.mjs new file mode 100644 index 000000000..21fcc67c4 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/dashboard-sub-states-live.mjs @@ -0,0 +1,118 @@ +// dashboard-sub-states-live: the sub-states of Needs you on real boxes, with +// a real Claude Code and the real turn check provider. Needs a dev box with +// turn check on and a second box paired (../sub-states-box.sh up). An agent's +// edit over MCP shows in Settings and spreads to the second box; a real form +// lands in the default and moves when the answer arrives; the field reads the +// same through the API, `berthd sessions --json` and MCP; removing the set +// brings the four columns back. See ../../features/dashboard-sub-states.md. +import { execFileSync } from "node:child_process"; +import { globSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export const live = true; +export const params = { sidebar: "list", labs: "0" }; + +const dir = process.env.VERIFY_BOX_DIR ?? "/tmp/verify-berthd-box"; +const root = execFileSync("git", ["rev-parse", "--show-toplevel"]).toString().trim(); +const boxEnv = { ...process.env, BERTH_HOME: join(dir, "box-home"), BERTH_USER_DIR: join(dir, "box-user"), TMUX_TMPDIR: join(dir, "tmux") }; +for (const k of ["BERTH_SESSION", "BERTH_DRIVER", "BERTH_LOCATION", "BERTH_WORKTREE_NAME"]) delete boxEnv[k]; + +// One MCP call, as an agent on the box makes it: `berthd mcp` on its socket. +function mcp(tool, args, caller) { + const lines = [ + { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18" } }, + { jsonrpc: "2.0", method: "notifications/initialized" }, + { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: tool, arguments: args } }, + ]; + const out = execFileSync(join(root, "bin/berthd"), ["mcp"], { input: lines.map((l) => JSON.stringify(l)).join("\n") + "\n", env: { ...boxEnv, ...(caller ? { BERTH_SESSION: caller } : {}) } }).toString(); + const answer = out.trim().split("\n").map((l) => JSON.parse(l)).find((m) => m.id === 2); + const text = answer.result.content[0].text; + if (answer.result.isError) throw new Error(`${tool}: ${text}`); + return JSON.parse(text); +} +const box = (...a) => JSON.parse(execFileSync(join(root, "bin/berthd"), a, { env: boxEnv }).toString()); + +const FORM = { + questions: [{ header: "API key", question: "I can't reach the acme mail provider's dashboard from here. Please open it, create a read-only API key, and store it by running scripts/set-key.sh in your terminal. Tell me when you've done it.", multiSelect: false, options: [{ label: "Done", description: "The key is stored." }, { label: "Stuck", description: "I couldn't do it." }] }], +}; +const PROMPT = `Connect the acme shop to the acme mail provider. You can't reach its dashboard, so first have me make the key: use the AskUserQuestion tool exactly once with this input, before anything else: ${JSON.stringify(FORM)}. When it's answered, reply with one word and stop.`; + +export async function run({ page, shot, step, expect, call }) { + const json = (path, init) => call(path, init); + const sub = (b) => json(`/v1/boxes/${b}/api/substates`); + const session = async () => (await json("/v1/boxes/devq/api/sessions")).find((s) => s.name === "ask"); + + await step("With no sub-states, the board has its four columns"); + await page.getByTestId("nav-home").click(); + const board = page.getByTestId("dashboard-board"); + await expect(board.getByRole("region")).toHaveText([/Needs you/, /Working/, /Done/, /Idle/], { timeout: 30000 }); + await shot("four-columns"); + if ((await session()).agent_sub_state !== undefined) throw new Error("a field with no set"); + + await step("An agent on devq turns on the preset over MCP; nothing but that box was told"); + const edited = mcp("berth_substates", { action: "preset" }, "ask"); + if (edited.states.map((s) => s.id).join() !== "decision,act") throw new Error(JSON.stringify(edited)); + + await step("It spreads to devr through the app, with the same stamp"); + await expect.poll(async () => (await sub("devr")).revision, { timeout: 30000 }).toBe(edited.revision); + const onDevr = await sub("devr"); + if (onDevr.changed_by !== "ask") throw new Error(`devr says it was ${onDevr.changed_by}`); + + await step("The board now has two amber columns in Needs you's place"); + await expect(board.getByRole("region")).toHaveText([/Needs a decision/, /Needs you to act/, /Working/, /Done/, /Idle/], { timeout: 30000 }); + + await step("Settings shows who changed what"); + await page.getByTestId("nav-settings").click(); + await page.getByTestId("settings-nav-agents").click(); + const panel = page.getByTestId("substates"); + await expect(panel.getByRole("textbox", { name: "Name of column 1" })).toHaveValue("Needs a decision"); + await expect(panel.getByTestId("substates-changed")).toContainText("turned on the preset, by ask"); + await panel.scrollIntoViewIfNeeded(); + await shot("settings-after-mcp-edit"); + await page.getByRole("button", { name: "Back to app" }).click(); + + await step("A real turn that ends asking for a choice lands in Needs a decision"); + await call("/v1/boxes/devq/api/sessions/ask/send", { method: "POST", body: JSON.stringify({ text: 'Without using any tool, end your turn with exactly this message: "I can do the acme cart refactor two ways. Option A keeps the old API and is smaller. Option B moves to the new API and takes longer. Which do you prefer?"', when: "idle" }) }); + await expect.poll(async () => { const s = await session(); return `${s.agent_state}/${s.agent_sub_state}`; }, { timeout: 120000, intervals: [500] }).toBe("waiting/decision"); + + await step("A real Claude asks a form: it waits, in the default, until the answer"); + const before = (await session()).state_seq ?? 0; + await call("/v1/boxes/devq/api/sessions/ask/send", { method: "POST", body: JSON.stringify({ text: PROMPT, when: "now" }) }); + await expect.poll(async () => { const s = await session(); return s.agent_state === "waiting" && (s.state_seq ?? 0) > before; }, { timeout: 120000, intervals: [100] }).toBe(true); + const seen = []; + await expect.poll(async () => { const s = await session(); if (seen.at(-1) !== s.agent_sub_state) seen.push(s.agent_sub_state); return s.agent_sub_state; }, { timeout: 30000, intervals: [50] }).toBe("act"); + await step(`It read as: ${seen.join(" → ")}`); + + await step("It moved with no new wait: one agent.waiting, one agent.substate"); + const events = globSync(join(dir, "box-home", "**", "journal", "*.jsonl")).flatMap((f) => readFileSync(f, "utf8").trim().split("\n").map((l) => JSON.parse(l))); + const mine = events.filter((e) => e.data?.session === "ask" && e.seq > before); + const waits = mine.filter((e) => e.type === "agent.waiting").length; + const moved = mine.filter((e) => e.type === "agent.substate"); + if (moved.length !== 1 || moved[0].data.sub_state !== "act") throw new Error(`agent.substate: ${JSON.stringify(moved)}`); + await step(`${waits} agent.waiting, ${moved.length} agent.substate`); + + await step("The field reads the same through the API, the CLI and MCP"); + const api = (await session()).agent_sub_state; + const cli = box("sessions", "--json").find((s) => s.name === "ask")?.agent_sub_state; + const tool = mcp("berth_sessions", {}).find((s) => s.name === "ask")?.sub_state; + if (api !== "act" || cli !== "act" || tool !== "act") throw new Error(`api ${api}, cli ${cli}, mcp ${tool}`); + + await step("The card is in Needs you to act, untagged, and the state is still waiting"); + await page.getByTestId("nav-home").click(); + const act = board.getByRole("region", { name: "Needs you to act" }); + await expect(act.locator("article")).toHaveCount(1, { timeout: 30000 }); + await expect(act.getByTestId("not-sorted")).toHaveCount(0); + if ((await session()).agent_state !== "waiting") throw new Error("not waiting"); + // Cards fade in as they arrive: the shot, and this check, wait for that. + const card = act.locator("article"); + await expect.poll(() => card.evaluate((el) => { let o = 1; for (let n = el; n; n = n.parentElement) o *= Number(getComputedStyle(n).opacity); return o; }), { timeout: 15000 }).toBe(1); + await expect(card).toContainText("acme"); + await shot("two-columns-real-form"); + + await step("Removing the set brings the four columns back, on both boxes"); + mcp("berth_substates", { action: "clear" }, "ask"); + await expect(board.getByRole("region")).toHaveText([/Needs you/, /Working/, /Done/, /Idle/], { timeout: 30000 }); + await expect.poll(async () => (await sub("devr")).states.length, { timeout: 30000 }).toBe(0); + await expect.poll(async () => (await session()).agent_sub_state, { timeout: 15000 }).toBeUndefined(); + await shot("four-columns-after"); +} diff --git a/.claude/skills/verify-berthd/scripts/scenarios/dashboard-sub-states-open-waits.mjs b/.claude/skills/verify-berthd/scripts/scenarios/dashboard-sub-states-open-waits.mjs new file mode 100644 index 000000000..03548ef3a --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/dashboard-sub-states-open-waits.mjs @@ -0,0 +1,65 @@ +// dashboard-sub-states-open-waits: an agent already waiting when the preset +// is turned on is sorted then, on a real box with a real Claude Code and the +// real turn check provider. Needs ../sub-states-box.sh up, and a fresh one: +// it starts from no set. A form asked with no set waits unsorted; turning on +// the preset in Settings places it in Needs you to act, untagged, with no +// second agent.waiting. See ../../features/dashboard-sub-states.md. +import { globSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export const live = true; +export const params = { sidebar: "list", labs: "0" }; + +const dir = process.env.VERIFY_BOX_DIR ?? "/tmp/verify-berthd-box"; + +const FORM = { + questions: [{ header: "API key", question: "I can't reach the acme mail provider's dashboard from here. Please open it, create a read-only API key, and store it by running scripts/set-key.sh in your terminal. Tell me when you've done it.", multiSelect: false, options: [{ label: "Done", description: "The key is stored." }, { label: "Stuck", description: "I couldn't do it." }] }], +}; +const PROMPT = `Connect the acme shop to the acme mail provider. You can't reach its dashboard, so first have me make the key: use the AskUserQuestion tool exactly once with this input, before anything else: ${JSON.stringify(FORM)}. When it's answered, reply with one word and stop.`; + +// The box's form window (settle + turn check's timeout + 2 s) and then some: +// past it, the wait is no longer the form's own placing's. +const FORM_WINDOW_MS = 16000; + +export async function run({ page, shot, step, expect, call }) { + const session = async () => (await call("/v1/boxes/devq/api/sessions")).find((s) => s.name === "ask"); + const board = page.getByTestId("dashboard-board"); + + await step("With no sub-states, a real Claude asks for a key and waits"); + if ((await call("/v1/boxes/devq/api/substates")).states.length !== 0) throw new Error("start from a fresh sub-states-box.sh up: devq has a set"); + const before = (await session()).state_seq ?? 0; + await call("/v1/boxes/devq/api/sessions/ask/send", { method: "POST", body: JSON.stringify({ text: PROMPT, when: "now" }) }); + await expect.poll(async () => { const s = await session(); return s.agent_state === "waiting" && (s.state_seq ?? 0) > before; }, { timeout: 120000, intervals: [200] }).toBe(true); + await page.getByTestId("nav-home").click(); + await expect(board.getByRole("region")).toHaveText([/Needs you/, /Working/, /Done/, /Idle/], { timeout: 30000 }); + await page.waitForTimeout(FORM_WINDOW_MS); + if ((await session()).agent_sub_state !== undefined) throw new Error("placed with no set"); + + await step("Turn on the preset in Settings"); + await page.getByTestId("nav-settings").click(); + await page.getByTestId("settings-nav-agents").click(); + const panel = page.getByTestId("substates"); + await panel.getByRole("button", { name: "Use Needs a decision and Needs you to act" }).click(); + await expect(panel.getByRole("textbox", { name: "Name of column 1" })).toHaveValue("Needs a decision"); + await page.getByRole("button", { name: "Back to app" }).click(); + + await step("The wait that was already open is sorted into Needs you to act"); + await expect.poll(async () => (await session()).agent_sub_state, { timeout: 60000, intervals: [250] }).toBe("act"); + await page.getByTestId("nav-home").click(); + const act = board.getByRole("region", { name: "Needs you to act" }); + const card = act.locator("article"); + await expect(card).toHaveCount(1, { timeout: 30000 }); + await expect(act.getByTestId("not-sorted")).toHaveCount(0); + await expect.poll(() => card.evaluate((el) => { let o = 1; for (let n = el; n; n = n.parentElement) o *= Number(getComputedStyle(n).opacity); return o; }), { timeout: 15000 }).toBe(1); + await shot("open-wait-sorted"); + + await step("No new wait: one agent.waiting for it, and an agent.substate about it"); + const events = globSync(join(dir, "box-home", "**", "journal", "*.jsonl")).flatMap((f) => readFileSync(f, "utf8").trim().split("\n").map((l) => JSON.parse(l))); + const mine = events.filter((e) => e.data?.session === "ask" && e.seq > before); + const waits = mine.filter((e) => e.type === "agent.waiting"); + const placed = mine.filter((e) => e.type === "agent.substate" && e.data.sub_state === "act"); + if (placed.length !== 1) throw new Error(`agent.substate: ${JSON.stringify(mine.filter((e) => e.type === "agent.substate"))}`); + if (waits.some((w) => w.seq > placed[0].seq)) throw new Error("a second agent.waiting after it was sorted"); + if ((await session()).agent_state !== "waiting") throw new Error("not waiting"); + await step(`${waits.length} agent.waiting before it, 1 agent.substate (${placed[0].data.sub_state})`); +} diff --git a/.claude/skills/verify-berthd/scripts/scenarios/dashboard-sub-states.mjs b/.claude/skills/verify-berthd/scripts/scenarios/dashboard-sub-states.mjs new file mode 100644 index 000000000..610a4f5a3 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/dashboard-sub-states.mjs @@ -0,0 +1,47 @@ +// dashboard-sub-states: on the mock, the board's four columns; the preset from +// Settings, two amber columns with their counts and a card tagged not sorted; +// and removing the set brings the four back. See ../../features/dashboard-sub-states.md. +export const params = { labs: "0" }; + +export async function run({ page, shot, step, expect }) { + const board = page.getByTestId("dashboard-board"); + await step("With no sub-states, the board has its four columns"); + await page.getByTestId("nav-home").click(); + await expect(board.getByRole("region")).toHaveText([/Needs you/, /Working/, /Done/, /Idle/]); + await shot("four-columns"); + + await step("Turn on the preset in Settings"); + await page.getByTestId("nav-settings").click(); + await page.getByTestId("settings-nav-agents").click(); + const panel = page.getByTestId("substates"); + await panel.getByRole("button", { name: "Use Needs a decision and Needs you to act" }).click(); + await expect(panel.getByRole("textbox", { name: "Name of column 1" })).toHaveValue("Needs a decision"); + await expect(panel.getByTestId("substates-outdated")).toContainText("gpu runs an older berthd"); + await expect(panel.getByTestId("substates-turncheck-off")).toContainText("Turn check is off on devl"); + await panel.scrollIntoViewIfNeeded(); + await shot("settings"); + await page.getByRole("button", { name: "Back to app" }).click(); + + await step("Two amber columns replace Needs you, with counts and a tagged card"); + await page.getByTestId("nav-home").click(); + await expect(board.getByRole("region")).toHaveText([/Needs a decision/, /Needs you to act/, /Working/, /Done/, /Idle/]); + const decision = board.getByRole("region", { name: "Needs a decision" }); + const act = board.getByRole("region", { name: "Needs you to act" }); + await expect(act.locator("article", { hasText: "Fix checkout webhook retries" })).toBeVisible(); + await expect(act.getByTestId("not-sorted")).toHaveCount(0); + await expect(decision.getByTestId("not-sorted")).toHaveCount(1); + await shot("two-columns"); + + await step("Removing them brings the four back"); + await page.getByTestId("nav-settings").click(); + await page.getByTestId("settings-nav-agents").click(); + await panel.getByRole("button", { name: "Remove Needs a decision" }).click(); + await panel.getByRole("button", { name: "Remove Needs you to act" }).click(); + await panel.getByRole("button", { name: "Save" }).click(); + await expect(panel.getByTestId("substates-changed")).toContainText("removed all sub-states"); + await page.getByRole("button", { name: "Back to app" }).click(); + await page.getByTestId("nav-home").click(); + await expect(board.getByRole("region")).toHaveText([/Needs you/, /Working/, /Done/, /Idle/]); + await expect(board.getByTestId("not-sorted")).toHaveCount(0); + await shot("four-columns-after"); +} diff --git a/.claude/skills/verify-berthd/scripts/sub-states-box.sh b/.claude/skills/verify-berthd/scripts/sub-states-box.sh new file mode 100755 index 000000000..ccbbdb457 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/sub-states-box.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# sub-states-box.sh up|down: what dashboard-sub-states-live drives: a dev box +# with a structured Claude and turn check on (devq, from dev-box.sh), and a +# second box of this build paired to the same agent (devr), so an edit can be +# seen to spread. Unsets every BERTH_* first: a worker's turn hooks off in its +# agents otherwise. The turn check key goes over stdin, never a command line. +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +root=$(git -C "$here" rev-parse --show-toplevel) +for v in $(env | grep -oE '^BERTH_[A-Z_]+'); do unset "$v"; done +export VERIFY_BOX_DIR=${VERIFY_BOX_DIR:-/tmp/verify-berthd-box} +d=$VERIFY_BOX_DIR +second=${VERIFY_SECOND_BOX_ADDR:-127.0.0.1:7546} +second_env=(BERTH_HOME="$d/b2-home" BERTH_USER_DIR="$d/b2-user" TMUX_TMPDIR="$d/b2-tmux") + +case ${1:-} in +up) + key=${VERIFY_TURNCHECK_KEY_FILE:-$HOME/.config/berth/box/turncheck.key} + [[ -r $key ]] || { echo "no turn check key at $key (VERIFY_TURNCHECK_KEY_FILE)" >&2; exit 1; } + VERIFY_DRIVER=claude "$here/dev-box.sh" up + eval "$("$here/dev-box.sh" env)" + mkdir -p "$d/b2-home" "$d/b2-user" "$d/b2-tmux" && chmod 700 "$d/b2-tmux" + env "${second_env[@]}" nohup "$root/bin/berthd" serve --listen "$second" >"$d/b2.log" 2>&1 & + echo $! >"$d/b2.pid" + for _ in $(seq 60); do grep -q "berthd serving" "$d/b2.log" && break; sleep 0.5; done + link=$(env "${second_env[@]}" "$root/bin/berthd" pair --address "$second" --json | jq -r .link) + env BERTH_HOME="$d/lap-home" BERTH_USER_DIR="$d/lap-user" BERTH_UI_ADDR=127.0.0.1:9378 BERTH_PROXY_ADDR=127.0.0.1:9377 "$root/bin/berth" pair "$link" --name devr >/dev/null + jq -n --rawfile k "$key" '{enabled:true,provider:"gateway",model:"typesafe-ai/jev",key_value:($k|gsub("\\s";""))}' | + curl -sf -X PUT -H "Authorization: Bearer $VERIFY_BERTH_TOKEN" -H 'content-type: application/json' http://127.0.0.1:9378/v1/boxes/devq/api/turncheck -d @- >/dev/null + echo "up: boxes devq (turn check on) and devr ($second)" + ;; +down) + [[ -f $d/b2.pid ]] && kill "$(cat "$d/b2.pid")" 2>/dev/null || true + env TMUX_TMPDIR="$d/b2-tmux" tmux -L berth kill-server 2>/dev/null || true + "$here/dev-box.sh" down + ;; +*) + echo "usage: sub-states-box.sh up|down" >&2 + exit 2 + ;; +esac diff --git a/app/e2e/dashboard-sub-states.spec.ts b/app/e2e/dashboard-sub-states.spec.ts new file mode 100644 index 000000000..66a4a8170 --- /dev/null +++ b/app/e2e/dashboard-sub-states.spec.ts @@ -0,0 +1,95 @@ +import { expect, mockOnly, test } from "./fixtures"; + +const BOARD_PREFS = { labs: false, labsChosen: true, version: 3 }; + +// With none set, the board has its four columns, as it always had. +test("with no sub-states the board has its four columns", async ({ app }) => { + mockOnly("the sub-states are a mock box's"); + await app.open({ prefs: BOARD_PREFS }); + await app.page.getByTestId("nav-home").click(); + const board = app.page.getByTestId("dashboard-board"); + await expect(board.getByRole("region")).toHaveText([/Needs you/, /Working/, /Done/, /Idle/]); + await expect(board.getByTestId("not-sorted")).toHaveCount(0); + await app.page.screenshot({ path: test.info().outputPath("four-columns.png") }); +}); + +test("the preset splits Needs you into two amber columns, with counts and a not sorted tag, and removing it restores the one", async ({ app }) => { + mockOnly("the sub-states are a mock box's"); + await app.open({ prefs: BOARD_PREFS }); + const settings = await app.openSettings("agents"); + await settings.getByRole("button", { name: "Use Needs a decision and Needs you to act" }).click(); + await expect(settings.getByRole("textbox", { name: "Name of column 1" })).toHaveValue("Needs a decision"); + await expect(settings.getByRole("textbox", { name: "Name of column 2" })).toHaveValue("Needs you to act"); + await expect(settings.getByTestId("substates-changed")).toContainText("turned on the preset, by laptop:demo"); + + await app.leaveSettings(); + await app.page.getByTestId("nav-home").click(); + const board = app.page.getByTestId("dashboard-board"); + await expect(board.getByRole("region")).toHaveText([/Needs a decision/, /Needs you to act/, /Working/, /Done/, /Idle/]); + const decision = board.getByRole("region", { name: "Needs a decision" }); + const act = board.getByRole("region", { name: "Needs you to act" }); + // Placed on a box that keeps sub-states: in its column, no tag. + await expect(act.locator("article")).toHaveCount(1); + await expect(act.locator("article", { hasText: "Fix checkout webhook retries" })).toBeVisible(); + await expect(act.getByTestId("not-sorted")).toHaveCount(0); + await expect(act.locator("header").getByText("1", { exact: true })).toBeVisible(); + // On an older box it has no sub-state: the default, tagged, not lost. + await expect(decision.locator("article", { hasText: "Plan the checkout release" })).toBeVisible(); + await expect(decision.locator("article")).toHaveCount(1); + await expect(decision.getByTestId("not-sorted")).toHaveCount(1); + await expect(decision.locator("header").getByText("1", { exact: true })).toBeVisible(); + // Amber means needs you, on both. + for (const column of [decision, act]) { + await expect(column.locator("header").locator(".text-warning-foreground").first()).toBeVisible(); + } + await expect(board.getByRole("region", { name: "Needs you", exact: true })).toHaveCount(0); + await app.page.screenshot({ path: test.info().outputPath("two-columns.png") }); + + // Everything else still says Needs you. + await expect(app.page.getByRole("heading", { name: "Agent Dashboard" })).toBeVisible(); + + const again = await app.openSettings("agents"); + await again.getByRole("button", { name: "Remove Needs a decision" }).click(); + await again.getByRole("button", { name: "Remove Needs you to act" }).click(); + await again.getByRole("button", { name: "Save" }).click(); + await expect(again.getByTestId("substates-changed")).toContainText("removed all sub-states"); + await app.leaveSettings(); + await app.page.getByTestId("nav-home").click(); + await expect(board.getByRole("region")).toHaveText([/Needs you/, /Working/, /Done/, /Idle/]); + await expect(board.getByTestId("not-sorted")).toHaveCount(0); +}); + +test("the editor adds, renames, reorders and removes, and says what the boxes can't do", async ({ app }) => { + mockOnly("the sub-states are a mock box's"); + await app.open({ prefs: BOARD_PREFS }); + const settings = await app.openSettings("agents"); + await settings.getByRole("button", { name: "Add a column" }).click(); + // Nothing half made is saved. + await expect(settings.getByRole("button", { name: "Save" })).toBeDisabled(); + await settings.getByRole("textbox", { name: "Name of column 1" }).fill("Waiting on a person"); + await settings.getByRole("textbox", { name: "Description of column 1" }).fill("It needs a person to look at something."); + await settings.getByRole("button", { name: "Add a column" }).click(); + await settings.getByRole("textbox", { name: "Name of column 2" }).fill("Needs a key"); + await settings.getByRole("textbox", { name: "Description of column 2" }).fill("It needs a credential it can't get."); + await settings.getByRole("button", { name: "Move Needs a key up" }).click(); + await settings.getByRole("button", { name: "Save" }).click(); + await expect(settings.getByRole("button", { name: "Save" })).toHaveCount(0); + await expect(settings.getByRole("textbox", { name: "Name of column 1" })).toHaveValue("Needs a key"); + + await settings.getByRole("textbox", { name: "Name of column 1" }).fill("Needs a credential"); + await settings.getByRole("button", { name: "Save" }).click(); + await expect(settings.getByRole("button", { name: "Save" })).toHaveCount(0); + + // An older box, and one where turn check is off, each get a word. + await expect(settings.getByTestId("substates-outdated")).toContainText("gpu runs an older berthd"); + await expect(settings.getByTestId("substates-turncheck-off")).toContainText("Turn check is off on devl"); + + await app.leaveSettings(); + await app.page.getByTestId("nav-home").click(); + await expect(app.page.getByTestId("dashboard-board").getByRole("region")).toHaveText([/Needs a credential/, /Waiting on a person/, /Working/, /Done/, /Idle/]); + + // At most six. + const later = await app.openSettings("agents"); + for (let i = 0; i < 4; i++) await later.getByRole("button", { name: "Add a column" }).click(); + await expect(later.getByRole("button", { name: "Add a column" })).toBeDisabled(); +}); diff --git a/app/src/App.tsx b/app/src/App.tsx index 3e98633ff..110900dc3 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -40,6 +40,7 @@ import { useWindowTitle } from "@/hooks/use-window-title"; import { useApplyTheme } from "@/hooks/use-theme"; import { startOutdatedWatch } from "@/lib/outdated"; import { startBoxDefaultsSync } from "@/lib/box-defaults"; +import { startSubStatesSync } from "@/lib/substates-sync"; import { startSkillsNoticeWatch } from "@/lib/skills-notice"; import { startRunsWatch } from "@/lib/runs"; import { watchStillness } from "@/lib/still"; @@ -104,6 +105,7 @@ export default function App() { // Agents started without the app (another agent, the CLI, a run) follow // the Labs setting too (lib/box-defaults.ts). useEffect(() => (connectedToAgent ? startBoxDefaultsSync() : undefined), [connectedToAgent]); + useEffect(() => (connectedToAgent ? startSubStatesSync() : undefined), [connectedToAgent]); // Which boxes' Shipyard skills aren't installed or are out of date, for // the sidebar's notice (lib/skills-notice.ts). useEffect(() => (connectedToAgent ? startSkillsNoticeWatch() : undefined), [connectedToAgent]); diff --git a/app/src/lib/events.ts b/app/src/lib/events.ts index 7f87e7ab0..c6c790263 100644 --- a/app/src/lib/events.ts +++ b/app/src/lib/events.ts @@ -33,6 +33,9 @@ const refreshes: [prefix: string, parts: BoxPart[]][] = [ ["review.", ["locations"]], // Agent CLIs added or found on the box: the agents it offers. ["agents.", ["info"]], + // The sub-states changed: their revision says to read them again, and a + // waiting agent may read as another now. + ["substates.", ["info", "sessions"]], ]; // The most recent events, newest first, for the Automations view. diff --git a/app/src/lib/mock-substates.ts b/app/src/lib/mock-substates.ts new file mode 100644 index 000000000..be7f16858 --- /dev/null +++ b/app/src/lib/mock-substates.ts @@ -0,0 +1,64 @@ +import type { Session } from "@/lib/api"; +import type { SubState, SubStateSet } from "@/lib/substates"; + +// Sub-states in demo mode: none until you turn them on, kept per box, and +// only on a box whose berthd can (gpu runs an older one). As on a box, a +// stamped set is kept only if newer, and removing all leaves a set with none. + +const held: Record = {}; +const fresh = (box: string) => (held[box] ??= { states: [] }); + +const PRESET: SubState[] = [ + { id: "decision", name: "Needs a decision", description: "The agent needs the person to clarify, give direction, approve, or choose how to approach something. What the person does next is answer.", parent: "waiting" }, + { id: "act", name: "Needs you to act", description: "The agent needs the person to do a task it can't do itself, such as fetching a credential from another service or changing something in a UI it can't reach. What the person does next is that task, then say it's done.", parent: "waiting" }, +]; + +const slug = (name: string) => + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + +function keep(box: string, states: SubState[], change: string, emit: (type: string, data: Record) => void): SubStateSet { + const cur = fresh(box); + const next: SubStateSet = { + states: states.map((s) => ({ ...s, id: s.id || slug(s.name), parent: "waiting" })), + revision: Math.max(Date.now(), (cur.revision ?? 0) + 1), + stamp: Math.random().toString(16).slice(2, 14), + changed_by: "laptop:demo", + change, + changed_at: new Date().toISOString(), + }; + held[box] = next; + emit("substates.changed", { revision: next.revision, change, changed_by: next.changed_by, count: next.states.length }); + return next; +} + +export function subStatesCall(box: string, method: string, path: string, body: unknown, emit: (type: string, data: Record) => void, delay: (v: T) => Promise): Promise | undefined { + if (path !== "substates" && path !== "substates/preset") return undefined; + if (box === "gpu") return Promise.reject(Object.assign(new Error("not found"), { status: 404 })); + if (method === "GET" && path === "substates") return delay(fresh(box)); + if (method === "POST" && path === "substates/preset") return delay(keep(box, PRESET, "turned on the preset", emit)); + if (method === "PUT") { + const req = body as SubStateSet; + if (!req.revision) return delay(keep(box, req.states, req.states.length ? "edited the sub-states" : "removed all sub-states", emit)); + const cur = fresh(box); + if (req.revision > (cur.revision ?? 0) || (req.revision === cur.revision && (req.stamp ?? "") > (cur.stamp ?? ""))) { + held[box] = req; + emit("substates.changed", { revision: req.revision, change: req.change, changed_by: req.changed_by, count: req.states.length }); + } + return delay(held[box]); + } + return undefined; +} + +export const subStatesEdit = (box: string) => ({ substates_revision: held[box]?.revision, substates_stamp: held[box]?.stamp }); + +// mockSubStated is a box's sessions as its berthd answers with a set: the +// checkout agent was placed under "act", and any other waiting agent has no +// sub-state, as one turn check couldn't place. With no set, as they are. +export function mockSubStated(box: string, list: Session[]): Session[] { + const ids = held[box]?.states.map((s) => s.id) ?? []; + if (!ids.length) return list; + return list.map((s) => (s.agent_state === "waiting" && s.name === "checkout-fix-claude" && ids.includes("act") ? { ...s, agent_sub_state: "act" } : s)); +} diff --git a/app/src/lib/mock.ts b/app/src/lib/mock.ts index 729b3412a..a82a3754d 100644 --- a/app/src/lib/mock.ts +++ b/app/src/lib/mock.ts @@ -7,6 +7,7 @@ import { mockRequirements } from "@/lib/mock-requirements"; import { browserCall, mockScreencast, mockShotSvg, wireBrowserMock } from "@/lib/mock-browser"; import { mockDevtoolsCall } from "@/lib/mock-devtools"; import { phoneCall } from "@/lib/mock-phone"; +import { mockSubStated, subStatesCall, subStatesEdit } from "@/lib/mock-substates"; import { turnCheckCall } from "@/lib/mock-turncheck"; import { worktreesCall } from "@/lib/mock-worktrees"; import { kitsCall, kitsStream } from "@/lib/mock-kits"; @@ -713,6 +714,8 @@ function boxCall(box: string, method: string, path: string, body?: unknown): Pro if (phone) return phone; const turnCheck = turnCheckCall(box, method, path, body, delay); if (turnCheck) return turnCheck; + const subStates = subStatesCall(box, method, path, body, (type, data) => emit({ type, box, origin: "berth", data }), delay); + if (subStates) return subStates; const usage = usageCall(box, method, path, body, delay); if (usage) return usage; const wts = worktreesCall(box, method, path, body, { locations, sessions }, emit, delay); @@ -736,7 +739,7 @@ function boxCall(box: string, method: string, path: string, body?: unknown): Pro } const key = `${method} ${path}`; if (key === "GET locations") return delay(locations[box] ?? []); - if (key === "GET sessions") return delay(sessions[box] ?? []); + if (key === "GET sessions") return delay(mockSubStated(box, sessions[box] ?? [])); if (key === "GET stats") return delay(stats[box]); if (key === "GET services") return delay(services[box] ?? []); const svc = /^locations\/([^/]+)\/worktrees\/([^/]+)\/services(?:\/([^/]+)(?:\/(start|stop|restart|log))?)?$/.exec(path); @@ -787,7 +790,8 @@ function boxCall(box: string, method: string, path: string, body?: unknown): Pro home: HOME, // gpu runs an older berthd (mockBuilds): it can't keep worktree names, // and its agent browser has no tabs. - capabilities: ["diff", "turns", "queue", "ask", "answer", "journal", "runs", "exec.detach", "browser", "browser.devtools", "titles", "sample", "service.terminal", "session.home", "agents.install", "artifacts", "processes", ...(box === "gpu" ? [] : ["worktree.titles", "agents.paths", "browser.tabs"]), ...(structuredDemo ? ["driver.claude", "conversations", "sessions.past"] : [])], + ...subStatesEdit(box), + capabilities: [...(box === "gpu" ? [] : ["substates"]), "diff", "turns", "queue", "ask", "answer", "journal", "runs", "exec.detach", "browser", "browser.devtools", "titles", "sample", "service.terminal", "session.home", "agents.install", "artifacts", "processes", ...(box === "gpu" ? [] : ["worktree.titles", "agents.paths", "browser.tabs"]), ...(structuredDemo ? ["driver.claude", "conversations", "sessions.past"] : [])], // Claude Code from npm under nvm, as the person's shell finds it; // Codex from Shipyard's own installer. agent_paths: [ diff --git a/app/src/lib/substates-sync.ts b/app/src/lib/substates-sync.ts new file mode 100644 index 000000000..53f66adb1 --- /dev/null +++ b/app/src/lib/substates-sync.ts @@ -0,0 +1,112 @@ +import { create } from "zustand"; + +import type { BoxInfo } from "@/lib/api"; +import { boxesBehind, editKey, newestSet, type ReadState, readDue, SUB_STATES_CAPABILITY, type SubStateSet, subStatesApi } from "@/lib/substates"; +import { useStore } from "@/lib/store"; + +// Each box keeps its own copy of the sub-states. This reads them, and spreads +// the newest edit to every box that can take it: when a box's info loads (it +// comes online, is added, or an edit on it changes its revision), so an +// agent's edit on one box reaches the fleet. A box refuses anything not newer +// than what it holds, so two laptops doing this at once can't flip it. + +type Info = BoxInfo & { substates_revision?: number; substates_stamp?: string }; + +export const useSubStateSets = create<{ sets: Record }>(() => ({ sets: {} })); + +// keepSubStateSet remembers what a box holds now, and spreads it if newer. +export function keepSubStateSet(box: string, set: SubStateSet) { + useSubStateSets.setState((s) => ({ sets: { ...s.sets, [box]: set } })); +} + +// How long before a failed read or spread tries again. +const RETRY_MS = 15_000; + +// startSubStatesSync starts keeping boxes alike; the function it returns stops. +export function startSubStatesSync(): () => void { + const reads = new Map(); + // What was last spread to each box, and the info it went against: a refresh + // that brings the same info back, or a box that refused it (a hook, say), + // isn't sent it again until something changes. retryAt is a failed try's. + const sent = new Map(); + const timers = new Set(); + const later = () => { + const t = window.setTimeout(() => { + timers.delete(t); + sync(); + }, RETRY_MS); + timers.add(t); + }; + const sync = () => { + const { client, boxes, status } = useStore.getState(); + if (!client) return; + const online = new Set(status?.boxes.filter((b) => b.state === "online").map((b) => b.name)); + const here: Record = {}; + const infos: Record = {}; + const now = Date.now(); + for (const [box, data] of Object.entries(boxes)) { + const info = data.info as Info | undefined; + if (!online.has(box) || !info?.capabilities?.includes(SUB_STATES_CAPABILITY)) continue; + infos[box] = info; + const edit = editKey(info.substates_revision, info.substates_stamp); + const held = useSubStateSets.getState().sets[box]; + if (held) here[box] = held; + if (!readDue(reads.get(box), edit, now)) continue; + // No revision: it never had a set, which is all a read would say. + if (!info.substates_revision) { + reads.set(box, { edit }); + keepSubStateSet(box, { states: [] }); + continue; + } + reads.set(box, { ...reads.get(box), reading: true, retryAt: undefined }); + subStatesApi.get(client, box).then( + (set) => { + reads.set(box, { edit }); + keepSubStateSet(box, set); + }, + () => { + reads.set(box, { ...reads.get(box), reading: false, retryAt: Date.now() + RETRY_MS }); + later(); + }, + ); + } + // The newest known, even on a box gone offline, goes to those here that + // have been read and are behind. A box takes only what is newer, so one + // not read yet is simply left for later. + const newest = newestSet(Object.values(useSubStateSets.getState().sets)); + if (!newest) return; + const key = `${newest.revision}:${newest.stamp}`; + for (const box of boxesBehind(here, newest)) { + const last = sent.get(box); + if (last && last.key === key && (last.retryAt ?? 0) > now) continue; + if (last && last.key === key && !last.retryAt && last.info === infos[box]) continue; + sent.set(box, { key, info: infos[box] }); + subStatesApi.spread(client, box, newest).then( + (set) => keepSubStateSet(box, set), + (err) => { + // Refused is refused until something changes; anything else, try again. + if ((err as { status?: number }).status === 403 || (err as { status?: number }).status === 400) return; + sent.set(box, { key, info: infos[box], retryAt: Date.now() + RETRY_MS }); + later(); + }, + ); + } + }; + const unsub = useStore.subscribe((s, prev) => { + if (s.boxes !== prev.boxes || s.client !== prev.client || s.status !== prev.status) sync(); + }); + const unsubSets = useSubStateSets.subscribe(sync); + sync(); + return () => { + unsub(); + unsubSets(); + for (const t of timers) window.clearTimeout(t); + timers.clear(); + }; +} + +// useSubStateSet is the newest set the app knows of, the one the board and +// Settings show. Undefined when no box ever had one. +export function useSubStateSet(): SubStateSet | undefined { + return useSubStateSets((s) => newestSet(Object.values(s.sets))); +} diff --git a/app/src/lib/substates.test.ts b/app/src/lib/substates.test.ts new file mode 100644 index 000000000..83d1964be --- /dev/null +++ b/app/src/lib/substates.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { boxesBehind, editKey, isNewer, newestSet, placeSession, readDue, type SubStateSet } from "./substates.ts"; + +const states = [ + { id: "decision", name: "Needs a decision", description: "d" }, + { id: "act", name: "Needs you to act", description: "a" }, +]; + +test("a known sub-state is placed there, untagged", () => { + assert.deepEqual(placeSession({ agent_sub_state: "act" }, states), { column: "act", unsorted: false }); +}); + +test("a missing sub-state goes to the default, tagged", () => { + assert.deepEqual(placeSession({}, states), { column: "decision", unsorted: true }); +}); + +test("an unknown sub-state goes to the default, tagged", () => { + assert.deepEqual(placeSession({ agent_sub_state: "just-removed" }, states), { column: "decision", unsorted: true }); +}); + +const set = (revision: number, stamp: string, id = "x"): SubStateSet => ({ states: [{ id, name: id, description: "d" }], revision, stamp }); + +test("the newest revision wins, whichever box holds it", () => { + const old = set(100, "z"); + const fresh = set(200, "a"); + assert.equal(newestSet([old, fresh, undefined]), fresh); + assert.equal(newestSet([fresh, old]), fresh); +}); + +test("a box that never had a set counts for nothing", () => { + assert.equal(newestSet([{ states: [] }, undefined]), undefined); +}); + +test("removing every sub-state is an edit like any other, and spreads", () => { + const removed: SubStateSet = { states: [], revision: 300, stamp: "a" }; + assert.equal(newestSet([set(200, "a"), removed]), removed); + assert.deepEqual(boxesBehind({ one: set(200, "a"), two: removed }, removed), ["one"]); +}); + +test("two edits in the same millisecond settle the same way on every laptop", () => { + const a = set(100, "a"); + const b = set(100, "b"); + assert.equal(isNewer(b, a), true); + assert.equal(isNewer(a, b), false); + assert.equal(newestSet([a, b]), newestSet([b, a])); +}); + +// Two laptops, each with the boxes as it last saw them. A box takes only what +// is newer than it holds, as a box does (putSubStates), whatever order they +// come in: every box ends on one set and stays there. +test("two laptops spreading at once don't flip a box", () => { + const a = set(100, "a", "a"); + const b = set(200, "b", "b"); + const boxes: Record = { one: a, two: b }; + const take = (box: string, s: SubStateSet) => { + if (isNewer(s, boxes[box])) boxes[box] = s; + }; + const seen: string[] = []; + for (let round = 0; round < 4; round++) { + // Laptop 1 still believes the older set is the newest; laptop 2 knows better. + take("two", a); + take("one", b); + take("two", newestSet(Object.values(boxes)) as SubStateSet); + seen.push(`${boxes.one.stamp}${boxes.two.stamp}`); + } + assert.deepEqual(seen, ["bb", "bb", "bb", "bb"]); + assert.deepEqual(boxesBehind(boxes, newestSet(Object.values(boxes)) as SubStateSet), []); +}); + +test("a box that never had a set is behind; one on the newest isn't", () => { + const n = set(100, "a"); + assert.deepEqual(boxesBehind({ fresh: { states: [] }, same: n }, n), ["fresh"]); +}); + +test("a failed read tries again with the same info, and not before its time", () => { + const e = editKey(5, "a"); + assert.equal(readDue(undefined, e, 0), true); + assert.equal(readDue({ reading: true }, e, 0), false); + // The GET failed: same edit, nothing kept. + assert.equal(readDue({ retryAt: 1000 }, e, 999), false); + assert.equal(readDue({ retryAt: 1000 }, e, 1000), true); + assert.equal(readDue({ edit: e }, e, 0), false); + assert.equal(readDue({ edit: e }, editKey(6, "a"), 0), true); +}); + +test("two edits in one millisecond are told apart when reading, too", () => { + assert.equal(readDue({ edit: editKey(5, "a") }, editKey(5, "b"), 0), true); +}); + +// The newest may be on a box that has gone: the set is still known, and goes +// on to the boxes that are here. +test("a box offline with the newest edit still spreads it", () => { + const n = set(200, "a"); + const held = { gone: n, here: set(100, "a") }; + const newest = newestSet(Object.values(held)) as SubStateSet; + assert.equal(newest, n); + assert.deepEqual(boxesBehind({ here: held.here }, newest), ["here"]); +}); diff --git a/app/src/lib/substates.ts b/app/src/lib/substates.ts new file mode 100644 index 000000000..ac6a46a7e --- /dev/null +++ b/app/src/lib/substates.ts @@ -0,0 +1,98 @@ +import type { Client, Session } from "@/lib/api"; + +// Sub-states of Needs you (see internal/box/substates.go): an optional set of +// columns that replace Needs you on the board, each with a description turn +// check places a waiting agent by. agent_state stays "waiting" whatever the +// sub-state, and with no set the board is as it always was. Everything else +// that says Needs you keeps saying it. + +export interface SubState { + id: string; + name: string; + description: string; + parent?: string; +} + +export interface SubStateSet { + states: SubState[]; + // Which edit this is: the newest wins across boxes. + revision?: number; + stamp?: string; + // Who changed what last. + changed_by?: string; + change?: string; + changed_at?: string; +} + +export const SUB_STATE_MAX = 6; +export const SUB_STATE_NAME_MAX = 40; +export const SUB_STATE_DESCRIPTION_MAX = 300; +export const SUB_STATES_CAPABILITY = "substates"; + +export const subStatesApi = { + get: (c: Client, box: string) => c.box(box, "GET", "substates"), + // An edit: the box stamps it. + edit: (c: Client, box: string, states: SubState[]) => c.box(box, "PUT", "substates", { states }), + // A stamped set from another box: kept only if it is newer. + spread: (c: Client, box: string, set: SubStateSet) => c.box(box, "PUT", "substates", set), + preset: (c: Client, box: string) => c.box(box, "POST", "substates/preset", {}), +}; + +// isNewer says whether a is a later edit than b, the same way a box does: +// by revision, and by stamp for two in the same millisecond. It is a total +// order, so every laptop picks the same set, and none can flip a box back. +export function isNewer(a: SubStateSet, b: SubStateSet): boolean { + const [ar, br] = [a.revision ?? 0, b.revision ?? 0]; + if (ar !== br) return ar > br; + return (a.stamp ?? "") > (b.stamp ?? ""); +} + +// newestSet is the latest edit among what boxes hold, whichever box made it, +// or undefined when none has ever had one. +export function newestSet(sets: Iterable): SubStateSet | undefined { + let best: SubStateSet | undefined; + for (const s of sets) { + if (!s?.revision) continue; + if (!best || isNewer(s, best)) best = s; + } + return best; +} + +// boxesBehind are the boxes to spread newest to, among those read. +export function boxesBehind(sets: Record, newest: SubStateSet): string[] { + return Object.entries(sets) + .filter(([, s]) => isNewer(newest, s)) + .map(([box]) => box); +} + +// A box's read: which edit was last read ("revision:stamp", as editKey makes +// it), whether one is on its way, and when a failed one may try again. +export interface ReadState { + edit?: string; + reading?: boolean; + retryAt?: number; +} + +export const editKey = (revision?: number, stamp?: string) => `${revision ?? 0}:${stamp ?? ""}`; + +// readDue says whether to read a box now that it says it holds edit. +export function readDue(st: ReadState | undefined, edit: string, now: number): boolean { + if (st?.reading || (st?.retryAt ?? 0) > now) return false; + return st?.edit !== edit; +} + +export interface Placement { + // The sub-state's ID: the one it says, else the default. + column: string; + // It couldn't be placed: no answer, or an ID not in the set (just removed, + // or not synced yet). It never drops off the board. + unsorted: boolean; +} + +// placeSession is the column a waiting session goes in, given a set with at +// least one sub-state. +export function placeSession(session: Pick, states: SubState[]): Placement { + const id = session.agent_sub_state; + if (id && states.some((s) => s.id === id)) return { column: id, unsorted: false }; + return { column: states[0].id, unsorted: true }; +} diff --git a/app/src/views/automations/catalog.ts b/app/src/views/automations/catalog.ts index b12a60a0e..4deb0432e 100644 --- a/app/src/views/automations/catalog.ts +++ b/app/src/views/automations/catalog.ts @@ -21,6 +21,7 @@ const worktree = ["location", "name", "path", "branch"]; export const CATALOG: CatalogEntry[] = [ { on: "agent.waiting", label: "Agent needs you", where: "box", fields: [...agent, "reason"], hint: "A permission, a question, or a startup prompt." }, + { on: "agent.substate", label: "Agent placed in a sub-state", where: "box", fields: [...agent, "sub_state", "why"], hint: "A question form sorted into a Needs you column, or couldn't be. No new wait: agent.waiting isn't sent again." }, { on: "agent.finished", label: "Agent finished", where: "box", fields: agent, hint: "Done with its turn." }, { on: "agent.started", label: "Agent started working", where: "box", fields: agent }, { on: "agent.ready", label: "Agent ready", where: "box", fields: agent, hint: "A new agent sitting at its prompt." }, diff --git a/app/src/views/dashboard/agent-card.tsx b/app/src/views/dashboard/agent-card.tsx index 7794bfb11..dbbbe1b9b 100644 --- a/app/src/views/dashboard/agent-card.tsx +++ b/app/src/views/dashboard/agent-card.tsx @@ -1,5 +1,5 @@ import { HourglassIcon, SquareIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { AgentIcon } from "@/components/agent-glyph"; import { openOrchestrate, SessionActions } from "@/components/orchestrate/session-actions"; @@ -54,7 +54,8 @@ export function duration(ms: number): string { // AgentCard is one agent on the board: where it works, how long it has been // in its state, the last thing it said, and what you can do about it. While // the board is selecting (or with ⌘ or ⇧ held), a click picks it instead. -export function AgentCard({ entry, selecting, selected, onSelect }: { entry: SessionEntry; selecting?: boolean; selected?: boolean; onSelect?(): void }) { +// children are lines of the board's own under where it works. +export function AgentCard({ entry, children, selecting, selected, onSelect }: { entry: SessionEntry; children?: ReactNode; selecting?: boolean; selected?: boolean; onSelect?(): void }) { const { box, session, state } = entry; const locations = useStore((s) => s.boxes[box]?.locations); const sessions = useStore((s) => s.boxes[box]?.sessions); @@ -142,6 +143,7 @@ export function AgentCard({ entry, selecting, selected, onSelect }: { entry: Ses )} + {children &&
{children}
} {quote && (
“{quote}” diff --git a/app/src/views/dashboard/dashboard-view.tsx b/app/src/views/dashboard/dashboard-view.tsx index c17e6f873..17f9ff7e9 100644 --- a/app/src/views/dashboard/dashboard-view.tsx +++ b/app/src/views/dashboard/dashboard-view.tsx @@ -23,11 +23,16 @@ import { ColumnMenu, openStop, StopDialog } from "@/views/dashboard/stop"; import { openAddBox } from "@/views/onboarding/add-box-dialog"; import { ViewHeader } from "@/views/view-header"; import { sessionWord } from "@/lib/state-model"; +import { placeSession } from "@/lib/substates"; +import { useSubStateSet } from "@/lib/substates-sync"; +import { NotSorted } from "@/views/dashboard/not-sorted"; interface Column { state: SessionState; title: string; empty: string; + // A sub-state of Needs you: its ID, for the agents placed in it. + sub?: string; } const COLUMNS: Column[] = [ @@ -57,6 +62,11 @@ export function DashboardView() { const [hidden, setHidden] = useState(() => load("berth.dashboard.hiddenBoxes", [])); const [showRest, setShowRest] = useState(false); const shownBoxes = shownOf(boxNames, hidden); + // With sub-states, one amber column each takes Needs you's place, in their + // order. Without any, the board is as it always was. + const subStates = useSubStateSet()?.states; + const place = (e: SessionEntry) => (subStates?.length ? placeSession(e.session, subStates) : undefined); + const columns = useMemo(() => (subStates?.length ? [...subStates.map((s): Column => ({ state: "waiting", sub: s.id, title: s.name, empty: `Nothing under ${s.name}.` })), ...COLUMNS.filter((c) => c.state !== "waiting")] : COLUMNS), [subStates]); const visible = all.filter((e) => shownBoxes.includes(e.box)); const rest = visible.filter((e) => !COLUMNS.some((c) => c.state === e.state)); const agents = all.filter((e) => COLUMNS.some((c) => c.state === e.state)); @@ -199,10 +209,10 @@ export function DashboardView() { ) : (
(scrolled = e.currentTarget.scrollTop)} className="min-h-0 flex-1 overflow-y-auto">
- {COLUMNS.map((c) => { - const items = visible.filter((e) => e.state === c.state).sort((a, b) => sinceOf(a) - sinceOf(b)); + {columns.map((c) => { + const items = visible.filter((e) => e.state === c.state && (!c.sub || place(e)?.column === c.sub)).sort((a, b) => sinceOf(a) - sinceOf(b)); return ( -
+

{c.title}

@@ -231,6 +241,7 @@ export function DashboardView() { : null} selecting={!!picked} selected={picked?.has(keyOf(e))} onSelect={() => pick([keyOf(e)], !picked?.has(keyOf(e)))} diff --git a/app/src/views/dashboard/not-sorted.tsx b/app/src/views/dashboard/not-sorted.tsx new file mode 100644 index 000000000..b88925530 --- /dev/null +++ b/app/src/views/dashboard/not-sorted.tsx @@ -0,0 +1,17 @@ +import { CircleDashedIcon } from "lucide-react"; + +import { Tip } from "@/components/tip"; + +// NotSorted tags a card the sub-states couldn't place, so it sits in the +// first column. The board gives it to whichever card it shows, as one of the +// card's own lines, so every card style has it. +export function NotSorted() { + return ( + +
+ + Not sorted +
+
+ ); +} diff --git a/app/src/views/settings/agents-section.tsx b/app/src/views/settings/agents-section.tsx index 7b35e1406..6f85b3548 100644 --- a/app/src/views/settings/agents-section.tsx +++ b/app/src/views/settings/agents-section.tsx @@ -25,6 +25,7 @@ import { NONE, useStore } from "@/lib/store"; import { layaPlaceholder, type TurnCheckChange, type TurnCheckConfig, type TurnCheckProvider, type TurnCheckStatus, turnCheckApi, turnCheckProviders, turnCheckReady } from "@/lib/turncheck"; import { Segmented } from "@/views/settings/controls"; import { Code, SettingsGroup, SettingsPage, SettingsRow } from "@/views/settings/rows"; +import { SubStatesSection } from "@/views/settings/substates-section"; // How to put each built-in agent on a box's PATH. const INSTALL: Record = { @@ -129,6 +130,7 @@ export function AgentsSection() { )} {online.length > 0 && b.name)} />} + {online.length > 0 && b.name)} />} {shownSkills && (
diff --git a/app/src/views/settings/substates-section.tsx b/app/src/views/settings/substates-section.tsx new file mode 100644 index 000000000..2ebd5ce7f --- /dev/null +++ b/app/src/views/settings/substates-section.tsx @@ -0,0 +1,140 @@ +import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Tip } from "@/components/tip"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { toastManager } from "@/components/ui/toast"; +import type { BoxInfo } from "@/lib/api"; +import { ago, errorMessage } from "@/lib/format"; +import { useStore } from "@/lib/store"; +import { SUB_STATE_DESCRIPTION_MAX, SUB_STATE_MAX, SUB_STATE_NAME_MAX, SUB_STATES_CAPABILITY, type SubState, type SubStateSet, subStatesApi } from "@/lib/substates"; +import { keepSubStateSet, useSubStateSet } from "@/lib/substates-sync"; +import { turnCheckApi } from "@/lib/turncheck"; +import { SettingsGroup } from "@/views/settings/rows"; + +// The sub-states of Needs you: a preset, an editor, and what each box makes +// of it. An edit goes to one box, which stamps it, and the app spreads it. +export function SubStatesSection({ boxes }: { boxes: string[] }) { + const client = useStore((s) => s.client); + const infos = useStore((s) => s.boxes); + const set = useSubStateSet(); + const canTake = (b: string) => !!(infos[b]?.info as BoxInfo | undefined)?.capabilities?.includes(SUB_STATES_CAPABILITY); + const capable = boxes.filter(canTake); + const known = boxes.filter((b) => infos[b]?.info); + const outdated = known.filter((b) => !canTake(b)); + const [draft, setDraft] = useState(); + const [busy, setBusy] = useState(false); + const [turnCheckOff, setTurnCheckOff] = useState([]); + const states = draft ?? set?.states ?? []; + const on = !!set?.states.length; + const capableKey = capable.join("\n"); + + // Sorting takes turn check: where it is off, every card is "not sorted". + useEffect(() => { + if (!client || !on) return setTurnCheckOff([]); + let live = true; + Promise.all(capable.map((b) => turnCheckApi.get(client, b).then((st) => (st.enabled ? undefined : b), () => undefined))).then((off) => { + if (live) setTurnCheckOff(off.filter((b): b is string => !!b)); + }); + return () => { + live = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- a new array each render; its names are what matter. + }, [client, on, capableKey]); + + async function apply(call: (box: string) => Promise) { + const box = capable[0]; + if (!box) return; + setBusy(true); + try { + keepSubStateSet(box, await call(box)); + setDraft(undefined); + } catch (err) { + toastManager.add({ type: "error", title: "Sub-states", description: errorMessage(err) }); + } finally { + setBusy(false); + } + } + const patch = (i: number, p: Partial) => setDraft(states.map((s, j) => (j === i ? { ...s, ...p } : s))); + const move = (i: number, by: number) => { + const next = [...states]; + const [s] = next.splice(i, 1); + next.splice(i + by, 0, s); + setDraft(next); + }; + const complete = states.every((s) => s.name.trim() && s.description.trim()); + + return ( + +
+ {!capable.length &&

{known.length ? "No box here can keep these yet: update berthd." : "Waiting for a box."}

} + {states.map((s, i) => ( +
+
+ patch(i, { name: e.target.value })} /> +