9d145865dd
Gives each lane its own slot-derived runtime (ports, detached process lifecycle, profile-driven hooks) and its own database/Redis logical index/.env file, so two lanes running the same repo's stack at once no longer share state. Machine-level DB/Redis credentials live at ~/.ccam/secrets.env (mode 0600, never returned by any route); a hook's output is redacted of that password (raw and URL-encoded forms) before it reaches a log file or the lane_hook_output websocket broadcast. Wired into provision/up/reset/remove; reset accepts --keep-db to skip the drop/recreate/migrate/reseed block entirely.
162 lines
5.7 KiB
JavaScript
162 lines
5.7 KiB
JavaScript
/**
|
|
* @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");
|
|
const { resolveProfile } = require("./lane-profile");
|
|
const { dbName } = require("./lane-slots");
|
|
|
|
/**
|
|
* 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");
|
|
}
|
|
}
|
|
|
|
// The database name this action would drop (reset unless --keep-db,
|
|
// remove always) — echoed the same way `head`/`dirty` are, so the
|
|
// confirmation dialog names the destructive fact rather than leaving it a
|
|
// surprise. Null when the lane has no slot yet or the profile declares no
|
|
// DB_PREFIX — nothing has been derived to drop.
|
|
const profile = resolveProfile(lane);
|
|
const database = profile && lane.slot ? dbName(profile, lane.slot) : null;
|
|
|
|
return {
|
|
action,
|
|
lane: lane.id,
|
|
kind: lane.kind,
|
|
branch: lane.branch,
|
|
dirty,
|
|
untracked,
|
|
unpushed,
|
|
head: head || null,
|
|
database,
|
|
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 };
|