@loicngr/kobo 1.11.9 → 1.11.10

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 (173) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +5 -0
  3. package/README.md +2 -2
  4. package/dist/server/db/migrations.js +155 -19
  5. package/dist/server/db/schema.js +93 -0
  6. package/dist/server/index.js +35 -26
  7. package/dist/server/routes/dev-server.js +8 -7
  8. package/dist/server/routes/documents.js +33 -3
  9. package/dist/server/routes/health.js +16 -15
  10. package/dist/server/routes/images.js +17 -2
  11. package/dist/server/routes/workspaces.js +63 -48
  12. package/dist/server/services/agent/orchestrator.js +8 -1
  13. package/dist/server/services/auto-loop-service.js +1 -1
  14. package/dist/server/services/dev-server-service.js +48 -41
  15. package/dist/server/services/forge/bitbucket/provider.js +6 -11
  16. package/dist/server/services/forge/github/provider.js +10 -9
  17. package/dist/server/services/forge/gitlab/provider.js +11 -13
  18. package/dist/server/services/git-stats-service.js +8 -6
  19. package/dist/server/services/image-service.js +34 -5
  20. package/dist/server/services/pr-watcher-service.js +20 -9
  21. package/dist/server/services/search-service.js +53 -40
  22. package/dist/server/services/websocket-service.js +8 -3
  23. package/dist/server/services/workspace-service.js +81 -10
  24. package/dist/server/services/worktree-purge-service.js +1 -1
  25. package/dist/server/utils/git-ops.js +89 -22
  26. package/dist/server/utils/safe-path.js +14 -0
  27. package/package.json +22 -22
  28. package/src/client/dist/pwa/assets/{ActivityFeed-Dv06XbgS.css → ActivityFeed-BrjeLlhi.css} +1 -1
  29. package/src/client/dist/pwa/assets/ActivityFeed-UEAexGjc.js +8 -0
  30. package/src/client/dist/pwa/assets/ChangelogPage-XSYwu9XA.js +1 -0
  31. package/src/client/dist/pwa/assets/{ClosePopup-BZ1O0Jxe.js → ClosePopup-dpcrW9DC.js} +1 -1
  32. package/src/client/dist/pwa/assets/CreatePage-BK__rjvR.js +2 -0
  33. package/src/client/dist/pwa/assets/CreatePage-QwtfYunr.css +1 -0
  34. package/src/client/dist/pwa/assets/{DiffViewer-D9FN4RS8.css → DiffViewer-CS3P9Ngi.css} +1 -1
  35. package/src/client/dist/pwa/assets/DiffViewer-DnFN6Sc_.js +8 -0
  36. package/src/client/dist/pwa/assets/HealthPage-DVrZfMI6.js +1 -0
  37. package/src/client/dist/pwa/assets/MainLayout-D_3nFaCG.js +19 -0
  38. package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +1 -0
  39. package/src/client/dist/pwa/assets/QBadge-sMJZ_eZm.js +1 -0
  40. package/src/client/dist/pwa/assets/QBanner-DedhGMcj.js +1 -0
  41. package/src/client/dist/pwa/assets/QBtnToggle-BgfN19l8.js +1 -0
  42. package/src/client/dist/pwa/assets/QChip-EQGYShN0.js +1 -0
  43. package/src/client/dist/pwa/assets/QIcon--SvgE-nS.js +1 -0
  44. package/src/client/dist/pwa/assets/QList-Be_FbTA3.js +1 -0
  45. package/src/client/dist/pwa/assets/QPage-DuIsRmYu.js +1 -0
  46. package/src/client/dist/pwa/assets/QSelect-CtxxliRU.js +36 -0
  47. package/src/client/dist/pwa/assets/QSeparator-BdyRE-AM.js +1 -0
  48. package/src/client/dist/pwa/assets/QSpace-D9UEvM99.js +1 -0
  49. package/src/client/dist/pwa/assets/QSpinnerDots-B4RmFJUj.js +1 -0
  50. package/src/client/dist/pwa/assets/QTooltip-HEIFh5w9.js +1 -0
  51. package/src/client/dist/pwa/assets/SearchPage-1B5HOf90.js +1 -0
  52. package/src/client/dist/pwa/assets/SettingsPage-v8f4rrCt.js +16 -0
  53. package/src/client/dist/pwa/assets/TouchPan-lynWXY69.js +1 -0
  54. package/src/client/dist/pwa/assets/WorkspacePage-ChnyJ3xI.css +1 -0
  55. package/src/client/dist/pwa/assets/WorkspacePage-Vjzi8YnW.js +4 -0
  56. package/src/client/dist/pwa/assets/codicon-Brq4_Ui5.ttf +0 -0
  57. package/src/client/dist/pwa/assets/{css.worker-CBlhdqTa.js → css.worker-JKZQyld4.js} +38 -34
  58. package/src/client/dist/pwa/assets/cssMode-cg8iVI-i.js +1 -0
  59. package/src/client/dist/pwa/assets/documents-Dlx8yiSO.js +1 -0
  60. package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +1 -0
  61. package/src/client/dist/pwa/assets/editor.api-CfaJRkrX.js +847 -0
  62. package/src/client/dist/pwa/assets/editor.worker-D-m8IBMa.js +30 -0
  63. package/src/client/dist/pwa/assets/expand-template-D3LZldj1.js +1 -0
  64. package/src/client/dist/pwa/assets/formatters-D0dU6VcA.js +1 -0
  65. package/src/client/dist/pwa/assets/{freemarker2-Dgrv0PyH.js → freemarker2-B_8OIClK.js} +1 -1
  66. package/src/client/dist/pwa/assets/handlebars-CaYi_uxU.js +1 -0
  67. package/src/client/dist/pwa/assets/html-BnCUDUl1.js +1 -0
  68. package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +506 -0
  69. package/src/client/dist/pwa/assets/htmlMode-BZjKGnQF.js +1 -0
  70. package/src/client/dist/pwa/assets/i18n-B6SDs7Lm.js +1 -0
  71. package/src/client/dist/pwa/assets/index-CK1nk7qr.css +1 -0
  72. package/src/client/dist/pwa/assets/index-CoOsPyyG.js +82 -0
  73. package/src/client/dist/pwa/assets/{javascript-IK9qSsfx.js → javascript-DkSfoeHU.js} +1 -1
  74. package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +62 -0
  75. package/src/client/dist/pwa/assets/{jsonMode-D_Vdxb-B.js → jsonMode-yKXxfx1V.js} +3 -3
  76. package/src/client/dist/pwa/assets/{kobo-commands-zljfAram.js → kobo-commands-vn1IQxLn.js} +1 -1
  77. package/src/client/dist/pwa/assets/layout-CiQpugQB.js +1 -0
  78. package/src/client/dist/pwa/assets/liquid-Dlhysv3U.js +1 -0
  79. package/src/client/dist/pwa/assets/lspLanguageFeatures-CeYSQFUz.js +4 -0
  80. package/src/client/dist/pwa/assets/mdx-B8iME5NA.js +1 -0
  81. package/src/client/dist/pwa/assets/network-auth-j2Drut6q.js +1 -0
  82. package/src/client/dist/pwa/assets/pending-diff-open-B4RYz7G_.js +1 -0
  83. package/src/client/dist/pwa/assets/permissionModes-6jhegAm-.js +1 -0
  84. package/src/client/dist/pwa/assets/{project-color-6xNNY9u6.js → project-color-e3OdFi9X.js} +1 -1
  85. package/src/client/dist/pwa/assets/python-CbFbC0ZR.js +1 -0
  86. package/src/client/dist/pwa/assets/razor-7c2NCdFI.js +1 -0
  87. package/src/client/dist/pwa/assets/reactivity.esm-bundler-DtSZKRQB.js +1 -0
  88. package/src/client/dist/pwa/assets/render-chat-markdown-Fer8kY38.js +67 -0
  89. package/src/client/dist/pwa/assets/runtime-dom.esm-bundler-DgevF6KV.js +4 -0
  90. package/src/client/dist/pwa/assets/{ts.worker-PmaSgaZk.js → ts.worker-C-oGa8JT.js} +199 -195
  91. package/src/client/dist/pwa/assets/{tsMode-DiiMWOVl.js → tsMode-BmShKjbx.js} +1 -1
  92. package/src/client/dist/pwa/assets/typescript-Dhu7Upbs.js +1 -0
  93. package/src/client/dist/pwa/assets/use-checkbox-SrkgiJho.js +1 -0
  94. package/src/client/dist/pwa/assets/use-onboarding-CTZuBZOi.css +1 -0
  95. package/src/client/dist/pwa/assets/use-onboarding-fe4sPCNf.js +2 -0
  96. package/src/client/dist/pwa/assets/use-quasar-CeHlTrGg.js +1 -0
  97. package/src/client/dist/pwa/assets/vs-B-xsceNQ.js +56 -0
  98. package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +1 -0
  99. package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +3 -0
  100. package/src/client/dist/pwa/assets/workers-zVMTXVoC.js +1 -0
  101. package/src/client/dist/pwa/assets/xml-D64wpCtR.js +1 -0
  102. package/src/client/dist/pwa/assets/yaml-CLSRKwfx.js +1 -0
  103. package/src/client/dist/pwa/index.html +8 -8
  104. package/src/client/dist/pwa/manifest.json +1 -1
  105. package/src/client/dist/pwa/sw.js +1 -1
  106. package/src/client/dist/pwa/assets/ActivityFeed-Bs1uccfa.js +0 -8
  107. package/src/client/dist/pwa/assets/ChangelogPage-nW4vA3qx.js +0 -1
  108. package/src/client/dist/pwa/assets/CreatePage-BrdHh0Th.js +0 -2
  109. package/src/client/dist/pwa/assets/CreatePage-CRIBi2Vx.css +0 -1
  110. package/src/client/dist/pwa/assets/DiffViewer-DhtJQzG2.js +0 -8
  111. package/src/client/dist/pwa/assets/HealthPage-CExFCl_7.js +0 -1
  112. package/src/client/dist/pwa/assets/MainLayout-Coqyj1FP.css +0 -1
  113. package/src/client/dist/pwa/assets/MainLayout-WYZAjpD2.js +0 -37
  114. package/src/client/dist/pwa/assets/QBadge-DV5kn4mL.js +0 -1
  115. package/src/client/dist/pwa/assets/QBanner-CmfaeizG.js +0 -1
  116. package/src/client/dist/pwa/assets/QBtnGroup-C442-yBb.js +0 -1
  117. package/src/client/dist/pwa/assets/QBtnToggle-CqyoLg5U.js +0 -1
  118. package/src/client/dist/pwa/assets/QChip-DMYWc1jc.js +0 -1
  119. package/src/client/dist/pwa/assets/QIcon-C6E-oEjI.js +0 -1
  120. package/src/client/dist/pwa/assets/QInput-BF8x4RTv.js +0 -1
  121. package/src/client/dist/pwa/assets/QList-D5EKdFPn.js +0 -1
  122. package/src/client/dist/pwa/assets/QPage-D3rjwA1l.js +0 -1
  123. package/src/client/dist/pwa/assets/QSelect-CAa3syxF.js +0 -36
  124. package/src/client/dist/pwa/assets/QSeparator-DWTCQxsa.js +0 -1
  125. package/src/client/dist/pwa/assets/QSpace-ezNrZkuj.js +0 -1
  126. package/src/client/dist/pwa/assets/QSpinnerDots-DIV68-cV.js +0 -1
  127. package/src/client/dist/pwa/assets/QTooltip-6IQJP1_Y.js +0 -1
  128. package/src/client/dist/pwa/assets/SearchPage-CQtm05UQ.js +0 -1
  129. package/src/client/dist/pwa/assets/SettingsPage-B38IzmSs.js +0 -16
  130. package/src/client/dist/pwa/assets/ThinkingItem-BqWpfNZP.js +0 -1
  131. package/src/client/dist/pwa/assets/ThinkingItem-Ckk4K1O-.css +0 -1
  132. package/src/client/dist/pwa/assets/TouchPan-C939psBh.js +0 -1
  133. package/src/client/dist/pwa/assets/WorkspacePage-CFF5Ghug.css +0 -1
  134. package/src/client/dist/pwa/assets/WorkspacePage-dS-Nc6QU.js +0 -4
  135. package/src/client/dist/pwa/assets/build-path-tree-C5rzY2hF.js +0 -1
  136. package/src/client/dist/pwa/assets/chunk-QTnfLwEv.js +0 -1
  137. package/src/client/dist/pwa/assets/codicon-ngg6Pgfi.ttf +0 -0
  138. package/src/client/dist/pwa/assets/cssMode-p60S_iK6.js +0 -1
  139. package/src/client/dist/pwa/assets/documents-CzlF-PFm.js +0 -1
  140. package/src/client/dist/pwa/assets/editor-BFebQ78e.css +0 -1
  141. package/src/client/dist/pwa/assets/editor.api2-DfOm2dM5.js +0 -872
  142. package/src/client/dist/pwa/assets/editor.main-6eriWmTe.js +0 -6
  143. package/src/client/dist/pwa/assets/editor.worker-Cu3tR8iJ.js +0 -26
  144. package/src/client/dist/pwa/assets/expand-template-B_RAt6BV.js +0 -1
  145. package/src/client/dist/pwa/assets/formatters-BoLOujHM.js +0 -1
  146. package/src/client/dist/pwa/assets/handlebars-D0XTaj97.js +0 -1
  147. package/src/client/dist/pwa/assets/html-DkYxFVm6.js +0 -1
  148. package/src/client/dist/pwa/assets/html.worker-BKBrNna1.js +0 -502
  149. package/src/client/dist/pwa/assets/htmlMode-Dj3mtvXI.js +0 -1
  150. package/src/client/dist/pwa/assets/i18n-Dzzn4YfF.js +0 -1
  151. package/src/client/dist/pwa/assets/index-C06a940j.js +0 -84
  152. package/src/client/dist/pwa/assets/index-CwYR3iHt.css +0 -1
  153. package/src/client/dist/pwa/assets/json.worker-BaLYt9Ss.js +0 -58
  154. package/src/client/dist/pwa/assets/layout-BAnzeEPY.js +0 -1
  155. package/src/client/dist/pwa/assets/liquid-DURTrym0.js +0 -1
  156. package/src/client/dist/pwa/assets/lspLanguageFeatures-Bki_90YN.js +0 -4
  157. package/src/client/dist/pwa/assets/mdx-z7FKEEaF.js +0 -1
  158. package/src/client/dist/pwa/assets/monaco.contribution-D52lvpFV.js +0 -2
  159. package/src/client/dist/pwa/assets/network-auth-DrGWXKl2.js +0 -1
  160. package/src/client/dist/pwa/assets/pending-diff-open-DX42h-5U.js +0 -1
  161. package/src/client/dist/pwa/assets/permissionModes-DMHgg-69.js +0 -1
  162. package/src/client/dist/pwa/assets/python-DHQ3U-kG.js +0 -1
  163. package/src/client/dist/pwa/assets/razor-DMlreu1b.js +0 -1
  164. package/src/client/dist/pwa/assets/render-chat-markdown-0Vj9B94m.js +0 -66
  165. package/src/client/dist/pwa/assets/typescript-0wxHr6dC.js +0 -1
  166. package/src/client/dist/pwa/assets/use-checkbox-E9SV7iH5.js +0 -1
  167. package/src/client/dist/pwa/assets/use-onboarding--YqDGUQR.js +0 -2
  168. package/src/client/dist/pwa/assets/use-onboarding-B3l7mx48.css +0 -1
  169. package/src/client/dist/pwa/assets/use-quasar-oX1TyrPM.js +0 -1
  170. package/src/client/dist/pwa/assets/vue.runtime.esm-bundler-JZnIeD9D.js +0 -3
  171. package/src/client/dist/pwa/assets/workers-D0OEUSgt.js +0 -1
  172. package/src/client/dist/pwa/assets/xml-C7PczWV5.js +0 -1
  173. package/src/client/dist/pwa/assets/yaml-Dj6QPnyC.js +0 -1
