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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user