@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.
Files changed (83) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +8 -0
  3. package/dist/server/index.js +30 -15
  4. package/dist/server/routes/changelog.js +2 -23
  5. package/dist/server/routes/workspaces.js +37 -0
  6. package/dist/server/services/pr-watcher-service.js +92 -34
  7. package/dist/server/services/worktree-purge-service.js +25 -1
  8. package/dist/server/services/worktree-restore-service.js +79 -0
  9. package/dist/server/services/worktree-service.js +151 -3
  10. package/dist/server/utils/changelog.js +23 -0
  11. package/dist/server/utils/startup-display.js +71 -0
  12. package/dist/server/utils/workspace-lifecycle-guard.js +22 -0
  13. package/package.json +1 -1
  14. package/src/client/dist/pwa/assets/{ActivityFeed-C7tG4Cgq.js → ActivityFeed-CGb-YHB0.js} +1 -1
  15. package/src/client/dist/pwa/assets/{ChangelogPage-FYve4efm.js → ChangelogPage-BDVpqtWz.js} +1 -1
  16. package/src/client/dist/pwa/assets/{ClosePopup-DLm_uRNv.js → ClosePopup-ClH5KG_w.js} +1 -1
  17. package/src/client/dist/pwa/assets/{ComparisonPanel-DRzYHcWf.js → ComparisonPanel-D6kz5Q-o.js} +1 -1
  18. package/src/client/dist/pwa/assets/{CreatePage-CT10FMyq.js → CreatePage-f-C_R4o9.js} +1 -1
  19. package/src/client/dist/pwa/assets/{DashboardPage-CT3XzDaz.js → DashboardPage-C_9_IXdR.js} +1 -1
  20. package/src/client/dist/pwa/assets/{DiffViewer-CIY7UOt6.js → DiffViewer-CMjfVpAG.js} +3 -3
  21. package/src/client/dist/pwa/assets/{DocumentsPanel-D2IlwigV.js → DocumentsPanel-Yim06L6k.js} +1 -1
  22. package/src/client/dist/pwa/assets/{DrawerToggleButton-0fKZjElk.js → DrawerToggleButton-aD_LMlna.js} +1 -1
  23. package/src/client/dist/pwa/assets/{GitPanel-DjG7-AsO.js → GitPanel-x-whHRdM.js} +3 -3
  24. package/src/client/dist/pwa/assets/{HealthPage-BJKRSg5I.js → HealthPage-BNZP_1l-.js} +1 -1
  25. package/src/client/dist/pwa/assets/MainLayout-BMxAagOe.js +2 -0
  26. package/src/client/dist/pwa/assets/{MainLayout-BUTz2ks4.css → MainLayout-DfddEd0b.css} +1 -1
  27. package/src/client/dist/pwa/assets/{QBtnDropdown-Bqa7KhX7.js → QBtnDropdown-BLNdi1w5.js} +1 -1
  28. package/src/client/dist/pwa/assets/{QDrawer-DinZbnyn.js → QDrawer-C6TAn5dv.js} +1 -1
  29. package/src/client/dist/pwa/assets/{SchedulePanel-BDmDDRMX.js → SchedulePanel-D2HyVnU8.js} +1 -1
  30. package/src/client/dist/pwa/assets/{SearchPage-BdisvUwr.js → SearchPage-BG_zXo6V.js} +1 -1
  31. package/src/client/dist/pwa/assets/{SettingsPage-DCJgxm6J.js → SettingsPage-BNBeaT9t.js} +1 -1
  32. package/src/client/dist/pwa/assets/{TerminalPanel-DBnSOJ3B.js → TerminalPanel-BzUFE4eS.js} +1 -1
  33. package/src/client/dist/pwa/assets/{TouchPan-D77wzGyc.js → TouchPan-CK2rmJkA.js} +1 -1
  34. package/src/client/dist/pwa/assets/{TourReplayButton-DU2PItj-.js → TourReplayButton-DHFo5_Kk.js} +1 -1
  35. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-CRVNJtmB.js → WorkspaceAttentionLabels-BbtPN7pb.js} +1 -1
  36. package/src/client/dist/pwa/assets/WorkspacePage-Aay3kkPT.js +4 -0
  37. package/src/client/dist/pwa/assets/{WorkspacePage-D3-AEaPN.css → WorkspacePage-ygEb8olx.css} +1 -1
  38. package/src/client/dist/pwa/assets/{browser-sSy6JmeQ.js → browser-pf20JpKz.js} +1 -1
  39. package/src/client/dist/pwa/assets/{build-path-tree-B2-SsE87.js → build-path-tree-BRLZv-pU.js} +1 -1
  40. package/src/client/dist/pwa/assets/de-CG7RYRLI.js +17 -0
  41. package/src/client/dist/pwa/assets/{documents-D4BcPyOF.js → documents-C1u794ea.js} +1 -1
  42. package/src/client/dist/pwa/assets/{editor.api-NH9tSUby.js → editor.api-BaRghNyI.js} +1 -1
  43. package/src/client/dist/pwa/assets/{efforts-BdCfujEx.js → efforts-EHbgZlde.js} +1 -1
  44. package/src/client/dist/pwa/assets/engineFeatures-DyKNpIKw.js +1 -0
  45. package/src/client/dist/pwa/assets/es-DfpfEjEu.js +17 -0
  46. package/src/client/dist/pwa/assets/{expand-template-3E9x3FGS.js → expand-template-D7OUa8G7.js} +1 -1
  47. package/src/client/dist/pwa/assets/{format-CR20Lbwg.js → format-D3dPFA-T.js} +1 -1
  48. package/src/client/dist/pwa/assets/{formatters-TuKWWdWq.js → formatters-DBdoGhB7.js} +1 -1
  49. package/src/client/dist/pwa/assets/fr-DgzZRmjM.js +17 -0
  50. package/src/client/dist/pwa/assets/{freemarker2-F7dLhXbU.js → freemarker2-DI7u6MdV.js} +1 -1
  51. package/src/client/dist/pwa/assets/{handlebars-hXFhj5eh.js → handlebars-BgkIQIEY.js} +1 -1
  52. package/src/client/dist/pwa/assets/{html-7nsVC4pO.js → html-BOPtZli3.js} +1 -1
  53. package/src/client/dist/pwa/assets/{i18n-Djnp5xDA.js → i18n-Butermk0.js} +1 -1
  54. package/src/client/dist/pwa/assets/index-BATViFoR.js +20 -0
  55. package/src/client/dist/pwa/assets/it-DKzqhjlT.js +17 -0
  56. package/src/client/dist/pwa/assets/{javascript-59rTAWws.js → javascript-Cf6-AeMO.js} +1 -1
  57. package/src/client/dist/pwa/assets/{liquid-BP6Awnwy.js → liquid-Bd9X87K-.js} +1 -1
  58. package/src/client/dist/pwa/assets/{mdx-CiCpqnQ4.js → mdx-C_ObhhDT.js} +1 -1
  59. package/src/client/dist/pwa/assets/{monaco-contributions-DzI5nDgU.js → monaco-contributions-rHxRMwAP.js} +1 -1
  60. package/src/client/dist/pwa/assets/{monaco.contribution-9uLUIE5c.js → monaco.contribution-DkNJdReE.js} +2 -2
  61. package/src/client/dist/pwa/assets/{network-auth--GlWvRqv.js → network-auth-CyD51r9F.js} +1 -1
  62. package/src/client/dist/pwa/assets/{notify-theme-DQ_bPMUo.js → notify-theme-Bnw3Xbxg.js} +1 -1
  63. package/src/client/dist/pwa/assets/{pending-diff-open-CK20L_pt.js → pending-diff-open-DsW49A6Z.js} +1 -1
  64. package/src/client/dist/pwa/assets/{permissionModes-DORv0Hwo.js → permissionModes-mF8Kofv1.js} +1 -1
  65. package/src/client/dist/pwa/assets/{project-color-DrojOIC6.js → project-color-CoEJs17L.js} +1 -1
  66. package/src/client/dist/pwa/assets/{python-BfVlMXCp.js → python-BIG7FEi1.js} +1 -1
  67. package/src/client/dist/pwa/assets/{razor-CxyOPnW7.js → razor-CNJSV12m.js} +1 -1
  68. package/src/client/dist/pwa/assets/{render-chat-markdown-nZGk_TRQ.js → render-chat-markdown-Ddq-hxhM.js} +1 -1
  69. package/src/client/dist/pwa/assets/{typescript-Bke62OsW.js → typescript-Blrg4f-0.js} +1 -1
  70. package/src/client/dist/pwa/assets/{use-tours-BEdbVcQS.js → use-tours-Cz5Zz63W.js} +2 -2
  71. package/src/client/dist/pwa/assets/{workspace-templates-1zUtbH31.js → workspace-templates-BS34DvEp.js} +1 -1
  72. package/src/client/dist/pwa/assets/{xml-Cg47p-KM.js → xml-Bv6_W6i5.js} +1 -1
  73. package/src/client/dist/pwa/assets/{yaml-Dj1MTjr-.js → yaml-DH7PMAq0.js} +1 -1
  74. package/src/client/dist/pwa/index.html +1 -1
  75. package/src/client/dist/pwa/sw.js +1 -1
  76. package/src/client/dist/pwa/assets/MainLayout-BSHKEpzs.js +0 -2
  77. package/src/client/dist/pwa/assets/WorkspacePage-Dv3ED-Bt.js +0 -4
  78. package/src/client/dist/pwa/assets/de-D_OhjWJx.js +0 -17
  79. package/src/client/dist/pwa/assets/engineFeatures-6ikmzzfp.js +0 -1
  80. package/src/client/dist/pwa/assets/es-kxJ0xgkc.js +0 -17
  81. package/src/client/dist/pwa/assets/fr-BgQGh90C.js +0 -17
  82. package/src/client/dist/pwa/assets/index-DztjpQfb.js +0 -20
  83. 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
