@loicngr/kobo 2.1.2 → 2.1.5

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 (200) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/CONFIGURATION.md +11 -3
  3. package/dist/server/index.js +24 -91
  4. package/dist/server/routes/git.js +6 -7
  5. package/dist/server/routes/settings.js +21 -11
  6. package/dist/server/routes/workspaces.js +82 -41
  7. package/dist/server/services/agent/engines/claude-code/engine.js +87 -20
  8. package/dist/server/services/agent/engines/claude-code/event-mapper.js +88 -22
  9. package/dist/server/services/agent/engines/claude-code/options-builder.js +1 -0
  10. package/dist/server/services/agent/engines/codex/engine.js +27 -5
  11. package/dist/server/services/agent/engines/codex/options-builder.js +1 -0
  12. package/dist/server/services/agent/integration-mcp.js +4 -3
  13. package/dist/server/services/agent/orchestrator.js +36 -1
  14. package/dist/server/services/agent/session-controller.js +12 -0
  15. package/dist/server/services/engine-handoff-service.js +120 -24
  16. package/dist/server/services/pr-checkout-service.js +90 -53
  17. package/dist/server/services/pr-watcher-service.js +13 -7
  18. package/dist/server/services/retention/worker.js +40 -0
  19. package/dist/server/services/review-service.js +8 -4
  20. package/dist/server/services/session-event-metrics.js +43 -0
  21. package/dist/server/services/session-handoff-service.js +6 -4
  22. package/dist/server/services/templates-service.js +67 -49
  23. package/dist/server/services/terminal-service.js +45 -14
  24. package/dist/server/services/terminal-websocket-service.js +143 -0
  25. package/dist/server/services/wakeup-service.js +23 -21
  26. package/dist/server/services/workspace-service.js +3 -44
  27. package/dist/server/services/workspace-template-service.js +16 -18
  28. package/dist/server/services/worktree-purge-service.js +1 -4
  29. package/dist/server/services/ws-events-retention-service.js +197 -104
  30. package/dist/server/services/ws-events-retention-worker-service.js +112 -0
  31. package/dist/server/utils/atomic-json-file.js +23 -0
  32. package/dist/server/utils/git-ops.js +262 -60
  33. package/dist/shared/codex-models.js +20 -3
  34. package/dist/shared/models.js +12 -0
  35. package/package.json +8 -8
  36. package/src/client/dist/pwa/assets/{ActivityFeed-DCmiJP-z.css → ActivityFeed-B4lTKMVE.css} +1 -1
  37. package/src/client/dist/pwa/assets/ActivityFeed-DXWXioW9.js +8 -0
  38. package/src/client/dist/pwa/assets/ChangelogPage-BL5Jh8-v.js +1 -0
  39. package/src/client/dist/pwa/assets/ChangelogPage-DVyx9ZjV.css +1 -0
  40. package/src/client/dist/pwa/assets/{ClosePopup-BikuzYle.js → ClosePopup-C1a82pL9.js} +1 -1
  41. package/src/client/dist/pwa/assets/ComparisonPanel-Bv2SZrVZ.js +2 -0
  42. package/src/client/dist/pwa/assets/ComparisonPanel-ByVbqYCs.css +1 -0
  43. package/src/client/dist/pwa/assets/CreatePage-CMyl2j8Z.css +1 -0
  44. package/src/client/dist/pwa/assets/CreatePage-d43ITOC1.js +2 -0
  45. package/src/client/dist/pwa/assets/DashboardPage-BEXXRguE.js +1 -0
  46. package/src/client/dist/pwa/assets/DashboardPage-CtaHaVdf.css +1 -0
  47. package/src/client/dist/pwa/assets/DiffViewer-B92w_jd7.js +8 -0
  48. package/src/client/dist/pwa/assets/DocumentsPanel-C9JXyqHg.js +1 -0
  49. package/src/client/dist/pwa/assets/{DrawerToggleButton-Dfvkaro0.js → DrawerToggleButton-Btx8QwHo.js} +1 -1
  50. package/src/client/dist/pwa/assets/{GitPanel-C4wOcZV2.css → GitPanel-C3blk_Pd.css} +1 -1
  51. package/src/client/dist/pwa/assets/GitPanel-Cgz9crLN.js +3 -0
  52. package/src/client/dist/pwa/assets/HealthPage-CDhgJQkL.js +1 -0
  53. package/src/client/dist/pwa/assets/HealthPage-DT8FGrKA.css +1 -0
  54. package/src/client/dist/pwa/assets/MainLayout-DTTtj9sq.js +2 -0
  55. package/src/client/dist/pwa/assets/{MainLayout-gHENJEFQ.css → MainLayout-knNNtkre.css} +1 -1
  56. package/src/client/dist/pwa/assets/QBadge-rGILsUYl.js +1 -0
  57. package/src/client/dist/pwa/assets/QBanner-7EWRdc4U.js +1 -0
  58. package/src/client/dist/pwa/assets/QBtn-b_baQtAt.js +1 -0
  59. package/src/client/dist/pwa/assets/QBtnDropdown-vjat4GZl.js +1 -0
  60. package/src/client/dist/pwa/assets/QBtnGroup-CUmAgBV8.js +1 -0
  61. package/src/client/dist/pwa/assets/QBtnToggle-COL46j6W.js +1 -0
  62. package/src/client/dist/pwa/assets/QChip-CG1kWhFm.js +1 -0
  63. package/src/client/dist/pwa/assets/QExpansionItem-BQuy89vv.js +1 -0
  64. package/src/client/dist/pwa/assets/QItemSection-BrVklYfM.js +1 -0
  65. package/src/client/dist/pwa/assets/QList-BVX-b8nc.js +1 -0
  66. package/src/client/dist/pwa/assets/QMarkupTable-B8_FKLvo.js +1 -0
  67. package/src/client/dist/pwa/assets/QPage-BCDXjfVj.js +1 -0
  68. package/src/client/dist/pwa/assets/QSelect-DBW_TZri.js +1 -0
  69. package/src/client/dist/pwa/assets/QSpinnerDots-BsKmccpX.js +1 -0
  70. package/src/client/dist/pwa/assets/QTooltip-DzYbguTR.js +1 -0
  71. package/src/client/dist/pwa/assets/SchedulePanel-BX6iKh6S.js +1 -0
  72. package/src/client/dist/pwa/assets/SchedulePanel-CBM_GpS3.css +1 -0
  73. package/src/client/dist/pwa/assets/SearchPage-_In2gH2P.js +1 -0
  74. package/src/client/dist/pwa/assets/SettingsPage-BqkNYzC5.js +9 -0
  75. package/src/client/dist/pwa/assets/{SettingsPage-BYdLXJEJ.css → SettingsPage-CpykRKq2.css} +1 -1
  76. package/src/client/dist/pwa/assets/SplitWorkspacePage-DRoXbSRP.js +1 -0
  77. package/src/client/dist/pwa/assets/{TerminalPanel-B9LDe7uG.js → TerminalPanel-CfqiuSuk.js} +2 -2
  78. package/src/client/dist/pwa/assets/{TerminalPanel-DbVZk0vD.css → TerminalPanel-Dz9EE-Ph.css} +1 -1
  79. package/src/client/dist/pwa/assets/TouchPan-DcOCRf7x.js +1 -0
  80. package/src/client/dist/pwa/assets/{TourReplayButton-Dtv_UDaY.js → TourReplayButton-gp7m6tSV.js} +1 -1
  81. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DSw8Upcp.css +1 -0
  82. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-IcoMGkJh.js +1 -0
  83. package/src/client/dist/pwa/assets/{WorkspacePage--5HsnMQ1.css → WorkspacePage-BnW-gp3u.css} +1 -1
  84. package/src/client/dist/pwa/assets/WorkspacePage-CR2xAzW_.js +4 -0
  85. package/src/client/dist/pwa/assets/{browser-B15GGwtW.js → browser-BveHkbe-.js} +1 -1
  86. package/src/client/dist/pwa/assets/build-path-tree-Dtp7rlxS.js +1 -0
  87. package/src/client/dist/pwa/assets/custom-sounds-CBoWzJ85.js +1 -0
  88. package/src/client/dist/pwa/assets/de-BJsWkZro.js +17 -0
  89. package/src/client/dist/pwa/assets/defineProperty-BbfpZ9Tg.js +1 -0
  90. package/src/client/dist/pwa/assets/documents-sb2sQ4JC.js +1 -0
  91. package/src/client/dist/pwa/assets/{editor.api-DhaLhNl-.js → editor.api-CbkPxoJf.js} +1 -1
  92. package/src/client/dist/pwa/assets/efforts-BCli1Unj.js +9 -0
  93. package/src/client/dist/pwa/assets/es-tNYw7itB.js +17 -0
  94. package/src/client/dist/pwa/assets/expand-template-CfaEhb2f.js +1 -0
  95. package/src/client/dist/pwa/assets/{format-2JisC3s1.js → format-BRl2DUlm.js} +7 -7
  96. package/src/client/dist/pwa/assets/formatters-C5TjPSR_.js +1 -0
  97. package/src/client/dist/pwa/assets/fr-B5sFski4.js +17 -0
  98. package/src/client/dist/pwa/assets/{freemarker2-PYrDqU04.js → freemarker2-CDmvOQyn.js} +1 -1
  99. package/src/client/dist/pwa/assets/{handlebars-BuiY0fFf.js → handlebars-Bx_w8mx1.js} +1 -1
  100. package/src/client/dist/pwa/assets/{html-Du3cjdug.js → html-DVRfiRkS.js} +1 -1
  101. package/src/client/dist/pwa/assets/i18n-Bm-L4nW3.js +1 -0
  102. package/src/client/dist/pwa/assets/i18n-D21JKsVt.js +19 -0
  103. package/src/client/dist/pwa/assets/index-C7vFf6p2.js +2 -0
  104. package/src/client/dist/pwa/assets/index-C8zAZ-k-.css +1 -0
  105. package/src/client/dist/pwa/assets/it-Bc2FncM9.js +17 -0
  106. package/src/client/dist/pwa/assets/{javascript-Bb48hPli.js → javascript-CPvttU5J.js} +1 -1
  107. package/src/client/dist/pwa/assets/latest-request-CN4-B22z.js +1 -0
  108. package/src/client/dist/pwa/assets/{liquid-Be77iQe3.js → liquid-CI0gQddb.js} +1 -1
  109. package/src/client/dist/pwa/assets/{mdx-CfohNJja.js → mdx-BveBwYJH.js} +1 -1
  110. package/src/client/dist/pwa/assets/{monaco-contributions-CyM4wsIg.js → monaco-contributions-CHXFh4P2.js} +1 -1
  111. package/src/client/dist/pwa/assets/monaco.contribution-DOK8t_9B.js +2 -0
  112. package/src/client/dist/pwa/assets/network-auth-CItnNZDh.js +1 -0
  113. package/src/client/dist/pwa/assets/{notify-theme-DvvaSyg1.js → notify-theme-BO6r1Rd7.js} +1 -1
  114. package/src/client/dist/pwa/assets/pending-diff-open-DpS4iyDM.js +1 -0
  115. package/src/client/dist/pwa/assets/{permissionModes-B-ciuUbz.js → permissionModes-Cc5VaOdC.js} +1 -1
  116. package/src/client/dist/pwa/assets/preload-helper-BZ1Pz5am.js +1 -0
  117. package/src/client/dist/pwa/assets/{private.use-form-Bk8ti264.js → private.use-form-vxvbh_VN.js} +1 -1
  118. package/src/client/dist/pwa/assets/{project-color-Be0t12-n.js → project-color-Bnv02P9D.js} +1 -1
  119. package/src/client/dist/pwa/assets/{python-BNemmtx8.js → python-BesLCxpi.js} +1 -1
  120. package/src/client/dist/pwa/assets/{razor-3NM8iFVf.js → razor-Dv1PMxuY.js} +1 -1
  121. package/src/client/dist/pwa/assets/{reactivity.esm-bundler-Bo9anJjg.js → reactivity.esm-bundler-tuEvQ47f.js} +1 -1
  122. package/src/client/dist/pwa/assets/{render-chat-markdown-BWJNIDv3.js → render-chat-markdown-CafzCcmu.js} +1 -1
  123. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-DOiZcasU.js +1 -0
  124. package/src/client/dist/pwa/assets/{typescript-DwyZ4CF7.js → typescript-26L2Xsu9.js} +1 -1
  125. package/src/client/dist/pwa/assets/use-checkbox-BLeif5-t.js +1 -0
  126. package/src/client/dist/pwa/assets/use-id-b4A2I8LB.js +1 -0
  127. package/src/client/dist/pwa/assets/use-is-mobile-DLIZxEo9.js +1 -0
  128. package/src/client/dist/pwa/assets/use-search-index-status-Mv3pIO6p.js +1 -0
  129. package/src/client/dist/pwa/assets/{use-slide-transition-CiaDm-tR.js → use-slide-transition-C5eDGOmu.js} +1 -1
  130. package/src/client/dist/pwa/assets/{use-tours-CjASQcG5.js → use-tours-B5uIqxRS.js} +2 -2
  131. package/src/client/dist/pwa/assets/use-worktree-restore-BBChALUM.js +1 -0
  132. package/src/client/dist/pwa/assets/workspace-templates-CDY-MiH2.js +1 -0
  133. package/src/client/dist/pwa/assets/{xml-B1UujXdS.js → xml-CkRojTcc.js} +1 -1
  134. package/src/client/dist/pwa/assets/{yaml-mHN8kNgU.js → yaml-DKkQHwew.js} +1 -1
  135. package/src/client/dist/pwa/index.html +12 -9
  136. package/src/client/dist/pwa/sw.js +1 -1
  137. package/src/client/dist/pwa/assets/ActivityFeed-CO5rOaa5.js +0 -8
  138. package/src/client/dist/pwa/assets/ChangelogPage-DKJkFiU_.css +0 -1
  139. package/src/client/dist/pwa/assets/ChangelogPage-DNRcPRtW.js +0 -1
  140. package/src/client/dist/pwa/assets/ComparisonPanel-CRRaDs2D.css +0 -1
  141. package/src/client/dist/pwa/assets/ComparisonPanel-DZoDqgJI.js +0 -2
  142. package/src/client/dist/pwa/assets/CreatePage-B9C9J9Pw.css +0 -1
  143. package/src/client/dist/pwa/assets/CreatePage-wNH35uTG.js +0 -2
  144. package/src/client/dist/pwa/assets/DashboardPage-DUyy4SzS.js +0 -1
  145. package/src/client/dist/pwa/assets/DashboardPage-XiCj24i2.css +0 -1
  146. package/src/client/dist/pwa/assets/DiffViewer-CZ5QwXqi.js +0 -8
  147. package/src/client/dist/pwa/assets/DocumentsPanel-sBatgKA-.js +0 -1
  148. package/src/client/dist/pwa/assets/GitPanel-B8e1olfm.js +0 -3
  149. package/src/client/dist/pwa/assets/HealthPage-BwXYpczh.js +0 -1
  150. package/src/client/dist/pwa/assets/HealthPage-CFZ-tdKK.css +0 -1
  151. package/src/client/dist/pwa/assets/MainLayout-DFXIS_wv.js +0 -2
  152. package/src/client/dist/pwa/assets/QBadge-CQF1smL-.js +0 -1
  153. package/src/client/dist/pwa/assets/QBanner-bdDA-A5g.js +0 -1
  154. package/src/client/dist/pwa/assets/QBtn-CcQQrYA_.js +0 -1
  155. package/src/client/dist/pwa/assets/QBtnDropdown-C-1EhO9J.js +0 -1
  156. package/src/client/dist/pwa/assets/QBtnGroup-wd5lKRXu.js +0 -1
  157. package/src/client/dist/pwa/assets/QBtnToggle-BnC8rTql.js +0 -1
  158. package/src/client/dist/pwa/assets/QChip-D75nKi-L.js +0 -1
  159. package/src/client/dist/pwa/assets/QExpansionItem-RpMPvfxX.js +0 -1
  160. package/src/client/dist/pwa/assets/QItemSection-Bt9H35JJ.js +0 -1
  161. package/src/client/dist/pwa/assets/QList-D5msNbSZ.js +0 -1
  162. package/src/client/dist/pwa/assets/QMarkupTable-D3REW6RC.js +0 -1
  163. package/src/client/dist/pwa/assets/QPage-HAaeQ-cv.js +0 -1
  164. package/src/client/dist/pwa/assets/QSelect-Dpru_T81.js +0 -1
  165. package/src/client/dist/pwa/assets/QSpinnerDots-DDsPDUH9.js +0 -1
  166. package/src/client/dist/pwa/assets/QTooltip-C31vXtse.js +0 -1
  167. package/src/client/dist/pwa/assets/SchedulePanel-BYKSFoFj.css +0 -1
  168. package/src/client/dist/pwa/assets/SchedulePanel-D6LTiRFg.js +0 -1
  169. package/src/client/dist/pwa/assets/SearchPage-Dj487BdP.js +0 -1
  170. package/src/client/dist/pwa/assets/SettingsPage-CJmgFiAa.js +0 -9
  171. package/src/client/dist/pwa/assets/SplitWorkspacePage-BGSDgGIO.js +0 -1
  172. package/src/client/dist/pwa/assets/TouchPan-DJqqxDBF.js +0 -1
  173. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +0 -1
  174. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-VlJCIkwU.js +0 -1
  175. package/src/client/dist/pwa/assets/WorkspacePage-B_SlX8YB.js +0 -4
  176. package/src/client/dist/pwa/assets/build-path-tree-CU6uXunR.js +0 -1
  177. package/src/client/dist/pwa/assets/custom-sounds-CDJsgChY.js +0 -1
  178. package/src/client/dist/pwa/assets/de-B_Y4EArT.js +0 -17
  179. package/src/client/dist/pwa/assets/documents-Cf6nmu8Z.js +0 -1
  180. package/src/client/dist/pwa/assets/efforts-DoYZi0AZ.js +0 -9
  181. package/src/client/dist/pwa/assets/engineFeatures-C9XiCfvv.js +0 -1
  182. package/src/client/dist/pwa/assets/es-DtsTTShP.js +0 -17
  183. package/src/client/dist/pwa/assets/expand-template-LDLM-6iN.js +0 -1
  184. package/src/client/dist/pwa/assets/formatters-ZVNs2VOl.js +0 -1
  185. package/src/client/dist/pwa/assets/fr-C82MIVrA.js +0 -17
  186. package/src/client/dist/pwa/assets/i18n-BumeGGx_.js +0 -1
  187. package/src/client/dist/pwa/assets/index-DolbyIM4.js +0 -20
  188. package/src/client/dist/pwa/assets/index-VMFLfvp2.css +0 -1
  189. package/src/client/dist/pwa/assets/it-BJNuTcj2.js +0 -17
  190. package/src/client/dist/pwa/assets/monaco.contribution-BcIfN6Mi.js +0 -2
  191. package/src/client/dist/pwa/assets/network-auth-B1xAaHmx.js +0 -1
  192. package/src/client/dist/pwa/assets/pending-diff-open-B6N0vI5n.js +0 -1
  193. package/src/client/dist/pwa/assets/pinia-CTJ3OfP3.js +0 -1
  194. package/src/client/dist/pwa/assets/runtime-core.esm-bundler-CNFKm4ij.js +0 -1
  195. package/src/client/dist/pwa/assets/use-checkbox-2xzX4yYj.js +0 -1
  196. package/src/client/dist/pwa/assets/use-id-vOr8PRTG.js +0 -1
  197. package/src/client/dist/pwa/assets/use-is-mobile-CJfSpTHx.js +0 -1
  198. package/src/client/dist/pwa/assets/use-search-index-status-Bluf9L99.js +0 -1
  199. package/src/client/dist/pwa/assets/workspace-templates-BxYxJt3S.js +0 -1
  200. /package/src/client/dist/pwa/assets/{engineFeatures-B-9jHocY.css → use-worktree-restore-B-9jHocY.css} +0 -0
