@loicngr/kobo 1.11.16 → 1.11.17

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 (175) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +9 -0
  3. package/dist/mcp-server/kobo-tasks-server.js +8 -7
  4. package/dist/server/db/migrations.js +57 -0
  5. package/dist/server/db/schema.js +10 -39
  6. package/dist/server/index.js +55 -7
  7. package/dist/server/routes/git.js +8 -3
  8. package/dist/server/routes/health.js +19 -2
  9. package/dist/server/routes/settings.js +24 -0
  10. package/dist/server/routes/workspaces.js +371 -72
  11. package/dist/server/services/agent/engines/claude-code/engine.js +39 -35
  12. package/dist/server/services/agent/engines/codex/client.js +16 -4
  13. package/dist/server/services/agent/engines/codex/engine.js +23 -2
  14. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +44 -5
  15. package/dist/server/services/agent/engines/types.js +13 -0
  16. package/dist/server/services/agent/orchestrator.js +354 -133
  17. package/dist/server/services/agent/session-controller.js +27 -0
  18. package/dist/server/services/agent/{engines/codex/turn-liveness.js → turn-liveness.js} +6 -2
  19. package/dist/server/services/change-source-branch-service.js +115 -11
  20. package/dist/server/services/db-backup-service.js +27 -16
  21. package/dist/server/services/pr-watcher-service.js +17 -2
  22. package/dist/server/services/settings-service.js +39 -0
  23. package/dist/server/services/websocket-service.js +114 -48
  24. package/dist/server/services/workspace-service.js +54 -2
  25. package/dist/server/services/worktree-purge-service.js +19 -5
  26. package/dist/server/services/worktree-service.js +38 -1
  27. package/dist/server/services/ws-events-retention-service.js +133 -0
  28. package/dist/server/utils/git-ops.js +209 -14
  29. package/dist/server/utils/git-repo-lock.js +45 -0
  30. package/dist/server/utils/logger.js +108 -0
  31. package/dist/server/utils/script-runner.js +11 -0
  32. package/package.json +1 -1
  33. package/src/client/dist/pwa/assets/ActivityFeed-BlDBh5jB.js +8 -0
  34. package/src/client/dist/pwa/assets/ActivityFeed-tC8sR0GV.css +1 -0
  35. package/src/client/dist/pwa/assets/ChangelogPage-DACtknly.js +1 -0
  36. package/src/client/dist/pwa/assets/ChangelogPage-D_WOVByV.css +1 -0
  37. package/src/client/dist/pwa/assets/ClosePopup-DEy0yFXE.js +1 -0
  38. package/src/client/dist/pwa/assets/CreatePage-Bb-Qn-Rd.js +2 -0
  39. package/src/client/dist/pwa/assets/CreatePage-RMBX-Zjn.css +1 -0
  40. package/src/client/dist/pwa/assets/DiffViewer-DKt6gFi4.js +8 -0
  41. package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +1 -0
  42. package/src/client/dist/pwa/assets/DocumentsPanel-B8QY7FTj.css +1 -0
  43. package/src/client/dist/pwa/assets/DocumentsPanel-DhP1jk11.js +1 -0
  44. package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +1 -0
  45. package/src/client/dist/pwa/assets/GitPanel-BKrtkPrV.css +1 -0
  46. package/src/client/dist/pwa/assets/GitPanel-DNCCB0G_.js +3 -0
  47. package/src/client/dist/pwa/assets/HealthPage-B1J4-dOH.js +1 -0
  48. package/src/client/dist/pwa/assets/HealthPage-BbqLoFQj.css +1 -0
  49. package/src/client/dist/pwa/assets/MainLayout-F8ShDnAy.css +1 -0
  50. package/src/client/dist/pwa/assets/MainLayout-qWwDy1B5.js +2 -0
  51. package/src/client/dist/pwa/assets/QBadge-BgS0-MXB.js +1 -0
  52. package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +1 -0
  53. package/src/client/dist/pwa/assets/QBtn-B7WFdfmy.js +1 -0
  54. package/src/client/dist/pwa/assets/QBtnGroup-BMQlX2YS.js +1 -0
  55. package/src/client/dist/pwa/assets/QBtnToggle-eMu_yinY.js +1 -0
  56. package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +1 -0
  57. package/src/client/dist/pwa/assets/QExpansionItem-C8CWo-aP.js +1 -0
  58. package/src/client/dist/pwa/assets/QItemSection-CDBweQnA.js +1 -0
  59. package/src/client/dist/pwa/assets/QList-Ccqhimcb.js +1 -0
  60. package/src/client/dist/pwa/assets/QPage-RpDxx5ah.js +1 -0
  61. package/src/client/dist/pwa/assets/QSelect-D9bHHxAA.js +1 -0
  62. package/src/client/dist/pwa/assets/QSlideTransition-6lWJvjLP.js +1 -0
  63. package/src/client/dist/pwa/assets/QSpace-D3PNLwa4.js +1 -0
  64. package/src/client/dist/pwa/assets/QSpinnerDots-DWOFEezR.js +1 -0
  65. package/src/client/dist/pwa/assets/SchedulePanel-Brtqtz1H.js +1 -0
  66. package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +1 -0
  67. package/src/client/dist/pwa/assets/SearchPage-CvZxJiJs.js +1 -0
  68. package/src/client/dist/pwa/assets/SearchPage-DdWM01rq.css +1 -0
  69. package/src/client/dist/pwa/assets/SettingsPage-JVje8OPs.js +16 -0
  70. package/src/client/dist/pwa/assets/SettingsPage-dMAg4JP8.css +1 -0
  71. package/src/client/dist/pwa/assets/TerminalPanel-B6xJJs8p.css +1 -0
  72. package/src/client/dist/pwa/assets/TerminalPanel-Dtc6B3US.js +18 -0
  73. package/src/client/dist/pwa/assets/TouchPan-DnEDaTFL.js +1 -0
  74. package/src/client/dist/pwa/assets/WorkspacePage-BVxtefbj.js +4 -0
  75. package/src/client/dist/pwa/assets/WorkspacePage-DN-A6XpI.css +1 -0
  76. package/src/client/dist/pwa/assets/build-path-tree-BU8Y8vRw.js +1 -0
  77. package/src/client/dist/pwa/assets/clipboard-t9qe6lK7.js +1 -0
  78. package/src/client/dist/pwa/assets/{cssMode-DNEi2cLH.js → cssMode-D_JDSbNP.js} +1 -1
  79. package/src/client/dist/pwa/assets/debounce-CDIE7CV6.js +1 -0
  80. package/src/client/dist/pwa/assets/documents-B6eOZeFK.js +1 -0
  81. package/src/client/dist/pwa/assets/{editor.api-_tFrtCR7.js → editor.api-Ccbr7NXr.js} +1 -1
  82. package/src/client/dist/pwa/assets/engineFeatures-DW9PydRL.js +1 -0
  83. package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +1 -0
  84. package/src/client/dist/pwa/assets/expand-template-Xq6JwXsl.js +1 -0
  85. package/src/client/dist/pwa/assets/{freemarker2-kfVsFQjE.js → freemarker2-CUvQzJdx.js} +1 -1
  86. package/src/client/dist/pwa/assets/{handlebars-EwBi9SHt.js → handlebars-BXiAPlES.js} +1 -1
  87. package/src/client/dist/pwa/assets/{html-BOOCP_vn.js → html-CLmfyTMC.js} +1 -1
  88. package/src/client/dist/pwa/assets/{htmlMode-IN9oEIXW.js → htmlMode-DWYo6_e_.js} +1 -1
  89. package/src/client/dist/pwa/assets/i18n-CJclRByY.js +1 -0
  90. package/src/client/dist/pwa/assets/index-Bri8-39E.css +1 -0
  91. package/src/client/dist/pwa/assets/index-Dv1p-1sA.js +82 -0
  92. package/src/client/dist/pwa/assets/{javascript-CfV8qmeH.js → javascript-C9gWFLC_.js} +1 -1
  93. package/src/client/dist/pwa/assets/{jsonMode-C0b3m4VO.js → jsonMode-CbsjeFb8.js} +1 -1
  94. package/src/client/dist/pwa/assets/kobo-commands-DGygmOvc.js +9 -0
  95. package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +1 -0
  96. package/src/client/dist/pwa/assets/{liquid-D3YK93J-.js → liquid-BqJ3SIha.js} +1 -1
  97. package/src/client/dist/pwa/assets/{lspLanguageFeatures-PklP2gEH.js → lspLanguageFeatures-BB0y1B51.js} +1 -1
  98. package/src/client/dist/pwa/assets/{mdx-DjZcevPS.js → mdx-rIxQLZDB.js} +1 -1
  99. package/src/client/dist/pwa/assets/network-auth-pzKTXRYP.js +1 -0
  100. package/src/client/dist/pwa/assets/notify-theme-D7RrwvJa.js +1 -0
  101. package/src/client/dist/pwa/assets/pending-diff-open-Cia8sndE.js +1 -0
  102. package/src/client/dist/pwa/assets/private.use-form-D8Yuc3aU.js +1 -0
  103. package/src/client/dist/pwa/assets/{project-color-Cc9yKGk3.js → project-color-Dur1z6Gn.js} +1 -1
  104. package/src/client/dist/pwa/assets/{python-BrgzhqrC.js → python-C6d5du9h.js} +1 -1
  105. package/src/client/dist/pwa/assets/{razor-g8NeeeQc.js → razor-Aek1yDc0.js} +1 -1
  106. package/src/client/dist/pwa/assets/{render-chat-markdown-CZyJL1-w.js → render-chat-markdown-B6sa44Xr.js} +2 -2
  107. package/src/client/dist/pwa/assets/skill-suite-prompts-DdoGRwhz.js +36 -0
  108. package/src/client/dist/pwa/assets/{tsMode-DAopv4hH.js → tsMode-rxNEyqyY.js} +1 -1
  109. package/src/client/dist/pwa/assets/{typescript-C2jZCVPF.js → typescript-DndxWYhC.js} +1 -1
  110. package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +1 -0
  111. package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +1 -0
  112. package/src/client/dist/pwa/assets/use-onboarding-BzSwN4xT.js +2 -0
  113. package/src/client/dist/pwa/assets/{vs-mk-rn9sP.js → vs-De8HKJ9r.js} +3 -3
  114. package/src/client/dist/pwa/assets/{workers-CP5iY9Kz.js → workers-DdNkyVm9.js} +1 -1
  115. package/src/client/dist/pwa/assets/{xml-D3pYNERH.js → xml-Cl1k1mQr.js} +1 -1
  116. package/src/client/dist/pwa/assets/{yaml-BTRPY5XP.js → yaml-C8G_k5TR.js} +1 -1
  117. package/src/client/dist/pwa/fonts/GEIST-LICENSE.txt +92 -0
  118. package/src/client/dist/pwa/fonts/Geist-Variable.woff2 +0 -0
  119. package/src/client/dist/pwa/fonts/GeistMono-Variable.woff2 +0 -0
  120. package/src/client/dist/pwa/index.html +7 -5
  121. package/src/client/dist/pwa/sw.js +1 -1
  122. package/src/mcp-server/kobo-tasks-server.ts +8 -7
  123. package/dist/server/utils/process-tracker.js +0 -49
  124. package/src/client/dist/pwa/assets/ActivityFeed-BrjeLlhi.css +0 -1
  125. package/src/client/dist/pwa/assets/ActivityFeed-WBBk7DgE.js +0 -8
  126. package/src/client/dist/pwa/assets/ChangelogPage-Cr5cW9Wx.js +0 -1
  127. package/src/client/dist/pwa/assets/ChangelogPage-DGhfnkax.css +0 -1
  128. package/src/client/dist/pwa/assets/ClosePopup-2KSSXX6P.js +0 -1
  129. package/src/client/dist/pwa/assets/CreatePage-Bl1Vjr27.css +0 -1
  130. package/src/client/dist/pwa/assets/CreatePage-DQk8TrUP.js +0 -2
  131. package/src/client/dist/pwa/assets/DiffViewer-C9xV8dh-.js +0 -8
  132. package/src/client/dist/pwa/assets/DiffViewer-CS3P9Ngi.css +0 -1
  133. package/src/client/dist/pwa/assets/DrawerToggleButton-CjM28eaw.js +0 -1
  134. package/src/client/dist/pwa/assets/HealthPage-BbLpLRjV.js +0 -1
  135. package/src/client/dist/pwa/assets/HealthPage-BqirO5le.css +0 -1
  136. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BCOjigVR.woff +0 -0
  137. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-Ocp2tNig.woff +0 -0
  138. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-6jEn9xEl.woff +0 -0
  139. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CCs8I9zJ.woff +0 -0
  140. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-BLaboPi0.woff +0 -0
  141. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-DHuK6FBl.woff +0 -0
  142. package/src/client/dist/pwa/assets/MainLayout-BUril7SK.js +0 -19
  143. package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +0 -1
  144. package/src/client/dist/pwa/assets/QBadge-DS9cpnyx.js +0 -1
  145. package/src/client/dist/pwa/assets/QBanner-tANb95XA.js +0 -1
  146. package/src/client/dist/pwa/assets/QBtn-DJGkBel8.js +0 -1
  147. package/src/client/dist/pwa/assets/QBtnToggle-D-Hsqmcb.js +0 -1
  148. package/src/client/dist/pwa/assets/QChip-iLXQ5Khf.js +0 -1
  149. package/src/client/dist/pwa/assets/QItemSection-B-5iFRBZ.js +0 -1
  150. package/src/client/dist/pwa/assets/QList-D-yd1vEv.js +0 -1
  151. package/src/client/dist/pwa/assets/QSelect-TFRaHbzG.js +0 -36
  152. package/src/client/dist/pwa/assets/QSeparator-C_2BuEw9.js +0 -1
  153. package/src/client/dist/pwa/assets/QSpace-BuMMeXnK.js +0 -1
  154. package/src/client/dist/pwa/assets/QSpinnerDots-C-tv95pD.js +0 -1
  155. package/src/client/dist/pwa/assets/SearchPage-CPM7L9_M.js +0 -1
  156. package/src/client/dist/pwa/assets/SearchPage-CngtAqls.css +0 -1
  157. package/src/client/dist/pwa/assets/SettingsPage-BRYycuYY.js +0 -16
  158. package/src/client/dist/pwa/assets/SettingsPage-D5BacmAr.css +0 -1
  159. package/src/client/dist/pwa/assets/TouchPan-79_u9-Fd.js +0 -1
  160. package/src/client/dist/pwa/assets/WorkspacePage-DB1n1bmg.js +0 -4
  161. package/src/client/dist/pwa/assets/WorkspacePage-hxEpXqBA.css +0 -1
  162. package/src/client/dist/pwa/assets/documents-BMvE9Q_U.js +0 -1
  163. package/src/client/dist/pwa/assets/engineFeatures-JkVb6hry.js +0 -1
  164. package/src/client/dist/pwa/assets/expand-template-D2yUa54D.css +0 -1
  165. package/src/client/dist/pwa/assets/expand-template-Dl4wUjF8.js +0 -1
  166. package/src/client/dist/pwa/assets/i18n-Dezgz2hU.js +0 -1
  167. package/src/client/dist/pwa/assets/index-B12jWMl8.js +0 -82
  168. package/src/client/dist/pwa/assets/index-CK1nk7qr.css +0 -1
  169. package/src/client/dist/pwa/assets/kobo-commands-DznaKlq6.js +0 -9
  170. package/src/client/dist/pwa/assets/layout-me0gwmBV.js +0 -1
  171. package/src/client/dist/pwa/assets/network-auth-BMTuTKHx.js +0 -1
  172. package/src/client/dist/pwa/assets/pending-diff-open-BG3IOnWw.js +0 -1
  173. package/src/client/dist/pwa/assets/use-checkbox-D-9LXk-S.js +0 -1
  174. package/src/client/dist/pwa/assets/use-is-mobile-DX2Fiee-.js +0 -1
  175. package/src/client/dist/pwa/assets/use-onboarding-BNo8mb65.js +0 -2
