@loicngr/kobo 1.11.33 → 1.11.35

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 (144) hide show
  1. package/AGENTS.md +10 -0
  2. package/CHANGELOG.md +8 -0
  3. package/dist/server/db/migrations.js +14 -0
  4. package/dist/server/db/schema.js +9 -0
  5. package/dist/server/index.js +32 -15
  6. package/dist/server/routes/activity.js +17 -0
  7. package/dist/server/routes/changelog.js +2 -23
  8. package/dist/server/routes/workspaces.js +37 -0
  9. package/dist/server/services/activity-service.js +62 -0
  10. package/dist/server/services/pr-watcher-service.js +92 -34
  11. package/dist/server/services/websocket-service.js +3 -0
  12. package/dist/server/services/worktree-purge-service.js +25 -1
  13. package/dist/server/services/worktree-restore-service.js +79 -0
  14. package/dist/server/services/worktree-service.js +151 -3
  15. package/dist/server/utils/changelog.js +23 -0
  16. package/dist/server/utils/startup-display.js +71 -0
  17. package/dist/server/utils/workspace-lifecycle-guard.js +22 -0
  18. package/package.json +1 -1
  19. package/src/client/dist/pwa/assets/ActivityFeed-YRbIRutK.js +8 -0
  20. package/src/client/dist/pwa/assets/ChangelogPage-DVLWWvvz.js +1 -0
  21. package/src/client/dist/pwa/assets/ClosePopup-BqW5dy-O.js +1 -0
  22. package/src/client/dist/pwa/assets/ComparisonPanel-LZrvk6a6.js +2 -0
  23. package/src/client/dist/pwa/assets/CreatePage-gFrn4xl9.js +2 -0
  24. package/src/client/dist/pwa/assets/DashboardPage-0odIhseb.js +1 -0
  25. package/src/client/dist/pwa/assets/DiffViewer-NWB2uQkz.js +8 -0
  26. package/src/client/dist/pwa/assets/DocumentsPanel-3yApHgbl.js +1 -0
  27. package/src/client/dist/pwa/assets/DrawerToggleButton-C_bNtZsE.js +1 -0
  28. package/src/client/dist/pwa/assets/{GitPanel-De5_E4NA.css → GitPanel-BFCnyhE2.css} +1 -1
  29. package/src/client/dist/pwa/assets/GitPanel-BMjWwiAX.js +3 -0
  30. package/src/client/dist/pwa/assets/HealthPage-Vyr1YG2o.js +1 -0
  31. package/src/client/dist/pwa/assets/MainLayout-DxMVVA6f.css +1 -0
  32. package/src/client/dist/pwa/assets/MainLayout-s8_9FPE2.js +2 -0
  33. package/src/client/dist/pwa/assets/{QBadge-CWEaKfKi.js → QBadge-DbkxGtht.js} +1 -1
  34. package/src/client/dist/pwa/assets/{QBanner-DRiFK0GP.js → QBanner-DhqH95GW.js} +1 -1
  35. package/src/client/dist/pwa/assets/QBtn-CZaBclNw.js +1 -0
  36. package/src/client/dist/pwa/assets/QBtnDropdown-BxuXEBp5.js +1 -0
  37. package/src/client/dist/pwa/assets/{QBtnGroup-BY6XqeTJ.js → QBtnGroup-DELKYo3u.js} +1 -1
  38. package/src/client/dist/pwa/assets/{QBtnToggle-CCgwqSgg.js → QBtnToggle-BI3k3Ch2.js} +1 -1
  39. package/src/client/dist/pwa/assets/{QChip-CJ6WmvGE.js → QChip-owciRuZu.js} +1 -1
  40. package/src/client/dist/pwa/assets/QDrawer-BdlDiU6a.js +1 -0
  41. package/src/client/dist/pwa/assets/QItemSection-D_5_0UrX.js +1 -0
  42. package/src/client/dist/pwa/assets/QList-qupzuEqO.js +1 -0
  43. package/src/client/dist/pwa/assets/{QMarkupTable-MxPL53nz.js → QMarkupTable-B9QuKtK_.js} +1 -1
  44. package/src/client/dist/pwa/assets/QPage-DaX9sMQC.js +1 -0
  45. package/src/client/dist/pwa/assets/QSelect-BrSemS3o.js +1 -0
  46. package/src/client/dist/pwa/assets/{QSpinnerDots-6v3Yl0T6.js → QSpinnerDots-DnCV3VzI.js} +1 -1
  47. package/src/client/dist/pwa/assets/QTooltip-CLCFHW2D.js +1 -0
  48. package/src/client/dist/pwa/assets/SchedulePanel-F9bdAq6K.js +1 -0
  49. package/src/client/dist/pwa/assets/SearchPage-CBCefYqc.js +1 -0
  50. package/src/client/dist/pwa/assets/SettingsPage-BPgTHmKj.js +9 -0
  51. package/src/client/dist/pwa/assets/{SettingsPage-CDA54yPx.css → SettingsPage-Do9d0Pov.css} +1 -1
  52. package/src/client/dist/pwa/assets/SplitWorkspacePage-DDs2nuoT.js +1 -0
  53. package/src/client/dist/pwa/assets/SplitWorkspacePage-lWQO8xro.css +1 -0
  54. package/src/client/dist/pwa/assets/{TerminalPanel-jufNVqXc.js → TerminalPanel-CulE0wTU.js} +9 -9
  55. package/src/client/dist/pwa/assets/TouchPan-DUgqs2cW.js +1 -0
  56. package/src/client/dist/pwa/assets/TourReplayButton-CdHpGBdr.js +1 -0
  57. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-W5SvqzXO.js +1 -0
  58. package/src/client/dist/pwa/assets/WorkspacePage-B6O7j57u.js +4 -0
  59. package/src/client/dist/pwa/assets/{WorkspacePage-D3-AEaPN.css → WorkspacePage-DYQrfeYw.css} +1 -1
  60. package/src/client/dist/pwa/assets/{browser-DUmYgiEg.js → browser-CnuqDTyc.js} +1 -1
  61. package/src/client/dist/pwa/assets/build-path-tree-JodIO7-I.js +1 -0
  62. package/src/client/dist/pwa/assets/de-Dt_m-H83.js +17 -0
  63. package/src/client/dist/pwa/assets/{documents-39BXaRLp.js → documents-3ZBK1pXE.js} +1 -1
  64. package/src/client/dist/pwa/assets/{editor.api-Do1AYiz7.js → editor.api-DZ9WO_J7.js} +1 -1
  65. package/src/client/dist/pwa/assets/{efforts-DjlRTQQN.js → efforts-LrtJPx-j.js} +2 -2
  66. package/src/client/dist/pwa/assets/engineFeatures-DmnHj_uD.js +1 -0
  67. package/src/client/dist/pwa/assets/es-DFg25DCS.js +17 -0
  68. package/src/client/dist/pwa/assets/{expand-template-17KFFCsY.js → expand-template-Dv4JC2zU.js} +1 -1
  69. package/src/client/dist/pwa/assets/{format-DdlEFKmU.js → format-CHjfjuhr.js} +7 -7
  70. package/src/client/dist/pwa/assets/{formatters-Bq2A89Eg.js → formatters-BsgH8CoH.js} +1 -1
  71. package/src/client/dist/pwa/assets/fr-CQ1slpol.js +17 -0
  72. package/src/client/dist/pwa/assets/{freemarker2-Br0g81VI.js → freemarker2-Dla55SIR.js} +1 -1
  73. package/src/client/dist/pwa/assets/{handlebars-FbaXsXfL.js → handlebars-DtuuZy7D.js} +1 -1
  74. package/src/client/dist/pwa/assets/{html-BSoQjC6U.js → html-CKYOCR8W.js} +1 -1
  75. package/src/client/dist/pwa/assets/i18n-DTfKQUMf.js +1 -0
  76. package/src/client/dist/pwa/assets/index-CIP08MpU.js +20 -0
  77. package/src/client/dist/pwa/assets/it-w09rfaP7.js +17 -0
  78. package/src/client/dist/pwa/assets/{javascript-DyaXAXwe.js → javascript-BxzTnsIQ.js} +1 -1
  79. package/src/client/dist/pwa/assets/{liquid-CM0jAcRe.js → liquid-C8BfPFUy.js} +1 -1
  80. package/src/client/dist/pwa/assets/{mdx-C8TCxhMn.js → mdx-DZm_YXkC.js} +1 -1
  81. package/src/client/dist/pwa/assets/{monaco-contributions-DWI2C6KU.js → monaco-contributions-HDbRHiNR.js} +1 -1
  82. package/src/client/dist/pwa/assets/monaco.contribution-DRW-851R.js +2 -0
  83. package/src/client/dist/pwa/assets/network-auth-Ce9hsryo.js +1 -0
  84. package/src/client/dist/pwa/assets/notify-theme-OBpMEBvP.js +1 -0
  85. package/src/client/dist/pwa/assets/pending-diff-open-CVQxWyzP.js +1 -0
  86. package/src/client/dist/pwa/assets/permissionModes-CVFHS2ec.js +36 -0
  87. package/src/client/dist/pwa/assets/{pinia-jcytgarH.js → pinia-BKqmoVru.js} +1 -1
  88. package/src/client/dist/pwa/assets/{private.use-form-DVqJLg-9.js → private.use-form-C7umbGO-.js} +1 -1
  89. package/src/client/dist/pwa/assets/{project-color-DujVR8Fa.js → project-color-mcpQp2o6.js} +1 -1
  90. package/src/client/dist/pwa/assets/{python-7qqqHkG7.js → python-CO5iQN74.js} +1 -1
  91. package/src/client/dist/pwa/assets/{razor-o3t0P5Qt.js → razor-BC7D8LMf.js} +1 -1
  92. package/src/client/dist/pwa/assets/{render-chat-markdown-BHUn1rwa.js → render-chat-markdown-Cb15kvox.js} +1 -1
  93. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-CDnVUfeZ.js +1 -0
  94. package/src/client/dist/pwa/assets/{typescript-gEevI6ZM.js → typescript-BCHCSNpD.js} +1 -1
  95. package/src/client/dist/pwa/assets/{use-checkbox-DDRAnhSJ.js → use-checkbox-FnauAgTp.js} +1 -1
  96. package/src/client/dist/pwa/assets/use-id-BIuaWKkB.js +1 -0
  97. package/src/client/dist/pwa/assets/use-is-mobile-CWjqe8gC.js +1 -0
  98. package/src/client/dist/pwa/assets/use-tours-DhLhglwr.js +2 -0
  99. package/src/client/dist/pwa/assets/workspace-page-layout-Dw9NhZsL.js +1 -0
  100. package/src/client/dist/pwa/assets/{workspace-templates-BH4xqYR_.js → workspace-templates-CoWrOEac.js} +1 -1
  101. package/src/client/dist/pwa/assets/{xml-xV5YWh8z.js → xml-y9M3NAIZ.js} +1 -1
  102. package/src/client/dist/pwa/assets/{yaml-DNkjt0UK.js → yaml-CthlToPo.js} +1 -1
  103. package/src/client/dist/pwa/index.html +7 -6
  104. package/src/client/dist/pwa/sw.js +1 -1
  105. package/src/client/dist/pwa/assets/ActivityFeed--4irDPfF.js +0 -8
  106. package/src/client/dist/pwa/assets/ChangelogPage-BT_iOfWn.js +0 -1
  107. package/src/client/dist/pwa/assets/ClosePopup-_NdLt5i2.js +0 -1
  108. package/src/client/dist/pwa/assets/ComparisonPanel-Kij_awqg.js +0 -2
  109. package/src/client/dist/pwa/assets/CreatePage-KdrJL7Me.js +0 -2
  110. package/src/client/dist/pwa/assets/DashboardPage-CPAd3eCD.js +0 -1
  111. package/src/client/dist/pwa/assets/DiffViewer-CYpgLe75.js +0 -8
  112. package/src/client/dist/pwa/assets/DocumentsPanel-0KQrXuXF.js +0 -1
  113. package/src/client/dist/pwa/assets/DrawerToggleButton-CcH-cA9p.js +0 -1
  114. package/src/client/dist/pwa/assets/GitPanel-BD05_3k6.js +0 -3
  115. package/src/client/dist/pwa/assets/HealthPage-Dr-h990u.js +0 -1
  116. package/src/client/dist/pwa/assets/MainLayout-BUTz2ks4.css +0 -1
  117. package/src/client/dist/pwa/assets/MainLayout-BmhjVDiz.js +0 -2
  118. package/src/client/dist/pwa/assets/QBtn-DqZryKOh.js +0 -1
  119. package/src/client/dist/pwa/assets/QBtnDropdown-Ch7WOARF.js +0 -1
  120. package/src/client/dist/pwa/assets/QDrawer-C5PVKIEd.js +0 -1
  121. package/src/client/dist/pwa/assets/QItemSection-CjrpcdN_.js +0 -1
  122. package/src/client/dist/pwa/assets/SchedulePanel-D1L1upGW.js +0 -1
  123. package/src/client/dist/pwa/assets/SearchPage-CXGTP0of.js +0 -1
  124. package/src/client/dist/pwa/assets/SettingsPage-D7V7uN5K.js +0 -9
  125. package/src/client/dist/pwa/assets/TouchPan-Ci_dyxVk.js +0 -1
  126. package/src/client/dist/pwa/assets/TourReplayButton-CZqPH9hM.js +0 -1
  127. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-BEC7IA-V.js +0 -1
  128. package/src/client/dist/pwa/assets/WorkspacePage-D-TanDoR.js +0 -4
  129. package/src/client/dist/pwa/assets/build-path-tree-DyyHUwzz.js +0 -1
  130. package/src/client/dist/pwa/assets/de-BUjatW-C.js +0 -17
  131. package/src/client/dist/pwa/assets/engineFeatures-C78AGiF7.js +0 -1
  132. package/src/client/dist/pwa/assets/es-DRiVweiR.js +0 -17
  133. package/src/client/dist/pwa/assets/fr-lXvcEWhJ.js +0 -17
  134. package/src/client/dist/pwa/assets/i18n-663QQ_uF.js +0 -1
  135. package/src/client/dist/pwa/assets/index-BDTPwynK.js +0 -20
  136. package/src/client/dist/pwa/assets/it-DBTMyadC.js +0 -17
  137. package/src/client/dist/pwa/assets/monaco.contribution-DS4KiLpR.js +0 -2
  138. package/src/client/dist/pwa/assets/network-auth-WHMGZHjT.js +0 -1
  139. package/src/client/dist/pwa/assets/notify-theme--s1RcQmT.js +0 -1
  140. package/src/client/dist/pwa/assets/pending-diff-open-DWEfW98v.js +0 -1
  141. package/src/client/dist/pwa/assets/permissionModes-B5Kgd8T6.js +0 -36
  142. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-CS0xkoG_.js +0 -1
  143. package/src/client/dist/pwa/assets/use-is-mobile-ClPMtqZc.js +0 -1
  144. package/src/client/dist/pwa/assets/use-tours-Co_sci4c.js +0 -2
