@worca/ui 0.20.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.
@@ -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, statSync } from 'node:fs';
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) and is bounded by
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
- const MAX_WALK_FILES = 100_000;
32
- function _walkDirSize(rootPath) {
33
- let total = 0;
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 < MAX_WALK_FILES) {
114
+ while (stack.length > 0 && count < maxFiles) {
37
115
  const cur = stack.pop();
38
- let entries;
116
+ let dir;
39
117
  try {
40
- entries = readdirSync(cur, { withFileTypes: true });
118
+ dir = await fsp.opendir(cur);
41
119
  } catch {
42
120
  continue;
43
121
  }
44
- for (const e of entries) {
122
+ for await (const e of dir) {
45
123
  count++;
46
- if (count >= MAX_WALK_FILES) break;
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
- total += statSync(child).size;
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 total;
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) return hit.bytes;
145
+ if (hit && hit.expiry > now)
146
+ return { bytes: hit.bytes, truncated: hit.truncated };
67
147
 
68
- let bytes = 0;
148
+ let result = { bytes: 0, truncated: false };
69
149
  try {
70
- bytes = _walkDirSize(worktreePath);
150
+ result = await walkDirSize(worktreePath);
71
151
  } catch {
72
- bytes = 0;
152
+ result = { bytes: 0, truncated: false };
73
153
  }
74
- _diskCache.set(worktreePath, { bytes, expiry: now + DISK_CACHE_TTL_MS });
75
- return bytes;
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: worktreeExists ? _getDiskBytes(worktreePath) : 0,
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({ ok: true, worktrees });
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
  }