@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
@@ -6,6 +6,22 @@ export class SessionController {
6
6
  _engineProcess;
7
7
  _startPromise;
8
8
  _status = 'running';
9
+ /**
10
+ * Wall-clock creation time. The watchdog needs it: `engineProcess` stays
11
+ * undefined until `engine.start` resolves, and without a grace window any
12
+ * engine whose start becomes blocking would see every fresh session
13
+ * declared dead on the first sweep.
14
+ */
15
+ startedAt = Date.now();
16
+ /**
17
+ * Wall-clock time of the last event this controller relayed. Serialized to
18
+ * the client so a stalled agent is visible as "last event 22 min ago"
19
+ * instead of an indefinite "the agent is busy".
20
+ */
21
+ _lastEventAt = Date.now();
22
+ get lastEventAt() {
23
+ return this._lastEventAt;
24
+ }
9
25
  get engineProcess() {
10
26
  return this._engineProcess;
11
27
  }
@@ -48,6 +64,16 @@ export class SessionController {
48
64
  }
49
65
  async stop() {
50
66
  this._status = 'stopping';
67
+ // `startEngine` may still be in flight: `_engineProcess` stays undefined
68
+ // until `engine.start` resolves, so without this wait `stop()` would
69
+ // resolve instantly while the engine keeps spinning up in the
70
+ // background — the caller (e.g. worktree removal) would then race ahead
71
+ // of a process that hasn't even started yet. `startEngine` itself
72
+ // already stops the process it just got if it sees `_status ===
73
+ // 'stopping'`, so waiting here is enough; a failed start must not fail
74
+ // the stop, hence the swallowed rejection.
75
+ if (this._startPromise)
76
+ await this._startPromise.catch(() => { });
51
77
  if (this._engineProcess)
52
78
  await this._engineProcess.stop();
53
79
  }
@@ -61,6 +87,7 @@ export class SessionController {
61
87
  return this._engineProcess?.engineSessionId;
62
88
  }
63
89
  handle(ev) {
90
+ this._lastEventAt = Date.now();
64
91
  this.onEvent(ev);
65
92
  }
66
93
  }
@@ -1,6 +1,10 @@
1
1
  /**
2
- * Tracks a single active Codex turn. A pending user decision deliberately
3
- * pauses the deadline: the agent is waiting on a human, not stalled.
2
+ * Tracks a single active agent turn. Shared by every engine (D2): one module,
3
+ * armed BEFORE the handshake, re-armed on ANY event, paused while a human
4
+ * decision is pending, stopped in the cleanup block.
5
+ *
6
+ * A pending user decision deliberately pauses the deadline: the agent is
7
+ * waiting on a human, not stalled.
4
8
  */