package/AGENTS.md CHANGED
@@ -263,6 +263,16 @@ 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
+
268
+ ### Workspace navigation and activity digest
269
+
270
+ `utils/workspace-sort.ts` applies the browser-persisted sort within each drawer group; fuzzy search relevance remains primary. `ActionAvailability.vue` pairs disabled controls with visible, focusable explanations derived from `utils/action-blocker.ts`.
271
+
272
+ `SplitWorkspacePage.vue` hosts two same-origin embedded clients (`?pane=1`), isolating routers, stores and terminal registries. `utils/split-workspace.ts` and the router bridge synchronize the host URL without treating that bookkeeping as a departure. Actual departures consult both panes for dirty edits, unsent drafts and all in-memory queued messages. Embedded clients suppress automatic tours and browser/audio notifications; the host owns these.
273
+
274
+ `activity-service.ts` records significant metadata from `emit` and `emitEphemeral` in `workspace_activity` (migration v41, 30-day retention). It excludes streaming data and superseded session endings. `/api/activity` exposes a paginated monotonic cursor; `stores/activity.ts` keeps browser-local visit checkpoints and protects against late responses after visibility changes. `ActivityDigest.vue` links events to workspace/session or Git context. Never advance an unread checkpoint to the head of unloaded pages.
275
+
266
276
  ### Workspace attention indicators