@@ -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
- console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
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
- console.log(`[kobo] Pre-migration backup before applying ${pending.length} migration(s): ${result.created}`);
61
+ startupDebug(`[kobo] Pre-migration backup before applying ${pending.length} migration(s): ${result.created}`);
61
62
  if (result.deleted.length > 0) {
62
- console.log(`[kobo] Rotated ${result.deleted.length} old pre-migration backup(s)`);
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
- console.log(`[kobo] Daily DB backup: ${r.created}`);
109
+ startupDebug(`[kobo] Daily DB backup: ${r.created}`);
109
110
  if (r.deleted.length > 0) {
110
- console.log(`[kobo] Rotated ${r.deleted.length} old DB backup(s)`);
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
- if (getGlobalSettings().networkAccessEnabled) {
233
- console.log(`Server running — network access ON (port ${info.port})`);
234
- for (const url of getLanUrls(info.port))
235
- console.log(` LAN: ${url}`);
236
- console.log(` Token: ${getGlobalSettings().networkAccessToken}`);
237
- }
238
- else {
239
- console.log(`Server running at http://localhost:${info.port} (localhost only)`);
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, isGitWorktree } from '../utils/git-ops.js';
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
- // Only a genuinely recreated worktree (gh pr checkout / git worktree add)
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
- lastKnownGitStats.set(ws.id, await computeGitStats(ws, pr));
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
- void purgeWorktree(ws.id)
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
- const snap = await getForgeProvider(resolveForge(ws.projectPath)).getPrStatus(ws.worktreePath, ws.workingBranch);
336
- if (snap === null) {
337
- lastKnownPr.delete(workspaceId);
373
+ if (isWorkspaceLifecycleBusy(workspaceId))
338
374
  return null;
339
- }
340
- // Mirror the watcher's base-change detection so a manual refresh fixes a
341
- // stale `sourceBranch` (typical scenario: user ran `gh pr edit --base …`
342
- // and clicks the GitPanel refresh button instead of waiting for the next
343
- // 30s tick). Best-effort: a DB write failure here leaves the snapshot
344
- // cached but the metadata stale — the watcher will retry on its own.
345
- if (snap.state === 'OPEN' && snap.base && snap.base !== ws.sourceBranch) {
346
- try {
347
- updateWorkspaceSourceBranch(workspaceId, snap.base);
348
- emitEphemeral(workspaceId, 'pr:base-changed', {
349
- oldBase: ws.sourceBranch,
350
- newBase: snap.base,
351
- prUrl: snap.url,
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
- catch (err) {
355
- console.error(`[pr-watcher] updateWorkspaceSourceBranch (refresh) failed for '${ws.name}':`, err instanceof Error ? err.message : err);
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
- export async function purgeWorktree(workspaceId) {
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
+ }