feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* @file OpenAPI fragment for dashboard-managed git worktree lane provisioning
|
||||
* and the confirmed, preflight-guarded reset, remove, and purge lifecycle API.
|
||||
* It documents the asynchronous provisioning and destructive action contracts
|
||||
* for the built-in Swagger and ReDoc surfaces, plus the read-only
|
||||
* `LaneStageDetectionFields` schema for the stage-detection fields every lane
|
||||
* response carries, and the idempotent `POST /api/lanes/ensure` lookup the
|
||||
* Workspace page opens a directory with.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const tags = [
|
||||
{
|
||||
name: "Lanes",
|
||||
description: "Durable parallel-work lanes and dashboard-managed git worktrees",
|
||||
},
|
||||
];
|
||||
|
||||
const schemas = {
|
||||
LaneWorktreeCreateRequest: {
|
||||
type: "object",
|
||||
required: ["sourceRepo"],
|
||||
properties: {
|
||||
sourceRepo: {
|
||||
type: "string",
|
||||
description: "Existing absolute path to the source git repository.",
|
||||
example: "/Users/me/src/project",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Human-readable lane title.",
|
||||
example: "Criteria form",
|
||||
},
|
||||
base: {
|
||||
type: "string",
|
||||
description:
|
||||
"Preferred base branch. Defaults to the LANE_BASE_BRANCH env var, or `main` when that is also unset.",
|
||||
example: "main",
|
||||
},
|
||||
slug: {
|
||||
type: "string",
|
||||
description: "Optional branch/directory slug override.",
|
||||
example: "criteria-form",
|
||||
},
|
||||
},
|
||||
},
|
||||
LaneEnsureRequest: {
|
||||
type: "object",
|
||||
required: ["cwd"],
|
||||
properties: {
|
||||
cwd: {
|
||||
type: "string",
|
||||
description:
|
||||
"Absolute working directory to find or adopt a lane for. A lane whose own cwd is this path, or the closest path-boundary parent of it, is returned as-is.",
|
||||
example: "/Users/me/src/project/packages/app",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description:
|
||||
"Title for the lane if one has to be created; ignored when one already exists.",
|
||||
example: "App package",
|
||||
},
|
||||
},
|
||||
},
|
||||
LaneDestructiveActionRequest: {
|
||||
type: "object",
|
||||
required: ["confirm", "expect"],
|
||||
properties: {
|
||||
confirm: { type: "boolean", enum: [true] },
|
||||
force: {
|
||||
type: "boolean",
|
||||
description: "Required by reset/remove when unpushed commits exist.",
|
||||
},
|
||||
expect: {
|
||||
type: "object",
|
||||
description:
|
||||
"Required complete facts returned by the preceding preflight: head, dirty, untracked, unpushed for reset/remove; sessions, events, tokenRows for purge. Differences return 409 ESTALE without destructive work.",
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
LaneStageDetectionFields: {
|
||||
type: "object",
|
||||
description:
|
||||
"Fields the server's stage-detection heuristic (server/lib/stage-detect.js) adds to every lane returned by GET /api/lanes and GET /api/lanes/:id. An inferred stage is never evidence and never renders as done — see docs/LANES.md#stage-detection.",
|
||||
properties: {
|
||||
detected_stage: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description:
|
||||
"Stage id inferred from ingested tool events, or null if no signal has been seen. Independent of the agent's own declared `stage`.",
|
||||
},
|
||||
detected_signal: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description:
|
||||
"The tool-event signal that produced detected_stage, capped at 120 characters; null when detected_stage is null.",
|
||||
},
|
||||
detected: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Present on each entry of pipeline_nodes. True for the inferred node and any node before it that carries no declared record; decorates that node's state without ever upgrading it to done.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const paths = {
|
||||
"/api/lanes/ensure": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Find or adopt the lane owning a working directory",
|
||||
description:
|
||||
"Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200), otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.",
|
||||
operationId: "ensureLane",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/LaneEnsureRequest" } },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "An existing lane already owns that cwd.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["lane", "created"],
|
||||
properties: {
|
||||
lane: { type: "object", additionalProperties: true },
|
||||
created: { type: "boolean", enum: [false] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
201: {
|
||||
description: "No lane owned that cwd, so an adopted one was created.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["lane", "created"],
|
||||
properties: {
|
||||
lane: { type: "object", additionalProperties: true },
|
||||
created: { type: "boolean", enum: [true] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "cwd is missing or not an absolute path (EBADCWD).",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "The browser request was not same-origin/loopback.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/worktree": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Provision a managed git worktree lane",
|
||||
description:
|
||||
"Validates the absolute source repository, creates a managed lane in `provisioning` state, and returns immediately. Worktree creation continues under the lane lock; the existing `lane_update` broadcast reports either `idle` or `failed` with git stderr in `notes`.",
|
||||
operationId: "createLaneWorktree",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/LaneWorktreeCreateRequest" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
202: {
|
||||
description: "Managed lane accepted for background provisioning.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["lane"],
|
||||
properties: { lane: { type: "object", additionalProperties: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "sourceRepo is relative, missing, or not a git repository.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "The browser request was not same-origin/loopback.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description:
|
||||
"The computed worktree directory already belongs to a lane, or no unique directory was available after 50 attempts.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}": {
|
||||
patch: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Partially update a lane",
|
||||
description:
|
||||
"Updates lane fields, including run_id; browser requests must pass the loopback same-origin guard. The provisioning-time facts kind, source_repo, slug and base_branch are NOT patchable — kind is check 1 of the destroy guard — and are silently ignored here.",
|
||||
operationId: "updateLane",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", additionalProperties: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "Updated lane." },
|
||||
400: { description: "kind was not one of adopted|managed (EBADKIND)." },
|
||||
403: { description: "The browser request was not same-origin/loopback." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/preflight": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Count facts before a destructive lane action",
|
||||
operationId: "preflightLaneAction",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{
|
||||
name: "action",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string", enum: ["reset", "remove", "purge"] },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"Current counted facts for the selected action. reset/remove additionally return blocked[] (hard blockers: adopted, missing, unreadable, unpushed-commits — the only one force overrides) and warnings[] (informational only, e.g. no-remote).",
|
||||
},
|
||||
400: { description: "Unknown action." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "A lane's working-copy facts",
|
||||
description:
|
||||
"Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git three times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.",
|
||||
operationId: "getLaneGitFacts",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"available:true with {branch, head, subject, dirty, untracked}, or available:false alone.",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/branches": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "A candidate source repo's local branches",
|
||||
description:
|
||||
"Feeds the Add-lane picker: given a repo path, returns its local branches (not origin/* refs — those aren't checkout-able into a new worktree without a fetch first) plus which one is currently checked out. Same repo validation as POST /api/lanes/worktree; a repo neither endpoint can resolve can't be provisioned from either. Read-only, no same-origin guard.",
|
||||
operationId: "listLaneBranches",
|
||||
parameters: [
|
||||
{
|
||||
name: "repo",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "Absolute path to an existing git repository.",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: { description: "{branches: string[], current: string | null}." },
|
||||
400: { description: "repo is missing, relative, does not exist, or is not a git repo." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/{action}": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Confirm a reset, managed-worktree removal, or session purge",
|
||||
description:
|
||||
"Actions run under the lane lock after waiting for the lane child's actual exit. A failed spawn is already exited because no child started. Reset requires a live managed worktree; remove tears down a managed worktree, prunes git's stale record when the directory was deleted by hand, or only forgets an adopted-lane row without touching its directory. Reset/remove require force when managed work has unpushed commits.",
|
||||
operationId: "runLaneDestructiveAction",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{
|
||||
name: "action",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string", enum: ["reset", "remove", "purge"] },
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/LaneDestructiveActionRequest" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "Action completed; purge includes its deleted-row counts." },
|
||||
400: {
|
||||
description:
|
||||
"Confirmation or complete expect facts missing, or the managed-worktree guard refused the target.",
|
||||
},
|
||||
403: { description: "The browser request was not same-origin/loopback." },
|
||||
409: { description: "Preflight facts changed (ESTALE) or force is required (EUNPUSHED)." },
|
||||
500: {
|
||||
description:
|
||||
"Git, run-exit timeout, or internal failure; git failures include error.stderr.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
Reference in New Issue
Block a user