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
+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";