0004cf7036
A lane's `remove` tore down what the lane pointed at: `git worktree remove --force`, a branch delete, and a drop of the lane's own database. One mis-click on a dashboard row cost a whole branch's unpushed code — and the row is the only thing AgentHub actually owns. `remove` now stops the run and the stack, clears AgentHub's own runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/`, and deletes the row. The working tree, its branch, git's worktree records and the database stay exactly where they are, for the user to delete by hand when they mean to. That also fixes #28. Removal used to route through `assertDestroyable`, whose third check demands the cwd appear in `git worktree list`; a managed lane whose directory git no longer listed answered `ENOTWORKTREE` ("lane is not listed as a worktree in …") and could never be dropped from the dashboard at all. Removal no longer asks git anything, so no preflight blocker stops it, and the unpushed-commits force-gate now applies to `reset` alone — nothing `remove` does can discard a commit. The UI follows: one label for both lane kinds ("Delete lane record"), copy that says what survives, and no Force checkbox on removal. `removeWorktree`/`removeLaneData` remain in place as guarded primitives; no route calls them any more.
163 lines
5.8 KiB
JavaScript
163 lines
5.8 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; never
|
|
// for remove, which forgets the lane and leaves its data in place — see the
|
|
// remove branch in routes/lanes.js) — 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 };
|