@loicngr/kobo 1.11.32 → 1.11.34
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 +2 -0
- package/CHANGELOG.md +8 -0
- package/dist/server/index.js +30 -15
- package/dist/server/routes/changelog.js +2 -23
- package/dist/server/routes/workspaces.js +37 -0
- package/dist/server/services/pr-watcher-service.js +92 -34
- package/dist/server/services/worktree-purge-service.js +25 -1
- package/dist/server/services/worktree-restore-service.js +79 -0
- package/dist/server/services/worktree-service.js +151 -3
- package/dist/server/utils/changelog.js +23 -0
- package/dist/server/utils/startup-display.js +71 -0
- package/dist/server/utils/workspace-lifecycle-guard.js +22 -0
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/{ActivityFeed-C7tG4Cgq.js → ActivityFeed-CGb-YHB0.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-FYve4efm.js → ChangelogPage-BDVpqtWz.js} +1 -1
- package/src/client/dist/pwa/assets/{ClosePopup-DLm_uRNv.js → ClosePopup-ClH5KG_w.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-DRzYHcWf.js → ComparisonPanel-D6kz5Q-o.js} +1 -1
- package/src/client/dist/pwa/assets/{CreatePage-CT10FMyq.js → CreatePage-f-C_R4o9.js} +1 -1
- package/src/client/dist/pwa/assets/{DashboardPage-CT3XzDaz.js → DashboardPage-C_9_IXdR.js} +1 -1
- package/src/client/dist/pwa/assets/{DiffViewer-CIY7UOt6.js → DiffViewer-CMjfVpAG.js} +3 -3
- package/src/client/dist/pwa/assets/{DocumentsPanel-D2IlwigV.js → DocumentsPanel-Yim06L6k.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-0fKZjElk.js → DrawerToggleButton-aD_LMlna.js} +1 -1
- package/src/client/dist/pwa/assets/{GitPanel-DjG7-AsO.js → GitPanel-x-whHRdM.js} +3 -3
- package/src/client/dist/pwa/assets/{HealthPage-BJKRSg5I.js → HealthPage-BNZP_1l-.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-BMxAagOe.js +2 -0
- package/src/client/dist/pwa/assets/{MainLayout-BUTz2ks4.css → MainLayout-DfddEd0b.css} +1 -1
- package/src/client/dist/pwa/assets/{QBtnDropdown-Bqa7KhX7.js → QBtnDropdown-BLNdi1w5.js} +1 -1
- package/src/client/dist/pwa/assets/{QDrawer-DinZbnyn.js → QDrawer-C6TAn5dv.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-BDmDDRMX.js → SchedulePanel-D2HyVnU8.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-BdisvUwr.js → SearchPage-BG_zXo6V.js} +1 -1
- package/src/client/dist/pwa/assets/{SettingsPage-DCJgxm6J.js → SettingsPage-BNBeaT9t.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-DBnSOJ3B.js → TerminalPanel-BzUFE4eS.js} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-D77wzGyc.js → TouchPan-CK2rmJkA.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-DU2PItj-.js → TourReplayButton-DHFo5_Kk.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-CRVNJtmB.js → WorkspaceAttentionLabels-BbtPN7pb.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-Aay3kkPT.js +4 -0
- package/src/client/dist/pwa/assets/{WorkspacePage-D3-AEaPN.css → WorkspacePage-ygEb8olx.css} +1 -1
- package/src/client/dist/pwa/assets/{browser-sSy6JmeQ.js → browser-pf20JpKz.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-B2-SsE87.js → build-path-tree-BRLZv-pU.js} +1 -1
- package/src/client/dist/pwa/assets/de-CG7RYRLI.js +17 -0
- package/src/client/dist/pwa/assets/{documents-D4BcPyOF.js → documents-C1u794ea.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-NH9tSUby.js → editor.api-BaRghNyI.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-BdCfujEx.js → efforts-EHbgZlde.js} +1 -1
- package/src/client/dist/pwa/assets/engineFeatures-DyKNpIKw.js +1 -0
- package/src/client/dist/pwa/assets/es-DfpfEjEu.js +17 -0
- package/src/client/dist/pwa/assets/{expand-template-3E9x3FGS.js → expand-template-D7OUa8G7.js} +1 -1
- package/src/client/dist/pwa/assets/{format-CR20Lbwg.js → format-D3dPFA-T.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-TuKWWdWq.js → formatters-DBdoGhB7.js} +1 -1
- package/src/client/dist/pwa/assets/fr-DgzZRmjM.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-F7dLhXbU.js → freemarker2-DI7u6MdV.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-hXFhj5eh.js → handlebars-BgkIQIEY.js} +1 -1
- package/src/client/dist/pwa/assets/{html-7nsVC4pO.js → html-BOPtZli3.js} +1 -1
- package/src/client/dist/pwa/assets/{i18n-Djnp5xDA.js → i18n-Butermk0.js} +1 -1
- package/src/client/dist/pwa/assets/index-BATViFoR.js +20 -0
- package/src/client/dist/pwa/assets/it-DKzqhjlT.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-59rTAWws.js → javascript-Cf6-AeMO.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-BP6Awnwy.js → liquid-Bd9X87K-.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-CiCpqnQ4.js → mdx-C_ObhhDT.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-DzI5nDgU.js → monaco-contributions-rHxRMwAP.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-9uLUIE5c.js → monaco.contribution-DkNJdReE.js} +2 -2
- package/src/client/dist/pwa/assets/{network-auth--GlWvRqv.js → network-auth-CyD51r9F.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-DQ_bPMUo.js → notify-theme-Bnw3Xbxg.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-CK20L_pt.js → pending-diff-open-DsW49A6Z.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-DORv0Hwo.js → permissionModes-mF8Kofv1.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-DrojOIC6.js → project-color-CoEJs17L.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BfVlMXCp.js → python-BIG7FEi1.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-CxyOPnW7.js → razor-CNJSV12m.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-nZGk_TRQ.js → render-chat-markdown-Ddq-hxhM.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-Bke62OsW.js → typescript-Blrg4f-0.js} +1 -1
- package/src/client/dist/pwa/assets/{use-tours-BEdbVcQS.js → use-tours-Cz5Zz63W.js} +2 -2
- package/src/client/dist/pwa/assets/{workspace-templates-1zUtbH31.js → workspace-templates-BS34DvEp.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-Cg47p-KM.js → xml-Bv6_W6i5.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-Dj1MTjr-.js → yaml-DH7PMAq0.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/MainLayout-BSHKEpzs.js +0 -2
- package/src/client/dist/pwa/assets/WorkspacePage-Dv3ED-Bt.js +0 -4
- package/src/client/dist/pwa/assets/de-D_OhjWJx.js +0 -17
- package/src/client/dist/pwa/assets/engineFeatures-6ikmzzfp.js +0 -1
- package/src/client/dist/pwa/assets/es-kxJ0xgkc.js +0 -17
- package/src/client/dist/pwa/assets/fr-BgQGh90C.js +0 -17
- package/src/client/dist/pwa/assets/index-DztjpQfb.js +0 -20
- package/src/client/dist/pwa/assets/it-BmzSIMv5.js +0 -17
package/AGENTS.md
CHANGED
|
@@ -263,6 +263,8 @@ Sequence: `captureRestoreData` (best-effort forge lookup for PR number / URL / m
|
|
|
263
263
|
|
|
264
264
|
**Auto-restore on manual recreation.** When the user manually recreates the worktree folder (`gh pr checkout <pr-number>` or `git worktree add <path> <branch>`), the pr-watcher detects the folder reappearing on its next 30 s tick via `autoRestoreManuallyRecreatedWorktrees()`: it iterates archived workspaces with `worktreePurgedAt`, checks `fs.existsSync(worktreePath)`, and on a hit calls `restoreWorktreeFromDisk(id)` which clears `worktree_purged_at` + `worktree_purge_restore_data` + `archived_at` in one transaction, then emits `workspace:worktree-restored`. The client websocket store reuses the same handler as `workspace:archived`/`unarchived` to refresh both the active and archived workspace lists. No UI action needed.
|
|
265
265
|
|
|
266
|
+
**Restore**: `POST /api/workspaces/:id/restore-worktree` calls `worktree-restore-service.ts` to recreate the exact checkout and only then clear purge/archive metadata via `restoreWorktreeFromDisk`. Source order: surviving local branch → optional `headCommitSha` in the existing restore JSON → exact branch fetched from `origin`. No SQL migration is needed for the optional JSON key; older records remain supported. No reset, overwrite, agent/dev-server start or setup script. `workspace-lifecycle-guard.ts` excludes overlapping restore/purge/delete operations, while the existing common-Git-directory lock serializes Git mutations. The watcher shares `isMatchingWorkspaceWorktree` validation and invalidates in-flight PR checks on restoration; delayed auto-purges carry their expected archive timestamp to avoid purging a workspace restored in the meantime. The context menu and purged banner expose the action; HTTP and the existing `workspace:worktree-restored` event reconcile client lists. Discarded uncommitted/ignored files and dependencies are not recoverable.
|
|
267
|
+
|
|
266
268
|
### Workspace attention indicators
|
|
267
269
|
|
|
268
270
|
`src/client/src/utils/workspace-attention.ts` derives a small set of badges (CI failure, changes-requested) from the PR snapshot + git stats stored on each workspace. `WorkspaceAttentionLabels.vue` renders them inline on the workspace cards in the left drawer. The derivation is a pure function, easy to unit-test and free of IO. Drawer cards therefore stay reactive to whatever the pr-watcher / bulk-info refresh writes back into the store.
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to Kōbō are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
|
|
5
5
|
section — the in-app "What's new" dialog reads this file.
|
|
6
6
|
|
|
7
|
+
## 1.11.34
|
|
8
|
+
|
|
9
|
+
- feat(workspaces): restore purged workspaces and improve startup display
|
|
10
|
+
|
|
11
|
+
## 1.11.33
|
|
12
|
+
|
|
13
|
+
- fix(onboarding): quitting a tour marks it seen for good
|
|
14
|
+
|
|
7
15
|
## 1.11.32
|
|
8
16
|
|
|
9
17
|
- chore(deps): npm update in the server and client trees
|
package/dist/server/index.js
CHANGED
|
@@ -46,8 +46,9 @@ import * as wakeupService from './services/wakeup-service.js';
|
|
|
46
46
|
import { emit, emitEphemeral, handleConnection, setMessageHandler } from './services/websocket-service.js';
|
|
47
47
|
import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
|
|
48
48
|
import { pruneWsEvents, resolveRetentionConfig } from './services/ws-events-retention-service.js';
|
|
49
|
-
import { getClientSpaPath, getDbPath, getKoboHome, getPackageVersion, resolveSpaFile } from './utils/paths.js';
|
|
50
|
-
|
|
49
|
+
import { getChangelogPath, getClientSpaPath, getDbPath, getKoboHome, getPackageVersion, resolveSpaFile, } from './utils/paths.js';
|
|
50
|
+
import { formatStartupBanner, readStartupNotes, startStartupSpinner, startupDebug } from './utils/startup-display.js';
|
|
51
|
+
startupDebug(`[kobo] Kōbō home: ${getKoboHome()}`);
|
|
51
52
|
// Initialize DB + run migrations
|
|
52
53
|
const db = getDb();
|
|
53
54
|
// Pre-migration backup: snapshot the DB before applying any pending schema
|
|
@@ -57,9 +58,9 @@ try {
|
|
|
57
58
|
const pending = getPendingMigrations(db);
|
|
58
59
|
if (pending.length > 0) {
|
|
59
60
|
const result = await createPreMigrationBackup(db, getDbPath(), `v${pending[pending.length - 1]}`);
|
|
60
|
-
|
|
61
|
+
startupDebug(`[kobo] Pre-migration backup before applying ${pending.length} migration(s): ${result.created}`);
|
|
61
62
|
if (result.deleted.length > 0) {
|
|
62
|
-
|
|
63
|
+
startupDebug(`[kobo] Rotated ${result.deleted.length} old pre-migration backup(s)`);
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
}
|
|
@@ -105,9 +106,9 @@ retentionTimer.unref?.();
|
|
|
105
106
|
void createDailyDbBackupIfNeeded(db, getDbPath())
|
|
106
107
|
.then((r) => {
|
|
107
108
|
if (r.created) {
|
|
108
|
-
|
|
109
|
+
startupDebug(`[kobo] Daily DB backup: ${r.created}`);
|
|
109
110
|
if (r.deleted.length > 0) {
|
|
110
|
-
|
|
111
|
+
startupDebug(`[kobo] Rotated ${r.deleted.length} old DB backup(s)`);
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
})
|
|
@@ -223,21 +224,25 @@ if (Object.keys(networkAccessEnvOverrides).length > 0) {
|
|
|
223
224
|
updateNetworkAccessSettings(patch);
|
|
224
225
|
}
|
|
225
226
|
const bindHost = resolveBindHost(getGlobalSettings().networkAccessEnabled);
|
|
227
|
+
const stopStartupSpinner = startStartupSpinner();
|
|
226
228
|
const server = serve({
|
|
227
229
|
fetch: app.fetch,
|
|
228
230
|
port: PORT,
|
|
229
231
|
hostname: bindHost,
|
|
230
232
|
}, (info) => {
|
|
231
233
|
setBackendPort(info.port);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
234
|
+
stopStartupSpinner();
|
|
235
|
+
const settings = getGlobalSettings();
|
|
236
|
+
console.log(formatStartupBanner({
|
|
237
|
+
version: getPackageVersion(),
|
|
238
|
+
port: info.port,
|
|
239
|
+
devClientOrigin: resolveDevClientOrigin(),
|
|
240
|
+
networkEnabled: settings.networkAccessEnabled,
|
|
241
|
+
lanUrls: settings.networkAccessEnabled ? getLanUrls(info.port) : [],
|
|
242
|
+
token: settings.networkAccessToken,
|
|
243
|
+
changelog: readStartupNotes(getChangelogPath()),
|
|
244
|
+
color: Boolean(process.stdout.isTTY && process.env.TERM !== 'dumb' && !('NO_COLOR' in process.env)),
|
|
245
|
+
}));
|
|
241
246
|
// Content migration runs AFTER the HTTP listener is up so the frontend
|
|
242
247
|
// can observe progress via WS broadcasts + GET /api/migration/status.
|
|
243
248
|
// Not awaited — the callback returns quickly, the migration runs in the
|
|
@@ -246,6 +251,16 @@ const server = serve({
|
|
|
246
251
|
console.error('[boot] content migration failed:', err);
|
|
247
252
|
});
|
|
248
253
|
});
|
|
254
|
+
server.on('error', (err) => {
|
|
255
|
+
stopStartupSpinner();
|
|
256
|
+
if (err.code === 'EADDRINUSE') {
|
|
257
|
+
console.error(`[kobo] Port ${PORT} is already in use. Stop the other server or choose another SERVER_PORT.`);
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
console.error('[kobo] Unable to start the server:', err);
|
|
261
|
+
}
|
|
262
|
+
void gracefulShutdown('server error', 1);
|
|
263
|
+
});
|
|
249
264
|
// Create WebSocketServer attached to the HTTP server
|
|
250
265
|
const wss = new WebSocketServer({ noServer: true });
|
|
251
266
|
const terminalWss = new WebSocketServer({ noServer: true });
|
|
@@ -1,31 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
|
+
import { parseChangelog } from '../utils/changelog.js';
|
|
3
4
|
import { getChangelogPath, getPackageVersion } from '../utils/paths.js';
|
|
5
|
+
export { parseChangelog } from '../utils/changelog.js';
|
|
4
6
|
/** Hono sub-router for the in-app "What's new" dialog. */
|
|
5
7
|
const app = new Hono();
|
|
6
|
-
/**
|
|
7
|
-
* Parse a Keep-a-Changelog markdown file into ordered version sections. Each
|
|
8
|
-
* `## <version>` heading starts a new entry; everything until the next heading
|
|
9
|
-
* is its notes. A leading `v` on the version is stripped.
|
|
10
|
-
*/
|
|
11
|
-
export function parseChangelog(markdown) {
|
|
12
|
-
const entries = [];
|
|
13
|
-
let current = null;
|
|
14
|
-
for (const line of markdown.split('\n')) {
|
|
15
|
-
const heading = line.match(/^##\s+v?(\d+\.\d+\.\d+[\w./-]*)\s*$/);
|
|
16
|
-
if (heading) {
|
|
17
|
-
if (current)
|
|
18
|
-
entries.push({ version: current.version, notes: current.lines.join('\n').trim() });
|
|
19
|
-
current = { version: heading[1], lines: [] };
|
|
20
|
-
}
|
|
21
|
-
else if (current) {
|
|
22
|
-
current.lines.push(line);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
if (current)
|
|
26
|
-
entries.push({ version: current.version, notes: current.lines.join('\n').trim() });
|
|
27
|
-
return entries;
|
|
28
|
-
}
|
|
29
8
|
/**
|
|
30
9
|
* Latest version published to npm, looked up at most once a day.
|
|
31
10
|
*
|
|
@@ -40,12 +40,14 @@ import * as permissionPolicyService from '../services/workspace-permission-polic
|
|
|
40
40
|
import * as workspaceService from '../services/workspace-service.js';
|
|
41
41
|
import { presetFromWorkspace } from '../services/workspace-template-service.js';
|
|
42
42
|
import * as purgeWorktreeService from '../services/worktree-purge-service.js';
|
|
43
|
+
import { restorePurgedWorktree, WorktreeRestoreError } from '../services/worktree-restore-service.js';
|
|
43
44
|
import * as worktreeService from '../services/worktree-service.js';
|
|
44
45
|
import { resolveUniqueBranchAndPath } from '../utils/branch-resolver.js';
|
|
45
46
|
import * as gitOps from '../utils/git-ops.js';
|
|
46
47
|
import { logError } from '../utils/logger.js';
|
|
47
48
|
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
48
49
|
import * as safePath from '../utils/safe-path.js';
|
|
50
|
+
import { WorkspaceLifecycleBusyError, withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
|
|
49
51
|
import { resolveExtractedName } from '../utils/workspace-name.js';
|
|
50
52
|
import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
51
53
|
/** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
|
|
@@ -2904,10 +2906,31 @@ app.post('/:id/purge-worktree', migrationGuard, async (c) => {
|
|
|
2904
2906
|
return c.json({ workspace, warnings: result.warnings, outcome: result.outcome });
|
|
2905
2907
|
}
|
|
2906
2908
|
catch (err) {
|
|
2909
|
+
if (err instanceof WorkspaceLifecycleBusyError)
|
|
2910
|
+
return c.json({ code: err.code, error: err.message }, 409);
|
|
2907
2911
|
const message = err instanceof Error ? err.message : String(err);
|
|
2908
2912
|
return c.json({ error: message }, 500);
|
|
2909
2913
|
}
|
|
2910
2914
|
});
|
|
2915
|
+
// Recreate the checkout before unarchiving; never starts agents or setup scripts.
|
|
2916
|
+
app.post('/:id/restore-worktree', migrationGuard, async (c) => {
|
|
2917
|
+
try {
|
|
2918
|
+
return c.json(await restorePurgedWorktree(c.req.param('id')));
|
|
2919
|
+
}
|
|
2920
|
+
catch (err) {
|
|
2921
|
+
if (err instanceof WorktreeRestoreError) {
|
|
2922
|
+
const status = err.code === 'not-found'
|
|
2923
|
+
? 404
|
|
2924
|
+
: ['not-purged', 'workspace-busy', 'path-conflict', 'branch-in-use'].includes(err.code)
|
|
2925
|
+
? 409
|
|
2926
|
+
: ['worktree-not-owned', 'project-unavailable', 'recovery-source-unavailable'].includes(err.code)
|
|
2927
|
+
? 422
|
|
2928
|
+
: 500;
|
|
2929
|
+
return c.json({ code: err.code, error: err.message }, status);
|
|
2930
|
+
}
|
|
2931
|
+
return c.json({ code: 'git-failed', error: 'Unable to restore the worktree.' }, 500);
|
|
2932
|
+
}
|
|
2933
|
+
});
|
|
2911
2934
|
// POST /api/workspaces/:id/unarchive — restore an archived workspace
|
|
2912
2935
|
app.post('/:id/unarchive', migrationGuard, (c) => {
|
|
2913
2936
|
try {
|
|
@@ -2940,6 +2963,18 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
|
|
|
2940
2963
|
// rather than thrown, so a bulk delete never aborts mid-batch. Returns the
|
|
2941
2964
|
// list of user-facing warning messages (empty when everything was clean).
|
|
2942
2965
|
async function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
2966
|
+
return withWorkspaceLifecycleGuard(workspace.id, async () => {
|
|
2967
|
+
const current = workspaceService.getWorkspace(workspace.id);
|
|
2968
|
+
if (!current)
|
|
2969
|
+
throw new Error(`Workspace '${workspace.id}' not found`);
|
|
2970
|
+
// A bulk deletion may have captured the list before restoration completed.
|
|
2971
|
+
if (workspace.archivedAt && !current.archivedAt) {
|
|
2972
|
+
throw new Error('Workspace was unarchived while deletion was pending. Retry from its current state.');
|
|
2973
|
+
}
|
|
2974
|
+
return deleteWorkspaceWithSideEffectsUnlocked(current, opts);
|
|
2975
|
+
});
|
|
2976
|
+
}
|
|
2977
|
+
async function deleteWorkspaceWithSideEffectsUnlocked(workspace, opts) {
|
|
2943
2978
|
// Stop the agent and WAIT for it to die. `git worktree remove --force` runs
|
|
2944
2979
|
// a few lines below: firing and forgetting here pulled the directory from
|
|
2945
2980
|
// under an agent still writing to it, destroying uncommitted work.
|
|
@@ -3079,6 +3114,8 @@ app.delete('/:id', migrationGuard, async (c) => {
|
|
|
3079
3114
|
return c.json({ ok: true, warnings }, 200);
|
|
3080
3115
|
}
|
|
3081
3116
|
catch (err) {
|
|
3117
|
+
if (err instanceof WorkspaceLifecycleBusyError)
|
|
3118
|
+
return c.json({ code: err.code, error: err.message }, 409);
|
|
3082
3119
|
const message = err instanceof Error ? err.message : String(err);
|
|
3083
3120
|
return c.json({ error: message }, 500);
|
|
3084
3121
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import { fetchSourceBranchAsync
|
|
2
|
+
import { fetchSourceBranchAsync } from '../utils/git-ops.js';
|
|
3
3
|
import { withGitRepoLock } from '../utils/git-repo-lock.js';
|
|
4
|
+
import { isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
|
|
4
5
|
import { hasController } from './agent/orchestrator.js';
|
|
5
6
|
import { stopDevServer } from './dev-server-service.js';
|
|
6
7
|
import { getForgeProvider } from './forge/registry.js';
|
|
@@ -12,6 +13,7 @@ import { destroyTerminal } from './terminal-service.js';
|
|
|
12
13
|
import { emitEphemeral } from './websocket-service.js';
|
|
13
14
|
import { archiveWorkspace, getWorkspace, listArchivedWorkspaces, listWorkspaces, markWorkspaceUnread, restoreWorktreeFromDisk, updateWorkspaceSourceBranch, } from './workspace-service.js';
|
|
14
15
|
import { purgeWorktree } from './worktree-purge-service.js';
|
|
16
|
+
import { isMatchingWorkspaceWorktree } from './worktree-service.js';
|
|
15
17
|
// ── PR Watcher ────────────────────────────────────────────────────────────────
|
|
16
18
|
// Polls GitHub every POLL_INTERVAL_MS to detect merged/closed PRs and
|
|
17
19
|
// automatically archive the corresponding workspace.
|
|
@@ -41,6 +43,14 @@ async function runBounded(items, concurrency, worker) {
|
|
|
41
43
|
const lastKnownPr = new Map();
|
|
42
44
|
/** Latest git-stats snapshot per workspace, refreshed each watcher tick. */
|
|
43
45
|
const lastKnownGitStats = new Map();
|
|
46
|
+
const activeChecks = new Map();
|
|
47
|
+
/** Also cancels stale results from checks that started before restoration. */
|
|
48
|
+
export function invalidateWorkspacePrCaches(workspaceId) {
|
|
49
|
+
lastKnownPr.delete(workspaceId);
|
|
50
|
+
lastKnownGitStats.delete(workspaceId);
|
|
51
|
+
for (const check of activeChecks.get(workspaceId) ?? [])
|
|
52
|
+
check.invalidated = true;
|
|
53
|
+
}
|
|
44
54
|
/**
|
|
45
55
|
* Read-only snapshot map, keyed by workspace id. Used by the drawer indicator
|
|
46
56
|
* AND the Git panel. Workspaces without a known PR are absent.
|
|
@@ -91,19 +101,15 @@ function markUnread(workspaceId) {
|
|
|
91
101
|
}
|
|
92
102
|
function autoRestoreManuallyRecreatedWorktrees() {
|
|
93
103
|
for (const ws of listArchivedWorkspaces()) {
|
|
94
|
-
if (!ws.worktreePurgedAt)
|
|
104
|
+
if (!ws.worktreePurgedAt || !ws.worktreeOwned || isWorkspaceLifecycleBusy(ws.id))
|
|
95
105
|
continue;
|
|
96
106
|
if (!fs.existsSync(ws.worktreePath))
|
|
97
107
|
continue;
|
|
98
|
-
|
|
99
|
-
// should trigger restore. A purge that failed to fully remove the folder
|
|
100
|
-
// (e.g. root-owned Docker files) leaves a residual non-git directory that
|
|
101
|
-
// satisfies existsSync but is NOT a valid worktree — restoring it would
|
|
102
|
-
// wrongly un-archive the workspace and flood git fetches with "(null)".
|
|
103
|
-
if (!isGitWorktree(ws.worktreePath))
|
|
108
|
+
if (!isMatchingWorkspaceWorktree(ws))
|
|
104
109
|
continue;
|
|
105
110
|
try {
|
|
106
111
|
const restored = restoreWorktreeFromDisk(ws.id);
|
|
112
|
+
invalidateWorkspacePrCaches(ws.id);
|
|
107
113
|
emitEphemeral(ws.id, 'workspace:worktree-restored', { workspace: restored });
|
|
108
114
|
console.log(`[pr-watcher] auto-restored worktree for workspace '${ws.name}' (manual restore detected)`);
|
|
109
115
|
}
|
|
@@ -129,8 +135,20 @@ export async function checkPrStatuses() {
|
|
|
129
135
|
await runBounded(workspaces, WORKSPACE_CHECK_CONCURRENCY, async (ws) => {
|
|
130
136
|
// Without this guard, every git/forge spawn below fails with ENOENT and
|
|
131
137
|
// floods the logs when a worktree was deleted externally.
|
|
132
|
-
if (!fs.existsSync(ws.worktreePath))
|
|
138
|
+
if (isWorkspaceLifecycleBusy(ws.id) || !fs.existsSync(ws.worktreePath))
|
|
133
139
|
return;
|
|
140
|
+
const check = { invalidated: false };
|
|
141
|
+
const checks = activeChecks.get(ws.id) ?? new Set();
|
|
142
|
+
checks.add(check);
|
|
143
|
+
activeChecks.set(ws.id, checks);
|
|
144
|
+
const stale = () => {
|
|
145
|
+
const current = getWorkspace(ws.id);
|
|
146
|
+
return (check.invalidated ||
|
|
147
|
+
isWorkspaceLifecycleBusy(ws.id) ||
|
|
148
|
+
!current ||
|
|
149
|
+
current.archivedAt !== ws.archivedAt ||
|
|
150
|
+
current.worktreePurgedAt !== ws.worktreePurgedAt);
|
|
151
|
+
};
|
|
134
152
|
try {
|
|
135
153
|
// Opt-out: skip the forge call entirely for a workspace with PR-watch
|
|
136
154
|
// disabled — no PR-status fetch, no auto-archive/purge, no PR-derived
|
|
@@ -140,6 +158,8 @@ export async function checkPrStatuses() {
|
|
|
140
158
|
const pr = ws.prWatchDisabledAt
|
|
141
159
|
? null
|
|
142
160
|
: await getForgeProvider(resolveForge(ws.projectPath)).getPrStatus(ws.worktreePath, ws.workingBranch);
|
|
161
|
+
if (stale())
|
|
162
|
+
return;
|
|
143
163
|
// Detect a PR base change BEFORE computing git stats so the new base
|
|
144
164
|
// is used in commitCount / behindCount / diffStats. Otherwise the
|
|
145
165
|
// user keeps seeing stale ahead/behind counts vs the OLD base until
|
|
@@ -174,12 +194,17 @@ export async function checkPrStatuses() {
|
|
|
174
194
|
// concurrently across four worktrees made them fight over the same file
|
|
175
195
|
// lock, with the error swallowed.
|
|
176
196
|
await withGitRepoLock(ws.worktreePath, () => fetchSourceBranchAsync(ws.worktreePath, ws.sourceBranch));
|
|
177
|
-
|
|
197
|
+
if (stale())
|
|
198
|
+
return;
|
|
199
|
+
const stats = await computeGitStats(ws, pr);
|
|
200
|
+
if (stale())
|
|
201
|
+
return;
|
|
202
|
+
lastKnownGitStats.set(ws.id, stats);
|
|
178
203
|
}
|
|
179
204
|
catch (err) {
|
|
180
205
|
console.error(`[pr-watcher] computeGitStats failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
181
206
|
}
|
|
182
|
-
if (!pr)
|
|
207
|
+
if (stale() || !pr)
|
|
183
208
|
return;
|
|
184
209
|
const prev = lastKnownPr.get(ws.id);
|
|
185
210
|
// We delay updating `lastKnownPr` until after the actions succeed.
|
|
@@ -224,13 +249,18 @@ export async function checkPrStatuses() {
|
|
|
224
249
|
catch (err) {
|
|
225
250
|
console.error(`[pr-watcher] stopDevServer failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
226
251
|
}
|
|
252
|
+
if (stale())
|
|
253
|
+
return;
|
|
254
|
+
const current = getWorkspace(ws.id);
|
|
255
|
+
if (!current || ['extracting', 'brainstorming', 'executing'].includes(current.status) || hasController(ws.id))
|
|
256
|
+
return;
|
|
227
257
|
try {
|
|
228
258
|
destroyTerminal(ws.id);
|
|
229
259
|
}
|
|
230
260
|
catch {
|
|
231
261
|
// Terminal may not exist — ignore
|
|
232
262
|
}
|
|
233
|
-
archiveWorkspace(ws.id);
|
|
263
|
+
const archived = archiveWorkspace(ws.id);
|
|
234
264
|
lastKnownPr.delete(ws.id);
|
|
235
265
|
emitEphemeral(ws.id, 'workspace:archived', {
|
|
236
266
|
reason: `PR ${pr.state.toLowerCase()}`,
|
|
@@ -245,7 +275,10 @@ export async function checkPrStatuses() {
|
|
|
245
275
|
// `onPrMerged` never rejects and is capped by the script-runner's
|
|
246
276
|
// own timeout, so this cannot hang the watcher indefinitely.
|
|
247
277
|
await prMergedHook;
|
|
248
|
-
|
|
278
|
+
const current = getWorkspace(ws.id);
|
|
279
|
+
if (check.invalidated || !archived.archivedAt || current?.archivedAt !== archived.archivedAt)
|
|
280
|
+
return;
|
|
281
|
+
void purgeWorktree(ws.id, archived.archivedAt)
|
|
249
282
|
.then((result) => {
|
|
250
283
|
// Auto-purge runs with nobody watching: without this trace an
|
|
251
284
|
// outcome of 'removal-failed' left no record anywhere.
|
|
@@ -317,6 +350,11 @@ export async function checkPrStatuses() {
|
|
|
317
350
|
catch (err) {
|
|
318
351
|
console.error(`[pr-watcher] Failed to check PR for workspace '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
319
352
|
}
|
|
353
|
+
finally {
|
|
354
|
+
checks.delete(check);
|
|
355
|
+
if (!checks.size)
|
|
356
|
+
activeChecks.delete(ws.id);
|
|
357
|
+
}
|
|
320
358
|
});
|
|
321
359
|
}
|
|
322
360
|
/**
|
|
@@ -332,31 +370,51 @@ export async function refreshPrSnapshot(workspaceId) {
|
|
|
332
370
|
const ws = getWorkspace(workspaceId);
|
|
333
371
|
if (!ws)
|
|
334
372
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
335
|
-
|
|
336
|
-
if (snap === null) {
|
|
337
|
-
lastKnownPr.delete(workspaceId);
|
|
373
|
+
if (isWorkspaceLifecycleBusy(workspaceId))
|
|
338
374
|
return null;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
375
|
+
const check = { invalidated: false };
|
|
376
|
+
const checks = activeChecks.get(workspaceId) ?? new Set();
|
|
377
|
+
checks.add(check);
|
|
378
|
+
activeChecks.set(workspaceId, checks);
|
|
379
|
+
try {
|
|
380
|
+
const snap = await getForgeProvider(resolveForge(ws.projectPath)).getPrStatus(ws.worktreePath, ws.workingBranch);
|
|
381
|
+
const current = getWorkspace(workspaceId);
|
|
382
|
+
if (check.invalidated ||
|
|
383
|
+
isWorkspaceLifecycleBusy(workspaceId) ||
|
|
384
|
+
!current ||
|
|
385
|
+
current.archivedAt !== ws.archivedAt ||
|
|
386
|
+
current.worktreePurgedAt !== ws.worktreePurgedAt)
|
|
387
|
+
return null;
|
|
388
|
+
if (snap === null) {
|
|
389
|
+
lastKnownPr.delete(workspaceId);
|
|
390
|
+
return null;
|
|
353
391
|
}
|
|
354
|
-
|
|
355
|
-
|
|
392
|
+
// Mirror the watcher's base-change detection so a manual refresh fixes a
|
|
393
|
+
// stale `sourceBranch` (typical scenario: user ran `gh pr edit --base …`
|
|
394
|
+
// and clicks the GitPanel refresh button instead of waiting for the next
|
|
395
|
+
// 30s tick). Best-effort: a DB write failure here leaves the snapshot
|
|
396
|
+
// cached but the metadata stale — the watcher will retry on its own.
|
|
397
|
+
if (snap.state === 'OPEN' && snap.base && snap.base !== ws.sourceBranch) {
|
|
398
|
+
try {
|
|
399
|
+
updateWorkspaceSourceBranch(workspaceId, snap.base);
|
|
400
|
+
emitEphemeral(workspaceId, 'pr:base-changed', {
|
|
401
|
+
oldBase: ws.sourceBranch,
|
|
402
|
+
newBase: snap.base,
|
|
403
|
+
prUrl: snap.url,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
console.error(`[pr-watcher] updateWorkspaceSourceBranch (refresh) failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
408
|
+
}
|
|
356
409
|
}
|
|
410
|
+
lastKnownPr.set(workspaceId, snap);
|
|
411
|
+
return snap;
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
checks.delete(check);
|
|
415
|
+
if (!checks.size)
|
|
416
|
+
activeChecks.delete(workspaceId);
|
|
357
417
|
}
|
|
358
|
-
lastKnownPr.set(workspaceId, snap);
|
|
359
|
-
return snap;
|
|
360
418
|
}
|
|
361
419
|
/**
|
|
362
420
|
* Runs a single check while honouring the `checking` re-entrancy guard. Used
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
1
2
|
import fs from 'node:fs';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
2
4
|
import { logError, logWarn } from '../utils/logger.js';
|
|
5
|
+
import { withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
|
|
3
6
|
import * as agentManager from './agent/orchestrator.js';
|
|
4
7
|
import * as devServerService from './dev-server-service.js';
|
|
5
8
|
import { getForgeProvider } from './forge/registry.js';
|
|
@@ -8,10 +11,17 @@ import { destroyTerminal } from './terminal-service.js';
|
|
|
8
11
|
import { emitEphemeral } from './websocket-service.js';
|
|
9
12
|
import { archiveWorkspace, getWorkspace, markWorktreePurged, } from './workspace-service.js';
|
|
10
13
|
import { isPermissionError, removeWorktree } from './worktree-service.js';
|
|
11
|
-
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
export async function purgeWorktree(workspaceId, expectedArchivedAt) {
|
|
16
|
+
return withWorkspaceLifecycleGuard(workspaceId, () => purgeWorktreeGuarded(workspaceId, expectedArchivedAt));
|
|
17
|
+
}
|
|
18
|
+
async function purgeWorktreeGuarded(workspaceId, expectedArchivedAt) {
|
|
12
19
|
const workspace = getWorkspace(workspaceId);
|
|
13
20
|
if (!workspace)
|
|
14
21
|
return { outcome: 'not-found', warnings: [] };
|
|
22
|
+
if (expectedArchivedAt !== undefined && workspace.archivedAt !== expectedArchivedAt) {
|
|
23
|
+
return { outcome: 'cancelled', warnings: [] };
|
|
24
|
+
}
|
|
15
25
|
if (workspace.worktreePurgedAt)
|
|
16
26
|
return { outcome: 'already-purged', warnings: [] };
|
|
17
27
|
if (!workspace.worktreeOwned)
|
|
@@ -98,6 +108,19 @@ function buildRemovalFailureMessage(worktreePath, projectPath, errMsg) {
|
|
|
98
108
|
}
|
|
99
109
|
async function captureRestoreData(workspace) {
|
|
100
110
|
const forge = resolveForge(workspace.projectPath);
|
|
111
|
+
let headCommitSha = null;
|
|
112
|
+
try {
|
|
113
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--verify', 'HEAD'], {
|
|
114
|
+
cwd: workspace.worktreePath,
|
|
115
|
+
encoding: 'utf8',
|
|
116
|
+
timeout: 30_000,
|
|
117
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
118
|
+
});
|
|
119
|
+
headCommitSha = stdout.trim() || null;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// A missing or broken checkout must not prevent a best-effort purge.
|
|
123
|
+
}
|
|
101
124
|
let prNumber = null;
|
|
102
125
|
let prUrl = null;
|
|
103
126
|
// Direct provider call (not the pr-watcher cache) avoids an import cycle
|
|
@@ -123,6 +146,7 @@ async function captureRestoreData(workspace) {
|
|
|
123
146
|
prUrl,
|
|
124
147
|
forge: forge,
|
|
125
148
|
mergeCommitSha: null,
|
|
149
|
+
headCommitSha,
|
|
126
150
|
originalWorktreePath: workspace.worktreePath,
|
|
127
151
|
originalSourceBranch: workspace.sourceBranch,
|
|
128
152
|
originalWorkingBranch: workspace.workingBranch,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { withGitRepoLock } from '../utils/git-repo-lock.js';
|
|
2
|
+
import { withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
|
|
3
|
+
import { invalidateWorkspacePrCaches } from './pr-watcher-service.js';
|
|
4
|
+
import { emitEphemeral } from './websocket-service.js';
|
|
5
|
+
import { getWorkspace, restoreWorktreeFromDisk } from './workspace-service.js';
|
|
6
|
+
import { isMatchingWorkspaceWorktree, restoreWorktreeCheckoutUnlocked, } from './worktree-service.js';
|
|
7
|
+
export class WorktreeRestoreError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function savedHead(json) {
|
|
15
|
+
try {
|
|
16
|
+
const data = JSON.parse(json ?? 'null');
|
|
17
|
+
if (data && typeof data === 'object' && 'headCommitSha' in data && typeof data.headCommitSha === 'string') {
|
|
18
|
+
return /^[a-f0-9]{40,64}$/i.test(data.headCommitSha) ? data.headCommitSha : null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Older/hand-edited metadata does not prevent restoring a surviving branch.
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
export async function restorePurgedWorktree(id) {
|
|
27
|
+
try {
|
|
28
|
+
return await withWorkspaceLifecycleGuard(id, async () => {
|
|
29
|
+
const initial = getWorkspace(id);
|
|
30
|
+
if (!initial)
|
|
31
|
+
throw new WorktreeRestoreError('not-found', 'Workspace not found.');
|
|
32
|
+
return withGitRepoLock(initial.projectPath, async () => {
|
|
33
|
+
const workspace = getWorkspace(id);
|
|
34
|
+
if (!workspace)
|
|
35
|
+
throw new WorktreeRestoreError('not-found', 'Workspace not found.');
|
|
36
|
+
if (!workspace.worktreeOwned) {
|
|
37
|
+
throw new WorktreeRestoreError('worktree-not-owned', 'This workspace uses an externally managed worktree.');
|
|
38
|
+
}
|
|
39
|
+
const input = {
|
|
40
|
+
projectPath: workspace.projectPath,
|
|
41
|
+
worktreePath: workspace.worktreePath,
|
|
42
|
+
workingBranch: workspace.workingBranch,
|
|
43
|
+
headCommitSha: savedHead(workspace.worktreePurgeRestoreData),
|
|
44
|
+
};
|
|
45
|
+
if (!workspace.worktreePurgedAt) {
|
|
46
|
+
if (!isMatchingWorkspaceWorktree(input)) {
|
|
47
|
+
throw new WorktreeRestoreError('not-purged', 'Workspace is not marked as purged and its checkout is missing or different.');
|
|
48
|
+
}
|
|
49
|
+
return { workspace, outcome: 'already-restored', source: 'existing-worktree' };
|
|
50
|
+
}
|
|
51
|
+
const checkout = await restoreWorktreeCheckoutUnlocked(input);
|
|
52
|
+
if (!isMatchingWorkspaceWorktree(input)) {
|
|
53
|
+
throw new WorktreeRestoreError('path-conflict', 'The recreated checkout does not match this workspace.');
|
|
54
|
+
}
|
|
55
|
+
// Preserve a valid checkout on DB failure. A retry or manual detection can
|
|
56
|
+
// safely complete the metadata update without deleting any Git history.
|
|
57
|
+
const restored = restoreWorktreeFromDisk(id);
|
|
58
|
+
invalidateWorkspacePrCaches(id);
|
|
59
|
+
emitEphemeral(id, 'workspace:worktree-restored', { workspace: restored });
|
|
60
|
+
return { workspace: restored, outcome: 'restored', source: checkout.source };
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (err instanceof WorktreeRestoreError)
|
|
66
|
+
throw err;
|
|
67
|
+
const code = err && typeof err === 'object' && 'code' in err ? err.code : null;
|
|
68
|
+
const known = [
|
|
69
|
+
'workspace-busy',
|
|
70
|
+
'project-unavailable',
|
|
71
|
+
'path-conflict',
|
|
72
|
+
'branch-in-use',
|
|
73
|
+
'recovery-source-unavailable',
|
|
74
|
+
];
|
|
75
|
+
const matched = known.find((candidate) => candidate === code);
|
|
76
|
+
// Don't expose Git stderr: remote URLs can contain credentials.
|
|
77
|
+
throw new WorktreeRestoreError(matched ?? 'git-failed', 'Unable to restore the worktree. Check the project and Git recovery source, then retry.');
|
|
78
|
+
}
|
|
79
|
+
}
|