feat(lanes): lane runtime UI, docs, and env additions (A1+A2)

Client-side rendering for the per-lane runtime facts (slot, ports,
database, Redis index, service liveness) added in the server-side
A1/A2 work, plus the doc updates (README, CLAUDE.md, docs/API.md,
client/server READMEs) describing the new profile.env keys, hook
environment contract, and REST endpoints.
This commit is contained in:
2026-08-04 10:04:58 +07:00
parent 9d145865dd
commit 4d9a385c5e
14 changed files with 614 additions and 24 deletions
+3
View File
@@ -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
+181 -1
View File
@@ -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<LaneRuntime | null>(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<Lane["liveness"], string> = {
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<string | null>(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({
</div>
)}
{/* 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 && (
<div
data-testid={`lane-runtime-${lane.id}`}
className="mb-3 rounded border border-border bg-surface-2/50 px-2 py-1.5 font-mono text-[11px]"
>
<div className="mb-1 flex items-center justify-between">
<span className="text-fg-muted">{t("runtime.slot", { slot: runtime.slot })}</span>
<span
className={runtime.healthy ? "text-status-success" : "text-fg-muted"}
data-testid="lane-runtime-state"
>
{runtime.healthy
? t("runtime.healthy")
: runtime.up
? t("runtime.partial")
: t("runtime.down")}
</span>
</div>
{Object.entries(runtime.ports).map(([name, info]) => (
<div key={name} className="flex items-center gap-1.5 truncate">
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
info.listening ? "bg-status-success" : "bg-surface-4"
}`}
/>
<span className="text-fg-secondary">{name}</span>
<span className="text-fg-muted">:{info.port ?? "—"}</span>
{info.port !== null && info.port !== info.expected && (
<span
className="truncate text-status-warning/80"
title={t("runtime.steppedAsideTitle", { expected: info.expected })}
>
{info.expected}
</span>
)}
</div>
))}
{/* 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 && (
<div
data-testid="lane-runtime-bootline"
className="mt-1 truncate text-fg-muted"
title={bootLine}
>
{bootLine}
</div>
)}
{bootLine === null && runtime.lastError && (
<div
className="mt-1 truncate text-status-danger/90"
title={runtime.lastError.message}
>
{runtime.lastError.code || "error"}: {runtime.lastError.message}
</div>
)}
</div>
)}
<dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
{git?.available && (
<div data-testid="lane-git" className="space-y-1">
@@ -191,6 +349,28 @@ export default function LaneCard({
{t(`action.${a}`)}
</button>
))}
{/* 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 && (
<button
type="button"
data-testid="lane-runtime-toggle"
disabled={runtimeBusy !== null}
onClick={(e) => {
e.stopPropagation();
void runtimeAction(runtime.provisioned && runtime.up ? "down" : "up");
}}
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
title={t("runtime.toggleTitle")}
>
{runtimeBusy
? t(`runtime.busy.${runtimeBusy}`)
: runtime.provisioned && runtime.up
? t("runtime.stop")
: t("runtime.boot")}
</button>
)}
{/* 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
@@ -48,6 +48,8 @@ function laneFixture(over: Partial<Lane> = {}): Lane {
liveness: "idle",
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
...over,
};
}
@@ -59,13 +61,7 @@ const SUGGESTIONS: CwdSuggestion[] = [
function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
return render(
<AddLaneModal
open
onClose={vi.fn()}
onAdded={vi.fn()}
cwdSuggestions={SUGGESTIONS}
{...over}
/>
<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={SUGGESTIONS} {...over} />
);
}
@@ -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();
});
@@ -47,6 +47,8 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
liveness: "idle",
detected_stage: null,
detected_signal: null,
slot: null,
ports: {},
...overrides,
};
}
@@ -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> = {}): Lane {
@@ -53,6 +63,8 @@ function makeLane(overrides: Partial<Lane> = {}): 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(<LaneCard lane={makeLane({ title: "no profile" })} onAction={vi.fn()} />);
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(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
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(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
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(<LaneCard lane={makeLane()} onAction={onAction} />);
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(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
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(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
expect(await screen.findByText(/EUNHEALTHY/)).toBeInTheDocument();
expect(screen.getByText(/health check failed/)).toBeInTheDocument();
});
});
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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",
+35
View File
@@ -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<LaneGitFacts>(`/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<LaneRuntime>(`/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.
+88 -2
View File
@@ -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<string, number>;
}
/** 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<string, never> }
| {
available: true;
provisioned: true;
slot: number;
kind: Lane["kind"];
hooks: string[];
profileDir: string;
services: LaneRuntimeService[];
ports: Record<string, LaneRuntimePort>;
/** 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";
@@ -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 };