@@ -1,133 +1,226 @@
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. */
1
+ import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
2
+ /** Keep each write transaction short enough for live event writers. */
3
+ const BATCH_SIZE = 500;
8
4
  const VACUUM_FREE_PAGE_RATIO = 0.25;
9
- /** Retention is OPT-IN: absent or invalid settings mean "delete nothing". */
10
5
  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
6
  function normalize(value) {
14
- return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : DISABLED;
7
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : DISABLED;
15
8
  }
16
- /** Read the retention window from the global settings. */
17
9
  export function resolveRetentionConfig(global) {
18
10
  return {
19
11
  retentionDays: normalize(global.wsEventsRetentionDays),
20
12
  keepPerWorkspace: normalize(global.wsEventsKeepPerWorkspace),
21
13
  };
22
14
  }
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
15
  function cutoffFor(config, nowMs) {
35
16
  return new Date(nowMs - config.retentionDays * 24 * 60 * 60 * 1000).toISOString();
36
17
  }
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
- */
18
+ /** Compute each protected tail once using the workspace/rowid index. Rows
19
+ * appended beyond the initial rowid are outside this pass. The age cutoff also
20
+ * protects newly emitted events while other workspaces continue writing. */
21
+ function workspaceBoundaries(db, keep) {
22
+ return db.transaction(() => {
23
+ const last = db.prepare('SELECT rowid AS rid FROM ws_events ORDER BY rowid DESC LIMIT 1').get();
24
+ if (!last)
25
+ return [];
26
+ const workspaces = db.prepare('SELECT id FROM workspaces ORDER BY id').all();
27
+ const tail = db.prepare('SELECT rowid AS rid FROM ws_events WHERE workspace_id = ? AND rowid <= ? ORDER BY rowid DESC LIMIT 1 OFFSET ?');
28
+ return workspaces.map(({ id }) => {
29
+ const boundary = keep > 0 ? tail.get(id, last.rid, keep - 1) : undefined;
30
+ return {
31
+ workspaceId: id,
32
+ beforeRowid: keep > 0 ? (boundary?.rid ?? 0) : last.rid + 1,
33
+ afterRowid: 0,
34
+ };
35
+ });
36
+ })();
37
+ }
38
+ /** Read-only preview, using the same protected boundaries as deletion. */
42
39
  export function countPrunableWsEvents(db, config, nowMs = Date.now()) {
43
40
  if (config.retentionDays <= 0)
44
41
  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;
42
+ const count = db.prepare('SELECT COUNT(*) AS count FROM ws_events WHERE workspace_id = ? AND rowid < ? AND created_at < ?');
74
43
  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(() => {
44
+ return workspaceBoundaries(db, config.keepPerWorkspace).reduce((total, boundary) => total + count.get(boundary.workspaceId, boundary.beforeRowid, cutoff).count, 0);
45
+ }
46
+ /** Aggregate outside the write transaction; concurrent WAL writers may keep emitting. */
47
+ function readSessionMetrics(db, workspaceId, sessionId) {
48
+ const signature = db
49
+ .prepare(`SELECT COUNT(*) AS rows, COALESCE(MAX(rowid), 0) AS maxRowid
50
+ FROM ws_events WHERE workspace_id = ? AND session_id = ?`)
51
+ .get(workspaceId, sessionId);
52
+ return { ...signature, ...readMetricsAfter(db, workspaceId, sessionId, 0) };
53
+ }
54
+ function readMetricsAfter(db, workspaceId, sessionId, afterRowid) {
55
+ return db
56
+ .prepare(`SELECT
57
+ COUNT(*) AS events,
58
+ COALESCE(SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'tool:call' THEN 1 ELSE 0 END), 0) AS toolCalls,
59
+ COALESCE(SUM(CASE WHEN json_extract(e.payload, '$.kind') = 'error'
60
+ OR (json_extract(e.payload, '$.kind') = 'tool:result'
61
+ AND json_extract(e.payload, '$.isError') = 1) THEN 1 ELSE 0 END), 0) AS errors,
62
+ COALESCE(MAX(CASE WHEN json_extract(e.payload, '$.kind') = 'usage'
63
+ AND json_type(e.payload, '$.inputTokens') IN ('integer', 'real')
64
+ THEN CAST(json_extract(e.payload, '$.inputTokens') AS INTEGER) ELSE 0 END), 0) AS inputTokens,
65
+ COALESCE(MAX(CASE WHEN json_extract(e.payload, '$.kind') = 'usage'
66
+ AND json_type(e.payload, '$.outputTokens') IN ('integer', 'real')
67
+ THEN CAST(json_extract(e.payload, '$.outputTokens') AS INTEGER) ELSE 0 END), 0) AS outputTokens
68
+ FROM ws_events e
69
+ JOIN agent_sessions s ON s.id = e.session_id AND s.workspace_id = e.workspace_id
70
+ WHERE e.workspace_id = ? AND e.session_id = ? AND e.rowid > ?
71
+ AND e.type = 'agent:event' AND json_valid(e.payload)`)
72
+ .get(workspaceId, sessionId, afterRowid);
73
+ }
74
+ class RetentionPass {
75
+ db;
76
+ result;
77
+ boundaries;
78
+ cutoff;
79
+ selectBatch;
80
+ deleteOne;
81
+ touched = new Map();
82
+ workspaceIndex = 0;
83
+ constructor(db, config, nowMs) {
84
+ this.db = db;
85
+ const freePagesBefore = db.pragma('freelist_count', { simple: true });
86
+ this.result = {
87
+ deleted: 0,
88
+ sessionsRecomputed: 0,
89
+ vacuumed: false,
90
+ freePagesBefore,
91
+ freePagesAfter: freePagesBefore,
92
+ };
93
+ this.boundaries = config.retentionDays > 0 ? workspaceBoundaries(db, config.keepPerWorkspace) : [];
94
+ this.cutoff = config.retentionDays > 0 ? cutoffFor(config, nowMs) : '';
95
+ this.selectBatch = db.prepare(`SELECT id, rowid AS rid, session_id FROM ws_events
96
+ WHERE workspace_id = ? AND rowid > ? AND rowid < ? AND created_at < ?
97
+ ORDER BY rowid ASC LIMIT ?`);
98
+ this.deleteOne = db.prepare('DELETE FROM ws_events WHERE rowid = ? AND id = ?');
99
+ }
100
+ /** One indexed read and at most BATCH_SIZE deletes; no whole-table reranking. */
101
+ step() {
102
+ const boundary = this.boundaries[this.workspaceIndex];
103
+ if (!boundary)
104
+ return false;
105
+ const batch = this.selectBatch.all(boundary.workspaceId, boundary.afterRowid, boundary.beforeRowid, this.cutoff, BATCH_SIZE);
106
+ const touched = new Set();
107
+ const deleted = this.db
108
+ .transaction(() => {
109
+ let count = 0;
88
110
  for (const row of batch) {
89
- deleteOne.run(row.rid);
90
- if (row.session_id)
91
- touched.add(`${row.workspace_id}::${row.session_id}`);
111
+ const change = this.deleteOne.run(row.rid, row.id).changes;
112
+ count += change;
113
+ if (change && row.session_id)
114
+ touched.add(row.session_id);
92
115
  }
93
- })();
94
- result.deleted += batch.length;
116
+ return count;
117
+ })
118
+ .immediate();
119
+ this.result.deleted += deleted;
120
+ if (touched.size) {
121
+ const sessions = this.touched.get(boundary.workspaceId) ?? new Set();
122
+ for (const sessionId of touched)
123
+ sessions.add(sessionId);
124
+ this.touched.set(boundary.workspaceId, sessions);
125
+ }
126
+ if (batch.length)
127
+ boundary.afterRowid = batch[batch.length - 1].rid;
95
128
  if (batch.length < BATCH_SIZE)
96
- break;
129
+ this.workspaceIndex++;
130
+ return true;
97
131
  }
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);
132
+ async finishAsync() {
133
+ for (const [workspaceId, sessions] of this.touched) {
134
+ for (const sessionId of sessions) {
135
+ // Transactional recompute cannot overwrite metrics from a concurrent emit.
136
+ await this.recomputeAsync(workspaceId, sessionId);
137
+ await yieldToEventLoop();
138
+ }
109
139
  }
140
+ return this.finish(false);
110
141
  }
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)');
142
+ finishSync(compact) {
143
+ for (const [workspaceId, sessions] of this.touched) {
144
+ for (const sessionId of sessions)
145
+ this.recomputeSync(workspaceId, sessionId);
146
+ }
147
+ return this.finish(compact);
116
148
  }