@@ -4,6 +4,10 @@ import { getAllPersistedSnapshots } from './usage/db.js';
4
4
  // ── State ──────────────────────────────────────────────────────────────────────
5
5
  /** Maps each WS client to the set of workspaceIds they are subscribed to */
6
6
  const clients = new Map();
7
+ /** Above this many bytes queued for a single client, we stop sending it events
8
+ * rather than growing the process heap on its behalf. It will catch up through
9
+ * `sync:request` on its next reconnect. */
10
+ const MAX_BUFFERED_BYTES = 1024 * 1024;
7
11
  let messageHandler = null;
8
12
  /** Register the handler that processes routed WS messages (e.g. chat:message, workspace:start). */
9
13
  export function setMessageHandler(handler) {
@@ -82,14 +86,28 @@ export function handleConnection(ws) {
82
86
  ws.send(JSON.stringify({ type: 'error', payload: { message: `Unknown message type: ${type}` } }));
83
87
  }
84
88
  });
85
- // Ping every 30s to keep the connection alive and detect stale clients
89
+ // Heartbeat with a verdict. Pinging without ever waiting for a pong detected
90
+ // nothing: half-open sockets stayed in `clients` for ever, and every emit
91
+ // kept serialising a message for a peer that will never read it.
92
+ let isAlive = true;
93
+ ws.on('pong', () => {
94
+ isAlive = true;
95
+ });
86
96
  const pingInterval = setInterval(() => {
87
- if (ws.readyState === ws.OPEN) {
88
- ws.ping();
97
+ if (ws.readyState !== 1 /* WebSocket.OPEN */) {
98
+ clearInterval(pingInterval);
99
+ clients.delete(ws);
100
+ return;
89
101
  }
90
- else {
102
+ if (!isAlive) {
103
+ console.warn('[ws] client missed the heartbeat — terminating the connection');
91
104
  clearInterval(pingInterval);
105
+ clients.delete(ws);
106
+ ws.terminate();
107
+ return;
92
108
  }
109
+ isAlive = false;
110
+ ws.ping();
93
111
  }, 30_000);
94
112
  ws.on('close', () => {
95
113
  clearInterval(pingInterval);
@@ -127,6 +145,13 @@ export function emit(workspaceId, type, payload, sessionId) {
127
145
  let emitSendErrorLogged = false;
128
146
  for (const [ws, subs] of clients) {
129
147
  if (subs.has(workspaceId) && ws.readyState === 1 /* WebSocket.OPEN */) {
148
+ if ((ws.bufferedAmount ?? 0) > MAX_BUFFERED_BYTES) {
149
+ if (!emitSendErrorLogged) {
150
+ console.warn(`[ws] client backlogged, dropping live events (workspace=${workspaceId}, type=${type})`);
151
+ emitSendErrorLogged = true;
152
+ }
153
+ continue;
154
+ }
130
155
  try {
131
156
  ws.send(message);
132
157
  }
@@ -152,6 +177,13 @@ export function emitEphemeral(workspaceId, type, payload) {
152
177
  let sendErrorLogged = false;
153
178
  for (const [ws, subs] of clients) {
154
179
  if (subs.has(workspaceId) && ws.readyState === 1 /* WebSocket.OPEN */) {
180
+ if ((ws.bufferedAmount ?? 0) > MAX_BUFFERED_BYTES) {
181
+ if (!sendErrorLogged) {
182
+ console.warn(`[ws] client backlogged, dropping ephemeral event (workspace=${workspaceId}, type=${type})`);
183
+ sendErrorLogged = true;
184
+ }
185
+ continue;
186
+ }
155
187
  try {
156
188
  ws.send(message);
157
189
  }
@@ -180,66 +212,100 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
180
212
  ws.send(JSON.stringify({ type: 'sync:empty', payload: { message: 'No subscriptions' } }));
181
213
  return;
182
214
  }
183
- const db = getDb();
184
- // Build a query with placeholders for all subscribed workspaces
185
- const placeholders = resolvedIds.map(() => '?').join(', ');
186
- let rows;
187
215
  // Initial window size: on a fresh connection (no lastEventId), we only
188
216
  // replay the most recent slice of history. The client fetches older
189
217
  // events on-demand via GET /api/workspaces/:id/events as the user scrolls
190
218
  // up. This keeps first-paint fast on long-lived workspaces with tens of
191
219
  // thousands of events without ever deleting anything from the DB.
192
220
  const INITIAL_WINDOW = 300;
193
- let mode = 'snapshot';
194
- if (lastEventId) {
195
- // Resume path: replay every event strictly after the cursor (delta
196
- // since last seen). If the cursor is stale/unknown, fall back to the
197
- // recent window rather than streaming the entire history.
198
- const lastRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(lastEventId);
221
+ /** Hard ceiling on one replay message. A tab left open overnight used to ask
222
+ * for every row since its cursor at once; the client simply asks again with
223
+ * the new cursor when `truncated` is set. */
224
+ const MAX_REPLAY_EVENTS = 2_000;
225
+ try {
226
+ const db = getDb();
227
+ let mode = 'snapshot';
228
+ let truncated = false;
229
+ const rows = [];
230
+ const lastRow = lastEventId
231
+ ? db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(lastEventId)
232
+ : undefined;
233
+ // One bounded query PER workspace. With `workspace_id IN (...)` SQLite
234
+ // materialised and sorted the whole history of every requested workspace
235
+ // before applying the limit — so the limit protected the message, not the
236
+ // server.
199
237
  if (lastRow) {
200
238
  mode = 'delta';
201
- rows = db
202
- .prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) AND rowid > ? ORDER BY rowid ASC`)
203
- .all(...resolvedIds, lastRow.rowid);
239
+ const statement = db.prepare('SELECT rowid AS rid, * FROM ws_events WHERE workspace_id = ? AND rowid > ? ORDER BY rowid ASC LIMIT ?');
240
+ for (const workspaceId of resolvedIds) {
241
+ const batch = statement.all(workspaceId, lastRow.rowid, MAX_REPLAY_EVENTS);
242
+ if (batch.length === MAX_REPLAY_EVENTS)
243
+ truncated = true;
244
+ rows.push(...batch);
245
+ }
204
246
  }
205
247
  else {
206
- rows = db
207
- .prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) ORDER BY rowid DESC LIMIT ?`)
208
- .all(...resolvedIds, INITIAL_WINDOW);
209
- rows.reverse();
248
+ const statement = db.prepare('SELECT rowid AS rid, * FROM ws_events WHERE workspace_id = ? ORDER BY rowid DESC LIMIT ?');
249
+ for (const workspaceId of resolvedIds) {
250
+ const batch = statement.all(workspaceId, INITIAL_WINDOW);
251
+ rows.push(...batch);
252
+ }
210
253
  }
254
+ rows.sort((a, b) => a.rid - b.rid);
255
+ if (rows.length > MAX_REPLAY_EVENTS) {
256
+ if (mode === 'delta') {
257
+ // Keep the OLDEST slice: the client's cursor then advances and its next
258
+ // sync:request picks up exactly where this message stopped.
259
+ rows.length = MAX_REPLAY_EVENTS;
260
+ }
261
+ else {
262
+ // Snapshot mode has no cursor to advance — the client is looking at a
263
+ // fresh window and wants what just happened. Dropping the head here
264
+ // (rather than the tail) is the difference between "the last few
265
+ // minutes" and "ancient history with no way to reach the present".
266
+ rows.splice(0, rows.length - MAX_REPLAY_EVENTS);
267
+ }
268
+ truncated = true;
269
+ }
270
+ const events = rows.map((row) => {
271
+ let parsedPayload;
272
+ try {
273
+ parsedPayload = JSON.parse(row.payload);
274
+ }
275
+ catch {
276
+ console.error('[ws] corrupt ws_events row, falling back to raw:', {
277
+ id: row.id,
278
+ workspace_id: row.workspace_id,
279
+ type: row.type,
280
+ });
281
+ parsedPayload = { raw: row.payload };
282
+ }
283
+ return {
284
+ id: row.id,
285
+ workspaceId: row.workspace_id,
286
+ type: row.type,
287
+ payload: parsedPayload,
288
+ sessionId: row.session_id ?? undefined,
289
+ createdAt: row.created_at,
290
+ replayable: true,
291
+ };
292
+ });
293
+ ws.send(JSON.stringify({ type: 'sync:response', payload: { events, mode, truncated } }));
211
294
  }
212
- else {
213
- // Fresh connect: most recent INITIAL_WINDOW events, in chronological order.
214
- rows = db
215
- .prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) ORDER BY rowid DESC LIMIT ?`)
216
- .all(...resolvedIds, INITIAL_WINDOW);
217
- rows.reverse();
218
- }
219
- const events = rows.map((row) => {
220
- let parsedPayload;
295
+ catch (err) {
296
+ // This handler runs inside ws's 'message' callback: an uncaught SQLite
297
+ // error here used to reach the process, not the client.
298
+ console.error('[ws] sync request failed:', err);
221
299
  try {
222
- parsedPayload = JSON.parse(row.payload);
300
+ ws.send(JSON.stringify({
301
+ type: 'sync:error',
302
+ payload: { message: err instanceof Error ? err.message : String(err) },
303
+ }));
223
304
  }
224
305
  catch {
225
- console.error('[ws] corrupt ws_events row, falling back to raw:', {
226
- id: row.id,
227
- workspace_id: row.workspace_id,
228
- type: row.type,
229
- });
230
- parsedPayload = { raw: row.payload };
306
+ /* the client is gone too — nothing left to tell it */
231
307
  }
232
- return {
233
- id: row.id,
234
- workspaceId: row.workspace_id,
235
- type: row.type,
236
- payload: parsedPayload,
237
- sessionId: row.session_id ?? undefined,
238
- createdAt: row.created_at,
239
- replayable: true,
240
- };
241
- });
242
- ws.send(JSON.stringify({ type: 'sync:response', payload: { events, mode } }));
308
+ }
243
309
  }
244
310
  // ── Utilities ──────────────────────────────────────────────────────────────────
245
311
  /**
@@ -10,8 +10,13 @@ import { emitEphemeral } from './websocket-service.js';
10
10
  /** Allowed status transitions per current status. Enforced by updateWorkspaceStatus. */
11
11
  const VALID_TRANSITIONS = {
12
12
  created: ['extracting', 'brainstorming', 'idle', 'error'],
13
- extracting: ['extracting', 'brainstorming', 'idle', 'error', 'awaiting-user'],
14
- brainstorming: ['executing', 'completed', 'idle', 'error', 'awaiting-user'],
13
+ // `quota` is reachable from every state that can be running a live session.
14
+ // It used to be missing here and on `brainstorming`, so an auto-loop
15
+ // workspace with a distinct brainstormModel silently lost its backoff: the
16
+ // transition threw, the error was swallowed, and the very next lines
17
+ // cancelled the timer that had just been armed.
18
+ extracting: ['extracting', 'brainstorming', 'idle', 'error', 'awaiting-user', 'quota'],
19
+ brainstorming: ['executing', 'completed', 'idle', 'error', 'awaiting-user', 'quota'],
15
20
  executing: ['completed', 'idle', 'error', 'quota', 'awaiting-user'],
16
21
  'awaiting-user': ['executing', 'brainstorming', 'extracting', 'idle', 'error', 'completed', 'quota'],
17
22
  completed: ['idle', 'executing'],
@@ -840,3 +845,50 @@ export function deleteSession(sessionId, workspaceId) {
840
845
  transaction();
841
846
  return true;
842
847
  }
848
+ /**
849
+ * Recompute one session's persisted metrics from the events that remain, on an
850
+ * explicit database handle.
851
+ *
852
+ * Until v36 an `AFTER DELETE ... FOR EACH ROW` trigger did this per deleted
853
+ * row, re-aggregating the whole session every time — 79.7 s for 20 000 events,
854
+ * with the SQLite write lock held throughout. The trigger is gone; every code
855
+ * path that deletes `agent:event` rows while KEEPING the session calls this
856
+ * once, at the end of its transaction.
857
+ */
858
+ export function recomputeSessionMetricsOn(db, workspaceId, sessionId) {
859
+ db.prepare('DELETE FROM session_event_metrics WHERE workspace_id = ? AND session_id = ?').run(workspaceId, sessionId);
860
+ db.prepare(`INSERT INTO session_event_metrics (
861
+ workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
862
+ )
863
+ SELECT
864
+ e.workspace_id,
865
+ e.session_id,
866
+ SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END),
867
+ SUM(CASE
868
+ WHEN json_extract(e.payload, '$.kind') = 'error'
869
+ OR (json_extract(e.payload, '$.kind') = 'tool:result'
870
+ AND json_extract(e.payload, '$.isError') = 1)
871
+ THEN 1 ELSE 0
872
+ END),
873
+ MAX(CASE
874
+ WHEN json_extract(e.payload, '$.kind') = 'usage'
875
+ AND json_type(e.payload, '$.inputTokens') IN ('integer', 'real')
876
+ THEN CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER) ELSE 0
877
+ END),
878
+ MAX(CASE
879
+ WHEN json_extract(e.payload, '$.kind') = 'usage'
880
+ AND json_type(e.payload, '$.outputTokens') IN ('integer', 'real')
881
+ THEN CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER) ELSE 0
882
+ END)
883
+ FROM ws_events e
884
+ JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
885
+ WHERE e.workspace_id = ?
886
+ AND e.session_id = ?
887
+ AND e.type = 'agent:event'
888
+ AND json_valid(e.payload)
889
+ GROUP BY e.workspace_id, e.session_id`).run(workspaceId, sessionId);
890
+ }
891
+ /** Same, on the singleton connection. */
892
+ export function recomputeSessionMetrics(workspaceId, sessionId) {
893
+ recomputeSessionMetricsOn(getDb(), workspaceId, sessionId);
894
+ }
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import { logError, logWarn } from '../utils/logger.js';
2
3
  import * as agentManager from './agent/orchestrator.js';
3
4
  import * as devServerService from './dev-server-service.js';
4
5
  import { getForgeProvider } from './forge/registry.js';
@@ -16,19 +17,21 @@ export async function purgeWorktree(workspaceId) {
16
17
  if (!workspace.worktreeOwned)
17
18
  return { outcome: 'worktree-not-owned', warnings: [] };
18
19
  const warnings = [];
20
+ // `removeWorktree` runs later in this same function — the agent must be dead
21
+ // by then, not merely asked to stop.
19
22
  try {
20
- agentManager.stopAgent(workspaceId);
23
+ await agentManager.stopAgentAndWait(workspaceId);
21
24
  }
22
25
  catch (err) {
23
26
  const msg = err instanceof Error ? err.message : String(err);
24
- console.error(`[purge] stopAgent failed for '${workspace.name}':`, msg);
27
+ logError('purge', `stopAgent failed for '${workspace.name}'`, { workspaceId: workspace.id, error: msg });
25
28
  }
26
29
  try {
27
30
  await devServerService.stopDevServer(workspaceId);
28
31
  }
29
32
  catch (err) {
30
33
  const msg = err instanceof Error ? err.message : String(err);
31
- console.error(`[purge] stopDevServer failed for '${workspace.name}':`, msg);
34
+ logError('purge', `stopDevServer failed for '${workspace.name}'`, { workspaceId: workspace.id, error: msg });
32
35
  }
33
36
  try {
34
37
  destroyTerminal(workspaceId);
@@ -49,13 +52,21 @@ export async function purgeWorktree(workspaceId) {
49
52
  }
50
53
  if (fs.existsSync(workspace.worktreePath)) {
51
54
  try {
52
- removeWorktree(workspace.projectPath, workspace.worktreePath);
55
+ await removeWorktree(workspace.projectPath, workspace.worktreePath);
53
56
  }
54
57
  catch (err) {
55
58
  const msg = err instanceof Error ? err.message : String(err);
56
59
  warnings.push(buildRemovalFailureMessage(workspace.worktreePath, workspace.projectPath, msg));
57
60
  }
58
61
  }
62
+ // `worktree_purged_at` is not cosmetic: it drives the "restore" UI and the
63
+ // PR watcher's auto-restore probe, which tests whether the folder reappeared.
64
+ // Setting it on a directory that never left puts both in contradiction with
65
+ // the disk — and told the user the space was reclaimed when it wasn't.
66
+ if (fs.existsSync(workspace.worktreePath)) {
67
+ emitEphemeral(workspaceId, 'workspace:worktree-purge-failed', { workspaceId, warnings });
68
+ return { outcome: 'removal-failed', warnings };
69
+ }
59
70
  try {
60
71
  markWorktreePurged(workspaceId, restoreData);
61
72
  }
@@ -101,7 +112,10 @@ async function captureRestoreData(workspace) {
101
112
  }
102
113
  }
103
114
  catch (err) {
104
- console.warn(`[purge] PR lookup for restore data failed:`, err instanceof Error ? err.message : err);
115
+ logWarn('purge', 'PR lookup for restore data failed', {
116
+ workspaceId: workspace.id,
117
+ error: err instanceof Error ? err.message : String(err),
118
+ });
105
119
  }
106
120
  }
107
121
  return {
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { isGitBranchExistsError } from '../utils/git-ops.js';
5
+ import { withGitRepoLock } from '../utils/git-repo-lock.js';
5
6
  import { resolveWorkspaceWorktreePath, resolveWorktreesRoot } from '../utils/worktree-paths.js';
6
7
  function git(repoPath, args) {
7
8
  return execFileSync('git', args, {
@@ -93,6 +94,10 @@ export function createWorktree(projectPath, branchName, baseRef, worktreesPath,
93
94
  }
94
95
  const worktreePath = resolveWorkspaceWorktreePath(projectPath, branchName, worktreesPath, projectSlug);
95
96
  const base = baseRef.startsWith('origin/') ? 'origin' : 'local';
97
+ // Tracks which of the two git commands below actually ran. Callers use it to
98
+ // decide whether a rollback may delete the branch: deleting one we merely
99
+ // attached to would destroy commits Kobo never created.
100
+ let branchCreated = true;
96
101
  try {
97
102
  git(projectPath, ['worktree', 'add', '-b', branchName, worktreePath, baseRef]);
98
103
  }
@@ -101,13 +106,33 @@ export function createWorktree(projectPath, branchName, baseRef, worktreesPath,
101
106
  // If branch already exists, add worktree without creating the branch
102
107
  if (isGitBranchExistsError(message)) {
103
108
  git(projectPath, ['worktree', 'add', worktreePath, branchName]);
109
+ branchCreated = false;
104
110
  }
105
111
  else {
106
112
  throw new Error(`Failed to create worktree for branch '${branchName}': ${message}`);
107
113
  }
108
114
  }
109
115
  addToExclude(projectPath, worktreePath);
110
- return { worktreePath, base };
116
+ return { worktreePath, base, branchCreated };
117
+ }
118
+ /** `git worktree remove` drops the administrative entry even when it fails to
119
+ * delete the directory, and the prune only ever ran in the Docker fallback.
120
+ * Prune the stale metadata, then refuse to report success while the folder is
121
+ * still there — the purge feature marked workspaces as purged on that false
122
+ * success, telling the user the disk space was reclaimed when it wasn't.
123
+ *
124
+ * Callers hold the repository lock: acquiring it again here would deadlock on
125
+ * the same key, since the chain is strictly sequential per common git dir. */
126
+ function assertWorktreeGone(projectPath, worktreePath) {
127
+ try {
128
+ git(projectPath, ['worktree', 'prune']);
129
+ }
130
+ catch (err) {
131
+ console.warn(`[worktree] prune after removing '${worktreePath}' failed:`, err instanceof Error ? err.message : err);
132
+ }
133
+ if (fs.existsSync(worktreePath)) {
134
+ throw new Error(`Failed to remove worktree '${worktreePath}': the directory is still on disk after removal`);
135
+ }
111
136
  }
112
137
  /** Remove a git worktree and clean up the .git/info/exclude entry.
113
138
  *
@@ -116,6 +141,16 @@ export function createWorktree(projectPath, branchName, baseRef, worktreesPath,
116
141
  * ownership with a throwaway container (`chown -R <uid>:<gid>`) and retry once.
117
142
  * Otherwise rethrow so the caller's recovery toast (sudo rm -rf …) fires. */
118
143
  export function removeWorktree(projectPath, worktreePath) {
144
+ // `git worktree remove` and `git worktree prune` both rewrite the worktree
145
+ // administrative files of the COMMON git dir — the very state a concurrent
146
+ // source-branch change (fetch / reset / cherry-pick) is manipulating under
147
+ // the same lock. The whole removal sequence is held, Docker chown included:
148
+ // it sits between the `remove` and the `prune` that repairs its metadata, so
149
+ // releasing in the middle would expose a half-removed repository.
150
+ return withGitRepoLock(projectPath, () => removeWorktreeLocked(projectPath, worktreePath));
151
+ }
152
+ /** The actual removal. Always called with the repository lock held. */
153
+ function removeWorktreeLocked(projectPath, worktreePath) {
119
154
  try {
120
155
  git(projectPath, ['worktree', 'remove', worktreePath, '--force']);
121
156
  }
@@ -136,6 +171,7 @@ export function removeWorktree(projectPath, worktreePath) {
136
171
  git(projectPath, ['worktree', 'prune']);
137
172
  console.log(`[worktree] Docker cleanup succeeded; removed '${worktreePath}'`);
138
173
  removeFromExclude(projectPath, worktreePath);
174
+ assertWorktreeGone(projectPath, worktreePath);
139
175
  return;
140
176
  }
141
177
  catch (retryErr) {
@@ -147,6 +183,7 @@ export function removeWorktree(projectPath, worktreePath) {
147
183
  throw new Error(`Failed to remove worktree '${worktreePath}': ${message}`);
148
184
  }
149
185
  removeFromExclude(projectPath, worktreePath);
186
+ assertWorktreeGone(projectPath, worktreePath);
150
187
  }
151
188
  /** List all git worktrees for a repository by parsing `git worktree list --porcelain`. */
152
189
  export function listWorktrees(projectPath) {
@@ -0,0 +1,133 @@
1
+ import { recomputeSessionMetricsOn } from './workspace-service.js';
2
+ /** Rows deleted per transaction. Small enough that the SQLite write lock is
3
+ * never held for more than a few milliseconds at a time — the whole point of
4
+ * doing this AFTER the quadratic AFTER DELETE trigger was removed. */
5
+ const BATCH_SIZE = 5_000;
6
+ /** Free-page ratio above which a full VACUUM is worth its cost. Production was
7
+ * measured at 620 free pages out of 656 (95 %) for zero remaining rows. */
8
+ const VACUUM_FREE_PAGE_RATIO = 0.25;
9
+ /** Retention is OPT-IN: absent or invalid settings mean "delete nothing". */
10
+ const DISABLED = 0;
11
+ /** Anything that is not a non-negative integer falls back to "disabled": a bad
12
+ * value must never be read as a licence to delete. */
13
+ function normalize(value) {
14
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : DISABLED;
15
+ }
16
+ /** Read the retention window from the global settings. */
17
+ export function resolveRetentionConfig(global) {
18
+ return {
19
+ retentionDays: normalize(global.wsEventsRetentionDays),
20
+ keepPerWorkspace: normalize(global.wsEventsKeepPerWorkspace),
21
+ };
22
+ }
23
+ /** Shared candidate selection: rows older than the cutoff that fall outside the
24
+ * per-workspace tail. Used both to count (preview) and to delete. */
25
+ const CANDIDATE_CTE = `
26
+ WITH ranked AS (
27
+ SELECT rowid AS rid,
28
+ workspace_id,
29
+ session_id,
30
+ created_at,
31
+ ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY rowid DESC) AS rn
32
+ FROM ws_events
33
+ )`;
34
+ function cutoffFor(config, nowMs) {
35
+ return new Date(nowMs - config.retentionDays * 24 * 60 * 60 * 1000).toISOString();
36
+ }
37
+ /**
38
+ * How many events `config` would delete right now. Deletes nothing — this backs
39
+ * the confirmation dialog, so the user is told the real volume before agreeing
40
+ * to destroy it.
41
+ */
42
+ export function countPrunableWsEvents(db, config, nowMs = Date.now()) {
43
+ if (config.retentionDays <= 0)
44
+ return 0;
45
+ const row = db
46
+ .prepare(`${CANDIDATE_CTE}
47
+ SELECT COUNT(*) AS c FROM ranked WHERE created_at < ? AND rn > ?`)
48
+ .get(cutoffFor(config, nowMs), config.keepPerWorkspace);
49
+ return row.c;
50
+ }
51
+ /**
52
+ * Delete `ws_events` rows older than the retention window, batch by batch, then
53
+ * recompute the metrics of the sessions touched and reclaim the freed pages.
54
+ *
55
+ * Deletes agent output ONLY: workspaces, tasks, sessions, branches and worktrees
56
+ * are never touched.
57
+ *
58
+ * Order matters: this must never run while the removed `AFTER DELETE` trigger
59
+ * is present — it re-aggregated the whole session per deleted row and held the
60
+ * write lock long enough to make every concurrent WebSocket emit time out and
61
+ * lose its event.
62
+ */
63
+ export function pruneWsEvents(db, config, nowMs = Date.now()) {
64
+ const freePagesBefore = db.pragma('freelist_count', { simple: true }) ?? 0;
65
+ const result = {
66
+ deleted: 0,
67
+ sessionsRecomputed: 0,
68
+ vacuumed: false,
69
+ freePagesBefore,
70
+ freePagesAfter: freePagesBefore,
71
+ };
72
+ if (config.retentionDays <= 0)
73
+ return result;
74
+ const cutoff = cutoffFor(config, nowMs);
75
+ const selectBatch = db.prepare(`${CANDIDATE_CTE}
76
+ SELECT rid, workspace_id, session_id
77
+ FROM ranked
78
+ WHERE created_at < ? AND rn > ?
79
+ ORDER BY rid ASC
80
+ LIMIT ?`);
81
+ const deleteOne = db.prepare('DELETE FROM ws_events WHERE rowid = ?');
82
+ const touched = new Set();
83
+ for (;;) {
84
+ const batch = selectBatch.all(cutoff, config.keepPerWorkspace, BATCH_SIZE);
85
+ if (batch.length === 0)
86
+ break;
87
+ db.transaction(() => {
88
+ for (const row of batch) {
89
+ deleteOne.run(row.rid);
90
+ if (row.session_id)
91
+ touched.add(`${row.workspace_id}::${row.session_id}`);
92
+ }
93
+ })();
94
+ result.deleted += batch.length;
95
+ if (batch.length < BATCH_SIZE)
96
+ break;
97
+ }
98
+ // Once per session, at the very end — never per deleted row.
99
+ for (const key of touched) {
100
+ const separator = key.indexOf('::');
101
+ const workspaceId = key.slice(0, separator);
102
+ const sessionId = key.slice(separator + 2);
103
+ try {
104
+ recomputeSessionMetricsOn(db, workspaceId, sessionId);
105
+ result.sessionsRecomputed += 1;
106
+ }
107
+ catch (err) {
108
+ console.error(`[retention] metric recompute failed (workspace=${workspaceId}, session=${sessionId}):`, err);
109
+ }
110
+ }
111
+ if (result.deleted === 0)
112
+ return result;
113
+ // Truncate the WAL, then compact only when it is actually worth it.
114
+ try {
115
+ db.pragma('wal_checkpoint(TRUNCATE)');
116
+ }
117
+ catch (err) {
118
+ console.error('[retention] wal_checkpoint failed:', err);
119
+ }
120
+ const freelist = db.pragma('freelist_count', { simple: true }) ?? 0;
121
+ const pageCount = db.pragma('page_count', { simple: true }) ?? 0;
122
+ if (pageCount > 0 && freelist / pageCount >= VACUUM_FREE_PAGE_RATIO) {
123
+ try {
124
+ db.exec('VACUUM');
125
+ result.vacuumed = true;
126
+ }
127
+ catch (err) {
128
+ console.error('[retention] VACUUM failed:', err);
129
+ }
130
+ }
131
+ result.freePagesAfter = db.pragma('freelist_count', { simple: true }) ?? 0;
132
+ return result;
133
+ }