From 950ed857e53878984efb900ee4df8ad3ad03920f Mon Sep 17 00:00:00 2001 From: smoke test Date: Wed, 8 Jul 2026 12:09:56 +0100 Subject: [PATCH] feat(hub): widen the REST Box payload for native clients + start lifecycle verb GET /api/v1/boxes now carries the raw host-side fields (state, name, provider, projectRoot, projectIndex, vncEnabled, gitWorktrees, per-agent session titles, agent activity) so native clients (the tray app) can rely on REST instead of shelling 'agentbox list'. Synthetic creating/error job boxes carry name/provider/projectRoot too, and deliberately no 'state' - its absence is how a client tells a failed create from a real box whose agent errored. POST /boxes/:id/start brings a stopped box back up (resumes when paused, no-op when running). Claude-Session: https://claude.ai/code/session_01JKGc7YWFuXVJvXKNHYnHeB --- CLAUDE.md | 31 ++++++++++--------- apps/hub/README.md | 2 +- .../api/v1/boxes/[id]/[action]/route.ts | 6 ++-- .../hub/app/(dashboard)/api/v1/lib/openapi.ts | 28 +++++++++++++++-- .../app/(dashboard)/api/v1/lib/validate.ts | 2 +- apps/hub/lib/boxes/backend-types.ts | 3 ++ apps/hub/lib/boxes/types.ts | 19 ++++++++++++ apps/hub/lib/hub-backend.ts | 29 +++++++++++++++++ apps/web/content/docs/api.mdx | 15 +++++++-- 9 files changed, 111 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3a223c2c..d3b7a0b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,20 +78,23 @@ updates live over the hub's SSE stream and falls back to polling. It has **no build-time coupling** to this repo — it's a Swift Package Manager / AppKit app (Swift 5.10, no Xcode, no external deps) that drives the two public surfaces: -- **Boxes + actions** via the installed CLI, shelled through a login shell (`/bin/zsh -lc 'agentbox …'`, - because a GUI app has no inherited PATH). The list comes from `agentbox list -g --json` — it keeps - the CLI (not hub REST `/api/v1/boxes`) because only `ListedBox` carries the resolved Web/VNC URLs. -- **Approvals + live events** via the local **Control Hub** at `127.0.0.1:8787`: REST - `/api/v1/approvals` (+ `…/{id}/answer`) and the SSE `/api/events` stream. **Auth split to remember - when changing the hub:** `/api/v1/*` uses `Authorization: Bearer `, but `/api/events` reads - the **`agentbox_hub_token` cookie** (Bearer there 401s). Token is `~/.agentbox/hub/token`. SSE - events are refetch signals only (empty `data: {}`). - -**When you change the hub API, the SSE event/auth contract, the `agentbox list --json` shape, or any -CLI command/flag the app uses (git/services/url/screen/start/stop/hub), update the tray app too** — -its own [`CLAUDE.md`](../agentbox-tray/CLAUDE.md) documents exactly which surfaces it depends on. The -app's data/action layer sits behind a `BoxSource` protocol so a future `HubAPIBoxSource` can target -the hosted control-plane unchanged (aligns with [`docs/control-plane-roadmap.md`](./docs/control-plane-roadmap.md)). +- **Boxes + actions** via the local **Control Hub** REST API at `127.0.0.1:8787`: `GET /api/v1/boxes` + (which carries the raw host-side fields — `state`, `projectRoot`, endpoint URLs, session titles — + and the synthetic `creating`/`error` boxes for in-flight/failed creates) plus the lifecycle + (`start`/`pause`/`resume`/`stop`/`destroy`), git, rename, and services routes. Approvals use + `/api/v1/approvals` (+ `…/{id}/answer`), live events the SSE `/api/events` stream. **Auth split to + remember when changing the hub:** `/api/v1/*` uses `Authorization: Bearer `, but + `/api/events` reads the **`agentbox_hub_token` cookie** (Bearer there 401s). Token is + `~/.agentbox/hub/token`. SSE events are refetch signals only (empty `data: {}`). +- **Inherently-local actions** via the installed CLI, shelled through a login shell + (`/bin/zsh -lc 'agentbox …'`, because a GUI app has no inherited PATH): `open --in`/`open --targets` + (terminal attach in iTerm2/cmux/Herdr), and `hub status`/`hub start` to bootstrap the hub itself. + +**When you change the hub `/api/v1` API (the Box payload especially), the SSE event/auth contract, or +the CLI commands the app still shells (`open`, `hub`), update the tray app too** — its own +[`CLAUDE.md`](../agentbox-tray/CLAUDE.md) documents exactly which surfaces it depends on. The app's +data/action layer sits behind a `BoxSource` protocol (implemented by `HubAPIBoxSource`) so it can +target the hosted control-plane unchanged (aligns with [`docs/control-plane-roadmap.md`](./docs/control-plane-roadmap.md)). ## Documentation map diff --git a/apps/hub/README.md b/apps/hub/README.md index 2cab3624..f6b617cc 100644 --- a/apps/hub/README.md +++ b/apps/hub/README.md @@ -44,7 +44,7 @@ statuses, no schema). - **Envelope:** success returns the resource directly; errors are `{ error: { code, message, details? } }` with a matching HTTP status. - **Routes:** `GET /boxes`, `GET /boxes/:id`, - `POST /boxes/:id/{pause,resume,stop,destroy}`, `POST /boxes` (create → + `POST /boxes/:id/{start,pause,resume,stop,destroy}`, `POST /boxes` (create → `202 {jobId}`), `GET|POST /projects`, `GET /approvals`, `POST /approvals/:id/answer`, `GET /jobs/:id`, `GET /jobs/:id/logs` (SSE), `GET /health`, `GET /openapi.json` (OpenAPI 3.1), `GET /docs` (Scalar). diff --git a/apps/hub/app/(dashboard)/api/v1/boxes/[id]/[action]/route.ts b/apps/hub/app/(dashboard)/api/v1/boxes/[id]/[action]/route.ts index 4d7be172..c8bcd6f0 100644 --- a/apps/hub/app/(dashboard)/api/v1/boxes/[id]/[action]/route.ts +++ b/apps/hub/app/(dashboard)/api/v1/boxes/[id]/[action]/route.ts @@ -1,9 +1,9 @@ -// POST /api/v1/boxes/:id/:action — lifecycle: pause | resume | stop | destroy. +// POST /api/v1/boxes/:id/:action — lifecycle: start | pause | resume | stop | destroy. // Mutations need the in-process host backend; the Postgres/plane path 503s (hosted // writes are a documented follow-up). import { backendOrNull } from '../../../lib/backend'; import { fail, failFromAction, ok } from '../../../lib/envelope'; -import { isLifecycleAction } from '../../../lib/validate'; +import { isLifecycleAction, LIFECYCLE_ACTIONS } from '../../../lib/validate'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -14,7 +14,7 @@ export async function POST( ): Promise { const { id, action } = await ctx.params; if (!isLifecycleAction(action)) { - return fail('invalid_request', `unknown action: ${action}`, { allowed: ['pause', 'resume', 'stop', 'destroy'] }); + return fail('invalid_request', `unknown action: ${action}`, { allowed: [...LIFECYCLE_ACTIONS] }); } // In-flight create jobs surface in GET /boxes as synthetic `creating`/`error` // boxes with a `job:` id — they have no real container yet, so lifecycle would diff --git a/apps/hub/app/(dashboard)/api/v1/lib/openapi.ts b/apps/hub/app/(dashboard)/api/v1/lib/openapi.ts index f3d4496c..885f0b57 100644 --- a/apps/hub/app/(dashboard)/api/v1/lib/openapi.ts +++ b/apps/hub/app/(dashboard)/api/v1/lib/openapi.ts @@ -100,10 +100,11 @@ export function buildOpenApi(): Record { post: { tags: ['Boxes'], summary: 'Run a lifecycle action', - description: 'One of pause | resume | stop | destroy.', + description: + 'One of start | pause | resume | stop | destroy. start brings a stopped box back up (resumes if paused, no-op if already running); it does not restart the agent session — that happens on the next attach.', parameters: [ { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, - { name: 'action', in: 'path', required: true, schema: { type: 'string', enum: ['pause', 'resume', 'stop', 'destroy'] } }, + { name: 'action', in: 'path', required: true, schema: { type: 'string', enum: ['start', 'pause', 'resume', 'stop', 'destroy'] } }, ], responses: { '200': { description: 'Done', content: { 'application/json': { schema: { type: 'object', properties: { ok: { const: true } }, required: ['ok'] } } } }, @@ -448,6 +449,29 @@ export function buildOpenApi(): Record { commits: { type: ['number', 'null'] }, filesTouched: { type: ['number', 'null'] }, error: { type: ['string', 'null'] }, + displayName: { type: ['string', 'null'], description: 'Cosmetic user-set label (rename); null when unset' }, + webUrl: { type: ['string', 'null'], description: 'Host-openable web-service URL; null when absent/unreachable (e.g. paused)' }, + vncUrl: { type: ['string', 'null'], description: 'Host-openable VNC desktop URL; null when absent/unreachable' }, + state: { + type: 'string', + enum: ['running', 'paused', 'stopped', 'missing'], + description: + 'Raw provider runtime state (host topology only). Absent on synthetic creating/error rows — presence distinguishes a real box whose agent errored from a failed create job.', + }, + name: { type: 'string' }, + provider: { type: 'string', description: "Raw provider id ('docker', 'daytona', …; plugin ids possible)" }, + projectRoot: { type: 'string', description: 'Absolute host path of the project. Host topology only — never emitted by the hosted plane' }, + projectIndex: { type: 'number' }, + vncEnabled: { type: 'boolean' }, + gitWorktrees: { + type: 'array', + items: { type: 'object', properties: { kind: { type: 'string' }, branch: { type: 'string' } } }, + }, + claudeSessionTitle: { type: 'string' }, + codexSessionTitle: { type: 'string' }, + opencodeSessionTitle: { type: 'string' }, + claudeActivity: { type: 'string', description: 'working | idle | waiting | end-plan | question | compacting | error | unknown' }, + codexActivity: { type: 'string' }, }, required: ['id', 'projectId', 'status', 'agent'], }, diff --git a/apps/hub/app/(dashboard)/api/v1/lib/validate.ts b/apps/hub/app/(dashboard)/api/v1/lib/validate.ts index 31c3f6bb..87175adc 100644 --- a/apps/hub/app/(dashboard)/api/v1/lib/validate.ts +++ b/apps/hub/app/(dashboard)/api/v1/lib/validate.ts @@ -11,7 +11,7 @@ const AGENTS = ['claude', 'codex', 'opencode', 'none'] as const; // that package out of the Next bundle, like AGENTS above). The backend enforces // that the chosen provider is actually configured on the host. const PROVIDERS = ['docker', 'daytona', 'hetzner', 'vercel', 'e2b'] as const; -export const LIFECYCLE_ACTIONS = ['pause', 'resume', 'stop', 'destroy'] as const; +export const LIFECYCLE_ACTIONS = ['start', 'pause', 'resume', 'stop', 'destroy'] as const; export type LifecycleAction = (typeof LIFECYCLE_ACTIONS)[number]; function isObject(v: unknown): v is Record { diff --git a/apps/hub/lib/boxes/backend-types.ts b/apps/hub/lib/boxes/backend-types.ts index f2455d45..f06eaaf4 100644 --- a/apps/hub/lib/boxes/backend-types.ts +++ b/apps/hub/lib/boxes/backend-types.ts @@ -157,6 +157,9 @@ export interface HubBackend { // authMode is an env-derived concern layered on by source.ts, not the host // backend — so the backend produces everything else. getData(): Promise>; + // Start a fully-stopped box (resumes when paused, no-op when running). Does + // not restore agent tmux sessions — that's a CLI-only concern. + start(id: string): Promise; pause(id: string): Promise; resume(id: string): Promise; stop(id: string): Promise; diff --git a/apps/hub/lib/boxes/types.ts b/apps/hub/lib/boxes/types.ts index b30a5f66..f71cf976 100644 --- a/apps/hub/lib/boxes/types.ts +++ b/apps/hub/lib/boxes/types.ts @@ -30,6 +30,25 @@ export interface Box { // box has no such endpoint or it isn't reachable (e.g. paused/stopped). webUrl?: string | null; vncUrl?: string | null; + // ── Raw host-side fields (host/localhost topology only; the hosted/Postgres + // source leaves them all undefined). Native clients (the tray app) key off + // these instead of the UI-normalized fields above. ── + // Raw provider runtime state. ABSENT on synthetic job boxes — its presence is + // how a client tells a real box whose agent errored (state 'running', status + // 'error') from a failed create job (no state). 'destroyed' exists only for + // assignability from BoxState; the list never emits it. + state?: 'running' | 'paused' | 'stopped' | 'missing' | 'destroyed'; + name?: string; + // Absolute host path of the project — host topology only, never hosted. + projectRoot?: string; + projectIndex?: number; + vncEnabled?: boolean; + gitWorktrees?: Array<{ kind?: string; branch?: string }>; + claudeSessionTitle?: string; + codexSessionTitle?: string; + opencodeSessionTitle?: string; + claudeActivity?: string; + codexActivity?: string; } export interface Project { diff --git a/apps/hub/lib/hub-backend.ts b/apps/hub/lib/hub-backend.ts index 19b014bd..e9d4d755 100644 --- a/apps/hub/lib/hub-backend.ts +++ b/apps/hub/lib/hub-backend.ts @@ -169,6 +169,18 @@ function mapBox(b: ListedBox): Box { error: status === 'error' ? (b.claudeSessionTitle ?? 'Agent reported an error') : null, webUrl: eps.find((e) => e.kind === 'web')?.url ?? null, vncUrl: eps.find((e) => e.kind === 'vnc')?.url ?? null, + // Raw host-side fields for native clients (tray) — see Box for semantics. + state: b.state, + name: b.name, + projectRoot: root, + projectIndex: b.projectIndex, + vncEnabled: b.vncEnabled ?? false, + gitWorktrees: b.gitWorktrees?.map((w) => ({ kind: w.kind, branch: w.branch })), + claudeSessionTitle: b.claudeSessionTitle, + codexSessionTitle: b.codexSessionTitle, + opencodeSessionTitle: b.opencodeSessionTitle, + claudeActivity: b.claudeActivity, + codexActivity: b.codexActivity, }; } @@ -323,6 +335,10 @@ function mapJobToBox(job: QueueJob, status: BoxStatus): Box { commits: null, filesTouched: null, error: status === 'error' ? (job.reason ?? 'create failed') : null, + // Raw host-side fields so native clients can group/label the synthetic row. + // `state` is deliberately absent — that's the synthetic-box marker. + name: job.boxName, + projectRoot: root, }; } @@ -882,6 +898,19 @@ export function createHubBackend(handle: RelayServerHandle): HubBackend { async providersWithFreshness(): Promise { return listProvidersWithFreshness(listProviders(await loadQueue())); }, + start: (id) => + runLifecycle(id, async (box, provider) => { + // Mirrors the CLI dashboard's resumeBox: docker `start` rejects a paused + // container, so probe first. No-op when already running (idempotent). + // Unlike CLI `agentbox start` this does not restore agent tmux sessions + // (restoreAgentSessions is CLI-only) — the agent restarts on next attach. + const state = await provider.probeState(box); + if (state === 'running') return; + if (state === 'paused') await provider.resume(box); + else await provider.start(box); + // Refresh the box's SSH-config alias now it's back online (IP may have changed). + await hubWriteSshConfig(box, provider); + }), pause: (id) => runLifecycle(id, (box, provider) => provider.pause(box)), resume: (id) => runLifecycle(id, async (box, provider) => { diff --git a/apps/web/content/docs/api.mdx b/apps/web/content/docs/api.mdx index 5bd8b407..d5a6e369 100644 --- a/apps/web/content/docs/api.mdx +++ b/apps/web/content/docs/api.mdx @@ -67,7 +67,8 @@ the interactive reference at `GET /docs`. | `GET /boxes` | List every box (normalized view). | | `GET /boxes/{id}` | One box. | | `POST /boxes` | Create a box — returns `202 { jobId }`. Body: `{ projectId, agent, provider?, name?, prompt?, fromBranch?, setupWizard? }`. `agent: "none"` just creates the box without starting an agent (`prompt` ignored). `provider` defaults to `docker`; a cloud provider must be configured on the host (see `GET /providers`). `fromBranch` bases the box's per-box branch on a chosen ref (branch/tag/SHA) instead of the project's HEAD — validated against the host repo. `setupWizard: true` seeds the agent's first turn to generate an `agentbox.yaml` (for projects that have none); inert for `agent: "none"`. | -| `POST /boxes/{id}/pause` | Pause a running box. | +| `POST /boxes/{id}/start` | Start a stopped box (resumes if paused, no-op if already running). Does not restart the agent session — that happens on the next attach. | +| `POST /boxes/{id}/pause` | Pause a running box. | | `POST /boxes/{id}/resume` | Resume a paused box. | | `POST /boxes/{id}/stop` | Stop a box. | | `POST /boxes/{id}/destroy` | Destroy a box (container + volumes). | @@ -119,8 +120,16 @@ the interactive reference at `GET /docs`. | `GET /jobs/{id}/logs` | Stream the build log (SSE). | The box view is provider-agnostic: `{ id, projectId, repo, branch, task, agent, -status, createdAt, lastActivity, host, commits, filesTouched, error }`, where -`status` is one of `running | paused | stopped | creating | error`. +status, createdAt, lastActivity, host, commits, filesTouched, error, displayName, +webUrl, vncUrl }`, where `status` is one of +`running | paused | stopped | creating | error`. Boxes still being created (and +failed creates) appear as synthetic `creating`/`error` entries with a `job:` id +until the real box lands. On the local host topology each box also carries raw +host-side fields for native clients (the tray app): `state` (the raw provider +state — absent on synthetic entries, which is how a client tells a failed create +from a running box whose agent errored), `name`, `provider`, `projectRoot`, +`projectIndex`, `vncEnabled`, `gitWorktrees`, per-agent session titles, and +`claudeActivity`/`codexActivity`. ## Creating a box