267
277
 
268
278
  `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.35
8
+
9
+ - feat(workspaces): add split view, activity digest and compact toolbar
10
+
11
+ ## 1.11.34
12
+
13
+ - feat(workspaces): restore purged workspaces and improve startup display
14
+
7
15
  ## 1.11.33
8
16
 
9
17
  - fix(onboarding): quitting a tour marks it seen for good
@@ -726,6 +726,20 @@ export const migrations = [
726
726
  }
727
727
  },
728
728
  },
729
+ {
730
+ version: 41,
731
+ name: 'add-workspace-activity',
732
+ migrate: (db) => {
733
+ db.exec(`CREATE TABLE IF NOT EXISTS workspace_activity (
734
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
735
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
736
+ kind TEXT NOT NULL,
737
+ session_id TEXT,
738
+ created_at TEXT NOT NULL
739
+ );
740
+ CREATE INDEX IF NOT EXISTS idx_workspace_activity_created ON workspace_activity(created_at);`);
741
+ },
742
+ },
729
743
  ];
730
744
  /** Current schema version — always equals the highest migration version. */
731
745
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -192,6 +192,15 @@ export function initSchema(db) {
192
192
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
193
193
  );
194
194
 
195
+ CREATE TABLE IF NOT EXISTS workspace_activity (
196
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
197
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
198
+ kind TEXT NOT NULL,
199
+ session_id TEXT,
200
+ created_at TEXT NOT NULL
201
+ );
202
+ CREATE INDEX IF NOT EXISTS idx_workspace_activity_created ON workspace_activity(created_at);
203
+
195
204
  CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id);
196
205
  CREATE INDEX IF NOT EXISTS idx_agent_sessions_workspace_id ON agent_sessions(workspace_id);
197
206
  CREATE INDEX IF NOT EXISTS idx_ws_events_workspace_id ON ws_events(workspace_id);
@@ -8,6 +8,7 @@ import { closeDb, getDb } from './db/index.js';
8
8
  import { getPendingMigrations, runMigrations } from './db/migrations.js';
9
9
  import { hostCheckMiddleware } from './middleware/host-check-middleware.js';
10
10
  import { networkAuthMiddleware } from './middleware/network-auth-middleware.js';
11
+ import activityRouter from './routes/activity.js';
11
12
  import changelogRouter from './routes/changelog.js';
12
13
  import devServerRouter from './routes/dev-server.js';
13
14
  import documentsRouter from './routes/documents.js';
@@ -46,8 +47,9 @@ import * as wakeupService from './services/wakeup-service.js';
46
47
  import { emit, emitEphemeral, handleConnection, setMessageHandler } from './services/websocket-service.js';
47
48
  import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
48
49
  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()}`);
