@loicngr/kobo 1.11.39 → 1.11.41

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 (154) hide show
  1. package/AGENTS.md +101 -32
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +6 -4
  4. package/dist/mcp-server/kobo-tasks-handlers.js +16 -9
  5. package/dist/mcp-server/kobo-tasks-server.js +13 -2
  6. package/dist/mcp-server/workspace-dialogue-client.js +28 -0
  7. package/dist/server/db/mcp-message-schema.js +16 -0
  8. package/dist/server/db/migrations.js +4 -0
  9. package/dist/server/db/schema.js +4 -0
  10. package/dist/server/db/search-schema.js +53 -0
  11. package/dist/server/index.js +17 -86
  12. package/dist/server/middleware/network-auth-middleware.js +5 -1
  13. package/dist/server/routes/changelog.js +4 -49
  14. package/dist/server/routes/git.js +2 -2
  15. package/dist/server/routes/images.js +65 -5
  16. package/dist/server/routes/mcp.js +47 -0
  17. package/dist/server/routes/pull-requests.js +1 -1
  18. package/dist/server/routes/search.js +13 -4
  19. package/dist/server/routes/settings.js +5 -0
  20. package/dist/server/routes/workspaces.js +250 -248
  21. package/dist/server/services/agent/engines/claude-code/engine.js +31 -33
  22. package/dist/server/services/agent/engines/codex/engine.js +37 -8
  23. package/dist/server/services/agent/engines/codex/event-mapper.js +3 -1
  24. package/dist/server/services/agent/engines/codex/options-builder.js +4 -3
  25. package/dist/server/services/agent/engines/codex/server-requests.js +8 -1
  26. package/dist/server/services/agent/orchestrator.js +20 -3
  27. package/dist/server/services/attachment-service.js +122 -0
  28. package/dist/server/services/change-source-branch-service.js +10 -42
  29. package/dist/server/services/chat-receipt-service.js +33 -0
  30. package/dist/server/services/cron-service.js +27 -1
  31. package/dist/server/services/image-service.js +16 -7
  32. package/dist/server/services/mcp-client-context.js +12 -0
  33. package/dist/server/services/mcp-connection-info-service.js +63 -0
  34. package/dist/server/services/mcp-message-delivery-service.js +59 -0
  35. package/dist/server/services/mcp-message-request-service.js +64 -0
  36. package/dist/server/services/pr-checkout-service.js +10 -10
  37. package/dist/server/services/pr-watcher-service.js +14 -9
  38. package/dist/server/services/search/indexer.js +232 -0
  39. package/dist/server/services/search/worker.js +76 -0
  40. package/dist/server/services/search-service.js +104 -110
  41. package/dist/server/services/update-check-service.js +119 -0
  42. package/dist/server/services/websocket-service.js +121 -35
  43. package/dist/server/services/workspace-dialogue-service.js +128 -0
  44. package/dist/server/services/workspace-message-service.js +58 -0
  45. package/dist/server/services/workspace-permission-policy-service.js +33 -3
  46. package/dist/server/services/worktree-restore-service.js +2 -2
  47. package/dist/server/services/worktree-service.js +197 -124
  48. package/dist/server/utils/bounded-process.js +137 -0
  49. package/dist/server/utils/git-ops.js +76 -128
  50. package/dist/server/utils/git-repo-lock.js +27 -13
  51. package/dist/shared/attachments.js +58 -0
  52. package/dist/shared/workspace-dialogue-tools.js +89 -0
  53. package/dist/shared/workspace-message-types.js +19 -0
  54. package/package.json +2 -1
  55. package/src/client/dist/pwa/assets/{ActivityFeed-Chsw1Mh7.css → ActivityFeed-BYR5x7aa.css} +1 -1
  56. package/src/client/dist/pwa/assets/ActivityFeed-DTIe6O9o.js +8 -0
  57. package/src/client/dist/pwa/assets/{ChangelogPage-D8563008.js → ChangelogPage-BWeu0YeI.js} +1 -1
  58. package/src/client/dist/pwa/assets/{ComparisonPanel-Bd0X08sg.js → ComparisonPanel-o_EHlTwV.js} +2 -2
  59. package/src/client/dist/pwa/assets/CreatePage-CGi88k6z.css +1 -0
  60. package/src/client/dist/pwa/assets/CreatePage-DbY5gBv8.js +2 -0
  61. package/src/client/dist/pwa/assets/DashboardPage-CIo_gesD.js +1 -0
  62. package/src/client/dist/pwa/assets/{DiffViewer-DnR79ycb.css → DiffViewer-DEPOa3D7.css} +1 -1
  63. package/src/client/dist/pwa/assets/DiffViewer-DEcf48KI.js +8 -0
  64. package/src/client/dist/pwa/assets/{DocumentsPanel-BJsJ0fNs.js → DocumentsPanel-D9vcB91W.js} +1 -1
  65. package/src/client/dist/pwa/assets/{DrawerToggleButton-BVLO5K5d.js → DrawerToggleButton-Ca0_CxSl.js} +1 -1
  66. package/src/client/dist/pwa/assets/GitPanel-DCmB2R5i.js +3 -0
  67. package/src/client/dist/pwa/assets/{HealthPage-RFZS7Lao.js → HealthPage-_q3vy_xn.js} +1 -1
  68. package/src/client/dist/pwa/assets/MainLayout-Duwnq3cc.js +2 -0
  69. package/src/client/dist/pwa/assets/{QBtnDropdown-X8oNU5Mb.js → QBtnDropdown-BeiZIHVA.js} +1 -1
  70. package/src/client/dist/pwa/assets/{QDrawer-Cij17skX.js → QDrawer-Fj3M9QGD.js} +1 -1
  71. package/src/client/dist/pwa/assets/QSelect-DtSUuK8t.js +1 -0
  72. package/src/client/dist/pwa/assets/{QTooltip-CwcCGSWq.js → QTooltip-DwkX8eQ6.js} +1 -1
  73. package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +1 -0
  74. package/src/client/dist/pwa/assets/SchedulePanel-BlZDd7TK.js +1 -0
  75. package/src/client/dist/pwa/assets/SearchPage-BGc1oQSM.css +1 -0
  76. package/src/client/dist/pwa/assets/SearchPage-BGi1yRxU.js +1 -0
  77. package/src/client/dist/pwa/assets/SettingsPage-DnO-ImWE.js +9 -0
  78. package/src/client/dist/pwa/assets/SettingsPage-sVlsJQ0b.css +1 -0
  79. package/src/client/dist/pwa/assets/{SplitWorkspacePage-D7lGFRKs.js → SplitWorkspacePage-vz6jN1DM.js} +1 -1
  80. package/src/client/dist/pwa/assets/{TerminalPanel-DI8DHb9e.js → TerminalPanel-IdPIGp2_.js} +9 -9
  81. package/src/client/dist/pwa/assets/{TouchPan-B3nrkCY8.js → TouchPan-DTJqxVp9.js} +1 -1
  82. package/src/client/dist/pwa/assets/{TourReplayButton-DU1UShMY.js → TourReplayButton-Bj2GGyhU.js} +1 -1
  83. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-C-yN5IX8.js → WorkspaceAttentionLabels-BBaGZlwv.js} +1 -1
  84. package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +1 -0
  85. package/src/client/dist/pwa/assets/WorkspacePage-Dy0spsfi.js +4 -0
  86. package/src/client/dist/pwa/assets/{browser-f9MHQjfi.js → browser-BFj5wwbS.js} +1 -1
  87. package/src/client/dist/pwa/assets/{build-path-tree-BWBzP8os.js → build-path-tree-C-HSiySk.js} +1 -1
  88. package/src/client/dist/pwa/assets/de-BdLe7apJ.js +17 -0
  89. package/src/client/dist/pwa/assets/{documents-C2cLnEBQ.js → documents-C2Xnjh9Z.js} +1 -1
  90. package/src/client/dist/pwa/assets/{editor.api-sw146MWN.js → editor.api-BbKfnNmv.js} +1 -1
  91. package/src/client/dist/pwa/assets/{efforts-C5ZZgCH4.js → efforts-BM7fdaaL.js} +1 -1
  92. package/src/client/dist/pwa/assets/engineFeatures-b74YRODT.js +1 -0
  93. package/src/client/dist/pwa/assets/es-C1LM-3aq.js +17 -0
  94. package/src/client/dist/pwa/assets/expand-template-DiDWiIkx.js +1 -0
  95. package/src/client/dist/pwa/assets/{format-BwWP6Aay.js → format-CnbUxAmw.js} +1 -1
  96. package/src/client/dist/pwa/assets/{formatters-DyWtO-nR.js → formatters-B2uUIbAY.js} +1 -1
  97. package/src/client/dist/pwa/assets/fr-KCK6r5ga.js +17 -0
  98. package/src/client/dist/pwa/assets/{freemarker2-DZ5Hm8UH.js → freemarker2-Cu1vGY0x.js} +1 -1
  99. package/src/client/dist/pwa/assets/{handlebars-BRUGpTA7.js → handlebars-CETuqLyz.js} +1 -1
  100. package/src/client/dist/pwa/assets/{html-DNOayNNs.js → html-BTNpr39P.js} +1 -1
  101. package/src/client/dist/pwa/assets/i18n-BkUirOzl.js +1 -0
  102. package/src/client/dist/pwa/assets/index-mvh5w1cM.js +20 -0
  103. package/src/client/dist/pwa/assets/it-BM6t0IGU.js +17 -0
  104. package/src/client/dist/pwa/assets/{javascript-C0tDQU3z.js → javascript-C7Ncb6V7.js} +1 -1
  105. package/src/client/dist/pwa/assets/{liquid-CN46O6OF.js → liquid-D0hUdg4x.js} +1 -1
  106. package/src/client/dist/pwa/assets/{mdx-DM-lrr8Q.js → mdx-BK5H1Xst.js} +1 -1
  107. package/src/client/dist/pwa/assets/{monaco-contributions-CkN7y-8c.js → monaco-contributions-BBW6JNgz.js} +1 -1
  108. package/src/client/dist/pwa/assets/{monaco.contribution-2BcAOLjc.js → monaco.contribution-1LZrAlY4.js} +2 -2
  109. package/src/client/dist/pwa/assets/network-auth-CVGS__6a.js +1 -0
  110. package/src/client/dist/pwa/assets/{notify-theme-a91Tcr6o.js → notify-theme-DIKLxr8F.js} +1 -1
  111. package/src/client/dist/pwa/assets/{pending-diff-open-DOtELZfB.js → pending-diff-open-B02zuH1j.js} +1 -1
  112. package/src/client/dist/pwa/assets/{permissionModes-C-9Crj-c.js → permissionModes-C3otN1Vk.js} +1 -1
  113. package/src/client/dist/pwa/assets/{project-color-BRe4oyNx.js → project-color-BUcW_eZA.js} +1 -1
  114. package/src/client/dist/pwa/assets/{python-BRpJG6sZ.js → python-DEeUVvnp.js} +1 -1
  115. package/src/client/dist/pwa/assets/{razor-CehCmvHl.js → razor-CG6cL8ya.js} +1 -1
  116. package/src/client/dist/pwa/assets/{render-chat-markdown-DlbQLZ02.js → render-chat-markdown-Ct_T5TPN.js} +1 -1
  117. package/src/client/dist/pwa/assets/{typescript-Dt_8mVuj.js → typescript-CSQAg5UC.js} +1 -1
  118. package/src/client/dist/pwa/assets/use-search-index-status-DtLzygcy.js +1 -0
  119. package/src/client/dist/pwa/assets/use-tours-_6HwJ_Rp.js +2 -0
  120. package/src/client/dist/pwa/assets/{workspace-templates-GCObcjO6.js → workspace-templates-B8WWsWEI.js} +1 -1
  121. package/src/client/dist/pwa/assets/{xml-_gOwdzB4.js → xml-BSB4-DXR.js} +1 -1
  122. package/src/client/dist/pwa/assets/{yaml-BxeBqpGf.js → yaml-CSGKph85.js} +1 -1
  123. package/src/client/dist/pwa/index.html +1 -1
  124. package/src/client/dist/pwa/sw.js +1 -1
  125. package/src/mcp-server/README.md +169 -9
  126. package/src/mcp-server/kobo-tasks-handlers.ts +14 -11
  127. package/src/mcp-server/kobo-tasks-server.ts +19 -2
  128. package/src/mcp-server/workspace-dialogue-client.ts +34 -0
  129. package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +0 -8
  130. package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +0 -1
  131. package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +0 -2
  132. package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +0 -1
  133. package/src/client/dist/pwa/assets/DiffViewer-BpsLNxnj.js +0 -8
  134. package/src/client/dist/pwa/assets/GitPanel-Cal_wzC0.js +0 -3
  135. package/src/client/dist/pwa/assets/MainLayout-vvyiRcql.js +0 -2
  136. package/src/client/dist/pwa/assets/QSelect-xd-AlGo_.js +0 -1
  137. package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +0 -1
  138. package/src/client/dist/pwa/assets/SchedulePanel-CXRUsz6G.js +0 -1
  139. package/src/client/dist/pwa/assets/SearchPage-CVgRa025.css +0 -1
  140. package/src/client/dist/pwa/assets/SearchPage-DopbgLxh.js +0 -1
  141. package/src/client/dist/pwa/assets/SettingsPage-Do9d0Pov.css +0 -1
  142. package/src/client/dist/pwa/assets/SettingsPage-Jx6TnOzR.js +0 -9
  143. package/src/client/dist/pwa/assets/WorkspacePage-BAC6u9OP.css +0 -1
  144. package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +0 -4
  145. package/src/client/dist/pwa/assets/de-QkDLrcGq.js +0 -17
  146. package/src/client/dist/pwa/assets/engineFeatures-CAVMDawq.js +0 -1
  147. package/src/client/dist/pwa/assets/es-BgQFKlo8.js +0 -17
  148. package/src/client/dist/pwa/assets/expand-template-B6xAlAaM.js +0 -1
  149. package/src/client/dist/pwa/assets/fr-DAdLWjNM.js +0 -17
  150. package/src/client/dist/pwa/assets/i18n-CBez_vip.js +0 -1
  151. package/src/client/dist/pwa/assets/index-VL3fI9a3.js +0 -20
  152. package/src/client/dist/pwa/assets/it-Bumaewp1.js +0 -17
  153. package/src/client/dist/pwa/assets/network-auth-KYS4snQH.js +0 -1
  154. package/src/client/dist/pwa/assets/use-tours-CSVM_54K.js +0 -2
