@worca/ui 0.21.0 → 0.23.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 +6144 -4668
- package/app/main.bundle.js.map +4 -4
- package/app/protocol.js +2 -1
- package/app/styles.css +909 -15
- package/app/utils/state-actions.js +13 -2
- package/package.json +1 -1
- package/server/app.js +68 -1
- package/server/fleet-routes.js +1147 -0
- package/server/integrations/commands/fleet.js +266 -0
- package/server/integrations/commands/global.js +9 -0
- package/server/integrations/commands/parser.js +4 -1
- package/server/integrations/index.js +3 -0
- package/server/integrations/renderers.js +98 -0
- package/server/integrations/rest_client.js +7 -0
- package/server/worktrees-routes.js +226 -106
- package/server/ws-fleet-manifest-watcher.js +130 -0
- package/server/ws-message-router.js +20 -0
- package/server/ws-modular.js +10 -1
|
@@ -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,95 @@ 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
|
+
|
|
265
|
+
function _isPidAlive(pid) {
|
|
266
|
+
if (!pid || typeof pid !== 'number') return false;
|
|
267
|
+
try {
|
|
268
|
+
process.kill(pid, 0);
|
|
269
|
+
return true;
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (err.code === 'EPERM') return true;
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
195
276
|
async function _listWorktrees(worcaDir) {
|
|
196
277
|
const pipelinesDir = join(worcaDir, 'multi', 'pipelines.d');
|
|
197
278
|
if (!existsSync(pipelinesDir)) return [];
|
|
198
279
|
|
|
199
|
-
|
|
280
|
+
// Phase 1: cheap synchronous metadata (registry parse, status read).
|
|
281
|
+
// Phase 2: disk walks in parallel — without this, 13 worktrees serialize
|
|
282
|
+
// ~3s of awaits even when most results would have been disk-cache hits.
|
|
283
|
+
const metas = [];
|
|
200
284
|
for (const file of readdirSync(pipelinesDir)) {
|
|
201
285
|
if (!file.endsWith('.json')) continue;
|
|
202
286
|
|
|
@@ -211,13 +295,35 @@ async function _listWorktrees(worcaDir) {
|
|
|
211
295
|
const worktreePath = reg.worktree_path;
|
|
212
296
|
const worktreeExists = existsSync(worktreePath);
|
|
213
297
|
|
|
214
|
-
// Prefer actual status.json; fall back to registry field
|
|
215
298
|
let status = reg.status || 'unknown';
|
|
216
299
|
if (worktreeExists) {
|
|
217
300
|
const actual = _readWorktreeStatus(worktreePath);
|
|
218
301
|
if (actual) status = actual;
|
|
219
302
|
}
|
|
220
303
|
|
|
304
|
+
// Stale-registry reconciliation: a child can die before ever writing
|
|
305
|
+
// status.json (e.g. fleet halt right after dispatch, preflight crash,
|
|
306
|
+
// SIGKILL). In that case the worktree exists but .worca/runs/ doesn't,
|
|
307
|
+
// _readWorktreeStatus returns null, and we'd fall back to reg.status
|
|
308
|
+
// which may still say "running" with a dead pid. Treat that as
|
|
309
|
+
// "interrupted" and patch the registry so this only happens once.
|
|
310
|
+
//
|
|
311
|
+
// Only reconcile when reg.pid is present — a missing pid means the
|
|
312
|
+
// entry is either from a non-standard registration path (e.g. test
|
|
313
|
+
// fixtures) or pre-dates the pid-on-registration contract, so we
|
|
314
|
+
// can't make liveness claims about it.
|
|
315
|
+
if (
|
|
316
|
+
status === 'running' &&
|
|
317
|
+
typeof reg.pid === 'number' &&
|
|
318
|
+
!_isPidAlive(reg.pid)
|
|
319
|
+
) {
|
|
320
|
+
status = 'interrupted';
|
|
321
|
+
_patchRegistry(worcaDir, reg.run_id, {
|
|
322
|
+
status: 'interrupted',
|
|
323
|
+
interrupted_reason: 'stale_pid',
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
221
327
|
let ageSeconds = 0;
|
|
222
328
|
if (reg.started_at) {
|
|
223
329
|
const started = new Date(reg.started_at).getTime();
|
|
@@ -226,32 +332,44 @@ async function _listWorktrees(worcaDir) {
|
|
|
226
332
|
}
|
|
227
333
|
}
|
|
228
334
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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,
|
|
335
|
+
metas.push({
|
|
336
|
+
reg,
|
|
337
|
+
worktreePath,
|
|
338
|
+
worktreeExists,
|
|
245
339
|
status,
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
group_type: reg.group_type || null,
|
|
250
|
-
group_status: null, // populated by W-040 / W-047
|
|
251
|
-
resumable: RESUMABLE_STATUSES.has(status),
|
|
340
|
+
ageSeconds,
|
|
341
|
+
cleanup_state: reg.cleanup_state || null,
|
|
342
|
+
cleanup_error: reg.cleanup_error || null,
|
|
252
343
|
});
|
|
253
344
|
}
|
|
254
|
-
|
|
345
|
+
|
|
346
|
+
const disks = await Promise.all(
|
|
347
|
+
metas.map((m) =>
|
|
348
|
+
m.worktreeExists
|
|
349
|
+
? _getDiskBytes(m.worktreePath)
|
|
350
|
+
: Promise.resolve({ bytes: 0, truncated: false }),
|
|
351
|
+
),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
return metas.map((m, i) => ({
|
|
355
|
+
run_id: m.reg.run_id || '',
|
|
356
|
+
title: m.reg.title || '',
|
|
357
|
+
branch: m.reg.branch || '',
|
|
358
|
+
worktree_path: m.worktreePath,
|
|
359
|
+
disk_bytes: disks[i].bytes,
|
|
360
|
+
truncated: disks[i].truncated,
|
|
361
|
+
age_seconds: m.ageSeconds,
|
|
362
|
+
started_at: m.reg.started_at || null,
|
|
363
|
+
status: m.status,
|
|
364
|
+
removable: m.status !== 'running',
|
|
365
|
+
fleet_id: m.reg.fleet_id || null,
|
|
366
|
+
workspace_id: m.reg.workspace_id || null,
|
|
367
|
+
group_type: m.reg.group_type || null,
|
|
368
|
+
group_status: null,
|
|
369
|
+
resumable: RESUMABLE_STATUSES.has(m.status),
|
|
370
|
+
cleanup_state: m.cleanup_state,
|
|
371
|
+
cleanup_error: m.cleanup_error,
|
|
372
|
+
}));
|
|
255
373
|
}
|
|
256
374
|
|
|
257
375
|
const RUN_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
@@ -367,13 +485,19 @@ export function createWorktreesRouter() {
|
|
|
367
485
|
|
|
368
486
|
// POST /worktrees/cleanup
|
|
369
487
|
//
|
|
370
|
-
// Batch worktree removal.
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
488
|
+
// Batch worktree removal — async. Synchronously validates each id and
|
|
489
|
+
// stamps `cleanup_state: 'pending'` on the registry entries that pass
|
|
490
|
+
// pre-flight checks, then returns 202. The actual removal happens in
|
|
491
|
+
// the background with bounded concurrency. Clients poll GET /worktrees
|
|
492
|
+
// and observe `cleanup_state` per entry; on success the entry vanishes,
|
|
493
|
+
// on failure `cleanup_error` is set and `cleanup_state` is cleared.
|
|
494
|
+
//
|
|
495
|
+
// Response shape `{ ok, accepted, rejected }` where `rejected[]` carries
|
|
496
|
+
// entries that failed pre-flight (running, resumable without force, etc).
|
|
497
|
+
// A single bad id never blocks the rest of the batch; this stays
|
|
498
|
+
// compatible with the legacy synchronous shape's promise that partial
|
|
499
|
+
// failures are not signalled via HTTP status.
|
|
500
|
+
router.post('/cleanup', (req, res) => {
|
|
377
501
|
const worcaDir = req.project?.worcaDir;
|
|
378
502
|
if (!worcaDir) {
|
|
379
503
|
return res
|
|
@@ -395,86 +519,82 @@ export function createWorktreesRouter() {
|
|
|
395
519
|
}
|
|
396
520
|
}
|
|
397
521
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
}
|
|
522
|
+
// Pre-flight: read each registry entry, decide pending vs reject. We do
|
|
523
|
+
// this synchronously so the HTTP response can carry the rejection list
|
|
524
|
+
// — clients shouldn't have to poll to learn that a 'running' worktree
|
|
525
|
+
// was refused.
|
|
526
|
+
const accepted = [];
|
|
527
|
+
const rejected = [];
|
|
528
|
+
for (const run_id of run_ids) {
|
|
529
|
+
const regFile = join(worcaDir, 'multi', 'pipelines.d', `${run_id}.json`);
|
|
530
|
+
if (!existsSync(regFile)) {
|
|
531
|
+
rejected.push({
|
|
532
|
+
run_id,
|
|
533
|
+
ok: false,
|
|
534
|
+
error: `Worktree "${run_id}" not found`,
|
|
535
|
+
});
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
let reg;
|
|
539
|
+
try {
|
|
540
|
+
reg = JSON.parse(readFileSync(regFile, 'utf8'));
|
|
541
|
+
} catch {
|
|
542
|
+
rejected.push({
|
|
543
|
+
run_id,
|
|
544
|
+
ok: false,
|
|
545
|
+
error: 'Failed to read registry entry',
|
|
546
|
+
});
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
431
549
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
code: 'running',
|
|
438
|
-
};
|
|
439
|
-
}
|
|
550
|
+
let status = reg.status || 'unknown';
|
|
551
|
+
if (reg.worktree_path && existsSync(reg.worktree_path)) {
|
|
552
|
+
const actual = _readWorktreeStatus(reg.worktree_path);
|
|
553
|
+
if (actual) status = actual;
|
|
554
|
+
}
|
|
440
555
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
};
|
|
451
|
-
}
|
|
556
|
+
if (status === 'running') {
|
|
557
|
+
rejected.push({
|
|
558
|
+
run_id,
|
|
559
|
+
ok: false,
|
|
560
|
+
error: 'Cannot remove a running worktree',
|
|
561
|
+
code: 'running',
|
|
562
|
+
});
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
452
565
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
566
|
+
const isResumable = RESUMABLE_STATUSES.has(status);
|
|
567
|
+
const isGrouped = !!(reg.fleet_id || reg.workspace_id);
|
|
568
|
+
if (!force && (isResumable || isGrouped)) {
|
|
569
|
+
rejected.push({
|
|
570
|
+
run_id,
|
|
571
|
+
ok: false,
|
|
572
|
+
error:
|
|
573
|
+
'Removing this worktree prevents resuming the run. Pass force=true to confirm.',
|
|
574
|
+
code: 'resumable_or_grouped',
|
|
575
|
+
});
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
462
578
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
579
|
+
// Stamp pending so a reload mid-cleanup shows the same state.
|
|
580
|
+
_patchRegistry(worcaDir, run_id, {
|
|
581
|
+
cleanup_state: 'pending',
|
|
582
|
+
cleanup_error: undefined,
|
|
583
|
+
});
|
|
584
|
+
accepted.push({ run_id, reg });
|
|
468
585
|
}
|
|
469
586
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
587
|
+
// Respond immediately — the client polls GET /worktrees to observe progress.
|
|
588
|
+
res.status(202).json({
|
|
589
|
+
ok: rejected.length === 0,
|
|
590
|
+
accepted: accepted.map((a) => a.run_id),
|
|
591
|
+
rejected,
|
|
592
|
+
});
|
|
475
593
|
|
|
476
|
-
|
|
477
|
-
|
|
594
|
+
// Fire-and-forget background removal. Errors are persisted into the
|
|
595
|
+
// registry so the client can render them; nothing here is awaited by
|
|
596
|
+
// the HTTP request.
|
|
597
|
+
void _runCleanupBatch(worcaDir, accepted);
|
|
478
598
|
});
|
|
479
599
|
|
|
480
600
|
return router;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet manifest watcher — monitors ~/.worca/fleet-runs/<fleet_id>.json for changes.
|
|
3
|
+
* Emits fleet-update WS events when a fleet manifest is written (§13.5).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { effectiveFleetStatus } from './fleet-routes.js';
|
|
10
|
+
|
|
11
|
+
const FLEET_DEBOUNCE_MS = 200;
|
|
12
|
+
const DEFAULT_FLEET_RUNS_DIR = join(homedir(), '.worca', 'fleet-runs');
|
|
13
|
+
|
|
14
|
+
const FAILURE_STATES = new Set(['failed', 'setup_failed', 'unrecoverable']);
|
|
15
|
+
|
|
16
|
+
function readJson(path) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveChildStatus(child) {
|
|
25
|
+
const { project_path, run_id } = child;
|
|
26
|
+
if (!project_path || !run_id) return 'running';
|
|
27
|
+
const registryPath = join(
|
|
28
|
+
project_path,
|
|
29
|
+
'.worca',
|
|
30
|
+
'multi',
|
|
31
|
+
'pipelines.d',
|
|
32
|
+
`${run_id}.json`,
|
|
33
|
+
);
|
|
34
|
+
const entry = readJson(registryPath);
|
|
35
|
+
return entry?.status ?? 'running';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{ broadcaster: { broadcast: Function }, fleetRunsDir?: string }} deps
|
|
40
|
+
*/
|
|
41
|
+
export function createFleetManifestWatcher({
|
|
42
|
+
broadcaster,
|
|
43
|
+
fleetRunsDir = DEFAULT_FLEET_RUNS_DIR,
|
|
44
|
+
}) {
|
|
45
|
+
let fsWatcher = null;
|
|
46
|
+
/** @type {Map<string, ReturnType<typeof setTimeout>>} */
|
|
47
|
+
const debounceTimers = new Map();
|
|
48
|
+
|
|
49
|
+
function broadcastFleetUpdate(fleetId, manifestPath) {
|
|
50
|
+
const manifest = readJson(manifestPath);
|
|
51
|
+
if (!manifest) return;
|
|
52
|
+
|
|
53
|
+
const rawChildren = Array.isArray(manifest.children)
|
|
54
|
+
? manifest.children
|
|
55
|
+
: [];
|
|
56
|
+
const children = rawChildren.map((child) => ({
|
|
57
|
+
run_id: child.run_id,
|
|
58
|
+
project_path: child.project_path,
|
|
59
|
+
status: resolveChildStatus(child),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
const completed_children = children.filter(
|
|
63
|
+
(c) => c.status === 'completed',
|
|
64
|
+
).length;
|
|
65
|
+
const failed_children = children.filter((c) =>
|
|
66
|
+
FAILURE_STATES.has(c.status),
|
|
67
|
+
).length;
|
|
68
|
+
|
|
69
|
+
// Derive the effective status (same rules as REST) instead of broadcasting
|
|
70
|
+
// raw manifest.status — otherwise cards stay "running" forever, because
|
|
71
|
+
// run_fleet.py never writes a terminal status after it exits. Pure
|
|
72
|
+
// function: persists nothing, so we don't trigger a watch→write→watch loop.
|
|
73
|
+
const { status, halt_reason } = effectiveFleetStatus(
|
|
74
|
+
manifest,
|
|
75
|
+
children.map((c) => c.status),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
broadcaster.broadcast('fleet-update', {
|
|
79
|
+
fleet_id: fleetId,
|
|
80
|
+
status,
|
|
81
|
+
halt_reason,
|
|
82
|
+
completed_children,
|
|
83
|
+
failed_children,
|
|
84
|
+
children,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function scheduleUpdate(fleetId, manifestPath) {
|
|
89
|
+
const existing = debounceTimers.get(fleetId);
|
|
90
|
+
if (existing) clearTimeout(existing);
|
|
91
|
+
debounceTimers.set(
|
|
92
|
+
fleetId,
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
debounceTimers.delete(fleetId);
|
|
95
|
+
broadcastFleetUpdate(fleetId, manifestPath);
|
|
96
|
+
}, FLEET_DEBOUNCE_MS),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
if (existsSync(fleetRunsDir)) {
|
|
102
|
+
fsWatcher = watch(
|
|
103
|
+
fleetRunsDir,
|
|
104
|
+
{ persistent: false },
|
|
105
|
+
(_event, filename) => {
|
|
106
|
+
if (!filename?.endsWith('.json')) return;
|
|
107
|
+
const fleetId = filename.slice(0, -5);
|
|
108
|
+
scheduleUpdate(fleetId, join(fleetRunsDir, filename));
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// fs.watch unsupported or dir unavailable — skip silently
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function destroy() {
|
|
117
|
+
if (fsWatcher) {
|
|
118
|
+
try {
|
|
119
|
+
fsWatcher.close();
|
|
120
|
+
} catch {
|
|
121
|
+
/* ignore */
|
|
122
|
+
}
|
|
123
|
+
fsWatcher = null;
|
|
124
|
+
}
|
|
125
|
+
for (const timer of debounceTimers.values()) clearTimeout(timer);
|
|
126
|
+
debounceTimers.clear();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { destroy };
|
|
130
|
+
}
|
|
@@ -80,6 +80,22 @@ export function createMessageRouter({
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// When a run-scoped subscribe arrives with a `payload.projectId` that
|
|
84
|
+
// differs from the client's currently-bound subs.projectId, re-bind the
|
|
85
|
+
// WS to that project. Without this, the targeted project's WatcherSet
|
|
86
|
+
// stays in POLLING tier (no logWatcher / no live updates), and the
|
|
87
|
+
// backfill / live stream silently never arrive — symptoms reported by
|
|
88
|
+
// the user as "no logs / no agent prompts" on the run-detail page in
|
|
89
|
+
// global mode. We keep the previous projectId reference so demotion
|
|
90
|
+
// still happens via the normal client-count mechanism.
|
|
91
|
+
function _adoptProjectFromPayload(ws, payload) {
|
|
92
|
+
const requested = payload?.projectId;
|
|
93
|
+
if (!requested || !watcherSets.has(requested)) return;
|
|
94
|
+
const subs = clientManager.getSubs(ws);
|
|
95
|
+
if (subs?.projectId === requested) return;
|
|
96
|
+
clientManager.setProtocol(ws, subs?.protocolVersion ?? 1, requested);
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
async function handleMessage(ws, data) {
|
|
84
100
|
let json;
|
|
85
101
|
try {
|
|
@@ -147,6 +163,7 @@ export function createMessageRouter({
|
|
|
147
163
|
);
|
|
148
164
|
return;
|
|
149
165
|
}
|
|
166
|
+
_adoptProjectFromPayload(ws, req.payload);
|
|
150
167
|
const proj = resolveProject(ws, req.payload);
|
|
151
168
|
const runs = discoverRuns(proj.worcaDir);
|
|
152
169
|
const run = runs.find((r) => r.id === runId);
|
|
@@ -294,6 +311,7 @@ export function createMessageRouter({
|
|
|
294
311
|
);
|
|
295
312
|
return;
|
|
296
313
|
}
|
|
314
|
+
_adoptProjectFromPayload(ws, req.payload);
|
|
297
315
|
const proj = resolveProject(ws, req.payload);
|
|
298
316
|
if (!proj) {
|
|
299
317
|
ws.send(
|
|
@@ -336,6 +354,7 @@ export function createMessageRouter({
|
|
|
336
354
|
// subscribe-log
|
|
337
355
|
if (req.type === 'subscribe-log') {
|
|
338
356
|
const { stage, runId, iteration } = req.payload || {};
|
|
357
|
+
_adoptProjectFromPayload(ws, req.payload);
|
|
339
358
|
const proj = resolveProject(ws, req.payload);
|
|
340
359
|
const s = clientManager.ensureSubs(ws);
|
|
341
360
|
s.logStage = stage || '*';
|
|
@@ -652,6 +671,7 @@ export function createMessageRouter({
|
|
|
652
671
|
);
|
|
653
672
|
return;
|
|
654
673
|
}
|
|
674
|
+
_adoptProjectFromPayload(ws, req.payload);
|
|
655
675
|
const proj = resolveProject(ws, req.payload);
|
|
656
676
|
if (!proj.wset.beadsWatcher) {
|
|
657
677
|
ws.send(JSON.stringify(makeOk(req, { issues: [], runId })));
|
package/server/ws-modular.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { existsSync, watch } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
10
11
|
import { join } from 'node:path';
|
|
11
12
|
import { WebSocketServer } from 'ws';
|
|
12
13
|
import { readProjects, synthesizeDefaultProject } from './project-registry.js';
|
|
@@ -14,6 +15,7 @@ import { TIER_FULL, TIER_POLLING, WatcherSet } from './watcher-set.js';
|
|
|
14
15
|
import { readProjectWorcaVersion } from './worca-setup.js';
|
|
15
16
|
import { createBroadcaster } from './ws-broadcaster.js';
|
|
16
17
|
import { createClientManager } from './ws-client-manager.js';
|
|
18
|
+
import { createFleetManifestWatcher } from './ws-fleet-manifest-watcher.js';
|
|
17
19
|
import { createMessageRouter } from './ws-message-router.js';
|
|
18
20
|
import { resolveLatestRunDir } from './ws-status-watcher.js';
|
|
19
21
|
|
|
@@ -45,7 +47,13 @@ export function attachWsServer(httpServer, config) {
|
|
|
45
47
|
getSubs: clientManager.getSubs,
|
|
46
48
|
});
|
|
47
49
|
|
|
48
|
-
//
|
|
50
|
+
// 3a. Fleet manifest watcher — global, not per-project (§13.5)
|
|
51
|
+
const fleetManifestWatcher = createFleetManifestWatcher({
|
|
52
|
+
broadcaster,
|
|
53
|
+
fleetRunsDir: join(homedir(), '.worca', 'fleet-runs'),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// 3b. Create WatcherSet(s) — one per project
|
|
49
57
|
/** @type {Map<string, WatcherSet>} */
|
|
50
58
|
const watcherSets = new Map();
|
|
51
59
|
|
|
@@ -269,6 +277,7 @@ export function attachWsServer(httpServer, config) {
|
|
|
269
277
|
|
|
270
278
|
wss.on('close', () => {
|
|
271
279
|
clientManager.destroy();
|
|
280
|
+
fleetManifestWatcher.destroy();
|
|
272
281
|
if (dirWatcher) {
|
|
273
282
|
try {
|
|
274
283
|
dirWatcher.close();
|