@loicngr/kobo 1.11.35 → 1.11.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -0
- package/CHANGELOG.md +9 -0
- package/dist/server/index.js +11 -24
- package/dist/server/routes/dev-server.js +2 -1
- package/dist/server/routes/pull-requests.js +5 -4
- package/dist/server/routes/workspaces.js +122 -91
- package/dist/server/services/agent/engines/claude-code/engine.js +26 -0
- package/dist/server/services/agent/engines/claude-code/event-mapper.js +10 -10
- package/dist/server/services/agent/engines/codex/client.js +1 -0
- package/dist/server/services/agent/engines/codex/engine.js +47 -10
- package/dist/server/services/agent/engines/codex/event-mapper.js +5 -1
- package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +21 -8
- package/dist/server/services/agent/engines/codex/jsonrpc/transport.js +30 -3
- package/dist/server/services/agent/orchestrator.js +175 -68
- package/dist/server/services/agent/session-controller.js +12 -2
- package/dist/server/services/auto-loop-service.js +71 -6
- package/dist/server/services/change-source-branch-service.js +36 -4
- package/dist/server/services/cron-service.js +19 -0
- package/dist/server/services/db-backup-service.js +30 -0
- package/dist/server/services/dev-server-service.js +167 -38
- package/dist/server/services/image-service.js +25 -28
- package/dist/server/services/pr-checkout-service.js +34 -5
- package/dist/server/services/quota-backoff-service.js +25 -8
- package/dist/server/services/wakeup-service.js +37 -3
- package/dist/server/services/worktree-purge-service.js +3 -7
- package/dist/server/utils/agent-stop-result.js +12 -0
- package/dist/server/utils/git-ops.js +8 -1
- package/dist/server/utils/git-worktree-fingerprint.js +86 -0
- package/dist/server/utils/safe-path.js +21 -0
- package/dist/server/utils/script-runner.js +32 -9
- package/dist/server/utils/workspace-lifecycle-guard.js +27 -0
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/{ActivityFeed-Bakgvay6.css → ActivityFeed-BDyFBoxd.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-C_-hxIo8.js +8 -0
- package/src/client/dist/pwa/assets/{ChangelogPage-DVLWWvvz.js → ChangelogPage-CBlGJ4zT.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-LZrvk6a6.js → ComparisonPanel-DZAE2X9j.js} +1 -1
- package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-ArmtcF4d.js +2 -0
- package/src/client/dist/pwa/assets/{DashboardPage-0odIhseb.js → DashboardPage-eQTCXcdz.js} +1 -1
- package/src/client/dist/pwa/assets/{DiffViewer-NWB2uQkz.js → DiffViewer-CaT7vuSr.js} +3 -3
- package/src/client/dist/pwa/assets/{DocumentsPanel-3yApHgbl.js → DocumentsPanel-A0isCTaI.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-C_bNtZsE.js → DrawerToggleButton-T70fh8dp.js} +1 -1
- package/src/client/dist/pwa/assets/{GitPanel-BMjWwiAX.js → GitPanel-CAvk0G7h.js} +3 -3
- package/src/client/dist/pwa/assets/{HealthPage-Vyr1YG2o.js → HealthPage-D8Mr0V6w.js} +1 -1
- package/src/client/dist/pwa/assets/{MainLayout-s8_9FPE2.js → MainLayout-x6Es4Da-.js} +2 -2
- package/src/client/dist/pwa/assets/{QBtnDropdown-BxuXEBp5.js → QBtnDropdown-CQqzzlFI.js} +1 -1
- package/src/client/dist/pwa/assets/{QDrawer-BdlDiU6a.js → QDrawer-Cwj2qh0r.js} +1 -1
- package/src/client/dist/pwa/assets/{QSelect-BrSemS3o.js → QSelect-BSPtuXJg.js} +1 -1
- package/src/client/dist/pwa/assets/{QTooltip-CLCFHW2D.js → QTooltip-DC_kDKku.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-F9bdAq6K.js → SchedulePanel-9aJSoz6Y.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-CBCefYqc.js → SearchPage-BjNvtE_U.js} +1 -1
- package/src/client/dist/pwa/assets/{SettingsPage-BPgTHmKj.js → SettingsPage-CvLnT73V.js} +1 -1
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-DDs2nuoT.js → SplitWorkspacePage-DTao0DHr.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-CulE0wTU.js → TerminalPanel-BrKIW-bJ.js} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-DUgqs2cW.js → TouchPan-_n1AQgoo.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-CdHpGBdr.js → TourReplayButton-CJF3nVvn.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-W5SvqzXO.js → WorkspaceAttentionLabels-CxesUiIm.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspacePage-DYQrfeYw.css → WorkspacePage-DoXtwpjV.css} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-MxBE0mr3.js +4 -0
- package/src/client/dist/pwa/assets/{browser-CnuqDTyc.js → browser-DGWpkvN3.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-JodIO7-I.js → build-path-tree-Ba1dSLgi.js} +1 -1
- package/src/client/dist/pwa/assets/{de-Dt_m-H83.js → de-DVK21RB6.js} +1 -1
- package/src/client/dist/pwa/assets/{documents-3ZBK1pXE.js → documents-BMHzjW_g.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-DZ9WO_J7.js → editor.api-CYTuNwqu.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-LrtJPx-j.js → efforts-BPlRSHXp.js} +1 -1
- package/src/client/dist/pwa/assets/{engineFeatures-DmnHj_uD.js → engineFeatures-DVB2rklO.js} +1 -1
- package/src/client/dist/pwa/assets/{es-DFg25DCS.js → es-C8cQwDZM.js} +1 -1
- package/src/client/dist/pwa/assets/{expand-template-Dv4JC2zU.js → expand-template-Cj82YE-O.js} +1 -1
- package/src/client/dist/pwa/assets/{format-CHjfjuhr.js → format-Dk7O3LhA.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-BsgH8CoH.js → formatters-TGjvs7Ca.js} +1 -1
- package/src/client/dist/pwa/assets/{fr-CQ1slpol.js → fr-BVOMZ2Bl.js} +1 -1
- package/src/client/dist/pwa/assets/{freemarker2-Dla55SIR.js → freemarker2-39lO_dWH.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-DtuuZy7D.js → handlebars-M_0V82cD.js} +1 -1
- package/src/client/dist/pwa/assets/{html-CKYOCR8W.js → html-yILqYDsl.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-2UeMfd5m.js +1 -0
- package/src/client/dist/pwa/assets/{index-CIP08MpU.js → index-BJU5dnkF.js} +2 -2
- package/src/client/dist/pwa/assets/{it-w09rfaP7.js → it-CxkAoOj4.js} +1 -1
- package/src/client/dist/pwa/assets/{javascript-BxzTnsIQ.js → javascript-DrW-amDc.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-C8BfPFUy.js → liquid-D_mXmUoL.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-DZm_YXkC.js → mdx-E8O3lvR5.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-HDbRHiNR.js → monaco-contributions-C2K3sl_V.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-DRW-851R.js → monaco.contribution-DLhHQIJn.js} +2 -2
- package/src/client/dist/pwa/assets/{network-auth-Ce9hsryo.js → network-auth-BUwWtnui.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-OBpMEBvP.js → notify-theme-1mOpY2vN.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-CVQxWyzP.js → pending-diff-open-BL7dKRqF.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-CVFHS2ec.js → permissionModes-BtC3_P-P.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-mcpQp2o6.js → project-color-BFBFZWeG.js} +1 -1
- package/src/client/dist/pwa/assets/{python-CO5iQN74.js → python-DoZAYim6.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-BC7D8LMf.js → razor-9bZ30g2y.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-Cb15kvox.js → render-chat-markdown-7EtWNjG0.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-BCHCSNpD.js → typescript-C1CBfL3L.js} +1 -1
- package/src/client/dist/pwa/assets/{use-tours-DhLhglwr.js → use-tours-BJ81clv6.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-CoWrOEac.js → workspace-templates-r1-bipjM.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-y9M3NAIZ.js → xml-COuu5isK.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-CthlToPo.js → yaml-ChZ7WqaJ.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-YRbIRutK.js +0 -8
- package/src/client/dist/pwa/assets/CreatePage-DxOyY65u.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-gFrn4xl9.js +0 -2
- package/src/client/dist/pwa/assets/WorkspacePage-B6O7j57u.js +0 -4
- package/src/client/dist/pwa/assets/i18n-DTfKQUMf.js +0 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
|
|
1
2
|
// src/server/services/change-source-branch-service.ts
|
|
2
3
|
import { spawn } from 'node:child_process';
|
|
3
4
|
import * as path from 'node:path';
|
|
@@ -44,6 +45,15 @@ const BACKUP_BRANCHES_KEPT = 3;
|
|
|
44
45
|
* running, unknown base). Returns a status discriminating the outcome.
|
|
45
46
|
*/
|
|
46
47
|
export async function changeSourceBranch(workspaceId, newBase) {
|
|
48
|
+
// Always acquire workspace ownership before the shared repository lock.
|
|
49
|
+
return withWorkspaceLifecycleGuard(workspaceId, () => {
|
|
50
|
+
const workspace = getWorkspace(workspaceId);
|
|
51
|
+
if (!workspace)
|
|
52
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
53
|
+
return withGitRepoLock(workspace.worktreePath, () => changeSourceBranchGuarded(workspaceId, newBase));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function changeSourceBranchGuarded(workspaceId, newBase) {
|
|
47
57
|
const workspace = getWorkspace(workspaceId);
|
|
48
58
|
if (!workspace)
|
|
49
59
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
@@ -85,16 +95,16 @@ export async function changeSourceBranch(workspaceId, newBase) {
|
|
|
85
95
|
}
|
|
86
96
|
// A stale index.lock makes every git write fail half-way through, and the
|
|
87
97
|
// raw git message tells the user nothing. Typical origin: a setup/cleanup
|
|
88
|
-
// script SIGKILLed at its timeout during a commit.
|
|
89
|
-
// does not go through the repository lock, so the check is immediate here.
|
|
98
|
+
// script SIGKILLed at its timeout during a commit. Both paths hold the lock.
|
|
90
99
|
gitOps.assertNoIndexLock(worktreePath);
|
|
100
|
+
gitOps.assertCurrentBranch(worktreePath, workingBranch);
|
|
91
101
|
return runCustomScript(workspace, oldBase, trimmedNew, effective.changeSourceBranchScript);
|
|
92
102
|
}
|
|
93
|
-
//
|
|
103
|
+
// Both strategies run under the repository lock: it fetches,
|
|
94
104
|
// resets and cherry-picks, all of which touch the COMMON git dir shared by
|
|
95
105
|
// every worktree of the project. A concurrent fetch from the PR watcher
|
|
96
106
|
// during the cherry-pick leaves it aborted mid-way.
|
|
97
|
-
return
|
|
107
|
+
return runBuiltInStrategy(workspaceId, worktreePath, workingBranch, workspace.projectPath, oldBase, trimmedNew);
|
|
98
108
|
}
|
|
99
109
|
/** The built-in cherry-pick strategy. Always called under the repository lock. */
|
|
100
110
|
async function runBuiltInStrategy(workspaceId, worktreePath, workingBranch, projectPath, oldBase, trimmedNew) {
|
|
@@ -114,6 +124,10 @@ async function runBuiltInStrategy(workspaceId, worktreePath, workingBranch, proj
|
|
|
114
124
|
catch {
|
|
115
125
|
// offline / no remote — proceed with local refs
|
|
116
126
|
}
|
|
127
|
+
assertWorkspaceUnchanged(workspaceId, worktreePath, workingBranch, oldBase);
|
|
128
|
+
gitOps.assertCurrentBranch(worktreePath, workingBranch);
|
|
129
|
+
if (getAgentStatus(workspaceId) !== null)
|
|
130
|
+
throw new Error('Cannot change the source branch while the agent is running');
|
|
117
131
|
if (!gitOps.branchExists(worktreePath, trimmedNew, 'origin')) {
|
|
118
132
|
throw new Error(`Source branch 'origin/${trimmedNew}' does not exist`);
|
|
119
133
|
}
|
|
@@ -235,6 +249,12 @@ async function runCustomScript(workspace, oldBase, newBase, script) {
|
|
|
235
249
|
catch (err) {
|
|
236
250
|
console.warn('[change-source-branch] PR lookup failed, KOBO_PR_NUMBER will be empty:', err);
|
|
237
251
|
}
|
|
252
|
+
assertWorkspaceUnchanged(workspace.id, workspace.worktreePath, workspace.workingBranch, oldBase);
|
|
253
|
+
gitOps.assertCurrentBranch(workspace.worktreePath, workspace.workingBranch);
|
|
254
|
+
if (gitOps.worktreeHasChangesStrict(workspace.worktreePath))
|
|
255
|
+
return { status: 'dirty', forcePushNeeded: false, commitCount: 0 };
|
|
256
|
+
if (getAgentStatus(workspace.id) !== null)
|
|
257
|
+
throw new Error('Cannot change the source branch while the agent is running');
|
|
238
258
|
return new Promise((resolve, reject) => {
|
|
239
259
|
const child = spawn('bash', ['-c', script], {
|
|
240
260
|
cwd: workspace.worktreePath,
|
|
@@ -292,3 +312,15 @@ async function runCustomScript(workspace, oldBase, newBase, script) {
|
|
|
292
312
|
});
|
|
293
313
|
});
|
|
294
314
|
}
|
|
315
|
+
/** Async lookups must not authorize mutations using obsolete workspace metadata. */
|
|
316
|
+
function assertWorkspaceUnchanged(id, worktreePath, branch, sourceBranch) {
|
|
317
|
+
const current = getWorkspace(id);
|
|
318
|
+
if (!current ||
|
|
319
|
+
current.archivedAt ||
|
|
320
|
+
current.worktreePurgedAt ||
|
|
321
|
+
current.worktreePath !== worktreePath ||
|
|
322
|
+
current.workingBranch !== branch ||
|
|
323
|
+
current.sourceBranch !== sourceBranch) {
|
|
324
|
+
throw new Error('Workspace changed during source-branch validation; retry the operation');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -2,6 +2,7 @@ import { CronExpressionParser } from 'cron-parser';
|
|
|
2
2
|
import { nanoid } from 'nanoid';
|
|
3
3
|
import { getDb } from '../db/index.js';
|
|
4
4
|
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
5
|
+
import { isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
|
|
5
6
|
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
6
7
|
import * as orchestrator from './agent/orchestrator.js';
|
|
7
8
|
import * as settingsService from './settings-service.js';
|
|
@@ -15,6 +16,14 @@ export const MIN_DELAY_BETWEEN_FIRES_SECONDS = 60;
|
|
|
15
16
|
// real fire time hasn't been reached yet.
|
|
16
17
|
const MAX_SETTIMEOUT_MS = 2_000_000_000; // ~23 days, with margin under 2^31-1
|
|
17
18
|
const timers = new Map();
|
|
19
|
+
let suspended = false;
|
|
20
|
+
/** Preserve cron intent and deadlines while the process is shutting down. */
|
|
21
|
+
export function suspendForShutdown() {
|
|
22
|
+
suspended = true;
|
|
23
|
+
for (const timer of timers.values())
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
timers.clear();
|
|
26
|
+
}
|
|
18
27
|
/**
|
|
19
28
|
* Arm a setTimeout that fires `fireOrSkip(id)` when `fireAt` is reached.
|
|
20
29
|
* Replaces any existing timer for this id. Handles long horizons by
|
|
@@ -22,6 +31,8 @@ const timers = new Map();
|
|
|
22
31
|
* days.
|
|
23
32
|
*/
|
|
24
33
|
function scheduleAt(id, fireAt) {
|
|
34
|
+
if (suspended)
|
|
35
|
+
return;
|
|
25
36
|
const previous = timers.get(id);
|
|
26
37
|
if (previous)
|
|
27
38
|
clearTimeout(previous);
|
|
@@ -154,6 +165,8 @@ export function listAll() {
|
|
|
154
165
|
* unexpected error is logged and the cron is preserved when possible.
|
|
155
166
|
*/
|
|
156
167
|
function fireOrSkip(id) {
|
|
168
|
+
if (suspended)
|
|
169
|
+
return;
|
|
157
170
|
try {
|
|
158
171
|
timers.delete(id);
|
|
159
172
|
const db = getDb();
|
|
@@ -168,6 +181,10 @@ function fireOrSkip(id) {
|
|
|
168
181
|
cancel(id, wsRow ? 'archive' : 'deleted');
|
|
169
182
|
return;
|
|
170
183
|
}
|
|
184
|
+
if (isWorkspaceLifecycleBusy(row.workspace_id)) {
|
|
185
|
+
scheduleAt(id, new Date(Date.now() + 15_000));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
171
188
|
let status = 'skipped-active';
|
|
172
189
|
if (!orchestrator.hasController(row.workspace_id)) {
|
|
173
190
|
try {
|
|
@@ -244,6 +261,8 @@ function fireOrSkip(id) {
|
|
|
244
261
|
* Rows pointing at deleted/archived workspaces are removed without firing.
|
|
245
262
|
*/
|
|
246
263
|
export function restoreOnBoot() {
|
|
264
|
+
suspendForShutdown();
|
|
265
|
+
suspended = false;
|
|
247
266
|
try {
|
|
248
267
|
// Boot semantics: clear any existing in-memory timers before rearming.
|
|
249
268
|
for (const t of timers.values())
|
|
@@ -90,3 +90,33 @@ export async function createPreMigrationBackup(db, dbPath, tag, keepCount = DEFA
|
|
|
90
90
|
throw err;
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
+
/** Recheck daily-backup eligibility hourly, including on long-running servers. */
|
|
94
|
+
export function startDailyDbBackupScheduler(backup, intervalMs = 60 * 60 * 1000) {
|
|
95
|
+
let inFlight = null;
|
|
96
|
+
let stopped = false;
|
|
97
|
+
const attempt = async () => {
|
|
98
|
+
try {
|
|
99
|
+
await backup();
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
console.error('[kobo] Scheduled DB backup failed:', err);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const tick = () => {
|
|
106
|
+
if (stopped || inFlight)
|
|
107
|
+
return;
|
|
108
|
+
inFlight = attempt().finally(() => {
|
|
109
|
+
inFlight = null;
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
const timer = setInterval(tick, intervalMs);
|
|
113
|
+
timer.unref?.();
|
|
114
|
+
tick();
|
|
115
|
+
return {
|
|
116
|
+
async stop() {
|
|
117
|
+
stopped = true;
|
|
118
|
+
clearInterval(timer);
|
|
119
|
+
await inFlight;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
2
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
|
|
4
5
|
import { getProjectSettings } from './settings-service.js';
|
|
5
6
|
import { emitEphemeral } from './websocket-service.js';
|
|
6
7
|
import { getWorkspace, listWorkspaces, updateDevServerStatus } from './workspace-service.js';
|
|
@@ -23,9 +24,13 @@ function runCommand(command, args, options = {}) {
|
|
|
23
24
|
// ── State ──────────────────────────────────────────────────────────────────────
|
|
24
25
|
/** workspaceId -> spawned dev-server process */
|
|
25
26
|
const trackedProcesses = new Map();
|
|
27
|
+
const stoppingProcesses = new Map();
|
|
28
|
+
const generations = new Map();
|
|
26
29
|
/** Test-only: clear the tracked-processes map between tests. */
|
|
27
30
|
export function _resetTrackedProcessesForTests() {
|
|
28
31
|
trackedProcesses.clear();
|
|
32
|
+
stoppingProcesses.clear();
|
|
33
|
+
generations.clear();
|
|
29
34
|
}
|
|
30
35
|
// ── Pure helpers ───────────────────────────────────────────────────────────────
|
|
31
36
|
/**
|
|
@@ -84,23 +89,29 @@ export function resolveInstance(projectPath, workingBranch) {
|
|
|
84
89
|
}
|
|
85
90
|
return null;
|
|
86
91
|
}
|
|
87
|
-
function containerBelongsToProject(containerName, projectName) {
|
|
88
|
-
const name = containerName.toLowerCase();
|
|
89
|
-
const project = projectName.toLowerCase();
|
|
90
|
-
return name === project || name.startsWith(`${project}-`) || name.startsWith(`${project}_`);
|
|
91
|
-
}
|
|
92
92
|
// ── Docker helpers ─────────────────────────────────────────────────────────────
|
|
93
93
|
/**
|
|
94
94
|
* List all running Docker container names.
|
|
95
95
|
* Uses execFile so Docker inspection cannot block the Node event loop.
|
|
96
96
|
*/
|
|
97
|
-
export async function listRunningContainers() {
|
|
97
|
+
export async function listRunningContainers(projectName) {
|
|
98
98
|
try {
|
|
99
|
-
const
|
|
99
|
+
const args = projectName
|
|
100
|
+
? [
|
|
101
|
+
'ps',
|
|
102
|
+
'--filter',
|
|
103
|
+
`label=com.docker.compose.project=${projectName}`,
|
|
104
|
+
'--format',
|
|
105
|
+
'{{.Names}}\t{{.Label "com.docker.compose.project"}}',
|
|
106
|
+
]
|
|
107
|
+
: ['ps', '--format', '{{.Names}}'];
|
|
108
|
+
const output = await runCommand('docker', args, { timeout: 10_000 });
|
|
100
109
|
return output
|
|
101
110
|
.split('\n')
|
|
102
111
|
.map((s) => s.trim())
|
|
103
|
-
.filter(Boolean)
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.filter((line) => !projectName || line.split('\t')[1] === projectName)
|
|
114
|
+
.map((line) => (projectName ? line.split('\t')[0] : line));
|
|
104
115
|
}
|
|
105
116
|
catch {
|
|
106
117
|
return [];
|
|
@@ -128,8 +139,7 @@ export async function getStatus(projectPath, workingBranch, workspaceId) {
|
|
|
128
139
|
containers: [],
|
|
129
140
|
};
|
|
130
141
|
}
|
|
131
|
-
const
|
|
132
|
-
const matching = running.filter((name) => containerBelongsToProject(name, config.projectName));
|
|
142
|
+
const matching = await listRunningContainers(config.projectName);
|
|
133
143
|
if (matching.length > 0) {
|
|
134
144
|
return {
|
|
135
145
|
status: 'running',
|
|
@@ -167,6 +177,7 @@ export async function getStatus(projectPath, workingBranch, workspaceId) {
|
|
|
167
177
|
* Start the dev-server for a workspace.
|
|
168
178
|
*/
|
|
169
179
|
export function startDevServer(workspaceId) {
|
|
180
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
170
181
|
const workspace = getWorkspace(workspaceId);
|
|
171
182
|
if (!workspace) {
|
|
172
183
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
@@ -179,6 +190,8 @@ export function startDevServer(workspaceId) {
|
|
|
179
190
|
// silently overwriting the tracked process — the first process would
|
|
180
191
|
// otherwise become untrackable (never killed by stopDevServer) and its
|
|
181
192
|
// exit handler could later clobber state set by the second process.
|
|
193
|
+
if (stoppingProcesses.has(workspaceId))
|
|
194
|
+
throw new Error(`Dev server for workspace '${workspaceId}' is stopping`);
|
|
182
195
|
if (trackedProcesses.has(workspaceId)) {
|
|
183
196
|
throw new Error(`Dev server for workspace '${workspaceId}' is already starting`);
|
|
184
197
|
}
|
|
@@ -196,6 +209,8 @@ export function startDevServer(workspaceId) {
|
|
|
196
209
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
197
210
|
detached: true,
|
|
198
211
|
});
|
|
212
|
+
const generation = (generations.get(workspaceId) ?? 0) + 1;
|
|
213
|
+
generations.set(workspaceId, generation);
|
|
199
214
|
trackedProcesses.set(workspaceId, proc);
|
|
200
215
|
// Log stdout/stderr for debugging
|
|
201
216
|
proc.stdout?.on('data', (data) => {
|
|
@@ -205,21 +220,35 @@ export function startDevServer(workspaceId) {
|
|
|
205
220
|
console.error(`[dev-server:${instanceName}] ${data.toString().trim()}`);
|
|
206
221
|
});
|
|
207
222
|
proc.on('exit', (code) => {
|
|
208
|
-
|
|
209
|
-
void
|
|
210
|
-
.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
223
|
+
// Shell exit does not imply that its background children have stopped.
|
|
224
|
+
void (async () => {
|
|
225
|
+
while (trackedProcesses.get(workspaceId) === proc && !stoppingProcesses.has(workspaceId)) {
|
|
226
|
+
if (proc.pid && (await hasLiveProcessGroup(proc.pid))) {
|
|
227
|
+
await pollDelay(250);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (trackedProcesses.get(workspaceId) !== proc || stoppingProcesses.has(workspaceId))
|
|
231
|
+
return;
|
|
232
|
+
trackedProcesses.delete(workspaceId);
|
|
233
|
+
const currentStatus = await getStatus(workspace.projectPath, workspace.workingBranch);
|
|
234
|
+
if (generations.get(workspaceId) !== generation || stoppingProcesses.has(workspaceId))
|
|
235
|
+
return;
|
|
236
|
+
updateDevServerStatus(workspaceId, currentStatus.status);
|
|
237
|
+
emitEphemeral(workspaceId, 'devserver:status', currentStatus);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
})().catch((err) => {
|
|
241
|
+
console.error(`[dev-server] Failed to confirm process-group exit for workspace ${workspaceId}:`, err);
|
|
216
242
|
});
|
|
217
|
-
if (code !== 0)
|
|
243
|
+
if (code !== 0)
|
|
218
244
|
console.error(`[dev-server] Process exited with code ${code} for workspace ${workspaceId}`);
|
|
219
|
-
}
|
|
220
245
|
});
|
|
221
246
|
proc.on('error', (err) => {
|
|
222
|
-
trackedProcesses.
|
|
247
|
+
if (trackedProcesses.get(workspaceId) !== proc)
|
|
248
|
+
return;
|
|
249
|
+
// A signal failure is also an 'error' event; it does not prove process exit.
|
|
250
|
+
if (!proc.pid)
|
|
251
|
+
trackedProcesses.delete(workspaceId);
|
|
223
252
|
updateDevServerStatus(workspaceId, 'error');
|
|
224
253
|
console.error(`[dev-server] Process error for workspace ${workspaceId}:`, err);
|
|
225
254
|
emitEphemeral(workspaceId, 'devserver:status', {
|
|
@@ -249,6 +278,96 @@ export function startDevServer(workspaceId) {
|
|
|
249
278
|
* Stop the dev-server for a workspace.
|
|
250
279
|
*/
|
|
251
280
|
export async function stopDevServer(workspaceId) {
|
|
281
|
+
const pending = stoppingProcesses.get(workspaceId);
|
|
282
|
+
if (pending)
|
|
283
|
+
return pending;
|
|
284
|
+
generations.set(workspaceId, (generations.get(workspaceId) ?? 0) + 1);
|
|
285
|
+
const stopping = stopDevServerOwned(workspaceId);
|
|
286
|
+
stoppingProcesses.set(workspaceId, stopping);
|
|
287
|
+
try {
|
|
288
|
+
return await stopping;
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
if (stoppingProcesses.get(workspaceId) === stopping)
|
|
292
|
+
stoppingProcesses.delete(workspaceId);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function pollDelay(ms) {
|
|
296
|
+
return new Promise((resolve) => {
|
|
297
|
+
const timer = setTimeout(resolve, ms);
|
|
298
|
+
timer.unref?.();
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/** A zombie cannot write files, even if the OS has not reaped its PID yet. */
|
|
302
|
+
async function hasLiveProcessGroup(groupId) {
|
|
303
|
+
try {
|
|
304
|
+
process.kill(-groupId, 0);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
if (err.code === 'ESRCH')
|
|
308
|
+
return false;
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
const processes = await runCommand('ps', ['-eo', 'pgid=,stat='], { timeout: 1000 });
|
|
312
|
+
return processes.split('\n').some((line) => {
|
|
313
|
+
const [group, state] = line.trim().split(/\s+/);
|
|
314
|
+
return Number(group) === groupId && !!state && !/^[ZX]/.test(state);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
async function signalGroupAndWait(groupId, signal, timeoutMs) {
|
|
318
|
+
try {
|
|
319
|
+
process.kill(-groupId, signal);
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
if (err.code === 'ESRCH')
|
|
323
|
+
return true;
|
|
324
|
+
throw err;
|
|
325
|
+
}
|
|
326
|
+
const deadline = Date.now() + timeoutMs;
|
|
327
|
+
while (await hasLiveProcessGroup(groupId)) {
|
|
328
|
+
if (Date.now() >= deadline)
|
|
329
|
+
return false;
|
|
330
|
+
await pollDelay(50);
|
|
331
|
+
}
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
function signalAndWait(proc, signal, timeoutMs) {
|
|
335
|
+
if (proc.pid)
|
|
336
|
+
return signalGroupAndWait(proc.pid, signal, timeoutMs);
|
|
337
|
+
if (proc.exitCode != null || proc.signalCode != null)
|
|
338
|
+
return Promise.resolve(true);
|
|
339
|
+
return new Promise((resolve, reject) => {
|
|
340
|
+
const finish = (exited) => {
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
proc.removeListener('exit', onExit);
|
|
343
|
+
proc.removeListener('error', onError);
|
|
344
|
+
resolve(exited);
|
|
345
|
+
};
|
|
346
|
+
const onExit = () => finish(true);
|
|
347
|
+
const onError = (err) => {
|
|
348
|
+
clearTimeout(timer);
|
|
349
|
+
proc.removeListener('exit', onExit);
|
|
350
|
+
proc.removeListener('error', onError);
|
|
351
|
+
reject(err);
|
|
352
|
+
};
|
|
353
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
354
|
+
proc.once('exit', onExit);
|
|
355
|
+
proc.once('error', onError);
|
|
356
|
+
try {
|
|
357
|
+
if (proc.pid)
|
|
358
|
+
process.kill(-proc.pid, signal);
|
|
359
|
+
else
|
|
360
|
+
proc.kill(signal);
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
clearTimeout(timer);
|
|
364
|
+
proc.removeListener('exit', onExit);
|
|
365
|
+
proc.removeListener('error', onError);
|
|
366
|
+
reject(err);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
async function stopDevServerOwned(workspaceId) {
|
|
252
371
|
const workspace = getWorkspace(workspaceId);
|
|
253
372
|
if (!workspace) {
|
|
254
373
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
@@ -260,20 +379,15 @@ export async function stopDevServer(workspaceId) {
|
|
|
260
379
|
// Kill tracked process first (covers Node servers and any spawned process)
|
|
261
380
|
const tracked = trackedProcesses.get(workspaceId);
|
|
262
381
|
if (tracked) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
tracked.kill('SIGTERM');
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
catch (err) {
|
|
272
|
-
console.error('[dev-server] Failed to kill tracked process:', err instanceof Error ? err.message : err);
|
|
273
|
-
}
|
|
274
|
-
trackedProcesses.delete(workspaceId);
|
|
382
|
+
const exited = (await signalAndWait(tracked, 'SIGTERM', 3_000)) || (await signalAndWait(tracked, 'SIGKILL', 1_000));
|
|
383
|
+
if (!exited)
|
|
384
|
+
throw new Error('Dev server stop is not confirmed; retry after the process exits');
|
|
385
|
+
if (trackedProcesses.get(workspaceId) === tracked)
|
|
386
|
+
trackedProcesses.delete(workspaceId);
|
|
275
387
|
}
|
|
276
388
|
const settings = getProjectSettings(workspace.projectPath);
|
|
389
|
+
let stopError;
|
|
390
|
+
let customStopSucceeded = false;
|
|
277
391
|
if (settings?.devServer.stopCommand) {
|
|
278
392
|
try {
|
|
279
393
|
await runCommand('bash', ['-c', settings.devServer.stopCommand], {
|
|
@@ -285,9 +399,10 @@ export async function stopDevServer(workspaceId) {
|
|
|
285
399
|
},
|
|
286
400
|
timeout: 30_000,
|
|
287
401
|
});
|
|
402
|
+
customStopSucceeded = true;
|
|
288
403
|
}
|
|
289
404
|
catch (err) {
|
|
290
|
-
|
|
405
|
+
stopError = err;
|
|
291
406
|
}
|
|
292
407
|
}
|
|
293
408
|
// Always try docker compose down with project name if we have one
|
|
@@ -295,11 +410,26 @@ export async function stopDevServer(workspaceId) {
|
|
|
295
410
|
if (config?.projectName) {
|
|
296
411
|
try {
|
|
297
412
|
await runCommand('docker', ['compose', '-p', config.projectName, 'down'], { cwd, timeout: 30_000 });
|
|
413
|
+
stopError = undefined;
|
|
298
414
|
}
|
|
299
|
-
catch {
|
|
300
|
-
|
|
415
|
+
catch (err) {
|
|
416
|
+
stopError = err;
|
|
417
|
+
// The custom command may use -f/--env-file absent from the generic fallback.
|
|
418
|
+
// Only an independent, successful Docker query can confirm it stopped everything.
|
|
419
|
+
if (customStopSucceeded) {
|
|
420
|
+
try {
|
|
421
|
+
const remaining = await runCommand('docker', ['ps', '--filter', `label=com.docker.compose.project=${config.projectName}`, '--format', '{{.ID}}'], { timeout: 10_000 });
|
|
422
|
+
if (!remaining.trim())
|
|
423
|
+
stopError = undefined;
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// An unavailable daemon is not confirmation of shutdown.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
301
429
|
}
|
|
302
430
|
}
|
|
431
|
+
if (stopError)
|
|
432
|
+
throw new Error(`Dev server stop failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
303
433
|
const status = {
|
|
304
434
|
status: 'stopped',
|
|
305
435
|
instanceName,
|
|
@@ -338,8 +468,7 @@ export async function getDevServerLogs(workspaceId, tail = 200) {
|
|
|
338
468
|
if (!config) {
|
|
339
469
|
return 'No dev-server instance found';
|
|
340
470
|
}
|
|
341
|
-
const
|
|
342
|
-
const matching = running.filter((name) => containerBelongsToProject(name, config.projectName));
|
|
471
|
+
const matching = await listRunningContainers(config.projectName);
|
|
343
472
|
if (matching.length === 0) {
|
|
344
473
|
return 'No running containers found';
|
|
345
474
|
}
|
|
@@ -1,33 +1,15 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { nanoid } from 'nanoid';
|
|
5
|
+
import { ensureDirectoryInside } from '../utils/safe-path.js';
|
|
4
6
|
const ALLOWED_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']);
|
|
5
7
|
const IMAGES_DIR = '.ai/images';
|
|
6
8
|
const INDEX_FILE = 'index.json';
|
|
7
9
|
// Per-worktree lock to serialize index.json writes
|
|
8
10
|
const locks = new Map();
|
|
9
11
|
function ensureRealImagesDirectory(worktreePath) {
|
|
10
|
-
|
|
11
|
-
let current = worktreePath;
|
|
12
|
-
for (const segment of ['.ai', 'images']) {
|
|
13
|
-
current = path.join(current, segment);
|
|
14
|
-
if (fs.existsSync(current)) {
|
|
15
|
-
const stat = fs.lstatSync(current);
|
|
16
|
-
if (stat.isSymbolicLink())
|
|
17
|
-
throw new Error(`Symbolic links are not allowed in image storage: ${current}`);
|
|
18
|
-
if (!stat.isDirectory())
|
|
19
|
-
throw new Error(`Image storage path is not a directory: ${current}`);
|
|
20
|
-
}
|
|
21
|
-
else {
|
|
22
|
-
fs.mkdirSync(current);
|
|
23
|
-
}
|
|
24
|
-
const realCurrent = fs.realpathSync(current);
|
|
25
|
-
const relative = path.relative(realWorktree, realCurrent);
|
|
26
|
-
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
27
|
-
throw new Error('Image storage path escapes the worktree');
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return fs.realpathSync(current);
|
|
12
|
+
return ensureDirectoryInside(worktreePath, '.ai/images');
|
|
31
13
|
}
|
|
32
14
|
function withLock(worktreePath, fn) {
|
|
33
15
|
const prev = locks.get(worktreePath) ?? Promise.resolve();
|
|
@@ -56,9 +38,17 @@ function writeIndex(imagesDir, entries) {
|
|
|
56
38
|
if (fs.existsSync(indexPath) && fs.lstatSync(indexPath).isSymbolicLink()) {
|
|
57
39
|
throw new Error('Symbolic links are not allowed for image index');
|
|
58
40
|
}
|
|
59
|
-
const tempPath = path.join(imagesDir, `.index-${
|
|
60
|
-
|
|
61
|
-
|
|
41
|
+
const tempPath = path.join(imagesDir, `.index-${randomUUID()}.tmp`);
|
|
42
|
+
let created = false;
|
|
43
|
+
try {
|
|
44
|
+
fs.writeFileSync(tempPath, JSON.stringify(entries, null, 2), { flag: 'wx', mode: 0o600 });
|
|
45
|
+
created = true;
|
|
46
|
+
fs.renameSync(tempPath, indexPath);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
if (created && fs.existsSync(tempPath))
|
|
50
|
+
fs.unlinkSync(tempPath);
|
|
51
|
+
}
|
|
62
52
|
}
|
|
63
53
|
/** Save an image buffer to `.ai/images/` and update the index. Returns the UID and relative path. */
|
|
64
54
|
export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
@@ -75,10 +65,17 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
|
75
65
|
await withLock(worktreePath, () => {
|
|
76
66
|
// Write the image file inside the lock so both the file write and index
|
|
77
67
|
// update happen atomically — avoids orphan files on crash between the two.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
68
|
+
const imagePath = path.join(imagesDir, filename);
|
|
69
|
+
fs.writeFileSync(imagePath, fileBuffer, { flag: 'wx', mode: 0o600 });
|
|
70
|
+
try {
|
|
71
|
+
const entries = readIndex(imagesDir);
|
|
72
|
+
entries.push({ uid, originalName, createdAt: new Date().toISOString() });
|
|
73
|
+
writeIndex(imagesDir, entries);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
fs.unlinkSync(imagePath);
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
82
79
|
});
|
|
83
80
|
return { uid, relativePath: `${IMAGES_DIR}/${filename}` };
|
|
84
81
|
}
|
|
@@ -5,7 +5,10 @@ import path from 'node:path';
|
|
|
5
5
|
import * as gitOps from '../utils/git-ops.js';
|
|
6
6
|
import { GitConflictError } from '../utils/git-ops.js';
|
|
7
7
|
import { withGitRepoLock } from '../utils/git-repo-lock.js';
|
|
8
|
+
import { fingerprintWorktree } from '../utils/git-worktree-fingerprint.js';
|
|
9
|
+
import { withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
|
|
8
10
|
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
11
|
+
import { hasController } from './agent/orchestrator.js';
|
|
9
12
|
import { createWorktree, listWorktrees, removeWorktreeUnlocked } from './worktree-service.js';
|
|
10
13
|
/**
|
|
11
14
|
* A `pathCollision.worktreePath` decision is untrusted input reaching
|
|
@@ -114,8 +117,10 @@ function diagnoseChanges(worktreePath) {
|
|
|
114
117
|
/** Classify the workspace, if any, that already tracks this branch.
|
|
115
118
|
* A purged worktree outranks the archived flag: purging always archives, and
|
|
116
119
|
* "recreate the worktree" is the useful offer, not "unarchive". */
|
|
117
|
-
export function resolveWorkspaceState(workspaces, branch) {
|
|
118
|
-
const match = workspaces.find((w) => w.workingBranch === branch
|
|
120
|
+
export function resolveWorkspaceState(workspaces, branch, projectPath) {
|
|
121
|
+
const match = workspaces.find((w) => w.workingBranch === branch &&
|
|
122
|
+
typeof w.projectPath === 'string' &&
|
|
123
|
+
path.resolve(w.projectPath) === path.resolve(projectPath));
|
|
119
124
|
if (!match)
|
|
120
125
|
return { state: 'none' };
|
|
121
126
|
if (match.worktreePurgedAt)
|
|
@@ -134,10 +139,13 @@ function safeRevParse(repoPath, ref) {
|
|
|
134
139
|
}
|
|
135
140
|
}
|
|
136
141
|
/** Hash the observed state so `/resolve` can refuse a plan built on stale facts. */
|
|
137
|
-
export function computeFingerprint(report) {
|
|
142
|
+
export async function computeFingerprint(report) {
|
|
138
143
|
const localHead = safeRevParse(report.projectPath, report.headBranch);
|
|
139
144
|
const remoteHead = safeRevParse(report.projectPath, `origin/${report.headBranch}`);
|
|
140
145
|
const payload = JSON.stringify({
|
|
146
|
+
contents: report.worktree.state === 'orphan' || report.worktree.state === 'attached'
|
|
147
|
+
? await fingerprintWorktree(report.worktree.path)
|
|
148
|
+
: null,
|
|
141
149
|
localHead,
|
|
142
150
|
remoteHead,
|
|
143
151
|
worktree: report.worktree,
|
|
@@ -174,7 +182,7 @@ export function diagnoseLocalState(projectPath, headBranch, worktreesPath, attac
|
|
|
174
182
|
headBranch,
|
|
175
183
|
targetWorktreePath,
|
|
176
184
|
blockers,
|
|
177
|
-
workspace: resolveWorkspaceState(workspaces, headBranch),
|
|
185
|
+
workspace: resolveWorkspaceState(workspaces, headBranch, projectPath),
|
|
178
186
|
worktree,
|
|
179
187
|
localChanges: inspectPath ? diagnoseChanges(inspectPath) : EMPTY_CHANGES,
|
|
180
188
|
ongoingOperation: inspectPath ? gitOps.getOngoingGitOperation(inspectPath) : null,
|
|
@@ -249,14 +257,35 @@ export class StaleDiagnosisError extends Error {
|
|
|
249
257
|
* or a source-branch change.
|
|
250
258
|
*/
|
|
251
259
|
export function resolvePrCheckout(input) {
|
|
260
|
+
const initial = diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths, input.workspaces);
|
|
261
|
+
const owner = initial.worktree.state === 'attached'
|
|
262
|
+
? initial.worktree.workspaceId
|
|
263
|
+
: initial.workspace.state !== 'none'
|
|
264
|
+
? initial.workspace.id
|
|
265
|
+
: null;
|
|
266
|
+
const resolve = () => resolvePrCheckoutLocked(input, owner);
|
|
267
|
+
return owner ? withWorkspaceLifecycleGuard(owner, resolve) : resolve();
|
|
268
|
+
}
|
|
269
|
+
function resolvePrCheckoutLocked(input, owner) {
|
|
252
270
|
return withGitRepoLock(input.projectPath, async () => {
|
|
271
|
+
if (owner && hasController(owner))
|
|
272
|
+
throw new Error('Stop the workspace agent before changing its checkout');
|
|
253
273
|
const applied = [];
|
|
254
274
|
// 1. Refresh, then refuse a plan built on facts that no longer hold.
|
|
255
275
|
gitOps.fetchSourceBranch(input.projectPath, input.headBranch);
|
|
256
276
|
applied.push({ kind: 'fetch', detail: `origin/${input.headBranch}` });
|
|
257
277
|
const fresh = diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths ?? new Map(), input.workspaces ?? []);
|
|
258
|
-
if (computeFingerprint(fresh) !== input.fingerprint)
|
|
278
|
+
if ((await computeFingerprint(fresh)) !== input.fingerprint)
|
|
259
279
|
throw new StaleDiagnosisError(fresh);
|
|
280
|
+
if ((fresh.worktree.state === 'orphan' || fresh.worktree.state === 'attached') &&
|
|
281
|
+
input.decisions.orphanWorktree === 'create-elsewhere') {
|
|
282
|
+
throw new Error('Branch is already checked out. Attach its existing worktree instead.');
|
|
283
|
+
}
|
|
284
|
+
if (fresh.localChanges.present &&
|
|
285
|
+
(input.decisions.localChanges ?? 'keep') === 'keep' &&
|
|
286
|
+
input.decisions.divergence === 'reset-hard') {
|
|
287
|
+
throw new Error('Cannot preserve local changes during a hard reset. Stash or commit them first, or keep the branch.');
|
|
288
|
+
}
|
|
260
289
|
// 2. Clear stale worktree metadata before anything needs the path.
|
|
261
290
|
if (fresh.worktree.state === 'stale-metadata') {
|
|
262
291
|
git(input.projectPath, ['worktree', 'prune']);
|