57dc91585d
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.
161 lines
4.9 KiB
JavaScript
161 lines
4.9 KiB
JavaScript
/**
|
|
* @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 };
|