@worca/ui 0.12.0 → 0.14.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.
@@ -0,0 +1,272 @@
1
+ /**
2
+ * REST routes for worktree management.
3
+ *
4
+ * GET /worktrees — list worktree entries enriched with disk/age/group data
5
+ * DELETE /worktrees/:run_id — remove a worktree (409 if running, 412 if resumable/grouped without ?force=1)
6
+ *
7
+ * Expects req.project.worcaDir to be set by projectResolver middleware.
8
+ */
9
+
10
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { Router } from 'express';
13
+ import { removeWorktree } from './worktree-ops.js';
14
+
15
+ const RESUMABLE_STATUSES = new Set(['failed', 'paused', 'cancelled']);
16
+
17
+ // Disk usage cache — keyed by worktree path, expires after 30 s
18
+ const _diskCache = new Map();
19
+ const DISK_CACHE_TTL_MS = 30_000;
20
+
21
+ /**
22
+ * Sum file sizes under a directory tree. Cross-platform: prior `du -sb`
23
+ * relied on GNU coreutils and silently returned 0 on macOS / BSD du,
24
+ * which is why the Worktrees view always showed "0 B".
25
+ *
26
+ * Skips symlinks (don't follow into other trees) and is bounded by
27
+ * MAX_WALK_FILES so a runaway directory can't hang the request.
28
+ * Errors on individual entries are swallowed so a transiently-locked
29
+ * file doesn't poison the whole sum.
30
+ */
31
+ const MAX_WALK_FILES = 100_000;
32
+ function _walkDirSize(rootPath) {
33
+ let total = 0;
34
+ let count = 0;
35
+ const stack = [rootPath];
36
+ while (stack.length > 0 && count < MAX_WALK_FILES) {
37
+ const cur = stack.pop();
38
+ let entries;
39
+ try {
40
+ entries = readdirSync(cur, { withFileTypes: true });
41
+ } catch {
42
+ continue;
43
+ }
44
+ for (const e of entries) {
45
+ count++;
46
+ if (count >= MAX_WALK_FILES) break;
47
+ const child = join(cur, e.name);
48
+ if (e.isSymbolicLink()) continue;
49
+ if (e.isDirectory()) {
50
+ stack.push(child);
51
+ } else if (e.isFile()) {
52
+ try {
53
+ total += statSync(child).size;
54
+ } catch {
55
+ /* ignore — file vanished mid-walk */
56
+ }
57
+ }
58
+ }
59
+ }
60
+ return total;
61
+ }
62
+
63
+ function _getDiskBytes(worktreePath) {
64
+ const now = Date.now();
65
+ const hit = _diskCache.get(worktreePath);
66
+ if (hit && hit.expiry > now) return hit.bytes;
67
+
68
+ let bytes = 0;
69
+ try {
70
+ bytes = _walkDirSize(worktreePath);
71
+ } catch {
72
+ bytes = 0;
73
+ }
74
+ _diskCache.set(worktreePath, { bytes, expiry: now + DISK_CACHE_TTL_MS });
75
+ return bytes;
76
+ }
77
+
78
+ /**
79
+ * Read pipeline_status from a worktree's status.json files.
80
+ * Checks .worca/runs/ (W-048 layout) then flat .worca/status.json (legacy).
81
+ * Returns null if no status file is found.
82
+ */
83
+ function _readWorktreeStatus(worktreePath) {
84
+ const runsDir = join(worktreePath, '.worca', 'runs');
85
+ if (existsSync(runsDir)) {
86
+ for (const entry of readdirSync(runsDir)) {
87
+ const sp = join(runsDir, entry, 'status.json');
88
+ if (!existsSync(sp)) continue;
89
+ try {
90
+ const data = JSON.parse(readFileSync(sp, 'utf8'));
91
+ if (data.pipeline_status) return data.pipeline_status;
92
+ } catch {
93
+ /* ignore malformed */
94
+ }
95
+ }
96
+ }
97
+
98
+ const flat = join(worktreePath, '.worca', 'status.json');
99
+ if (existsSync(flat)) {
100
+ try {
101
+ const data = JSON.parse(readFileSync(flat, 'utf8'));
102
+ if (data.pipeline_status) return data.pipeline_status;
103
+ } catch {
104
+ /* ignore malformed */
105
+ }
106
+ }
107
+
108
+ return null;
109
+ }
110
+
111
+ function _listWorktrees(worcaDir) {
112
+ const pipelinesDir = join(worcaDir, 'multi', 'pipelines.d');
113
+ if (!existsSync(pipelinesDir)) return [];
114
+
115
+ const entries = [];
116
+ for (const file of readdirSync(pipelinesDir)) {
117
+ if (!file.endsWith('.json')) continue;
118
+
119
+ let reg;
120
+ try {
121
+ reg = JSON.parse(readFileSync(join(pipelinesDir, file), 'utf8'));
122
+ } catch {
123
+ continue;
124
+ }
125
+ if (!reg.worktree_path) continue;
126
+
127
+ const worktreePath = reg.worktree_path;
128
+ const worktreeExists = existsSync(worktreePath);
129
+
130
+ // Prefer actual status.json; fall back to registry field
131
+ let status = reg.status || 'unknown';
132
+ if (worktreeExists) {
133
+ const actual = _readWorktreeStatus(worktreePath);
134
+ if (actual) status = actual;
135
+ }
136
+
137
+ let ageSeconds = 0;
138
+ if (reg.started_at) {
139
+ const started = new Date(reg.started_at).getTime();
140
+ if (!Number.isNaN(started)) {
141
+ ageSeconds = Math.max(0, Math.floor((Date.now() - started) / 1_000));
142
+ }
143
+ }
144
+
145
+ entries.push({
146
+ run_id: reg.run_id || '',
147
+ title: reg.title || '',
148
+ branch: reg.branch || '',
149
+ worktree_path: worktreePath,
150
+ disk_bytes: worktreeExists ? _getDiskBytes(worktreePath) : 0,
151
+ age_seconds: ageSeconds,
152
+ // started_at lets the client sort with the same sortByStartDesc helper
153
+ // used by run-list, keeping ordering consistent across views.
154
+ started_at: reg.started_at || null,
155
+ status,
156
+ removable: status !== 'running',
157
+ fleet_id: reg.fleet_id || null,
158
+ workspace_id: reg.workspace_id || null,
159
+ group_type: reg.group_type || null,
160
+ group_status: null, // populated by W-040 / W-047
161
+ resumable: RESUMABLE_STATUSES.has(status),
162
+ });
163
+ }
164
+ return entries;
165
+ }
166
+
167
+ const RUN_ID_RE = /^[a-zA-Z0-9_-]+$/;
168
+ function _validateRunId(runId) {
169
+ return (
170
+ typeof runId === 'string' &&
171
+ runId.length > 0 &&
172
+ runId.length <= 128 &&
173
+ RUN_ID_RE.test(runId)
174
+ );
175
+ }
176
+
177
+ /**
178
+ * Create the worktrees REST router.
179
+ * Mount with: router.use('/worktrees', requireWorcaDir, createWorktreesRouter())
180
+ */
181
+ export function createWorktreesRouter() {
182
+ const router = Router({ mergeParams: true });
183
+
184
+ // GET /worktrees
185
+ router.get('/', (req, res) => {
186
+ const worcaDir = req.project?.worcaDir;
187
+ if (!worcaDir) {
188
+ return res
189
+ .status(501)
190
+ .json({ ok: false, error: 'worcaDir not configured' });
191
+ }
192
+ try {
193
+ const worktrees = _listWorktrees(worcaDir);
194
+ res.json({ ok: true, worktrees });
195
+ } catch (err) {
196
+ res.status(500).json({ ok: false, error: err.message });
197
+ }
198
+ });
199
+
200
+ // DELETE /worktrees/:run_id
201
+ router.delete('/:run_id', (req, res) => {
202
+ const worcaDir = req.project?.worcaDir;
203
+ if (!worcaDir) {
204
+ return res
205
+ .status(501)
206
+ .json({ ok: false, error: 'worcaDir not configured' });
207
+ }
208
+
209
+ const { run_id } = req.params;
210
+ if (!_validateRunId(run_id)) {
211
+ return res.status(400).json({ ok: false, error: 'Invalid run ID' });
212
+ }
213
+
214
+ const force = req.query.force === '1';
215
+
216
+ try {
217
+ const regFile = join(worcaDir, 'multi', 'pipelines.d', `${run_id}.json`);
218
+ if (!existsSync(regFile)) {
219
+ return res
220
+ .status(404)
221
+ .json({ ok: false, error: `Worktree "${run_id}" not found` });
222
+ }
223
+
224
+ let reg;
225
+ try {
226
+ reg = JSON.parse(readFileSync(regFile, 'utf8'));
227
+ } catch {
228
+ return res
229
+ .status(500)
230
+ .json({ ok: false, error: 'Failed to read registry entry' });
231
+ }
232
+
233
+ // Derive actual pipeline status
234
+ let status = reg.status || 'unknown';
235
+ if (reg.worktree_path && existsSync(reg.worktree_path)) {
236
+ const actual = _readWorktreeStatus(reg.worktree_path);
237
+ if (actual) status = actual;
238
+ }
239
+
240
+ // 409 — cannot remove a running worktree
241
+ if (status === 'running') {
242
+ return res.status(409).json({
243
+ ok: false,
244
+ error: 'Cannot remove a running worktree',
245
+ code: 'running',
246
+ });
247
+ }
248
+
249
+ // 412 — resumable or grouped run requires ?force=1 confirmation
250
+ const isResumable = RESUMABLE_STATUSES.has(status);
251
+ const isGrouped = !!(reg.fleet_id || reg.workspace_id);
252
+ if (!force && (isResumable || isGrouped)) {
253
+ return res.status(412).json({
254
+ ok: false,
255
+ error:
256
+ 'Removing this worktree prevents resuming the run. Use ?force=1 to confirm.',
257
+ code: 'resumable_or_grouped',
258
+ resumable: isResumable,
259
+ fleet_id: reg.fleet_id || null,
260
+ workspace_id: reg.workspace_id || null,
261
+ });
262
+ }
263
+
264
+ removeWorktree(worcaDir, run_id);
265
+ res.json({ ok: true, run_id });
266
+ } catch (err) {
267
+ res.status(500).json({ ok: false, error: err.message });
268
+ }
269
+ });
270
+
271
+ return router;
272
+ }
@@ -17,14 +17,14 @@ import {
17
17
  /**
18
18
  * @param {{
19
19
  * broadcaster: { broadcastToLogSubscribers: Function },
20
- * resolveActiveRunDir: Function,
20
+ * resolveLatestRunDir: Function,
21
21
  * worcaDir: string,
22
22
  * currentActiveRunId: Function
23
23
  * }} deps
24
24
  */
25
25
  export function createLogWatcher({
26
26
  broadcaster,
27
- resolveActiveRunDir,
27
+ resolveLatestRunDir,
28
28
  worcaDir,
29
29
  currentActiveRunId,
30
30
  }) {
@@ -35,7 +35,7 @@ export function createLogWatcher({
35
35
  const logByteOffsets = new Map();
36
36
 
37
37
  function resolveLogsBaseDir() {
38
- const runDir = resolveActiveRunDir();
38
+ const runDir = resolveLatestRunDir();
39
39
  return runDir === worcaDir ? worcaDir : runDir;
40
40
  }
41
41
 
@@ -54,16 +54,21 @@ export function createLogWatcher({
54
54
  logByteOffsets.clear();
55
55
  }
56
56
 
57
- function watchSingleLogFile(stage, filePath, iteration) {
58
- const key =
59
- iteration != null
60
- ? `${stage}__iter${iteration}`
61
- : stage || '__orchestrator__';
57
+ function _watcherKey(runId, stage, iteration, suffix = '') {
58
+ const stagePart = stage || '__orchestrator__';
59
+ const iterPart = iteration != null ? `__iter${iteration}` : '';
60
+ const runPart = runId ? `${runId}__` : '';
61
+ return `${runPart}${stagePart}${iterPart}${suffix}`;
62
+ }
63
+
64
+ function watchSingleLogFile(stage, filePath, iteration, options = {}) {
65
+ const explicitRunId = options.runId || null;
66
+ const key = _watcherKey(explicitRunId, stage, iteration);
62
67
  if (logWatchers.has(key)) return;
63
68
  try {
64
69
  if (!existsSync(filePath)) return;
65
70
  logByteOffsets.set(key, fileByteLength(filePath));
66
- const watcherRunId = currentActiveRunId();
71
+ const watcherRunId = explicitRunId || currentActiveRunId();
67
72
  const watcher = watch(filePath, (eventType) => {
68
73
  if (eventType === 'change') {
69
74
  try {
@@ -99,62 +104,66 @@ export function createLogWatcher({
99
104
  }
100
105
  }
101
106
 
102
- function watchStageDir(stage, stageDir) {
103
- const dirKey = `${stage}__dir`;
107
+ function watchStageDir(stage, stageDir, options = {}) {
108
+ const explicitRunId = options.runId || null;
109
+ const dirKey = _watcherKey(explicitRunId, stage, null, '__dir');
104
110
  if (logWatchers.has(dirKey)) return;
105
111
  try {
106
112
  const dirWatcher = watch(stageDir, (_eventType, filename) => {
107
113
  if (filename && /^iter-\d+\.log$/.test(filename)) {
108
114
  const iterNum = parseInt(filename.match(/\d+/)[0], 10);
109
115
  const iterPath = join(stageDir, filename);
110
- watchSingleLogFile(stage, iterPath, iterNum);
116
+ watchSingleLogFile(stage, iterPath, iterNum, options);
111
117
  }
112
118
  });
113
119
  logWatchers.set(dirKey, dirWatcher);
114
- const logsBase = resolveLogsBaseDir();
120
+ const logsBase = options.runDir || resolveLogsBaseDir();
115
121
  const backfill = listIterationFiles(logsBase, stage);
116
122
  for (const { iteration, path } of backfill) {
117
- watchSingleLogFile(stage, path, iteration);
123
+ watchSingleLogFile(stage, path, iteration, options);
118
124
  }
119
125
  } catch {
120
126
  /* ignore */
121
127
  }
122
128
  }
123
129
 
124
- function watchLogFile(stage) {
125
- const logsBase = resolveLogsBaseDir();
130
+ function watchLogFile(stage, options = {}) {
131
+ const logsBase = options.runDir || resolveLogsBaseDir();
126
132
  if (!stage) {
127
133
  const logPath = resolveLogPath(logsBase, null);
128
- watchSingleLogFile(null, logPath, null);
134
+ watchSingleLogFile(null, logPath, null, options);
129
135
  return;
130
136
  }
131
137
  const stageDir = resolveLogPath(logsBase, stage);
132
138
  if (existsSync(stageDir) && statSync(stageDir).isDirectory()) {
133
139
  const iters = listIterationFiles(logsBase, stage);
134
140
  for (const { iteration, path } of iters) {
135
- watchSingleLogFile(stage, path, iteration);
141
+ watchSingleLogFile(stage, path, iteration, options);
136
142
  }
137
- watchStageDir(stage, stageDir);
143
+ watchStageDir(stage, stageDir, options);
138
144
  } else {
139
145
  const logPath = join(logsBase, 'logs', `${stage}.log`);
140
146
  if (existsSync(logPath)) {
141
- watchSingleLogFile(stage, logPath, null);
147
+ watchSingleLogFile(stage, logPath, null, options);
142
148
  }
143
149
  }
144
150
  }
145
151
 
146
- function watchAllLogFiles() {
147
- const logsBase = resolveLogsBaseDir();
152
+ function watchAllLogFiles(options = {}) {
153
+ const logsBase = options.runDir || resolveLogsBaseDir();
148
154
  const logFiles = listLogFiles(logsBase);
149
155
  const watchedStages = new Set();
150
156
  for (const { stage } of logFiles) {
151
157
  if (watchedStages.has(stage)) continue;
152
158
  watchedStages.add(stage);
153
159
  const actualStage = stage === 'orchestrator' ? null : stage;
154
- watchLogFile(actualStage);
160
+ watchLogFile(actualStage, options);
155
161
  }
156
162
  const logsDir = join(logsBase, 'logs');
157
- const dirKey = '__logs_dir__';
163
+ const explicitRunId = options.runId || null;
164
+ const dirKey = explicitRunId
165
+ ? `${explicitRunId}__logs_dir__`
166
+ : '__logs_dir__';
158
167
  if (logWatchers.has(dirKey)) return;
159
168
  if (!existsSync(logsDir)) return;
160
169
  try {
@@ -163,16 +172,16 @@ export function createLogWatcher({
163
172
  if (filename.endsWith('.log')) {
164
173
  const stage = filename.replace('.log', '');
165
174
  const actualStage = stage === 'orchestrator' ? null : stage;
166
- watchLogFile(actualStage);
175
+ watchLogFile(actualStage, options);
167
176
  } else {
168
177
  const stagePath = join(logsDir, filename);
169
178
  try {
170
179
  if (existsSync(stagePath) && statSync(stagePath).isDirectory()) {
171
180
  const iters = listIterationFiles(logsBase, filename);
172
181
  for (const { iteration, path } of iters) {
173
- watchSingleLogFile(filename, path, iteration);
182
+ watchSingleLogFile(filename, path, iteration, options);
174
183
  }
175
- watchStageDir(filename, stagePath);
184
+ watchStageDir(filename, stagePath, options);
176
185
  }
177
186
  } catch {
178
187
  /* ignore */
@@ -32,6 +32,7 @@ import {
32
32
  stopPipeline as pmStopPipeline,
33
33
  reconcileStatus,
34
34
  } from './process-manager.js';
35
+ import { resolveRunDir } from './run-dir-resolver.js';
35
36
  import { readSettings } from './settings-reader.js';
36
37
  import { discoverRuns } from './watcher.js';
37
38
 
@@ -342,90 +343,100 @@ export function createMessageRouter({
342
343
 
343
344
  if (!proj.wset.logWatcher) return;
344
345
 
345
- const archivedRunDir = runId
346
- ? join(proj.worcaDir, 'results', runId)
347
- : null;
348
- const archivedLogDir = archivedRunDir
349
- ? join(archivedRunDir, 'logs')
350
- : null;
351
- const isArchived = archivedLogDir && existsSync(archivedLogDir);
352
-
353
- if (isArchived) {
354
- proj.wset.logWatcher.sendArchivedLogs(
355
- ws,
356
- archivedLogDir,
357
- stage,
358
- iteration,
359
- );
346
+ // Resolve runId → on-disk run dir. Handles local active (runs/<id>),
347
+ // archived (results/<id>), and worktree overlay (pipelines.d/<id>.json
348
+ // → worktree_path/.worca/runs/<id>). Falls back to the project's
349
+ // latest-active-run base when no runId is given.
350
+ let logsBase;
351
+ let watchOptions;
352
+ if (runId) {
353
+ const runDir = resolveRunDir(proj.worcaDir, runId);
354
+ if (runDir) {
355
+ logsBase = runDir;
356
+ // Tail only when the run is still alive (pipeline.pid present);
357
+ // archived dirs get backfill but no live watcher.
358
+ watchOptions = existsSync(join(runDir, 'pipeline.pid'))
359
+ ? { runDir, runId }
360
+ : null;
361
+ } else {
362
+ // Run not found anywhere; nothing to send or watch.
363
+ return;
364
+ }
360
365
  } else {
361
- const logsBase = proj.wset.logWatcher.resolveLogsBaseDir();
362
- if (stage) {
363
- if (iteration != null) {
364
- const logPath = resolveIterationLogPath(logsBase, stage, iteration);
365
- const lines = readLastLines(logPath, 200);
366
- if (lines.length > 0) {
367
- ws.send(
368
- JSON.stringify({
369
- id: `evt-${Date.now()}`,
370
- ok: true,
371
- type: 'log-bulk',
372
- payload: { stage, iteration, lines },
373
- }),
374
- );
375
- }
376
- } else {
377
- const stageDir = resolveLogPath(logsBase, stage);
378
- if (existsSync(stageDir) && statSync(stageDir).isDirectory()) {
379
- const iters = listIterationFiles(logsBase, stage);
380
- for (const { iteration: iterNum, path } of iters) {
381
- const lines = readLastLines(path, 200);
382
- if (lines.length > 0) {
383
- ws.send(
384
- JSON.stringify({
385
- id: `evt-${Date.now()}-iter${iterNum}`,
386
- ok: true,
387
- type: 'log-bulk',
388
- payload: { stage, iteration: iterNum, lines },
389
- }),
390
- );
391
- }
392
- }
393
- } else {
394
- const logPath = join(logsBase, 'logs', `${stage}.log`);
395
- const lines = readLastLines(logPath, 200);
366
+ logsBase = proj.wset.logWatcher.resolveLogsBaseDir();
367
+ watchOptions = {};
368
+ }
369
+
370
+ if (stage) {
371
+ if (iteration != null) {
372
+ const logPath = resolveIterationLogPath(logsBase, stage, iteration);
373
+ const lines = readLastLines(logPath, 200);
374
+ if (lines.length > 0) {
375
+ ws.send(
376
+ JSON.stringify({
377
+ id: `evt-${Date.now()}`,
378
+ ok: true,
379
+ type: 'log-bulk',
380
+ payload: { stage, iteration, lines },
381
+ }),
382
+ );
383
+ }
384
+ } else {
385
+ const stageDir = resolveLogPath(logsBase, stage);
386
+ if (existsSync(stageDir) && statSync(stageDir).isDirectory()) {
387
+ const iters = listIterationFiles(logsBase, stage);
388
+ for (const { iteration: iterNum, path } of iters) {
389
+ const lines = readLastLines(path, 200);
396
390
  if (lines.length > 0) {
397
391
  ws.send(
398
392
  JSON.stringify({
399
- id: `evt-${Date.now()}`,
393
+ id: `evt-${Date.now()}-iter${iterNum}`,
400
394
  ok: true,
401
395
  type: 'log-bulk',
402
- payload: { stage, lines },
396
+ payload: { stage, iteration: iterNum, lines },
403
397
  }),
404
398
  );
405
399
  }
406
400
  }
407
- }
408
- proj.wset.logWatcher.watchLogFile(stage);
409
- } else {
410
- const logFiles = listLogFiles(logsBase);
411
- for (const { stage: s2, iteration: iterNum, path } of logFiles) {
412
- const lines = readLastLines(path, 200);
401
+ } else {
402
+ const logPath = join(logsBase, 'logs', `${stage}.log`);
403
+ const lines = readLastLines(logPath, 200);
413
404
  if (lines.length > 0) {
414
405
  ws.send(
415
406
  JSON.stringify({
416
- id: `evt-${Date.now()}-${s2}-${iterNum || 0}`,
407
+ id: `evt-${Date.now()}`,
417
408
  ok: true,
418
409
  type: 'log-bulk',
419
- payload: {
420
- stage: s2,
421
- iteration: iterNum ?? undefined,
422
- lines,
423
- },
410
+ payload: { stage, lines },
424
411
  }),
425
412
  );
426
413
  }
427
414
  }
428
- proj.wset.logWatcher.watchAllLogFiles();
415
+ }
416
+ if (watchOptions) {
417
+ proj.wset.logWatcher.watchLogFile(stage, watchOptions);
418
+ }
419
+ } else {
420
+ const logFiles = listLogFiles(logsBase);
421
+ for (const { stage: s2, iteration: iterNum, path } of logFiles) {
422
+ const lines = readLastLines(path, 200);
423
+ if (lines.length > 0) {
424
+ ws.send(
425
+ JSON.stringify({
426
+ id: `evt-${Date.now()}-${s2}-${iterNum || 0}`,
427
+ ok: true,
428
+ type: 'log-bulk',
429
+ payload: {
430
+ stage: s2,
431
+ iteration: iterNum ?? undefined,
432
+ lines,
433
+ },
434
+ }),
435
+ );
436
+ }
437
+ }
438
+ if (watchOptions) {
439
+ proj.wset.logWatcher.watchAllLogFiles(watchOptions);
429
440
  }
430
441
  }
431
442
  return;
@@ -852,56 +863,6 @@ export function createMessageRouter({
852
863
  return;
853
864
  }
854
865
 
855
- // list-pipelines — return parallel pipeline entries for a project
856
- if (req.type === 'list-pipelines') {
857
- const proj = resolveProject(ws, req.payload);
858
- const multiWatcher = proj.wset.getMultiWatcher?.();
859
- const pipelines = multiWatcher ? multiWatcher.listPipelines() : [];
860
- ws.send(JSON.stringify(makeOk(req, { pipelines })));
861
- return;
862
- }
863
-
864
- // subscribe-pipeline — subscribe to a specific parallel pipeline's events
865
- if (req.type === 'subscribe-pipeline') {
866
- const { runId } = req.payload || {};
867
- if (typeof runId !== 'string') {
868
- ws.send(
869
- JSON.stringify(
870
- makeError(req, 'bad_request', 'payload.runId required'),
871
- ),
872
- );
873
- return;
874
- }
875
- const proj = resolveProject(ws, req.payload);
876
- const multiWatcher = proj.wset.getMultiWatcher?.();
877
- if (multiWatcher) {
878
- multiWatcher.promotePipeline(runId);
879
- }
880
- const s = clientManager.ensureSubs(ws);
881
- s.pipelineRunId = runId;
882
- s.pipelineProjectId = proj.wset.projectId;
883
- ws.send(JSON.stringify(makeOk(req, { subscribed: true })));
884
- return;
885
- }
886
-
887
- // unsubscribe-pipeline — clear pipeline subscription and demote watcher
888
- if (req.type === 'unsubscribe-pipeline') {
889
- const s = clientManager.ensureSubs(ws);
890
- const prevRunId = s.pipelineRunId;
891
- const prevProjectId = s.pipelineProjectId;
892
- s.pipelineRunId = null;
893
- s.pipelineProjectId = null;
894
- if (prevRunId && prevProjectId) {
895
- const wset = watcherSets.get(prevProjectId) || getDefaultWs();
896
- const multiWatcher = wset?.getMultiWatcher?.();
897
- if (multiWatcher) {
898
- multiWatcher.demotePipeline(prevRunId);
899
- }
900
- }
901
- ws.send(JSON.stringify(makeOk(req, { unsubscribed: true })));
902
- return;
903
- }
904
-
905
866
  // Unknown type
906
867
  ws.send(
907
868
  JSON.stringify(
@@ -15,9 +15,9 @@ import { readProjectWorcaVersion } from './worca-setup.js';
15
15
  import { createBroadcaster } from './ws-broadcaster.js';
16
16
  import { createClientManager } from './ws-client-manager.js';
17
17
  import { createMessageRouter } from './ws-message-router.js';
18
- import { resolveActiveRunDir } from './ws-status-watcher.js';
18
+ import { resolveLatestRunDir } from './ws-status-watcher.js';
19
19
 
20
- export { resolveActiveRunDir };
20
+ export { resolveLatestRunDir };
21
21
 
22
22
  /**
23
23
  * Attach a WebSocket server to an existing HTTP server.
@@ -300,6 +300,15 @@ export function attachWsServer(httpServer, config) {
300
300
  if (existsSync(runsPath) || existsSync(resultsPath)) {
301
301
  return projectId;
302
302
  }
303
+ const registryPath = join(
304
+ wset.worcaDir,
305
+ 'multi',
306
+ 'pipelines.d',
307
+ `${runId}.json`,
308
+ );
309
+ if (existsSync(registryPath)) {
310
+ return projectId;
311
+ }
303
312
  }
304
313
  return null;
305
314
  }