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,150 @@
|
||||
/**
|
||||
* @file Preflight checks before destructive lane operations (reset, remove, purge).
|
||||
* Queries the current state without mutations: git status, unpushed commits, sessions
|
||||
* to be purged, and blockers (adopted lanes, missing directories, unpushed work).
|
||||
* Every result is read-only; the route and action layer decide what to do with blocks.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const { db } = require("../db");
|
||||
const wt = require("./worktree");
|
||||
const lanesLib = require("./lanes");
|
||||
|
||||
/**
|
||||
* Preflight for reset, remove, or purge. Returns an object describing what will happen:
|
||||
* - reset/remove: {action, lane, kind, branch, dirty, untracked, unpushed, head, blocked, warnings}
|
||||
* - purge: {action, lane, sessions, events, tokenRows, bytesEstimate, activeSessionSkipped}
|
||||
*
|
||||
* blocked[] holds only conditions that genuinely prevent the action: "adopted" (not
|
||||
* managed), "missing" (dir gone), "unreadable" (git failed against the directory), and
|
||||
* "unpushed-commits" (unpushed > 0 — the only one a `force: true` overrides). warnings[]
|
||||
* holds purely informational facts that never gate the action, starting with "no-remote"
|
||||
* (no git remote configured — nothing is backed up, but the action proceeds). Both are
|
||||
* data, not an exception.
|
||||
*
|
||||
* @param {object} lane - The lane to check
|
||||
* @param {string} action - One of "reset", "remove", "purge"
|
||||
* @returns {Promise<object>} Preflight report
|
||||
*/
|
||||
async function preflight(lane, action) {
|
||||
const blocked = [];
|
||||
const warnings = [];
|
||||
|
||||
// Check if lane is adopted (not managed)
|
||||
if (lane.kind === "adopted") {
|
||||
blocked.push("adopted");
|
||||
}
|
||||
|
||||
// For reset/remove, return git status and blockers
|
||||
if (action === "reset" || action === "remove") {
|
||||
let dirty = 0;
|
||||
let untracked = 0;
|
||||
let unpushed = 0;
|
||||
let head = null;
|
||||
|
||||
// First check if directory exists
|
||||
if (!fs.existsSync(lane.cwd)) {
|
||||
blocked.push("missing");
|
||||
} else {
|
||||
// Directory exists, try to read git status
|
||||
try {
|
||||
// Get status counts
|
||||
const status = await wt.statusCounts(lane.cwd);
|
||||
dirty = status.dirty;
|
||||
untracked = status.untracked;
|
||||
head = status.head;
|
||||
|
||||
// Get unpushed count — measured against the lane's own base branch when
|
||||
// there is no remote, so it counts this lane's work, not the whole repo.
|
||||
unpushed = await wt.unpushedCount(lane.cwd, lane.base_branch);
|
||||
if (unpushed > 0) {
|
||||
blocked.push("unpushed-commits");
|
||||
}
|
||||
|
||||
// Detect no remotes configured at all — informational only, never a blocker:
|
||||
// a perfectly ordinary local-only managed lane has no remote at all.
|
||||
const noRemote = await wt.hasNoRemotes(lane.cwd);
|
||||
if (noRemote) {
|
||||
warnings.push("no-remote");
|
||||
}
|
||||
} catch (err) {
|
||||
// Directory exists but git failed (corrupt repo, permission denied, etc.)
|
||||
blocked.push("unreadable");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
lane: lane.id,
|
||||
kind: lane.kind,
|
||||
branch: lane.branch,
|
||||
dirty,
|
||||
untracked,
|
||||
unpushed,
|
||||
head: head || null,
|
||||
blocked,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
// For purge, count sessions and events to be deleted
|
||||
if (action === "purge") {
|
||||
const counts = countPurgeSessions(lane);
|
||||
|
||||
return {
|
||||
action,
|
||||
lane: lane.id,
|
||||
sessions: counts.sessions,
|
||||
events: counts.events,
|
||||
tokenRows: counts.tokenRows,
|
||||
bytesEstimate: (counts.events + counts.tokenRows) * 512,
|
||||
activeSessionSkipped: counts.activeSessionSkipped,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown action should never reach here (route validates)
|
||||
throw Object.assign(new Error(`unknown action: ${action}`), { code: "EBADACTION" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Count sessions, events, and token_usage rows that would be deleted by purgeLaneSessions.
|
||||
* Uses the shared purgeCandidateSessions helper to ensure the counts match exactly what
|
||||
* gets deleted, so the confirmation dialog's numbers are truthful.
|
||||
*/
|
||||
function countPurgeSessions(lane) {
|
||||
const result = { sessions: 0, events: 0, tokenRows: 0, activeSessionSkipped: false };
|
||||
|
||||
// Use the shared helpers so the path matching (and its LIKE escaping) has
|
||||
// exactly one definition, in server/lib/lanes.js.
|
||||
const sessionsToDelete = lanesLib.purgeCandidateSessions(lane);
|
||||
result.activeSessionSkipped = lanesLib.hasActiveLaneSession(lane);
|
||||
|
||||
if (sessionsToDelete.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Count events for those sessions
|
||||
const sessionIds = sessionsToDelete.map((s) => s.id);
|
||||
const eventsCount = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count FROM events WHERE session_id IN (${sessionIds.map(() => "?").join(",")})`
|
||||
)
|
||||
.get(...sessionIds);
|
||||
result.events = eventsCount ? eventsCount.count : 0;
|
||||
|
||||
// Count orphaned token_usage rows
|
||||
const tokenCount = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count FROM token_usage WHERE session_id IN (${sessionIds.map(() => "?").join(",")})`
|
||||
)
|
||||
.get(...sessionIds);
|
||||
result.tokenRows = tokenCount ? tokenCount.count : 0;
|
||||
|
||||
// Count sessions (for completeness)
|
||||
result.sessions = sessionsToDelete.length;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { preflight };
|
||||
Reference in New Issue
Block a user