feat(coder): guard session delete against worktree work loss
Deleting a BooChat session CASCADE-wipes its session_worktrees row, which would silently orphan uncommitted/unpushed/unmerged work in the worktree. Add a pre-DELETE gate: the server reads session_worktrees from the shared DB first (no row = chat-only session = delete immediately, zero round-trip), and for worktree-backed sessions calls a new BooCoder endpoint that runs git on the host (only the host systemd service can see /tmp/booworktrees). checkWorktreeWorkAtRisk reports dirty/unpushed/unmerged via the audited hostExec+shellEscape path; default branch is detected from refs/remotes/origin/HEAD (not the worktree's own branch), never hardcoded. Any at-risk worktree returns 409 with per-worktree RiskReport[]; force=true bypasses the check entirely. Fail-closed: coder unreachable/errored also blocks (force still escapes). The sidebar renders a block dialog distinguishing work-at-risk (Commit/Stash/Force) from couldn't-verify (Cancel/Force only); stash uses -u and re-blocks on remaining commits with an explanatory message. Commit never auto-commits — it routes the user to the session. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
45
apps/coder/src/routes/worktree-safety.ts
Normal file
45
apps/coder/src/routes/worktree-safety.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Session-delete work-loss guard (coder side).
|
||||
*
|
||||
* Session delete itself lives in apps/server (Docker), which CANNOT see the
|
||||
* host worktree dirs (/tmp/booworktrees) or run git on them. Only BooCoder
|
||||
* (host systemd) can. So the server's DELETE route calls these endpoints
|
||||
* pre-delete to learn whether a session's worktree holds work at risk, and to
|
||||
* stash it. The server owns the gate; coder owns the git truth.
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
import { checkWorktreeWorkAtRisk, stashWorktree } from '../services/worktrees.js';
|
||||
|
||||
export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
// GET risk for a session's worktree(s). One row per session today (PK on
|
||||
// session_id); the loop already handles the Phase-1.5 multi-worktree case.
|
||||
app.get<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/worktree-risk',
|
||||
async (req) => {
|
||||
const rows = await sql<{ worktree_path: string }[]>`
|
||||
SELECT worktree_path FROM session_worktrees WHERE session_id = ${req.params.sessionId}
|
||||
`;
|
||||
const reports = [];
|
||||
for (const row of rows) {
|
||||
reports.push(await checkWorktreeWorkAtRisk(row.worktree_path));
|
||||
}
|
||||
return { reports };
|
||||
},
|
||||
);
|
||||
|
||||
// Stash a session's worktree(s) — clears the dirty risk; recoverable.
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/worktree-stash',
|
||||
async (req) => {
|
||||
const rows = await sql<{ worktree_path: string }[]>`
|
||||
SELECT worktree_path FROM session_worktrees WHERE session_id = ${req.params.sessionId}
|
||||
`;
|
||||
const results = [];
|
||||
for (const row of rows) {
|
||||
results.push({ worktreePath: row.worktree_path, ...(await stashWorktree(row.worktree_path)) });
|
||||
}
|
||||
return { results };
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user