5
9
  export function createTurnLiveness(input) {
6
10
  let timer;
@@ -2,6 +2,7 @@
2
2
  import { spawn } from 'node:child_process';
3
3
  import * as path from 'node:path';
4
4
  import * as gitOps from '../utils/git-ops.js';
5
+ import { withGitRepoLock } from '../utils/git-repo-lock.js';
5
6
  import { getAgentStatus } from './agent/orchestrator.js';
6
7
  import { getForgeProvider } from './forge/registry.js';
7
8
  import { resolveForge } from './forge/resolve.js';
@@ -9,8 +10,33 @@ import { getEffectiveSettings } from './settings-service.js';
9
10
  import { getWorkspace, updateWorkspaceSourceBranch } from './workspace-service.js';
10
11
  /** Above this many proper commits, refuse and ask for a manual rebase. */
11
12
  export const MAX_PROPER_COMMITS = 50;
13
+ /** Label written by `stashPush` and read back by `stashPop` — see F27. */
14
+ const STASH_LABEL = 'kobo-change-source-branch';
12
15
  /** Wall-clock limit on the custom change-source-branch script. */
13
16
  const SCRIPT_TIMEOUT_MS = 5 * 60 * 1000;
17
+ /**
18
+ * Rotate the backup branches, best-effort. Called on EVERY exit path that has
19
+ * already created one — a conflict is precisely the case that repeats, and
20
+ * rotating only on full success let the failure scenario grow without bound.
21
+ * Returns the warnings so a caller can surface them instead of burying them in
22
+ * the server log; never throws, since the outcome being returned (or the error
23
+ * being rethrown) always matters more than the housekeeping.
24
+ */
25
+ function rotateBackupBranches(worktreePath, workingBranch) {
26
+ try {
27
+ const { warnings } = gitOps.pruneBackupBranches(worktreePath, workingBranch, BACKUP_BRANCHES_KEPT);
28
+ for (const warning of warnings)
29
+ console.warn(`[change-source-branch] ${warning}`);
30
+ return warnings;
31
+ }
32
+ catch (err) {
33
+ const message = err instanceof Error ? err.message : String(err);
34
+ console.warn(`[change-source-branch] backup rotation failed: ${message}`);
35
+ return [`Backup branch rotation failed: ${message}`];
36
+ }
37
+ }
38
+ /** Three kept means two levels of fallback beyond the branch just created. */
39
+ const BACKUP_BRANCHES_KEPT = 3;
14
40
  /**
15
41
  * Re-target a workspace onto `newBase`: reconstruct its working branch via
16
42
  * cherry-pick of its proper commits, update the `source_branch` metadata, and
@@ -30,12 +56,53 @@ export async function changeSourceBranch(workspaceId, newBase) {
30
56
  throw new Error('New source branch is required');
31
57
  if (trimmedNew === oldBase)
32
58
  throw new Error(`The source branch is already '${oldBase}'`);
59
+ const worktreePath = workspace.worktreePath;
60
+ const workingBranch = workspace.workingBranch;
33
61
  const effective = getEffectiveSettings(workspace.projectPath);
34
- if (effective.changeSourceBranchScript && effective.changeSourceBranchScript.trim().length > 0) {
62
+ const customScript = effective.changeSourceBranchScript?.trim() ?? '';
63
+ if (customScript.length > 0) {
64
+ // Un script personnalisé remplace la STRATEGIE, jamais les GARANTIES.
65
+ // Le script par défaut fait `git reset --hard`, qui détruit sans recours
66
+ // le travail non commité et les fichiers non suivis. Le chemin intégré
67
+ // sait mettre de côté puis restaurer quand la branche est alignée ; un
68
+ // script arbitraire étant une boîte noire, on ne peut pas le supposer —
69
+ // donc tout worktree sale est refusé ici, sans exception.
70
+ // Fail-closed: `worktreeHasChanges` swallows any git failure and reports
71
+ // "clean" (see its doc comment), which is exactly wrong on a destructive
72
+ // path — an unknown state must never be read as "safe to reset --hard".
73
+ // `worktreeHasChangesStrict` lets the error through instead, and here we
74
+ // treat that failure the same as "dirty": refuse rather than guess.
75
+ let dirty;
76
+ try {
77
+ dirty = gitOps.worktreeHasChangesStrict(worktreePath);
78
+ }
79
+ catch (err) {
80
+ console.error('[change-source-branch] could not determine worktree state, refusing as a precaution:', err);
81
+ dirty = true;
82
+ }
83
+ if (dirty) {
84
+ return { status: 'dirty', forcePushNeeded: false, commitCount: 0 };
85
+ }
86
+ // A stale index.lock makes every git write fail half-way through, and the
87
+ // raw git message tells the user nothing. Typical origin: a setup/cleanup
88
+ // script SIGKILLed at its timeout during a commit. The custom-script path
89
+ // does not go through the repository lock, so the check is immediate here.
90
+ gitOps.assertNoIndexLock(worktreePath);
35
91
  return runCustomScript(workspace, oldBase, trimmedNew, effective.changeSourceBranchScript);
36
92
  }
37
- const worktreePath = workspace.worktreePath;
38
- const workingBranch = workspace.workingBranch;
93
+ // The whole built-in strategy runs under the repository lock: it fetches,
94
+ // resets and cherry-picks, all of which touch the COMMON git dir shared by
95
+ // every worktree of the project. A concurrent fetch from the PR watcher
96
+ // during the cherry-pick leaves it aborted mid-way.
97
+ return withGitRepoLock(worktreePath, () => runBuiltInStrategy(workspaceId, worktreePath, workingBranch, workspace.projectPath, oldBase, trimmedNew));
98
+ }
99
+ /** The built-in cherry-pick strategy. Always called under the repository lock. */
100
+ async function runBuiltInStrategy(workspaceId, worktreePath, workingBranch, projectPath, oldBase, trimmedNew) {
101
+ // Index-lock guard FIRST, and here rather than at the entry point: the
102
+ // repository lock above may have queued us for a while, so a check made at
103
+ // request time describes a state that no longer holds — it would miss a lock
104
+ // taken since, or report one that has been released in the meantime.
105
+ gitOps.assertNoIndexLock(worktreePath);
39
106
  // Fetch every branch so all `origin/*` refs are current: the proper-commit
40
107
  // computation and the `reset --hard` target both depend on fresh refs.
41
108
  // Best-effort — offline still lets us proceed with whatever is local, and
@@ -56,13 +123,25 @@ export async function changeSourceBranch(workspaceId, newBase) {
56
123
  return { status: 'too-many', forcePushNeeded, commitCount: commits.length };
57
124
  }
58
125
  const isAligned = commits.length === 0;
59
- const dirty = gitOps.worktreeHasChanges(worktreePath);
126
+ // Fail-closed, same rule as the custom-script path above: an indeterminate
127
+ // worktree state must never be read as "nothing to lose". Refuse right
128
+ // here, before either branch below — in particular before the
129
+ // `isAligned && dirty` stash path, since stashing a state we could not
130
+ // inspect would be worse than refusing outright.
131
+ let dirty;
132
+ try {
133
+ dirty = gitOps.worktreeHasChangesStrict(worktreePath);
134
+ }
135
+ catch (err) {
136
+ console.error('[change-source-branch] could not determine worktree state, refusing as a precaution:', err);
137
+ return { status: 'dirty', forcePushNeeded, commitCount: commits.length };
138
+ }
60
139
  if (!isAligned && dirty) {
61
140
  return { status: 'dirty', forcePushNeeded, commitCount: commits.length };
62
141
  }
63
142
  const stashed = isAligned && dirty;
64
143
  if (stashed)
65
- gitOps.stashPush(worktreePath, 'kobo-change-source-branch');
144
+ gitOps.stashPush(worktreePath, STASH_LABEL);
66
145
  try {
67
146
  gitOps.reconstructBranchOnto(worktreePath, workingBranch, trimmedNew, commits);
68
147
  }
@@ -73,7 +152,12 @@ export async function changeSourceBranch(workspaceId, newBase) {
73
152
  // or abort. `stashed` is always false here — a conflict needs commits,
74
153
  // the stash path is aligned-only — so no stash is stranded.
75
154
  updateWorkspaceSourceBranch(workspaceId, trimmedNew);
76
- return { status: 'conflict', forcePushNeeded, commitCount: commits.length };
155
+ return {
156
+ status: 'conflict',
157
+ forcePushNeeded,
158
+ commitCount: commits.length,
159
+ warnings: emptyToUndefined(rotateBackupBranches(worktreePath, workingBranch)),
160
+ };
77
161
  }
78
162
  // Non-conflict failure (e.g. the `reset --hard` itself failed) — best
79
163
  // effort restore of the stash before letting the error propagate, same
@@ -81,12 +165,16 @@ export async function changeSourceBranch(workspaceId, newBase) {
81
165
  // work on an unrelated failure.
82
166
  if (stashed) {
83
167
  try {
84
- gitOps.stashPop(worktreePath);
168
+ gitOps.stashPop(worktreePath, STASH_LABEL);
85
169
  }
86
170
  catch {
87
171
  /* best-effort — the original error is more relevant than this one */
88
172
  }
89
173
  }
174
+ // The backup branch is created first, so it exists even when the reset that
175
+ // follows blew up. Rotate before rethrowing; its own warnings can only be
176
+ // logged here, the error is what reaches the caller.
177
+ rotateBackupBranches(worktreePath, workingBranch);
90
178
  throw err;
91
179
  }
92
180
  // The reset/reconstruct already landed on disk at this point. Update the
@@ -97,14 +185,19 @@ export async function changeSourceBranch(workspaceId, newBase) {
97
185
  updateWorkspaceSourceBranch(workspaceId, trimmedNew);
98
186
  if (stashed) {
99
187
  try {
100
- gitOps.stashPop(worktreePath);
188
+ gitOps.stashPop(worktreePath, STASH_LABEL);
101
189
  }
102
190
  catch {
103
- return { status: 'conflict', forcePushNeeded, commitCount: commits.length };
191
+ return {
192
+ status: 'conflict',
193
+ forcePushNeeded,
194
+ commitCount: commits.length,
195
+ warnings: emptyToUndefined(rotateBackupBranches(worktreePath, workingBranch)),
196
+ };
104
197
  }
105
198
  }
106
199
  try {
107
- const provider = getForgeProvider(resolveForge(workspace.projectPath));
200
+ const provider = getForgeProvider(resolveForge(projectPath));
108
201
  if (provider.capabilities.canChangePrBase) {
109
202
  const pr = await provider.getPrStatus(worktreePath, workingBranch);
110
203
  if (pr)
@@ -114,7 +207,18 @@ export async function changeSourceBranch(workspaceId, newBase) {
114
207
  catch (err) {
115
208
  console.error('[change-source-branch] PR base update failed (non-fatal):', err);
116
209
  }
117
- return { status: isAligned ? 'aligned' : 'done', forcePushNeeded, commitCount: commits.length };
210
+ // Each backup pins a whole previous history and git can never reclaim it.
211
+ const warnings = rotateBackupBranches(worktreePath, workingBranch);
212
+ return {
213
+ status: isAligned ? 'aligned' : 'done',
214
+ forcePushNeeded,
215
+ commitCount: commits.length,
216
+ warnings: emptyToUndefined(warnings),
217
+ };
218
+ }
219
+ /** Keep `warnings` out of the JSON response entirely when there is nothing to say. */
220
+ function emptyToUndefined(warnings) {
221
+ return warnings.length > 0 ? warnings : undefined;
118
222
  }
119
223
  /** Spawn the script with `bash -c`, return the standard result on exit 0, throw on non-zero. */
120
224
  async function runCustomScript(workspace, oldBase, newBase, script) {
@@ -8,12 +8,12 @@ let backupSequence = 0;
8
8
  export function _resetBackupSequenceForTests() {
9
9
  backupSequence = 0;
10
10
  }
11
- function listBackups(dir) {
11
+ function listBackups(dir, prefix = BACKUP_PREFIX) {
12
12
  if (!fs.existsSync(dir))
13
13
  return [];
14
14
  return fs
15
15
  .readdirSync(dir)
16
- .filter((f) => f.startsWith(BACKUP_PREFIX))
16
+ .filter((f) => f.startsWith(prefix))
17
17
  .map((f) => {
18
18
  const full = path.join(dir, f);
19
19
  const stat = fs.statSync(full);
@@ -21,6 +21,23 @@ function listBackups(dir) {
21
21
  })
22
22
  .sort((a, b) => b.mtimeMs - a.mtimeMs);
23
23
  }
24
+ /** Delete every backup of `prefix` beyond the `keepCount` most recent. */
25
+ function rotateBackups(dir, prefix, keepCount) {
26
+ const deleted = [];
27
+ const all = listBackups(dir, prefix);
28
+ if (all.length <= keepCount)
29
+ return deleted;
30
+ for (const entry of all.slice(keepCount)) {
31
+ try {
32
+ fs.unlinkSync(entry.path);
33
+ deleted.push(entry.path);
34
+ }
35
+ catch (err) {
36
+ console.error(`[kobo] Failed to delete old DB backup ${entry.path}:`, err);
37
+ }
38
+ }
39
+ return deleted;
40
+ }
24
41
  /**
25
42
  * Create a WAL-safe snapshot of the Kōbō DB if no backup has been made in the
26
43
  * last 24h. Rotates old backups, keeping only the `keepCount` most recent.
@@ -46,18 +63,7 @@ export async function createDailyDbBackupIfNeeded(db, dbPath, keepCount = DEFAUL
46
63
  const backupPath = path.join(dir, `${BACKUP_PREFIX}${stamp}-${backupSequence}`);
47
64
  await db.backup(backupPath);
48
65
  result.created = backupPath;
49
- const all = listBackups(dir);
50
- if (all.length > keepCount) {
51
- for (const entry of all.slice(keepCount)) {
52
- try {
53
- fs.unlinkSync(entry.path);
54
- result.deleted.push(entry.path);
55
- }
56
- catch (err) {
57
- console.error(`[kobo] Failed to delete old DB backup ${entry.path}:`, err);
58
- }
59
- }
60
- }
66
+ result.deleted.push(...rotateBackups(dir, BACKUP_PREFIX, keepCount));
61
67
  }
62
68
  catch (err) {
63
69
  console.error('[kobo] DB daily backup failed:', err);
@@ -65,14 +71,19 @@ export async function createDailyDbBackupIfNeeded(db, dbPath, keepCount = DEFAUL
65
71
  return result;
66
72
  }
67
73
  const PREMIGRATION_PREFIX = 'kobo.db.premigration-';
68
- export async function createPreMigrationBackup(db, dbPath, tag) {
74
+ /** Backups of this prefix kept. One full-size copy was added per shipped
75
+ * migration — of schema AND of settings — and nothing ever removed them. */
76
+ const DEFAULT_PREMIGRATION_KEEP = 5;
77
+ export async function createPreMigrationBackup(db, dbPath, tag, keepCount = DEFAULT_PREMIGRATION_KEEP) {
69
78
  const dir = path.dirname(dbPath);
70
79
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
71
80
  backupSequence += 1;
72
81
  const backupPath = path.join(dir, `${PREMIGRATION_PREFIX}${tag}-${stamp}-${backupSequence}`);
73
82
  try {
74
83
  await db.backup(backupPath);
75
- return { created: backupPath, deleted: [] };
84
+ // Rotation is scoped to the pre-migration prefix: the daily backups are a
85
+ // separate net with its own retention and must never be touched here.
86
+ return { created: backupPath, deleted: rotateBackups(dir, PREMIGRATION_PREFIX, keepCount) };
76
87
  }
77
88
  catch (err) {
78
89
  console.error('[kobo] Pre-migration backup failed:', err);
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import { fetchSourceBranchAsync, isGitWorktree } from '../utils/git-ops.js';
3
+ import { withGitRepoLock } from '../utils/git-repo-lock.js';
3
4
  import { stopDevServer } from './dev-server-service.js';
4
5
  import { getForgeProvider } from './forge/registry.js';
5
6
  import { resolveForge } from './forge/resolve.js';
@@ -162,7 +163,13 @@ export async function checkPrStatuses() {
162
163
  // logic below. Its own try/catch so a git failure neither skips PR
163
164
  // transitions nor poisons other workspaces.
164
165
  try {
165
- void fetchSourceBranchAsync(ws.worktreePath, ws.sourceBranch).catch(() => { });
166
+ // AWAITED, and serialised on the repository's common git dir: remote
167
+ // refs live there, shared by every worktree of the project. Firing this
168
+ // without awaiting made the stats below read the refs from BEFORE the
169
+ // fetch, so ahead/behind counters were always one tick stale; firing it
170
+ // concurrently across four worktrees made them fight over the same file
171
+ // lock, with the error swallowed.
172
+ await withGitRepoLock(ws.worktreePath, () => fetchSourceBranchAsync(ws.worktreePath, ws.sourceBranch));
166
173
  lastKnownGitStats.set(ws.id, await computeGitStats(ws, pr));
167
174
  }
168
175
  catch (err) {
@@ -219,7 +226,15 @@ export async function checkPrStatuses() {
219
226
  try {
220
227
  const { autoPurgeOnPrMerged } = getGlobalSettings();
221
228
  if (autoPurgeOnPrMerged) {
222
- void purgeWorktree(ws.id).catch((err) => {
229
+ void purgeWorktree(ws.id)
230
+ .then((result) => {
231
+ // Auto-purge runs with nobody watching: without this trace an
232
+ // outcome of 'removal-failed' left no record anywhere.
233
+ if (result.outcome !== 'purged') {
234
+ console.warn(`[pr-watcher] auto-purge for '${ws.name}' ended as '${result.outcome}':`, result.warnings.join('\n') || '(no warning)');
235
+ }
236
+ })
237
+ .catch((err) => {
223
238
  console.error(`[pr-watcher] auto-purge failed for '${ws.name}':`, err instanceof Error ? err.message : err);
224
239
  });
225
240
  }
@@ -832,6 +832,27 @@ const settingsMigrations = [
832
832
  global.whipVolume = normalizeWhipVolume(global.whipVolume);
833
833
  },
834
834
  },
835
+ {
836
+ version: 54,
837
+ name: 'add-ws-events-retention',
838
+ migrate: ({ global }) => {
839
+ // Seeded DISABLED (0), on fresh installs as well as migrated ones. This
840
+ // feature permanently deletes conversation history, so it is opt-in: a
841
+ // silent loss of history is worse than a growing file. A window the user
842
+ // already chose survives untouched; only a negative or non-integer value
843
+ // falls back to 0.
844
+ if (typeof global.wsEventsRetentionDays !== 'number' ||
845
+ !Number.isInteger(global.wsEventsRetentionDays) ||
846
+ global.wsEventsRetentionDays < 0) {
847
+ global.wsEventsRetentionDays = 0;
848
+ }
849
+ if (typeof global.wsEventsKeepPerWorkspace !== 'number' ||
850
+ !Number.isInteger(global.wsEventsKeepPerWorkspace) ||
851
+ global.wsEventsKeepPerWorkspace < 0) {
852
+ global.wsEventsKeepPerWorkspace = 0;
853
+ }
854
+ },
855
+ },
835
856
  ];
836
857
  /** Current settings schema version — always equals the highest migration version. */
837
858
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -879,6 +900,8 @@ function defaultSettings() {
879
900
  terminalCommand: '',
880
901
  autoPurgeOnPrMerged: false,
881
902
  autoLoopMaxRetries: 5,
903
+ wsEventsRetentionDays: 0,
904
+ wsEventsKeepPerWorkspace: 0,
882
905
  networkAccessEnabled: false,
883
906
  networkAccessToken: '',
884
907
  networkAccessBehindProxy: false,
@@ -1270,6 +1293,20 @@ export function updateGlobalSettings(data) {
1270
1293
  delete data.autoLoopMaxRetries;
1271
1294
  }
1272
1295
  }
1296
+ // Same shape as the autoLoopMaxRetries guard above: an invalid value is
1297
+ // dropped so the stored one survives. Retention deletes history — a bad write
1298
+ // here would delete more than the user ever asked for. `0` is NOT invalid:
1299
+ // it is the default, and it means "never delete anything".
1300
+ for (const key of ['wsEventsRetentionDays', 'wsEventsKeepPerWorkspace']) {
1301
+ if (key in data) {
1302
+ const value = data[key];
1303
+ const max = key === 'wsEventsRetentionDays' ? 3650 : 1_000_000;
1304
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > max) {
1305
+ console.warn(`[settings] Invalid ${key} value rejected: ${value}`);
1306
+ delete data[key];
1307
+ }
1308
+ }
1309
+ }
1273
1310
  const allowedGlobalKeys = [
1274
1311
  'defaultModelByEngine',
1275
1312
  'dangerouslySkipPermissions',
@@ -1291,6 +1328,8 @@ export function updateGlobalSettings(data) {
1291
1328
  'terminalCommand',
1292
1329
  'autoPurgeOnPrMerged',
1293
1330
  'autoLoopMaxRetries',
1331
+ 'wsEventsRetentionDays',
1332
+ 'wsEventsKeepPerWorkspace',
1294
1333
  'browserNotifications',
1295
1334
  'audioNotifications',
1296
1335
  'audioQuestionNotifications',