@@ -63,8 +63,22 @@ function rowToCron(row) {
63
63
  lastFiredAt: row.last_fired_at,
64
64
  oneShot: row.one_shot === 1,
65
65
  createdAt: row.created_at,
66
+ ...(expressionError(row.expression) ? { validationError: expressionError(row.expression) } : {}),
66
67
  };
67
68
  }
69
+ function expressionError(expression) {
70
+ const trimmed = expression.trim();
71
+ if (!['@hourly', '@daily', '@weekly', '@monthly', '@yearly'].includes(trimmed) && trimmed.split(/\s+/).length !== 5) {
72
+ return `Invalid cron expression: ${expression} — expected five fields or a supported alias`;
73
+ }
74
+ try {
75
+ CronExpressionParser.parse(trimmed);
76
+ return null;
77
+ }
78
+ catch (err) {
79
+ return `Invalid cron expression: ${expression} — ${err instanceof Error ? err.message : String(err)}`;
80
+ }
81
+ }
68
82
  /**
69
83
  * Compute the next fire time for an expression strictly after `from`.
70
84
  * Uses cron-parser 5.x API. Throws with a descriptive error if the
@@ -72,6 +86,9 @@ function rowToCron(row) {
72
86
  * `@monthly` / `@yearly` are accepted natively.
73
87
  */