@@ -3,6 +3,10 @@ import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import { deriveReadyToMerge } from '../types.js';
5
5
  const execFileAsync = promisify(execFile);
6
+ const CLI_TIMEOUT_MS = 30_000;
7
+ function cliOptions(repoPath) {
8
+ return { cwd: repoPath, encoding: 'utf-8', timeout: CLI_TIMEOUT_MS };
9
+ }
6
10
  /** Map GitLab MR state to the normalised PrSnapshot state. */
7
11
  function mapState(state) {
8
12
  if (state === 'merged')
@@ -100,10 +104,7 @@ function mapGlabJobToCheck(job) {
100
104
  */
101
105
  async function fetchGlabCi(repoPath, branch) {
102
106
  try {
103
- const { stdout } = await execFileAsync('glab', ['ci', 'get', '-b', branch, '-F', 'json'], {
104
- cwd: repoPath,
105
- encoding: 'utf-8',
106
- });
107
+ const { stdout } = await execFileAsync('glab', ['ci', 'get', '-b', branch, '-F', 'json'], cliOptions(repoPath));
107
108
  const raw = stdout.trim();
108
109
  if (!raw)
109
110
  return { rollup: null, checks: [] };
@@ -138,7 +139,7 @@ export const gitlabProvider = {
138
139
  },
139
140
  async isAvailable(repoPath) {
140
141
  try {
141
- await execFileAsync('glab', ['auth', 'status'], { cwd: repoPath, encoding: 'utf-8' });
142
+ await execFileAsync('glab', ['auth', 'status'], cliOptions(repoPath));
142
143
  return { available: true };
143
144
  }
144
145
  catch (err) {
@@ -147,10 +148,7 @@ export const gitlabProvider = {
147
148
  },
148
149
  async getPrStatus(repoPath, branch) {
149
150
  try {
150
- const { stdout } = await execFileAsync('glab', ['mr', 'view', branch, '--output', 'json'], {
151
- cwd: repoPath,
152
- encoding: 'utf-8',
153
- });
151
+ const { stdout } = await execFileAsync('glab', ['mr', 'view', branch, '--output', 'json'], cliOptions(repoPath));
154
152
  const raw = stdout.trim();
155
153
  if (!raw)
156
154
  return null;
@@ -176,19 +174,19 @@ export const gitlabProvider = {
176
174
  '--description',
177
175
  opts.body,
178
176
  '--yes',
179
- ], { cwd: repoPath, encoding: 'utf-8' });
177
+ ], cliOptions(repoPath));
180
178
  const match = stdout.match(/https?:\/\/[^\s]+\/-\/merge_requests\/(\d+)/);
181
179
  if (!match)
182
180
  throw new Error('Could not parse MR URL from glab output');
183
181
  return { url: match[0], number: Number.parseInt(match[1], 10) };
184
182
  },
185
183
  async changePrBase(repoPath, base) {
186
- await execFileAsync('glab', ['mr', 'update', '--target-branch', base], { cwd: repoPath, encoding: 'utf-8' });
184
+ await execFileAsync('glab', ['mr', 'update', '--target-branch', base], cliOptions(repoPath));
187
185
  },
188
186
  async mergeRequest(repoPath, number) {
189
- await execFileAsync('glab', ['mr', 'merge', String(number), '--yes'], { cwd: repoPath, encoding: 'utf-8' });
187
+ await execFileAsync('glab', ['mr', 'merge', String(number), '--yes'], cliOptions(repoPath));
190
188
  },
191
189
  async deleteRemoteBranch(repoPath, branch) {
192
- await execFileAsync('git', ['push', 'origin', '--delete', branch], { cwd: repoPath, encoding: 'utf-8' });
190
+ await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
193
191
  },
194
192
  };
@@ -10,13 +10,15 @@ import { resolveForge } from './forge/resolve.js';
10
10
  */
11
11
  export async function computeGitStats(workspace, prSnapshot) {
12
12
  const { worktreePath, sourceBranch, workingBranch, projectPath } = workspace;
13
- const commitCount = gitOps.getCommitCount(worktreePath, sourceBranch, workingBranch);
14
- const behindCount = gitOps.getCommitsBehind(worktreePath, sourceBranch, workingBranch);
15
- const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, sourceBranch, workingBranch);
16
- const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath, workingBranch);
17
- const workingTree = gitOps.getWorkingTreeStatus(worktreePath);
18
13
  const forgeProvider = getForgeProvider(resolveForge(projectPath));
19
- const availability = await forgeProvider.isAvailable(worktreePath);
14
+ const [commitCount, behindCount, diffStats, unpushedCount, workingTree, availability] = await Promise.all([
15
+ gitOps.getCommitCountAsync(worktreePath, sourceBranch, workingBranch),
16
+ gitOps.getCommitsBehindAsync(worktreePath, sourceBranch, workingBranch),
17
+ gitOps.getStructuredDiffStatsBetweenAsync(worktreePath, sourceBranch, workingBranch),
18
+ gitOps.getUnpushedCountAsync(worktreePath, workingBranch),
19
+ gitOps.getWorkingTreeStatusAsync(worktreePath),
20
+ forgeProvider.isAvailable(worktreePath),
21
+ ]);
20
22
  return {
21
23
  commitCount,
22
24
  behindCount,
@@ -6,6 +6,29 @@ const IMAGES_DIR = '.ai/images';
6
6
  const INDEX_FILE = 'index.json';
7
7
  // Per-worktree lock to serialize index.json writes
8
8
  const locks = new Map();
9
+ function ensureRealImagesDirectory(worktreePath) {
10
+ const realWorktree = fs.realpathSync(worktreePath);
11
+ let current = worktreePath;
12
+ for (const segment of ['.ai', 'images']) {
13
+ current = path.join(current, segment);
14
+ if (fs.existsSync(current)) {
15
+ const stat = fs.lstatSync(current);
16
+ if (stat.isSymbolicLink())
17
+ throw new Error(`Symbolic links are not allowed in image storage: ${current}`);
18
+ if (!stat.isDirectory())
19
+ throw new Error(`Image storage path is not a directory: ${current}`);
20
+ }
21
+ else {
22
+ fs.mkdirSync(current);
23
+ }
24
+ const realCurrent = fs.realpathSync(current);
25
+ const relative = path.relative(realWorktree, realCurrent);
26
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
27
+ throw new Error('Image storage path escapes the worktree');
28
+ }
29
+ }
30
+ return fs.realpathSync(current);
31
+ }
9
32
  function withLock(worktreePath, fn) {
10
33
  const prev = locks.get(worktreePath) ?? Promise.resolve();
11
34
  // The second argument to .then() means: even if the previous operation in the
@@ -18,6 +41,8 @@ function readIndex(imagesDir) {
18
41
  const indexPath = path.join(imagesDir, INDEX_FILE);
19
42
  if (!fs.existsSync(indexPath))
20
43
  return [];
44
+ if (fs.lstatSync(indexPath).isSymbolicLink())
45
+ throw new Error('Symbolic links are not allowed for image index');
21
46
  try {
22
47
  return JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
23
48
  }
@@ -27,7 +52,13 @@ function readIndex(imagesDir) {
27
52
  }
28
53
  }
29
54
  function writeIndex(imagesDir, entries) {
30
- fs.writeFileSync(path.join(imagesDir, INDEX_FILE), JSON.stringify(entries, null, 2));
55
+ const indexPath = path.join(imagesDir, INDEX_FILE);
56
+ if (fs.existsSync(indexPath) && fs.lstatSync(indexPath).isSymbolicLink()) {
57
+ throw new Error('Symbolic links are not allowed for image index');
58
+ }
59
+ const tempPath = path.join(imagesDir, `.index-${process.pid}.tmp`);
60
+ fs.writeFileSync(tempPath, JSON.stringify(entries, null, 2));
61
+ fs.renameSync(tempPath, indexPath);
31
62
  }
32
63
  /** Save an image buffer to `.ai/images/` and update the index. Returns the UID and relative path. */
33
64
  export async function saveImage(worktreePath, fileBuffer, originalName) {
@@ -39,9 +70,7 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
39
70
  throw new Error(`Unsupported image extension: '${ext}'. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`);
40
71
  }
41
72
  const uid = nanoid(10);
42
- const imagesDir = path.join(worktreePath, IMAGES_DIR);
43
- // mkdirSync is idempotent — safe to call outside the lock
44
- fs.mkdirSync(imagesDir, { recursive: true });
73
+ const imagesDir = ensureRealImagesDirectory(worktreePath);
45
74
  const filename = `${uid}.${ext}`;
46
75
  await withLock(worktreePath, () => {
47
76
  // Write the image file inside the lock so both the file write and index
@@ -55,7 +84,7 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
55
84
  }
56
85
  /** Delete an image by UID from disk and the index. */
57
86
  export async function deleteImage(worktreePath, uid) {
58
- const imagesDir = path.join(worktreePath, IMAGES_DIR);
87
+ const imagesDir = ensureRealImagesDirectory(worktreePath);
59
88
  await withLock(worktreePath, () => {
60
89
  const entries = readIndex(imagesDir);
61
90
  const idx = entries.findIndex((e) => e.uid === uid);
@@ -18,8 +18,19 @@ import { purgeWorktree } from './worktree-purge-service.js';
18
18
  // it is recorded but NOT acted upon — prevents re-archiving manually
19
19
  // unarchived workspaces.
20
20
  const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
21
+ const WORKSPACE_CHECK_CONCURRENCY = 4;
21
22
  let timer = null;
22
23
  let checking = false;
24
+ async function runBounded(items, concurrency, worker) {
25
+ let nextIndex = 0;
26
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
27
+ while (nextIndex < items.length) {
28
+ const item = items[nextIndex++];
29
+ await worker(item);
30
+ }
31
+ });
32
+ await Promise.all(workers);
33
+ }
23
34
  /** Tracks the last known PR snapshot per workspace, used to detect transitions
24
35
  * (state, base, reviewDecision). */
25
36
  const lastKnownPr = new Map();
@@ -110,11 +121,11 @@ export async function checkPrStatuses() {
110
121
  lastKnownGitStats.delete(id);
111
122
  }
112
123
  }
113
- for (const ws of workspaces) {
124
+ await runBounded(workspaces, WORKSPACE_CHECK_CONCURRENCY, async (ws) => {
114
125
  // Without this guard, every git/forge spawn below fails with ENOENT and
115
126
  // floods the logs when a worktree was deleted externally.
116
127
  if (!fs.existsSync(ws.worktreePath))
117
- continue;
128
+ return;
118
129
  try {
119
130
  // Opt-out: skip the forge call entirely for a workspace with PR-watch
120
131
  // disabled — no PR-status fetch, no auto-archive/purge, no PR-derived
@@ -143,7 +154,7 @@ export async function checkPrStatuses() {
143
154
  console.error(`[pr-watcher] updateWorkspaceSourceBranch failed for '${ws.name}':`, err instanceof Error ? err.message : err);
144
155
  // Leave the cache untouched so the next tick retries — and skip
145
156
  // stats too, since they'd be computed against the stale base.
146
- continue;
157
+ return;
147
158
  }
148
159
  }
149
160
  }
@@ -158,7 +169,7 @@ export async function checkPrStatuses() {
158
169
  console.error(`[pr-watcher] computeGitStats failed for '${ws.name}':`, err instanceof Error ? err.message : err);
159
170
  }
160
171
  if (!pr)
161
- continue;
172
+ return;
162
173
  const prev = lastKnownPr.get(ws.id);
163
174
  // We delay updating `lastKnownPr` until after the actions succeed.
164
175
  // Setting it eagerly would poison the cache: if updateWorkspaceSourceBranch
@@ -179,13 +190,13 @@ export async function checkPrStatuses() {
179
190
  // (The defensive base preservation from the no-base branch doesn't apply here
180
191
  // because we ARE in the OPEN→MERGED/CLOSED branch which always has a base.)
181
192
  lastKnownPr.set(ws.id, pr);
182
- continue;
193
+ return;
183
194
  }
184
195
  console.log(`[pr-watcher] PR ${pr.state.toLowerCase()} for workspace '${ws.name}' — archiving`);
185
196
  // Best-effort cleanup (same as manual archive): stop dev server + terminal.
186
197
  // Agent is already not running here (guarded above).
187
198
  try {
188
- stopDevServer(ws.id);
199
+ await stopDevServer(ws.id);
189
200
  }
190
201
  catch (err) {
191
202
  console.error(`[pr-watcher] stopDevServer failed for '${ws.name}':`, err instanceof Error ? err.message : err);
@@ -217,7 +228,7 @@ export async function checkPrStatuses() {
217
228
  console.error(`[pr-watcher] auto-purge guard failed for '${ws.name}':`, err instanceof Error ? err.message : err);
218
229
  }
219
230
  }
220
- continue; // do not run base-change detection on a workspace we just archived
231
+ return; // do not run base-change detection on a workspace we just archived
221
232
  }
222
233
  // Review, CI, mergeability, and readiness transitions (only on OPEN PRs;
223
234
  // first-sight is silent). Each check is independent so simultaneous
@@ -254,7 +265,7 @@ export async function checkPrStatuses() {
254
265
  if (pr.state !== 'OPEN' || !pr.base) {
255
266
  const next = pr.base ? pr : { ...pr, base: prev?.base ?? pr.base };
256
267
  lastKnownPr.set(ws.id, next);
257
- continue;
268
+ return;
258
269
  }
259
270
  lastKnownPr.set(ws.id, pr);
260
271
  // Emit the base-change event AFTER the snapshot is committed so a
@@ -272,7 +283,7 @@ export async function checkPrStatuses() {
272
283
  catch (err) {
273
284
  console.error(`[pr-watcher] Failed to check PR for workspace '${ws.name}':`, err instanceof Error ? err.message : err);
274
285
  }
275
- }
286
+ });
276
287
  }
277
288
  /**
278
289
  * On-demand refresh of a single workspace's PR snapshot. Bypasses the 30s tick.
@@ -1,6 +1,6 @@
1
1
  import { getDb } from '../db/index.js';
2
2
  /** Event types that carry user-authored or assistant-authored readable text. */
3
- const SEARCHABLE_TYPES = ['user:message', 'agent:output'];
3
+ const SEARCHABLE_TYPES = ['user:message', 'agent:event', 'agent:output'];
4
4
  const SNIPPET_CONTEXT = 100; // chars on each side of the match
5
5
  /**
6
6
  * Extract the readable text content of a ws_events payload, or `null` when
@@ -29,6 +29,9 @@ function extractReadableText(type, payload) {
29
29
  return parts.length > 0 ? parts.join('\n') : null;
30
30
  }
31
31
  }
32
+ if (type === 'agent:event' && p.kind === 'message:text') {
33
+ return typeof p.text === 'string' ? p.text : null;
34
+ }
32
35
  return null;
33
36
  }
34
37
  function buildSnippet(text, matchIndex, query) {
@@ -56,49 +59,59 @@ export function searchEvents(query, options = {}) {
56
59
  const db = getDb();
57
60
  const typePlaceholders = SEARCHABLE_TYPES.map(() => '?').join(', ');
58
61
  const archiveFilter = includeArchived ? '' : 'AND w.archived_at IS NULL';
59
- const likePattern = `%${trimmed}%`;
60
- // Over-fetch a bit so post-filter rejects don't shrink us below `limit` on
61
- // realistic datasets, without scanning the entire table.
62
- const dbLimit = Math.max(limit * 3, 100);
63
- const rows = db
64
- .prepare(`SELECT e.id, e.workspace_id, e.session_id, e.type, e.created_at, e.payload,
65
- w.name AS workspace_name, w.archived_at
66
- FROM ws_events e
67
- JOIN workspaces w ON e.workspace_id = w.id
68
- WHERE e.type IN (${typePlaceholders})
69
- AND e.payload LIKE ?
70
- ${archiveFilter}
71
- ORDER BY e.created_at DESC
72
- LIMIT ?`)
73
- .all(...SEARCHABLE_TYPES, likePattern, dbLimit);
62
+ const escapedQuery = trimmed.replace(/[\\%_]/g, '\\$&');
63
+ const likePattern = `%${escapedQuery}%`;
74
64
  const needle = trimmed.toLowerCase();
75
65
  const results = [];
76
- for (const row of rows) {
77
- let parsed;
78
- try {
79
- parsed = JSON.parse(row.payload);
80
- }
81
- catch {
82
- continue;
66
+ const batchSize = Math.min(Math.max(limit * 3, 100), 500);
67
+ const statement = db.prepare(`SELECT e.id, e.workspace_id, e.session_id, e.type, e.created_at, e.payload,
68
+ w.name AS workspace_name, w.archived_at
69
+ FROM ws_events e
70
+ JOIN workspaces w ON e.workspace_id = w.id
71
+ WHERE e.type IN (${typePlaceholders})
72
+ AND e.payload LIKE ? ESCAPE '\\'
73
+ ${archiveFilter}
74
+ AND (? IS NULL OR e.created_at < ? OR (e.created_at = ? AND e.id < ?))
75
+ ORDER BY e.created_at DESC, e.id DESC
76
+ LIMIT ?`);
77
+ let cursorCreatedAt = null;
78
+ let cursorId = null;
79
+ while (results.length < limit) {
80
+ const rows = statement.all(...SEARCHABLE_TYPES, likePattern, cursorCreatedAt, cursorCreatedAt, cursorCreatedAt, cursorId, batchSize);
81
+ for (const row of rows) {
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(row.payload);
85
+ }
86
+ catch {
87
+ continue;
88
+ }
89
+ const text = extractReadableText(row.type, parsed);
90
+ if (!text)
91
+ continue;
92
+ const idx = text.toLowerCase().indexOf(needle);
93
+ if (idx < 0)
94
+ continue;
95
+ results.push({
96
+ eventId: row.id,
97
+ sessionId: row.session_id,
98
+ workspaceId: row.workspace_id,
99
+ workspaceName: row.workspace_name,
100
+ archived: row.archived_at !== null,
101
+ type: row.type,
102
+ timestamp: row.created_at,
103
+ snippet: buildSnippet(text, idx, trimmed),
104
+ });
105
+ if (results.length >= limit)
106
+ break;
83
107
  }
84
- const text = extractReadableText(row.type, parsed);
85
- if (!text)
86
- continue;
87
- const idx = text.toLowerCase().indexOf(needle);
88
- if (idx < 0)
89
- continue;
90
- results.push({
91
- eventId: row.id,
92
- sessionId: row.session_id,
93
- workspaceId: row.workspace_id,
94
- workspaceName: row.workspace_name,
95
- archived: row.archived_at !== null,
96
- type: row.type,
97
- timestamp: row.created_at,
98
- snippet: buildSnippet(text, idx, trimmed),
99
- });
100
- if (results.length >= limit)
108
+ if (rows.length < batchSize)
109
+ break;
110
+ const last = rows.at(-1);
111
+ if (!last)
101
112
  break;
113
+ cursorCreatedAt = last.created_at;
114
+ cursorId = last.id;
102
115
  }
103
116
  return results;
104
117
  }
@@ -109,16 +109,18 @@ export function handleConnection(ws) {
109
109
  export function emit(workspaceId, type, payload, sessionId) {
110
110
  const id = nanoid();
111
111
  const createdAt = new Date().toISOString();
112
+ let replayable = false;
112
113
  // Best-effort persist — don't let FK violation (deleted workspace) break the broadcast
113
114
  try {
114
115
  const db = getDb();
115
116
  db.prepare('INSERT INTO ws_events (id, workspace_id, type, payload, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, workspaceId, type, JSON.stringify(payload), sessionId ?? null, createdAt);
117
+ replayable = true;
116
118
  }
117
119
  catch (err) {
118
120
  console.error(`[websocket-service] Failed to persist event (workspace=${workspaceId}, type=${type}):`, err);
119
121
  }
120
122
  // Build the event object to send
121
- const event = { id, workspaceId, type, payload, sessionId, createdAt };
123
+ const event = { id, workspaceId, type, payload, sessionId, createdAt, replayable };
122
124
  const message = JSON.stringify(event);
123
125
  // Broadcast to subscribed clients. Wrap `.send` in try/catch so a dropped
124
126
  // client doesn't throw and abort delivery to the remaining subscribers.
@@ -145,7 +147,7 @@ export function emit(workspaceId, type, payload, sessionId) {
145
147
  export function emitEphemeral(workspaceId, type, payload) {
146
148
  const id = nanoid();
147
149
  const createdAt = new Date().toISOString();
148
- const event = { id, workspaceId, type, payload, createdAt };
150
+ const event = { id, workspaceId, type, payload, createdAt, replayable: false };
149
151
  const message = JSON.stringify(event);
150
152
  let sendErrorLogged = false;
151
153
  for (const [ws, subs] of clients) {
@@ -188,12 +190,14 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
188
190
  // up. This keeps first-paint fast on long-lived workspaces with tens of
189
191
  // thousands of events without ever deleting anything from the DB.
190
192
  const INITIAL_WINDOW = 300;
193
+ let mode = 'snapshot';
191
194
  if (lastEventId) {
192
195
  // Resume path: replay every event strictly after the cursor (delta
193
196
  // since last seen). If the cursor is stale/unknown, fall back to the
194
197
  // recent window rather than streaming the entire history.
195
198
  const lastRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(lastEventId);
196
199
  if (lastRow) {
200
+ mode = 'delta';
197
201
  rows = db
198
202
  .prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) AND rowid > ? ORDER BY rowid ASC`)
199
203
  .all(...resolvedIds, lastRow.rowid);
@@ -232,9 +236,10 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
232
236
  payload: parsedPayload,
233
237
  sessionId: row.session_id ?? undefined,
234
238
  createdAt: row.created_at,
239
+ replayable: true,
235
240
  };
236
241
  });
237
- ws.send(JSON.stringify({ type: 'sync:response', payload: { events } }));
242
+ ws.send(JSON.stringify({ type: 'sync:response', payload: { events, mode } }));
238
243
  }
239
244
  // ── Utilities ──────────────────────────────────────────────────────────────────
240
245
  /**
@@ -165,20 +165,29 @@ export function updateWorkspaceStatus(id, status) {
165
165
  return getWorkspace(id);
166
166
  }
167
167
  const WORKSPACE_NAME_MAX_LENGTH = 200;
168
- /** Update a workspace's display name. */
169
- export function updateWorkspaceName(id, name) {
170
- // Strip control characters (incl. newlines) and collapse whitespace
168
+ function sanitizeWorkspaceName(name) {
171
169
  const noControl = Array.from(name, (c) => {
172
170
  const code = c.charCodeAt(0);
173
171
  return code < 32 || code === 127 ? ' ' : c;
174
172
  }).join('');
175
173
  const sanitized = noControl.replace(/\s+/g, ' ').trim();
176
- if (!sanitized) {
174
+ if (!sanitized)
177
175
  throw new Error('Workspace name cannot be empty');
178
- }
179
176
  if (sanitized.length > WORKSPACE_NAME_MAX_LENGTH) {
180
177
  throw new Error(`Workspace name cannot exceed ${WORKSPACE_NAME_MAX_LENGTH} characters`);
181
178
  }
179
+ return sanitized;
180
+ }
181
+ function normalizeWorkspaceDescription(description) {
182
+ const trimmed = description == null ? null : description.trim();
183
+ if (trimmed !== null && trimmed.length > 200) {
184
+ throw new Error(`Description must be 200 characters or fewer (got ${trimmed.length})`);
185
+ }
186
+ return trimmed && trimmed.length > 0 ? trimmed : null;
187
+ }
188
+ /** Update a workspace's display name. */
189
+ export function updateWorkspaceName(id, name) {
190
+ const sanitized = sanitizeWorkspaceName(name);
182
191
  const db = getDb();
183
192
  const now = new Date().toISOString();
184
193
  const result = db.prepare('UPDATE workspaces SET name = ?, updated_at = ? WHERE id = ?').run(sanitized, now, id);
@@ -311,11 +320,7 @@ export function updateAgentPermissionMode(id, mode) {
311
320
  * does not exist.
312
321
  */
313
322
  export function updateWorkspaceDescription(id, description) {
314
- const trimmed = description == null ? null : description.trim();
315
- if (trimmed !== null && trimmed.length > 200) {
316
- throw new Error(`Description must be 200 characters or fewer (got ${trimmed.length})`);
317
- }
318
- const stored = trimmed && trimmed.length > 0 ? trimmed : null;
323
+ const stored = normalizeWorkspaceDescription(description);
319
324
  const db = getDb();
320
325
  const result = db
321
326
  .prepare('UPDATE workspaces SET description = ?, updated_at = ? WHERE id = ?')
@@ -326,6 +331,72 @@ export function updateWorkspaceDescription(id, description) {
326
331
  emitEphemeral(id, 'workspace:description-updated', { description: stored });
327
332
  return getWorkspace(id);
328
333
  }
334
+ /** Validate and persist a multi-field API update with one atomic SQL statement. */
335
+ export function updateWorkspaceFields(id, fields) {
336
+ const workspace = getWorkspace(id);
337
+ if (!workspace)
338
+ throw new Error(`Workspace '${id}' not found`);
339
+ const assignments = [];
340
+ const values = [];
341
+ if (fields.model !== undefined) {
342
+ if (typeof fields.model !== 'string' || !fields.model.trim())
343
+ throw new Error('model must be a non-empty string');
344
+ assignments.push('model = ?');
345
+ values.push(fields.model.trim());
346
+ }
347
+ if (fields.reasoningEffort !== undefined) {
348
+ if (typeof fields.reasoningEffort !== 'string' || !fields.reasoningEffort.trim()) {
349
+ throw new Error('reasoningEffort must be a non-empty string');
350
+ }
351
+ assignments.push('reasoning_effort = ?');
352
+ values.push(fields.reasoningEffort.trim());
353
+ }
354
+ if (fields.agentPermissionMode !== undefined) {
355
+ if (!['plan', 'bypass', 'strict', 'interactive'].includes(fields.agentPermissionMode)) {
356
+ throw new Error('Invalid agentPermissionMode');
357
+ }
358
+ const legacyMode = fields.agentPermissionMode === 'plan' ? 'plan' : 'auto-accept';
359
+ const legacyProfile = fields.agentPermissionMode === 'plan' ? 'bypass' : fields.agentPermissionMode;
360
+ assignments.push('agent_permission_mode = ?', 'permission_mode = ?', 'permission_profile = ?');
361
+ values.push(fields.agentPermissionMode, legacyMode, legacyProfile);
362
+ }
363
+ if (fields.status !== undefined) {
364
+ const allowed = VALID_TRANSITIONS[workspace.status];
365
+ if (!allowed || (fields.status !== workspace.status && !allowed.includes(fields.status))) {
366
+ throw new Error(`Invalid status transition from '${workspace.status}' to '${fields.status}'. Allowed: ${allowed?.join(', ') ?? ''}`);
367
+ }
368
+ assignments.push('status = ?');
369
+ values.push(fields.status);
370
+ }
371
+ if (fields.name !== undefined) {
372
+ if (typeof fields.name !== 'string')
373
+ throw new Error('name must be a string');
374
+ assignments.push('name = ?');
375
+ values.push(sanitizeWorkspaceName(fields.name));
376
+ }
377
+ const hasDescription = Object.hasOwn(fields, 'description');
378
+ let storedDescription;
379
+ if (hasDescription) {
380
+ if (fields.description !== null && typeof fields.description !== 'string') {
381
+ throw new Error('description must be a string or null');
382
+ }
383
+ storedDescription = normalizeWorkspaceDescription(fields.description ?? null);
384
+ assignments.push('description = ?');
385
+ values.push(storedDescription);
386
+ }
387
+ if (assignments.length === 0)
388
+ return workspace;
389
+ assignments.push('updated_at = ?');
390
+ values.push(new Date().toISOString(), id);
391
+ const result = getDb()
392
+ .prepare(`UPDATE workspaces SET ${assignments.join(', ')} WHERE id = ?`)
393
+ .run(...values);
394
+ if (result.changes === 0)
395
+ throw new Error(`Workspace '${id}' not found`);
396
+ if (hasDescription)
397
+ emitEphemeral(id, 'workspace:description-updated', { description: storedDescription ?? null });
398
+ return getWorkspace(id);
399
+ }
329
400
  /**
330
401
  * Update a workspace's agent-side description (≤ 200 chars after trim).
331
402
  * Empty string (after trim) or `null` clears the column.
@@ -24,7 +24,7 @@ export async function purgeWorktree(workspaceId) {
24
24
  console.error(`[purge] stopAgent failed for '${workspace.name}':`, msg);
25
25
  }
26
26
  try {
27
- devServerService.stopDevServer(workspaceId);
27
+ await devServerService.stopDevServer(workspaceId);
28
28
  }
29
29
  catch (err) {
30
30
  const msg = err instanceof Error ? err.message : String(err);