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:
@@ -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`.
|
- **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.
|
- **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.
|
- **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<slot>/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.
|
- 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).
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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,
|
move backwards between work sessions. Detection never writes the declared stage,
|
||||||
and an inferred node never renders as done.
|
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 `<repo>/.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
|
[`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
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -400,6 +400,9 @@ Server broadcasts these event types over WebSocket:
|
|||||||
| `notification.received` | Notification object | Notification hook |
|
| `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_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 |
|
| `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
|
### EventBus Pattern
|
||||||
|
|
||||||
|
|||||||
@@ -15,13 +15,19 @@ import { useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||||
import { api } from "../../lib/api";
|
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:
|
/** 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
|
* each call is three git subprocesses server-side, and a branch name does not
|
||||||
* change on the timescale the lane list is polled at. */
|
* change on the timescale the lane list is polled at. */
|
||||||
const GIT_REFRESH_MS = 30_000;
|
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
|
* 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
|
* 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;
|
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> = {
|
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||||
active: "bg-status-success",
|
active: "bg-status-success",
|
||||||
idle: "bg-surface-4",
|
idle: "bg-surface-4",
|
||||||
@@ -80,7 +120,61 @@ export default function LaneCard({
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
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 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -144,6 +238,70 @@ export default function LaneCard({
|
|||||||
</div>
|
</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">
|
<dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
|
||||||
{git?.available && (
|
{git?.available && (
|
||||||
<div data-testid="lane-git" className="space-y-1">
|
<div data-testid="lane-git" className="space-y-1">
|
||||||
@@ -191,6 +349,28 @@ export default function LaneCard({
|
|||||||
{t(`action.${a}`)}
|
{t(`action.${a}`)}
|
||||||
</button>
|
</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
|
{/* Deleting the lane and deleting its history are each their own
|
||||||
button, by request: hiding "delete" behind a ⋯ made it unfindable,
|
button, by request: hiding "delete" behind a ⋯ made it unfindable,
|
||||||
and the one label that read as "delete" was `clear`. Neither fires
|
and the one label that read as "delete" was `clear`. Neither fires
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ function laneFixture(over: Partial<Lane> = {}): Lane {
|
|||||||
liveness: "idle",
|
liveness: "idle",
|
||||||
detected_stage: null,
|
detected_stage: null,
|
||||||
detected_signal: null,
|
detected_signal: null,
|
||||||
|
slot: null,
|
||||||
|
ports: {},
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -59,13 +61,7 @@ const SUGGESTIONS: CwdSuggestion[] = [
|
|||||||
|
|
||||||
function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
|
function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
|
||||||
return render(
|
return render(
|
||||||
<AddLaneModal
|
<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={SUGGESTIONS} {...over} />
|
||||||
open
|
|
||||||
onClose={vi.fn()}
|
|
||||||
onAdded={vi.fn()}
|
|
||||||
cwdSuggestions={SUGGESTIONS}
|
|
||||||
{...over}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +96,9 @@ describe("AddLaneModal", () => {
|
|||||||
await focusField(user, repoField);
|
await focusField(user, repoField);
|
||||||
await user.type(repoField, "/Users/tester/projects/repo");
|
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");
|
const base = await screen.findByLabelText("Branch to fork from");
|
||||||
expect(base).toHaveValue("main"); // the repo's current branch is preselected
|
expect(base).toHaveValue("main"); // the repo's current branch is preselected
|
||||||
expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument();
|
expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument();
|
||||||
@@ -143,7 +141,9 @@ describe("AddLaneModal", () => {
|
|||||||
base: "main",
|
base: "main",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 9, status: "provisioning" }));
|
expect(onAdded).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 9, status: "provisioning" })
|
||||||
|
);
|
||||||
expect(onClose).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
|||||||
liveness: "idle",
|
liveness: "idle",
|
||||||
detected_stage: null,
|
detected_stage: null,
|
||||||
detected_signal: null,
|
detected_signal: null,
|
||||||
|
slot: null,
|
||||||
|
ports: {},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import LaneCard from "../LaneCard";
|
import LaneCard from "../LaneCard";
|
||||||
import type { Lane } from "../../../lib/types";
|
import type { Lane } from "../../../lib/types";
|
||||||
@@ -17,13 +17,23 @@ import { api } from "../../../lib/api";
|
|||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
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(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(api.lanes.git).mockReset();
|
vi.mocked(api.lanes.git).mockReset();
|
||||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
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 {
|
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||||
@@ -53,6 +63,8 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
|||||||
liveness: "idle",
|
liveness: "idle",
|
||||||
detected_stage: null,
|
detected_stage: null,
|
||||||
detected_signal: null,
|
detected_signal: null,
|
||||||
|
slot: null,
|
||||||
|
ports: {},
|
||||||
...overrides,
|
...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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
"addLaneTitlePlaceholder": "Optional",
|
"addLaneTitlePlaceholder": "Optional",
|
||||||
"autoStage": "auto: {{stage}}",
|
"autoStage": "auto: {{stage}}",
|
||||||
"cardId": "Lane {{id}}",
|
"cardId": "Lane {{id}}",
|
||||||
"stageUndeclared": "not declared",
|
|
||||||
"confirmRemoveCancel": "Cancel",
|
"confirmRemoveCancel": "Cancel",
|
||||||
"confirmRemoveConfirm": "Remove",
|
"confirmRemoveConfirm": "Remove",
|
||||||
"confirmRemoveMessage": "This action cannot be undone.",
|
"confirmRemoveMessage": "This action cannot be undone.",
|
||||||
@@ -65,6 +64,17 @@
|
|||||||
"moreActions": "More actions",
|
"moreActions": "More actions",
|
||||||
"preflightError": "Could not load the current lane facts.",
|
"preflightError": "Could not load the current lane facts.",
|
||||||
"preflightErrorWithMessage": "Could not load the current lane facts: {{message}}",
|
"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.failed": "failed",
|
||||||
"status.idle": "idle",
|
"status.idle": "idle",
|
||||||
"status.provisioning": "provisioning",
|
"status.provisioning": "provisioning",
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||||
"autoStage": "tự động: {{stage}}",
|
"autoStage": "tự động: {{stage}}",
|
||||||
"cardId": "Làn đường {{id}}",
|
"cardId": "Làn đường {{id}}",
|
||||||
"stageUndeclared": "chưa khai báo",
|
|
||||||
"confirmRemoveCancel": "Hủy",
|
"confirmRemoveCancel": "Hủy",
|
||||||
"confirmRemoveConfirm": "Xóa",
|
"confirmRemoveConfirm": "Xóa",
|
||||||
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
|
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
|
||||||
@@ -65,6 +64,17 @@
|
|||||||
"moreActions": "Thêm hành động",
|
"moreActions": "Thêm hành động",
|
||||||
"preflightError": "Không thể tải trạng thái làn đường hiện tại.",
|
"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}}",
|
"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.failed": "thất bại",
|
||||||
"status.idle": "rảnh",
|
"status.idle": "rảnh",
|
||||||
"status.provisioning": "đang khởi tạo",
|
"status.provisioning": "đang khởi tạo",
|
||||||
|
|||||||
@@ -392,6 +392,7 @@ import type {
|
|||||||
Lane,
|
Lane,
|
||||||
LaneCounts,
|
LaneCounts,
|
||||||
LaneGitFacts,
|
LaneGitFacts,
|
||||||
|
LaneRuntime,
|
||||||
ModelPricing,
|
ModelPricing,
|
||||||
Session,
|
Session,
|
||||||
SessionDrillIn,
|
SessionDrillIn,
|
||||||
@@ -1932,6 +1933,40 @@ export const api = {
|
|||||||
* readable git repository, which is a normal state rather than an error.
|
* readable git repository, which is a normal state rather than an error.
|
||||||
*/
|
*/
|
||||||
git: (id: number) => request<LaneGitFacts>(`/lanes/${id}/git`),
|
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.
|
* POST /api/lanes — create a new lane.
|
||||||
* @param body Optional lane initialization fields.
|
* @param body Optional lane initialization fields.
|
||||||
|
|||||||
+88
-2
@@ -1671,7 +1671,13 @@ export interface WSMessage {
|
|||||||
| "workflow_upserted"
|
| "workflow_upserted"
|
||||||
| "remote_source.status"
|
| "remote_source.status"
|
||||||
| "remote_data.updated"
|
| "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. */
|
/** The message body, whose concrete shape is selected by `type` above. */
|
||||||
data:
|
data:
|
||||||
| Session
|
| Session
|
||||||
@@ -1687,7 +1693,10 @@ export interface WSMessage {
|
|||||||
| WorkflowRun
|
| WorkflowRun
|
||||||
| RemoteSourceStatusPayload
|
| RemoteSourceStatusPayload
|
||||||
| RemoteDataUpdatedPayload
|
| RemoteDataUpdatedPayload
|
||||||
| { lane?: Lane; removed?: number };
|
| { lane?: Lane; removed?: number }
|
||||||
|
| LaneHookOutputPayload
|
||||||
|
| LaneRuntimePayload
|
||||||
|
| LaneHookResultPayload;
|
||||||
/** ISO timestamp the server broadcast this message (not necessarily the
|
/** ISO timestamp the server broadcast this message (not necessarily the
|
||||||
* same instant the underlying event occurred). */
|
* same instant the underlying event occurred). */
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -2312,8 +2321,85 @@ export interface Lane {
|
|||||||
/** The tool-event signal that produced `detected_stage` (e.g. a command
|
/** 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. */
|
* name), capped at 120 characters server-side; null if `detected_stage` is null. */
|
||||||
detected_signal: string | 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. */
|
/** Facts returned before a reset or remove that must be echoed to the server. */
|
||||||
export interface LaneWorktreePreflight {
|
export interface LaneWorktreePreflight {
|
||||||
action: "reset" | "remove";
|
action: "reset" | "remove";
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
recordCall("GET", `/api/lanes/${id}/git`);
|
recordCall("GET", `/api/lanes/${id}/git`);
|
||||||
return { available: false };
|
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 () => {
|
stage: vi.fn().mockImplementation(async () => {
|
||||||
recordCall("POST", `/api/lanes/stage`);
|
recordCall("POST", `/api/lanes/stage`);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
+128
-5
@@ -108,7 +108,8 @@ https://dashboard.example.com
|
|||||||
|
|
||||||
Lane mutations (`POST /api/lanes/ensure`, `POST /api/lanes/worktree`,
|
Lane mutations (`POST /api/lanes/ensure`, `POST /api/lanes/worktree`,
|
||||||
`PATCH /api/lanes/:id`, confirmed
|
`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`,
|
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.
|
`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
|
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`,
|
`purge` returns `sessions`, `events`, `tokenRows`,
|
||||||
`bytesEstimate`, and `activeSessionSkipped`. `expect` is required: reset/remove
|
`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
|
include `sessions`, `events`, and `tokenRows`. Missing or incomplete
|
||||||
confirmation facts return `400 EEXPECT`.
|
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`
|
`blocked` reports conditions that affect the action: `adopted`
|
||||||
(not a managed worktree), `missing` (directory gone), `unreadable` (directory
|
(not a managed worktree), `missing` (directory gone), `unreadable` (directory
|
||||||
exists but git failed against it), and `unpushed-commits` (unpushed count > 0
|
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,
|
"confirm": true,
|
||||||
"force": true,
|
"force": true,
|
||||||
|
"keepDb": false,
|
||||||
"expect": {
|
"expect": {
|
||||||
"head": "9b3e74a",
|
"head": "9b3e74a",
|
||||||
"dirty": 4,
|
"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.
|
`expected` and `current` diagnostics, without resetting, removing, or purging.
|
||||||
Missing `force` for unpushed work returns `409 EUNPUSHED`.
|
Missing `force` for unpushed work returns `409 EUNPUSHED`.
|
||||||
`reset` restores the managed worktree's feature branch from its base, cleans
|
`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,
|
`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
|
```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
|
`ENOTMANAGED`, `EOUTSIDEROOT`, and `ENOTWORKTREE` guard failures on the managed
|
||||||
destroy path. Git failures return `500` with their git `stderr` in `error.stderr`.
|
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_<name> + 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
|
### Sessions
|
||||||
|
|
||||||
#### List Sessions
|
#### List Sessions
|
||||||
|
|||||||
+6
-1
@@ -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/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/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. |
|
| `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. |
|
| `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
|
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
|
||||||
an existing absolute git repository. The response lane starts as
|
an existing absolute git repository. The response lane starts as
|
||||||
|
|||||||
Reference in New Issue
Block a user