@worca/ui 0.19.0 → 0.21.0
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/app/main.bundle.js +1215 -970
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +180 -23
- package/app/utils/stage-order.js +2 -0
- package/package.json +2 -1
- package/server/bd-daemon.js +43 -0
- package/server/beads-reader.js +37 -23
- package/server/model-env-routes.js +189 -0
- package/server/model-validation.js +13 -0
- package/server/preferences-routes.js +4 -3
- package/server/project-routes.js +5 -0
- package/server/reserved-env-keys.json +19 -0
- package/server/settings-validator.js +12 -6
- package/server/watcher-set.js +2 -0
- package/server/worktree-ops.js +49 -22
- package/server/worktrees-routes.js +237 -28
- package/server/ws-beads-watcher.js +6 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// server/settings-validator.js
|
|
2
2
|
import { STAGE_ORDER } from '../app/utils/stage-order.js';
|
|
3
3
|
import { GLOBAL_ONLY_KEYS } from './keys-schema.js';
|
|
4
|
+
import { DEFAULT_MODELS, deriveValidModels } from './model-validation.js';
|
|
4
5
|
|
|
5
6
|
const VALID_AGENTS = [
|
|
6
7
|
'planner',
|
|
@@ -13,7 +14,7 @@ const VALID_AGENTS = [
|
|
|
13
14
|
'learner',
|
|
14
15
|
];
|
|
15
16
|
const VALID_STAGES = STAGE_ORDER;
|
|
16
|
-
export const VALID_MODELS =
|
|
17
|
+
export const VALID_MODELS = DEFAULT_MODELS;
|
|
17
18
|
const VALID_LOOPS = [
|
|
18
19
|
'implement_test',
|
|
19
20
|
'pr_changes',
|
|
@@ -27,7 +28,7 @@ const VALID_GUARDS = [
|
|
|
27
28
|
'block_force_push',
|
|
28
29
|
'restrict_git_commit',
|
|
29
30
|
];
|
|
30
|
-
const
|
|
31
|
+
const DEFAULT_PRICING_MODELS = ['opus', 'sonnet'];
|
|
31
32
|
const VALID_PRICING_FIELDS = [
|
|
32
33
|
'input_per_mtok',
|
|
33
34
|
'output_per_mtok',
|
|
@@ -48,6 +49,7 @@ export function validateSettingsPayload(body) {
|
|
|
48
49
|
return { valid: false, details };
|
|
49
50
|
}
|
|
50
51
|
const w = body.worca;
|
|
52
|
+
const validModels = deriveValidModels(w);
|
|
51
53
|
|
|
52
54
|
// agents
|
|
53
55
|
if (w.agents !== undefined) {
|
|
@@ -63,7 +65,7 @@ export function validateSettingsPayload(body) {
|
|
|
63
65
|
details.push(`Unknown agent name: "${name}"`);
|
|
64
66
|
continue;
|
|
65
67
|
}
|
|
66
|
-
if (cfg.model !== undefined && !
|
|
68
|
+
if (cfg.model !== undefined && !validModels.includes(cfg.model)) {
|
|
67
69
|
details.push(`Invalid model "${cfg.model}" for agent "${name}"`);
|
|
68
70
|
}
|
|
69
71
|
if (cfg.max_turns !== undefined) {
|
|
@@ -192,6 +194,9 @@ export function validateSettingsPayload(body) {
|
|
|
192
194
|
details.push('pricing must be an object');
|
|
193
195
|
} else {
|
|
194
196
|
const p = w.pricing;
|
|
197
|
+
const validPricingModels = [
|
|
198
|
+
...new Set([...DEFAULT_PRICING_MODELS, ...validModels]),
|
|
199
|
+
];
|
|
195
200
|
if (p.models !== undefined) {
|
|
196
201
|
if (
|
|
197
202
|
typeof p.models !== 'object' ||
|
|
@@ -201,7 +206,7 @@ export function validateSettingsPayload(body) {
|
|
|
201
206
|
details.push('pricing.models must be an object');
|
|
202
207
|
} else {
|
|
203
208
|
for (const [model, costs] of Object.entries(p.models)) {
|
|
204
|
-
if (!
|
|
209
|
+
if (!validPricingModels.includes(model)) {
|
|
205
210
|
details.push(`Unknown pricing model: "${model}"`);
|
|
206
211
|
continue;
|
|
207
212
|
}
|
|
@@ -864,12 +869,13 @@ export function validateGlobalSettings(prefs) {
|
|
|
864
869
|
}
|
|
865
870
|
}
|
|
866
871
|
|
|
872
|
+
const globalValidModels = deriveValidModels(w);
|
|
867
873
|
if (
|
|
868
874
|
w.circuit_breaker?.classifier_model !== undefined &&
|
|
869
|
-
!
|
|
875
|
+
!globalValidModels.includes(w.circuit_breaker.classifier_model)
|
|
870
876
|
) {
|
|
871
877
|
details.push(
|
|
872
|
-
`circuit_breaker.classifier_model must be one of: ${
|
|
878
|
+
`circuit_breaker.classifier_model must be one of: ${globalValidModels.join(', ')}`,
|
|
873
879
|
);
|
|
874
880
|
}
|
|
875
881
|
|
package/server/watcher-set.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
+
import { ensureBdDaemon } from './bd-daemon.js';
|
|
13
14
|
import { resolveRunDir } from './run-dir-resolver.js';
|
|
14
15
|
import { createBeadsWatcher } from './ws-beads-watcher.js';
|
|
15
16
|
import { createEventWatcher } from './ws-event-watcher.js';
|
|
@@ -146,6 +147,7 @@ export class WatcherSet {
|
|
|
146
147
|
|
|
147
148
|
// Beads watcher
|
|
148
149
|
if (!this.beadsWatcher) {
|
|
150
|
+
ensureBdDaemon(worcaDir).catch(() => {});
|
|
149
151
|
try {
|
|
150
152
|
this.beadsWatcher = this._factories.createBeadsWatcher({
|
|
151
153
|
worcaDir,
|
package/server/worktree-ops.js
CHANGED
|
@@ -2,33 +2,37 @@
|
|
|
2
2
|
* Shared worktree operations — single owner of `git worktree remove` shell-out.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
lstatSync,
|
|
9
|
-
readFileSync,
|
|
10
|
-
rmSync,
|
|
11
|
-
unlinkSync,
|
|
12
|
-
} from 'node:fs';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { existsSync, lstatSync } from 'node:fs';
|
|
7
|
+
import { readFile, rm, unlink } from 'node:fs/promises';
|
|
13
8
|
import { join } from 'node:path';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
14
12
|
|
|
15
13
|
/**
|
|
16
14
|
* Remove a worktree and its registry entry.
|
|
17
15
|
* Mirrors WorktreeSource.remove from src/worca/cli/cleanup.py:
|
|
18
16
|
* 1. Attempt `git worktree remove --force <path>` from the project root
|
|
19
|
-
* 2. On failure (e.g. non-worktree temp dir in tests), fall back to
|
|
17
|
+
* 2. On failure (e.g. non-worktree temp dir in tests), fall back to rm (async)
|
|
20
18
|
* 3. Run `git worktree prune` so git's metadata (`.git/worktrees/<id>/`)
|
|
21
19
|
* drops the entry even when the directory was removed manually
|
|
20
|
+
* (skipped when skipPrune is true — caller is responsible for pruning later)
|
|
22
21
|
* 4. Delete the registry file
|
|
23
22
|
*/
|
|
24
|
-
export function removeWorktree(
|
|
23
|
+
export async function removeWorktree(
|
|
24
|
+
worcaDir,
|
|
25
|
+
runId,
|
|
26
|
+
{ skipPrune = false } = {},
|
|
27
|
+
) {
|
|
25
28
|
const regFile = join(worcaDir, 'multi', 'pipelines.d', `${runId}.json`);
|
|
26
29
|
const projectRoot = join(worcaDir, '..');
|
|
27
30
|
let worktreePath = null;
|
|
28
31
|
|
|
29
32
|
if (existsSync(regFile)) {
|
|
30
33
|
try {
|
|
31
|
-
const
|
|
34
|
+
const content = await readFile(regFile, 'utf8');
|
|
35
|
+
const reg = JSON.parse(content);
|
|
32
36
|
worktreePath = reg.worktree_path || null;
|
|
33
37
|
} catch {
|
|
34
38
|
/* ignore */
|
|
@@ -37,11 +41,15 @@ export function removeWorktree(worcaDir, runId) {
|
|
|
37
41
|
|
|
38
42
|
if (worktreePath && existsSync(worktreePath)) {
|
|
39
43
|
try {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
await execFileAsync(
|
|
45
|
+
'git',
|
|
46
|
+
['worktree', 'remove', '--force', worktreePath],
|
|
47
|
+
{
|
|
48
|
+
cwd: projectRoot,
|
|
49
|
+
stdio: 'pipe',
|
|
50
|
+
timeout: 30_000,
|
|
51
|
+
},
|
|
52
|
+
);
|
|
45
53
|
} catch {
|
|
46
54
|
let isRealDir = false;
|
|
47
55
|
try {
|
|
@@ -51,13 +59,36 @@ export function removeWorktree(worcaDir, runId) {
|
|
|
51
59
|
/* ignore */
|
|
52
60
|
}
|
|
53
61
|
if (isRealDir) {
|
|
54
|
-
|
|
62
|
+
await rm(worktreePath, { recursive: true, force: true });
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
}
|
|
58
66
|
|
|
67
|
+
if (!skipPrune) {
|
|
68
|
+
try {
|
|
69
|
+
await execFileAsync('git', ['worktree', 'prune'], {
|
|
70
|
+
cwd: projectRoot,
|
|
71
|
+
stdio: 'pipe',
|
|
72
|
+
timeout: 30_000,
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
/* non-fatal */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (existsSync(regFile)) {
|
|
80
|
+
await unlink(regFile);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run `git worktree prune` once for the project at worcaDir.
|
|
86
|
+
* Use after a batch of removeWorktree({ skipPrune: true }) calls.
|
|
87
|
+
*/
|
|
88
|
+
export async function pruneWorktrees(worcaDir) {
|
|
89
|
+
const projectRoot = join(worcaDir, '..');
|
|
59
90
|
try {
|
|
60
|
-
|
|
91
|
+
await execFileAsync('git', ['worktree', 'prune'], {
|
|
61
92
|
cwd: projectRoot,
|
|
62
93
|
stdio: 'pipe',
|
|
63
94
|
timeout: 30_000,
|
|
@@ -65,8 +96,4 @@ export function removeWorktree(worcaDir, runId) {
|
|
|
65
96
|
} catch {
|
|
66
97
|
/* non-fatal */
|
|
67
98
|
}
|
|
68
|
-
|
|
69
|
-
if (existsSync(regFile)) {
|
|
70
|
-
unlinkSync(regFile);
|
|
71
|
-
}
|
|
72
99
|
}
|
|
@@ -3,14 +3,56 @@
|
|
|
3
3
|
*
|
|
4
4
|
* GET /worktrees — list worktree entries enriched with disk/age/group data
|
|
5
5
|
* DELETE /worktrees/:run_id — remove a worktree (409 if running, 412 if resumable/grouped without ?force=1)
|
|
6
|
+
* POST /worktrees/cleanup — batch remove (always returns 200 with `{ok, results, failed_count}`)
|
|
6
7
|
*
|
|
7
8
|
* Expects req.project.worcaDir to be set by projectResolver middleware.
|
|
9
|
+
*
|
|
10
|
+
* NOTE on disk semantics: `disk_bytes` reflects project files only — vendored
|
|
11
|
+
* and derived directories listed in WALK_SKIP_DIRS (node_modules, .git, .venv,
|
|
12
|
+
* dist, build, .next, etc.) are skipped during the walk. This answers "how
|
|
13
|
+
* much project disk would I free?" rather than raw on-disk bytes, and makes
|
|
14
|
+
* cold first loads ~10× faster on node_modules-heavy worktrees. The route
|
|
15
|
+
* surfaces `disk_walk_skip_dirs` in the GET response so clients can document
|
|
16
|
+
* the discrepancy with `du -sh`.
|
|
8
17
|
*/
|
|
9
18
|
|
|
10
|
-
import { existsSync, readdirSync, readFileSync
|
|
19
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
20
|
+
import * as fsp from 'node:fs/promises';
|
|
11
21
|
import { join } from 'node:path';
|
|
12
22
|
import { Router } from 'express';
|
|
13
|
-
import { removeWorktree } from './worktree-ops.js';
|
|
23
|
+
import { pruneWorktrees, removeWorktree } from './worktree-ops.js';
|
|
24
|
+
|
|
25
|
+
const CLEANUP_CONCURRENCY = 4;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run an array of `{run_id, fn}` tasks with bounded concurrency.
|
|
29
|
+
* Tasks are expected to return a result object — but if one throws,
|
|
30
|
+
* the limiter converts the throw into an attributable failure result
|
|
31
|
+
* so a single bad task can't halt the rest of the batch.
|
|
32
|
+
*/
|
|
33
|
+
async function runWithConcurrencyLimit(tasks, limit) {
|
|
34
|
+
const results = new Array(tasks.length);
|
|
35
|
+
let nextIdx = 0;
|
|
36
|
+
async function worker() {
|
|
37
|
+
while (nextIdx < tasks.length) {
|
|
38
|
+
const idx = nextIdx++;
|
|
39
|
+
const { run_id, fn } = tasks[idx];
|
|
40
|
+
try {
|
|
41
|
+
results[idx] = await fn();
|
|
42
|
+
} catch (err) {
|
|
43
|
+
results[idx] = {
|
|
44
|
+
run_id,
|
|
45
|
+
ok: false,
|
|
46
|
+
error: err?.message || String(err),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
await Promise.all(
|
|
52
|
+
Array.from({ length: Math.min(limit, tasks.length) }, worker),
|
|
53
|
+
);
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
14
56
|
|
|
15
57
|
const RESUMABLE_STATUSES = new Set(['failed', 'paused', 'cancelled']);
|
|
16
58
|
|
|
@@ -18,61 +60,103 @@ const RESUMABLE_STATUSES = new Set(['failed', 'paused', 'cancelled']);
|
|
|
18
60
|
const _diskCache = new Map();
|
|
19
61
|
const DISK_CACHE_TTL_MS = 30_000;
|
|
20
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Directory names skipped during the disk walk. These are vendored or derived
|
|
65
|
+
* trees that dominate file count without changing the user's mental model of
|
|
66
|
+
* "project disk". Excluding them drops the walked file count by ~10–20× on
|
|
67
|
+
* typical worktrees and keeps `disk_bytes` focused on the project's own
|
|
68
|
+
* source files — closing the gap between "raw on-disk bytes" and "bytes I
|
|
69
|
+
* would actually free by cleaning up this worktree".
|
|
70
|
+
*/
|
|
71
|
+
export const WALK_SKIP_DIRS = new Set([
|
|
72
|
+
'node_modules',
|
|
73
|
+
'.git',
|
|
74
|
+
'.venv',
|
|
75
|
+
'venv',
|
|
76
|
+
'__pycache__',
|
|
77
|
+
'.pytest_cache',
|
|
78
|
+
'.mypy_cache',
|
|
79
|
+
'.ruff_cache',
|
|
80
|
+
'dist',
|
|
81
|
+
'build',
|
|
82
|
+
'.next',
|
|
83
|
+
'.turbo',
|
|
84
|
+
'.cache',
|
|
85
|
+
]);
|
|
86
|
+
|
|
21
87
|
/**
|
|
22
88
|
* Sum file sizes under a directory tree. Cross-platform: prior `du -sb`
|
|
23
89
|
* relied on GNU coreutils and silently returned 0 on macOS / BSD du,
|
|
24
90
|
* which is why the Worktrees view always showed "0 B".
|
|
25
91
|
*
|
|
26
|
-
* Skips symlinks (don't follow into other trees)
|
|
92
|
+
* Skips symlinks (don't follow into other trees), skips directory names in
|
|
93
|
+
* WALK_SKIP_DIRS (node_modules, .git, build/cache dirs), and is bounded by
|
|
27
94
|
* MAX_WALK_FILES so a runaway directory can't hang the request.
|
|
95
|
+
* Override the cap with WORCA_DISK_WALK_MAX (positive integer); the
|
|
96
|
+
* raised default of 1M handles node_modules-heavy worktrees, but very
|
|
97
|
+
* large monorepos may still want a higher ceiling.
|
|
28
98
|
* Errors on individual entries are swallowed so a transiently-locked
|
|
29
99
|
* file doesn't poison the whole sum.
|
|
30
100
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
101
|
+
function _resolveWalkCap() {
|
|
102
|
+
const raw = process.env.WORCA_DISK_WALK_MAX;
|
|
103
|
+
if (raw) {
|
|
104
|
+
const n = Number.parseInt(raw, 10);
|
|
105
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
106
|
+
}
|
|
107
|
+
return 1_000_000;
|
|
108
|
+
}
|
|
109
|
+
const MAX_WALK_FILES = _resolveWalkCap();
|
|
110
|
+
export async function walkDirSize(rootPath, maxFiles = MAX_WALK_FILES) {
|
|
111
|
+
let bytes = 0;
|
|
34
112
|
let count = 0;
|
|
35
113
|
const stack = [rootPath];
|
|
36
|
-
while (stack.length > 0 && count <
|
|
114
|
+
while (stack.length > 0 && count < maxFiles) {
|
|
37
115
|
const cur = stack.pop();
|
|
38
|
-
let
|
|
116
|
+
let dir;
|
|
39
117
|
try {
|
|
40
|
-
|
|
118
|
+
dir = await fsp.opendir(cur);
|
|
41
119
|
} catch {
|
|
42
120
|
continue;
|
|
43
121
|
}
|
|
44
|
-
for (const e of
|
|
122
|
+
for await (const e of dir) {
|
|
45
123
|
count++;
|
|
46
|
-
if (count >=
|
|
124
|
+
if (count >= maxFiles) break;
|
|
47
125
|
const child = join(cur, e.name);
|
|
48
126
|
if (e.isSymbolicLink()) continue;
|
|
49
127
|
if (e.isDirectory()) {
|
|
50
|
-
stack.push(child);
|
|
128
|
+
if (!WALK_SKIP_DIRS.has(e.name)) stack.push(child);
|
|
51
129
|
} else if (e.isFile()) {
|
|
52
130
|
try {
|
|
53
|
-
|
|
131
|
+
const st = await fsp.stat(child);
|
|
132
|
+
bytes += st.size;
|
|
54
133
|
} catch {
|
|
55
134
|
/* ignore — file vanished mid-walk */
|
|
56
135
|
}
|
|
57
136
|
}
|
|
58
137
|
}
|
|
59
138
|
}
|
|
60
|
-
return
|
|
139
|
+
return { bytes, truncated: count >= maxFiles };
|
|
61
140
|
}
|
|
62
141
|
|
|
63
|
-
function _getDiskBytes(worktreePath) {
|
|
142
|
+
async function _getDiskBytes(worktreePath) {
|
|
64
143
|
const now = Date.now();
|
|
65
144
|
const hit = _diskCache.get(worktreePath);
|
|
66
|
-
if (hit && hit.expiry > now)
|
|
145
|
+
if (hit && hit.expiry > now)
|
|
146
|
+
return { bytes: hit.bytes, truncated: hit.truncated };
|
|
67
147
|
|
|
68
|
-
let
|
|
148
|
+
let result = { bytes: 0, truncated: false };
|
|
69
149
|
try {
|
|
70
|
-
|
|
150
|
+
result = await walkDirSize(worktreePath);
|
|
71
151
|
} catch {
|
|
72
|
-
|
|
152
|
+
result = { bytes: 0, truncated: false };
|
|
73
153
|
}
|
|
74
|
-
_diskCache.set(worktreePath, {
|
|
75
|
-
|
|
154
|
+
_diskCache.set(worktreePath, {
|
|
155
|
+
bytes: result.bytes,
|
|
156
|
+
truncated: result.truncated,
|
|
157
|
+
expiry: now + DISK_CACHE_TTL_MS,
|
|
158
|
+
});
|
|
159
|
+
return result;
|
|
76
160
|
}
|
|
77
161
|
|
|
78
162
|
/**
|
|
@@ -108,7 +192,7 @@ function _readWorktreeStatus(worktreePath) {
|
|
|
108
192
|
return null;
|
|
109
193
|
}
|
|
110
194
|
|
|
111
|
-
function _listWorktrees(worcaDir) {
|
|
195
|
+
async function _listWorktrees(worcaDir) {
|
|
112
196
|
const pipelinesDir = join(worcaDir, 'multi', 'pipelines.d');
|
|
113
197
|
if (!existsSync(pipelinesDir)) return [];
|
|
114
198
|
|
|
@@ -142,12 +226,18 @@ function _listWorktrees(worcaDir) {
|
|
|
142
226
|
}
|
|
143
227
|
}
|
|
144
228
|
|
|
229
|
+
let diskInfo = { bytes: 0, truncated: false };
|
|
230
|
+
if (worktreeExists) {
|
|
231
|
+
diskInfo = await _getDiskBytes(worktreePath);
|
|
232
|
+
}
|
|
233
|
+
|
|
145
234
|
entries.push({
|
|
146
235
|
run_id: reg.run_id || '',
|
|
147
236
|
title: reg.title || '',
|
|
148
237
|
branch: reg.branch || '',
|
|
149
238
|
worktree_path: worktreePath,
|
|
150
|
-
disk_bytes:
|
|
239
|
+
disk_bytes: diskInfo.bytes,
|
|
240
|
+
truncated: diskInfo.truncated,
|
|
151
241
|
age_seconds: ageSeconds,
|
|
152
242
|
// started_at lets the client sort with the same sortByStartDesc helper
|
|
153
243
|
// used by run-list, keeping ordering consistent across views.
|
|
@@ -182,7 +272,7 @@ export function createWorktreesRouter() {
|
|
|
182
272
|
const router = Router({ mergeParams: true });
|
|
183
273
|
|
|
184
274
|
// GET /worktrees
|
|
185
|
-
router.get('/', (req, res) => {
|
|
275
|
+
router.get('/', async (req, res) => {
|
|
186
276
|
const worcaDir = req.project?.worcaDir;
|
|
187
277
|
if (!worcaDir) {
|
|
188
278
|
return res
|
|
@@ -190,15 +280,21 @@ export function createWorktreesRouter() {
|
|
|
190
280
|
.json({ ok: false, error: 'worcaDir not configured' });
|
|
191
281
|
}
|
|
192
282
|
try {
|
|
193
|
-
const worktrees = _listWorktrees(worcaDir);
|
|
194
|
-
res.json({
|
|
283
|
+
const worktrees = await _listWorktrees(worcaDir);
|
|
284
|
+
res.json({
|
|
285
|
+
ok: true,
|
|
286
|
+
worktrees,
|
|
287
|
+
// Documents the semantics shift in `disk_bytes` (project files only).
|
|
288
|
+
// Clients can render this as a caveat next to disk totals.
|
|
289
|
+
disk_walk_skip_dirs: [...WALK_SKIP_DIRS],
|
|
290
|
+
});
|
|
195
291
|
} catch (err) {
|
|
196
292
|
res.status(500).json({ ok: false, error: err.message });
|
|
197
293
|
}
|
|
198
294
|
});
|
|
199
295
|
|
|
200
296
|
// DELETE /worktrees/:run_id
|
|
201
|
-
router.delete('/:run_id', (req, res) => {
|
|
297
|
+
router.delete('/:run_id', async (req, res) => {
|
|
202
298
|
const worcaDir = req.project?.worcaDir;
|
|
203
299
|
if (!worcaDir) {
|
|
204
300
|
return res
|
|
@@ -261,12 +357,125 @@ export function createWorktreesRouter() {
|
|
|
261
357
|
});
|
|
262
358
|
}
|
|
263
359
|
|
|
264
|
-
removeWorktree(worcaDir, run_id);
|
|
360
|
+
await removeWorktree(worcaDir, run_id);
|
|
361
|
+
if (reg.worktree_path) _diskCache.delete(reg.worktree_path);
|
|
265
362
|
res.json({ ok: true, run_id });
|
|
266
363
|
} catch (err) {
|
|
267
364
|
res.status(500).json({ ok: false, error: err.message });
|
|
268
365
|
}
|
|
269
366
|
});
|
|
270
367
|
|
|
368
|
+
// POST /worktrees/cleanup
|
|
369
|
+
//
|
|
370
|
+
// Batch worktree removal. Always responds with HTTP 200 and a JSON body of
|
|
371
|
+
// shape `{ ok, results, failed_count }`, where `ok` is the AND of per-id
|
|
372
|
+
// outcomes and `failed_count` is the number of entries with `ok: false`.
|
|
373
|
+
// Per-entry errors carry a `code` field (`running`, `resumable_or_grouped`)
|
|
374
|
+
// when actionable. Clients must inspect `results[]` — a single bad id never
|
|
375
|
+
// aborts the batch, and partial failures are not signalled via HTTP status.
|
|
376
|
+
router.post('/cleanup', async (req, res) => {
|
|
377
|
+
const worcaDir = req.project?.worcaDir;
|
|
378
|
+
if (!worcaDir) {
|
|
379
|
+
return res
|
|
380
|
+
.status(501)
|
|
381
|
+
.json({ ok: false, error: 'worcaDir not configured' });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const { run_ids, force = false } = req.body || {};
|
|
385
|
+
if (!Array.isArray(run_ids) || run_ids.length === 0) {
|
|
386
|
+
return res
|
|
387
|
+
.status(400)
|
|
388
|
+
.json({ ok: false, error: 'run_ids must be a non-empty array' });
|
|
389
|
+
}
|
|
390
|
+
for (const id of run_ids) {
|
|
391
|
+
if (!_validateRunId(id)) {
|
|
392
|
+
return res
|
|
393
|
+
.status(400)
|
|
394
|
+
.json({ ok: false, error: `Invalid run ID: ${id}` });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const tasks = run_ids.map((run_id) => ({
|
|
399
|
+
run_id,
|
|
400
|
+
fn: async () => {
|
|
401
|
+
const regFile = join(
|
|
402
|
+
worcaDir,
|
|
403
|
+
'multi',
|
|
404
|
+
'pipelines.d',
|
|
405
|
+
`${run_id}.json`,
|
|
406
|
+
);
|
|
407
|
+
if (!existsSync(regFile)) {
|
|
408
|
+
return {
|
|
409
|
+
run_id,
|
|
410
|
+
ok: false,
|
|
411
|
+
error: `Worktree "${run_id}" not found`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
let reg;
|
|
416
|
+
try {
|
|
417
|
+
reg = JSON.parse(readFileSync(regFile, 'utf8'));
|
|
418
|
+
} catch {
|
|
419
|
+
return {
|
|
420
|
+
run_id,
|
|
421
|
+
ok: false,
|
|
422
|
+
error: 'Failed to read registry entry',
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let status = reg.status || 'unknown';
|
|
427
|
+
if (reg.worktree_path && existsSync(reg.worktree_path)) {
|
|
428
|
+
const actual = _readWorktreeStatus(reg.worktree_path);
|
|
429
|
+
if (actual) status = actual;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (status === 'running') {
|
|
433
|
+
return {
|
|
434
|
+
run_id,
|
|
435
|
+
ok: false,
|
|
436
|
+
error: 'Cannot remove a running worktree',
|
|
437
|
+
code: 'running',
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const isResumable = RESUMABLE_STATUSES.has(status);
|
|
442
|
+
const isGrouped = !!(reg.fleet_id || reg.workspace_id);
|
|
443
|
+
if (!force && (isResumable || isGrouped)) {
|
|
444
|
+
return {
|
|
445
|
+
run_id,
|
|
446
|
+
ok: false,
|
|
447
|
+
error:
|
|
448
|
+
'Removing this worktree prevents resuming the run. Pass force=true to confirm.',
|
|
449
|
+
code: 'resumable_or_grouped',
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
await removeWorktree(worcaDir, run_id, { skipPrune: true });
|
|
455
|
+
if (reg.worktree_path) _diskCache.delete(reg.worktree_path);
|
|
456
|
+
return { run_id, ok: true };
|
|
457
|
+
} catch (err) {
|
|
458
|
+
return { run_id, ok: false, error: err.message };
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
}));
|
|
462
|
+
|
|
463
|
+
let results;
|
|
464
|
+
try {
|
|
465
|
+
results = await runWithConcurrencyLimit(tasks, CLEANUP_CONCURRENCY);
|
|
466
|
+
} catch (err) {
|
|
467
|
+
return res.status(500).json({ ok: false, error: err.message });
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
await pruneWorktrees(worcaDir);
|
|
472
|
+
} catch {
|
|
473
|
+
/* non-fatal */
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const failed_count = results.reduce((n, r) => (r.ok ? n : n + 1), 0);
|
|
477
|
+
res.json({ ok: failed_count === 0, failed_count, results });
|
|
478
|
+
});
|
|
479
|
+
|
|
271
480
|
return router;
|
|
272
481
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, unwatchFile, watch, watchFile } from 'node:fs';
|
|
8
8
|
import { join, resolve } from 'node:path';
|
|
9
|
-
import { listIssues } from './beads-reader.js';
|
|
9
|
+
import { countIssuesByRunLabel, listIssues } from './beads-reader.js';
|
|
10
10
|
|
|
11
11
|
const BEADS_DEBOUNCE_MS = 500;
|
|
12
12
|
const BEADS_POLL_MS = 2000;
|
|
@@ -26,11 +26,15 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
|
|
|
26
26
|
BEADS_REFRESH_TIMER = setTimeout(async () => {
|
|
27
27
|
BEADS_REFRESH_TIMER = null;
|
|
28
28
|
try {
|
|
29
|
-
const issues = await
|
|
29
|
+
const [issues, counts] = await Promise.all([
|
|
30
|
+
listIssues(beadsDbPath),
|
|
31
|
+
countIssuesByRunLabel(beadsDbPath).catch(() => ({})),
|
|
32
|
+
]);
|
|
30
33
|
broadcaster.broadcast(
|
|
31
34
|
'beads-update',
|
|
32
35
|
{
|
|
33
36
|
issues,
|
|
37
|
+
counts,
|
|
34
38
|
dbExists: true,
|
|
35
39
|
dbPath: beadsDbPath,
|
|
36
40
|
},
|