@worca/ui 0.21.0 → 0.22.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/styles.css CHANGED
@@ -221,6 +221,19 @@ h1, h2, h3, h4, h5, h6 {
221
221
  opacity: 1;
222
222
  }
223
223
 
224
+ /* Loading spinner shown in sidebar nav items while underlying data is still
225
+ being fetched. Delayed fade-in avoids a noisy flash on fast loads. */
226
+ .sidebar-loading-spinner {
227
+ font-size: 14px;
228
+ --indicator-color: var(--text-secondary, currentColor);
229
+ opacity: 0;
230
+ animation: sidebar-spinner-fade 200ms ease-out 150ms forwards;
231
+ }
232
+
233
+ @keyframes sidebar-spinner-fade {
234
+ to { opacity: 0.7; }
235
+ }
236
+
224
237
  .sidebar-item.active sl-badge {
225
238
  --sl-color-primary-600: #ffffff;
226
239
  --sl-color-neutral-600: #ffffff;
@@ -4635,6 +4648,33 @@ sl-tooltip.bead-tooltip::part(body) {
4635
4648
  color: var(--muted);
4636
4649
  word-break: break-all;
4637
4650
  }
4651
+ /* Worktree card mid-cleanup state — surfaces server-side cleanup_state
4652
+ so a reload during a long bulk cleanup shows the same progress. */
4653
+ .worktree-card-cleaning {
4654
+ opacity: 0.72;
4655
+ pointer-events: none;
4656
+ }
4657
+ .worktree-card-cleaning .btn-cleanup {
4658
+ pointer-events: none;
4659
+ }
4660
+ .status-badge-cleaning {
4661
+ display: inline-flex;
4662
+ align-items: center;
4663
+ gap: 4px;
4664
+ }
4665
+ .badge-spinner,
4666
+ .btn-cleanup-spinner {
4667
+ font-size: 12px;
4668
+ }
4669
+ .worktree-card-cleanup-error {
4670
+ margin-top: 6px;
4671
+ padding: 6px 10px;
4672
+ border-radius: 6px;
4673
+ background: var(--sl-color-danger-50, #fef2f2);
4674
+ color: var(--sl-color-danger-700, #b91c1c);
4675
+ font-size: 12px;
4676
+ }
4677
+
4638
4678
  .worktrees-bulk-groups {
4639
4679
  margin: 8px 0 0;
4640
4680
  padding-left: 18px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -16,7 +16,7 @@
16
16
  * the discrepancy with `du -sh`.
17
17
  */
18
18
 
19
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
19
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
20
20
  import * as fsp from 'node:fs/promises';
21
21
  import { join } from 'node:path';
22
22
  import { Router } from 'express';
@@ -192,11 +192,84 @@ function _readWorktreeStatus(worktreePath) {
192
192
  return null;
193
193
  }
194
194
 
195
+ /**
196
+ * Run a pre-validated cleanup batch in the background. Each task stamps
197
+ * `cleanup_state: 'cleaning'`, calls `removeWorktree` (which deletes the
198
+ * registry entry on success), and on failure stamps `cleanup_error` while
199
+ * clearing `cleanup_state` so the UI can render the error and let the user
200
+ * retry. Concurrency is bounded by `CLEANUP_CONCURRENCY`.
201
+ */
202
+ async function _runCleanupBatch(worcaDir, accepted) {
203
+ const tasks = accepted.map(({ run_id, reg }) => ({
204
+ run_id,
205
+ fn: async () => {
206
+ _patchRegistry(worcaDir, run_id, { cleanup_state: 'cleaning' });
207
+ try {
208
+ await removeWorktree(worcaDir, run_id, { skipPrune: true });
209
+ if (reg.worktree_path) _diskCache.delete(reg.worktree_path);
210
+ return { run_id, ok: true };
211
+ } catch (err) {
212
+ _patchRegistry(worcaDir, run_id, {
213
+ cleanup_state: undefined,
214
+ cleanup_error: err?.message || String(err),
215
+ });
216
+ return { run_id, ok: false, error: err?.message || String(err) };
217
+ }
218
+ },
219
+ }));
220
+
221
+ try {
222
+ await runWithConcurrencyLimit(tasks, CLEANUP_CONCURRENCY);
223
+ } catch {
224
+ /* per-task failures already persisted into the registry */
225
+ }
226
+
227
+ try {
228
+ await pruneWorktrees(worcaDir);
229
+ } catch {
230
+ /* non-fatal */
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Atomically patch fields on a pipelines.d/<run>.json entry.
236
+ * Set a field to `undefined` to delete it. Returns `false` if the file is
237
+ * gone (the worktree was already cleaned up) or unreadable.
238
+ *
239
+ * Note: write is not strictly atomic — for a single-writer-per-id model
240
+ * (the cleanup background task owns its registry entry for the lifetime
241
+ * of the cleanup), read-modify-write is fine. A multi-writer scenario
242
+ * would need rename-into-place; we don't have that here.
243
+ */
244
+ function _patchRegistry(worcaDir, runId, patch) {
245
+ const regFile = join(worcaDir, 'multi', 'pipelines.d', `${runId}.json`);
246
+ if (!existsSync(regFile)) return false;
247
+ let reg;
248
+ try {
249
+ reg = JSON.parse(readFileSync(regFile, 'utf8'));
250
+ } catch {
251
+ return false;
252
+ }
253
+ for (const [k, v] of Object.entries(patch)) {
254
+ if (v === undefined) delete reg[k];
255
+ else reg[k] = v;
256
+ }
257
+ try {
258
+ writeFileSync(regFile, JSON.stringify(reg, null, 2), 'utf8');
259
+ return true;
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
195
265
  async function _listWorktrees(worcaDir) {
196
266
  const pipelinesDir = join(worcaDir, 'multi', 'pipelines.d');
197
267
  if (!existsSync(pipelinesDir)) return [];
198
268
 
199
- const entries = [];
269
+ // Phase 1: cheap synchronous metadata (registry parse, status read).
270
+ // Phase 2: disk walks in parallel — without this, 13 worktrees serialize
271
+ // ~3s of awaits even when most results would have been disk-cache hits.
272
+ const metas = [];
200
273
  for (const file of readdirSync(pipelinesDir)) {
201
274
  if (!file.endsWith('.json')) continue;
202
275
 
@@ -211,7 +284,6 @@ async function _listWorktrees(worcaDir) {
211
284
  const worktreePath = reg.worktree_path;
212
285
  const worktreeExists = existsSync(worktreePath);
213
286
 
214
- // Prefer actual status.json; fall back to registry field
215
287
  let status = reg.status || 'unknown';
216
288
  if (worktreeExists) {
217
289
  const actual = _readWorktreeStatus(worktreePath);
@@ -226,32 +298,44 @@ async function _listWorktrees(worcaDir) {
226
298
  }
227
299
  }
228
300
 
229
- let diskInfo = { bytes: 0, truncated: false };
230
- if (worktreeExists) {
231
- diskInfo = await _getDiskBytes(worktreePath);
232
- }
233
-
234
- entries.push({
235
- run_id: reg.run_id || '',
236
- title: reg.title || '',
237
- branch: reg.branch || '',
238
- worktree_path: worktreePath,
239
- disk_bytes: diskInfo.bytes,
240
- truncated: diskInfo.truncated,
241
- age_seconds: ageSeconds,
242
- // started_at lets the client sort with the same sortByStartDesc helper
243
- // used by run-list, keeping ordering consistent across views.
244
- started_at: reg.started_at || null,
301
+ metas.push({
302
+ reg,
303
+ worktreePath,
304
+ worktreeExists,
245
305
  status,
246
- removable: status !== 'running',
247
- fleet_id: reg.fleet_id || null,
248
- workspace_id: reg.workspace_id || null,
249
- group_type: reg.group_type || null,
250
- group_status: null, // populated by W-040 / W-047
251
- resumable: RESUMABLE_STATUSES.has(status),
306
+ ageSeconds,
307
+ cleanup_state: reg.cleanup_state || null,
308
+ cleanup_error: reg.cleanup_error || null,
252
309
  });
253
310
  }
254
- return entries;
311
+
312
+ const disks = await Promise.all(
313
+ metas.map((m) =>
314
+ m.worktreeExists
315
+ ? _getDiskBytes(m.worktreePath)
316
+ : Promise.resolve({ bytes: 0, truncated: false }),
317
+ ),
318
+ );
319
+
320
+ return metas.map((m, i) => ({
321
+ run_id: m.reg.run_id || '',
322
+ title: m.reg.title || '',
323
+ branch: m.reg.branch || '',
324
+ worktree_path: m.worktreePath,
325
+ disk_bytes: disks[i].bytes,
326
+ truncated: disks[i].truncated,
327
+ age_seconds: m.ageSeconds,
328
+ started_at: m.reg.started_at || null,
329
+ status: m.status,
330
+ removable: m.status !== 'running',
331
+ fleet_id: m.reg.fleet_id || null,
332
+ workspace_id: m.reg.workspace_id || null,
333
+ group_type: m.reg.group_type || null,
334
+ group_status: null,
335
+ resumable: RESUMABLE_STATUSES.has(m.status),
336
+ cleanup_state: m.cleanup_state,
337
+ cleanup_error: m.cleanup_error,
338
+ }));
255
339
  }
256
340
 
257
341
  const RUN_ID_RE = /^[a-zA-Z0-9_-]+$/;
@@ -367,13 +451,19 @@ export function createWorktreesRouter() {
367
451
 
368
452
  // POST /worktrees/cleanup
369
453
  //
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) => {
454
+ // Batch worktree removal — async. Synchronously validates each id and
455
+ // stamps `cleanup_state: 'pending'` on the registry entries that pass
456
+ // pre-flight checks, then returns 202. The actual removal happens in
457
+ // the background with bounded concurrency. Clients poll GET /worktrees
458
+ // and observe `cleanup_state` per entry; on success the entry vanishes,
459
+ // on failure `cleanup_error` is set and `cleanup_state` is cleared.
460
+ //
461
+ // Response shape `{ ok, accepted, rejected }` where `rejected[]` carries
462
+ // entries that failed pre-flight (running, resumable without force, etc).
463
+ // A single bad id never blocks the rest of the batch; this stays
464
+ // compatible with the legacy synchronous shape's promise that partial
465
+ // failures are not signalled via HTTP status.
466
+ router.post('/cleanup', (req, res) => {
377
467
  const worcaDir = req.project?.worcaDir;
378
468
  if (!worcaDir) {
379
469
  return res
@@ -395,86 +485,82 @@ export function createWorktreesRouter() {
395
485
  }
396
486
  }
397
487
 
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
- }
488
+ // Pre-flight: read each registry entry, decide pending vs reject. We do
489
+ // this synchronously so the HTTP response can carry the rejection list
490
+ // clients shouldn't have to poll to learn that a 'running' worktree
491
+ // was refused.
492
+ const accepted = [];
493
+ const rejected = [];
494
+ for (const run_id of run_ids) {
495
+ const regFile = join(worcaDir, 'multi', 'pipelines.d', `${run_id}.json`);
496
+ if (!existsSync(regFile)) {
497
+ rejected.push({
498
+ run_id,
499
+ ok: false,
500
+ error: `Worktree "${run_id}" not found`,
501
+ });
502
+ continue;
503
+ }
504
+ let reg;
505
+ try {
506
+ reg = JSON.parse(readFileSync(regFile, 'utf8'));
507
+ } catch {
508
+ rejected.push({
509
+ run_id,
510
+ ok: false,
511
+ error: 'Failed to read registry entry',
512
+ });
513
+ continue;
514
+ }
431
515
 
432
- if (status === 'running') {
433
- return {
434
- run_id,
435
- ok: false,
436
- error: 'Cannot remove a running worktree',
437
- code: 'running',
438
- };
439
- }
516
+ let status = reg.status || 'unknown';
517
+ if (reg.worktree_path && existsSync(reg.worktree_path)) {
518
+ const actual = _readWorktreeStatus(reg.worktree_path);
519
+ if (actual) status = actual;
520
+ }
440
521
 
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
- }
522
+ if (status === 'running') {
523
+ rejected.push({
524
+ run_id,
525
+ ok: false,
526
+ error: 'Cannot remove a running worktree',
527
+ code: 'running',
528
+ });
529
+ continue;
530
+ }
452
531
 
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
- }));
532
+ const isResumable = RESUMABLE_STATUSES.has(status);
533
+ const isGrouped = !!(reg.fleet_id || reg.workspace_id);
534
+ if (!force && (isResumable || isGrouped)) {
535
+ rejected.push({
536
+ run_id,
537
+ ok: false,
538
+ error:
539
+ 'Removing this worktree prevents resuming the run. Pass force=true to confirm.',
540
+ code: 'resumable_or_grouped',
541
+ });
542
+ continue;
543
+ }
462
544
 
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 });
545
+ // Stamp pending so a reload mid-cleanup shows the same state.
546
+ _patchRegistry(worcaDir, run_id, {
547
+ cleanup_state: 'pending',
548
+ cleanup_error: undefined,
549
+ });
550
+ accepted.push({ run_id, reg });
468
551
  }
469
552
 
470
- try {
471
- await pruneWorktrees(worcaDir);
472
- } catch {
473
- /* non-fatal */
474
- }
553
+ // Respond immediately — the client polls GET /worktrees to observe progress.
554
+ res.status(202).json({
555
+ ok: rejected.length === 0,
556
+ accepted: accepted.map((a) => a.run_id),
557
+ rejected,
558
+ });
475
559
 
476
- const failed_count = results.reduce((n, r) => (r.ok ? n : n + 1), 0);
477
- res.json({ ok: failed_count === 0, failed_count, results });
560
+ // Fire-and-forget background removal. Errors are persisted into the
561
+ // registry so the client can render them; nothing here is awaited by
562
+ // the HTTP request.
563
+ void _runCleanupBatch(worcaDir, accepted);
478
564
  });
479
565
 
480
566
  return router;