50
+ import { getChangelogPath, getClientSpaPath, getDbPath, getKoboHome, getPackageVersion, resolveSpaFile, } from './utils/paths.js';
51
+ import { formatStartupBanner, readStartupNotes, startStartupSpinner, startupDebug } from './utils/startup-display.js';
52
+ startupDebug(`[kobo] Kōbō home: ${getKoboHome()}`);
51
53
  // Initialize DB + run migrations
52
54
  const db = getDb();
53
55
  // Pre-migration backup: snapshot the DB before applying any pending schema
@@ -57,9 +59,9 @@ try {
57
59
  const pending = getPendingMigrations(db);
58
60
  if (pending.length > 0) {
59
61
  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}`);
62
+ startupDebug(`[kobo] Pre-migration backup before applying ${pending.length} migration(s): ${result.created}`);
61
63
  if (result.deleted.length > 0) {
62
- console.log(`[kobo] Rotated ${result.deleted.length} old pre-migration backup(s)`);
64
+ startupDebug(`[kobo] Rotated ${result.deleted.length} old pre-migration backup(s)`);
63
65
  }
64
66
  }
65
67
  }
@@ -105,9 +107,9 @@ retentionTimer.unref?.();
105
107
  void createDailyDbBackupIfNeeded(db, getDbPath())
106
108
  .then((r) => {
107
109
  if (r.created) {
108
- console.log(`[kobo] Daily DB backup: ${r.created}`);
110
+ startupDebug(`[kobo] Daily DB backup: ${r.created}`);
109
111
  if (r.deleted.length > 0) {
110
- console.log(`[kobo] Rotated ${r.deleted.length} old DB backup(s)`);
112
+ startupDebug(`[kobo] Rotated ${r.deleted.length} old DB backup(s)`);
111
113
  }
112
114
  }
113
115
  })
@@ -162,6 +164,7 @@ app.route('/api/sentry', sentryRouter);
162
164
  app.route('/api/git', gitRouter);
163
165
  app.route('/api/fs', fsRouter);
164
166
  app.route('/api/changelog', changelogRouter);
167
+ app.route('/api/activity', activityRouter);
165
168
  app.route('/api/settings', settingsRouter);
166
169
  app.route('/api/dev-server', devServerRouter);
167
170
  app.route('/api/templates', templatesRouter);
@@ -223,21 +226,25 @@ if (Object.keys(networkAccessEnvOverrides).length > 0) {
223
226
  updateNetworkAccessSettings(patch);
224
227
  }
225
228
  const bindHost = resolveBindHost(getGlobalSettings().networkAccessEnabled);
229
+ const stopStartupSpinner = startStartupSpinner();
226
230
  const server = serve({
227
231
  fetch: app.fetch,
228
232
  port: PORT,
229
233
  hostname: bindHost,
230
234
  }, (info) => {
231
235
  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
- }
236
+ stopStartupSpinner();
237
+ const settings = getGlobalSettings();
238
+ console.log(formatStartupBanner({
239
+ version: getPackageVersion(),
240
+ port: info.port,
241
+ devClientOrigin: resolveDevClientOrigin(),
242
+ networkEnabled: settings.networkAccessEnabled,
243
+ lanUrls: settings.networkAccessEnabled ? getLanUrls(info.port) : [],
244
+ token: settings.networkAccessToken,
245
+ changelog: readStartupNotes(getChangelogPath()),
246
+ color: Boolean(process.stdout.isTTY && process.env.TERM !== 'dumb' && !('NO_COLOR' in process.env)),
247
+ }));
241
248
  // Content migration runs AFTER the HTTP listener is up so the frontend
242
249
  // can observe progress via WS broadcasts + GET /api/migration/status.
243
250
  // Not awaited — the callback returns quickly, the migration runs in the
@@ -246,6 +253,16 @@ const server = serve({
246
253
  console.error('[boot] content migration failed:', err);
247
254
  });
248
255
  });
256
+ server.on('error', (err) => {
257
+ stopStartupSpinner();
258
+ if (err.code === 'EADDRINUSE') {
259
+ console.error(`[kobo] Port ${PORT} is already in use. Stop the other server or choose another SERVER_PORT.`);
260
+ }
261
+ else {
262
+ console.error('[kobo] Unable to start the server:', err);
263
+ }
264
+ void gracefulShutdown('server error', 1);
265
+ });
249
266
  // Create WebSocketServer attached to the HTTP server
250
267
  const wss = new WebSocketServer({ noServer: true });
251
268
  const terminalWss = new WebSocketServer({ noServer: true });
@@ -0,0 +1,17 @@
1
+ import { Hono } from 'hono';
2
+ import { activityCursor, listActivity } from '../services/activity-service.js';
3
+ const app = new Hono();
4
+ app.get('/', (c) => {
5
+ try {
6
+ if (c.req.query('head') === '1')
7
+ return c.json({ cursor: activityCursor() });
8
+ const raw = c.req.query('after') ?? '0';
9
+ if (!/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw)))
10
+ return c.json({ error: 'after must be a non-negative integer' }, 400);
11
+ return c.json(listActivity(Number(raw)));
12
+ }
13
+ catch (error) {
14
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 500);
15
+ }
16
+ });
17
+ export default app;
@@ -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
  }
@@ -0,0 +1,62 @@
1
+ import { getDb } from '../db/index.js';
2
+ export function classifyActivity(type, payload) {
3
+ const event = payload && typeof payload === 'object' ? payload : {};
4
+ if (type === 'agent:event') {
5
+ if (event.kind === 'session:ended' && event.superseded)
6
+ return null;
7
+ if (event.kind === 'session:user-input-requested')
8
+ return event.requestKind === 'permission' ? 'permission' : 'question';
9
+ if (event.kind === 'error')
10
+ return 'error';
11
+ if (event.kind === 'session:ended' && event.reason === 'completed')
12
+ return 'completed';
13
+ if (event.kind === 'session:ended' && (event.reason === 'error' || event.reason === 'watchdog'))
14
+ return 'error';
15
+ }
16
+ const kinds = {
17
+ 'pr:merged': 'pr-merged',
18
+ 'pr:approved': 'pr-approved',
19
+ 'pr:changes-requested': 'pr-changes-requested',
20
+ 'pr:ci-failed': 'pr-ci-failed',
21
+ 'pr:ci-recovered': 'pr-ci-recovered',
22
+ 'pr:merge-conflict': 'pr-merge-conflict',
23
+ 'pr:ready-to-merge': 'pr-ready-to-merge',
24
+ 'workspace:archived': 'archived',
25
+ 'workspace:worktree-purged': 'purged',
26
+ 'workspace:worktree-restored': 'restored',
27
+ };
28
+ return kinds[type] ?? null;
29
+ }
30
+ /** Compact metadata only; failure must never interrupt agent/event delivery. */
31
+ export function recordActivity(workspaceId, type, payload, sessionId, db) {
32
+ const kind = classifyActivity(type, payload);
33
+ if (!kind)
34
+ return;
35
+ try {
36
+ const database = db ?? getDb();
37
+ database
38
+ .prepare('INSERT INTO workspace_activity (workspace_id, kind, session_id, created_at) VALUES (?, ?, ?, ?)')
39
+ .run(workspaceId, kind, sessionId ?? null, new Date().toISOString());
40
+ // Bounded even when no browser is connected. Only significant events reach here.
41
+ database
42
+ .prepare('DELETE FROM workspace_activity WHERE created_at < ?')
43
+ .run(new Date(Date.now() - 30 * 86400_000).toISOString());
44
+ }
45
+ catch (error) {
46
+ console.error('[activity] Could not record workspace activity:', error);
47
+ }
48
+ }
49
+ export function activityCursor(db = getDb()) {
50
+ return (db.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'workspace_activity'").get()?.seq ?? 0);
51
+ }
52
+ export function listActivity(after, limit = 200, db = getDb()) {
53
+ limit = Math.min(200, Math.max(1, limit));
54
+ const rows = db
55
+ .prepare(`SELECT a.id, a.workspace_id AS workspaceId, w.name AS workspaceName,
56
+ a.kind, a.session_id AS sessionId, a.created_at AS createdAt
57
+ FROM workspace_activity a JOIN workspaces w ON w.id = a.workspace_id
58
+ WHERE a.id > ? AND a.created_at >= ? ORDER BY a.id ASC LIMIT ?`)
59
+ .all(after, new Date(Date.now() - 30 * 86400_000).toISOString(), limit + 1);
60
+ const items = rows.slice(0, limit);
61
+ return { items, nextCursor: items.at(-1)?.id ?? after, cursor: activityCursor(db), hasMore: rows.length > limit };
62
+ }
@@ -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,6 @@
1
1
  import { nanoid } from 'nanoid';
2
2
  import { getDb } from '../db/index.js';
3
+ import { recordActivity } from './activity-service.js';
3
4
  import { getAllPersistedSnapshots } from './usage/db.js';
4
5
  // ── State ──────────────────────────────────────────────────────────────────────
5
6
  /** Maps each WS client to the set of workspaceIds they are subscribed to */
@@ -154,6 +155,7 @@ function insertEventStatement() {
154
155
  return cachedInsert.statement;
155
156
  }
156
157
  export function emit(workspaceId, type, payload, sessionId) {
158
+ recordActivity(workspaceId, type, payload, sessionId);
157
159
  const id = nanoid();
158
160
  const createdAt = new Date().toISOString();
159
161
  let replayable = false;
@@ -198,6 +200,7 @@ export function emit(workspaceId, type, payload, sessionId) {
198
200
  * Used for ephemeral status updates (e.g., dev-server status) that don't need replay.
199
201
  */
200
202
  export function emitEphemeral(workspaceId, type, payload) {
203
+ recordActivity(workspaceId, type, payload);
201
204
  const id = nanoid();
202
205
  const createdAt = new Date().toISOString();
203
206
  const event = { id, workspaceId, type, payload, createdAt, replayable: false };