@loicngr/kobo 1.11.9 → 1.11.11
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 +8 -7
- 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 +66 -49
- package/dist/server/services/agent/orchestrator.js +8 -1
- package/dist/server/services/auto-loop-service.js +1 -1
- package/dist/server/services/dev-server-service.js +48 -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/pr-watcher-service.js +20 -9
- package/dist/server/services/search-service.js +53 -40
- package/dist/server/services/skill-suite-prompts.js +72 -1
- 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/safe-path.js +14 -0
- package/dist/shared/skill-suite-prompts.js +12 -1
- package/package.json +22 -22
- package/src/client/dist/pwa/assets/ActivityFeed-BNDQPIiW.js +8 -0
- package/src/client/dist/pwa/assets/{ActivityFeed-Dv06XbgS.css → ActivityFeed-BrjeLlhi.css} +1 -1
- package/src/client/dist/pwa/assets/ChangelogPage-DWeiMCL1.js +1 -0
- package/src/client/dist/pwa/assets/{ClosePopup-BZ1O0Jxe.js → ClosePopup-Bl8KdN_j.js} +1 -1
- package/src/client/dist/pwa/assets/CreatePage-QwtfYunr.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-qCRXIoz-.js +2 -0
- package/src/client/dist/pwa/assets/DiffViewer-BwfYKTny.js +8 -0
- package/src/client/dist/pwa/assets/{DiffViewer-D9FN4RS8.css → DiffViewer-CS3P9Ngi.css} +1 -1
- package/src/client/dist/pwa/assets/HealthPage-zm1qy4bo.js +1 -0
- package/src/client/dist/pwa/assets/MainLayout-BDVUkruC.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-BKZW3k57.js +1 -0
- package/src/client/dist/pwa/assets/QChip-CMB-IbQd.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-DCm1FiOk.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-8fQLc_4N.js +1 -0
- package/src/client/dist/pwa/assets/SearchPage-B-prQgu3.js +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-BJZWZYPC.css +1 -0
- package/src/client/dist/pwa/assets/SettingsPage-CbasGyLP.js +16 -0
- package/src/client/dist/pwa/assets/TouchPan-CmmS3gg6.js +1 -0
- package/src/client/dist/pwa/assets/WorkspacePage-B9I2mQJE.js +4 -0
- package/src/client/dist/pwa/assets/WorkspacePage-ChnyJ3xI.css +1 -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-B4oUyX4f.js +1 -0
- package/src/client/dist/pwa/assets/documents-UdlveYUp.js +1 -0
- package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +1 -0
- package/src/client/dist/pwa/assets/editor.api-ByJzr33G.js +847 -0
- package/src/client/dist/pwa/assets/editor.worker-D-m8IBMa.js +30 -0
- package/src/client/dist/pwa/assets/expand-template-zfuUsWH3.js +1 -0
- package/src/client/dist/pwa/assets/formatters-D0dU6VcA.js +1 -0
- package/src/client/dist/pwa/assets/{freemarker2-Dgrv0PyH.js → freemarker2-DurjKE7N.js} +1 -1
- package/src/client/dist/pwa/assets/handlebars-gwHbbDT2.js +1 -0
- package/src/client/dist/pwa/assets/html-DDNV7Gkm.js +1 -0
- package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +506 -0
- package/src/client/dist/pwa/assets/htmlMode-BxdPTVhK.js +1 -0
- package/src/client/dist/pwa/assets/i18n-Z1bkxUCG.js +1 -0
- package/src/client/dist/pwa/assets/index-CK1nk7qr.css +1 -0
- package/src/client/dist/pwa/assets/index-M0ntkdkQ.js +82 -0
- package/src/client/dist/pwa/assets/{javascript-IK9qSsfx.js → javascript-Dp0eAm2m.js} +1 -1
- package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +62 -0
- package/src/client/dist/pwa/assets/{jsonMode-D_Vdxb-B.js → jsonMode-DLllFEMT.js} +3 -3
- package/src/client/dist/pwa/assets/{kobo-commands-zljfAram.js → kobo-commands-So0Do2g1.js} +1 -1
- package/src/client/dist/pwa/assets/layout-CfEpJmm_.js +1 -0
- package/src/client/dist/pwa/assets/liquid-BVwIeblL.js +1 -0
- package/src/client/dist/pwa/assets/lspLanguageFeatures-CExl7Xgb.js +4 -0
- package/src/client/dist/pwa/assets/mdx-ydpr14Ec.js +1 -0
- package/src/client/dist/pwa/assets/network-auth-CxxR2FZL.js +1 -0
- package/src/client/dist/pwa/assets/pending-diff-open-BqHyeaCb.js +1 -0
- package/src/client/dist/pwa/assets/permissionModes-6jhegAm-.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-6xNNY9u6.js → project-color-Dl2gXBlN.js} +1 -1
- package/src/client/dist/pwa/assets/python-BParijRW.js +1 -0
- package/src/client/dist/pwa/assets/razor-DmywR0-y.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-BQBEE7Bn.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-DiiMWOVl.js → tsMode-C51hBxK8.js} +1 -1
- package/src/client/dist/pwa/assets/typescript-C9LzA9s6.js +1 -0
- package/src/client/dist/pwa/assets/use-checkbox-SrkgiJho.js +1 -0
- package/src/client/dist/pwa/assets/use-onboarding--VIOMbMd.js +2 -0
- package/src/client/dist/pwa/assets/use-onboarding-CTZuBZOi.css +1 -0
- package/src/client/dist/pwa/assets/use-quasar-B6Aitpct.js +1 -0
- package/src/client/dist/pwa/assets/vs-BJBy3q9a.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-B89GKCYK.js +1 -0
- package/src/client/dist/pwa/assets/xml-C2D1fXDa.js +1 -0
- package/src/client/dist/pwa/assets/yaml-8QFrUEL6.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-Bs1uccfa.js +0 -8
- package/src/client/dist/pwa/assets/ChangelogPage-nW4vA3qx.js +0 -1
- package/src/client/dist/pwa/assets/CreatePage-BrdHh0Th.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-CRIBi2Vx.css +0 -1
- package/src/client/dist/pwa/assets/DiffViewer-DhtJQzG2.js +0 -8
- package/src/client/dist/pwa/assets/HealthPage-CExFCl_7.js +0 -1
- package/src/client/dist/pwa/assets/MainLayout-Coqyj1FP.css +0 -1
- package/src/client/dist/pwa/assets/MainLayout-WYZAjpD2.js +0 -37
- 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-CqyoLg5U.js +0 -1
- package/src/client/dist/pwa/assets/QChip-DMYWc1jc.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-CAa3syxF.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-6IQJP1_Y.js +0 -1
- package/src/client/dist/pwa/assets/SearchPage-CQtm05UQ.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-B38IzmSs.js +0 -16
- package/src/client/dist/pwa/assets/SettingsPage-BXiWr2ba.css +0 -1
- package/src/client/dist/pwa/assets/ThinkingItem-BqWpfNZP.js +0 -1
- package/src/client/dist/pwa/assets/ThinkingItem-Ckk4K1O-.css +0 -1
- package/src/client/dist/pwa/assets/TouchPan-C939psBh.js +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-CFF5Ghug.css +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-dS-Nc6QU.js +0 -4
- package/src/client/dist/pwa/assets/build-path-tree-C5rzY2hF.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-p60S_iK6.js +0 -1
- package/src/client/dist/pwa/assets/documents-CzlF-PFm.js +0 -1
- package/src/client/dist/pwa/assets/editor-BFebQ78e.css +0 -1
- package/src/client/dist/pwa/assets/editor.api2-DfOm2dM5.js +0 -872
- package/src/client/dist/pwa/assets/editor.main-6eriWmTe.js +0 -6
- package/src/client/dist/pwa/assets/editor.worker-Cu3tR8iJ.js +0 -26
- package/src/client/dist/pwa/assets/expand-template-B_RAt6BV.js +0 -1
- package/src/client/dist/pwa/assets/formatters-BoLOujHM.js +0 -1
- package/src/client/dist/pwa/assets/handlebars-D0XTaj97.js +0 -1
- package/src/client/dist/pwa/assets/html-DkYxFVm6.js +0 -1
- package/src/client/dist/pwa/assets/html.worker-BKBrNna1.js +0 -502
- package/src/client/dist/pwa/assets/htmlMode-Dj3mtvXI.js +0 -1
- package/src/client/dist/pwa/assets/i18n-Dzzn4YfF.js +0 -1
- package/src/client/dist/pwa/assets/index-C06a940j.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-BAnzeEPY.js +0 -1
- package/src/client/dist/pwa/assets/liquid-DURTrym0.js +0 -1
- package/src/client/dist/pwa/assets/lspLanguageFeatures-Bki_90YN.js +0 -4
- package/src/client/dist/pwa/assets/mdx-z7FKEEaF.js +0 -1
- package/src/client/dist/pwa/assets/monaco.contribution-D52lvpFV.js +0 -2
- package/src/client/dist/pwa/assets/network-auth-DrGWXKl2.js +0 -1
- package/src/client/dist/pwa/assets/pending-diff-open-DX42h-5U.js +0 -1
- package/src/client/dist/pwa/assets/permissionModes-DMHgg-69.js +0 -1
- package/src/client/dist/pwa/assets/python-DHQ3U-kG.js +0 -1
- package/src/client/dist/pwa/assets/razor-DMlreu1b.js +0 -1
- package/src/client/dist/pwa/assets/render-chat-markdown-0Vj9B94m.js +0 -66
- package/src/client/dist/pwa/assets/typescript-0wxHr6dC.js +0 -1
- package/src/client/dist/pwa/assets/use-checkbox-E9SV7iH5.js +0 -1
- package/src/client/dist/pwa/assets/use-onboarding--YqDGUQR.js +0 -2
- package/src/client/dist/pwa/assets/use-onboarding-B3l7mx48.css +0 -1
- package/src/client/dist/pwa/assets/use-quasar-oX1TyrPM.js +0 -1
- package/src/client/dist/pwa/assets/vue.runtime.esm-bundler-JZnIeD9D.js +0 -3
- package/src/client/dist/pwa/assets/workers-D0OEUSgt.js +0 -1
- package/src/client/dist/pwa/assets/xml-C7PczWV5.js +0 -1
- package/src/client/dist/pwa/assets/yaml-Dj6QPnyC.js +0 -1
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
4
|
import * as imageService from '../services/image-service.js';
|
|
5
5
|
import * as workspaceService from '../services/workspace-service.js';
|
|
6
|
+
import { isPathInside } from '../utils/safe-path.js';
|
|
6
7
|
/** Maximum allowed upload size for a single image (10 MB). */
|
|
7
8
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
8
9
|
/** MIME types accepted for image uploads. */
|
|
@@ -67,7 +68,7 @@ app.get('/:id/images/file', async (c) => {
|
|
|
67
68
|
}
|
|
68
69
|
// Allowlist: only the upload storage layout is served. No symlink escape
|
|
69
70
|
// either — we resolve and check containment.
|
|
70
|
-
if (
|
|
71
|
+
if (!/^\.ai\/images\/[^/]+$/.test(requested) || requested.includes('..')) {
|
|
71
72
|
return c.json({ error: 'Invalid or disallowed image path' }, 400);
|
|
72
73
|
}
|
|
73
74
|
const worktreePath = workspace.worktreePath;
|
|
@@ -78,9 +79,23 @@ app.get('/:id/images/file', async (c) => {
|
|
|
78
79
|
if (fullPath !== imagesRoot && !fullPath.startsWith(imagesRoot + path.sep)) {
|
|
79
80
|
return c.json({ error: 'Path escapes images root' }, 400);
|
|
80
81
|
}
|
|
82
|
+
if (!fs.existsSync(imagesRoot) || fs.lstatSync(imagesRoot).isSymbolicLink()) {
|
|
83
|
+
return c.json({ error: 'Invalid images root' }, 400);
|
|
84
|
+
}
|
|
85
|
+
if (!fs.existsSync(fullPath))
|
|
86
|
+
return c.json({ error: 'Image not found' }, 404);
|
|
87
|
+
const fileStat = fs.lstatSync(fullPath);
|
|
88
|
+
if (fileStat.isSymbolicLink())
|
|
89
|
+
return c.json({ error: 'Symbolic links are not allowed' }, 400);
|
|
90
|
+
const realWorktree = await fs.promises.realpath(worktreePath);
|
|
91
|
+
const realImagesRoot = await fs.promises.realpath(imagesRoot);
|
|
92
|
+
const realFile = await fs.promises.realpath(fullPath);
|
|
93
|
+
if (!isPathInside(realWorktree, realImagesRoot) || !isPathInside(realImagesRoot, realFile)) {
|
|
94
|
+
return c.json({ error: 'Path escapes images root' }, 400);
|
|
95
|
+
}
|
|
81
96
|
let buffer;
|
|
82
97
|
try {
|
|
83
|
-
buffer = await fs.promises.readFile(
|
|
98
|
+
buffer = await fs.promises.readFile(realFile);
|
|
84
99
|
}
|
|
85
100
|
catch {
|
|
86
101
|
return c.json({ error: 'Image not found' }, 404);
|
|
@@ -48,9 +48,23 @@ const app = new Hono();
|
|
|
48
48
|
/** Tracks workspaces currently running a setup script to prevent concurrent executions. */
|
|
49
49
|
const setupScriptRunning = new Set();
|
|
50
50
|
const VALID_AGENT_PERMISSION_MODES = ['plan', 'bypass', 'strict', 'interactive'];
|
|
51
|
+
const VALID_WORKSPACE_STATUSES = [
|
|
52
|
+
'created',
|
|
53
|
+
'extracting',
|
|
54
|
+
'brainstorming',
|
|
55
|
+
'executing',
|
|
56
|
+
'awaiting-user',
|
|
57
|
+
'completed',
|
|
58
|
+
'idle',
|
|
59
|
+
'error',
|
|
60
|
+
'quota',
|
|
61
|
+
];
|
|
51
62
|
function isAgentPermissionMode(value) {
|
|
52
63
|
return typeof value === 'string' && VALID_AGENT_PERMISSION_MODES.includes(value);
|
|
53
64
|
}
|
|
65
|
+
function isWorkspaceStatus(value) {
|
|
66
|
+
return typeof value === 'string' && VALID_WORKSPACE_STATUSES.includes(value);
|
|
67
|
+
}
|
|
54
68
|
async function deliverAgentPrompt(workspace, workingDir, prompt) {
|
|
55
69
|
const delivery = await agentManager.sendMessageForFallback(workspace.id, prompt);
|
|
56
70
|
if (delivery.status === 'sent')
|
|
@@ -780,7 +794,9 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
780
794
|
// active skill suite includes gstack, and only if the user has actually
|
|
781
795
|
// installed gstack locally (the binary may be absent — skip silently).
|
|
782
796
|
// Fast + idempotent; safe to run on every workspace boot.
|
|
783
|
-
if (globalSettings.skillSuite === 'gstack' ||
|
|
797
|
+
if (globalSettings.skillSuite === 'gstack' ||
|
|
798
|
+
globalSettings.skillSuite === 'superpowers+gstack' ||
|
|
799
|
+
globalSettings.skillSuite === 'superpowers+gstack+ecc') {
|
|
784
800
|
brainstormPrompt += `\n\nIMPORTANT (gstack): Before brainstorming, check whether \`~/.claude/skills/gstack/bin/gstack-brain-sync\` exists. If it does, run it with \`--once\` to load gstack learnings (project-specific patterns, pitfalls, and preferences from prior sessions) into memory. If the binary is missing (gstack not installed in this environment), skip silently and continue — do NOT install anything and do NOT mention the absence. Fast and idempotent when present.`;
|
|
785
801
|
}
|
|
786
802
|
if (body.autoLoop === true) {
|
|
@@ -1650,29 +1666,19 @@ app.get('/:id/session-metrics', (c) => {
|
|
|
1650
1666
|
session.id,
|
|
1651
1667
|
{ sessionId: session.id, toolCalls: 0, errors: 0, inputTokens: 0, outputTokens: 0 },
|
|
1652
1668
|
]));
|
|
1653
|
-
const
|
|
1654
|
-
.prepare(
|
|
1669
|
+
const rows = db
|
|
1670
|
+
.prepare(`SELECT session_id, tool_calls, errors, input_tokens, output_tokens
|
|
1671
|
+
FROM session_event_metrics
|
|
1672
|
+
WHERE workspace_id = ?`)
|
|
1655
1673
|
.all(id);
|
|
1656
|
-
for (const row of
|
|
1674
|
+
for (const row of rows) {
|
|
1657
1675
|
const metric = metrics.get(row.session_id);
|
|
1658
1676
|
if (!metric)
|
|
1659
1677
|
continue;
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
if (event.kind === 'error' || (event.kind === 'tool:result' && event.isError === true))
|
|
1665
|
-
metric.errors++;
|
|
1666
|
-
if (event.kind === 'usage') {
|
|
1667
|
-
if (typeof event.inputTokens === 'number')
|
|
1668
|
-
metric.inputTokens = Math.max(metric.inputTokens, event.inputTokens);
|
|
1669
|
-
if (typeof event.outputTokens === 'number')
|
|
1670
|
-
metric.outputTokens = Math.max(metric.outputTokens, event.outputTokens);
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
catch {
|
|
1674
|
-
// Corrupt historical events are intentionally ignored in diagnostics.
|
|
1675
|
-
}
|
|
1678
|
+
metric.toolCalls = row.tool_calls;
|
|
1679
|
+
metric.errors = row.errors;
|
|
1680
|
+
metric.inputTokens = row.input_tokens;
|
|
1681
|
+
metric.outputTokens = row.output_tokens;
|
|
1676
1682
|
}
|
|
1677
1683
|
return c.json({ sessions, metrics: [...metrics.values()] });
|
|
1678
1684
|
}
|
|
@@ -1719,7 +1725,8 @@ app.get('/:id/events', (c) => {
|
|
|
1719
1725
|
// optional: scope to a session view. Session views also include
|
|
1720
1726
|
// workspace-level rows where session_id IS NULL (legacy/pre-session items).
|
|
1721
1727
|
const session = c.req.query('session');
|
|
1722
|
-
const
|
|
1728
|
+
const parsedLimit = parseInt(c.req.query('limit') ?? '100', 10);
|
|
1729
|
+
const limit = Math.max(1, Math.min(Number.isFinite(parsedLimit) ? parsedLimit : 100, 500));
|
|
1723
1730
|
const db = getDb();
|
|
1724
1731
|
let rows;
|
|
1725
1732
|
if (around) {
|
|
@@ -1758,12 +1765,12 @@ app.get('/:id/events', (c) => {
|
|
|
1758
1765
|
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) ORDER BY rowid DESC LIMIT ?')
|
|
1759
1766
|
.all(id, session, limit)
|
|
1760
1767
|
: db
|
|
1761
|
-
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? ORDER BY rowid
|
|
1768
|
+
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? ORDER BY rowid DESC LIMIT ?')
|
|
1762
1769
|
.all(id, limit);
|
|
1763
1770
|
}
|
|
1764
1771
|
// Reverse to chronological order (we queried DESC for "before" pagination,
|
|
1765
1772
|
// or for the "session + no cursor" case where we fetched the newest first).
|
|
1766
|
-
if (
|
|
1773
|
+
if (!around)
|
|
1767
1774
|
rows.reverse();
|
|
1768
1775
|
const events = rows.map((row) => {
|
|
1769
1776
|
let parsedPayload;
|
|
@@ -1798,10 +1805,12 @@ app.get('/:id/events', (c) => {
|
|
|
1798
1805
|
hasMore = older.c > 0;
|
|
1799
1806
|
}
|
|
1800
1807
|
}
|
|
1801
|
-
else
|
|
1802
|
-
const total =
|
|
1803
|
-
|
|
1804
|
-
|
|
1808
|
+
else {
|
|
1809
|
+
const total = session
|
|
1810
|
+
? db
|
|
1811
|
+
.prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL)')
|
|
1812
|
+
.get(id, session)
|
|
1813
|
+
: db.prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ?').get(id);
|
|
1805
1814
|
hasMore = total.c > rows.length;
|
|
1806
1815
|
}
|
|
1807
1816
|
}
|
|
@@ -2017,7 +2026,11 @@ app.put('/:id/tags', async (c) => {
|
|
|
2017
2026
|
app.patch('/:id', migrationGuard, async (c) => {
|
|
2018
2027
|
try {
|
|
2019
2028
|
const id = c.req.param('id');
|
|
2020
|
-
const
|
|
2029
|
+
const parsed = await c.req.json();
|
|
2030
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
2031
|
+
return c.json({ error: 'Request body must be a JSON object' }, 400);
|
|
2032
|
+
}
|
|
2033
|
+
const body = parsed;
|
|
2021
2034
|
const workspace = workspaceService.getWorkspace(id);
|
|
2022
2035
|
if (!workspace) {
|
|
2023
2036
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
@@ -2026,12 +2039,12 @@ app.patch('/:id', migrationGuard, async (c) => {
|
|
|
2026
2039
|
if ('agent_description' in body) {
|
|
2027
2040
|
return c.json({ error: 'agent_description must be set via the agent MCP tool, not via the API' }, 400);
|
|
2028
2041
|
}
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
updated = workspaceService.updateWorkspaceModel(id, body.model);
|
|
2042
|
+
if (body.model !== undefined && (typeof body.model !== 'string' || !body.model.trim())) {
|
|
2043
|
+
return c.json({ error: 'model must be a non-empty string' }, 400);
|
|
2032
2044
|
}
|
|
2033
|
-
if (body.reasoningEffort !== undefined
|
|
2034
|
-
|
|
2045
|
+
if (body.reasoningEffort !== undefined &&
|
|
2046
|
+
(typeof body.reasoningEffort !== 'string' || !body.reasoningEffort.trim())) {
|
|
2047
|
+
return c.json({ error: 'reasoningEffort must be a non-empty string' }, 400);
|
|
2035
2048
|
}
|
|
2036
2049
|
if (body.agentPermissionMode !== undefined) {
|
|
2037
2050
|
if (!isAgentPermissionMode(body.agentPermissionMode)) {
|
|
@@ -2048,25 +2061,25 @@ app.patch('/:id', migrationGuard, async (c) => {
|
|
|
2048
2061
|
error: `Engine '${engineId}' does not support agentPermissionMode '${body.agentPermissionMode}'. Supported: ${supported.join(', ')}`,
|
|
2049
2062
|
}, 400);
|
|
2050
2063
|
}
|
|
2051
|
-
updated = workspaceService.updateAgentPermissionMode(id, body.agentPermissionMode);
|
|
2052
2064
|
}
|
|
2053
|
-
if (body.status) {
|
|
2065
|
+
if (body.status !== undefined) {
|
|
2066
|
+
if (!isWorkspaceStatus(body.status)) {
|
|
2067
|
+
return c.json({ error: `Invalid status. Must be one of: ${VALID_WORKSPACE_STATUSES.join(', ')}` }, 400);
|
|
2068
|
+
}
|
|
2054
2069
|
if (body.status === 'idle' && agentManager.hasController(id)) {
|
|
2055
2070
|
return c.json({ error: 'Cannot mark an active agent workspace idle; end or stop the agent session first' }, 409);
|
|
2056
2071
|
}
|
|
2057
|
-
updated = workspaceService.updateWorkspaceStatus(id, body.status);
|
|
2058
2072
|
}
|
|
2059
|
-
if (body.name !== undefined) {
|
|
2060
|
-
|
|
2073
|
+
if (body.name !== undefined && typeof body.name !== 'string') {
|
|
2074
|
+
return c.json({ error: 'name must be a string' }, 400);
|
|
2061
2075
|
}
|
|
2062
2076
|
if ('description' in body) {
|
|
2063
2077
|
const desc = body.description;
|
|
2064
2078
|
if (desc !== null && typeof desc !== 'string') {
|
|
2065
2079
|
return c.json({ error: 'description must be a string or null' }, 400);
|
|
2066
2080
|
}
|
|
2067
|
-
updated = workspaceService.updateWorkspaceDescription(id, desc);
|
|
2068
2081
|
}
|
|
2069
|
-
if (
|
|
2082
|
+
if (body.status === undefined &&
|
|
2070
2083
|
body.model === undefined &&
|
|
2071
2084
|
body.reasoningEffort === undefined &&
|
|
2072
2085
|
body.agentPermissionMode === undefined &&
|
|
@@ -2074,7 +2087,7 @@ app.patch('/:id', migrationGuard, async (c) => {
|
|
|
2074
2087
|
!('description' in body)) {
|
|
2075
2088
|
return c.json({ error: 'Missing field: status, model, reasoningEffort, agentPermissionMode, name, or description' }, 400);
|
|
2076
2089
|
}
|
|
2077
|
-
return c.json(
|
|
2090
|
+
return c.json(workspaceService.updateWorkspaceFields(id, body));
|
|
2078
2091
|
}
|
|
2079
2092
|
catch (err) {
|
|
2080
2093
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -2270,7 +2283,7 @@ app.post('/:id/run-setup-script', async (c) => {
|
|
|
2270
2283
|
}
|
|
2271
2284
|
});
|
|
2272
2285
|
// POST /api/workspaces/:id/archive — mark workspace as archived (soft-delete)
|
|
2273
|
-
app.post('/:id/archive', migrationGuard, (c) => {
|
|
2286
|
+
app.post('/:id/archive', migrationGuard, async (c) => {
|
|
2274
2287
|
try {
|
|
2275
2288
|
const id = c.req.param('id');
|
|
2276
2289
|
const workspace = workspaceService.getWorkspace(id);
|
|
@@ -2287,7 +2300,7 @@ app.post('/:id/archive', migrationGuard, (c) => {
|
|
|
2287
2300
|
// Agent may not be running — ignore
|
|
2288
2301
|
}
|
|
2289
2302
|
try {
|
|
2290
|
-
devServerService.stopDevServer(id);
|
|
2303
|
+
await devServerService.stopDevServer(id);
|
|
2291
2304
|
}
|
|
2292
2305
|
catch (err) {
|
|
2293
2306
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -2362,7 +2375,7 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
|
|
|
2362
2375
|
// Every side-effect is best-effort — failures are collected as warnings
|
|
2363
2376
|
// rather than thrown, so a bulk delete never aborts mid-batch. Returns the
|
|
2364
2377
|
// list of user-facing warning messages (empty when everything was clean).
|
|
2365
|
-
function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
2378
|
+
async function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
2366
2379
|
// Stop agent if running (best-effort)
|
|
2367
2380
|
try {
|
|
2368
2381
|
agentManager.stopAgent(workspace.id);
|
|
@@ -2373,7 +2386,7 @@ function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
|
2373
2386
|
// Stop dev server if it was running. The processSpawn would otherwise
|
|
2374
2387
|
// outlive the workspace (and keep its port + docker containers alive).
|
|
2375
2388
|
try {
|
|
2376
|
-
devServerService.stopDevServer(workspace.id);
|
|
2389
|
+
await devServerService.stopDevServer(workspace.id);
|
|
2377
2390
|
}
|
|
2378
2391
|
catch (err) {
|
|
2379
2392
|
console.error(`[workspaces] stopDevServer during delete failed for '${workspace.name}':`, err);
|
|
@@ -2457,7 +2470,7 @@ app.delete('/archived', migrationGuard, async (c) => {
|
|
|
2457
2470
|
let deleted = 0;
|
|
2458
2471
|
for (const workspace of archived) {
|
|
2459
2472
|
try {
|
|
2460
|
-
warnings.push(...deleteWorkspaceWithSideEffects(workspace, body));
|
|
2473
|
+
warnings.push(...(await deleteWorkspaceWithSideEffects(workspace, body)));
|
|
2461
2474
|
deleted++;
|
|
2462
2475
|
}
|
|
2463
2476
|
catch (err) {
|
|
@@ -2485,7 +2498,7 @@ app.delete('/:id', migrationGuard, async (c) => {
|
|
|
2485
2498
|
const body = await c.req
|
|
2486
2499
|
.json()
|
|
2487
2500
|
.catch(() => ({}));
|
|
2488
|
-
const warnings = deleteWorkspaceWithSideEffects(workspace, body);
|
|
2501
|
+
const warnings = await deleteWorkspaceWithSideEffects(workspace, body);
|
|
2489
2502
|
// When everything worked cleanly we keep the legacy 204 response so
|
|
2490
2503
|
// existing clients aren't surprised by a JSON body. Warnings promote the
|
|
2491
2504
|
// response to 200 so the body is readable.
|
|
@@ -2507,6 +2520,10 @@ app.post('/:id/start', migrationGuard, async (c) => {
|
|
|
2507
2520
|
if (!workspace) {
|
|
2508
2521
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
2509
2522
|
}
|
|
2523
|
+
if (workspace.archivedAt)
|
|
2524
|
+
return c.json({ error: `Workspace '${id}' is archived` }, 409);
|
|
2525
|
+
if (workspace.worktreePurgedAt)
|
|
2526
|
+
return c.json({ error: `Workspace '${id}' worktree is purged` }, 409);
|
|
2510
2527
|
// If the workspace declares an engine, ensure it is still registered.
|
|
2511
2528
|
// Otherwise startAgent() would throw from deep inside resolveEngine and
|
|
2512
2529
|
// surface as an opaque 500 — better to fail fast with a clear 400.
|
|
@@ -3300,7 +3317,7 @@ app.post('/:id/merge-pr', async (c) => {
|
|
|
3300
3317
|
}, 409);
|
|
3301
3318
|
}
|
|
3302
3319
|
const snapshot = await provider.getPrStatus(workspace.worktreePath, workspace.workingBranch);
|
|
3303
|
-
if (
|
|
3320
|
+
if (snapshot?.state !== 'OPEN') {
|
|
3304
3321
|
return c.json({ error: 'No open PR found for this workspace', code: 'pr_not_open' }, 409);
|
|
3305
3322
|
}
|
|
3306
3323
|
if (!snapshot.readyToMerge) {
|
|
@@ -3654,7 +3671,7 @@ app.post('/:id/start-ci-fix', migrationGuard, async (c) => {
|
|
|
3654
3671
|
if (!snapshot) {
|
|
3655
3672
|
snapshot = getAllPrSnapshots()[id] ?? null;
|
|
3656
3673
|
}
|
|
3657
|
-
if (
|
|
3674
|
+
if (snapshot?.state !== 'OPEN' || snapshot.ci.rollup !== 'FAILURE') {
|
|
3658
3675
|
return c.json({ error: 'No failing CI detected on this workspace' }, 400);
|
|
3659
3676
|
}
|
|
3660
3677
|
const effective = settingsService.getEffectiveSettings(workspace.projectPath);
|
|
@@ -847,6 +847,13 @@ function onSessionEnded(workspaceId, agentSessionId, sourceController, exitCode,
|
|
|
847
847
|
* query the controller via `_getControllers()` for tests.
|
|
848
848
|
*/
|
|
849
849
|
export function startAgent(workspaceId, workingDir, prompt, model, resume = false, agentPermissionMode, existingSessionId, reasoningEffort) {
|
|
850
|
+
const workspace = getWs(workspaceId);
|
|
851
|
+
if (!workspace)
|
|
852
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
853
|
+
if (workspace.archivedAt)
|
|
854
|
+
throw new Error(`Workspace '${workspaceId}' is archived`);
|
|
855
|
+
if (workspace.worktreePurgedAt)
|
|
856
|
+
throw new Error(`Workspace '${workspaceId}' worktree is purged`);
|
|
850
857
|
// "For this turn" never survives a new agent session.
|
|
851
858
|
permissionPolicyService.clearTurnPermissions(workspaceId);
|
|
852
859
|
// Zombie detection: an SDK iterator hung on a never-resolved canUseTool
|
|
@@ -1317,7 +1324,7 @@ export function _getPendingDeferred() {
|
|
|
1317
1324
|
const out = new Map();
|
|
1318
1325
|
for (const [wid, arr] of pendingQueue) {
|
|
1319
1326
|
const head = arr[0];
|
|
1320
|
-
if (
|
|
1327
|
+
if (head?.kind !== 'question')
|
|
1321
1328
|
continue;
|
|
1322
1329
|
out.set(wid, {
|
|
1323
1330
|
toolCallId: head.toolCallId,
|
|
@@ -78,7 +78,7 @@ export function enable(workspaceId) {
|
|
|
78
78
|
*/
|
|
79
79
|
export function disable(workspaceId, reason) {
|
|
80
80
|
const row = getRow(workspaceId);
|
|
81
|
-
if (
|
|
81
|
+
if (row?.auto_loop !== 1)
|
|
82
82
|
return;
|
|
83
83
|
const db = getDb();
|
|
84
84
|
db.prepare('UPDATE workspaces SET auto_loop = 0 WHERE id = ?').run(workspaceId);
|
|
@@ -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',
|
|
@@ -192,9 +206,14 @@ export function startDevServer(workspaceId) {
|
|
|
192
206
|
});
|
|
193
207
|
proc.on('exit', (code) => {
|
|
194
208
|
trackedProcesses.delete(workspaceId);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
});
|
|
198
217
|
if (code !== 0) {
|
|
199
218
|
console.error(`[dev-server] Process exited with code ${code} for workspace ${workspaceId}`);
|
|
200
219
|
}
|
|
@@ -229,7 +248,7 @@ export function startDevServer(workspaceId) {
|
|
|
229
248
|
/**
|
|
230
249
|
* Stop the dev-server for a workspace.
|
|
231
250
|
*/
|
|
232
|
-
export function stopDevServer(workspaceId) {
|
|
251
|
+
export async function stopDevServer(workspaceId) {
|
|
233
252
|
const workspace = getWorkspace(workspaceId);
|
|
234
253
|
if (!workspace) {
|
|
235
254
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
@@ -256,18 +275,15 @@ export function stopDevServer(workspaceId) {
|
|
|
256
275
|
}
|
|
257
276
|
const settings = getProjectSettings(workspace.projectPath);
|
|
258
277
|
if (settings?.devServer.stopCommand) {
|
|
259
|
-
// Custom stop script — run synchronously with instance context in env
|
|
260
278
|
try {
|
|
261
|
-
|
|
279
|
+
await runCommand('bash', ['-c', settings.devServer.stopCommand], {
|
|
262
280
|
cwd,
|
|
263
281
|
env: {
|
|
264
282
|
...cleanEnv(),
|
|
265
283
|
INSTANCE: instanceName,
|
|
266
284
|
PROJECT_NAME: config?.projectName ?? '',
|
|
267
285
|
},
|
|
268
|
-
|
|
269
|
-
timeout: 30000,
|
|
270
|
-
shell: 'bash',
|
|
286
|
+
timeout: 30_000,
|
|
271
287
|
});
|
|
272
288
|
}
|
|
273
289
|
catch (err) {
|
|
@@ -278,11 +294,7 @@ export function stopDevServer(workspaceId) {
|
|
|
278
294
|
// (handles cases where custom stop command doesn't use -p flag)
|
|
279
295
|
if (config?.projectName) {
|
|
280
296
|
try {
|
|
281
|
-
|
|
282
|
-
cwd: cwd,
|
|
283
|
-
encoding: 'utf-8',
|
|
284
|
-
timeout: 30000,
|
|
285
|
-
});
|
|
297
|
+
await runCommand('docker', ['compose', '-p', config.projectName, 'down'], { cwd, timeout: 30_000 });
|
|
286
298
|
}
|
|
287
299
|
catch {
|
|
288
300
|
// May already be stopped by the custom command — ignore
|
|
@@ -303,10 +315,9 @@ export function stopDevServer(workspaceId) {
|
|
|
303
315
|
// ── Logs ───────────────────────────────────────────────────────────────────────
|
|
304
316
|
/**
|
|
305
317
|
* Get logs from running dev-server containers for a workspace.
|
|
306
|
-
*
|
|
307
|
-
* `docker ps` output (not user input), so no injection risk.
|
|
318
|
+
* Docker log reads run concurrently without blocking the Node event loop.
|
|
308
319
|
*/
|
|
309
|
-
export function getDevServerLogs(workspaceId, tail = 200) {
|
|
320
|
+
export async function getDevServerLogs(workspaceId, tail = 200) {
|
|
310
321
|
const workspace = getWorkspace(workspaceId);
|
|
311
322
|
if (!workspace) {
|
|
312
323
|
return 'Workspace not found';
|
|
@@ -315,24 +326,20 @@ export function getDevServerLogs(workspaceId, tail = 200) {
|
|
|
315
326
|
if (!config) {
|
|
316
327
|
return 'No dev-server instance found';
|
|
317
328
|
}
|
|
318
|
-
const running = listRunningContainers();
|
|
319
|
-
const matching = running.filter((name) => name
|
|
329
|
+
const running = await listRunningContainers();
|
|
330
|
+
const matching = running.filter((name) => containerBelongsToProject(name, config.projectName));
|
|
320
331
|
if (matching.length === 0) {
|
|
321
332
|
return 'No running containers found';
|
|
322
333
|
}
|
|
323
|
-
const outputs =
|
|
324
|
-
for (const container of matching) {
|
|
334
|
+
const outputs = await Promise.all(matching.map(async (container) => {
|
|
325
335
|
try {
|
|
326
|
-
const logs =
|
|
327
|
-
|
|
328
|
-
timeout: 10000,
|
|
329
|
-
});
|
|
330
|
-
outputs.push(`=== ${container} ===\n${logs}`);
|
|
336
|
+
const logs = await runCommand('docker', ['logs', '--tail', String(tail), container], { timeout: 10_000 });
|
|
337
|
+
return `=== ${container} ===\n${logs}`;
|
|
331
338
|
}
|
|
332
339
|
catch (err) {
|
|
333
340
|
const message = err instanceof Error ? err.message : String(err);
|
|
334
|
-
|
|
341
|
+
return `=== ${container} ===\n[Error fetching logs: ${message}]`;
|
|
335
342
|
}
|
|
336
|
-
}
|
|
343
|
+
}));
|
|
337
344
|
return outputs.join('\n\n');
|
|
338
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
|
};
|