From 62bd04278ebb5919f2e1d6a8946617eaa5f263a9 Mon Sep 17 00:00:00 2001 From: Stener1 Date: Mon, 2 Nov 2026 08:48:55 +0000 Subject: [PATCH 1/4] feat(app): needs you can split into columns of your own, which turn check sorts agents into An optional set of sub-states under Needs you. agent_state doesn't change: a session in one is still waiting, and an optional agent_sub_state says which. With no set, the board, the API and turn check's requests are as they were. At the end of a turn, a waiting verdict is followed by a second request that picks the sub-state, so the descriptions never reach the verdict's request. A question form gets one request on its text, and its place arrives as agent.substate, never a second agent.waiting. A permission prompt goes to the default by rule. What can't be placed sits in the default, marked not sorted. Each box keeps a stamped copy, and the app spreads the newest. The set can be edited over /v1/substates, berthd substates and MCP, including by an agent on the box, with limits, a before: hook and an event. /v1/turncheck stays closed to local callers. Co-Authored-By: Claude Opus 5.5 --- .../skills/verify-berthd/features/README.md | 1 + .../features/dashboard-sub-states.md | 43 + .../scenarios/dashboard-sub-states-live.mjs | 118 +++ .../scenarios/dashboard-sub-states.mjs | 47 + .../verify-berthd/scripts/sub-states-box.sh | 41 + app/e2e/dashboard-sub-states.spec.ts | 95 ++ app/src/App.tsx | 2 + app/src/lib/events.ts | 3 + app/src/lib/mock-substates.ts | 64 ++ app/src/lib/mock.ts | 8 +- app/src/lib/substates-sync.ts | 112 +++ app/src/lib/substates.test.ts | 100 +++ app/src/lib/substates.ts | 98 +++ app/src/views/automations/catalog.ts | 1 + app/src/views/dashboard/agent-card.tsx | 13 +- app/src/views/dashboard/dashboard-view.tsx | 16 +- app/src/views/settings/agents-section.tsx | 2 + app/src/views/settings/substates-section.tsx | 140 +++ cmd/berthd/main.go | 1 + docs/changelog.mdx | 8 + docs/concepts/security.mdx | 8 + docs/concepts/sessions-and-agents.mdx | 43 + docs/guides/runs.mdx | 3 +- docs/reference/app-api.mdx | 6 +- docs/reference/cli.mdx | 1 + docs/reference/config.mdx | 1 + docs/reference/events.mdx | 3 + docs/reference/plugin-sdk.mdx | 2 +- internal/box/api.go | 13 + internal/box/sessions.go | 4 + internal/box/substates.go | 711 +++++++++++++++ internal/box/substates_test.go | 813 ++++++++++++++++++ internal/box/tasks.go | 2 + .../testdata/turncheck/substate-decision.json | 67 ++ .../box/testdata/turncheck/substate-task.json | 67 ++ internal/box/turncheck.go | 232 +++-- internal/box/turncheck_test.go | 8 +- internal/box/turns.go | 53 +- internal/box/upgrade.go | 12 + internal/boxcmd/boxcmd.go | 4 + internal/boxcmd/substatescmd.go | 95 ++ internal/integrations/adapters/adapters.go | 15 +- .../integrations/skills/berth-hooks/SKILL.md | 4 +- .../skills/berth-orchestrate/SKILL.md | 3 +- internal/integrations/skills/berth/SKILL.md | 12 +- internal/mcpserver/mcp.go | 74 +- packages/plugin-sdk/src/types.ts | 3 + 47 files changed, 3075 insertions(+), 97 deletions(-) create mode 100644 .claude/skills/verify-berthd/features/dashboard-sub-states.md create mode 100644 .claude/skills/verify-berthd/scripts/scenarios/dashboard-sub-states-live.mjs create mode 100644 .claude/skills/verify-berthd/scripts/scenarios/dashboard-sub-states.mjs create mode 100755 .claude/skills/verify-berthd/scripts/sub-states-box.sh create mode 100644 app/e2e/dashboard-sub-states.spec.ts create mode 100644 app/src/lib/mock-substates.ts create mode 100644 app/src/lib/substates-sync.ts create mode 100644 app/src/lib/substates.test.ts create mode 100644 app/src/lib/substates.ts create mode 100644 app/src/views/settings/substates-section.tsx create mode 100644 internal/box/substates.go create mode 100644 internal/box/substates_test.go create mode 100644 internal/box/testdata/turncheck/substate-decision.json create mode 100644 internal/box/testdata/turncheck/substate-task.json create mode 100644 internal/boxcmd/substatescmd.go diff --git a/.claude/skills/verify-berthd/features/README.md b/.claude/skills/verify-berthd/features/README.md index 11a07f637..6428de0e7 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 | [dashboard-sub-states.md](dashboard-sub-states.md) | `dashboard-sub-states`; `dashboard-sub-states-live` (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..f24840350 --- /dev/null +++ b/.claude/skills/verify-berthd/features/dashboard-sub-states.md @@ -0,0 +1,43 @@ +# 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`). `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. +- `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 + ``` + + 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.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..5166e8f41 100644 --- a/app/src/views/dashboard/agent-card.tsx +++ b/app/src/views/dashboard/agent-card.tsx @@ -1,4 +1,4 @@ -import { HourglassIcon, SquareIcon } from "lucide-react"; +import { CircleDashedIcon, HourglassIcon, SquareIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { AgentIcon } from "@/components/agent-glyph"; @@ -54,7 +54,7 @@ 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 }) { +export function AgentCard({ entry, notSorted, selecting, selected, onSelect }: { entry: SessionEntry; notSorted?: boolean; 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 +142,15 @@ export function AgentCard({ entry, selecting, selected, onSelect }: { entry: Ses )} + {/* Under sub-states: it couldn't be placed, so it sits in the default. */} + {notSorted && ( + +
+ + Not sorted +
+
+ )} {quote && (
“{quote}” diff --git a/app/src/views/dashboard/dashboard-view.tsx b/app/src/views/dashboard/dashboard-view.tsx index c17e6f873..017f6f616 100644 --- a/app/src/views/dashboard/dashboard-view.tsx +++ b/app/src/views/dashboard/dashboard-view.tsx @@ -23,11 +23,15 @@ 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"; 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 +61,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 +208,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 +240,7 @@ export function DashboardView() { pick([keyOf(e)], !picked?.has(keyOf(e)))} 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 })} /> +