@worca/ui 0.11.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/app/main.bundle.js +993 -861
- package/app/main.bundle.js.map +4 -4
- package/app/protocol.js +1 -7
- package/app/styles.css +58 -0
- package/package.json +1 -1
- package/server/process-manager.js +118 -79
- package/server/project-routes.js +6 -90
- package/server/watcher-set.js +3 -44
- package/server/watcher.js +112 -43
- package/server/worktrees-routes.js +349 -0
- package/server/ws-log-watcher.js +3 -3
- package/server/ws-message-router.js +0 -50
- package/server/ws-modular.js +11 -2
- package/server/ws-status-watcher.js +187 -23
- package/server/ws.js +1 -1
- package/server/multi-watcher.js +0 -237
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
+
}
|
package/server/ws-log-watcher.js
CHANGED
|
@@ -17,14 +17,14 @@ import {
|
|
|
17
17
|
/**
|
|
18
18
|
* @param {{
|
|
19
19
|
* broadcaster: { broadcastToLogSubscribers: Function },
|
|
20
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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(
|
package/server/ws-modular.js
CHANGED
|
@@ -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 {
|
|
18
|
+
import { resolveLatestRunDir } from './ws-status-watcher.js';
|
|
19
19
|
|
|
20
|
-
export {
|
|
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
|
}
|