74
88
  function nextAfter(expression, from) {
89
+ const validationError = expressionError(expression);
90
+ if (validationError)
91
+ throw new Error(validationError);
75
92
  try {
76
93
  const it = CronExpressionParser.parse(expression, { currentDate: from });
77
94
  return it.next().toDate();
@@ -173,6 +190,8 @@ function fireOrSkip(id) {
173
190
  const row = db.prepare('SELECT * FROM pending_crons WHERE id = ?').get(id);
174
191
  if (!row)
175
192
  return; // cancelled in flight
193
+ if (expressionError(row.expression))
194
+ return; // Retain invalid legacy intent without executing it.
176
195
  const wsRow = db
177
196
  .prepare(`SELECT project_path, working_branch, worktree_path, model, agent_permission_mode, reasoning_effort, archived_at
178
197
  FROM workspaces WHERE id = ?`)
@@ -212,6 +231,9 @@ function fireOrSkip(id) {
212
231
  const now = new Date();
213
232
  let nextFire;
214
233
  try {
234
+ // Valid five-field schedules have at most one occurrence per minute.
235
+ // Skip missed slots from now, without adding a minute of timer jitter
236
+ // that would discard the next scheduled occurrence too.
215
237
  nextFire = nextAfter(row.expression, now);
216
238
  }
217
239
  catch (err) {
@@ -220,7 +242,6 @@ function fireOrSkip(id) {
220
242
  // arm and this fire. Cancel as 'completed' (the cron self-terminates)
221
243
  // rather than 'user' which would imply the user requested it.
222
244
  console.error(`[cron-service] failed to recompute next fire for cron '${id}':`, err);
223
- cancel(id, 'completed');
224
245
  return;
225
246
  }
226
247
  // One-shot crons cancel themselves after a real fire (not on skip-active —
@@ -278,6 +299,11 @@ export function restoreOnBoot() {
278
299
  db.prepare('DELETE FROM pending_crons WHERE id = ?').run(row.id);
279
300
  continue;
280
301
  }
302
+ const validationError = expressionError(row.expression);
303
+ if (validationError) {
304
+ console.warn(`[cron-service] ${validationError}; cron ${row.id} remains inactive`);
305
+ continue;
306
+ }
281
307
  const storedDate = new Date(row.next_fire_at);
282
308
  let nextFireAt;
283
309
  if (storedDate.getTime() <= now.getTime()) {
@@ -39,14 +39,18 @@ function writeIndex(imagesDir, entries) {
39
39
  throw new Error('Symbolic links are not allowed for image index');
40
40
  }
41
41
  const tempPath = path.join(imagesDir, `.index-${randomUUID()}.tmp`);
42
- let created = false;
42
+ const descriptor = fs.openSync(tempPath, 'wx', 0o600);
43
43
  try {
44
- fs.writeFileSync(tempPath, JSON.stringify(entries, null, 2), { flag: 'wx', mode: 0o600 });
45
- created = true;
44
+ try {
45
+ fs.writeFileSync(descriptor, JSON.stringify(entries, null, 2));
46
+ }
47
+ finally {
48
+ fs.closeSync(descriptor);
49
+ }
46
50
  fs.renameSync(tempPath, indexPath);
47
51
  }
48
52
  finally {
49
- if (created && fs.existsSync(tempPath))
53
+ if (fs.existsSync(tempPath))
50
54
  fs.unlinkSync(tempPath);
51
55
  }
52
56
  }
@@ -63,11 +67,16 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
63
67
  const imagesDir = ensureRealImagesDirectory(worktreePath);
64
68
  const filename = `${uid}.${ext}`;
65
69
  await withLock(worktreePath, () => {
66
- // Write the image file inside the lock so both the file write and index
67
- // update happen atomically — avoids orphan files on crash between the two.
70
+ // Serialize image/index writes and clean up incomplete writes on failure.
68
71
  const imagePath = path.join(imagesDir, filename);
69
- fs.writeFileSync(imagePath, fileBuffer, { flag: 'wx', mode: 0o600 });
72
+ const descriptor = fs.openSync(imagePath, 'wx', 0o600);
70
73
  try {
74
+ try {
75
+ fs.writeFileSync(descriptor, fileBuffer);
76
+ }
77
+ finally {
78
+ fs.closeSync(descriptor);
79
+ }
71
80
  const entries = readIndex(imagesDir);
72
81
  entries.push({ uid, originalName, createdAt: new Date().toISOString() });
73
82
  writeIndex(imagesDir, entries);
@@ -0,0 +1,12 @@
1
+ import { normalizeClientName } from '../../shared/workspace-message-types.js';
2
+ export function createMcpClientContext(name, transport = 'http', encoding) {
3
+ if (encoding === 'uri' && typeof name === 'string') {
4
+ try {
5
+ name = decodeURIComponent(name);
6
+ }
7
+ catch {
8
+ name = undefined;
9
+ }
10
+ }
11
+ return { kind: 'mcp', clientName: normalizeClientName(name), transport };
12
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { getCompiledMcpServerPath, getDbPath, getMcpServerSourcePath } from '../utils/paths.js';
6
+ import { getLanHostnames, isAllowedRequestHost, resolveProxyHostname } from './network-access-service.js';
7
+ /** No request/forwarded headers or credentials participate in generated metadata. */
8
+ export function buildMcpConnectionInfo(input) {
9
+ const backendUrl = `http://127.0.0.1:${input.port}`;
10
+ const args = input.compiledPath
11
+ ? [input.compiledPath]
12
+ : input.sourcePath && input.loaderPath
13
+ ? ['--import', pathToFileURL(input.loaderPath).href, input.sourcePath]
14
+ : null;
15
+ return {
16
+ localUrl: `${backendUrl}/api/mcp`,
17
+ lanUrls: input.networkEnabled
18
+ ? input.lanHostnames
19
+ .map((host) => `${host.includes(':') ? `[${host}]` : host}:${input.port}`)
20
+ .filter((host) => isAllowedRequestHost({
21
+ host,
22
+ enabled: input.networkEnabled,
23
+ lanHostnames: input.lanHostnames,
24
+ behindProxy: input.behindProxy,
25
+ proxyHostname: input.proxyHostname,
26
+ }))
27
+ .map((host) => `http://${host}/api/mcp`)
28
+ : [],
29
+ networkEnabled: input.networkEnabled,
30
+ behindProxy: input.behindProxy,
31
+ proxyHostname: input.proxyHostname,
32
+ localRequiresToken: input.behindProxy,
33
+ stdio: args
34
+ ? {
35
+ command: input.command,
36
+ args,
37
+ env: { KOBO_DB_PATH: input.databasePath, KOBO_BACKEND_URL: backendUrl, KOBO_WORKSPACE_ID: '' },
38
+ }
39
+ : null,
40
+ };
41
+ }
42
+ export function getMcpConnectionInfo(port, settings, databasePath = getDbPath()) {
43
+ const source = getMcpServerSourcePath();
44
+ let loaderPath = null;
45
+ try {
46
+ loaderPath = createRequire(import.meta.url).resolve('tsx');
47
+ }
48
+ catch {
49
+ /* Production installs can omit the development loader. */
50
+ }
51
+ return buildMcpConnectionInfo({
52
+ port,
53
+ networkEnabled: settings.networkAccessEnabled,
54
+ behindProxy: settings.networkAccessBehindProxy,
55
+ proxyHostname: resolveProxyHostname(),
56
+ lanHostnames: getLanHostnames(),
57
+ command: process.execPath,
58
+ compiledPath: getCompiledMcpServerPath(),
59
+ sourcePath: fs.existsSync(source) ? source : null,
60
+ loaderPath,
61
+ databasePath: path.resolve(databasePath),
62
+ });
63
+ }
@@ -0,0 +1,59 @@
1
+ import { getDb } from '../db/index.js';
2
+ import { finishMessageRequest, markMessageDispatching, reserveMessageRequest, } from './mcp-message-request-service.js';
3
+ import { broadcastPersistedEvent, persistWorkspaceEvent } from './websocket-service.js';
4
+ import { deliverWorkspaceMessage } from './workspace-message-service.js';
5
+ export async function deliverKeyedMcpMessage(workspaceId, key, message) {
6
+ const db = getDb();
7
+ const request = reserveMessageRequest(db, workspaceId, key, message.content, message.sessionId);
8
+ if (!request.fresh)
9
+ return request.result;
10
+ let dispatched = false;
11
+ let accepted;
12
+ try {
13
+ await deliverWorkspaceMessage(workspaceId, { ...message, clientMessageId: request.requestId }, {
14
+ beforeDispatch() {
15
+ // The normal closed-engine fallback may enter this boundary again
16
+ // while resuming the very same request, never a second reservation.
17
+ if (dispatched)
18
+ return;
19
+ markMessageDispatching(db, request.requestId);
20
+ dispatched = true;
21
+ },
22
+ persist(payload, sessionId) {
23
+ const committed = db.transaction(() => {
24
+ const event = persistWorkspaceEvent(workspaceId, 'user:message', payload, sessionId);
25
+ const result = { accepted: true, requestId: request.requestId, eventId: event.id, sessionId };
26
+ finishMessageRequest(db, request.requestId, 'accepted', result);
27
+ return { event, result };
28
+ })();
29
+ accepted = committed.result;
30
+ broadcastPersistedEvent(committed.event);
31
+ },
32
+ });
33
+ if (!accepted)
34
+ throw new Error('Delivery completed without a durable receipt');
35
+ return accepted;
36
+ }
37
+ catch (error) {
38
+ // A notification failure cannot invalidate an already committed receipt.
39
+ if (accepted)
40
+ return accepted;
41
+ const result = {
42
+ accepted: false,
43
+ requestId: request.requestId,
44
+ code: dispatched ? 'delivery_unknown' : 'delivery_rejected',
45
+ message: dispatched
46
+ ? 'Delivery may have occurred; inspect history before sending a new request'
47
+ : error instanceof Error
48
+ ? error.message
49
+ : String(error),
50
+ };
51
+ try {
52
+ finishMessageRequest(db, request.requestId, dispatched ? 'unknown' : 'rejected', result);
53
+ }
54
+ catch {
55
+ /* The reserved/dispatching row still prevents redelivery if storage failed. */
56
+ }
57
+ return result;
58
+ }
59
+ }
@@ -0,0 +1,64 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { nanoid } from 'nanoid';
3
+ export function reserveMessageRequest(db, workspaceId, key, content, sessionId) {
4
+ const fingerprint = createHash('sha256')
5
+ .update(JSON.stringify([content, sessionId ?? null]))
6
+ .digest('hex');
7
+ return db
8
+ .transaction(() => {
9
+ const requestId = `mcp-${nanoid()}`;
10
+ const now = new Date().toISOString();
11
+ const inserted = db
12
+ .prepare(`INSERT INTO mcp_message_requests(workspace_id,idempotency_key,request_id,fingerprint,state,created_at,updated_at)
13
+ VALUES (?,?,?,?,'reserved',?,?) ON CONFLICT(workspace_id,idempotency_key) DO NOTHING`)
14
+ .run(workspaceId, key, requestId, fingerprint, now, now);
15
+ if (inserted.changes)
16
+ return { fresh: true, requestId };
17
+ const row = db
18
+ .prepare('SELECT request_id,fingerprint,state,result_json FROM mcp_message_requests WHERE workspace_id=? AND idempotency_key=?')
19
+ .get(workspaceId, key);
20
+ let result;
21
+ if (row.fingerprint !== fingerprint)
22
+ result = {
23
+ accepted: false,
24
+ requestId: row.request_id,
25
+ code: 'idempotency_conflict',
26
+ message: 'This key was already used for different content or a different session',
27
+ };
28
+ else if (row.result_json) {
29
+ result = JSON.parse(row.result_json);
30
+ if (result.accepted)
31
+ result = { ...result, replayed: true };
32
+ }
33
+ else
34
+ result = {
35
+ accepted: false,
36
+ requestId: row.request_id,
37
+ code: row.state === 'unknown' ? 'delivery_unknown' : 'in_progress',
38
+ message: row.state === 'unknown'
39
+ ? 'Delivery may have occurred; inspect history before sending a new request'
40
+ : 'This request is still in progress; retry the same key to check its outcome',
41
+ };
42
+ return { fresh: false, requestId: row.request_id, result };
43
+ })
44
+ .immediate();
45
+ }
46
+ export function markMessageDispatching(db, requestId) {
47
+ const result = db
48
+ .prepare("UPDATE mcp_message_requests SET state='dispatching',updated_at=? WHERE request_id=? AND state='reserved'")
49
+ .run(new Date().toISOString(), requestId);
50
+ if (result.changes !== 1)
51
+ throw new Error('MCP request is no longer reserved');
52
+ }
53
+ export function finishMessageRequest(db, requestId, state, result) {
54
+ const changed = db
55
+ .prepare(`UPDATE mcp_message_requests SET state=?,result_json=?,event_id=?,session_id=?,updated_at=?
56
+ WHERE request_id=? AND state IN ('reserved','dispatching')`)
57
+ .run(state, JSON.stringify(result), result.accepted ? result.eventId : null, result.accepted ? (result.sessionId ?? null) : null, new Date().toISOString(), requestId);
58
+ if (changed.changes !== 1)
59
+ throw new Error('MCP request is no longer pending');
60
+ }
61
+ /** Backend startup only: never assume an interrupted engine delivery did not happen. */
62
+ export function reconcileMessageRequests(db) {
63
+ db.prepare("UPDATE mcp_message_requests SET state='unknown',updated_at=? WHERE state IN ('reserved','dispatching')").run(new Date().toISOString());
64
+ }
@@ -9,7 +9,7 @@ import { fingerprintWorktree } from '../utils/git-worktree-fingerprint.js';
9
9
  import { withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
10
10
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
11
11
  import { hasController } from './agent/orchestrator.js';
12
- import { createWorktree, listWorktrees, removeWorktreeUnlocked } from './worktree-service.js';
12
+ import { createWorktreeUnlocked, listWorktrees, removeWorktreeUnlocked } from './worktree-service.js';
13
13
  /**
14
14
  * A `pathCollision.worktreePath` decision is untrusted input reaching
15
15
  * `git worktree add` directly — including from a network-access client that
@@ -81,9 +81,9 @@ function hasCommonAncestor(repoPath, branch) {
81
81
  }
82
82
  }
83
83
  /** Locate any worktree already sitting on `branch`, or at the target path. */
84
- function diagnoseWorktree(projectPath, branch, targetPath, attachedPaths) {
84
+ async function diagnoseWorktree(projectPath, branch, targetPath, attachedPaths) {
85
85
  const blockers = [];
86
- const worktrees = listWorktrees(projectPath);
86
+ const worktrees = await listWorktrees(projectPath);
87
87
  const onBranch = worktrees.find((wt) => wt.branch === branch);
88
88
  if (onBranch) {
89
89
  if (!fs.existsSync(onBranch.path)) {
@@ -163,7 +163,7 @@ export async function computeFingerprint(report) {
163
163
  * `attachedPaths` maps worktree path -> workspace id; the route supplies it so
164
164
  * this function stays free of database access and easy to test.
165
165
  */
166
- export function diagnoseLocalState(projectPath, headBranch, worktreesPath, attachedPaths = new Map(), workspaces = []) {
166
+ export async function diagnoseLocalState(projectPath, headBranch, worktreesPath, attachedPaths = new Map(), workspaces = []) {
167
167
  const targetWorktreePath = resolveWorkspaceWorktreePath(projectPath, headBranch, worktreesPath);
168
168
  const blockers = [];
169
169
  const lockPath = gitOps.getIndexLockPath(projectPath);
@@ -174,7 +174,7 @@ export function diagnoseLocalState(projectPath, headBranch, worktreesPath, attac
174
174
  !hasCommonAncestor(projectPath, headBranch)) {
175
175
  blockers.push({ kind: 'no-common-ancestor', branch: headBranch });
176
176
  }
177
- const { worktree, blockers: worktreeBlockers } = diagnoseWorktree(projectPath, headBranch, targetWorktreePath, attachedPaths);
177
+ const { worktree, blockers: worktreeBlockers } = await diagnoseWorktree(projectPath, headBranch, targetWorktreePath, attachedPaths);
178
178
  blockers.push(...worktreeBlockers);
179
179
  const inspectPath = worktree.state === 'orphan' || worktree.state === 'attached' ? worktree.path : null;
180
180
  return {
@@ -256,8 +256,8 @@ export class StaleDiagnosisError extends Error {
256
256
  * `change-source-branch-service`, so it cannot interleave with a worktree purge
257
257
  * or a source-branch change.
258
258
  */
259
- export function resolvePrCheckout(input) {
260
- const initial = diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths, input.workspaces);
259
+ export async function resolvePrCheckout(input) {
260
+ const initial = await diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths, input.workspaces);
261
261
  const owner = initial.worktree.state === 'attached'
262
262
  ? initial.worktree.workspaceId
263
263
  : initial.workspace.state !== 'none'
@@ -274,7 +274,7 @@ function resolvePrCheckoutLocked(input, owner) {
274
274
  // 1. Refresh, then refuse a plan built on facts that no longer hold.
275
275
  gitOps.fetchSourceBranch(input.projectPath, input.headBranch);
276
276
  applied.push({ kind: 'fetch', detail: `origin/${input.headBranch}` });
277
- const fresh = diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths ?? new Map(), input.workspaces ?? []);
277
+ const fresh = await diagnoseLocalState(input.projectPath, input.headBranch, input.worktreesPath, input.attachedPaths ?? new Map(), input.workspaces ?? []);
278
278
  if ((await computeFingerprint(fresh)) !== input.fingerprint)
279
279
  throw new StaleDiagnosisError(fresh);
280
280
  if ((fresh.worktree.state === 'orphan' || fresh.worktree.state === 'attached') &&
@@ -346,7 +346,7 @@ function resolvePrCheckoutLocked(input, owner) {
346
346
  const baseRef = gitOps.localBranchExists(input.projectPath, input.headBranch)
347
347
  ? input.headBranch
348
348
  : `origin/${input.headBranch}`;
349
- const target = createWorktree(input.projectPath, input.headBranch, baseRef, input.worktreesPath, undefined, explicitPath);
349
+ const target = await createWorktreeUnlocked(input.projectPath, input.headBranch, baseRef, input.worktreesPath, undefined, explicitPath);
350
350
  worktreePath = target.worktreePath;
351
351
  created = true;
352
352
  applied.push({ kind: 'create-worktree', detail: worktreePath });
@@ -361,7 +361,7 @@ function resolvePrCheckoutLocked(input, owner) {
361
361
  catch (err) {
362
362
  if (created) {
363
363
  try {
364
- removeWorktreeUnlocked(input.projectPath, worktreePath);
364
+ await removeWorktreeUnlocked(input.projectPath, worktreePath);
365
365
  }
366
366
  catch (cleanupErr) {
367
367
  console.error('[pr-checkout] rollback failed to remove the worktree:', cleanupErr);
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { fetchSourceBranchAsync } from '../utils/git-ops.js';
3
3
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
4
- import { isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
4
+ import { isWorkspaceLifecycleBusy, withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
5
5
  import { hasController } from './agent/orchestrator.js';
6
6
  import { stopDevServer } from './dev-server-service.js';
7
7
  import { getForgeProvider } from './forge/registry.js';
@@ -99,19 +99,24 @@ function markUnread(workspaceId) {
99
99
  console.error('[pr-watcher] markUnread failed:', err instanceof Error ? err.message : err);
100
100
  }
101
101
  }
102
- function autoRestoreManuallyRecreatedWorktrees() {
102
+ async function autoRestoreManuallyRecreatedWorktrees() {
103
103
  for (const ws of listArchivedWorkspaces()) {
104
104
  if (!ws.worktreePurgedAt || !ws.worktreeOwned || isWorkspaceLifecycleBusy(ws.id))
105
105
  continue;
106
106
  if (!fs.existsSync(ws.worktreePath))
107
107
  continue;
108
- if (!isMatchingWorkspaceWorktree(ws))
109
- continue;
110
108
  try {
111
- const restored = restoreWorktreeFromDisk(ws.id);
112
- invalidateWorkspacePrCaches(ws.id);
113
- emitEphemeral(ws.id, 'workspace:worktree-restored', { workspace: restored });
114
- console.log(`[pr-watcher] auto-restored worktree for workspace '${ws.name}' (manual restore detected)`);
109
+ await withWorkspaceLifecycleGuard(ws.id, async () => {
110
+ const current = getWorkspace(ws.id);
111
+ if (!current?.worktreePurgedAt || !current.worktreeOwned)
112
+ return;
113
+ if (!(await isMatchingWorkspaceWorktree(current)))
114
+ return;
115
+ const restored = restoreWorktreeFromDisk(ws.id);
116
+ invalidateWorkspacePrCaches(ws.id);
117
+ emitEphemeral(ws.id, 'workspace:worktree-restored', { workspace: restored });
118
+ console.log(`[pr-watcher] auto-restored worktree for workspace '${ws.name}' (manual restore detected)`);
119
+ });
115
120
  }
116
121
  catch (err) {
117
122
  console.error(`[pr-watcher] auto-restore failed for '${ws.name}':`, err instanceof Error ? err.message : err);
@@ -119,7 +124,7 @@ function autoRestoreManuallyRecreatedWorktrees() {
119
124
  }
120
125
  }
121
126
  export async function checkPrStatuses() {
122
- autoRestoreManuallyRecreatedWorktrees();
127
+ await autoRestoreManuallyRecreatedWorktrees();
123
128
  const workspaces = listWorkspaces(false); // non-archived only
124
129
  // Clean up entries for workspaces that no longer exist
125
130
  for (const id of lastKnownPr.keys()) {