@worca/ui 0.12.0 → 0.13.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/server/watcher.js CHANGED
@@ -49,28 +49,7 @@ export function discoverRuns(worcaDir) {
49
49
  const runs = [];
50
50
  const seenIds = new Set();
51
51
 
52
- // 1. Check active_run pointer for the current run
53
- const activeRunPath = join(worcaDir, 'active_run');
54
- if (existsSync(activeRunPath)) {
55
- try {
56
- const activeId = readFileSync(activeRunPath, 'utf8').trim();
57
- const runDir = join(worcaDir, 'runs', activeId);
58
- const candidate = join(runDir, 'status.json');
59
- if (existsSync(candidate)) {
60
- let status = JSON.parse(readFileSync(candidate, 'utf8'));
61
- status = enrichWithDispatchEvents(status, runDir);
62
- const active =
63
- !isTerminal(status) && status.pipeline_status === 'running';
64
- const id = createRunId(status);
65
- runs.push({ id, active, ...status });
66
- seenIds.add(id);
67
- }
68
- } catch {
69
- /* ignore */
70
- }
71
- }
72
-
73
- // 2. Scan .worca/runs/ for other runs
52
+ // 1. Scan .worca/runs/ for runs
74
53
  const runsDir = join(worcaDir, 'runs');
75
54
  if (existsSync(runsDir)) {
76
55
  for (const entry of readdirSync(runsDir)) {
@@ -92,7 +71,7 @@ export function discoverRuns(worcaDir) {
92
71
  }
93
72
  }
94
73
 
95
- // 3. Legacy: flat .worca/status.json
74
+ // 2. Legacy: flat .worca/status.json
96
75
  const statusPath = join(worcaDir, 'status.json');
97
76
  if (existsSync(statusPath)) {
98
77
  try {
@@ -109,7 +88,7 @@ export function discoverRuns(worcaDir) {
109
88
  }
110
89
  }
111
90
 
112
- // 4. Results: handle both dir format (results/{id}/status.json) and file format (results/{id}.json)
91
+ // 3. Results: handle both dir format (results/{id}/status.json) and file format (results/{id}.json)
113
92
  const resultsDir = join(worcaDir, 'results');
114
93
  if (existsSync(resultsDir)) {
115
94
  for (const entry of readdirSync(resultsDir, { withFileTypes: true })) {
@@ -148,6 +127,51 @@ export function discoverRuns(worcaDir) {
148
127
  }
149
128
  }
150
129
 
130
+ // 4. Fan out across pipelines.d/ registry entries (worktree runs)
131
+ const pipelinesDir = join(worcaDir, 'multi', 'pipelines.d');
132
+ if (existsSync(pipelinesDir)) {
133
+ for (const entry of readdirSync(pipelinesDir)) {
134
+ if (!entry.endsWith('.json')) continue;
135
+ try {
136
+ const reg = JSON.parse(readFileSync(join(pipelinesDir, entry), 'utf8'));
137
+ if (!reg.worktree_path) continue;
138
+ const wtRunsDir = join(reg.worktree_path, '.worca', 'runs');
139
+ if (!existsSync(wtRunsDir)) continue;
140
+ for (const runEntry of readdirSync(wtRunsDir)) {
141
+ const sp = join(wtRunsDir, runEntry, 'status.json');
142
+ if (!existsSync(sp)) continue;
143
+ try {
144
+ let status = JSON.parse(readFileSync(sp, 'utf8'));
145
+ status = enrichWithDispatchEvents(
146
+ status,
147
+ join(wtRunsDir, runEntry),
148
+ );
149
+ const id = createRunId(status);
150
+ if (seenIds.has(id)) continue;
151
+ seenIds.add(id);
152
+ const active =
153
+ !isTerminal(status) && status.pipeline_status === 'running';
154
+ runs.push({
155
+ id,
156
+ active,
157
+ ...status,
158
+ worktree_worca_dir: join(reg.worktree_path, '.worca'),
159
+ is_worktree_run: true,
160
+ fleet_id: reg.fleet_id || null,
161
+ workspace_id: reg.workspace_id || null,
162
+ group_type: reg.group_type || null,
163
+ target_branch: reg.target_branch || null,
164
+ });
165
+ } catch {
166
+ /* ignore malformed status */
167
+ }
168
+ }
169
+ } catch {
170
+ /* ignore malformed registry entry */
171
+ }
172
+ }
173
+ }
174
+
151
175
  return runs;
152
176
  }
153
177
 
@@ -159,23 +183,7 @@ export async function discoverRunsAsync(worcaDir) {
159
183
  const runs = [];
160
184
  const seenIds = new Set();
161
185
 
162
- // 1. Active run
163
- const activeRunPath = join(worcaDir, 'active_run');
164
- try {
165
- const activeId = (await readFile(activeRunPath, 'utf8')).trim();
166
- const runDir = join(worcaDir, 'runs', activeId);
167
- const candidate = join(runDir, 'status.json');
168
- let status = JSON.parse(await readFile(candidate, 'utf8'));
169
- status = enrichWithDispatchEvents(status, runDir);
170
- const active = !isTerminal(status) && status.pipeline_status === 'running';
171
- const id = createRunId(status);
172
- runs.push({ id, active, ...status });
173
- seenIds.add(id);
174
- } catch {
175
- /* ignore */
176
- }
177
-
178
- // 2. Scan .worca/runs/
186
+ // 1. Scan .worca/runs/
179
187
  const runsDir = join(worcaDir, 'runs');
180
188
  try {
181
189
  const entries = await readdir(runsDir);
@@ -203,7 +211,7 @@ export async function discoverRunsAsync(worcaDir) {
203
211
  /* ignore */
204
212
  }
205
213
 
206
- // 3. Legacy flat status.json
214
+ // 2. Legacy flat status.json
207
215
  try {
208
216
  const status = JSON.parse(
209
217
  await readFile(join(worcaDir, 'status.json'), 'utf8'),
@@ -219,7 +227,7 @@ export async function discoverRunsAsync(worcaDir) {
219
227
  /* ignore */
220
228
  }
221
229
 
222
- // 4. Results
230
+ // 3. Results
223
231
  const resultsDir = join(worcaDir, 'results');
224
232
  try {
225
233
  const entries = await readdir(resultsDir, { withFileTypes: true });
@@ -252,6 +260,67 @@ export async function discoverRunsAsync(worcaDir) {
252
260
  /* ignore */
253
261
  }
254
262
 
263
+ // 4. Fan out across pipelines.d/ registry entries (worktree runs)
264
+ const pipelinesDirAsync = join(worcaDir, 'multi', 'pipelines.d');
265
+ try {
266
+ const regEntries = await readdir(pipelinesDirAsync);
267
+ const wtReadPromises = regEntries
268
+ .filter((e) => e.endsWith('.json'))
269
+ .map(async (e) => {
270
+ try {
271
+ const reg = JSON.parse(
272
+ await readFile(join(pipelinesDirAsync, e), 'utf8'),
273
+ );
274
+ if (!reg.worktree_path) return [];
275
+ const wtRunsDir = join(reg.worktree_path, '.worca', 'runs');
276
+ let runEntries;
277
+ try {
278
+ runEntries = await readdir(wtRunsDir);
279
+ } catch {
280
+ return [];
281
+ }
282
+ const results = [];
283
+ for (const runEntry of runEntries) {
284
+ try {
285
+ const sp = join(wtRunsDir, runEntry, 'status.json');
286
+ let status = JSON.parse(await readFile(sp, 'utf8'));
287
+ status = enrichWithDispatchEvents(
288
+ status,
289
+ join(wtRunsDir, runEntry),
290
+ );
291
+ results.push({ status, reg });
292
+ } catch {
293
+ /* ignore */
294
+ }
295
+ }
296
+ return results;
297
+ } catch {
298
+ return [];
299
+ }
300
+ });
301
+ const wtResults = (await Promise.all(wtReadPromises)).flat();
302
+ for (const { status, reg } of wtResults) {
303
+ const id = createRunId(status);
304
+ if (seenIds.has(id)) continue;
305
+ seenIds.add(id);
306
+ const active =
307
+ !isTerminal(status) && status.pipeline_status === 'running';
308
+ runs.push({
309
+ id,
310
+ active,
311
+ ...status,
312
+ worktree_worca_dir: join(reg.worktree_path, '.worca'),
313
+ is_worktree_run: true,
314
+ fleet_id: reg.fleet_id || null,
315
+ workspace_id: reg.workspace_id || null,
316
+ group_type: reg.group_type || null,
317
+ target_branch: reg.target_branch || null,
318
+ });
319
+ }
320
+ } catch {
321
+ /* ignore */
322
+ }
323
+
255
324
  return runs;
256
325
  }
257
326
 
@@ -0,0 +1,349 @@
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 { execFileSync } from 'node:child_process';
11
+ import {
12
+ existsSync,
13
+ lstatSync,
14
+ readdirSync,
15
+ readFileSync,
16
+ rmSync,
17
+ statSync,
18
+ unlinkSync,
19
+ } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { Router } from 'express';
22
+
23
+ const RESUMABLE_STATUSES = new Set(['failed', 'paused', 'cancelled']);
24
+
25
+ // Disk usage cache — keyed by worktree path, expires after 30 s
26
+ const _diskCache = new Map();
27
+ const DISK_CACHE_TTL_MS = 30_000;
28
+
29
+ /**
30
+ * Sum file sizes under a directory tree. Cross-platform: prior `du -sb`
31
+ * relied on GNU coreutils and silently returned 0 on macOS / BSD du,
32
+ * which is why the Worktrees view always showed "0 B".
33
+ *
34
+ * Skips symlinks (don't follow into other trees) and is bounded by
35
+ * MAX_WALK_FILES so a runaway directory can't hang the request.
36
+ * Errors on individual entries are swallowed so a transiently-locked
37
+ * file doesn't poison the whole sum.
38
+ */
39
+ const MAX_WALK_FILES = 100_000;
40
+ function _walkDirSize(rootPath) {
41
+ let total = 0;
42
+ let count = 0;
43
+ const stack = [rootPath];
44
+ while (stack.length > 0 && count < MAX_WALK_FILES) {
45
+ const cur = stack.pop();
46
+ let entries;
47
+ try {
48
+ entries = readdirSync(cur, { withFileTypes: true });
49
+ } catch {
50
+ continue;
51
+ }
52
+ for (const e of entries) {
53
+ count++;
54
+ if (count >= MAX_WALK_FILES) break;
55
+ const child = join(cur, e.name);
56
+ if (e.isSymbolicLink()) continue;
57
+ if (e.isDirectory()) {
58
+ stack.push(child);
59
+ } else if (e.isFile()) {
60
+ try {
61
+ total += statSync(child).size;
62
+ } catch {
63
+ /* ignore — file vanished mid-walk */
64
+ }
65
+ }
66
+ }
67
+ }
68
+ return total;
69
+ }
70
+
71
+ function _getDiskBytes(worktreePath) {
72
+ const now = Date.now();
73
+ const hit = _diskCache.get(worktreePath);
74
+ if (hit && hit.expiry > now) return hit.bytes;
75
+
76
+ let bytes = 0;
77
+ try {
78
+ bytes = _walkDirSize(worktreePath);
79
+ } catch {
80
+ bytes = 0;
81
+ }
82
+ _diskCache.set(worktreePath, { bytes, expiry: now + DISK_CACHE_TTL_MS });
83
+ return bytes;
84
+ }
85
+
86
+ /**
87
+ * Read pipeline_status from a worktree's status.json files.
88
+ * Checks .worca/runs/ (W-048 layout) then flat .worca/status.json (legacy).
89
+ * Returns null if no status file is found.
90
+ */
91
+ function _readWorktreeStatus(worktreePath) {
92
+ const runsDir = join(worktreePath, '.worca', 'runs');
93
+ if (existsSync(runsDir)) {
94
+ for (const entry of readdirSync(runsDir)) {
95
+ const sp = join(runsDir, entry, 'status.json');
96
+ if (!existsSync(sp)) continue;
97
+ try {
98
+ const data = JSON.parse(readFileSync(sp, 'utf8'));
99
+ if (data.pipeline_status) return data.pipeline_status;
100
+ } catch {
101
+ /* ignore malformed */
102
+ }
103
+ }
104
+ }
105
+
106
+ const flat = join(worktreePath, '.worca', 'status.json');
107
+ if (existsSync(flat)) {
108
+ try {
109
+ const data = JSON.parse(readFileSync(flat, 'utf8'));
110
+ if (data.pipeline_status) return data.pipeline_status;
111
+ } catch {
112
+ /* ignore malformed */
113
+ }
114
+ }
115
+
116
+ return null;
117
+ }
118
+
119
+ function _listWorktrees(worcaDir) {
120
+ const pipelinesDir = join(worcaDir, 'multi', 'pipelines.d');
121
+ if (!existsSync(pipelinesDir)) return [];
122
+
123
+ const entries = [];
124
+ for (const file of readdirSync(pipelinesDir)) {
125
+ if (!file.endsWith('.json')) continue;
126
+
127
+ let reg;
128
+ try {
129
+ reg = JSON.parse(readFileSync(join(pipelinesDir, file), 'utf8'));
130
+ } catch {
131
+ continue;
132
+ }
133
+ if (!reg.worktree_path) continue;
134
+
135
+ const worktreePath = reg.worktree_path;
136
+ const worktreeExists = existsSync(worktreePath);
137
+
138
+ // Prefer actual status.json; fall back to registry field
139
+ let status = reg.status || 'unknown';
140
+ if (worktreeExists) {
141
+ const actual = _readWorktreeStatus(worktreePath);
142
+ if (actual) status = actual;
143
+ }
144
+
145
+ let ageSeconds = 0;
146
+ if (reg.started_at) {
147
+ const started = new Date(reg.started_at).getTime();
148
+ if (!Number.isNaN(started)) {
149
+ ageSeconds = Math.max(0, Math.floor((Date.now() - started) / 1_000));
150
+ }
151
+ }
152
+
153
+ entries.push({
154
+ run_id: reg.run_id || '',
155
+ title: reg.title || '',
156
+ branch: reg.branch || '',
157
+ worktree_path: worktreePath,
158
+ disk_bytes: worktreeExists ? _getDiskBytes(worktreePath) : 0,
159
+ age_seconds: ageSeconds,
160
+ // started_at lets the client sort with the same sortByStartDesc helper
161
+ // used by run-list, keeping ordering consistent across views.
162
+ started_at: reg.started_at || null,
163
+ status,
164
+ removable: status !== 'running',
165
+ fleet_id: reg.fleet_id || null,
166
+ workspace_id: reg.workspace_id || null,
167
+ group_type: reg.group_type || null,
168
+ group_status: null, // populated by W-040 / W-047
169
+ resumable: RESUMABLE_STATUSES.has(status),
170
+ });
171
+ }
172
+ return entries;
173
+ }
174
+
175
+ /**
176
+ * Remove a worktree and its registry entry.
177
+ * Mirrors WorktreeSource.remove from src/worca/cli/cleanup.py:
178
+ * 1. Attempt `git worktree remove --force <path>` from the project root
179
+ * 2. On failure (e.g. non-worktree temp dir in tests), fall back to rmSync
180
+ * 3. Run `git worktree prune` so git's metadata (`.git/worktrees/<id>/`)
181
+ * drops the entry even when the directory was removed manually
182
+ * 4. Delete the registry file
183
+ */
184
+ function _removeWorktree(worcaDir, runId) {
185
+ const regFile = join(worcaDir, 'multi', 'pipelines.d', `${runId}.json`);
186
+ // worcaDir is `<projectRoot>/.worca` — git commands must run inside the
187
+ // project repo, not the server's cwd, or `git worktree remove` errors out
188
+ // and the .git/worktrees/<id>/ metadata is left as `prunable`.
189
+ const projectRoot = join(worcaDir, '..');
190
+ let worktreePath = null;
191
+
192
+ if (existsSync(regFile)) {
193
+ try {
194
+ const reg = JSON.parse(readFileSync(regFile, 'utf8'));
195
+ worktreePath = reg.worktree_path || null;
196
+ } catch {
197
+ /* ignore */
198
+ }
199
+ }
200
+
201
+ if (worktreePath && existsSync(worktreePath)) {
202
+ try {
203
+ execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
204
+ cwd: projectRoot,
205
+ stdio: 'pipe',
206
+ timeout: 30_000,
207
+ });
208
+ } catch {
209
+ // Path is not a registered git worktree — brute-force remove.
210
+ // Refuse to follow symlinks: rmSync on a symlink to a real directory
211
+ // would delete the link itself (good), but we don't want to risk a
212
+ // user-symlinked path here being mistaken for a worktree we own.
213
+ let isRealDir = false;
214
+ try {
215
+ const st = lstatSync(worktreePath);
216
+ isRealDir = st.isDirectory() && !st.isSymbolicLink();
217
+ } catch {
218
+ /* ignore */
219
+ }
220
+ if (isRealDir) {
221
+ rmSync(worktreePath, { recursive: true, force: true });
222
+ }
223
+ }
224
+ }
225
+
226
+ // Always prune — covers (a) successful remove leaving residual metadata,
227
+ // (b) brute-force rmSync path, and (c) entries already left prunable by
228
+ // earlier failures. Errors are non-fatal (e.g. project not a git repo).
229
+ try {
230
+ execFileSync('git', ['worktree', 'prune'], {
231
+ cwd: projectRoot,
232
+ stdio: 'pipe',
233
+ timeout: 30_000,
234
+ });
235
+ } catch {
236
+ /* non-fatal */
237
+ }
238
+
239
+ if (existsSync(regFile)) {
240
+ unlinkSync(regFile);
241
+ }
242
+ }
243
+
244
+ const RUN_ID_RE = /^[a-zA-Z0-9_-]+$/;
245
+ function _validateRunId(runId) {
246
+ return (
247
+ typeof runId === 'string' &&
248
+ runId.length > 0 &&
249
+ runId.length <= 128 &&
250
+ RUN_ID_RE.test(runId)
251
+ );
252
+ }
253
+
254
+ /**
255
+ * Create the worktrees REST router.
256
+ * Mount with: router.use('/worktrees', requireWorcaDir, createWorktreesRouter())
257
+ */
258
+ export function createWorktreesRouter() {
259
+ const router = Router({ mergeParams: true });
260
+
261
+ // GET /worktrees
262
+ router.get('/', (req, res) => {
263
+ const worcaDir = req.project?.worcaDir;
264
+ if (!worcaDir) {
265
+ return res
266
+ .status(501)
267
+ .json({ ok: false, error: 'worcaDir not configured' });
268
+ }
269
+ try {
270
+ const worktrees = _listWorktrees(worcaDir);
271
+ res.json({ ok: true, worktrees });
272
+ } catch (err) {
273
+ res.status(500).json({ ok: false, error: err.message });
274
+ }
275
+ });
276
+
277
+ // DELETE /worktrees/:run_id
278
+ router.delete('/:run_id', (req, res) => {
279
+ const worcaDir = req.project?.worcaDir;
280
+ if (!worcaDir) {
281
+ return res
282
+ .status(501)
283
+ .json({ ok: false, error: 'worcaDir not configured' });
284
+ }
285
+
286
+ const { run_id } = req.params;
287
+ if (!_validateRunId(run_id)) {
288
+ return res.status(400).json({ ok: false, error: 'Invalid run ID' });
289
+ }
290
+
291
+ const force = req.query.force === '1';
292
+
293
+ try {
294
+ const regFile = join(worcaDir, 'multi', 'pipelines.d', `${run_id}.json`);
295
+ if (!existsSync(regFile)) {
296
+ return res
297
+ .status(404)
298
+ .json({ ok: false, error: `Worktree "${run_id}" not found` });
299
+ }
300
+
301
+ let reg;
302
+ try {
303
+ reg = JSON.parse(readFileSync(regFile, 'utf8'));
304
+ } catch {
305
+ return res
306
+ .status(500)
307
+ .json({ ok: false, error: 'Failed to read registry entry' });
308
+ }
309
+
310
+ // Derive actual pipeline status
311
+ let status = reg.status || 'unknown';
312
+ if (reg.worktree_path && existsSync(reg.worktree_path)) {
313
+ const actual = _readWorktreeStatus(reg.worktree_path);
314
+ if (actual) status = actual;
315
+ }
316
+
317
+ // 409 — cannot remove a running worktree
318
+ if (status === 'running') {
319
+ return res.status(409).json({
320
+ ok: false,
321
+ error: 'Cannot remove a running worktree',
322
+ code: 'running',
323
+ });
324
+ }
325
+
326
+ // 412 — resumable or grouped run requires ?force=1 confirmation
327
+ const isResumable = RESUMABLE_STATUSES.has(status);
328
+ const isGrouped = !!(reg.fleet_id || reg.workspace_id);
329
+ if (!force && (isResumable || isGrouped)) {
330
+ return res.status(412).json({
331
+ ok: false,
332
+ error:
333
+ 'Removing this worktree prevents resuming the run. Use ?force=1 to confirm.',
334
+ code: 'resumable_or_grouped',
335
+ resumable: isResumable,
336
+ fleet_id: reg.fleet_id || null,
337
+ workspace_id: reg.workspace_id || null,
338
+ });
339
+ }
340
+
341
+ _removeWorktree(worcaDir, run_id);
342
+ res.json({ ok: true, run_id });
343
+ } catch (err) {
344
+ res.status(500).json({ ok: false, error: err.message });
345
+ }
346
+ });
347
+
348
+ return router;
349
+ }
@@ -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
 
@@ -852,56 +852,6 @@ export function createMessageRouter({
852
852
  return;
853
853
  }
854
854
 
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
855
  // Unknown type
906
856
  ws.send(
907
857
  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
  }