@loicngr/kobo 2.1.22 → 2.1.24
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 +72 -1
- package/CHANGELOG.md +8 -0
- package/CONFIGURATION.md +145 -3
- package/dist/mcp-server/kobo-tasks-server.js +7 -6
- package/dist/server/index.js +21 -6
- package/dist/server/routes/mcp.js +13 -7
- package/dist/server/services/agent/orchestrator.js +1 -0
- package/dist/server/services/dev-server-service.js +44 -72
- package/dist/server/services/lifecycle-hook-service.js +21 -2
- package/dist/server/services/mcp-message-delivery-service.js +1 -1
- package/dist/server/services/mcp-message-request-service.js +11 -5
- package/dist/server/services/settings-service.js +51 -0
- package/dist/server/services/workspace-dialogue-service.js +2 -0
- package/dist/server/services/workspace-message-service.js +61 -5
- package/dist/server/utils/dev-server-instance.js +58 -0
- package/dist/server/utils/script-runner.js +6 -1
- package/dist/shared/launch-instructions.js +8 -4
- package/dist/shared/mcp-attachments.js +58 -0
- package/dist/shared/memory-tools.js +2 -2
- package/dist/shared/workspace-creation-tools.js +4 -28
- package/dist/shared/workspace-dialogue-tools.js +18 -6
- package/docker-compose.vps.yml +1 -1
- package/docs/deployment-vps.md +11 -0
- package/package.json +1 -1
- package/scripts/kobo-update +6 -2
- package/src/client/dist/pwa/assets/ActivityFeed-BYspOKKg.js +2 -0
- package/src/client/dist/pwa/assets/{ActivityFeed-DdJvMzRI.css → ActivityFeed-CmAFv-2c.css} +1 -1
- package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-CwnNUWCh.js → AutoLoopFinalReviewSettings-DXX3CqrY.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-D9ke0TYz.js → ChangelogPage-DKkI5IU-.js} +1 -1
- package/src/client/dist/pwa/assets/{ComparisonPanel-ytDu1L7a.js → ComparisonPanel-Cw5Dkan0.js} +1 -1
- package/src/client/dist/pwa/assets/{CreatePage-YQ_jGilf.js → CreatePage-B7PLFOH6.js} +1 -1
- package/src/client/dist/pwa/assets/{DashboardPage-C-PyArla.js → DashboardPage-Dw8nfkYR.js} +1 -1
- package/src/client/dist/pwa/assets/{DiffViewer-CPobwSwm.js → DiffViewer-DFlDwkmd.js} +3 -3
- package/src/client/dist/pwa/assets/{DocumentsPanel-BjZGNI-p.js → DocumentsPanel-CH0KzdJV.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-Dtd6fhYu.js → DrawerToggleButton-CZVpU8HH.js} +1 -1
- package/src/client/dist/pwa/assets/{GitPanel-BHJ3lpJZ.js → GitPanel-DjKL30gM.js} +3 -3
- package/src/client/dist/pwa/assets/{HealthPage-Lv_IdF1l.js → HealthPage-DnPp8GSx.js} +1 -1
- package/src/client/dist/pwa/assets/{MainLayout-DYumlWcP.js → MainLayout-1PaKfzdy.js} +2 -2
- package/src/client/dist/pwa/assets/{MemoryPanel-DS2F3x-S.js → MemoryPanel-BarliK2r.js} +1 -1
- package/src/client/dist/pwa/assets/{MemoryProposalList-D_KDS3uX.js → MemoryProposalList-BtVjVPp7.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnDropdown-XkBxmJO9.js → QBtnDropdown-Rzurb7nO.js} +1 -1
- package/src/client/dist/pwa/assets/{QExpansionItem-B5vbHxJ2.js → QExpansionItem-CaKEBcyx.js} +1 -1
- package/src/client/dist/pwa/assets/{QScrollArea-BE_fAg20.js → QScrollArea-hrsAyyJf.js} +1 -1
- package/src/client/dist/pwa/assets/{QSelect-BjZk4-r8.js → QSelect-Dr7rAt6u.js} +1 -1
- package/src/client/dist/pwa/assets/{QTooltip-Dhn6VXWR.js → QTooltip-CNMsXTmD.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-BDhIt9pX.js → SchedulePanel-nDw2eZ9K.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-CJrtIenZ.js → SearchPage-BXJ6iVt1.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-Dcq1Jeoo.css +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-SHpE2q9Z.js +9 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-BV62otQw.js → SplitWorkspacePage-tfZ1kAdZ.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-TQUHtZ-q.js → TerminalPanel-Cw8R7lAw.js} +1 -1
- package/src/client/dist/pwa/assets/{ToolCallItem-DxdYefuz.js → ToolCallItem-DEKhFJEv.js} +2 -2
- package/src/client/dist/pwa/assets/{TouchPan-CtwfZgkX.js → TouchPan-DybGzRRK.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-DEC_Yt6E.js → TourReplayButton-BNdlAZUR.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-BehGsWhI.js → WorkspaceAttentionLabels-B0H9omcK.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspacePage-Bk-WWOYM.css → WorkspacePage-B-82Rw9T.css} +1 -1
- package/src/client/dist/pwa/assets/{WorkspacePage-CQz6QL5L.js → WorkspacePage-tWGXnmrz.js} +3 -3
- package/src/client/dist/pwa/assets/{browser-B3bGJv7H.js → browser-DUTZhU-w.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-Bwwe_8vU.js → build-path-tree-ZThcsIN5.js} +1 -1
- package/src/client/dist/pwa/assets/{custom-sounds-1Rb1a5LA.js → custom-sounds-CquHn2gw.js} +1 -1
- package/src/client/dist/pwa/assets/{de-bfOiFUu1.js → de-B3H0T-ft.js} +2 -2
- package/src/client/dist/pwa/assets/{editor.api-D2PTZoSF.js → editor.api-pSvhgban.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-JkWc_mU5.js → efforts-DJaFsLgd.js} +1 -1
- package/src/client/dist/pwa/assets/{es-C5Sk8pec.js → es-B4lACW6k.js} +2 -2
- package/src/client/dist/pwa/assets/{expand-template-C-aGZz7k.js → expand-template-C5D_p5eV.js} +1 -1
- package/src/client/dist/pwa/assets/{format-BbXB3nKS.js → format-DRfnkeRP.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-Bu3lC0Jk.js → formatters-Cog2icjt.js} +1 -1
- package/src/client/dist/pwa/assets/{fr-BNVJp67K.js → fr-3uNi02Jz.js} +2 -2
- package/src/client/dist/pwa/assets/{freemarker2-qq6O820q.js → freemarker2-W-OLsqOk.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-nXATCa7t.js → handlebars-Kt5RbWuO.js} +1 -1
- package/src/client/dist/pwa/assets/{html-ZSNyTQeY.js → html-D5VKPBbB.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-CEzCwMir.js +1 -0
- package/src/client/dist/pwa/assets/{index-BZKHE14f.js → index-BZwhT2-I.js} +4 -4
- package/src/client/dist/pwa/assets/{it-B9g24VRZ.js → it-DtFWyLHb.js} +2 -2
- package/src/client/dist/pwa/assets/{javascript-B3Elzrc0.js → javascript-DBjx8Bg2.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-CrMGh9mA.js → liquid-DYT4a7D7.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-C6nxSGQ5.js → mdx-9L1kysEY.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-WtuTHJJ8.js → monaco-contributions-DAme5TZS.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution--KeSH1BU.js → monaco.contribution-C58k7TPw.js} +2 -2
- package/src/client/dist/pwa/assets/{network-auth-DknPaiQS.js → network-auth-BDUlgV-U.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-DTaB0RsK.js → notify-theme-DrQB-mrb.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-bm_jpHka.js → project-color-BlV5PUUW.js} +1 -1
- package/src/client/dist/pwa/assets/{python-CQI_FwfK.js → python-DJIlKr-Y.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-BT2wh4_7.js → razor-CC3uqdaH.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-D3ibItMf.js → typescript-BnnkT9oE.js} +1 -1
- package/src/client/dist/pwa/assets/{use-search-index-status-BnFR-_2l.js → use-search-index-status-CExdHIar.js} +1 -1
- package/src/client/dist/pwa/assets/{use-tours-CsPhvrxf.js → use-tours-DOUjqO2f.js} +2 -2
- package/src/client/dist/pwa/assets/{use-worktree-restore-B5s0BuHk.js → use-worktree-restore-zIOYNIcb.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-GLO1mBlj.js → workspace-templates-DXJG6flv.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-EIEQlO3Y.js → xml-DJtVmsJT.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-CJdHLr6G.js → yaml-Dwuw8FcP.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 +48 -4
- package/src/mcp-server/kobo-tasks-server.ts +8 -10
- package/src/client/dist/pwa/assets/ActivityFeed-2wu6qXx1.js +0 -2
- package/src/client/dist/pwa/assets/SettingsPage-BSwN45zt.js +0 -9
- package/src/client/dist/pwa/assets/SettingsPage-CNIpxG-U.css +0 -1
- package/src/client/dist/pwa/assets/i18n-B_b02gtS.js +0 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync
|
|
3
|
-
import
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { resolveInstance, sanitizeBranchName } from '../utils/dev-server-instance.js';
|
|
4
4
|
import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
|
|
5
5
|
import { getProjectSettings } from './settings-service.js';
|
|
6
6
|
import { emitEphemeral } from './websocket-service.js';
|
|
7
7
|
import { getWorkspace, listWorkspaces, updateDevServerStatus } from './workspace-service.js';
|
|
8
|
+
export { parseEnvFile, resolveInstance, sanitizeBranchName } from '../utils/dev-server-instance.js';
|
|
8
9
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
9
10
|
/** Build a clean env for child processes, stripping Kobo-specific variables. */
|
|
10
11
|
function cleanEnv() {
|
|
@@ -26,68 +27,16 @@ function runCommand(command, args, options = {}) {
|
|
|
26
27
|
const trackedProcesses = new Map();
|
|
27
28
|
const stoppingProcesses = new Map();
|
|
28
29
|
const generations = new Map();
|
|
30
|
+
let shuttingDown = false;
|
|
31
|
+
export function beginDevServerShutdown() {
|
|
32
|
+
shuttingDown = true;
|
|
33
|
+
}
|
|
29
34
|
/** Test-only: clear the tracked-processes map between tests. */
|
|
30
35
|
export function _resetTrackedProcessesForTests() {
|
|
31
36
|
trackedProcesses.clear();
|
|
32
37
|
stoppingProcesses.clear();
|
|
33
38
|
generations.clear();
|
|
34
|
-
|
|
35
|
-
// ── Pure helpers ───────────────────────────────────────────────────────────────
|
|
36
|
-
/**
|
|
37
|
-
* Sanitize a branch name for use as a Docker instance name.
|
|
38
|
-
* Replace `/` and `_` with `-`, lowercase.
|
|
39
|
-
*/
|
|
40
|
-
export function sanitizeBranchName(branch) {
|
|
41
|
-
return branch.toLowerCase().replace(/[/_]/g, '-');
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Parse a `.env` file content into key=value pairs.
|
|
45
|
-
* Skips empty lines and comments (#). Handles quotes.
|
|
46
|
-
*/
|
|
47
|
-
export function parseEnvFile(content) {
|
|
48
|
-
const result = {};
|
|
49
|
-
for (const line of content.split('\n')) {
|
|
50
|
-
const trimmed = line.trim();
|
|
51
|
-
if (!trimmed || trimmed.startsWith('#'))
|
|
52
|
-
continue;
|
|
53
|
-
const eqIndex = trimmed.indexOf('=');
|
|
54
|
-
if (eqIndex === -1)
|
|
55
|
-
continue;
|
|
56
|
-
const key = trimmed.slice(0, eqIndex).trim();
|
|
57
|
-
let value = trimmed.slice(eqIndex + 1).trim();
|
|
58
|
-
// Strip surrounding quotes
|
|
59
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
60
|
-
value = value.slice(1, -1);
|
|
61
|
-
}
|
|
62
|
-
result[key] = value;
|
|
63
|
-
}
|
|
64
|
-
return result;
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Resolve the dev-server instance config for a given project + branch.
|
|
68
|
-
* Looks in `<projectPath>/.container/instances/` for `.env` files.
|
|
69
|
-
*/
|
|
70
|
-
export function resolveInstance(projectPath, workingBranch) {
|
|
71
|
-
const instancesDir = path.join(projectPath, '.container', 'instances');
|
|
72
|
-
if (!existsSync(instancesDir))
|
|
73
|
-
return null;
|
|
74
|
-
const sanitized = sanitizeBranchName(workingBranch);
|
|
75
|
-
const files = readdirSync(instancesDir).filter((f) => f.endsWith('.env'));
|
|
76
|
-
for (const file of files) {
|
|
77
|
-
const content = readFileSync(path.join(instancesDir, file), 'utf-8');
|
|
78
|
-
const parsed = parseEnvFile(content);
|
|
79
|
-
if (parsed.INSTANCE_NAME &&
|
|
80
|
-
parsed.INSTANCE_NAME.toLowerCase() === sanitized &&
|
|
81
|
-
parsed.PROJECT_NAME &&
|
|
82
|
-
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(parsed.PROJECT_NAME)) {
|
|
83
|
-
return {
|
|
84
|
-
instanceName: parsed.INSTANCE_NAME,
|
|
85
|
-
projectName: parsed.PROJECT_NAME,
|
|
86
|
-
httpPort: parsed.HTTP_PORT ?? '',
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return null;
|
|
39
|
+
shuttingDown = false;
|
|
91
40
|
}
|
|
92
41
|
// ── Docker helpers ─────────────────────────────────────────────────────────────
|
|
93
42
|
/**
|
|
@@ -177,6 +126,8 @@ export async function getStatus(projectPath, workingBranch, workspaceId) {
|
|
|
177
126
|
* Start the dev-server for a workspace.
|
|
178
127
|
*/
|
|
179
128
|
export function startDevServer(workspaceId) {
|
|
129
|
+
if (shuttingDown)
|
|
130
|
+
throw new Error('Kōbō is shutting down');
|
|
180
131
|
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
181
132
|
const workspace = getWorkspace(workspaceId);
|
|
182
133
|
if (!workspace) {
|
|
@@ -231,7 +182,7 @@ export function startDevServer(workspaceId) {
|
|
|
231
182
|
return;
|
|
232
183
|
trackedProcesses.delete(workspaceId);
|
|
233
184
|
const currentStatus = await getStatus(workspace.projectPath, workspace.workingBranch);
|
|
234
|
-
if (generations.get(workspaceId) !== generation || stoppingProcesses.has(workspaceId))
|
|
185
|
+
if (shuttingDown || generations.get(workspaceId) !== generation || stoppingProcesses.has(workspaceId))
|
|
235
186
|
return;
|
|
236
187
|
updateDevServerStatus(workspaceId, currentStatus.status);
|
|
237
188
|
emitEphemeral(workspaceId, 'devserver:status', currentStatus);
|
|
@@ -244,7 +195,7 @@ export function startDevServer(workspaceId) {
|
|
|
244
195
|
console.error(`[dev-server] Process exited with code ${code} for workspace ${workspaceId}`);
|
|
245
196
|
});
|
|
246
197
|
proc.on('error', (err) => {
|
|
247
|
-
if (trackedProcesses.get(workspaceId) !== proc)
|
|
198
|
+
if (shuttingDown || trackedProcesses.get(workspaceId) !== proc || generations.get(workspaceId) !== generation)
|
|
248
199
|
return;
|
|
249
200
|
// A signal failure is also an 'error' event; it does not prove process exit.
|
|
250
201
|
if (!proc.pid)
|
|
@@ -373,6 +324,16 @@ function signalAndWait(proc, signal, timeoutMs) {
|
|
|
373
324
|
}
|
|
374
325
|
});
|
|
375
326
|
}
|
|
327
|
+
async function stopTrackedProcess(workspaceId) {
|
|
328
|
+
const tracked = trackedProcesses.get(workspaceId);
|
|
329
|
+
if (!tracked)
|
|
330
|
+
return;
|
|
331
|
+
const exited = (await signalAndWait(tracked, 'SIGTERM', 3_000)) || (await signalAndWait(tracked, 'SIGKILL', 1_000));
|
|
332
|
+
if (!exited)
|
|
333
|
+
throw new Error('Dev server stop is not confirmed; retry after the process exits');
|
|
334
|
+
if (trackedProcesses.get(workspaceId) === tracked)
|
|
335
|
+
trackedProcesses.delete(workspaceId);
|
|
336
|
+
}
|
|
376
337
|
async function stopDevServerOwned(workspaceId) {
|
|
377
338
|
const workspace = getWorkspace(workspaceId);
|
|
378
339
|
if (!workspace) {
|
|
@@ -383,14 +344,7 @@ async function stopDevServerOwned(workspaceId) {
|
|
|
383
344
|
const worktreePath = workspace.worktreePath;
|
|
384
345
|
const cwd = worktreePath && existsSync(worktreePath) ? worktreePath : workspace.projectPath;
|
|
385
346
|
// Kill tracked process first (covers Node servers and any spawned process)
|
|
386
|
-
|
|
387
|
-
if (tracked) {
|
|
388
|
-
const exited = (await signalAndWait(tracked, 'SIGTERM', 3_000)) || (await signalAndWait(tracked, 'SIGKILL', 1_000));
|
|
389
|
-
if (!exited)
|
|
390
|
-
throw new Error('Dev server stop is not confirmed; retry after the process exits');
|
|
391
|
-
if (trackedProcesses.get(workspaceId) === tracked)
|
|
392
|
-
trackedProcesses.delete(workspaceId);
|
|
393
|
-
}
|
|
347
|
+
await stopTrackedProcess(workspaceId);
|
|
394
348
|
const settings = getProjectSettings(workspace.projectPath);
|
|
395
349
|
let stopError;
|
|
396
350
|
let customStopSucceeded = false;
|
|
@@ -448,12 +402,30 @@ async function stopDevServerOwned(workspaceId) {
|
|
|
448
402
|
emitEphemeral(workspaceId, 'devserver:status', status);
|
|
449
403
|
return status;
|
|
450
404
|
}
|
|
451
|
-
/**
|
|
452
|
-
export async function stopAllDevServers() {
|
|
405
|
+
/** Shut down local processes; optionally leave independently running Docker services alone. */
|
|
406
|
+
export async function stopAllDevServers(options = {}) {
|
|
407
|
+
beginDevServerShutdown();
|
|
453
408
|
const workspaceIds = new Set([...listWorkspaces(true).map((workspace) => workspace.id), ...trackedProcesses.keys()]);
|
|
454
409
|
await Promise.all([...workspaceIds].map(async (workspaceId) => {
|
|
455
410
|
try {
|
|
456
|
-
|
|
411
|
+
if (!options.preserveDocker) {
|
|
412
|
+
await stopDevServer(workspaceId);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
// A stop already requested by the user must still finish.
|
|
416
|
+
const stopping = stoppingProcesses.get(workspaceId);
|
|
417
|
+
if (stopping) {
|
|
418
|
+
await stopping;
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const hadProcess = trackedProcesses.has(workspaceId);
|
|
422
|
+
await stopTrackedProcess(workspaceId);
|
|
423
|
+
const workspace = getWorkspace(workspaceId);
|
|
424
|
+
// Preserve Docker state; getStatus re-inspects Compose after restart.
|
|
425
|
+
if (hadProcess && workspace && !resolveInstance(workspace.projectPath, workspace.workingBranch)) {
|
|
426
|
+
updateDevServerStatus(workspaceId, 'stopped');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
457
429
|
}
|
|
458
430
|
catch (err) {
|
|
459
431
|
console.error(`[dev-server] Failed to stop '${workspaceId}' during shutdown:`, err);
|
|
@@ -4,11 +4,14 @@ import { slugifyProjectName } from '../utils/project-slug.js';
|
|
|
4
4
|
import { runScript } from '../utils/script-runner.js';
|
|
5
5
|
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
6
6
|
import { getEffectiveSettings, getGlobalSettings, getProjectSettings } from './settings-service.js';
|
|
7
|
+
import { listWorkspaces } from './workspace-service.js';
|
|
8
|
+
export const SHUTDOWN_SCRIPT_TIMEOUT_MS = 60_000;
|
|
7
9
|
/** Which effective-settings key holds the script for each event. */
|
|
8
10
|
const SCRIPT_KEY = {
|
|
9
11
|
'session-ended': 'sessionEndedScript',
|
|
10
12
|
'pr-merged': 'prMergedScript',
|
|
11
13
|
'autoloop-disabled': 'autoLoopDisabledScript',
|
|
14
|
+
'kobo-shutdown': 'shutdownScript',
|
|
12
15
|
};
|
|
13
16
|
function getRow(workspaceId) {
|
|
14
17
|
const row = getDb()
|
|
@@ -33,7 +36,7 @@ export async function runLifecycleHook(event, workspaceId, extraEnv) {
|
|
|
33
36
|
const script = getEffectiveSettings(row.project_path)[SCRIPT_KEY[event]];
|
|
34
37
|
// Empty (or blank) means the user never wrote one. Hooks are opt-in: they
|
|
35
38
|
// run arbitrary shell, so absence of configuration is absence of a hook.
|
|
36
|
-
if (!script
|
|
39
|
+
if (!script?.trim())
|
|
37
40
|
return null;
|
|
38
41
|
const global = getGlobalSettings();
|
|
39
42
|
const projectSettings = getProjectSettings(row.project_path);
|
|
@@ -55,10 +58,11 @@ export async function runLifecycleHook(event, workspaceId, extraEnv) {
|
|
|
55
58
|
workspaceId,
|
|
56
59
|
worktreePath,
|
|
57
60
|
script,
|
|
58
|
-
// Namespaced per event:
|
|
61
|
+
// Namespaced per event: multiple hooks streaming into one `hook:output` feed
|
|
59
62
|
// would be indistinguishable in the UI.
|
|
60
63
|
eventPrefix: `hook:${event}`,
|
|
61
64
|
tmpFileName: `.hook-${event}.tmp`,
|
|
65
|
+
...(event === 'kobo-shutdown' ? { timeoutMs: SHUTDOWN_SCRIPT_TIMEOUT_MS } : {}),
|
|
62
66
|
env: {
|
|
63
67
|
workspaceName: row.name,
|
|
64
68
|
branchName: row.working_branch,
|
|
@@ -73,6 +77,21 @@ export async function runLifecycleHook(event, workspaceId, extraEnv) {
|
|
|
73
77
|
return null;
|
|
74
78
|
}
|
|
75
79
|
}
|
|
80
|
+
/** Called once by graceful shutdown, after stopping writers and before Docker teardown. */
|
|
81
|
+
export async function onKoboShutdown(signal, isAgentStillRunning) {
|
|
82
|
+
await Promise.all(listWorkspaces(true)
|
|
83
|
+
.filter((workspace) => !workspace.worktreePurgedAt)
|
|
84
|
+
.map(async (workspace) => {
|
|
85
|
+
if (isAgentStillRunning(workspace.id)) {
|
|
86
|
+
console.warn(`[lifecycle-hook] 'kobo-shutdown' skipped for '${workspace.id}': agent stop unconfirmed`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const result = await runLifecycleHook('kobo-shutdown', workspace.id, { KOBO_SHUTDOWN_SIGNAL: signal });
|
|
90
|
+
if (result && result.exitCode !== 0) {
|
|
91
|
+
console.error(`[lifecycle-hook] 'kobo-shutdown' failed for '${workspace.id}' with code ${result.exitCode}`);
|
|
92
|
+
}
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
76
95
|
/**
|
|
77
96
|
* An agent session ended. Fires for every real end — clean finish, error, user
|
|
78
97
|
* stop, watchdog kill — and lets the script tell them apart via
|
|
@@ -4,7 +4,7 @@ import { broadcastPersistedEvent, persistWorkspaceEvent } from './websocket-serv
|
|
|
4
4
|
import { deliverWorkspaceMessage } from './workspace-message-service.js';
|
|
5
5
|
export async function deliverKeyedMcpMessage(workspaceId, key, message) {
|
|
6
6
|
const db = getDb();
|
|
7
|
-
const request = reserveMessageRequest(db, workspaceId, key, message.content, message.sessionId);
|
|
7
|
+
const request = reserveMessageRequest(db, workspaceId, key, message.content, message.sessionId, message.attachments);
|
|
8
8
|
if (!request.fresh)
|
|
9
9
|
return request.result;
|
|
10
10
|
let dispatched = false;
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { nanoid } from 'nanoid';
|
|
3
|
-
export function reserveMessageRequest(db, workspaceId, key, content, sessionId) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
export function reserveMessageRequest(db, workspaceId, key, content, sessionId, attachments) {
|
|
4
|
+
// Keep text-only fingerprints compatible with receipts issued before attachment support.
|
|
5
|
+
const identity = [content, sessionId ?? null];
|
|
6
|
+
if (attachments?.length)
|
|
7
|
+
identity.push(attachments.map((file) => [
|
|
8
|
+
file.name,
|
|
9
|
+
file.mime_type,
|
|
10
|
+
createHash('sha256').update(file.data_base64).digest('hex'),
|
|
11
|
+
]));
|
|
12
|
+
const fingerprint = createHash('sha256').update(JSON.stringify(identity)).digest('hex');
|
|
7
13
|
return db
|
|
8
14
|
.transaction(() => {
|
|
9
15
|
const requestId = `mcp-${nanoid()}`;
|
|
@@ -23,7 +29,7 @@ export function reserveMessageRequest(db, workspaceId, key, content, sessionId)
|
|
|
23
29
|
accepted: false,
|
|
24
30
|
requestId: row.request_id,
|
|
25
31
|
code: 'idempotency_conflict',
|
|
26
|
-
message: 'This key was already used for different content or
|
|
32
|
+
message: 'This key was already used for different content, session or attachments',
|
|
27
33
|
};
|
|
28
34
|
else if (row.result_json) {
|
|
29
35
|
result = JSON.parse(row.result_json);
|
|
@@ -996,6 +996,28 @@ const settingsMigrations = [
|
|
|
996
996
|
global.showProgressBars = true;
|
|
997
997
|
},
|
|
998
998
|
},
|
|
999
|
+
{
|
|
1000
|
+
version: 66,
|
|
1001
|
+
name: 'shutdown-services-and-hooks',
|
|
1002
|
+
migrate: ({ global, projects }) => {
|
|
1003
|
+
if (typeof global.stopDevServersOnShutdown !== 'boolean')
|
|
1004
|
+
global.stopDevServersOnShutdown = true;
|
|
1005
|
+
if (typeof global.shutdownScript !== 'string')
|
|
1006
|
+
global.shutdownScript = '';
|
|
1007
|
+
for (const project of projects) {
|
|
1008
|
+
if (typeof project.shutdownScript !== 'string')
|
|
1009
|
+
project.shutdownScript = '';
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
},
|
|
1013
|
+
{
|
|
1014
|
+
version: 67,
|
|
1015
|
+
name: 'opt-in-immediate-chat-messages',
|
|
1016
|
+
migrate: ({ global }) => {
|
|
1017
|
+
if (typeof global.forceImmediateMessages !== 'boolean')
|
|
1018
|
+
global.forceImmediateMessages = false;
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
999
1021
|
];
|
|
1000
1022
|
/** Current settings schema version — always equals the highest migration version. */
|
|
1001
1023
|
export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
|
|
@@ -1044,6 +1066,7 @@ function defaultSettings() {
|
|
|
1044
1066
|
archiveScript: '',
|
|
1045
1067
|
changeSourceBranchScript: DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT,
|
|
1046
1068
|
sessionEndedScript: '',
|
|
1069
|
+
shutdownScript: '',
|
|
1047
1070
|
prMergedScript: '',
|
|
1048
1071
|
autoLoopDisabledScript: '',
|
|
1049
1072
|
editorCommand: '',
|
|
@@ -1052,7 +1075,9 @@ function defaultSettings() {
|
|
|
1052
1075
|
autoPurgeOnPrMerged: false,
|
|
1053
1076
|
autoLoopMaxRetries: 5,
|
|
1054
1077
|
agentWatchdogEnabled: false,
|
|
1078
|
+
forceImmediateMessages: false,
|
|
1055
1079
|
showProgressBars: true,
|
|
1080
|
+
stopDevServersOnShutdown: true,
|
|
1056
1081
|
awaitingUserReminderMinutes: 0,
|
|
1057
1082
|
activityDigestEnabled: true,
|
|
1058
1083
|
maxConcurrentAgents: 2,
|
|
@@ -1156,6 +1181,7 @@ function defaultProjectSettings(projectPath) {
|
|
|
1156
1181
|
archiveScript: '',
|
|
1157
1182
|
changeSourceBranchScript: '',
|
|
1158
1183
|
sessionEndedScript: '',
|
|
1184
|
+
shutdownScript: '',
|
|
1159
1185
|
prMergedScript: '',
|
|
1160
1186
|
autoLoopDisabledScript: '',
|
|
1161
1187
|
devServer: {
|
|
@@ -1201,11 +1227,19 @@ export function runSettingsMigrations(raw) {
|
|
|
1201
1227
|
current.global.workflowPolicy = resolveWorkflowPolicy(current.global.workflowPolicy);
|
|
1202
1228
|
if (!isMemoryMode(current.global.memoryMode))
|
|
1203
1229
|
current.global.memoryMode = 'hybrid';
|
|
1230
|
+
if (typeof current.global.forceImmediateMessages !== 'boolean')
|
|
1231
|
+
current.global.forceImmediateMessages = false;
|
|
1204
1232
|
if (typeof current.global.showProgressBars !== 'boolean')
|
|
1205
1233
|
current.global.showProgressBars = true;
|
|
1234
|
+
if (typeof current.global.stopDevServersOnShutdown !== 'boolean')
|
|
1235
|
+
current.global.stopDevServersOnShutdown = true;
|
|
1236
|
+
if (typeof current.global.shutdownScript !== 'string')
|
|
1237
|
+
current.global.shutdownScript = '';
|
|
1206
1238
|
if (typeof current.global.agentWatchdogEnabled !== 'boolean')
|
|
1207
1239
|
current.global.agentWatchdogEnabled = false;
|
|
1208
1240
|
for (const project of current.projects) {
|
|
1241
|
+
if (typeof project.shutdownScript !== 'string')
|
|
1242
|
+
project.shutdownScript = '';
|
|
1209
1243
|
if (!isWorkflowPolicy(project.workflowPolicy))
|
|
1210
1244
|
project.workflowPolicy = {};
|
|
1211
1245
|
}
|
|
@@ -1478,6 +1512,7 @@ export function getEffectiveSettings(projectPath) {
|
|
|
1478
1512
|
archiveScript: settings.global.archiveScript,
|
|
1479
1513
|
changeSourceBranchScript: settings.global.changeSourceBranchScript,
|
|
1480
1514
|
sessionEndedScript: settings.global.sessionEndedScript,
|
|
1515
|
+
shutdownScript: settings.global.shutdownScript,
|
|
1481
1516
|
prMergedScript: settings.global.prMergedScript,
|
|
1482
1517
|
autoLoopDisabledScript: settings.global.autoLoopDisabledScript,
|
|
1483
1518
|
notionStatusProperty: settings.global.notionStatusProperty,
|
|
@@ -1508,6 +1543,7 @@ export function getEffectiveSettings(projectPath) {
|
|
|
1508
1543
|
archiveScript: project.archiveScript || settings.global.archiveScript,
|
|
1509
1544
|
changeSourceBranchScript: project.changeSourceBranchScript || settings.global.changeSourceBranchScript,
|
|
1510
1545
|
sessionEndedScript: project.sessionEndedScript || settings.global.sessionEndedScript,
|
|
1546
|
+
shutdownScript: project.shutdownScript || settings.global.shutdownScript,
|
|
1511
1547
|
prMergedScript: project.prMergedScript || settings.global.prMergedScript,
|
|
1512
1548
|
autoLoopDisabledScript: project.autoLoopDisabledScript || settings.global.autoLoopDisabledScript,
|
|
1513
1549
|
notionStatusProperty: settings.global.notionStatusProperty,
|
|
@@ -1557,6 +1593,15 @@ export function updateGlobalSettings(input) {
|
|
|
1557
1593
|
delete data.autoLoopMaxRetries;
|
|
1558
1594
|
}
|
|
1559
1595
|
}
|
|
1596
|
+
if ('shutdownScript' in data && typeof data.shutdownScript !== 'string') {
|
|
1597
|
+
delete data.shutdownScript;
|
|
1598
|
+
}
|
|
1599
|
+
if ('stopDevServersOnShutdown' in data && typeof data.stopDevServersOnShutdown !== 'boolean') {
|
|
1600
|
+
delete data.stopDevServersOnShutdown;
|
|
1601
|
+
}
|
|
1602
|
+
if ('forceImmediateMessages' in data && typeof data.forceImmediateMessages !== 'boolean') {
|
|
1603
|
+
delete data.forceImmediateMessages;
|
|
1604
|
+
}
|
|
1560
1605
|
if ('showProgressBars' in data && typeof data.showProgressBars !== 'boolean') {
|
|
1561
1606
|
delete data.showProgressBars;
|
|
1562
1607
|
}
|
|
@@ -1619,6 +1664,7 @@ export function updateGlobalSettings(input) {
|
|
|
1619
1664
|
'archiveScript',
|
|
1620
1665
|
'changeSourceBranchScript',
|
|
1621
1666
|
'sessionEndedScript',
|
|
1667
|
+
'shutdownScript',
|
|
1622
1668
|
'prMergedScript',
|
|
1623
1669
|
'autoLoopDisabledScript',
|
|
1624
1670
|
'editorCommand',
|
|
@@ -1627,7 +1673,9 @@ export function updateGlobalSettings(input) {
|
|
|
1627
1673
|
'autoPurgeOnPrMerged',
|
|
1628
1674
|
'autoLoopMaxRetries',
|
|
1629
1675
|
'agentWatchdogEnabled',
|
|
1676
|
+
'forceImmediateMessages',
|
|
1630
1677
|
'showProgressBars',
|
|
1678
|
+
'stopDevServersOnShutdown',
|
|
1631
1679
|
'awaitingUserReminderMinutes',
|
|
1632
1680
|
'activityDigestEnabled',
|
|
1633
1681
|
'maxConcurrentAgents',
|
|
@@ -1823,6 +1871,8 @@ export function upsertProject(projectPath, data) {
|
|
|
1823
1871
|
}
|
|
1824
1872
|
if ('workflowPolicy' in data && !isWorkflowPolicy(data.workflowPolicy))
|
|
1825
1873
|
throw new Error('Invalid workflowPolicy');
|
|
1874
|
+
if ('shutdownScript' in data && typeof data.shutdownScript !== 'string')
|
|
1875
|
+
throw new Error('Invalid shutdownScript');
|
|
1826
1876
|
const allowedProjectKeys = [
|
|
1827
1877
|
'workflowPolicy',
|
|
1828
1878
|
'displayName',
|
|
@@ -1842,6 +1892,7 @@ export function upsertProject(projectPath, data) {
|
|
|
1842
1892
|
'archiveScript',
|
|
1843
1893
|
'changeSourceBranchScript',
|
|
1844
1894
|
'sessionEndedScript',
|
|
1895
|
+
'shutdownScript',
|
|
1845
1896
|
'prMergedScript',
|
|
1846
1897
|
'autoLoopDisabledScript',
|
|
1847
1898
|
'devServer',
|
|
@@ -85,6 +85,7 @@ export async function executeWorkspaceDialogueTool(name, input, source = createM
|
|
|
85
85
|
if (name === 'send_workspace_message' && args.idempotency_key)
|
|
86
86
|
return deliverKeyedMcpMessage(workspaceId, args.idempotency_key, {
|
|
87
87
|
content: args.content,
|
|
88
|
+
...(args.attachments ? { attachments: args.attachments } : {}),
|
|
88
89
|
sessionId: args.session_id,
|
|
89
90
|
source,
|
|
90
91
|
});
|
|
@@ -93,6 +94,7 @@ export async function executeWorkspaceDialogueTool(name, input, source = createM
|
|
|
93
94
|
accepted: true,
|
|
94
95
|
...(await deliverWorkspaceMessage(workspaceId, {
|
|
95
96
|
content: args.content,
|
|
97
|
+
...(args.attachments ? { attachments: args.attachments } : {}),
|
|
96
98
|
sessionId: args.session_id,
|
|
97
99
|
clientMessageId: `mcp-${nanoid()}`,
|
|
98
100
|
source,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { validateMcpAttachments } from '../../shared/mcp-attachments.js';
|
|
2
|
+
import { assertWorkspaceLifecycleAvailable, withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
|
|
2
3
|
import { isAgentUnavailableError, isShuttingDown, sendMessage, startAgent } from './agent/orchestrator.js';
|
|
4
|
+
import { attachmentReference, removeAttachments, saveAttachments } from './attachment-service.js';
|
|
3
5
|
import { hasLoopMessage } from './auto-loop-message-service.js';
|
|
4
6
|
import * as autoLoop from './auto-loop-service.js';
|
|
5
7
|
import { createChatReceipt } from './chat-receipt-service.js';
|
|
@@ -7,10 +9,28 @@ import { emitEphemeral } from './websocket-service.js';
|
|
|
7
9
|
import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './workspace-service.js';
|
|
8
10
|
/** Shared delivery for WebSocket chat and external MCP clients. */
|
|
9
11
|
export async function deliverWorkspaceMessage(workspaceId, message, hooks) {
|
|
12
|
+
// Keep the caller's original content unchanged for durable idempotency fingerprints.
|
|
13
|
+
message = { ...message };
|
|
10
14
|
const receipt = createChatReceipt(workspaceId, message, hooks?.persist);
|
|
15
|
+
let saved = [];
|
|
16
|
+
let attachmentWorktree;
|
|
17
|
+
let dispatched = false;
|
|
18
|
+
const beforeDispatch = () => {
|
|
19
|
+
if (saved.length) {
|
|
20
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
21
|
+
const current = getWorkspace(workspaceId);
|
|
22
|
+
if (!current || current.archivedAt || current.worktreePurgedAt || current.worktreePath !== attachmentWorktree)
|
|
23
|
+
throw new Error('Workspace changed before attachment delivery');
|
|
24
|
+
if (current.status === 'compacting' || current.status === 'awaiting-user' || isShuttingDown())
|
|
25
|
+
throw new Error('Workspace is not ready to receive a message');
|
|
26
|
+
}
|
|
27
|
+
hooks?.beforeDispatch?.();
|
|
28
|
+
dispatched = true;
|
|
29
|
+
};
|
|
11
30
|
let reason;
|
|
12
31
|
try {
|
|
13
|
-
|
|
32
|
+
const attachments = validateMcpAttachments(message.attachments);
|
|
33
|
+
if (!message.content.trim() && !attachments.length)
|
|
14
34
|
throw new Error('Message content must not be empty');
|
|
15
35
|
if (isShuttingDown())
|
|
16
36
|
throw new Error('Kōbō is shutting down');
|
|
@@ -21,6 +41,8 @@ export async function deliverWorkspaceMessage(workspaceId, message, hooks) {
|
|
|
21
41
|
if (workspace.archivedAt || workspace.worktreePurgedAt)
|
|
22
42
|
throw new Error('Restore the workspace before sending a message');
|
|
23
43
|
if (message.delivery === 'next_iteration') {
|
|
44
|
+
if (attachments.length)
|
|
45
|
+
throw new Error('Attachments require immediate delivery');
|
|
24
46
|
if (!message.clientMessageId)
|
|
25
47
|
throw new Error('clientMessageId is required for queued delivery');
|
|
26
48
|
const acceptedBefore = hasLoopMessage(workspaceId, message.clientMessageId);
|
|
@@ -45,11 +67,28 @@ export async function deliverWorkspaceMessage(workspaceId, message, hooks) {
|
|
|
45
67
|
reason = 'awaiting-user';
|
|
46
68
|
throw new Error('Answer the pending question or permission request before sending a chat message');
|
|
47
69
|
}
|
|
70
|
+
if (attachments.length) {
|
|
71
|
+
attachmentWorktree = workspace.worktreePath;
|
|
72
|
+
await withWorkspaceLifecycleGuard(workspaceId, async () => {
|
|
73
|
+
const files = attachments.map((file) => new File([Buffer.from(file.data_base64, 'base64')], file.name, { type: file.mime_type }));
|
|
74
|
+
saved = await saveAttachments(workspace.worktreePath, files);
|
|
75
|
+
});
|
|
76
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
77
|
+
const current = getWorkspace(workspaceId);
|
|
78
|
+
if (!current || current.archivedAt || current.worktreePurgedAt || current.worktreePath !== attachmentWorktree)
|
|
79
|
+
throw new Error('Workspace changed while saving attachments');
|
|
80
|
+
if (current.status === 'compacting' || current.status === 'awaiting-user' || isShuttingDown())
|
|
81
|
+
throw new Error('Workspace is not ready to receive a message');
|
|
82
|
+
message.content = [message.content.trim(), ...saved.map(attachmentReference)].filter(Boolean).join('\n\n');
|
|
83
|
+
// Binary data never belongs in the chat receipt or agent prompt.
|
|
84
|
+
delete message.attachments;
|
|
85
|
+
}
|
|
86
|
+
const trackDispatch = !!hooks?.beforeDispatch || saved.length > 0;
|
|
48
87
|
const loop = autoLoop.getStatus(workspaceId);
|
|
49
88
|
let sessionId = message.sessionId ?? getActiveSession(workspaceId)?.id;
|
|
50
89
|
try {
|
|
51
|
-
if (
|
|
52
|
-
await sendMessage(workspaceId, message.content, message.sessionId,
|
|
90
|
+
if (trackDispatch)
|
|
91
|
+
await sendMessage(workspaceId, message.content, message.sessionId, beforeDispatch);
|
|
53
92
|
else
|
|
54
93
|
await sendMessage(workspaceId, message.content, message.sessionId);
|
|
55
94
|
}
|
|
@@ -61,7 +100,7 @@ export async function deliverWorkspaceMessage(workspaceId, message, hooks) {
|
|
|
61
100
|
const current = getWorkspace(workspaceId);
|
|
62
101
|
if (!current || current.archivedAt || current.worktreePurgedAt)
|
|
63
102
|
throw new Error('Workspace is unavailable');
|
|
64
|
-
const started = startAgent(workspaceId, current.worktreePath, message.content, current.model, true, message.agentPermissionModeOverride ?? current.agentPermissionMode, message.sessionId, current.reasoningEffort, ...(
|
|
103
|
+
const started = startAgent(workspaceId, current.worktreePath, message.content, current.model, true, message.agentPermissionModeOverride ?? current.agentPermissionMode, message.sessionId, current.reasoningEffort, ...(trackDispatch ? [beforeDispatch] : []));
|
|
65
104
|
updateWorkspaceStatus(workspaceId, 'executing');
|
|
66
105
|
sessionId = message.sessionId ?? started.agentSessionId;
|
|
67
106
|
}
|
|
@@ -71,6 +110,23 @@ export async function deliverWorkspaceMessage(workspaceId, message, hooks) {
|
|
|
71
110
|
return { sessionId };
|
|
72
111
|
}
|
|
73
112
|
catch (error) {
|
|
113
|
+
// Once dispatch may have happened, keep files available to the agent even if
|
|
114
|
+
// acknowledgement failed. Only remove files from a definitively rejected send.
|
|
115
|
+
if (saved.length && attachmentWorktree && !dispatched) {
|
|
116
|
+
const worktree = attachmentWorktree;
|
|
117
|
+
try {
|
|
118
|
+
await withWorkspaceLifecycleGuard(workspaceId, async () => {
|
|
119
|
+
const current = getWorkspace(workspaceId);
|
|
120
|
+
if (!current || current.worktreePurgedAt || current.worktreePath !== worktree)
|
|
121
|
+
return;
|
|
122
|
+
for (const warning of await removeAttachments(worktree, saved))
|
|
123
|
+
console.warn(`[message-attachments] ${warning}`);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// A competing lifecycle operation owns the checkout now; do not touch it.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
74
130
|
receipt.reject(error instanceof Error ? error.message : String(error), reason);
|
|
75
131
|
throw error;
|
|
76
132
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Sanitize a branch name for use as a Docker instance name.
|
|
5
|
+
* Replace `/` and `_` with `-`, lowercase.
|
|
6
|
+
*/
|
|
7
|
+
export function sanitizeBranchName(branch) {
|
|
8
|
+
return branch.toLowerCase().replace(/[/_]/g, '-');
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Parse a `.env` file content into key=value pairs.
|
|
12
|
+
* Skips empty lines and comments (#). Handles quotes.
|
|
13
|
+
*/
|
|
14
|
+
export function parseEnvFile(content) {
|
|
15
|
+
const result = {};
|
|
16
|
+
for (const line of content.split('\n')) {
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
19
|
+
continue;
|
|
20
|
+
const eqIndex = trimmed.indexOf('=');
|
|
21
|
+
if (eqIndex === -1)
|
|
22
|
+
continue;
|
|
23
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
24
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
25
|
+
// Strip surrounding quotes
|
|
26
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
27
|
+
value = value.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
result[key] = value;
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the dev-server instance config for a given project + branch.
|
|
35
|
+
* Looks in `<projectPath>/.container/instances/` for `.env` files.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveInstance(projectPath, workingBranch) {
|
|
38
|
+
const instancesDir = path.join(projectPath, '.container', 'instances');
|
|
39
|
+
if (!existsSync(instancesDir))
|
|
40
|
+
return null;
|
|
41
|
+
const sanitized = sanitizeBranchName(workingBranch);
|
|
42
|
+
const files = readdirSync(instancesDir).filter((f) => f.endsWith('.env'));
|
|
43
|
+
for (const file of files) {
|
|
44
|
+
const content = readFileSync(path.join(instancesDir, file), 'utf-8');
|
|
45
|
+
const parsed = parseEnvFile(content);
|
|
46
|
+
if (parsed.INSTANCE_NAME &&
|
|
47
|
+
parsed.INSTANCE_NAME.toLowerCase() === sanitized &&
|
|
48
|
+
parsed.PROJECT_NAME &&
|
|
49
|
+
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(parsed.PROJECT_NAME)) {
|
|
50
|
+
return {
|
|
51
|
+
instanceName: parsed.INSTANCE_NAME,
|
|
52
|
+
projectName: parsed.PROJECT_NAME,
|
|
53
|
+
httpPort: parsed.HTTP_PORT ?? '',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import * as wsService from '../services/websocket-service.js';
|
|
5
|
+
import { resolveInstance, sanitizeBranchName } from './dev-server-instance.js';
|
|
5
6
|
import { getIndexLockPath } from './git-ops.js';
|
|
6
7
|
import { ensureDirectoryInside } from './safe-path.js';
|
|
7
8
|
/** Default wall-clock budget for a user script before it is force-killed. */
|
|
@@ -39,6 +40,8 @@ export function runScript(opts) {
|
|
|
39
40
|
scriptDirectory = fs.mkdtempSync(path.join(storage, '.script-'));
|
|
40
41
|
scriptPath = path.join(scriptDirectory, path.basename(tmpFileName));
|
|
41
42
|
fs.writeFileSync(scriptPath, script, { mode: 0o700, flag: 'wx' });
|
|
43
|
+
// Resolve only from this workspace's persisted instance, never the host's env.
|
|
44
|
+
const instance = env?.projectPath && env.branchName ? resolveInstance(env.projectPath, env.branchName) : null;
|
|
42
45
|
const proc = spawn('bash', [scriptPath], {
|
|
43
46
|
cwd: worktreePath,
|
|
44
47
|
env: {
|
|
@@ -49,6 +52,8 @@ export function runScript(opts) {
|
|
|
49
52
|
BRANCH_NAME: env?.branchName ?? '',
|
|
50
53
|
SOURCE_BRANCH: env?.sourceBranch ?? '',
|
|
51
54
|
PROJECT_PATH: env?.projectPath ?? '',
|
|
55
|
+
PROJECT_NAME: instance?.projectName ?? '',
|
|
56
|
+
INSTANCE: instance?.instanceName ?? sanitizeBranchName(env?.branchName ?? ''),
|
|
52
57
|
},
|
|
53
58
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
54
59
|
// Detached so this is the leader of its own process group: a script
|
|
@@ -74,7 +79,7 @@ export function runScript(opts) {
|
|
|
74
79
|
proc.stdout?.destroy();
|
|
75
80
|
proc.stderr?.destroy();
|
|
76
81
|
wsService.emit(workspaceId, `${eventPrefix}:output`, {
|
|
77
|
-
text: `[kobo] Script timed out after ${Math.round(timeoutMs / 60000)} minutes`,
|
|
82
|
+
text: `[kobo] Script timed out after ${timeoutMs < 60000 ? `${Math.round(timeoutMs / 1000)} seconds` : `${Math.round(timeoutMs / 60000)} minutes`}`,
|
|
78
83
|
});
|
|
79
84
|
// SIGKILL on the process group gives a running `git commit` no chance to
|
|
80
85
|
// release its index lock. Name the file here, while the user is looking
|