@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.
- package/AGENTS.md +101 -32
- package/CHANGELOG.md +8 -0
- package/README.md +6 -4
- package/dist/mcp-server/kobo-tasks-handlers.js +16 -9
- package/dist/mcp-server/kobo-tasks-server.js +13 -2
- package/dist/mcp-server/workspace-dialogue-client.js +28 -0
- package/dist/server/db/mcp-message-schema.js +16 -0
- package/dist/server/db/migrations.js +4 -0
- package/dist/server/db/schema.js +4 -0
- package/dist/server/db/search-schema.js +53 -0
- package/dist/server/index.js +17 -86
- package/dist/server/middleware/network-auth-middleware.js +5 -1
- package/dist/server/routes/changelog.js +4 -49
- package/dist/server/routes/git.js +2 -2
- package/dist/server/routes/images.js +65 -5
- package/dist/server/routes/mcp.js +47 -0
- package/dist/server/routes/pull-requests.js +1 -1
- package/dist/server/routes/search.js +13 -4
- package/dist/server/routes/settings.js +5 -0
- package/dist/server/routes/workspaces.js +250 -248
- package/dist/server/services/agent/engines/claude-code/engine.js +31 -33
- package/dist/server/services/agent/engines/codex/engine.js +37 -8
- package/dist/server/services/agent/engines/codex/event-mapper.js +3 -1
- package/dist/server/services/agent/engines/codex/options-builder.js +4 -3
- package/dist/server/services/agent/engines/codex/server-requests.js +8 -1
- package/dist/server/services/agent/orchestrator.js +20 -3
- package/dist/server/services/attachment-service.js +122 -0
- package/dist/server/services/change-source-branch-service.js +10 -42
- package/dist/server/services/chat-receipt-service.js +33 -0
- package/dist/server/services/cron-service.js +27 -1
- package/dist/server/services/image-service.js +16 -7
- package/dist/server/services/mcp-client-context.js +12 -0
- package/dist/server/services/mcp-connection-info-service.js +63 -0
- package/dist/server/services/mcp-message-delivery-service.js +59 -0
- package/dist/server/services/mcp-message-request-service.js +64 -0
- package/dist/server/services/pr-checkout-service.js +10 -10
- package/dist/server/services/pr-watcher-service.js +14 -9
- package/dist/server/services/search/indexer.js +232 -0
- package/dist/server/services/search/worker.js +76 -0
- package/dist/server/services/search-service.js +104 -110
- package/dist/server/services/update-check-service.js +119 -0
- package/dist/server/services/websocket-service.js +121 -35
- package/dist/server/services/workspace-dialogue-service.js +128 -0
- package/dist/server/services/workspace-message-service.js +58 -0
- package/dist/server/services/workspace-permission-policy-service.js +33 -3
- package/dist/server/services/worktree-restore-service.js +2 -2
- package/dist/server/services/worktree-service.js +197 -124
- package/dist/server/utils/bounded-process.js +137 -0
- package/dist/server/utils/git-ops.js +76 -128
- package/dist/server/utils/git-repo-lock.js +27 -13
- package/dist/shared/attachments.js +58 -0
- package/dist/shared/workspace-dialogue-tools.js +89 -0
- package/dist/shared/workspace-message-types.js +19 -0
- package/package.json +2 -1
- package/src/client/dist/pwa/assets/{ActivityFeed-Chsw1Mh7.css → ActivityFeed-BYR5x7aa.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-DTIe6O9o.js +8 -0
- package/src/client/dist/pwa/assets/{ChangelogPage-D8563008.js → ChangelogPage-BWeu0YeI.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-Bd0X08sg.js → ComparisonPanel-o_EHlTwV.js} +2 -2
- package/src/client/dist/pwa/assets/CreatePage-CGi88k6z.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-DbY5gBv8.js +2 -0
- package/src/client/dist/pwa/assets/DashboardPage-CIo_gesD.js +1 -0
- package/src/client/dist/pwa/assets/{DiffViewer-DnR79ycb.css → DiffViewer-DEPOa3D7.css} +1 -1
- package/src/client/dist/pwa/assets/DiffViewer-DEcf48KI.js +8 -0
- package/src/client/dist/pwa/assets/{DocumentsPanel-BJsJ0fNs.js → DocumentsPanel-D9vcB91W.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-BVLO5K5d.js → DrawerToggleButton-Ca0_CxSl.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-DCmB2R5i.js +3 -0
- package/src/client/dist/pwa/assets/{HealthPage-RFZS7Lao.js → HealthPage-_q3vy_xn.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-Duwnq3cc.js +2 -0
- package/src/client/dist/pwa/assets/{QBtnDropdown-X8oNU5Mb.js → QBtnDropdown-BeiZIHVA.js} +1 -1
- package/src/client/dist/pwa/assets/{QDrawer-Cij17skX.js → QDrawer-Fj3M9QGD.js} +1 -1
- package/src/client/dist/pwa/assets/QSelect-DtSUuK8t.js +1 -0
- package/src/client/dist/pwa/assets/{QTooltip-CwcCGSWq.js → QTooltip-DwkX8eQ6.js} +1 -1
- package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +1 -0
- package/src/client/dist/pwa/assets/SchedulePanel-BlZDd7TK.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-BGc1oQSM.css +1 -0
- package/src/client/dist/pwa/assets/SearchPage-BGi1yRxU.js +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-DnO-ImWE.js +9 -0
- package/src/client/dist/pwa/assets/SettingsPage-sVlsJQ0b.css +1 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-D7lGFRKs.js → SplitWorkspacePage-vz6jN1DM.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-DI8DHb9e.js → TerminalPanel-IdPIGp2_.js} +9 -9
- package/src/client/dist/pwa/assets/{TouchPan-B3nrkCY8.js → TouchPan-DTJqxVp9.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-DU1UShMY.js → TourReplayButton-Bj2GGyhU.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-C-yN5IX8.js → WorkspaceAttentionLabels-BBaGZlwv.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-Dy0spsfi.js +4 -0
- package/src/client/dist/pwa/assets/{browser-f9MHQjfi.js → browser-BFj5wwbS.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-BWBzP8os.js → build-path-tree-C-HSiySk.js} +1 -1
- package/src/client/dist/pwa/assets/de-BdLe7apJ.js +17 -0
- package/src/client/dist/pwa/assets/{documents-C2cLnEBQ.js → documents-C2Xnjh9Z.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-sw146MWN.js → editor.api-BbKfnNmv.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-C5ZZgCH4.js → efforts-BM7fdaaL.js} +1 -1
- package/src/client/dist/pwa/assets/engineFeatures-b74YRODT.js +1 -0
- package/src/client/dist/pwa/assets/es-C1LM-3aq.js +17 -0
- package/src/client/dist/pwa/assets/expand-template-DiDWiIkx.js +1 -0
- package/src/client/dist/pwa/assets/{format-BwWP6Aay.js → format-CnbUxAmw.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-DyWtO-nR.js → formatters-B2uUIbAY.js} +1 -1
- package/src/client/dist/pwa/assets/fr-KCK6r5ga.js +17 -0
- package/src/client/dist/pwa/assets/{freemarker2-DZ5Hm8UH.js → freemarker2-Cu1vGY0x.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-BRUGpTA7.js → handlebars-CETuqLyz.js} +1 -1
- package/src/client/dist/pwa/assets/{html-DNOayNNs.js → html-BTNpr39P.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-BkUirOzl.js +1 -0
- package/src/client/dist/pwa/assets/index-mvh5w1cM.js +20 -0
- package/src/client/dist/pwa/assets/it-BM6t0IGU.js +17 -0
- package/src/client/dist/pwa/assets/{javascript-C0tDQU3z.js → javascript-C7Ncb6V7.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-CN46O6OF.js → liquid-D0hUdg4x.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-DM-lrr8Q.js → mdx-BK5H1Xst.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-CkN7y-8c.js → monaco-contributions-BBW6JNgz.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-2BcAOLjc.js → monaco.contribution-1LZrAlY4.js} +2 -2
- package/src/client/dist/pwa/assets/network-auth-CVGS__6a.js +1 -0
- package/src/client/dist/pwa/assets/{notify-theme-a91Tcr6o.js → notify-theme-DIKLxr8F.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-DOtELZfB.js → pending-diff-open-B02zuH1j.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-C-9Crj-c.js → permissionModes-C3otN1Vk.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-BRe4oyNx.js → project-color-BUcW_eZA.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BRpJG6sZ.js → python-DEeUVvnp.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-CehCmvHl.js → razor-CG6cL8ya.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-DlbQLZ02.js → render-chat-markdown-Ct_T5TPN.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-Dt_8mVuj.js → typescript-CSQAg5UC.js} +1 -1
- package/src/client/dist/pwa/assets/use-search-index-status-DtLzygcy.js +1 -0
- package/src/client/dist/pwa/assets/use-tours-_6HwJ_Rp.js +2 -0
- package/src/client/dist/pwa/assets/{workspace-templates-GCObcjO6.js → workspace-templates-B8WWsWEI.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-_gOwdzB4.js → xml-BSB4-DXR.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-BxeBqpGf.js → yaml-CSGKph85.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/README.md +169 -9
- package/src/mcp-server/kobo-tasks-handlers.ts +14 -11
- package/src/mcp-server/kobo-tasks-server.ts +19 -2
- package/src/mcp-server/workspace-dialogue-client.ts +34 -0
- package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +0 -8
- package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +0 -2
- package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +0 -1
- package/src/client/dist/pwa/assets/DiffViewer-BpsLNxnj.js +0 -8
- package/src/client/dist/pwa/assets/GitPanel-Cal_wzC0.js +0 -3
- package/src/client/dist/pwa/assets/MainLayout-vvyiRcql.js +0 -2
- package/src/client/dist/pwa/assets/QSelect-xd-AlGo_.js +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +0 -1
- package/src/client/dist/pwa/assets/SchedulePanel-CXRUsz6G.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CVgRa025.css +0 -1
- package/src/client/dist/pwa/assets/SearchPage-DopbgLxh.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-Do9d0Pov.css +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-Jx6TnOzR.js +0 -9
- package/src/client/dist/pwa/assets/WorkspacePage-BAC6u9OP.css +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +0 -4
- package/src/client/dist/pwa/assets/de-QkDLrcGq.js +0 -17
- package/src/client/dist/pwa/assets/engineFeatures-CAVMDawq.js +0 -1
- package/src/client/dist/pwa/assets/es-BgQFKlo8.js +0 -17
- package/src/client/dist/pwa/assets/expand-template-B6xAlAaM.js +0 -1
- package/src/client/dist/pwa/assets/fr-DAdLWjNM.js +0 -17
- package/src/client/dist/pwa/assets/i18n-CBez_vip.js +0 -1
- package/src/client/dist/pwa/assets/index-VL3fI9a3.js +0 -20
- package/src/client/dist/pwa/assets/it-Bumaewp1.js +0 -17
- package/src/client/dist/pwa/assets/network-auth-KYS4snQH.js +0 -1
- 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
|
-
|
|
42
|
+
const descriptor = fs.openSync(tempPath, 'wx', 0o600);
|
|
43
43
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
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 (
|
|
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
|
-
//
|
|
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.
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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()) {
|