diff --git a/CLAUDE.md b/CLAUDE.md index 3b17090..3ebc76e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,10 @@ A **lane** is a durable unit of parallel agent work — one working directory, m - **A detection expires, an evidence rule does not.** `recordDetection` skips its forward-only comparison once `detected_at` is older than `DETECTION_TTL_MS` (default 5 min), so a lane can move backwards between work sessions. That window changes only WHICH detection is current — it never relaxes declared-wins (an agent's own claim has no expiry) and never lets an inferred node render `done`. - **Working-copy facts live at `GET /api/lanes/:id/git`, never inside `GET /api/lanes`.** That endpoint shells out to git three times; the lane list is polled and re-broadcast on every hook. A cwd that is not a readable repo returns `{available:false}` with HTTP 200 — a normal state, not a fault. Cards fetch it themselves every 30s and fail silently. - **The Workspace console collapses with CSS, never by unmounting.** Unmounting `RunConsole` disposes the run subscription and drops a live run's rendered history. -- **CCAM does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. +- **CCAM does not orchestrate:** no chaining, no queue, no retry logic, no gate evaluation. The session in control makes all decisions; the dashboard records the claimed stage and shows evidence. The runtime layer (`ccam lanes up|down|hook`) does not change this — it offers *primitives* a session calls; nothing in the dashboard sequences them. +- **The runtime never writes `stage`, `status` or `notes`** — only `slot` and `ports`. `status=running` means an AGENT is working, not that a server is listening; merging the two would corrupt `classifyLiveness`. Boot failures live in `LANES_ROOT/.state/lane/last-error.json` and surface via `GET /api/lanes/:id/runtime`. Same boundary as the console-never-writes-stage rule above. +- **A lane's stack is up or down as a computed fact, never a stored one.** `runtimeFacts` re-derives it from pid files and port probes on every read. A process dies to OOM, a stray `kill`, a reboot — caching a truth CCAM does not control buys ghost state. Slot allocation is the opposite (fully controlled, must be race-free), so that one does live in the DB, under `withLaneLock` plus a partial unique index. +- **A profile's `profile.env` is parsed, never sourced.** Hooks are executed deliberately; config is only read. Sourcing arbitrary shell from a user's repository into the dashboard process would be a code-execution path. Hook names always come from the fixed allowlist in `server/lib/lane-profile.js`, never from a request. - Pipeline templates are JSON files (`server/data/pipelines/`) with node definitions; custom templates override built-ins when `DASHBOARD_PIPELINES_DIR` is set. - Nodes render in five states: `failed` (rejected), `current` (now), `done` (with evidence), `passed-no-evidence` (claimed or skipped, amber), `pending` (not reached). - Liveness: a silent **watcher** (stage matching `/watch|poll/`) is dead after `LANE_DEAD_SEC` seconds (default 300); a silent **idle** lane is at rest, not dead. diff --git a/README.md b/README.md index ad76dd5..eaa6329 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,25 @@ events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can move backwards between work sessions. Detection never writes the declared stage, and an inferred node never renders as done. +A lane can also run **its own application stack**, isolated per lane, when its +repository declares a profile at `/.ccam/profile/` — a `profile.env` of +declarations plus shell hooks the dashboard calls. Each lane gets a slot, and its +ports and per-lane directories derive from it: + +```bash +ccam lanes up # boot the stack of the lane owning this directory +ccam lanes runtime # slot, ports, service health +ccam lanes logs api # tail a service log +ccam lanes down +``` + +Services are fully detached, so restarting the dashboard never stops a running +lane. This is resource namespacing on the host, not a container: lanes run as the +same user and share the network. + [`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the -preflight contract, the Workspace page, and `GET /api/lanes/:id/git`. +preflight contract, the Workspace page, `GET /api/lanes/:id/git`, and the full +runtime/profile contract. ## Tests diff --git a/client/README.md b/client/README.md index 1734df0..a16e797 100644 --- a/client/README.md +++ b/client/README.md @@ -400,6 +400,9 @@ Server broadcasts these event types over WebSocket: | `notification.received` | Notification object | Notification hook | | `remote_source.status` | `{ id, status, error?, last_sync_at? }` (`status`: `idle`/`syncing`/`ok`/`error`/`deleted`) | Remote Data Source sync poller + `/api/remote-sources` routes | | `remote_data.updated` | `{ sourceId, source, label?, counters?, last_sync_at? }` | Emitted once per successful remote sync; triggers stats/cost/session refetches. The server also broadcasts `session_created` / `session_updated` (and main-agent frames) for each mirrored session so Kanban/Sessions update immediately | +| `lane_hook_output` | `{ laneId, hook, stream, line }` | One output line from a lane's profile hook, pushed while it still runs. A build can take minutes; `LaneCard` shows the latest line so a boot does not read as a hang | +| `lane_runtime` | `{ laneId, runtime? , error? }` | A lane's stack finished coming up or failed to. Carries the fresh runtime facts, so a listener need not re-request them | +| `lane_hook_result` | `{ laneId, hook, code, error? }` | A profile hook exited (`POST /api/lanes/:id/hook/:name`). `code` is `null` when the hook could not be started | ### EventBus Pattern diff --git a/client/src/components/lanes/LaneCard.tsx b/client/src/components/lanes/LaneCard.tsx index 03aa261..588192a 100644 --- a/client/src/components/lanes/LaneCard.tsx +++ b/client/src/components/lanes/LaneCard.tsx @@ -15,13 +15,19 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { DestructiveLaneModal } from "./DestructiveLaneModal"; import { api } from "../../lib/api"; -import type { Lane, LaneGitFacts } from "../../lib/types"; +import { eventBus } from "../../lib/eventBus"; +import type { Lane, LaneGitFacts, LaneRuntime } from "../../lib/types"; /** How often a mounted card re-reads its working-copy facts. Slow on purpose: * each call is three git subprocesses server-side, and a branch name does not * change on the timescale the lane list is polled at. */ const GIT_REFRESH_MS = 30_000; +/** How often a mounted card re-probes its stack. Faster than the git refresh + * because a stack dying is exactly what the user needs to see, and slower than + * the lane poll because each call opens a socket per declared port. */ +const RUNTIME_REFRESH_MS = 10_000; + /** * The lane's own working copy, fetched per card rather than folded into the * polled lane list. Absent facts are not an error state: a lane may point at a @@ -55,6 +61,40 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null { return facts; } +/** + * The lane's own application stack. Same shape and same silence as the git + * facts above: a lane without a `.ccam/profile` simply has no runtime row, which + * is the common case and not an error worth a banner. + * + * `bump` lets an up/down action re-read immediately instead of waiting out the + * poll interval. + */ +function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null { + const [runtime, setRuntime] = useState(null); + + useEffect(() => { + let alive = true; + const read = () => { + api.lanes + .runtime(laneId) + .then((r) => { + if (alive) setRuntime(r); + }) + .catch(() => { + if (alive) setRuntime({ available: false }); + }); + }; + read(); + const timer = setInterval(read, RUNTIME_REFRESH_MS); + return () => { + alive = false; + clearInterval(timer); + }; + }, [laneId, bump]); + + return runtime; +} + const LIVENESS_DOT: Record = { active: "bg-status-success", idle: "bg-surface-4", @@ -80,7 +120,61 @@ export default function LaneCard({ null ); const [menuOpen, setMenuOpen] = useState(false); + const [runtimeBump, setRuntimeBump] = useState(0); + const [runtimeBusy, setRuntimeBusy] = useState<"up" | "down" | null>(null); + const [bootLine, setBootLine] = useState(null); const git = useLaneGitFacts(lane.id); + const runtime = useLaneRuntime(lane.id, runtimeBump); + + /** + * Boot or stop the lane's stack. Deliberately NOT routed through `onAction`: + * that prop drives the lane's Claude run, and folding a second lifecycle into + * it would make "stop" ambiguous about which thing it stops. + * + * `up` answers 202 and keeps booting in the background, so the button stays + * busy until the server's `lane_runtime` message says the attempt finished — + * resolving the request is not the same as the stack being up. + */ + const runtimeAction = async (which: "up" | "down") => { + setRuntimeBusy(which); + setBootLine(null); + try { + if (which === "down") { + await api.lanes.down(lane.id); + setRuntimeBusy(null); + } else { + await api.lanes.up(lane.id); + } + } catch { + // The failure surfaces as the runtime row's lastError on the next read; a + // toast here would say the same thing twice. + setRuntimeBusy(null); + } finally { + setRuntimeBump((n) => n + 1); + } + }; + + /** + * Live boot feedback. A build can run for minutes, and a card showing only a + * disabled button through all of it reads as a hang. The hook's own output + * lines are the honest progress indicator. + */ + useEffect( + () => + eventBus.subscribe((msg) => { + const data = msg.data as { laneId?: number; line?: string } | undefined; + if (!data || data.laneId !== lane.id) return; + if (msg.type === "lane_hook_output" && typeof data.line === "string") { + setBootLine(data.line); + } + if (msg.type === "lane_runtime") { + setRuntimeBusy(null); + setBootLine(null); + setRuntimeBump((n) => n + 1); + } + }), + [lane.id] + ); return ( <> @@ -144,6 +238,70 @@ export default function LaneCard({ )} + {/* Runtime strip: the lane's own app stack, shown only for lanes that + declare a profile. A port that drifted from `base + slot` is called + out — the number is otherwise predictable from the slot, and silently + serving on a different one is exactly the surprise worth flagging. */} + {runtime?.available && runtime.provisioned && ( +
+
+ {t("runtime.slot", { slot: runtime.slot })} + + {runtime.healthy + ? t("runtime.healthy") + : runtime.up + ? t("runtime.partial") + : t("runtime.down")} + +
+ {Object.entries(runtime.ports).map(([name, info]) => ( +
+ + {name} + :{info.port ?? "—"} + {info.port !== null && info.port !== info.expected && ( + + ⚠ {info.expected} + + )} +
+ ))} + {/* While a boot is in flight the hook's own latest line IS the + progress bar — a build can take minutes, and a disabled button + with nothing moving reads as a hang. */} + {bootLine !== null && ( +
+ {bootLine} +
+ )} + {bootLine === null && runtime.lastError && ( +
+ {runtime.lastError.code || "error"}: {runtime.lastError.message} +
+ )} +
+ )} +
{git?.available && (
@@ -191,6 +349,28 @@ export default function LaneCard({ {t(`action.${a}`)} ))} + {/* Stack controls, only for a lane whose repo declares a profile. + Separate from start/stop above: those drive the lane's Claude run, + these drive the application it is working on. */} + {runtime?.available && ( + + )} {/* Deleting the lane and deleting its history are each their own button, by request: hiding "delete" behind a ⋯ made it unfindable, and the one label that read as "delete" was `clear`. Neither fires diff --git a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx index 5037770..a03ece4 100644 --- a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx +++ b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx @@ -48,6 +48,8 @@ function laneFixture(over: Partial = {}): Lane { liveness: "idle", detected_stage: null, detected_signal: null, + slot: null, + ports: {}, ...over, }; } @@ -59,13 +61,7 @@ const SUGGESTIONS: CwdSuggestion[] = [ function renderModal(over: Partial> = {}) { return render( - + ); } @@ -100,7 +96,9 @@ describe("AddLaneModal", () => { await focusField(user, repoField); await user.type(repoField, "/Users/tester/projects/repo"); - await waitFor(() => expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo")); + await waitFor(() => + expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo") + ); const base = await screen.findByLabelText("Branch to fork from"); expect(base).toHaveValue("main"); // the repo's current branch is preselected expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument(); @@ -143,7 +141,9 @@ describe("AddLaneModal", () => { base: "main", }); }); - expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 9, status: "provisioning" })); + expect(onAdded).toHaveBeenCalledWith( + expect.objectContaining({ id: 9, status: "provisioning" }) + ); expect(onClose).toHaveBeenCalled(); }); diff --git a/client/src/components/lanes/__tests__/DestructiveLaneModal.test.tsx b/client/src/components/lanes/__tests__/DestructiveLaneModal.test.tsx index e1e7191..2cdd703 100644 --- a/client/src/components/lanes/__tests__/DestructiveLaneModal.test.tsx +++ b/client/src/components/lanes/__tests__/DestructiveLaneModal.test.tsx @@ -47,6 +47,8 @@ function makeLane(overrides: Partial = {}): Lane { liveness: "idle", detected_stage: null, detected_signal: null, + slot: null, + ports: {}, ...overrides, }; } diff --git a/client/src/components/lanes/__tests__/LaneCard.test.tsx b/client/src/components/lanes/__tests__/LaneCard.test.tsx index 254da30..b7d9b12 100644 --- a/client/src/components/lanes/__tests__/LaneCard.test.tsx +++ b/client/src/components/lanes/__tests__/LaneCard.test.tsx @@ -9,7 +9,7 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import LaneCard from "../LaneCard"; import type { Lane } from "../../../lib/types"; @@ -17,13 +17,23 @@ import { api } from "../../../lib/api"; vi.mock("../../../lib/api", () => ({ api: { - lanes: { git: vi.fn(), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }) }, + lanes: { + git: vi.fn(), + runtime: vi.fn(), + up: vi.fn(), + down: vi.fn(), + preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }), + }, }, })); beforeEach(() => { vi.mocked(api.lanes.git).mockReset(); vi.mocked(api.lanes.git).mockResolvedValue({ available: false }); + // A lane with no profile is the default fixture: most lanes never run a + // stack, so the runtime strip and its button stay absent unless a test opts in. + vi.mocked(api.lanes.runtime).mockReset(); + vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false }); }); function makeLane(overrides: Partial = {}): Lane { @@ -53,6 +63,8 @@ function makeLane(overrides: Partial = {}): Lane { liveness: "idle", detected_stage: null, detected_signal: null, + slot: null, + ports: {}, ...overrides, }; } @@ -225,3 +237,101 @@ describe("LaneCard git block", () => { } }); }); + +describe("LaneCard — the lane's own application stack", () => { + const upRuntime = { + available: true as const, + provisioned: true as const, + slot: 3, + kind: "managed" as const, + hooks: ["boot", "health"], + profileDir: "/work/demo/.ccam/profile", + services: [{ name: "web", pid: 4242, alive: true }], + ports: { api: { port: 8003, expected: 8003, listening: true } }, + steppedAside: false, + up: true, + healthy: true, + logs: ["boot.log"], + logDir: "/lanes/.state/lane3/logs", + lastError: null, + }; + + it("shows nothing at all for a lane whose repo declares no profile", async () => { + vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false }); + render(); + + expect(await screen.findByText("no profile")).toBeInTheDocument(); + expect(screen.queryByTestId("lane-runtime-1")).toBeNull(); + expect(screen.queryByTestId("lane-runtime-toggle")).toBeNull(); + }); + + it("lists each declared port with its listening state", async () => { + vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime); + render(); + + expect(await screen.findByTestId("lane-runtime-1")).toBeInTheDocument(); + expect(screen.getByText(":8003")).toBeInTheDocument(); + expect(screen.getByTestId("lane-runtime-state")).toHaveTextContent(/healthy/i); + }); + + it("flags a port that stepped aside from its base, showing the expected number", async () => { + vi.mocked(api.lanes.runtime).mockResolvedValue({ + ...upRuntime, + steppedAside: true, + ports: { api: { port: 8103, expected: 8003, listening: true } }, + }); + render(); + + expect(await screen.findByText(":8103")).toBeInTheDocument(); + expect(screen.getByText(/8003/)).toBeInTheDocument(); + }); + + it("stops the stack through /down, never through the run's onAction prop", async () => { + vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime); + vi.mocked(api.lanes.down).mockResolvedValue({ + ok: true, + killed: [4242], + runtime: { available: false }, + }); + const onAction = vi.fn(); + render(); + + await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle")); + + await waitFor(() => expect(api.lanes.down).toHaveBeenCalledWith(1)); + expect(api.lanes.up).not.toHaveBeenCalled(); + expect(onAction).not.toHaveBeenCalled(); + }); + + it("boots a provisioned-but-down lane and stays busy past the 202", async () => { + vi.mocked(api.lanes.runtime).mockResolvedValue({ + ...upRuntime, + services: [{ name: "web", pid: 4242, alive: false }], + ports: { api: { port: 8003, expected: 8003, listening: false } }, + up: false, + healthy: false, + }); + vi.mocked(api.lanes.up).mockResolvedValue({ ok: true, laneId: 1 }); + render(); + + await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle")); + + await waitFor(() => expect(api.lanes.up).toHaveBeenCalledWith(1)); + // The request resolving is not the stack being up: the server answered 202 + // and is still booting, so the button must not go idle yet. + await waitFor(() => expect(screen.getByTestId("lane-runtime-toggle")).toBeDisabled()); + }); + + it("surfaces the last boot error when nothing is streaming", async () => { + vi.mocked(api.lanes.runtime).mockResolvedValue({ + ...upRuntime, + up: false, + healthy: false, + lastError: { at: "2026-08-03T00:00:00Z", code: "EUNHEALTHY", message: "health check failed" }, + }); + render(); + + expect(await screen.findByText(/EUNHEALTHY/)).toBeInTheDocument(); + expect(screen.getByText(/health check failed/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index d93b950..3d42e0f 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -19,7 +19,6 @@ "addLaneTitlePlaceholder": "Optional", "autoStage": "auto: {{stage}}", "cardId": "Lane {{id}}", - "stageUndeclared": "not declared", "confirmRemoveCancel": "Cancel", "confirmRemoveConfirm": "Remove", "confirmRemoveMessage": "This action cannot be undone.", @@ -65,6 +64,17 @@ "moreActions": "More actions", "preflightError": "Could not load the current lane facts.", "preflightErrorWithMessage": "Could not load the current lane facts: {{message}}", + "runtime.boot": "▶ up", + "runtime.busy.down": "stopping…", + "runtime.busy.up": "booting…", + "runtime.down": "down", + "runtime.healthy": "healthy", + "runtime.partial": "partial", + "runtime.slot": "slot {{slot}}", + "runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use", + "runtime.stop": "■ down", + "runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)", + "stageUndeclared": "not declared", "status.failed": "failed", "status.idle": "idle", "status.provisioning": "provisioning", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index c7b22c6..2b2c688 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -19,7 +19,6 @@ "addLaneTitlePlaceholder": "Không bắt buộc", "autoStage": "tự động: {{stage}}", "cardId": "Làn đường {{id}}", - "stageUndeclared": "chưa khai báo", "confirmRemoveCancel": "Hủy", "confirmRemoveConfirm": "Xóa", "confirmRemoveMessage": "Hành động này không thể hoàn tác.", @@ -65,6 +64,17 @@ "moreActions": "Thêm hành động", "preflightError": "Không thể tải trạng thái làn đường hiện tại.", "preflightErrorWithMessage": "Không thể tải trạng thái làn đường hiện tại: {{message}}", + "runtime.boot": "▶ chạy", + "runtime.busy.down": "đang dừng…", + "runtime.busy.up": "đang khởi động…", + "runtime.down": "tắt", + "runtime.healthy": "khoẻ", + "runtime.partial": "một phần", + "runtime.slot": "slot {{slot}}", + "runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận", + "runtime.stop": "■ dừng", + "runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)", + "stageUndeclared": "chưa khai báo", "status.failed": "thất bại", "status.idle": "rảnh", "status.provisioning": "đang khởi tạo", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index a36e2a0..ee91f4b 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -392,6 +392,7 @@ import type { Lane, LaneCounts, LaneGitFacts, + LaneRuntime, ModelPricing, Session, SessionDrillIn, @@ -1932,6 +1933,40 @@ export const api = { * readable git repository, which is a normal state rather than an error. */ git: (id: number) => request(`/lanes/${id}/git`), + /** + * GET /api/lanes/:id/runtime — the lane's own application stack: slot, + * ports, service liveness, and the last boot error. + * + * Its own endpoint rather than a field on the polled lane list because it + * probes ports and stats pid files. Like `/git`, a lane with no profile + * answers `available: false` with HTTP 200 — a normal state, not a fault. + * @param id The lane id. + * @returns {@link LaneRuntime} + */ + runtime: (id: number) => request(`/lanes/${id}/runtime`), + /** + * POST /api/lanes/:id/up — boot the lane's stack through its profile hooks. + * Returns 202: the boot runs in the background and streams progress as + * `lane_hook_output`, finishing with a `lane_runtime` message. + * @param id The lane id. + * @param body `build: false` reuses an existing build. + */ + up: (id: number, body: { build?: boolean } = {}) => + request<{ ok: true; laneId: number }>(`/lanes/${id}/up`, { + method: "POST", + body: JSON.stringify(body), + }), + /** + * POST /api/lanes/:id/down — stop the lane's stack. Idempotent, and a no-op + * for a lane that was never brought up. + * @param id The lane id. + * @returns The pids stopped and the resulting runtime facts. + */ + down: (id: number) => + request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/lanes/${id}/down`, { + method: "POST", + body: JSON.stringify({}), + }), /** * POST /api/lanes — create a new lane. * @param body Optional lane initialization fields. diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index b1e8b4e..3ad8d7c 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -1671,7 +1671,13 @@ export interface WSMessage { | "workflow_upserted" | "remote_source.status" | "remote_data.updated" - | "lane_update"; + | "lane_update" + /** One output line from a lane's profile hook, while it runs. */ + | "lane_hook_output" + /** A lane's stack finished coming up (or failed to), with fresh facts. */ + | "lane_runtime" + /** A lane's profile hook exited, with its status code. */ + | "lane_hook_result"; /** The message body, whose concrete shape is selected by `type` above. */ data: | Session @@ -1687,7 +1693,10 @@ export interface WSMessage { | WorkflowRun | RemoteSourceStatusPayload | RemoteDataUpdatedPayload - | { lane?: Lane; removed?: number }; + | { lane?: Lane; removed?: number } + | LaneHookOutputPayload + | LaneRuntimePayload + | LaneHookResultPayload; /** ISO timestamp the server broadcast this message (not necessarily the * same instant the underlying event occurred). */ timestamp: string; @@ -2312,8 +2321,85 @@ export interface Lane { /** The tool-event signal that produced `detected_stage` (e.g. a command * name), capped at 120 characters server-side; null if `detected_stage` is null. */ detected_signal: string | null; + /** Runtime slot, or null until the lane's stack is first brought up. Every + * per-lane runtime fact (ports today, database name later) derives from it. */ + slot: number | null; + /** Ports the lane ACTUALLY bound, by declared name. Empty until first boot. + * May differ from `base + slot` when the preferred number was taken. */ + ports: Record; } +/** One line a lane's profile hook wrote, pushed while the hook is still running + * so a multi-minute boot shows progress instead of reading as a hang. */ +export interface LaneHookOutputPayload { + laneId: number; + /** Hook that produced it — `"up"` for the boot/health pair driven by `POST /up`. */ + hook: string; + stream: "stdout" | "stderr"; + line: string; +} + +/** A lane's stack finished coming up, or failed to. Carries the fresh facts so a + * listener does not have to re-request them. */ +export interface LaneRuntimePayload { + laneId: number; + runtime?: LaneRuntime; + error?: { code: string; message: string }; +} + +/** A lane's profile hook exited. `code` is null when it could not be started. */ +export interface LaneHookResultPayload { + laneId: number; + hook: string; + code: number | null; + error?: { code: string; message: string }; +} + +/** One declared port: the number in use, the number the base implies, and whether + * anything is answering on it right now. */ +export interface LaneRuntimePort { + port: number | null; + expected: number; + listening: boolean; +} + +/** A service the boot hook started, as recorded in its pid file. */ +export interface LaneRuntimeService { + name: string; + pid: number; + alive: boolean; +} + +/** + * What is actually running for a lane, recomputed by the server on every read + * rather than cached — a process can die without telling anyone. + * + * `available: false` means the lane has no `.ccam/profile`, which is a normal + * state (most lanes never run a stack), not an error. + */ +export type LaneRuntime = + | { available: false; searched?: string[] } + | { available: true; provisioned: false; hooks: string[]; ports: Record } + | { + available: true; + provisioned: true; + slot: number; + kind: Lane["kind"]; + hooks: string[]; + profileDir: string; + services: LaneRuntimeService[]; + ports: Record; + /** True when any port differs from `base + slot`; the card flags it. */ + steppedAside: boolean; + /** Any recorded service process is alive. */ + up: boolean; + /** Every declared port is answering. */ + healthy: boolean; + logs: string[]; + logDir: string; + lastError: { at: string; code: string | null; message: string } | null; + }; + /** Facts returned before a reset or remove that must be echoed to the server. */ export interface LaneWorktreePreflight { action: "reset" | "remove"; diff --git a/client/src/pages/__tests__/Workspace.test.tsx b/client/src/pages/__tests__/Workspace.test.tsx index b99a5a0..d941c79 100644 --- a/client/src/pages/__tests__/Workspace.test.tsx +++ b/client/src/pages/__tests__/Workspace.test.tsx @@ -94,6 +94,12 @@ vi.mock("../../lib/api", async (importOriginal) => { recordCall("GET", `/api/lanes/${id}/git`); return { available: false }; }), + // Same for the lane's own application stack: nothing here asserts on it, + // so report the "no .ccam/profile" shape, which is the common case. + runtime: vi.fn().mockImplementation(async (id: number) => { + recordCall("GET", `/api/lanes/${id}/runtime`); + return { available: false }; + }), stage: vi.fn().mockImplementation(async () => { recordCall("POST", `/api/lanes/stage`); return { ok: true }; diff --git a/docs/API.md b/docs/API.md index 2cab1cb..a1d46fb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -108,7 +108,8 @@ https://dashboard.example.com Lane mutations (`POST /api/lanes/ensure`, `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, confirmed -actions, and `DELETE /api/lanes/:id`) enforce the same loopback same-origin +actions, the runtime routes `POST /api/lanes/:id/up`, `/down` and +`/hook/:name`, and `DELETE /api/lanes/:id`) enforce the same loopback same-origin guard as `/api/run`: browser requests must originate from `localhost`, `127.0.0.1`, `::1`, or `0.0.0.0`; CLI and curl requests without `Origin` pass. @@ -195,13 +196,21 @@ GET /api/lanes/:id/preflight?action=reset|remove|purge ``` Returns the exact facts a user must confirm. `reset` and `remove` return -`head`, `dirty`, `untracked`, `unpushed`, `blocked`, and `warnings`; +`head`, `dirty`, `untracked`, `unpushed`, `database`, `blocked`, and `warnings`; `purge` returns `sessions`, `events`, `tokenRows`, `bytesEstimate`, and `activeSessionSkipped`. `expect` is required: reset/remove -must include all of `head`, `dirty`, `untracked`, and `unpushed`; purge must +must include all of `head`, `dirty`, `untracked`, and `unpushed` (not `database` +— see below); purge must include `sessions`, `events`, and `tokenRows`. Missing or incomplete confirmation facts return `400 EEXPECT`. +`database` is the name a `reset` (unless `keepDb: true`) or `remove` will drop +— `null` when the lane has no slot yet or its profile declares no `DB_PREFIX`. +It is not part of the staleness-checked `expect` set: it is derived from the +lane's slot and profile, not from mutable git/session state, so it cannot go +stale between preflight and the action. See +`docs/LANES.md#data-isolation-database-redis-and-env-a2`. + `blocked` reports conditions that affect the action: `adopted` (not a managed worktree), `missing` (directory gone), `unreadable` (directory exists but git failed against it), and `unpushed-commits` (unpushed count > 0 @@ -243,6 +252,7 @@ exit returns `500 ERUNTIMEOUT` and does not run git. `reset` and `remove` additi { "confirm": true, "force": true, + "keepDb": false, "expect": { "head": "9b3e74a", "dirty": 4, @@ -257,9 +267,14 @@ preflight. If any echoed fact changed, the action returns `409 ESTALE` with its `expected` and `current` diagnostics, without resetting, removing, or purging. Missing `force` for unpushed work returns `409 EUNPUSHED`. `reset` restores the managed worktree's feature branch from its base, cleans -untracked files but preserves ignored files, and clears the lane stage state. +untracked files but preserves ignored files, clears the lane stage state, and — +when the lane's profile declares data isolation (`docs/LANES.md#data-isolation-database-redis-and-env-a2`) +— refreshes `.env`, re-runs `bootstrap`, clears `LANE_DIRS`, and drops + +recreates + migrates + reseeds the database, unless `keepDb: true` is passed +(reset only; skips that whole block, leaving the database untouched). `remove` removes the managed git worktree, prunes it, deletes its feature branch, -then deletes the lane row. `purge` returns the deleted counts: +drops the lane's database and its `_test` sibling (best-effort; never for an +adopted lane), then deletes the lane row. `purge` returns the deleted counts: ```json { @@ -274,6 +289,114 @@ dashboard row forgotten, and its directory is never changed. `400` preserves `ENOTMANAGED`, `EOUTSIDEROOT`, and `ENOTWORKTREE` guard failures on the managed destroy path. Git failures return `500` with their git `stderr` in `error.stderr`. +#### Read a lane's runtime + +```http +GET /api/lanes/:id/runtime +``` + +What is actually running for this lane, recomputed on every call from pid files +and port probes — never cached, because a process can die without telling +anyone. See `docs/LANES.md#lane-runtime-running-a-lanes-own-stack`. + +```json +{ + "available": true, + "provisioned": true, + "slot": 3, + "kind": "managed", + "hooks": ["bootstrap", "boot", "health"], + "profileDir": "/work/myapp/.ccam/profile", + "services": [{ "name": "api", "pid": 40213, "alive": true }], + "ports": { "api": { "port": 8103, "expected": 8003, "listening": true } }, + "database": { "name": "myapp_l3", "testName": "myapp_l3_test" }, + "redisIndex": 3, + "steppedAside": true, + "up": true, + "healthy": true, + "logs": ["boot.log", "health.log", "api.log"], + "logDir": "/home/you/.claude/ccam-lanes/.state/lane3/logs", + "lastError": null +} +``` + +- `database` is `null` when the profile declares no `DB_PREFIX`; `redisIndex` + is `null` when `REDIS` is not `1`. Both are names/indices only — never a + connection string, so this endpoint never leaks the `~/.ccam/secrets.env` + password even though it derives `DATABASE_URL` internally to run hooks. +- A lane whose repository declares no `.ccam/profile` returns + `{"available": false, "searched": [...]}` with HTTP **200** — the same + contract as `GET /:id/git`. Most lanes never run a stack; that is a normal + state, not a fault. +- A lane with a profile but no slot yet returns `{"available": true, + "provisioned": false, "hooks": [...], "ports": {}}`. +- `expected` is `PORT_BASE_ + slot`; when `port` differs, the allocator + stepped aside from a number already in use and `steppedAside` is `true`. +- Not folded into `GET /api/lanes` on purpose: it opens a socket per declared + port and stats every pid file, and the lane list is polled and re-broadcast on + every hook. + +#### Boot or stop a lane's stack + +```http +POST /api/lanes/:id/up { "build": true } +POST /api/lanes/:id/down +``` + +`up` returns **202** and boots in the background, because a build can run for +minutes. Progress streams as `lane_hook_output` WebSocket messages and the +attempt finishes with a `lane_runtime` message carrying the fresh facts (or an +`error`). `build: false` passes `--no-build` to the `boot` hook. + +`up` runs the profile's `boot` then `health` hooks; it does **not** run +`bootstrap`. A failing `health` leaves the processes running so their logs +remain readable, and records `EUNHEALTHY` in the runtime's `lastError`. + +`down` is synchronous, idempotent, and a no-op for a lane that was never up: + +```json +{ "ok": true, "killed": [40213, 40219], "runtime": { "…": "…" } } +``` + +Both write only `slot` and `ports` on the lane row. Neither writes `stage`, +`status`, or `notes` — in CCAM those describe the agent's work, not the stack's +state. Adopted lanes may be brought up and down. + +Errors: `400 ENOPROFILE` (with the paths searched), `409 ESLOTS` (every slot +taken), `409 EPORTBUSY` (with `port` and the occupying `pids`), `404 ENOLANE`. + +#### Run a profile hook + +```http +POST /api/lanes/:id/hook/:name { "args": ["--scope", "smoke"] } +``` + +Runs one of the profile's hooks — the surface a driving session uses for +`ci-gate`, `e2e`, `migrate` and friends. Returns **202**; output streams as +`lane_hook_output` and completion arrives as `lane_hook_result` with the exit +`code`. + +`:name` is checked against a fixed allowlist (`bootstrap`, `boot`, `health`, +`migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop`) **before** +anything is spawned, and `args` travels as an array of strings straight into +argv — neither is ever joined into a command string. An unknown name returns +`400 ENOHOOK` with the allowed list; a lane with no slot returns `409 ENOSLOT`. + +#### Tail a lane's log + +```http +GET /api/lanes/:id/logs/:svc?tail=65536 +``` + +```json +{ "available": true, "svc": "api", "size": 20481, "truncated": false, "text": "…" } +``` + +`tail` is the number of trailing bytes (default 64 KiB, capped at 1 MiB). The +resolved path is confined to the lane's log directory after `realpath`, so a +name from the request can never escape it; anything else is `404 ENOLOG`. A lane +with no slot returns `{"available": false}`. + ### Sessions #### List Sessions diff --git a/server/README.md b/server/README.md index 8f9c061..d83fbe6 100644 --- a/server/README.md +++ b/server/README.md @@ -521,8 +521,13 @@ Request body shape: | `POST` | `/api/lanes/:id/reset` | Confirmed managed-worktree reset; requires `force` for unpushed commits. | | `POST` | `/api/lanes/:id/remove` | Confirmed managed-worktree teardown and branch deletion, prune of a hand-deleted worktree, or adopted-lane metadata removal; requires `force` for unpushed managed work. | | `POST` | `/api/lanes/:id/purge` | Confirmed deletion of eligible lane sessions, events, and token usage rows. | -| `PATCH` | `/api/lanes/:id` | Same-origin guarded partial lane update. `kind`, `source_repo`, `slug` and `base_branch` are provisioning facts and are not patchable; an invalid `kind` returns `400 EBADKIND`. | +| `PATCH` | `/api/lanes/:id` | Same-origin guarded partial lane update. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are provisioning facts and are not patchable; an invalid `kind` returns `400 EBADKIND`. | | `DELETE` | `/api/lanes/:id` | Same-origin guarded non-destructive lane-row removal. | +| `GET` | `/api/lanes/:id/runtime` | The lane's own application stack: slot, ports, per-service liveness, last boot error. Recomputed per call from pid files and port probes, never cached. A lane with no `.ccam/profile` returns `{available:false}` with `200`. | +| `POST` | `/api/lanes/:id/up` | Same-origin guarded stack boot through the profile's `boot` + `health` hooks; returns `202` and finishes in the background. | +| `POST` | `/api/lanes/:id/down` | Same-origin guarded stack teardown: recorded pid trees, then a port backstop only when a pid file existed. | +| `POST` | `/api/lanes/:id/hook/:name` | Same-origin guarded profile-hook run; `:name` must be in the fixed hook allowlist and `args` is passed as argv. Returns `202`. | +| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the lane's log directory. | The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be an existing absolute git repository. The response lane starts as