@loicngr/kobo 1.11.8 → 1.11.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +9 -0
- package/README.md +2 -2
- package/dist/server/db/migrations.js +155 -19
- package/dist/server/db/schema.js +93 -0
- package/dist/server/index.js +35 -26
- package/dist/server/routes/dev-server.js +10 -8
- package/dist/server/routes/documents.js +33 -3
- package/dist/server/routes/health.js +16 -15
- package/dist/server/routes/images.js +17 -2
- package/dist/server/routes/workspaces.js +64 -49
- package/dist/server/services/agent/engines/codex/engine.js +5 -1
- package/dist/server/services/agent/orchestrator.js +8 -1
- package/dist/server/services/auto-loop-service.js +1 -1
- package/dist/server/services/change-source-branch-service.js +43 -3
- package/dist/server/services/dev-server-service.js +55 -41
- package/dist/server/services/forge/bitbucket/provider.js +6 -11
- package/dist/server/services/forge/github/provider.js +10 -9
- package/dist/server/services/forge/gitlab/provider.js +11 -13
- package/dist/server/services/git-stats-service.js +8 -6
- package/dist/server/services/image-service.js +34 -5
- package/dist/server/services/notion-service.js +45 -5
- package/dist/server/services/pr-watcher-service.js +20 -9
- package/dist/server/services/search-service.js +53 -40
- package/dist/server/services/websocket-service.js +8 -3
- package/dist/server/services/workspace-service.js +81 -10
- package/dist/server/services/worktree-purge-service.js +1 -1
- package/dist/server/utils/git-ops.js +89 -22
- package/dist/server/utils/mcp-client.js +4 -0
- package/dist/server/utils/safe-path.js +14 -0
- package/dist/server/utils/script-runner.js +16 -1
- package/package.json +22 -22
- package/src/client/dist/pwa/assets/{ActivityFeed-D9EfbRqv.css → ActivityFeed-BrjeLlhi.css} +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-UEAexGjc.js +8 -0
- package/src/client/dist/pwa/assets/ChangelogPage-XSYwu9XA.js +1 -0
- package/src/client/dist/pwa/assets/{ClosePopup-_rbZbpfb.js → ClosePopup-dpcrW9DC.js} +1 -1
- package/src/client/dist/pwa/assets/CreatePage-BK__rjvR.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-QwtfYunr.css +1 -0
- package/src/client/dist/pwa/assets/{DiffViewer-CRrDdYBa.css → DiffViewer-CS3P9Ngi.css} +1 -1
- package/src/client/dist/pwa/assets/DiffViewer-DnFN6Sc_.js +8 -0
- package/src/client/dist/pwa/assets/HealthPage-DVrZfMI6.js +1 -0
- package/src/client/dist/pwa/assets/MainLayout-D_3nFaCG.js +19 -0
- package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +1 -0
- package/src/client/dist/pwa/assets/QBadge-sMJZ_eZm.js +1 -0
- package/src/client/dist/pwa/assets/QBanner-DedhGMcj.js +1 -0
- package/src/client/dist/pwa/assets/QBtnToggle-BgfN19l8.js +1 -0
- package/src/client/dist/pwa/assets/QChip-EQGYShN0.js +1 -0
- package/src/client/dist/pwa/assets/QIcon--SvgE-nS.js +1 -0
- package/src/client/dist/pwa/assets/QList-Be_FbTA3.js +1 -0
- package/src/client/dist/pwa/assets/QPage-DuIsRmYu.js +1 -0
- package/src/client/dist/pwa/assets/QSelect-CtxxliRU.js +36 -0
- package/src/client/dist/pwa/assets/QSeparator-BdyRE-AM.js +1 -0
- package/src/client/dist/pwa/assets/QSpace-D9UEvM99.js +1 -0
- package/src/client/dist/pwa/assets/QSpinnerDots-B4RmFJUj.js +1 -0
- package/src/client/dist/pwa/assets/QTooltip-HEIFh5w9.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-1B5HOf90.js +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-BXiWr2ba.css +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-v8f4rrCt.js +16 -0
- package/src/client/dist/pwa/assets/TouchPan-lynWXY69.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-ChnyJ3xI.css +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-Vjzi8YnW.js +4 -0
- package/src/client/dist/pwa/assets/codicon-Brq4_Ui5.ttf +0 -0
- package/src/client/dist/pwa/assets/{css.worker-CBlhdqTa.js → css.worker-JKZQyld4.js} +38 -34
- package/src/client/dist/pwa/assets/cssMode-cg8iVI-i.js +1 -0
- package/src/client/dist/pwa/assets/documents-Dlx8yiSO.js +1 -0
- package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +1 -0
- package/src/client/dist/pwa/assets/editor.api-CfaJRkrX.js +847 -0
- package/src/client/dist/pwa/assets/editor.worker-D-m8IBMa.js +30 -0
- package/src/client/dist/pwa/assets/expand-template-D3LZldj1.js +1 -0
- package/src/client/dist/pwa/assets/formatters-D0dU6VcA.js +1 -0
- package/src/client/dist/pwa/assets/{freemarker2-DczqHd5F.js → freemarker2-B_8OIClK.js} +1 -1
- package/src/client/dist/pwa/assets/handlebars-CaYi_uxU.js +1 -0
- package/src/client/dist/pwa/assets/html-BnCUDUl1.js +1 -0
- package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +506 -0
- package/src/client/dist/pwa/assets/htmlMode-BZjKGnQF.js +1 -0
- package/src/client/dist/pwa/assets/i18n-B6SDs7Lm.js +1 -0
- package/src/client/dist/pwa/assets/index-CK1nk7qr.css +1 -0
- package/src/client/dist/pwa/assets/index-CoOsPyyG.js +82 -0
- package/src/client/dist/pwa/assets/{javascript-Bs83z_W4.js → javascript-DkSfoeHU.js} +1 -1
- package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +62 -0
- package/src/client/dist/pwa/assets/{jsonMode-Bc-8bzJi.js → jsonMode-yKXxfx1V.js} +3 -3
- package/src/client/dist/pwa/assets/{kobo-commands-B2kM0INN.js → kobo-commands-vn1IQxLn.js} +1 -1
- package/src/client/dist/pwa/assets/layout-CiQpugQB.js +1 -0
- package/src/client/dist/pwa/assets/liquid-Dlhysv3U.js +1 -0
- package/src/client/dist/pwa/assets/lspLanguageFeatures-CeYSQFUz.js +4 -0
- package/src/client/dist/pwa/assets/mdx-B8iME5NA.js +1 -0
- package/src/client/dist/pwa/assets/network-auth-j2Drut6q.js +1 -0
- package/src/client/dist/pwa/assets/pending-diff-open-B4RYz7G_.js +1 -0
- package/src/client/dist/pwa/assets/permissionModes-6jhegAm-.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-DIFZ95_g.js → project-color-e3OdFi9X.js} +1 -1
- package/src/client/dist/pwa/assets/python-CbFbC0ZR.js +1 -0
- package/src/client/dist/pwa/assets/razor-7c2NCdFI.js +1 -0
- package/src/client/dist/pwa/assets/reactivity.esm-bundler-DtSZKRQB.js +1 -0
- package/src/client/dist/pwa/assets/render-chat-markdown-Fer8kY38.js +67 -0
- package/src/client/dist/pwa/assets/runtime-dom.esm-bundler-DgevF6KV.js +4 -0
- package/src/client/dist/pwa/assets/{ts.worker-PmaSgaZk.js → ts.worker-C-oGa8JT.js} +199 -195
- package/src/client/dist/pwa/assets/{tsMode-DOsxzgZi.js → tsMode-BmShKjbx.js} +1 -1
- package/src/client/dist/pwa/assets/typescript-Dhu7Upbs.js +1 -0
- package/src/client/dist/pwa/assets/use-checkbox-SrkgiJho.js +1 -0
- package/src/client/dist/pwa/assets/use-onboarding-CTZuBZOi.css +1 -0
- package/src/client/dist/pwa/assets/use-onboarding-fe4sPCNf.js +2 -0
- package/src/client/dist/pwa/assets/use-quasar-CeHlTrGg.js +1 -0
- package/src/client/dist/pwa/assets/vs-B-xsceNQ.js +56 -0
- package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +1 -0
- package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +3 -0
- package/src/client/dist/pwa/assets/workers-zVMTXVoC.js +1 -0
- package/src/client/dist/pwa/assets/xml-D64wpCtR.js +1 -0
- package/src/client/dist/pwa/assets/yaml-CLSRKwfx.js +1 -0
- package/src/client/dist/pwa/index.html +8 -8
- package/src/client/dist/pwa/manifest.json +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-BkkyAw81.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-BlvhBx6o.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-B_JcmRfU.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-RSyoQwD7.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-BSNxNex2.js +0 -8
- package/src/client/dist/pwa/assets/HealthPage-CUD1kipV.js +0 -1
- package/src/client/dist/pwa/assets/MainLayout-BCLgsn0C.js +0 -37
- package/src/client/dist/pwa/assets/MainLayout-DlS2wxkK.css +0 -1
- package/src/client/dist/pwa/assets/QBadge-DV5kn4mL.js +0 -1
- package/src/client/dist/pwa/assets/QBanner-CmfaeizG.js +0 -1
- package/src/client/dist/pwa/assets/QBtnGroup-C442-yBb.js +0 -1
- package/src/client/dist/pwa/assets/QBtnToggle-xg3LYW7D.js +0 -1
- package/src/client/dist/pwa/assets/QChip-DhUfnmbz.js +0 -1
- package/src/client/dist/pwa/assets/QIcon-C6E-oEjI.js +0 -1
- package/src/client/dist/pwa/assets/QInput-BF8x4RTv.js +0 -1
- package/src/client/dist/pwa/assets/QList-D5EKdFPn.js +0 -1
- package/src/client/dist/pwa/assets/QPage-D3rjwA1l.js +0 -1
- package/src/client/dist/pwa/assets/QSelect-BhyT0k2N.js +0 -36
- package/src/client/dist/pwa/assets/QSeparator-DWTCQxsa.js +0 -1
- package/src/client/dist/pwa/assets/QSpace-ezNrZkuj.js +0 -1
- package/src/client/dist/pwa/assets/QSpinnerDots-DIV68-cV.js +0 -1
- package/src/client/dist/pwa/assets/QTooltip-ybbFi3KK.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-Bb7TG3Vo.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-C2qe4xKu.css +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-CjPnnn60.js +0 -16
- package/src/client/dist/pwa/assets/ThinkingItem-B1RKyCIS.js +0 -1
- package/src/client/dist/pwa/assets/ThinkingItem-Ckk4K1O-.css +0 -1
- package/src/client/dist/pwa/assets/TouchPan-DzZPYIol.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-6Rgjarvf.js +0 -4
- package/src/client/dist/pwa/assets/WorkspacePage-Bgxg_Rua.css +0 -1
- package/src/client/dist/pwa/assets/build-path-tree-KRACVkD3.js +0 -1
- package/src/client/dist/pwa/assets/chunk-QTnfLwEv.js +0 -1
- package/src/client/dist/pwa/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/src/client/dist/pwa/assets/cssMode-CQsB6mFO.js +0 -1
- package/src/client/dist/pwa/assets/documents-C7efBF5t.js +0 -1
- package/src/client/dist/pwa/assets/editor-BFebQ78e.css +0 -1
- package/src/client/dist/pwa/assets/editor.api2-C58pkxmw.js +0 -872
- package/src/client/dist/pwa/assets/editor.main-BhLqjdq4.js +0 -6
- package/src/client/dist/pwa/assets/editor.worker-Cu3tR8iJ.js +0 -26
- package/src/client/dist/pwa/assets/expand-template-BA-hhG5v.js +0 -1
- package/src/client/dist/pwa/assets/formatters-DugWNOEs.js +0 -1
- package/src/client/dist/pwa/assets/handlebars-DmA7twi8.js +0 -1
- package/src/client/dist/pwa/assets/html-DtZDrViU.js +0 -1
- package/src/client/dist/pwa/assets/html.worker-BKBrNna1.js +0 -502
- package/src/client/dist/pwa/assets/htmlMode-DO9_qUdT.js +0 -1
- package/src/client/dist/pwa/assets/i18n-CipNqHTm.js +0 -1
- package/src/client/dist/pwa/assets/index-BxspWSDs.js +0 -84
- package/src/client/dist/pwa/assets/index-CwYR3iHt.css +0 -1
- package/src/client/dist/pwa/assets/json.worker-BaLYt9Ss.js +0 -58
- package/src/client/dist/pwa/assets/layout-C6frIxKZ.js +0 -1
- package/src/client/dist/pwa/assets/liquid-Dnxe0NUB.js +0 -1
- package/src/client/dist/pwa/assets/lspLanguageFeatures-qDBRYovw.js +0 -4
- package/src/client/dist/pwa/assets/mdx-C1oke9bq.js +0 -1
- package/src/client/dist/pwa/assets/monaco.contribution-BbFzrpO3.js +0 -2
- package/src/client/dist/pwa/assets/network-auth-Bj1LS5R7.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-C3voSObg.js +0 -1
- package/src/client/dist/pwa/assets/permissionModes-DMuXGtZu.js +0 -1
- package/src/client/dist/pwa/assets/python-Dgq-bJSM.js +0 -1
- package/src/client/dist/pwa/assets/razor-Bms4CQGF.js +0 -1
- package/src/client/dist/pwa/assets/render-chat-markdown-BRSTe4je.js +0 -66
- package/src/client/dist/pwa/assets/typescript-CzOp_GqX.js +0 -1
- package/src/client/dist/pwa/assets/use-checkbox-E9SV7iH5.js +0 -1
- package/src/client/dist/pwa/assets/use-onboarding-B3l7mx48.css +0 -1
- package/src/client/dist/pwa/assets/use-onboarding-BGy3sOAN.js +0 -2
- package/src/client/dist/pwa/assets/use-quasar-DsmzxifG.js +0 -1
- package/src/client/dist/pwa/assets/vue.runtime.esm-bundler-JZnIeD9D.js +0 -3
- package/src/client/dist/pwa/assets/workers-0fozwhvS.js +0 -1
- package/src/client/dist/pwa/assets/xml-CPa01uHm.js +0 -1
- package/src/client/dist/pwa/assets/yaml-Cw3fzSuh.js +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
2
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { getProjectSettings } from './settings-service.js';
|
|
@@ -10,6 +10,16 @@ function cleanEnv() {
|
|
|
10
10
|
const { PORT, SERVER_PORT, ...rest } = process.env;
|
|
11
11
|
return rest;
|
|
12
12
|
}
|
|
13
|
+
function runCommand(command, args, options = {}) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
execFile(command, args, { ...options, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
|
16
|
+
if (err)
|
|
17
|
+
reject(err);
|
|
18
|
+
else
|
|
19
|
+
resolve(stdout);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
13
23
|
// ── State ──────────────────────────────────────────────────────────────────────
|
|
14
24
|
/** workspaceId -> spawned dev-server process */
|
|
15
25
|
const trackedProcesses = new Map();
|
|
@@ -61,28 +71,32 @@ export function resolveInstance(projectPath, workingBranch) {
|
|
|
61
71
|
for (const file of files) {
|
|
62
72
|
const content = readFileSync(path.join(instancesDir, file), 'utf-8');
|
|
63
73
|
const parsed = parseEnvFile(content);
|
|
64
|
-
if (parsed.INSTANCE_NAME &&
|
|
74
|
+
if (parsed.INSTANCE_NAME &&
|
|
75
|
+
parsed.INSTANCE_NAME.toLowerCase() === sanitized &&
|
|
76
|
+
parsed.PROJECT_NAME &&
|
|
77
|
+
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(parsed.PROJECT_NAME)) {
|
|
65
78
|
return {
|
|
66
79
|
instanceName: parsed.INSTANCE_NAME,
|
|
67
|
-
projectName: parsed.PROJECT_NAME
|
|
80
|
+
projectName: parsed.PROJECT_NAME,
|
|
68
81
|
httpPort: parsed.HTTP_PORT ?? '',
|
|
69
82
|
};
|
|
70
83
|
}
|
|
71
84
|
}
|
|
72
85
|
return null;
|
|
73
86
|
}
|
|
87
|
+
function containerBelongsToProject(containerName, projectName) {
|
|
88
|
+
const name = containerName.toLowerCase();
|
|
89
|
+
const project = projectName.toLowerCase();
|
|
90
|
+
return name === project || name.startsWith(`${project}-`) || name.startsWith(`${project}_`);
|
|
91
|
+
}
|
|
74
92
|
// ── Docker helpers ─────────────────────────────────────────────────────────────
|
|
75
93
|
/**
|
|
76
94
|
* List all running Docker container names.
|
|
77
|
-
*
|
|
78
|
-
* Go template syntax with `{{}}`. Input is a static string, no injection risk.
|
|
95
|
+
* Uses execFile so Docker inspection cannot block the Node event loop.
|
|
79
96
|
*/
|
|
80
|
-
export function listRunningContainers() {
|
|
97
|
+
export async function listRunningContainers() {
|
|
81
98
|
try {
|
|
82
|
-
const output =
|
|
83
|
-
encoding: 'utf-8',
|
|
84
|
-
timeout: 10000,
|
|
85
|
-
});
|
|
99
|
+
const output = await runCommand('docker', ['ps', '--format', '{{.Names}}'], { timeout: 10_000 });
|
|
86
100
|
return output
|
|
87
101
|
.split('\n')
|
|
88
102
|
.map((s) => s.trim())
|
|
@@ -102,7 +116,7 @@ export function listRunningContainers() {
|
|
|
102
116
|
* in `docker ps` yet. This prevents the UI from flashing to `'stopped'` during
|
|
103
117
|
* long build phases.
|
|
104
118
|
*/
|
|
105
|
-
export function getStatus(projectPath, workingBranch, workspaceId) {
|
|
119
|
+
export async function getStatus(projectPath, workingBranch, workspaceId) {
|
|
106
120
|
const config = resolveInstance(projectPath, workingBranch);
|
|
107
121
|
if (!config) {
|
|
108
122
|
return {
|
|
@@ -114,8 +128,8 @@ export function getStatus(projectPath, workingBranch, workspaceId) {
|
|
|
114
128
|
containers: [],
|
|
115
129
|
};
|
|
116
130
|
}
|
|
117
|
-
const running = listRunningContainers();
|
|
118
|
-
const matching = running.filter((name) => name
|
|
131
|
+
const running = await listRunningContainers();
|
|
132
|
+
const matching = running.filter((name) => containerBelongsToProject(name, config.projectName));
|
|
119
133
|
if (matching.length > 0) {
|
|
120
134
|
return {
|
|
121
135
|
status: 'running',
|
|
@@ -161,6 +175,13 @@ export function startDevServer(workspaceId) {
|
|
|
161
175
|
if (!settings?.devServer.startCommand) {
|
|
162
176
|
throw new Error('No dev-server start command configured');
|
|
163
177
|
}
|
|
178
|
+
// Reject a second concurrent start for the same workspace instead of
|
|
179
|
+
// silently overwriting the tracked process — the first process would
|
|
180
|
+
// otherwise become untrackable (never killed by stopDevServer) and its
|
|
181
|
+
// exit handler could later clobber state set by the second process.
|
|
182
|
+
if (trackedProcesses.has(workspaceId)) {
|
|
183
|
+
throw new Error(`Dev server for workspace '${workspaceId}' is already starting`);
|
|
184
|
+
}
|
|
164
185
|
const instanceName = sanitizeBranchName(workspace.workingBranch);
|
|
165
186
|
// Execute as bash script (supports multi-line scripts)
|
|
166
187
|
const worktreePath = workspace.worktreePath;
|
|
@@ -185,9 +206,14 @@ export function startDevServer(workspaceId) {
|
|
|
185
206
|
});
|
|
186
207
|
proc.on('exit', (code) => {
|
|
187
208
|
trackedProcesses.delete(workspaceId);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
209
|
+
void getStatus(workspace.projectPath, workspace.workingBranch)
|
|
210
|
+
.then((currentStatus) => {
|
|
211
|
+
updateDevServerStatus(workspaceId, currentStatus.status);
|
|
212
|
+
emitEphemeral(workspaceId, 'devserver:status', currentStatus);
|
|
213
|
+
})
|
|
214
|
+
.catch((err) => {
|
|
215
|
+
console.error(`[dev-server] Failed to refresh status for workspace ${workspaceId}:`, err);
|
|
216
|
+
});
|
|
191
217
|
if (code !== 0) {
|
|
192
218
|
console.error(`[dev-server] Process exited with code ${code} for workspace ${workspaceId}`);
|
|
193
219
|
}
|
|
@@ -222,7 +248,7 @@ export function startDevServer(workspaceId) {
|
|
|
222
248
|
/**
|
|
223
249
|
* Stop the dev-server for a workspace.
|
|
224
250
|
*/
|
|
225
|
-
export function stopDevServer(workspaceId) {
|
|
251
|
+
export async function stopDevServer(workspaceId) {
|
|
226
252
|
const workspace = getWorkspace(workspaceId);
|
|
227
253
|
if (!workspace) {
|
|
228
254
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
@@ -249,18 +275,15 @@ export function stopDevServer(workspaceId) {
|
|
|
249
275
|
}
|
|
250
276
|
const settings = getProjectSettings(workspace.projectPath);
|
|
251
277
|
if (settings?.devServer.stopCommand) {
|
|
252
|
-
// Custom stop script — run synchronously with instance context in env
|
|
253
278
|
try {
|
|
254
|
-
|
|
279
|
+
await runCommand('bash', ['-c', settings.devServer.stopCommand], {
|
|
255
280
|
cwd,
|
|
256
281
|
env: {
|
|
257
282
|
...cleanEnv(),
|
|
258
283
|
INSTANCE: instanceName,
|
|
259
284
|
PROJECT_NAME: config?.projectName ?? '',
|
|
260
285
|
},
|
|
261
|
-
|
|
262
|
-
timeout: 30000,
|
|
263
|
-
shell: 'bash',
|
|
286
|
+
timeout: 30_000,
|
|
264
287
|
});
|
|
265
288
|
}
|
|
266
289
|
catch (err) {
|
|
@@ -271,11 +294,7 @@ export function stopDevServer(workspaceId) {
|
|
|
271
294
|
// (handles cases where custom stop command doesn't use -p flag)
|
|
272
295
|
if (config?.projectName) {
|
|
273
296
|
try {
|
|
274
|
-
|
|
275
|
-
cwd: cwd,
|
|
276
|
-
encoding: 'utf-8',
|
|
277
|
-
timeout: 30000,
|
|
278
|
-
});
|
|
297
|
+
await runCommand('docker', ['compose', '-p', config.projectName, 'down'], { cwd, timeout: 30_000 });
|
|
279
298
|
}
|
|
280
299
|
catch {
|
|
281
300
|
// May already be stopped by the custom command — ignore
|
|
@@ -296,10 +315,9 @@ export function stopDevServer(workspaceId) {
|
|
|
296
315
|
// ── Logs ───────────────────────────────────────────────────────────────────────
|
|
297
316
|
/**
|
|
298
317
|
* Get logs from running dev-server containers for a workspace.
|
|
299
|
-
*
|
|
300
|
-
* `docker ps` output (not user input), so no injection risk.
|
|
318
|
+
* Docker log reads run concurrently without blocking the Node event loop.
|
|
301
319
|
*/
|
|
302
|
-
export function getDevServerLogs(workspaceId, tail = 200) {
|
|
320
|
+
export async function getDevServerLogs(workspaceId, tail = 200) {
|
|
303
321
|
const workspace = getWorkspace(workspaceId);
|
|
304
322
|
if (!workspace) {
|
|
305
323
|
return 'Workspace not found';
|
|
@@ -308,24 +326,20 @@ export function getDevServerLogs(workspaceId, tail = 200) {
|
|
|
308
326
|
if (!config) {
|
|
309
327
|
return 'No dev-server instance found';
|
|
310
328
|
}
|
|
311
|
-
const running = listRunningContainers();
|
|
312
|
-
const matching = running.filter((name) => name
|
|
329
|
+
const running = await listRunningContainers();
|
|
330
|
+
const matching = running.filter((name) => containerBelongsToProject(name, config.projectName));
|
|
313
331
|
if (matching.length === 0) {
|
|
314
332
|
return 'No running containers found';
|
|
315
333
|
}
|
|
316
|
-
const outputs =
|
|
317
|
-
for (const container of matching) {
|
|
334
|
+
const outputs = await Promise.all(matching.map(async (container) => {
|
|
318
335
|
try {
|
|
319
|
-
const logs =
|
|
320
|
-
|
|
321
|
-
timeout: 10000,
|
|
322
|
-
});
|
|
323
|
-
outputs.push(`=== ${container} ===\n${logs}`);
|
|
336
|
+
const logs = await runCommand('docker', ['logs', '--tail', String(tail), container], { timeout: 10_000 });
|
|
337
|
+
return `=== ${container} ===\n${logs}`;
|
|
324
338
|
}
|
|
325
339
|
catch (err) {
|
|
326
340
|
const message = err instanceof Error ? err.message : String(err);
|
|
327
|
-
|
|
341
|
+
return `=== ${container} ===\n[Error fetching logs: ${message}]`;
|
|
328
342
|
}
|
|
329
|
-
}
|
|
343
|
+
}));
|
|
330
344
|
return outputs.join('\n\n');
|
|
331
345
|
}
|
|
@@ -144,12 +144,13 @@ function bktEnv() {
|
|
|
144
144
|
return process.env;
|
|
145
145
|
}
|
|
146
146
|
function bktOptions(repoPath) {
|
|
147
|
-
return { cwd: repoPath, encoding: 'utf-8', env: bktEnv() };
|
|
147
|
+
return { cwd: repoPath, encoding: 'utf-8', env: bktEnv(), timeout: 30_000 };
|
|
148
148
|
}
|
|
149
149
|
async function resolveTarget(repoPath) {
|
|
150
150
|
const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin'], {
|
|
151
151
|
cwd: repoPath,
|
|
152
152
|
encoding: 'utf-8',
|
|
153
|
+
timeout: 30_000,
|
|
153
154
|
});
|
|
154
155
|
const target = parseBitbucketRemote(stdout);
|
|
155
156
|
if (!target)
|
|
@@ -158,27 +159,21 @@ async function resolveTarget(repoPath) {
|
|
|
158
159
|
// keyring and context access, which cannot prompt from the Kobo server.
|
|
159
160
|
if (bktEnv().BKT_TOKEN)
|
|
160
161
|
return { ...target, context: null };
|
|
161
|
-
const { stdout: contextOutput } = await execFileAsync('bkt', ['context', 'list', '--json'],
|
|
162
|
-
cwd: repoPath,
|
|
163
|
-
encoding: 'utf-8',
|
|
164
|
-
});
|
|
162
|
+
const { stdout: contextOutput } = await execFileAsync('bkt', ['context', 'list', '--json'], bktOptions(repoPath));
|
|
165
163
|
const contexts = record(JSON.parse(contextOutput)).contexts;
|
|
166
164
|
const existing = (Array.isArray(contexts) ? contexts : [])
|
|
167
165
|
.map(record)
|
|
168
166
|
.find((context) => string(context.workspace) === target.workspace && string(context.default_repo) === target.repo);
|
|
169
167
|
if (existing && string(existing.name))
|
|
170
168
|
return { ...target, context: string(existing.name) };
|
|
171
|
-
const { stdout: authOutput } = await execFileAsync('bkt', ['auth', 'status', '--json'],
|
|
172
|
-
cwd: repoPath,
|
|
173
|
-
encoding: 'utf-8',
|
|
174
|
-
});
|
|
169
|
+
const { stdout: authOutput } = await execFileAsync('bkt', ['auth', 'status', '--json'], bktOptions(repoPath));
|
|
175
170
|
const hosts = record(JSON.parse(authOutput)).hosts;
|
|
176
171
|
const host = (Array.isArray(hosts) ? hosts : []).map(record).find((item) => string(item.kind) === 'cloud');
|
|
177
172
|
const hostKey = string(host?.key);
|
|
178
173
|
if (!hostKey)
|
|
179
174
|
throw new Error('No authenticated Bitbucket Cloud host found; run bkt auth login first');
|
|
180
175
|
const context = contextName(target.workspace, target.repo);
|
|
181
|
-
await execFileAsync('bkt', ['context', 'create', context, '--host', hostKey, '--workspace', target.workspace, '--repo', target.repo],
|
|
176
|
+
await execFileAsync('bkt', ['context', 'create', context, '--host', hostKey, '--workspace', target.workspace, '--repo', target.repo], bktOptions(repoPath));
|
|
182
177
|
return { ...target, context };
|
|
183
178
|
}
|
|
184
179
|
function targetArgs(target) {
|
|
@@ -286,6 +281,6 @@ export const bitbucketProvider = {
|
|
|
286
281
|
await execFileAsync('bkt', bktArgs(target, ['pr', 'merge', String(number), '--close-source=false', ...targetArgs(target)]), bktOptions(repoPath));
|
|
287
282
|
},
|
|
288
283
|
async deleteRemoteBranch(repoPath, branchName) {
|
|
289
|
-
await execFileAsync('git', ['push', 'origin', '--delete', branchName],
|
|
284
|
+
await execFileAsync('git', ['push', 'origin', '--delete', branchName], bktOptions(repoPath));
|
|
290
285
|
},
|
|
291
286
|
};
|
|
@@ -3,6 +3,10 @@ import { execFile } from 'node:child_process';
|
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import { deriveReadyToMerge } from '../types.js';
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
|
+
const CLI_TIMEOUT_MS = 30_000;
|
|
7
|
+
function cliOptions(repoPath) {
|
|
8
|
+
return { cwd: repoPath, encoding: 'utf-8', timeout: CLI_TIMEOUT_MS };
|
|
9
|
+
}
|
|
6
10
|
// NOTE: `reviewThreads` is intentionally NOT in this list.
|
|
7
11
|
// `gh pr view --json reviewThreads` is rejected with `Unknown JSON field`
|
|
8
12
|
// — there is no stable `gh` version that exposes it. Until upstream adds it,
|
|
@@ -101,7 +105,7 @@ export const githubProvider = {
|
|
|
101
105
|
},
|
|
102
106
|
async isAvailable(repoPath) {
|
|
103
107
|
try {
|
|
104
|
-
await execFileAsync('gh', ['auth', 'status'],
|
|
108
|
+
await execFileAsync('gh', ['auth', 'status'], cliOptions(repoPath));
|
|
105
109
|
return { available: true };
|
|
106
110
|
}
|
|
107
111
|
catch (err) {
|
|
@@ -110,10 +114,7 @@ export const githubProvider = {
|
|
|
110
114
|
},
|
|
111
115
|
async getPrStatus(repoPath, branch) {
|
|
112
116
|
try {
|
|
113
|
-
const { stdout } = await execFileAsync('gh', ['pr', 'view', branch, '--json', GH_PR_FIELDS],
|
|
114
|
-
cwd: repoPath,
|
|
115
|
-
encoding: 'utf-8',
|
|
116
|
-
});
|
|
117
|
+
const { stdout } = await execFileAsync('gh', ['pr', 'view', branch, '--json', GH_PR_FIELDS], cliOptions(repoPath));
|
|
117
118
|
const raw = stdout.trim();
|
|
118
119
|
if (!raw)
|
|
119
120
|
return null;
|
|
@@ -124,19 +125,19 @@ export const githubProvider = {
|
|
|
124
125
|
}
|
|
125
126
|
},
|
|
126
127
|
async createPr(repoPath, opts) {
|
|
127
|
-
const { stdout } = await execFileAsync('gh', ['pr', 'create', '--base', opts.base, '--head', opts.head, '--title', opts.title, '--body', opts.body],
|
|
128
|
+
const { stdout } = await execFileAsync('gh', ['pr', 'create', '--base', opts.base, '--head', opts.head, '--title', opts.title, '--body', opts.body], cliOptions(repoPath));
|
|
128
129
|
const match = stdout.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
|
|
129
130
|
if (!match)
|
|
130
131
|
throw new Error('Could not parse PR URL from gh output');
|
|
131
132
|
return { url: match[0], number: Number.parseInt(match[1], 10) };
|
|
132
133
|
},
|
|
133
134
|
async changePrBase(repoPath, base) {
|
|
134
|
-
await execFileAsync('gh', ['pr', 'edit', '--base', base],
|
|
135
|
+
await execFileAsync('gh', ['pr', 'edit', '--base', base], cliOptions(repoPath));
|
|
135
136
|
},
|
|
136
137
|
async mergeRequest(repoPath, number) {
|
|
137
|
-
await execFileAsync('gh', ['pr', 'merge', String(number), '--merge'],
|
|
138
|
+
await execFileAsync('gh', ['pr', 'merge', String(number), '--merge'], cliOptions(repoPath));
|
|
138
139
|
},
|
|
139
140
|
async deleteRemoteBranch(repoPath, branch) {
|
|
140
|
-
await execFileAsync('git', ['push', 'origin', '--delete', branch],
|
|
141
|
+
await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
|
|
141
142
|
},
|
|
142
143
|
};
|
|
@@ -3,6 +3,10 @@ import { execFile } from 'node:child_process';
|
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import { deriveReadyToMerge } from '../types.js';
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
|
+
const CLI_TIMEOUT_MS = 30_000;
|
|
7
|
+
function cliOptions(repoPath) {
|
|
8
|
+
return { cwd: repoPath, encoding: 'utf-8', timeout: CLI_TIMEOUT_MS };
|
|
9
|
+
}
|
|
6
10
|
/** Map GitLab MR state to the normalised PrSnapshot state. */
|
|
7
11
|
function mapState(state) {
|
|
8
12
|
if (state === 'merged')
|
|
@@ -100,10 +104,7 @@ function mapGlabJobToCheck(job) {
|
|
|
100
104
|
*/
|
|
101
105
|
async function fetchGlabCi(repoPath, branch) {
|
|
102
106
|
try {
|
|
103
|
-
const { stdout } = await execFileAsync('glab', ['ci', 'get', '-b', branch, '-F', 'json'],
|
|
104
|
-
cwd: repoPath,
|
|
105
|
-
encoding: 'utf-8',
|
|
106
|
-
});
|
|
107
|
+
const { stdout } = await execFileAsync('glab', ['ci', 'get', '-b', branch, '-F', 'json'], cliOptions(repoPath));
|
|
107
108
|
const raw = stdout.trim();
|
|
108
109
|
if (!raw)
|
|
109
110
|
return { rollup: null, checks: [] };
|
|
@@ -138,7 +139,7 @@ export const gitlabProvider = {
|
|
|
138
139
|
},
|
|
139
140
|
async isAvailable(repoPath) {
|
|
140
141
|
try {
|
|
141
|
-
await execFileAsync('glab', ['auth', 'status'],
|
|
142
|
+
await execFileAsync('glab', ['auth', 'status'], cliOptions(repoPath));
|
|
142
143
|
return { available: true };
|
|
143
144
|
}
|
|
144
145
|
catch (err) {
|
|
@@ -147,10 +148,7 @@ export const gitlabProvider = {
|
|
|
147
148
|
},
|
|
148
149
|
async getPrStatus(repoPath, branch) {
|
|
149
150
|
try {
|
|
150
|
-
const { stdout } = await execFileAsync('glab', ['mr', 'view', branch, '--output', 'json'],
|
|
151
|
-
cwd: repoPath,
|
|
152
|
-
encoding: 'utf-8',
|
|
153
|
-
});
|
|
151
|
+
const { stdout } = await execFileAsync('glab', ['mr', 'view', branch, '--output', 'json'], cliOptions(repoPath));
|
|
154
152
|
const raw = stdout.trim();
|
|
155
153
|
if (!raw)
|
|
156
154
|
return null;
|
|
@@ -176,19 +174,19 @@ export const gitlabProvider = {
|
|
|
176
174
|
'--description',
|
|
177
175
|
opts.body,
|
|
178
176
|
'--yes',
|
|
179
|
-
],
|
|
177
|
+
], cliOptions(repoPath));
|
|
180
178
|
const match = stdout.match(/https?:\/\/[^\s]+\/-\/merge_requests\/(\d+)/);
|
|
181
179
|
if (!match)
|
|
182
180
|
throw new Error('Could not parse MR URL from glab output');
|
|
183
181
|
return { url: match[0], number: Number.parseInt(match[1], 10) };
|
|
184
182
|
},
|
|
185
183
|
async changePrBase(repoPath, base) {
|
|
186
|
-
await execFileAsync('glab', ['mr', 'update', '--target-branch', base],
|
|
184
|
+
await execFileAsync('glab', ['mr', 'update', '--target-branch', base], cliOptions(repoPath));
|
|
187
185
|
},
|
|
188
186
|
async mergeRequest(repoPath, number) {
|
|
189
|
-
await execFileAsync('glab', ['mr', 'merge', String(number), '--yes'],
|
|
187
|
+
await execFileAsync('glab', ['mr', 'merge', String(number), '--yes'], cliOptions(repoPath));
|
|
190
188
|
},
|
|
191
189
|
async deleteRemoteBranch(repoPath, branch) {
|
|
192
|
-
await execFileAsync('git', ['push', 'origin', '--delete', branch],
|
|
190
|
+
await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
|
|
193
191
|
},
|
|
194
192
|
};
|
|
@@ -10,13 +10,15 @@ import { resolveForge } from './forge/resolve.js';
|
|
|
10
10
|
*/
|
|
11
11
|
export async function computeGitStats(workspace, prSnapshot) {
|
|
12
12
|
const { worktreePath, sourceBranch, workingBranch, projectPath } = workspace;
|
|
13
|
-
const commitCount = gitOps.getCommitCount(worktreePath, sourceBranch, workingBranch);
|
|
14
|
-
const behindCount = gitOps.getCommitsBehind(worktreePath, sourceBranch, workingBranch);
|
|
15
|
-
const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, sourceBranch, workingBranch);
|
|
16
|
-
const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath, workingBranch);
|
|
17
|
-
const workingTree = gitOps.getWorkingTreeStatus(worktreePath);
|
|
18
13
|
const forgeProvider = getForgeProvider(resolveForge(projectPath));
|
|
19
|
-
const availability = await
|
|
14
|
+
const [commitCount, behindCount, diffStats, unpushedCount, workingTree, availability] = await Promise.all([
|
|
15
|
+
gitOps.getCommitCountAsync(worktreePath, sourceBranch, workingBranch),
|
|
16
|
+
gitOps.getCommitsBehindAsync(worktreePath, sourceBranch, workingBranch),
|
|
17
|
+
gitOps.getStructuredDiffStatsBetweenAsync(worktreePath, sourceBranch, workingBranch),
|
|
18
|
+
gitOps.getUnpushedCountAsync(worktreePath, workingBranch),
|
|
19
|
+
gitOps.getWorkingTreeStatusAsync(worktreePath),
|
|
20
|
+
forgeProvider.isAvailable(worktreePath),
|
|
21
|
+
]);
|
|
20
22
|
return {
|
|
21
23
|
commitCount,
|
|
22
24
|
behindCount,
|
|
@@ -6,6 +6,29 @@ const IMAGES_DIR = '.ai/images';
|
|
|
6
6
|
const INDEX_FILE = 'index.json';
|
|
7
7
|
// Per-worktree lock to serialize index.json writes
|
|
8
8
|
const locks = new Map();
|
|
9
|
+
function ensureRealImagesDirectory(worktreePath) {
|
|
10
|
+
const realWorktree = fs.realpathSync(worktreePath);
|
|
11
|
+
let current = worktreePath;
|
|
12
|
+
for (const segment of ['.ai', 'images']) {
|
|
13
|
+
current = path.join(current, segment);
|
|
14
|
+
if (fs.existsSync(current)) {
|
|
15
|
+
const stat = fs.lstatSync(current);
|
|
16
|
+
if (stat.isSymbolicLink())
|
|
17
|
+
throw new Error(`Symbolic links are not allowed in image storage: ${current}`);
|
|
18
|
+
if (!stat.isDirectory())
|
|
19
|
+
throw new Error(`Image storage path is not a directory: ${current}`);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
fs.mkdirSync(current);
|
|
23
|
+
}
|
|
24
|
+
const realCurrent = fs.realpathSync(current);
|
|
25
|
+
const relative = path.relative(realWorktree, realCurrent);
|
|
26
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
27
|
+
throw new Error('Image storage path escapes the worktree');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return fs.realpathSync(current);
|
|
31
|
+
}
|
|
9
32
|
function withLock(worktreePath, fn) {
|
|
10
33
|
const prev = locks.get(worktreePath) ?? Promise.resolve();
|
|
11
34
|
// The second argument to .then() means: even if the previous operation in the
|
|
@@ -18,6 +41,8 @@ function readIndex(imagesDir) {
|
|
|
18
41
|
const indexPath = path.join(imagesDir, INDEX_FILE);
|
|
19
42
|
if (!fs.existsSync(indexPath))
|
|
20
43
|
return [];
|
|
44
|
+
if (fs.lstatSync(indexPath).isSymbolicLink())
|
|
45
|
+
throw new Error('Symbolic links are not allowed for image index');
|
|
21
46
|
try {
|
|
22
47
|
return JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
23
48
|
}
|
|
@@ -27,7 +52,13 @@ function readIndex(imagesDir) {
|
|
|
27
52
|
}
|
|
28
53
|
}
|
|
29
54
|
function writeIndex(imagesDir, entries) {
|
|
30
|
-
|
|
55
|
+
const indexPath = path.join(imagesDir, INDEX_FILE);
|
|
56
|
+
if (fs.existsSync(indexPath) && fs.lstatSync(indexPath).isSymbolicLink()) {
|
|
57
|
+
throw new Error('Symbolic links are not allowed for image index');
|
|
58
|
+
}
|
|
59
|
+
const tempPath = path.join(imagesDir, `.index-${process.pid}.tmp`);
|
|
60
|
+
fs.writeFileSync(tempPath, JSON.stringify(entries, null, 2));
|
|
61
|
+
fs.renameSync(tempPath, indexPath);
|
|
31
62
|
}
|
|
32
63
|
/** Save an image buffer to `.ai/images/` and update the index. Returns the UID and relative path. */
|
|
33
64
|
export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
@@ -39,9 +70,7 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
|
39
70
|
throw new Error(`Unsupported image extension: '${ext}'. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`);
|
|
40
71
|
}
|
|
41
72
|
const uid = nanoid(10);
|
|
42
|
-
const imagesDir =
|
|
43
|
-
// mkdirSync is idempotent — safe to call outside the lock
|
|
44
|
-
fs.mkdirSync(imagesDir, { recursive: true });
|
|
73
|
+
const imagesDir = ensureRealImagesDirectory(worktreePath);
|
|
45
74
|
const filename = `${uid}.${ext}`;
|
|
46
75
|
await withLock(worktreePath, () => {
|
|
47
76
|
// Write the image file inside the lock so both the file write and index
|
|
@@ -55,7 +84,7 @@ export async function saveImage(worktreePath, fileBuffer, originalName) {
|
|
|
55
84
|
}
|
|
56
85
|
/** Delete an image by UID from disk and the index. */
|
|
57
86
|
export async function deleteImage(worktreePath, uid) {
|
|
58
|
-
const imagesDir =
|
|
87
|
+
const imagesDir = ensureRealImagesDirectory(worktreePath);
|
|
59
88
|
await withLock(worktreePath, () => {
|
|
60
89
|
const entries = readIndex(imagesDir);
|
|
61
90
|
const idx = entries.findIndex((e) => e.uid === uid);
|
|
@@ -266,7 +266,15 @@ export async function extractNotionPage(notionUrl) {
|
|
|
266
266
|
}
|
|
267
267
|
finally {
|
|
268
268
|
mcpProcess.stdin?.end();
|
|
269
|
-
|
|
269
|
+
try {
|
|
270
|
+
if (mcpProcess.pid)
|
|
271
|
+
process.kill(-mcpProcess.pid, 'SIGTERM');
|
|
272
|
+
else
|
|
273
|
+
mcpProcess.kill();
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
mcpProcess.kill();
|
|
277
|
+
}
|
|
270
278
|
}
|
|
271
279
|
}
|
|
272
280
|
/** Lightweight MCP/auth smoke test; does not read a user page or mutate Notion. */
|
|
@@ -285,7 +293,15 @@ export async function testNotionConnection() {
|
|
|
285
293
|
}
|
|
286
294
|
finally {
|
|
287
295
|
mcpProcess.stdin?.end();
|
|
288
|
-
|
|
296
|
+
try {
|
|
297
|
+
if (mcpProcess.pid)
|
|
298
|
+
process.kill(-mcpProcess.pid, 'SIGTERM');
|
|
299
|
+
else
|
|
300
|
+
mcpProcess.kill();
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
mcpProcess.kill();
|
|
304
|
+
}
|
|
289
305
|
}
|
|
290
306
|
}
|
|
291
307
|
export async function listNotionUsers() {
|
|
@@ -339,7 +355,15 @@ export async function listNotionUsers() {
|
|
|
339
355
|
}
|
|
340
356
|
finally {
|
|
341
357
|
mcpProcess.stdin?.end();
|
|
342
|
-
|
|
358
|
+
try {
|
|
359
|
+
if (mcpProcess.pid)
|
|
360
|
+
process.kill(-mcpProcess.pid, 'SIGTERM');
|
|
361
|
+
else
|
|
362
|
+
mcpProcess.kill();
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
mcpProcess.kill();
|
|
366
|
+
}
|
|
343
367
|
}
|
|
344
368
|
}
|
|
345
369
|
export async function assignNotionPageToSelf(notionUrl, propertyName) {
|
|
@@ -398,7 +422,15 @@ export async function assignNotionPageToSelf(notionUrl, propertyName) {
|
|
|
398
422
|
}
|
|
399
423
|
finally {
|
|
400
424
|
mcpProcess.stdin?.end();
|
|
401
|
-
|
|
425
|
+
try {
|
|
426
|
+
if (mcpProcess.pid)
|
|
427
|
+
process.kill(-mcpProcess.pid, 'SIGTERM');
|
|
428
|
+
else
|
|
429
|
+
mcpProcess.kill();
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
mcpProcess.kill();
|
|
433
|
+
}
|
|
402
434
|
}
|
|
403
435
|
}
|
|
404
436
|
/** Update a status property on a Notion page. Best-effort, does not throw. */
|
|
@@ -433,6 +465,14 @@ export async function updateNotionStatus(notionUrl, propertyName, statusValue) {
|
|
|
433
465
|
}
|
|
434
466
|
finally {
|
|
435
467
|
mcpProcess.stdin?.end();
|
|
436
|
-
|
|
468
|
+
try {
|
|
469
|
+
if (mcpProcess.pid)
|
|
470
|
+
process.kill(-mcpProcess.pid, 'SIGTERM');
|
|
471
|
+
else
|
|
472
|
+
mcpProcess.kill();
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
mcpProcess.kill();
|
|
476
|
+
}
|
|
437
477
|
}
|
|
438
478
|
}
|
|
@@ -18,8 +18,19 @@ import { purgeWorktree } from './worktree-purge-service.js';
|
|
|
18
18
|
// it is recorded but NOT acted upon — prevents re-archiving manually
|
|
19
19
|
// unarchived workspaces.
|
|
20
20
|
const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
|
|
21
|
+
const WORKSPACE_CHECK_CONCURRENCY = 4;
|
|
21
22
|
let timer = null;
|
|
22
23
|
let checking = false;
|
|
24
|
+
async function runBounded(items, concurrency, worker) {
|
|
25
|
+
let nextIndex = 0;
|
|
26
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
27
|
+
while (nextIndex < items.length) {
|
|
28
|
+
const item = items[nextIndex++];
|
|
29
|
+
await worker(item);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
await Promise.all(workers);
|
|
33
|
+
}
|
|
23
34
|
/** Tracks the last known PR snapshot per workspace, used to detect transitions
|
|
24
35
|
* (state, base, reviewDecision). */
|
|
25
36
|
const lastKnownPr = new Map();
|
|
@@ -110,11 +121,11 @@ export async function checkPrStatuses() {
|
|
|
110
121
|
lastKnownGitStats.delete(id);
|
|
111
122
|
}
|
|
112
123
|
}
|
|
113
|
-
|
|
124
|
+
await runBounded(workspaces, WORKSPACE_CHECK_CONCURRENCY, async (ws) => {
|
|
114
125
|
// Without this guard, every git/forge spawn below fails with ENOENT and
|
|
115
126
|
// floods the logs when a worktree was deleted externally.
|
|
116
127
|
if (!fs.existsSync(ws.worktreePath))
|
|
117
|
-
|
|
128
|
+
return;
|
|
118
129
|
try {
|
|
119
130
|
// Opt-out: skip the forge call entirely for a workspace with PR-watch
|
|
120
131
|
// disabled — no PR-status fetch, no auto-archive/purge, no PR-derived
|
|
@@ -143,7 +154,7 @@ export async function checkPrStatuses() {
|
|
|
143
154
|
console.error(`[pr-watcher] updateWorkspaceSourceBranch failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
144
155
|
// Leave the cache untouched so the next tick retries — and skip
|
|
145
156
|
// stats too, since they'd be computed against the stale base.
|
|
146
|
-
|
|
157
|
+
return;
|
|
147
158
|
}
|
|
148
159
|
}
|
|
149
160
|
}
|
|
@@ -158,7 +169,7 @@ export async function checkPrStatuses() {
|
|
|
158
169
|
console.error(`[pr-watcher] computeGitStats failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
159
170
|
}
|
|
160
171
|
if (!pr)
|
|
161
|
-
|
|
172
|
+
return;
|
|
162
173
|
const prev = lastKnownPr.get(ws.id);
|
|
163
174
|
// We delay updating `lastKnownPr` until after the actions succeed.
|
|
164
175
|
// Setting it eagerly would poison the cache: if updateWorkspaceSourceBranch
|
|
@@ -179,13 +190,13 @@ export async function checkPrStatuses() {
|
|
|
179
190
|
// (The defensive base preservation from the no-base branch doesn't apply here
|
|
180
191
|
// because we ARE in the OPEN→MERGED/CLOSED branch which always has a base.)
|
|
181
192
|
lastKnownPr.set(ws.id, pr);
|
|
182
|
-
|
|
193
|
+
return;
|
|
183
194
|
}
|
|
184
195
|
console.log(`[pr-watcher] PR ${pr.state.toLowerCase()} for workspace '${ws.name}' — archiving`);
|
|
185
196
|
// Best-effort cleanup (same as manual archive): stop dev server + terminal.
|
|
186
197
|
// Agent is already not running here (guarded above).
|
|
187
198
|
try {
|
|
188
|
-
stopDevServer(ws.id);
|
|
199
|
+
await stopDevServer(ws.id);
|
|
189
200
|
}
|
|
190
201
|
catch (err) {
|
|
191
202
|
console.error(`[pr-watcher] stopDevServer failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
@@ -217,7 +228,7 @@ export async function checkPrStatuses() {
|
|
|
217
228
|
console.error(`[pr-watcher] auto-purge guard failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
218
229
|
}
|
|
219
230
|
}
|
|
220
|
-
|
|
231
|
+
return; // do not run base-change detection on a workspace we just archived
|
|
221
232
|
}
|
|
222
233
|
// Review, CI, mergeability, and readiness transitions (only on OPEN PRs;
|
|
223
234
|
// first-sight is silent). Each check is independent so simultaneous
|
|
@@ -254,7 +265,7 @@ export async function checkPrStatuses() {
|
|
|
254
265
|
if (pr.state !== 'OPEN' || !pr.base) {
|
|
255
266
|
const next = pr.base ? pr : { ...pr, base: prev?.base ?? pr.base };
|
|
256
267
|
lastKnownPr.set(ws.id, next);
|
|
257
|
-
|
|
268
|
+
return;
|
|
258
269
|
}
|
|
259
270
|
lastKnownPr.set(ws.id, pr);
|
|
260
271
|
// Emit the base-change event AFTER the snapshot is committed so a
|
|
@@ -272,7 +283,7 @@ export async function checkPrStatuses() {
|
|
|
272
283
|
catch (err) {
|
|
273
284
|
console.error(`[pr-watcher] Failed to check PR for workspace '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
274
285
|
}
|
|
275
|
-
}
|
|
286
|
+
});
|
|
276
287
|
}
|
|
277
288
|
/**
|
|
278
289
|
* On-demand refresh of a single workspace's PR snapshot. Bypasses the 30s tick.
|