117
- catch (err) {
118
- console.error('[retention] wal_checkpoint failed:', err);
149
+ async recomputeAsync(workspaceId, sessionId) {
150
+ while (!this.recomputeOnce(workspaceId, sessionId))
151
+ await yieldToEventLoop();
152
+ this.result.sessionsRecomputed++;
119
153
  }
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;
154
+ recomputeSync(workspaceId, sessionId) {
155
+ while (!this.recomputeOnce(workspaceId, sessionId)) {
156
+ // The synchronous maintenance API has no interleaved local writer;
157
+ // a second connection may still change this session between phases.
126
158
  }
127
- catch (err) {
128
- console.error('[retention] VACUUM failed:', err);
159
+ this.result.sessionsRecomputed++;
160
+ }
161
+ recomputeOnce(workspaceId, sessionId) {
162
+ const snapshot = this.db.transaction(() => readSessionMetrics(this.db, workspaceId, sessionId))();
163
+ return this.db
164
+ .transaction(() => {
165
+ const unchanged = this.db
166
+ .prepare(`SELECT COUNT(*) AS rows FROM ws_events
167
+ WHERE workspace_id = ? AND session_id = ? AND rowid <= ?`)
168
+ .get(workspaceId, sessionId, snapshot.maxRowid);
169
+ if (unchanged.rows !== snapshot.rows)
170
+ return false;
171
+ // Rows appended after the read snapshot are small and are aggregated
172
+ // under the lock so the insert trigger cannot race with our replacement.
173
+ const appended = readMetricsAfter(this.db, workspaceId, sessionId, snapshot.maxRowid);
174
+ this.db
175
+ .prepare('DELETE FROM session_event_metrics WHERE workspace_id = ? AND session_id = ?')
176
+ .run(workspaceId, sessionId);
177
+ if (snapshot.events + appended.events === 0)
178
+ return true;
179
+ this.db
180
+ .prepare(`INSERT INTO session_event_metrics (
181
+ workspace_id, session_id, tool_calls, errors, input_tokens, output_tokens
182
+ ) SELECT ?, ?, ?, ?, ?, ? WHERE EXISTS (
183
+ SELECT 1 FROM agent_sessions WHERE id = ? AND workspace_id = ?
184
+ )`)
185
+ .run(workspaceId, sessionId, snapshot.toolCalls + appended.toolCalls, snapshot.errors + appended.errors, Math.max(snapshot.inputTokens, appended.inputTokens), Math.max(snapshot.outputTokens, appended.outputTokens), sessionId, workspaceId);
186
+ return true;
187
+ })
188
+ .immediate();
189
+ }
190
+ finish(compact) {
191
+ if (!this.result.deleted)
192
+ return this.result;
193
+ // PASSIVE never waits for live readers. Reusable pages remain available for
194
+ // new events. Full VACUUM is reserved for explicit offline maintenance.
195
+ this.db.pragma(`wal_checkpoint(${compact ? 'TRUNCATE' : 'PASSIVE'})`);
196
+ const freelist = this.db.pragma('freelist_count', { simple: true });
197
+ const pageCount = this.db.pragma('page_count', { simple: true });
198
+ if (compact && pageCount > 0 && freelist / pageCount >= VACUUM_FREE_PAGE_RATIO) {
199
+ this.db.exec('VACUUM');
200
+ this.result.vacuumed = true;
129
201
  }
202
+ this.result.freePagesAfter = this.db.pragma('freelist_count', { simple: true });
203
+ return this.result;
204
+ }
205
+ }
206
+ /** Synchronous helper for offline maintenance and fixtures. Never call from
207
+ * HTTP handlers or the server scheduler; use the dedicated worker service. */
208
+ export function pruneWsEvents(db, config, nowMs = Date.now(), options = {}) {
209
+ const pass = new RetentionPass(db, config, nowMs);
210
+ while (pass.step()) { }
211
+ return pass.finishSync(options.compact ?? true);
212
+ }
213
+ /** Worker-only maintenance: yield between bounded transactions so stop requests
214
+ * are handled promptly. Always repair touched session metrics before closing. */
215
+ export async function pruneWsEventsInBatches(db, config, nowMs = Date.now(), shouldStop = () => false) {
216
+ const pass = new RetentionPass(db, config, nowMs);
217
+ try {
218
+ while (!shouldStop() && pass.step())
219
+ await yieldToEventLoop();
220
+ }
221
+ catch (error) {
222
+ await pass.finishAsync();
223
+ throw error;
130
224
  }
131
- result.freePagesAfter = db.pragma('freelist_count', { simple: true }) ?? 0;
132
- return result;
225
+ return pass.finishAsync();
133
226
  }
@@ -0,0 +1,112 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import { getDb } from '../db/index.js';
3
+ let active;
4
+ let requestId = 0;
5
+ function start() {
6
+ const dbPath = getDb().name;
7
+ if (active) {
8
+ if (active.stopping)
9
+ throw new Error('Retention worker stopped');
10
+ if (active.dbPath !== dbPath)
11
+ throw new Error('Stop the retention worker before replacing its database');
12
+ return active;
13
+ }
14
+ if (!dbPath || dbPath === ':memory:')
15
+ throw new Error('Retention worker requires a file-backed database');
16
+ const development = import.meta.url.endsWith('.ts');
17
+ const entry = new URL(`./retention/worker.${development ? 'ts' : 'js'}`, import.meta.url);
18
+ const worker = development
19
+ ? new Worker(`const { workerData } = require('node:worker_threads'); import('tsx/esm/api').then(({ tsImport }) => tsImport(workerData.entry, workerData.parent));`, { eval: true, workerData: { dbPath, entry: entry.href, parent: import.meta.url } })
20
+ : new Worker(entry, { workerData: { dbPath } });
21
+ const state = { worker, dbPath, pending: new Map() };
22
+ active = state;
23
+ worker.unref();
24
+ const fail = (error) => {
25
+ for (const request of state.pending.values())
26
+ request.reject(error);
27
+ state.pending.clear();
28
+ };
29
+ worker.on('error', fail);
30
+ worker.on('exit', (code) => {
31
+ fail(new Error(`Retention worker stopped (${code})`));
32
+ if (active === state)
33
+ active = undefined;
34
+ });
35
+ worker.on('message', (message) => {
36
+ const pending = state.pending.get(message.id);
37
+ if (!pending)
38
+ return;
39
+ state.pending.delete(message.id);
40
+ if (message.error)
41
+ pending.reject(new Error(message.error));
42
+ else if (message.result)
43
+ pending.resolve(message.result);
44
+ else
45
+ pending.reject(new Error('Retention worker returned no result'));
46
+ if (!state.pending.size && !state.stopping)
47
+ worker.unref();
48
+ });
49
+ return state;
50
+ }
51
+ function request(config, nowMs, preview) {
52
+ let state;
53
+ try {
54
+ state = start();
55
+ }
56
+ catch (error) {
57
+ return Promise.reject(error);
58
+ }
59
+ // Coalesce the same policy around the first request's snapshot time; distinct
60
+ // configurations remain serialized in
61
+ // the same worker, so two maintenance passes never compete for write locks.
62
+ const key = JSON.stringify([preview, config.retentionDays, config.keepPerWorkspace]);
63
+ for (const pending of state.pending.values())
64
+ if (pending.key === key)
65
+ return pending.promise;
66
+ const id = ++requestId;
67
+ let resolve;
68
+ let reject;
69
+ const promise = new Promise((res, rej) => {
70
+ resolve = res;
71
+ reject = rej;
72
+ });
73
+ state.pending.set(id, { key, promise, resolve, reject });
74
+ state.worker.ref();
75
+ state.worker.postMessage({ id, config, nowMs, preview });
76
+ return promise;
77
+ }
78
+ /** Daily/boot maintenance runs off the HTTP thread and never VACUUMs live data. */
79
+ export function runWsEventsRetention(config, nowMs = Date.now()) {
80
+ if (config.retentionDays <= 0) {
81
+ return Promise.resolve({
82
+ deleted: 0,
83
+ sessionsRecomputed: 0,
84
+ vacuumed: false,
85
+ freePagesBefore: 0,
86
+ freePagesAfter: 0,
87
+ });
88
+ }
89
+ return request(config, nowMs, false);
90
+ }
91
+ /** The confirmation preview is also off the live event loop. */
92
+ export function previewWsEventsRetention(config, nowMs = Date.now()) {
93
+ return request(config, nowMs, true);
94
+ }
95
+ /** Stop after the current bounded transaction, repair affected metrics and
96
+ * confirm worker exit before the server closes its main SQLite connection. */
97
+ export function stopWsEventsRetention() {
98
+ const state = active;
99
+ if (!state)
100
+ return Promise.resolve();
101
+ if (state.stopping)
102
+ return state.stopping;
103
+ for (const pending of state.pending.values())
104
+ pending.reject(new Error('Retention worker stopped'));
105
+ state.pending.clear();
106
+ state.worker.ref();
107
+ state.stopping = new Promise((resolve) => {
108
+ state.worker.once('exit', () => resolve());
109
+ state.worker.postMessage({ stop: true });
110
+ });
111
+ return state.stopping;
112
+ }
@@ -0,0 +1,23 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ /** Replace a JSON file only after a complete write in the same filesystem. */
5
+ export function writeJsonFileAtomically(filePath, value) {
6
+ const contents = JSON.stringify(value, null, 2);
7
+ const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
8
+ mkdirSync(path.dirname(filePath), { recursive: true });
9
+ try {
10
+ writeFileSync(temporaryPath, contents, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
11
+ renameSync(temporaryPath, filePath);
12
+ }
13
+ finally {
14
+ try {
15
+ unlinkSync(temporaryPath);
16
+ }
17
+ catch (error) {
18
+ if (error.code !== 'ENOENT') {
19
+ console.error('[atomic-json-file] Failed to remove temporary file:', error);
20
+ }
21
+ }
22
+ }
23
+ }