@loicngr/kobo 1.11.36 → 1.11.38
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 +18 -1
- package/CHANGELOG.md +8 -0
- package/dist/server/index.js +20 -24
- package/dist/server/routes/dev-server.js +2 -1
- package/dist/server/routes/pull-requests.js +5 -4
- package/dist/server/routes/workspaces.js +161 -109
- package/dist/server/services/agent/engines/claude-code/engine.js +11 -0
- package/dist/server/services/agent/engines/codex/client.js +1 -0
- package/dist/server/services/agent/engines/codex/engine.js +47 -10
- package/dist/server/services/agent/engines/codex/event-mapper.js +8 -2
- package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +21 -8
- package/dist/server/services/agent/engines/codex/jsonrpc/transport.js +30 -3
- package/dist/server/services/agent/event-router.js +1 -1
- package/dist/server/services/agent/orchestrator.js +219 -78
- package/dist/server/services/agent/session-controller.js +12 -2
- package/dist/server/services/auto-loop-service.js +60 -6
- package/dist/server/services/change-source-branch-service.js +36 -4
- package/dist/server/services/cron-service.js +19 -0
- package/dist/server/services/db-backup-service.js +30 -0
- package/dist/server/services/dev-server-service.js +167 -38
- package/dist/server/services/image-service.js +25 -28
- package/dist/server/services/pr-checkout-service.js +34 -5
- package/dist/server/services/pr-watcher-service.js +5 -3
- package/dist/server/services/quota-backoff-service.js +25 -8
- package/dist/server/services/wakeup-service.js +23 -3
- package/dist/server/services/websocket-service.js +10 -2
- package/dist/server/services/workspace-service.js +8 -4
- package/dist/server/services/worktree-purge-service.js +3 -7
- package/dist/server/utils/agent-stop-result.js +12 -0
- package/dist/server/utils/git-ops.js +8 -1
- package/dist/server/utils/git-worktree-fingerprint.js +86 -0
- package/dist/server/utils/safe-path.js +21 -0
- package/dist/server/utils/script-runner.js +32 -9
- package/dist/server/utils/workspace-lifecycle-guard.js +27 -0
- package/package.json +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +8 -0
- package/src/client/dist/pwa/assets/{ActivityFeed-Bakgvay6.css → ActivityFeed-Chsw1Mh7.css} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-BFGYQ6eY.js → ChangelogPage-D8563008.js} +1 -1
- package/src/client/dist/pwa/assets/ComparisonPanel-Bd0X08sg.js +2 -0
- package/src/client/dist/pwa/assets/ComparisonPanel-CRRaDs2D.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +1 -0
- package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +2 -0
- package/src/client/dist/pwa/assets/DashboardPage-D8g7_r4S.css +1 -0
- package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +1 -0
- package/src/client/dist/pwa/assets/{DiffViewer-DSlSAIiW.js → DiffViewer-BpsLNxnj.js} +3 -3
- package/src/client/dist/pwa/assets/{DocumentsPanel-C1eXTw_Q.js → DocumentsPanel-BJsJ0fNs.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-BxYpUb4y.js → DrawerToggleButton-BVLO5K5d.js} +1 -1
- package/src/client/dist/pwa/assets/{GitPanel-DnBWSdVp.js → GitPanel-Cal_wzC0.js} +3 -3
- package/src/client/dist/pwa/assets/{HealthPage-rX1TiaP9.js → HealthPage-RFZS7Lao.js} +1 -1
- package/src/client/dist/pwa/assets/{MainLayout-Ceuh5uJj.js → MainLayout-vvyiRcql.js} +2 -2
- package/src/client/dist/pwa/assets/{QBtnDropdown-K7syiyyH.js → QBtnDropdown-X8oNU5Mb.js} +1 -1
- package/src/client/dist/pwa/assets/{QDrawer-DapzE8DH.js → QDrawer-Cij17skX.js} +1 -1
- package/src/client/dist/pwa/assets/{QSelect-Bodml89k.js → QSelect-xd-AlGo_.js} +1 -1
- package/src/client/dist/pwa/assets/{QTooltip-K-9OdDfq.js → QTooltip-CwcCGSWq.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-CfumTzAa.js → SchedulePanel-CXRUsz6G.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-AhZYS-dS.js → SearchPage-DopbgLxh.js} +1 -1
- package/src/client/dist/pwa/assets/{SettingsPage-BoHXWvKn.js → SettingsPage-Jx6TnOzR.js} +1 -1
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-3wK11mcu.js → SplitWorkspacePage-D7lGFRKs.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-CKg0BeNK.js → TerminalPanel-DI8DHb9e.js} +1 -1
- package/src/client/dist/pwa/assets/{TouchPan-D6ctyPYf.js → TouchPan-B3nrkCY8.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-Dn4Dy_kH.js → TourReplayButton-DU1UShMY.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-DnoabsfK.js → WorkspaceAttentionLabels-C-yN5IX8.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspacePage-DYQrfeYw.css → WorkspacePage-BAC6u9OP.css} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +4 -0
- package/src/client/dist/pwa/assets/{browser-DHpSUK8-.js → browser-f9MHQjfi.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-DtEjsZlh.js → build-path-tree-BWBzP8os.js} +1 -1
- package/src/client/dist/pwa/assets/{de-Dt_m-H83.js → de-QkDLrcGq.js} +1 -1
- package/src/client/dist/pwa/assets/{documents-B2dg1wRf.js → documents-C2cLnEBQ.js} +1 -1
- package/src/client/dist/pwa/assets/{editor.api-Cd1D35oj.js → editor.api-sw146MWN.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-IB1vPjJQ.js → efforts-C5ZZgCH4.js} +1 -1
- package/src/client/dist/pwa/assets/{engineFeatures-DYSyDEJe.js → engineFeatures-CAVMDawq.js} +1 -1
- package/src/client/dist/pwa/assets/{es-DFg25DCS.js → es-BgQFKlo8.js} +1 -1
- package/src/client/dist/pwa/assets/{expand-template-CNAHiesL.js → expand-template-B6xAlAaM.js} +1 -1
- package/src/client/dist/pwa/assets/{format-DidwgbbX.js → format-BwWP6Aay.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-BjzyUHlu.js → formatters-DyWtO-nR.js} +1 -1
- package/src/client/dist/pwa/assets/{fr-CQ1slpol.js → fr-DAdLWjNM.js} +1 -1
- package/src/client/dist/pwa/assets/{freemarker2-jNGWawVw.js → freemarker2-DZ5Hm8UH.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-DfSqClrW.js → handlebars-BRUGpTA7.js} +1 -1
- package/src/client/dist/pwa/assets/{html-ByLX-n8T.js → html-DNOayNNs.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-CBez_vip.js +1 -0
- package/src/client/dist/pwa/assets/index-VL3fI9a3.js +20 -0
- package/src/client/dist/pwa/assets/{it-w09rfaP7.js → it-Bumaewp1.js} +1 -1
- package/src/client/dist/pwa/assets/{javascript-Dja1fHfv.js → javascript-C0tDQU3z.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-D315hW6C.js → liquid-CN46O6OF.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-B5yUwpzv.js → mdx-DM-lrr8Q.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-NfxrU0r3.js → monaco-contributions-CkN7y-8c.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-D1hXPkDZ.js → monaco.contribution-2BcAOLjc.js} +2 -2
- package/src/client/dist/pwa/assets/{network-auth-CARVtbNe.js → network-auth-KYS4snQH.js} +1 -1
- package/src/client/dist/pwa/assets/{notify-theme-CKC3-EAZ.js → notify-theme-a91Tcr6o.js} +1 -1
- package/src/client/dist/pwa/assets/{pending-diff-open-BlLrQaW7.js → pending-diff-open-DOtELZfB.js} +1 -1
- package/src/client/dist/pwa/assets/{permissionModes-DMzFEQhr.js → permissionModes-C-9Crj-c.js} +1 -1
- package/src/client/dist/pwa/assets/{project-color-CFjdIj6W.js → project-color-BRe4oyNx.js} +1 -1
- package/src/client/dist/pwa/assets/{python-Dc6mnEip.js → python-BRpJG6sZ.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-DArVFBGO.js → razor-CehCmvHl.js} +1 -1
- package/src/client/dist/pwa/assets/{render-chat-markdown-BTPAjU2g.js → render-chat-markdown-DlbQLZ02.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-COE1vIyB.js → typescript-Dt_8mVuj.js} +1 -1
- package/src/client/dist/pwa/assets/{use-tours-C8x2V1w5.js → use-tours-CSVM_54K.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-DHyA_GEW.js → workspace-templates-GCObcjO6.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-eYjr4cGR.js → xml-_gOwdzB4.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-UooOcssF.js → yaml-BxeBqpGf.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/client/dist/pwa/assets/ActivityFeed-BPx-xhFY.js +0 -8
- package/src/client/dist/pwa/assets/ComparisonPanel-C6DfjpZZ.js +0 -2
- package/src/client/dist/pwa/assets/ComparisonPanel-x1iuICgd.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-DxOyY65u.css +0 -1
- package/src/client/dist/pwa/assets/CreatePage-ypEzkAKv.js +0 -2
- package/src/client/dist/pwa/assets/DashboardPage-DsVRjzzy.js +0 -1
- package/src/client/dist/pwa/assets/DashboardPage-DsWn5cWl.css +0 -1
- package/src/client/dist/pwa/assets/WorkspacePage-BI7CW2yT.js +0 -4
- package/src/client/dist/pwa/assets/i18n-CnjXQjxy.js +0 -1
- package/src/client/dist/pwa/assets/index-DcwcFDrZ.js +0 -20
|
@@ -2,10 +2,12 @@ import fs from 'node:fs';
|
|
|
2
2
|
import { buildE2eIterationBlock, buildFinalizationIterationBlock } from '../../shared/auto-loop-prompts.js';
|
|
3
3
|
import { getDb } from '../db/index.js';
|
|
4
4
|
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
5
|
+
import { deferUntilWorkspaceAvailable, isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
|
|
5
6
|
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
6
7
|
import * as orchestrator from './agent/orchestrator.js';
|
|
7
8
|
import * as cleanupScriptService from './cleanup-script-service.js';
|
|
8
9
|
import * as lifecycleHookService from './lifecycle-hook-service.js';
|
|
10
|
+
import * as quotaBackoffService from './quota-backoff-service.js';
|
|
9
11
|
import * as settingsService from './settings-service.js';
|
|
10
12
|
import { getSuitePrompts } from './skill-suite-prompts.js';
|
|
11
13
|
import { emit, emitEphemeral } from './websocket-service.js';
|
|
@@ -129,7 +131,7 @@ export function onSessionEnded(workspaceId, reason, taskProgressDelta) {
|
|
|
129
131
|
return;
|
|
130
132
|
// Don't spawn a competing session while paused on canUseTool — the user
|
|
131
133
|
// will resume the deferred turn explicitly.
|
|
132
|
-
if (row.status === 'awaiting-user')
|
|
134
|
+
if (row.status === 'awaiting-user' || row.status === 'compacting')
|
|
133
135
|
return;
|
|
134
136
|
// A watchdog denotes Kōbō's own forced recovery from a stuck engine stream.
|
|
135
137
|
// The orchestrator owns its bounded backoff/retry path; counting it as a
|
|
@@ -295,7 +297,9 @@ function hasFreeAgentSlot() {
|
|
|
295
297
|
* free, exactly one starts and the other two keep waiting rather than each
|
|
296
298
|
* re-announcing that they wait.
|
|
297
299
|
*/
|
|
298
|
-
export function resumeWaitingWorkspaces() {
|
|
300
|
+
export function resumeWaitingWorkspaces(excluded = new Set()) {
|
|
301
|
+
if (orchestrator.isShuttingDown())
|
|
302
|
+
return;
|
|
299
303
|
const db = getDb();
|
|
300
304
|
const rows = db
|
|
301
305
|
.prepare(`SELECT id, status FROM workspaces
|
|
@@ -303,13 +307,15 @@ export function resumeWaitingWorkspaces() {
|
|
|
303
307
|
ORDER BY updated_at ASC`)
|
|
304
308
|
.all();
|
|
305
309
|
for (const row of rows) {
|
|
310
|
+
if (excluded.has(row.id))
|
|
311
|
+
continue;
|
|
306
312
|
if (!hasFreeAgentSlot())
|
|
307
313
|
return;
|
|
308
314
|
if (orchestrator.hasController(row.id))
|
|
309
315
|
continue;
|
|
310
316
|
// Same guards as spawnNextIteration's own callers: a quota backoff or a
|
|
311
317
|
// pending question owns the next start of that workspace.
|
|
312
|
-
if (row.status === 'awaiting-user' || row.status === 'quota')
|
|
318
|
+
if (row.status === 'awaiting-user' || row.status === 'compacting' || row.status === 'quota')
|
|
313
319
|
continue;
|
|
314
320
|
try {
|
|
315
321
|
spawnNextIteration(row.id);
|
|
@@ -320,11 +326,18 @@ export function resumeWaitingWorkspaces() {
|
|
|
320
326
|
}
|
|
321
327
|
}
|
|
322
328
|
function spawnNextIteration(workspaceId, opts = {}) {
|
|
329
|
+
// Lifecycle/ready callbacks can outlive timer suspension; preserve their persisted loop intent.
|
|
330
|
+
if (orchestrator.isShuttingDown())
|
|
331
|
+
return;
|
|
323
332
|
const row = getRow(workspaceId);
|
|
324
|
-
if (!row)
|
|
333
|
+
if (!row || row.archived_at || !row.auto_loop)
|
|
334
|
+
return;
|
|
335
|
+
if (deferUntilWorkspaceAvailable(workspaceId, resumeWaitingWorkspaces))
|
|
336
|
+
return;
|
|
337
|
+
if (orchestrator.hasController(workspaceId))
|
|
325
338
|
return;
|
|
326
339
|
// Same guard as onSessionEnded — never race a deferred-resume start.
|
|
327
|
-
if (row.status === 'awaiting-user')
|
|
340
|
+
if (row.status === 'awaiting-user' || row.status === 'compacting')
|
|
328
341
|
return;
|
|
329
342
|
const task = pickNextTask(workspaceId);
|
|
330
343
|
if (!task) {
|
|
@@ -439,7 +452,18 @@ function spawnNextIteration(workspaceId, opts = {}) {
|
|
|
439
452
|
* - `status !== 'quota'` (user already manually resumed, or another path
|
|
440
453
|
* transitioned the workspace)
|
|
441
454
|
*/
|
|
442
|
-
|
|
455
|
+
const pendingQuotaStops = new Set();
|
|
456
|
+
const QUOTA_STOP_RETRY_MS = 15_000;
|
|
457
|
+
function deferQuotaRetry(workspaceId, pending) {
|
|
458
|
+
const attempt = pending ?? quotaBackoffService.getPending(workspaceId);
|
|
459
|
+
quotaBackoffService.arm(workspaceId, QUOTA_STOP_RETRY_MS, {
|
|
460
|
+
resetsAt: attempt?.resetsAt ?? null,
|
|
461
|
+
source: attempt?.source ?? 'fallback_ladder',
|
|
462
|
+
reason: attempt?.reason ?? 'quota',
|
|
463
|
+
retryCount: attempt?.retryCount ?? 1,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
export function onQuotaBackoffExpired(workspaceId, pending) {
|
|
443
467
|
const row = getRow(workspaceId);
|
|
444
468
|
if (!row)
|
|
445
469
|
return;
|
|
@@ -449,6 +473,36 @@ export function onQuotaBackoffExpired(workspaceId) {
|
|
|
449
473
|
return;
|
|
450
474
|
if (row.status !== 'quota')
|
|
451
475
|
return;
|
|
476
|
+
if (pendingQuotaStops.has(workspaceId) || orchestrator.isShuttingDown() || isWorkspaceLifecycleBusy(workspaceId)) {
|
|
477
|
+
deferQuotaRetry(workspaceId, pending);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (orchestrator.hasController(workspaceId)) {
|
|
481
|
+
pendingQuotaStops.add(workspaceId);
|
|
482
|
+
const stopped = orchestrator.stopAgentAndWait(workspaceId, undefined, 'replacement');
|
|
483
|
+
// The timer already consumed its row. Persist recovery immediately, after the
|
|
484
|
+
// technical stop's synchronous cancellation, so shutdown/crash during the await loses no intent.
|
|
485
|
+
deferQuotaRetry(workspaceId, pending);
|
|
486
|
+
void stopped
|
|
487
|
+
.then((outcome) => {
|
|
488
|
+
// Stop, archive, or a manual replacement may have superseded this retry while we awaited the engine.
|
|
489
|
+
const current = getRow(workspaceId);
|
|
490
|
+
if (!current || current.archived_at || !current.auto_loop || current.status !== 'quota')
|
|
491
|
+
return;
|
|
492
|
+
if (outcome === 'timeout' || outcome === 'failed' || orchestrator.isShuttingDown()) {
|
|
493
|
+
deferQuotaRetry(workspaceId, pending);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
updateWorkspaceStatus(workspaceId, 'idle');
|
|
497
|
+
quotaBackoffService.cancel(workspaceId, 'completed');
|
|
498
|
+
spawnNextIteration(workspaceId);
|
|
499
|
+
})
|
|
500
|
+
.catch((err) => {
|
|
501
|
+
console.error(`[auto-loop] quota controller stop failed for '${workspaceId}':`, err);
|
|
502
|
+
})
|
|
503
|
+
.finally(() => pendingQuotaStops.delete(workspaceId));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
452
506
|
// The timer has consumed its persisted row. Release quota ownership before
|
|
453
507
|
// checking capacity so resumeWaitingWorkspaces can pick this workspace up
|
|
454
508
|
// when a slot frees, even if no agent can start at this instant.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withWorkspaceLifecycleGuard } from '../utils/workspace-lifecycle-guard.js';
|
|
1
2
|
// src/server/services/change-source-branch-service.ts
|
|
2
3
|
import { spawn } from 'node:child_process';
|
|
3
4
|
import * as path from 'node:path';
|
|
@@ -44,6 +45,15 @@ const BACKUP_BRANCHES_KEPT = 3;
|
|
|
44
45
|
* running, unknown base). Returns a status discriminating the outcome.
|
|
45
46
|
*/
|
|
46
47
|
export async function changeSourceBranch(workspaceId, newBase) {
|
|
48
|
+
// Always acquire workspace ownership before the shared repository lock.
|
|
49
|
+
return withWorkspaceLifecycleGuard(workspaceId, () => {
|
|
50
|
+
const workspace = getWorkspace(workspaceId);
|
|
51
|
+
if (!workspace)
|
|
52
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
53
|
+
return withGitRepoLock(workspace.worktreePath, () => changeSourceBranchGuarded(workspaceId, newBase));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function changeSourceBranchGuarded(workspaceId, newBase) {
|
|
47
57
|
const workspace = getWorkspace(workspaceId);
|
|
48
58
|
if (!workspace)
|
|
49
59
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
@@ -85,16 +95,16 @@ export async function changeSourceBranch(workspaceId, newBase) {
|
|
|
85
95
|
}
|
|
86
96
|
// A stale index.lock makes every git write fail half-way through, and the
|
|
87
97
|
// raw git message tells the user nothing. Typical origin: a setup/cleanup
|
|
88
|
-
// script SIGKILLed at its timeout during a commit.
|
|
89
|
-
// does not go through the repository lock, so the check is immediate here.
|
|
98
|
+
// script SIGKILLed at its timeout during a commit. Both paths hold the lock.
|
|
90
99
|
gitOps.assertNoIndexLock(worktreePath);
|
|
100
|
+
gitOps.assertCurrentBranch(worktreePath, workingBranch);
|
|
91
101
|
return runCustomScript(workspace, oldBase, trimmedNew, effective.changeSourceBranchScript);
|
|
92
102
|
}
|
|
93
|
-
//
|
|
103
|
+
// Both strategies run under the repository lock: it fetches,
|
|
94
104
|
// resets and cherry-picks, all of which touch the COMMON git dir shared by
|
|
95
105
|
// every worktree of the project. A concurrent fetch from the PR watcher
|
|
96
106
|
// during the cherry-pick leaves it aborted mid-way.
|
|
97
|
-
return
|
|
107
|
+
return runBuiltInStrategy(workspaceId, worktreePath, workingBranch, workspace.projectPath, oldBase, trimmedNew);
|
|
98
108
|
}
|
|
99
109
|
/** The built-in cherry-pick strategy. Always called under the repository lock. */
|
|
100
110
|
async function runBuiltInStrategy(workspaceId, worktreePath, workingBranch, projectPath, oldBase, trimmedNew) {
|
|
@@ -114,6 +124,10 @@ async function runBuiltInStrategy(workspaceId, worktreePath, workingBranch, proj
|
|
|
114
124
|
catch {
|
|
115
125
|
// offline / no remote — proceed with local refs
|
|
116
126
|
}
|
|
127
|
+
assertWorkspaceUnchanged(workspaceId, worktreePath, workingBranch, oldBase);
|
|
128
|
+
gitOps.assertCurrentBranch(worktreePath, workingBranch);
|
|
129
|
+
if (getAgentStatus(workspaceId) !== null)
|
|
130
|
+
throw new Error('Cannot change the source branch while the agent is running');
|
|
117
131
|
if (!gitOps.branchExists(worktreePath, trimmedNew, 'origin')) {
|
|
118
132
|
throw new Error(`Source branch 'origin/${trimmedNew}' does not exist`);
|
|
119
133
|
}
|
|
@@ -235,6 +249,12 @@ async function runCustomScript(workspace, oldBase, newBase, script) {
|
|
|
235
249
|
catch (err) {
|
|
236
250
|
console.warn('[change-source-branch] PR lookup failed, KOBO_PR_NUMBER will be empty:', err);
|
|
237
251
|
}
|
|
252
|
+
assertWorkspaceUnchanged(workspace.id, workspace.worktreePath, workspace.workingBranch, oldBase);
|
|
253
|
+
gitOps.assertCurrentBranch(workspace.worktreePath, workspace.workingBranch);
|
|
254
|
+
if (gitOps.worktreeHasChangesStrict(workspace.worktreePath))
|
|
255
|
+
return { status: 'dirty', forcePushNeeded: false, commitCount: 0 };
|
|
256
|
+
if (getAgentStatus(workspace.id) !== null)
|
|
257
|
+
throw new Error('Cannot change the source branch while the agent is running');
|
|
238
258
|
return new Promise((resolve, reject) => {
|
|
239
259
|
const child = spawn('bash', ['-c', script], {
|
|
240
260
|
cwd: workspace.worktreePath,
|
|
@@ -292,3 +312,15 @@ async function runCustomScript(workspace, oldBase, newBase, script) {
|
|
|
292
312
|
});
|
|
293
313
|
});
|
|
294
314
|
}
|
|
315
|
+
/** Async lookups must not authorize mutations using obsolete workspace metadata. */
|
|
316
|
+
function assertWorkspaceUnchanged(id, worktreePath, branch, sourceBranch) {
|
|
317
|
+
const current = getWorkspace(id);
|
|
318
|
+
if (!current ||
|
|
319
|
+
current.archivedAt ||
|
|
320
|
+
current.worktreePurgedAt ||
|
|
321
|
+
current.worktreePath !== worktreePath ||
|
|
322
|
+
current.workingBranch !== branch ||
|
|
323
|
+
current.sourceBranch !== sourceBranch) {
|
|
324
|
+
throw new Error('Workspace changed during source-branch validation; retry the operation');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -2,6 +2,7 @@ import { CronExpressionParser } from 'cron-parser';
|
|
|
2
2
|
import { nanoid } from 'nanoid';
|
|
3
3
|
import { getDb } from '../db/index.js';
|
|
4
4
|
import { slugifyProjectName } from '../utils/project-slug.js';
|
|
5
|
+
import { isWorkspaceLifecycleBusy } from '../utils/workspace-lifecycle-guard.js';
|
|
5
6
|
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
6
7
|
import * as orchestrator from './agent/orchestrator.js';
|
|
7
8
|
import * as settingsService from './settings-service.js';
|
|
@@ -15,6 +16,14 @@ export const MIN_DELAY_BETWEEN_FIRES_SECONDS = 60;
|
|
|
15
16
|
// real fire time hasn't been reached yet.
|
|
16
17
|
const MAX_SETTIMEOUT_MS = 2_000_000_000; // ~23 days, with margin under 2^31-1
|
|
17
18
|
const timers = new Map();
|
|
19
|
+
let suspended = false;
|
|
20
|
+
/** Preserve cron intent and deadlines while the process is shutting down. */
|
|
21
|
+
export function suspendForShutdown() {
|
|
22
|
+
suspended = true;
|
|
23
|
+
for (const timer of timers.values())
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
timers.clear();
|
|
26
|
+
}
|
|
18
27
|
/**
|
|
19
28
|
* Arm a setTimeout that fires `fireOrSkip(id)` when `fireAt` is reached.
|
|
20
29
|
* Replaces any existing timer for this id. Handles long horizons by
|
|
@@ -22,6 +31,8 @@ const timers = new Map();
|
|
|
22
31
|
* days.
|
|
23
32
|
*/
|
|
24
33
|
function scheduleAt(id, fireAt) {
|
|
34
|
+
if (suspended)
|
|
35
|
+
return;
|
|
25
36
|
const previous = timers.get(id);
|
|
26
37
|
if (previous)
|
|
27
38
|
clearTimeout(previous);
|
|
@@ -154,6 +165,8 @@ export function listAll() {
|
|
|
154
165
|
* unexpected error is logged and the cron is preserved when possible.
|
|
155
166
|
*/
|
|
156
167
|
function fireOrSkip(id) {
|
|
168
|
+
if (suspended)
|
|
169
|
+
return;
|
|
157
170
|
try {
|
|
158
171
|
timers.delete(id);
|
|
159
172
|
const db = getDb();
|
|
@@ -168,6 +181,10 @@ function fireOrSkip(id) {
|
|
|
168
181
|
cancel(id, wsRow ? 'archive' : 'deleted');
|
|
169
182
|
return;
|
|
170
183
|
}
|
|
184
|
+
if (isWorkspaceLifecycleBusy(row.workspace_id)) {
|
|
185
|
+
scheduleAt(id, new Date(Date.now() + 15_000));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
171
188
|
let status = 'skipped-active';
|
|
172
189
|
if (!orchestrator.hasController(row.workspace_id)) {
|
|
173
190
|
try {
|
|
@@ -244,6 +261,8 @@ function fireOrSkip(id) {
|
|
|
244
261
|
* Rows pointing at deleted/archived workspaces are removed without firing.
|
|
245
262
|
*/
|
|
246
263
|
export function restoreOnBoot() {
|
|
264
|
+
suspendForShutdown();
|
|
265
|
+
suspended = false;
|
|
247
266
|
try {
|
|
248
267
|
// Boot semantics: clear any existing in-memory timers before rearming.
|
|
249
268
|
for (const t of timers.values())
|
|
@@ -90,3 +90,33 @@ export async function createPreMigrationBackup(db, dbPath, tag, keepCount = DEFA
|
|
|
90
90
|
throw err;
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
+
/** Recheck daily-backup eligibility hourly, including on long-running servers. */
|
|
94
|
+
export function startDailyDbBackupScheduler(backup, intervalMs = 60 * 60 * 1000) {
|
|
95
|
+
let inFlight = null;
|
|
96
|
+
let stopped = false;
|
|
97
|
+
const attempt = async () => {
|
|
98
|
+
try {
|
|
99
|
+
await backup();
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
console.error('[kobo] Scheduled DB backup failed:', err);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const tick = () => {
|
|
106
|
+
if (stopped || inFlight)
|
|
107
|
+
return;
|
|
108
|
+
inFlight = attempt().finally(() => {
|
|
109
|
+
inFlight = null;
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
const timer = setInterval(tick, intervalMs);
|
|
113
|
+
timer.unref?.();
|
|
114
|
+
tick();
|
|
115
|
+
return {
|
|
116
|
+
async stop() {
|
|
117
|
+
stopped = true;
|
|
118
|
+
clearInterval(timer);
|
|
119
|
+
await inFlight;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
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
|
+
import { assertWorkspaceLifecycleAvailable } from '../utils/workspace-lifecycle-guard.js';
|
|
4
5
|
import { getProjectSettings } from './settings-service.js';
|
|
5
6
|
import { emitEphemeral } from './websocket-service.js';
|
|
6
7
|
import { getWorkspace, listWorkspaces, updateDevServerStatus } from './workspace-service.js';
|
|
@@ -23,9 +24,13 @@ function runCommand(command, args, options = {}) {
|
|
|
23
24
|
// ── State ──────────────────────────────────────────────────────────────────────
|
|
24
25
|
/** workspaceId -> spawned dev-server process */
|
|
25
26
|
const trackedProcesses = new Map();
|
|
27
|
+
const stoppingProcesses = new Map();
|
|
28
|
+
const generations = new Map();
|
|
26
29
|
/** Test-only: clear the tracked-processes map between tests. */
|
|
27
30
|
export function _resetTrackedProcessesForTests() {
|
|
28
31
|
trackedProcesses.clear();
|
|
32
|
+
stoppingProcesses.clear();
|
|
33
|
+
generations.clear();
|
|
29
34
|
}
|
|
30
35
|
// ── Pure helpers ───────────────────────────────────────────────────────────────
|
|
31
36
|
/**
|
|
@@ -84,23 +89,29 @@ export function resolveInstance(projectPath, workingBranch) {
|
|
|
84
89
|
}
|
|
85
90
|
return null;
|
|
86
91
|
}
|
|
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
|
-
}
|
|
92
92
|
// ── Docker helpers ─────────────────────────────────────────────────────────────
|
|
93
93
|
/**
|
|
94
94
|
* List all running Docker container names.
|
|
95
95
|
* Uses execFile so Docker inspection cannot block the Node event loop.
|
|
96
96
|
*/
|
|
97
|
-
export async function listRunningContainers() {
|
|
97
|
+
export async function listRunningContainers(projectName) {
|
|
98
98
|
try {
|
|
99
|
-
const
|
|
99
|
+
const args = projectName
|
|
100
|
+
? [
|
|
101
|
+
'ps',
|
|
102
|
+
'--filter',
|
|
103
|
+
`label=com.docker.compose.project=${projectName}`,
|
|
104
|
+
'--format',
|
|
105
|
+
'{{.Names}}\t{{.Label "com.docker.compose.project"}}',
|
|
106
|
+
]
|
|
107
|
+
: ['ps', '--format', '{{.Names}}'];
|
|
108
|
+
const output = await runCommand('docker', args, { timeout: 10_000 });
|
|
100
109
|
return output
|
|
101
110
|
.split('\n')
|
|
102
111
|
.map((s) => s.trim())
|
|
103
|
-
.filter(Boolean)
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.filter((line) => !projectName || line.split('\t')[1] === projectName)
|
|
114
|
+
.map((line) => (projectName ? line.split('\t')[0] : line));
|
|
104
115
|
}
|
|
105
116
|
catch {
|
|
106
117
|
return [];
|
|
@@ -128,8 +139,7 @@ export async function getStatus(projectPath, workingBranch, workspaceId) {
|
|
|
128
139
|
containers: [],
|
|
129
140
|
};
|
|
130
141
|
}
|
|
131
|
-
const
|
|
132
|
-
const matching = running.filter((name) => containerBelongsToProject(name, config.projectName));
|
|
142
|
+
const matching = await listRunningContainers(config.projectName);
|
|
133
143
|
if (matching.length > 0) {
|
|
134
144
|
return {
|
|
135
145
|
status: 'running',
|
|
@@ -167,6 +177,7 @@ export async function getStatus(projectPath, workingBranch, workspaceId) {
|
|
|
167
177
|
* Start the dev-server for a workspace.
|
|
168
178
|
*/
|
|
169
179
|
export function startDevServer(workspaceId) {
|
|
180
|
+
assertWorkspaceLifecycleAvailable(workspaceId);
|
|
170
181
|
const workspace = getWorkspace(workspaceId);
|
|
171
182
|
if (!workspace) {
|
|
172
183
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
@@ -179,6 +190,8 @@ export function startDevServer(workspaceId) {
|
|
|
179
190
|
// silently overwriting the tracked process — the first process would
|
|
180
191
|
// otherwise become untrackable (never killed by stopDevServer) and its
|
|
181
192
|
// exit handler could later clobber state set by the second process.
|
|
193
|
+
if (stoppingProcesses.has(workspaceId))
|
|
194
|
+
throw new Error(`Dev server for workspace '${workspaceId}' is stopping`);
|
|
182
195
|
if (trackedProcesses.has(workspaceId)) {
|
|
183
196
|
throw new Error(`Dev server for workspace '${workspaceId}' is already starting`);
|
|
184
197
|
}
|
|
@@ -196,6 +209,8 @@ export function startDevServer(workspaceId) {
|
|
|
196
209
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
197
210
|
detached: true,
|
|
198
211
|
});
|
|
212
|
+
const generation = (generations.get(workspaceId) ?? 0) + 1;
|
|
213
|
+
generations.set(workspaceId, generation);
|
|
199
214
|
trackedProcesses.set(workspaceId, proc);
|
|
200
215
|
// Log stdout/stderr for debugging
|
|
201
216
|
proc.stdout?.on('data', (data) => {
|
|
@@ -205,21 +220,35 @@ export function startDevServer(workspaceId) {
|
|
|
205
220
|
console.error(`[dev-server:${instanceName}] ${data.toString().trim()}`);
|
|
206
221
|
});
|
|
207
222
|
proc.on('exit', (code) => {
|
|
208
|
-
|
|
209
|
-
void
|
|
210
|
-
.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
223
|
+
// Shell exit does not imply that its background children have stopped.
|
|
224
|
+
void (async () => {
|
|
225
|
+
while (trackedProcesses.get(workspaceId) === proc && !stoppingProcesses.has(workspaceId)) {
|
|
226
|
+
if (proc.pid && (await hasLiveProcessGroup(proc.pid))) {
|
|
227
|
+
await pollDelay(250);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (trackedProcesses.get(workspaceId) !== proc || stoppingProcesses.has(workspaceId))
|
|
231
|
+
return;
|
|
232
|
+
trackedProcesses.delete(workspaceId);
|
|
233
|
+
const currentStatus = await getStatus(workspace.projectPath, workspace.workingBranch);
|
|
234
|
+
if (generations.get(workspaceId) !== generation || stoppingProcesses.has(workspaceId))
|
|
235
|
+
return;
|
|
236
|
+
updateDevServerStatus(workspaceId, currentStatus.status);
|
|
237
|
+
emitEphemeral(workspaceId, 'devserver:status', currentStatus);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
})().catch((err) => {
|
|
241
|
+
console.error(`[dev-server] Failed to confirm process-group exit for workspace ${workspaceId}:`, err);
|
|
216
242
|
});
|
|
217
|
-
if (code !== 0)
|
|
243
|
+
if (code !== 0)
|
|
218
244
|
console.error(`[dev-server] Process exited with code ${code} for workspace ${workspaceId}`);
|
|
219
|
-
}
|
|
220
245
|
});
|
|
221
246
|
proc.on('error', (err) => {
|
|
222
|
-
trackedProcesses.
|
|
247
|
+
if (trackedProcesses.get(workspaceId) !== proc)
|
|
248
|
+
return;
|
|
249
|
+
// A signal failure is also an 'error' event; it does not prove process exit.
|
|
250
|
+
if (!proc.pid)
|
|
251
|
+
trackedProcesses.delete(workspaceId);
|
|
223
252
|
updateDevServerStatus(workspaceId, 'error');
|
|
224
253
|
console.error(`[dev-server] Process error for workspace ${workspaceId}:`, err);
|
|
225
254
|
emitEphemeral(workspaceId, 'devserver:status', {
|
|
@@ -249,6 +278,96 @@ export function startDevServer(workspaceId) {
|
|
|
249
278
|
* Stop the dev-server for a workspace.
|
|
250
279
|
*/
|
|
251
280
|
export async function stopDevServer(workspaceId) {
|
|
281
|
+
const pending = stoppingProcesses.get(workspaceId);
|
|
282
|
+
if (pending)
|
|
283
|
+
return pending;
|
|
284
|
+
generations.set(workspaceId, (generations.get(workspaceId) ?? 0) + 1);
|
|
285
|
+
const stopping = stopDevServerOwned(workspaceId);
|
|
286
|
+
stoppingProcesses.set(workspaceId, stopping);
|
|
287
|
+
try {
|
|
288
|
+
return await stopping;
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
if (stoppingProcesses.get(workspaceId) === stopping)
|
|
292
|
+
stoppingProcesses.delete(workspaceId);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function pollDelay(ms) {
|
|
296
|
+
return new Promise((resolve) => {
|
|
297
|
+
const timer = setTimeout(resolve, ms);
|
|
298
|
+
timer.unref?.();
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/** A zombie cannot write files, even if the OS has not reaped its PID yet. */
|
|
302
|
+
async function hasLiveProcessGroup(groupId) {
|
|
303
|
+
try {
|
|
304
|
+
process.kill(-groupId, 0);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
if (err.code === 'ESRCH')
|
|
308
|
+
return false;
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
const processes = await runCommand('ps', ['-eo', 'pgid=,stat='], { timeout: 1000 });
|
|
312
|
+
return processes.split('\n').some((line) => {
|
|
313
|
+
const [group, state] = line.trim().split(/\s+/);
|
|
314
|
+
return Number(group) === groupId && !!state && !/^[ZX]/.test(state);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
async function signalGroupAndWait(groupId, signal, timeoutMs) {
|
|
318
|
+
try {
|
|
319
|
+
process.kill(-groupId, signal);
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
if (err.code === 'ESRCH')
|
|
323
|
+
return true;
|
|
324
|
+
throw err;
|
|
325
|
+
}
|
|
326
|
+
const deadline = Date.now() + timeoutMs;
|
|
327
|
+
while (await hasLiveProcessGroup(groupId)) {
|
|
328
|
+
if (Date.now() >= deadline)
|
|
329
|
+
return false;
|
|
330
|
+
await pollDelay(50);
|
|
331
|
+
}
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
function signalAndWait(proc, signal, timeoutMs) {
|
|
335
|
+
if (proc.pid)
|
|
336
|
+
return signalGroupAndWait(proc.pid, signal, timeoutMs);
|
|
337
|
+
if (proc.exitCode != null || proc.signalCode != null)
|
|
338
|
+
return Promise.resolve(true);
|
|
339
|
+
return new Promise((resolve, reject) => {
|
|
340
|
+
const finish = (exited) => {
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
proc.removeListener('exit', onExit);
|
|
343
|
+
proc.removeListener('error', onError);
|
|
344
|
+
resolve(exited);
|
|
345
|
+
};
|
|
346
|
+
const onExit = () => finish(true);
|
|
347
|
+
const onError = (err) => {
|
|
348
|
+
clearTimeout(timer);
|
|
349
|
+
proc.removeListener('exit', onExit);
|
|
350
|
+
proc.removeListener('error', onError);
|
|
351
|
+
reject(err);
|
|
352
|
+
};
|
|
353
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
354
|
+
proc.once('exit', onExit);
|
|
355
|
+
proc.once('error', onError);
|
|
356
|
+
try {
|
|
357
|
+
if (proc.pid)
|
|
358
|
+
process.kill(-proc.pid, signal);
|
|
359
|
+
else
|
|
360
|
+
proc.kill(signal);
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
clearTimeout(timer);
|
|
364
|
+
proc.removeListener('exit', onExit);
|
|
365
|
+
proc.removeListener('error', onError);
|
|
366
|
+
reject(err);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
async function stopDevServerOwned(workspaceId) {
|
|
252
371
|
const workspace = getWorkspace(workspaceId);
|
|
253
372
|
if (!workspace) {
|
|
254
373
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
@@ -260,20 +379,15 @@ export async function stopDevServer(workspaceId) {
|
|
|
260
379
|
// Kill tracked process first (covers Node servers and any spawned process)
|
|
261
380
|
const tracked = trackedProcesses.get(workspaceId);
|
|
262
381
|
if (tracked) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
tracked.kill('SIGTERM');
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
catch (err) {
|
|
272
|
-
console.error('[dev-server] Failed to kill tracked process:', err instanceof Error ? err.message : err);
|
|
273
|
-
}
|
|
274
|
-
trackedProcesses.delete(workspaceId);
|
|
382
|
+
const exited = (await signalAndWait(tracked, 'SIGTERM', 3_000)) || (await signalAndWait(tracked, 'SIGKILL', 1_000));
|
|
383
|
+
if (!exited)
|
|
384
|
+
throw new Error('Dev server stop is not confirmed; retry after the process exits');
|
|
385
|
+
if (trackedProcesses.get(workspaceId) === tracked)
|
|
386
|
+
trackedProcesses.delete(workspaceId);
|
|
275
387
|
}
|
|
276
388
|
const settings = getProjectSettings(workspace.projectPath);
|
|
389
|
+
let stopError;
|
|
390
|
+
let customStopSucceeded = false;
|
|
277
391
|
if (settings?.devServer.stopCommand) {
|
|
278
392
|
try {
|
|
279
393
|
await runCommand('bash', ['-c', settings.devServer.stopCommand], {
|
|
@@ -285,9 +399,10 @@ export async function stopDevServer(workspaceId) {
|
|
|
285
399
|
},
|
|
286
400
|
timeout: 30_000,
|
|
287
401
|
});
|
|
402
|
+
customStopSucceeded = true;
|
|
288
403
|
}
|
|
289
404
|
catch (err) {
|
|
290
|
-
|
|
405
|
+
stopError = err;
|
|
291
406
|
}
|
|
292
407
|
}
|
|
293
408
|
// Always try docker compose down with project name if we have one
|
|
@@ -295,11 +410,26 @@ export async function stopDevServer(workspaceId) {
|
|
|
295
410
|
if (config?.projectName) {
|
|
296
411
|
try {
|
|
297
412
|
await runCommand('docker', ['compose', '-p', config.projectName, 'down'], { cwd, timeout: 30_000 });
|
|
413
|
+
stopError = undefined;
|
|
298
414
|
}
|
|
299
|
-
catch {
|
|
300
|
-
|
|
415
|
+
catch (err) {
|
|
416
|
+
stopError = err;
|
|
417
|
+
// The custom command may use -f/--env-file absent from the generic fallback.
|
|
418
|
+
// Only an independent, successful Docker query can confirm it stopped everything.
|
|
419
|
+
if (customStopSucceeded) {
|
|
420
|
+
try {
|
|
421
|
+
const remaining = await runCommand('docker', ['ps', '--filter', `label=com.docker.compose.project=${config.projectName}`, '--format', '{{.ID}}'], { timeout: 10_000 });
|
|
422
|
+
if (!remaining.trim())
|
|
423
|
+
stopError = undefined;
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// An unavailable daemon is not confirmation of shutdown.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
301
429
|
}
|
|
302
430
|
}
|
|
431
|
+
if (stopError)
|
|
432
|
+
throw new Error(`Dev server stop failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
|
|
303
433
|
const status = {
|
|
304
434
|
status: 'stopped',
|
|
305
435
|
instanceName,
|
|
@@ -338,8 +468,7 @@ export async function getDevServerLogs(workspaceId, tail = 200) {
|
|
|
338
468
|
if (!config) {
|
|
339
469
|
return 'No dev-server instance found';
|
|
340
470
|
}
|
|
341
|
-
const
|
|
342
|
-
const matching = running.filter((name) => containerBelongsToProject(name, config.projectName));
|
|
471
|
+
const matching = await listRunningContainers(config.projectName);
|
|
343
472
|
if (matching.length === 0) {
|
|
344
473
|
return 'No running containers found';
|
|
345
474
|
}
|