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,160 @@
|
||||
/**
|
||||
* @file dashboard-runs.js
|
||||
* @description Persistence layer for runs spawned via the dashboard's
|
||||
* /api/run endpoint. The in-memory handle map in run-spawner.js reaps
|
||||
* handles 5 min after exit, which is fine for live re-attach but loses
|
||||
* historical data. This module mirrors every spawn / status transition
|
||||
* into a sqlite row so the Run page can show a full history of what
|
||||
* the user has spawned and resume any of those sessions.
|
||||
*
|
||||
* All db operations are wrapped in try/catch so a failure here can never
|
||||
* take down a live run — persistence is a side benefit, not a blocker.
|
||||
*
|
||||
* A run started through a lane also carries that lane's id (`lane_id`), so the
|
||||
* Workspace page can list one lane's own run history; runs spawned straight
|
||||
* from POST /api/run leave it null.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { db } = require("../db");
|
||||
|
||||
const PROMPT_PREVIEW_LIMIT = 500;
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO dashboard_runs (
|
||||
id, session_id, mode, cwd, model, permission_mode, effort,
|
||||
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
|
||||
lane_id
|
||||
) VALUES (
|
||||
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
|
||||
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
|
||||
@lane_id
|
||||
)
|
||||
`);
|
||||
|
||||
const updateStmt = db.prepare(`
|
||||
UPDATE dashboard_runs
|
||||
SET session_id = COALESCE(@session_id, session_id),
|
||||
status = COALESCE(@status, status),
|
||||
exit_code = COALESCE(@exit_code, exit_code),
|
||||
ended_at = COALESCE(@ended_at, ended_at)
|
||||
WHERE id = @id
|
||||
`);
|
||||
|
||||
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
|
||||
resume_session_id, prompt_preview, status, exit_code,
|
||||
started_at, ended_at, lane_id`;
|
||||
|
||||
const listStmt = db.prepare(`
|
||||
SELECT ${RUN_COLUMNS}
|
||||
FROM dashboard_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT @limit
|
||||
`);
|
||||
|
||||
const listByLaneStmt = db.prepare(`
|
||||
SELECT ${RUN_COLUMNS}
|
||||
FROM dashboard_runs
|
||||
WHERE lane_id = @laneId
|
||||
ORDER BY started_at DESC
|
||||
LIMIT @limit
|
||||
`);
|
||||
|
||||
const getStmt = db.prepare(`
|
||||
SELECT ${RUN_COLUMNS}
|
||||
FROM dashboard_runs WHERE id = @id
|
||||
`);
|
||||
|
||||
/**
|
||||
* Insert a new run record at spawn time. Idempotent on `id`.
|
||||
*/
|
||||
function recordRun(handle) {
|
||||
try {
|
||||
const startedAt = new Date(handle.startedAt || Date.now()).toISOString();
|
||||
const endedAt = handle.endedAt ? new Date(handle.endedAt).toISOString() : null;
|
||||
const prompt = typeof handle.prompt === "string" ? handle.prompt : "";
|
||||
insertStmt.run({
|
||||
id: handle.id,
|
||||
session_id: handle.sessionId || null,
|
||||
mode: handle.mode,
|
||||
cwd: handle.cwd || "",
|
||||
model: handle.model || null,
|
||||
permission_mode: handle.permissionMode || null,
|
||||
effort: handle.effort || null,
|
||||
resume_session_id: handle.resumeSessionId || null,
|
||||
prompt_preview: prompt.slice(0, PROMPT_PREVIEW_LIMIT) || null,
|
||||
status: handle.status || "spawning",
|
||||
exit_code: typeof handle.exitCode === "number" ? handle.exitCode : null,
|
||||
started_at: startedAt,
|
||||
ended_at: endedAt,
|
||||
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
||||
});
|
||||
} catch {
|
||||
/* persistence is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch an existing run record. Pass null/undefined for fields you don't
|
||||
* want to overwrite — COALESCE in SQL leaves the existing value untouched.
|
||||
*/
|
||||
function patchRun({ id, sessionId, status, exitCode, endedAt }) {
|
||||
try {
|
||||
updateStmt.run({
|
||||
id,
|
||||
session_id: sessionId ?? null,
|
||||
status: status ?? null,
|
||||
exit_code: typeof exitCode === "number" ? exitCode : null,
|
||||
ended_at: endedAt ? new Date(endedAt).toISOString() : null,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {{limit?: number, laneId?: number|null}} [opts] laneId narrows to one lane's runs. */
|
||||
function listRuns({ limit = 50, laneId = null } = {}) {
|
||||
try {
|
||||
const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(limit) || 50)));
|
||||
if (laneId != null) return listByLaneStmt.all({ limit: safeLimit, laneId });
|
||||
return listStmt.all({ limit: safeLimit });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getRun(id) {
|
||||
try {
|
||||
return getStmt.get({ id }) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const reconcileStmt = db.prepare(`
|
||||
UPDATE dashboard_runs
|
||||
SET status = 'abandoned',
|
||||
ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE status IN ('running', 'spawning')
|
||||
`);
|
||||
|
||||
/**
|
||||
* On server boot, any rows still flagged `running` or `spawning` are
|
||||
* orphans — the spawner only persists those statuses for handles it knows
|
||||
* about, and the in-memory map was just wiped by the restart. Mark them as
|
||||
* `abandoned` so the UI doesn't display them as live and the user can
|
||||
* resume them like any other completed past run.
|
||||
*
|
||||
* Returns the number of rows updated.
|
||||
*/
|
||||
function reconcileOrphans() {
|
||||
try {
|
||||
const info = reconcileStmt.run();
|
||||
return info.changes || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { recordRun, patchRun, listRuns, getRun, reconcileOrphans };
|
||||
Reference in New Issue
Block a user