@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/app/main.bundle.js +984 -852
- 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
|
@@ -1,32 +1,64 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Status file watcher — monitors status.json and
|
|
3
|
-
* Owns refresh scheduling, lastPipelineStatus tracking, and the status/
|
|
2
|
+
* Status file watcher — monitors status.json and runs/ directory for changes.
|
|
3
|
+
* Owns refresh scheduling, lastPipelineStatus tracking, and the status/runsDirWatcher FSWatchers.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
watch,
|
|
12
|
+
} from 'node:fs';
|
|
7
13
|
import { join } from 'node:path';
|
|
8
14
|
import { readSettings } from './settings-reader.js';
|
|
9
15
|
import { discoverRunsAsync } from './watcher.js';
|
|
10
16
|
|
|
11
17
|
const REFRESH_DEBOUNCE_MS = 75;
|
|
18
|
+
const WORKTREE_WATCHER_THRESHOLD = 50;
|
|
19
|
+
const WORKTREE_POLL_MS = 30_000;
|
|
20
|
+
// Display-layer: broadest set — any status that means "stop watching this run".
|
|
21
|
+
// Differs from runner/resume (which exclude 'failed' to keep it resumable) and
|
|
22
|
+
// cleanup ({completed, failed}). Here we add 'error' so the UI also stops polling
|
|
23
|
+
// pipelines that crashed before reaching a clean terminal state.
|
|
24
|
+
const TERMINAL_STATUSES = new Set([
|
|
25
|
+
'completed',
|
|
26
|
+
'failed',
|
|
27
|
+
'error',
|
|
28
|
+
'interrupted',
|
|
29
|
+
]);
|
|
12
30
|
|
|
13
31
|
/**
|
|
14
|
-
* Resolve the active run directory for a given worca base dir.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
32
|
+
* Resolve the latest active run directory for a given worca base dir.
|
|
33
|
+
* Scans runs/<runId>/pipeline.pid for live processes via process.kill(pid, 0).
|
|
34
|
+
* Returns the run dir of the latest live run (by run ID), or worcaDir as fallback.
|
|
17
35
|
*
|
|
18
36
|
* @param {string} worcaDir
|
|
19
37
|
* @returns {string}
|
|
20
38
|
*/
|
|
21
|
-
export function
|
|
22
|
-
const
|
|
23
|
-
if (existsSync(
|
|
39
|
+
export function resolveLatestRunDir(worcaDir) {
|
|
40
|
+
const runsDir = join(worcaDir, 'runs');
|
|
41
|
+
if (existsSync(runsDir)) {
|
|
42
|
+
let latest = null;
|
|
24
43
|
try {
|
|
25
|
-
const
|
|
26
|
-
|
|
44
|
+
for (const entry of readdirSync(runsDir, { withFileTypes: true })) {
|
|
45
|
+
if (!entry.isDirectory()) continue;
|
|
46
|
+
const pidPath = join(runsDir, entry.name, 'pipeline.pid');
|
|
47
|
+
if (!existsSync(pidPath)) continue;
|
|
48
|
+
try {
|
|
49
|
+
const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
|
|
50
|
+
if (!Number.isNaN(pid) && pid > 0) {
|
|
51
|
+
process.kill(pid, 0); // throws if dead
|
|
52
|
+
if (!latest || entry.name > latest) latest = entry.name;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
/* dead process or invalid PID */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
27
58
|
} catch {
|
|
28
59
|
/* ignore */
|
|
29
60
|
}
|
|
61
|
+
if (latest) return join(runsDir, latest);
|
|
30
62
|
}
|
|
31
63
|
return worcaDir; // legacy fallback
|
|
32
64
|
}
|
|
@@ -57,14 +89,118 @@ export function createStatusWatcher({
|
|
|
57
89
|
let watchedRunDir = null;
|
|
58
90
|
let activeRunWatcher = null;
|
|
59
91
|
let runsDirWatcher = null;
|
|
92
|
+
let pipelinesDirWatcher = null;
|
|
93
|
+
const worktreeRunWatchers = new Map(); // Map<run_id, FSWatcher>
|
|
94
|
+
let worktreePollingInterval = null;
|
|
60
95
|
|
|
61
96
|
function currentActiveRunId() {
|
|
62
97
|
if (!watchedRunDir) return null;
|
|
63
98
|
return watchedRunDir.split('/').pop() || null;
|
|
64
99
|
}
|
|
65
100
|
|
|
66
|
-
function
|
|
67
|
-
return
|
|
101
|
+
function _resolveLatestRunDir() {
|
|
102
|
+
return resolveLatestRunDir(worcaDir);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function reconcileWorktreeWatchers() {
|
|
106
|
+
const pipelinesDirPath = join(worcaDir, 'multi', 'pipelines.d');
|
|
107
|
+
if (!existsSync(pipelinesDirPath)) {
|
|
108
|
+
for (const w of worktreeRunWatchers.values()) {
|
|
109
|
+
try {
|
|
110
|
+
w.close();
|
|
111
|
+
} catch {
|
|
112
|
+
/* ignore */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
worktreeRunWatchers.clear();
|
|
116
|
+
if (worktreePollingInterval) {
|
|
117
|
+
clearInterval(worktreePollingInterval);
|
|
118
|
+
worktreePollingInterval = null;
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Read all non-terminal entries from pipelines.d/
|
|
124
|
+
const activeEntries = new Map(); // run_id -> reg
|
|
125
|
+
try {
|
|
126
|
+
for (const entry of readdirSync(pipelinesDirPath)) {
|
|
127
|
+
if (!entry.endsWith('.json')) continue;
|
|
128
|
+
try {
|
|
129
|
+
const reg = JSON.parse(
|
|
130
|
+
readFileSync(join(pipelinesDirPath, entry), 'utf8'),
|
|
131
|
+
);
|
|
132
|
+
if (
|
|
133
|
+
reg.run_id &&
|
|
134
|
+
reg.worktree_path &&
|
|
135
|
+
!TERMINAL_STATUSES.has(reg.status)
|
|
136
|
+
) {
|
|
137
|
+
activeEntries.set(reg.run_id, reg);
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
/* ignore malformed */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
/* ignore */
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// >50 concurrent worktrees: fall back to periodic polling
|
|
148
|
+
if (activeEntries.size > WORKTREE_WATCHER_THRESHOLD) {
|
|
149
|
+
for (const w of worktreeRunWatchers.values()) {
|
|
150
|
+
try {
|
|
151
|
+
w.close();
|
|
152
|
+
} catch {
|
|
153
|
+
/* ignore */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
worktreeRunWatchers.clear();
|
|
157
|
+
if (!worktreePollingInterval) {
|
|
158
|
+
worktreePollingInterval = setInterval(
|
|
159
|
+
() => scheduleRefresh(),
|
|
160
|
+
WORKTREE_POLL_MS,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Below threshold: stop polling if it was running
|
|
167
|
+
if (worktreePollingInterval) {
|
|
168
|
+
clearInterval(worktreePollingInterval);
|
|
169
|
+
worktreePollingInterval = null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Remove watchers for entries no longer active
|
|
173
|
+
for (const [runId, w] of worktreeRunWatchers) {
|
|
174
|
+
if (!activeEntries.has(runId)) {
|
|
175
|
+
try {
|
|
176
|
+
w.close();
|
|
177
|
+
} catch {
|
|
178
|
+
/* ignore */
|
|
179
|
+
}
|
|
180
|
+
worktreeRunWatchers.delete(runId);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Add watchers for new active entries
|
|
185
|
+
for (const [runId, reg] of activeEntries) {
|
|
186
|
+
if (worktreeRunWatchers.has(runId)) continue;
|
|
187
|
+
const wtRunsDir = join(reg.worktree_path, '.worca', 'runs');
|
|
188
|
+
if (!existsSync(wtRunsDir)) continue;
|
|
189
|
+
try {
|
|
190
|
+
const w = watch(
|
|
191
|
+
wtRunsDir,
|
|
192
|
+
{ recursive: true },
|
|
193
|
+
(_eventType, filename) => {
|
|
194
|
+
if (!filename || filename.endsWith('status.json')) {
|
|
195
|
+
scheduleRefresh();
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
worktreeRunWatchers.set(runId, w);
|
|
200
|
+
} catch {
|
|
201
|
+
/* ignore */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
68
204
|
}
|
|
69
205
|
|
|
70
206
|
function scheduleRefresh() {
|
|
@@ -79,6 +215,7 @@ export function createStatusWatcher({
|
|
|
79
215
|
}
|
|
80
216
|
try {
|
|
81
217
|
const runs = await discoverRunsAsync(worcaDir);
|
|
218
|
+
reconcileWorktreeWatchers();
|
|
82
219
|
const subscribedIds = new Set();
|
|
83
220
|
for (const ws of wss.clients) {
|
|
84
221
|
const s = getSubs(ws);
|
|
@@ -128,7 +265,7 @@ export function createStatusWatcher({
|
|
|
128
265
|
statusWatcher.close();
|
|
129
266
|
statusWatcher = null;
|
|
130
267
|
}
|
|
131
|
-
const runDir =
|
|
268
|
+
const runDir = _resolveLatestRunDir();
|
|
132
269
|
if (watchedRunDir !== null && runDir !== watchedRunDir) {
|
|
133
270
|
if (onActiveRunChange) onActiveRunChange();
|
|
134
271
|
}
|
|
@@ -181,7 +318,7 @@ export function createStatusWatcher({
|
|
|
181
318
|
);
|
|
182
319
|
} else {
|
|
183
320
|
setTimeout(() => {
|
|
184
|
-
if (
|
|
321
|
+
if (_resolveLatestRunDir() === runDir) tryWatch();
|
|
185
322
|
}, 500);
|
|
186
323
|
}
|
|
187
324
|
} catch {
|
|
@@ -195,19 +332,15 @@ export function createStatusWatcher({
|
|
|
195
332
|
// Initialize status watcher
|
|
196
333
|
setupStatusWatcher();
|
|
197
334
|
|
|
198
|
-
// Watch worcaDir for
|
|
335
|
+
// Watch worcaDir for legacy status.json changes
|
|
199
336
|
try {
|
|
200
337
|
if (existsSync(worcaDir)) {
|
|
201
338
|
activeRunWatcher = watch(
|
|
202
339
|
worcaDir,
|
|
203
340
|
{ recursive: false },
|
|
204
341
|
(_eventType, filename) => {
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
filename === 'active_run' ||
|
|
208
|
-
filename === 'status.json'
|
|
209
|
-
) {
|
|
210
|
-
const newRunDir = _resolveActiveRunDir();
|
|
342
|
+
if (!filename || filename === 'status.json') {
|
|
343
|
+
const newRunDir = _resolveLatestRunDir();
|
|
211
344
|
if (newRunDir !== watchedRunDir) {
|
|
212
345
|
setupStatusWatcher();
|
|
213
346
|
}
|
|
@@ -238,6 +371,24 @@ export function createStatusWatcher({
|
|
|
238
371
|
/* ignore */
|
|
239
372
|
}
|
|
240
373
|
|
|
374
|
+
// Watch .worca/multi/pipelines.d/ for pipeline additions/removals.
|
|
375
|
+
// Create the directory eagerly so the watcher fires even on first worktree run.
|
|
376
|
+
const pipelinesDirPath = join(worcaDir, 'multi', 'pipelines.d');
|
|
377
|
+
try {
|
|
378
|
+
mkdirSync(pipelinesDirPath, { recursive: true });
|
|
379
|
+
pipelinesDirWatcher = watch(
|
|
380
|
+
pipelinesDirPath,
|
|
381
|
+
{ recursive: false },
|
|
382
|
+
(_eventType, filename) => {
|
|
383
|
+
if (!filename || filename.endsWith('.json')) {
|
|
384
|
+
scheduleRefresh();
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
} catch {
|
|
389
|
+
/* ignore */
|
|
390
|
+
}
|
|
391
|
+
|
|
241
392
|
function getWatchedRunDir() {
|
|
242
393
|
return watchedRunDir;
|
|
243
394
|
}
|
|
@@ -246,12 +397,25 @@ export function createStatusWatcher({
|
|
|
246
397
|
if (statusWatcher) statusWatcher.close();
|
|
247
398
|
if (activeRunWatcher) activeRunWatcher.close();
|
|
248
399
|
if (runsDirWatcher) runsDirWatcher.close();
|
|
400
|
+
if (pipelinesDirWatcher) pipelinesDirWatcher.close();
|
|
401
|
+
for (const w of worktreeRunWatchers.values()) {
|
|
402
|
+
try {
|
|
403
|
+
w.close();
|
|
404
|
+
} catch {
|
|
405
|
+
/* ignore */
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
worktreeRunWatchers.clear();
|
|
409
|
+
if (worktreePollingInterval) {
|
|
410
|
+
clearInterval(worktreePollingInterval);
|
|
411
|
+
worktreePollingInterval = null;
|
|
412
|
+
}
|
|
249
413
|
}
|
|
250
414
|
|
|
251
415
|
return {
|
|
252
416
|
scheduleRefresh,
|
|
253
417
|
currentActiveRunId,
|
|
254
|
-
|
|
418
|
+
resolveLatestRunDir: _resolveLatestRunDir,
|
|
255
419
|
getWatchedRunDir,
|
|
256
420
|
lastPipelineStatus,
|
|
257
421
|
destroy,
|
package/server/ws.js
CHANGED
package/server/multi-watcher.js
DELETED
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MultiWatcher — watches .worca/multi/pipelines.d/ for a project,
|
|
3
|
-
* tracking parallel pipeline instances and their status changes.
|
|
4
|
-
*
|
|
5
|
-
* Each pipeline in pipelines.d/{run_id}.json is monitored. On status
|
|
6
|
-
* changes, broadcasts 'pipeline-status-changed' events. Optionally
|
|
7
|
-
* creates per-worktree WatcherSets for log/status streaming.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { existsSync, watch } from 'node:fs';
|
|
11
|
-
import { readdir, readFile } from 'node:fs/promises';
|
|
12
|
-
import { join } from 'node:path';
|
|
13
|
-
import { TIER_FULL, TIER_POLLING, WatcherSet } from './watcher-set.js';
|
|
14
|
-
|
|
15
|
-
export class MultiWatcher {
|
|
16
|
-
/**
|
|
17
|
-
* @param {string} projectId — parent project name
|
|
18
|
-
* @param {string} worcaDir — parent project's .worca/ directory
|
|
19
|
-
* @param {{ broadcaster, getSubs, wss, settingsPath, projectRoot, webhookInbox }} deps
|
|
20
|
-
*/
|
|
21
|
-
constructor(projectId, worcaDir, deps) {
|
|
22
|
-
this.projectId = projectId;
|
|
23
|
-
this.worcaDir = worcaDir;
|
|
24
|
-
this._deps = deps;
|
|
25
|
-
this._dirWatcher = null;
|
|
26
|
-
this._debounceTimer = null;
|
|
27
|
-
this._closed = false;
|
|
28
|
-
|
|
29
|
-
/** @type {Map<string, { entry: object, watcherSet: WatcherSet|null }>} */
|
|
30
|
-
this.pipelines = new Map();
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Start watching pipelines.d/ directory. */
|
|
34
|
-
start() {
|
|
35
|
-
this._syncPipelines(); // Initial scan
|
|
36
|
-
|
|
37
|
-
const pipelinesDir = join(this.worcaDir, 'multi', 'pipelines.d');
|
|
38
|
-
if (existsSync(pipelinesDir)) {
|
|
39
|
-
try {
|
|
40
|
-
this._dirWatcher = watch(pipelinesDir, { persistent: false }, () => {
|
|
41
|
-
if (this._closed) return;
|
|
42
|
-
if (this._debounceTimer) clearTimeout(this._debounceTimer);
|
|
43
|
-
this._debounceTimer = setTimeout(() => {
|
|
44
|
-
this._debounceTimer = null;
|
|
45
|
-
if (!this._closed) this._syncPipelines();
|
|
46
|
-
}, 300);
|
|
47
|
-
});
|
|
48
|
-
} catch {
|
|
49
|
-
// fs.watch not supported or dir doesn't exist — skip
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Scan pipelines.d/, diff against current map, broadcast changes. */
|
|
55
|
-
async _syncPipelines() {
|
|
56
|
-
const pipelinesDir = join(this.worcaDir, 'multi', 'pipelines.d');
|
|
57
|
-
const freshEntries = new Map();
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
const files = await readdir(pipelinesDir);
|
|
61
|
-
const readPromises = files
|
|
62
|
-
.filter((f) => f.endsWith('.json'))
|
|
63
|
-
.map(async (fname) => {
|
|
64
|
-
try {
|
|
65
|
-
const entry = JSON.parse(
|
|
66
|
-
await readFile(join(pipelinesDir, fname), 'utf8'),
|
|
67
|
-
);
|
|
68
|
-
return entry.run_id ? [entry.run_id, entry] : null;
|
|
69
|
-
} catch {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
for (const result of await Promise.all(readPromises)) {
|
|
74
|
-
if (result) freshEntries.set(result[0], result[1]);
|
|
75
|
-
}
|
|
76
|
-
} catch {
|
|
77
|
-
// directory doesn't exist or unreadable — freshEntries stays empty
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Add new pipelines or update changed ones
|
|
81
|
-
for (const [runId, entry] of freshEntries) {
|
|
82
|
-
const existing = this.pipelines.get(runId);
|
|
83
|
-
if (!existing) {
|
|
84
|
-
this._addPipeline(runId, entry);
|
|
85
|
-
} else if (
|
|
86
|
-
existing.entry.status !== entry.status ||
|
|
87
|
-
existing.entry.stage !== entry.stage
|
|
88
|
-
) {
|
|
89
|
-
// Destroy WatcherSet when pipeline transitions out of running
|
|
90
|
-
if (
|
|
91
|
-
existing.entry.status === 'running' &&
|
|
92
|
-
entry.status !== 'running' &&
|
|
93
|
-
existing.watcherSet
|
|
94
|
-
) {
|
|
95
|
-
try {
|
|
96
|
-
existing.watcherSet.destroy();
|
|
97
|
-
} catch {
|
|
98
|
-
/* ignore */
|
|
99
|
-
}
|
|
100
|
-
existing.watcherSet = null;
|
|
101
|
-
}
|
|
102
|
-
existing.entry = entry;
|
|
103
|
-
this._broadcastPipelineStatus(runId, entry);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Remove deleted pipelines
|
|
108
|
-
for (const runId of [...this.pipelines.keys()]) {
|
|
109
|
-
if (!freshEntries.has(runId)) {
|
|
110
|
-
this._removePipeline(runId);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Register a new pipeline and broadcast its status. */
|
|
116
|
-
_addPipeline(runId, entry) {
|
|
117
|
-
let watcherSet = null;
|
|
118
|
-
|
|
119
|
-
// Create a WatcherSet for running worktree pipelines
|
|
120
|
-
if (entry.worktree_path && entry.status === 'running') {
|
|
121
|
-
const worktreeWorcaDir = join(entry.worktree_path, '.worca');
|
|
122
|
-
if (existsSync(worktreeWorcaDir)) {
|
|
123
|
-
try {
|
|
124
|
-
const pipelineProjectId = `${this.projectId}::${runId}`;
|
|
125
|
-
watcherSet = new WatcherSet(
|
|
126
|
-
pipelineProjectId,
|
|
127
|
-
worktreeWorcaDir,
|
|
128
|
-
{
|
|
129
|
-
...this._deps,
|
|
130
|
-
settingsPath: join(
|
|
131
|
-
entry.worktree_path,
|
|
132
|
-
'.claude',
|
|
133
|
-
'settings.json',
|
|
134
|
-
),
|
|
135
|
-
projectRoot: entry.worktree_path,
|
|
136
|
-
},
|
|
137
|
-
// Skip creating a nested MultiWatcher in pipeline WatcherSets
|
|
138
|
-
{ _skipMultiWatcher: true },
|
|
139
|
-
);
|
|
140
|
-
watcherSet.create();
|
|
141
|
-
// Start in POLLING tier — promoted when user subscribes
|
|
142
|
-
} catch (err) {
|
|
143
|
-
console.error(
|
|
144
|
-
`[MultiWatcher:${this.projectId}] Failed to create WatcherSet for pipeline ${runId}:`,
|
|
145
|
-
err.message,
|
|
146
|
-
);
|
|
147
|
-
watcherSet = null;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
this.pipelines.set(runId, { entry, watcherSet });
|
|
153
|
-
this._broadcastPipelineStatus(runId, entry);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Destroy a pipeline's WatcherSet and broadcast removal. */
|
|
157
|
-
_removePipeline(runId) {
|
|
158
|
-
const pipeline = this.pipelines.get(runId);
|
|
159
|
-
if (pipeline?.watcherSet) {
|
|
160
|
-
try {
|
|
161
|
-
pipeline.watcherSet.destroy();
|
|
162
|
-
} catch {
|
|
163
|
-
// ignore cleanup errors
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
this.pipelines.delete(runId);
|
|
167
|
-
this._deps.broadcaster.broadcast('pipeline-status-changed', {
|
|
168
|
-
project: this.projectId,
|
|
169
|
-
runId,
|
|
170
|
-
status: 'removed',
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Broadcast a pipeline status change event. */
|
|
175
|
-
_broadcastPipelineStatus(runId, entry) {
|
|
176
|
-
this._deps.broadcaster.broadcast('pipeline-status-changed', {
|
|
177
|
-
project: this.projectId,
|
|
178
|
-
runId,
|
|
179
|
-
status: entry.status,
|
|
180
|
-
stage: entry.stage || null,
|
|
181
|
-
title: entry.title || null,
|
|
182
|
-
worktree_path: entry.worktree_path || null,
|
|
183
|
-
started_at: entry.started_at || null,
|
|
184
|
-
pid: entry.pid || null,
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** List current pipeline entries (for list-pipelines WS request). */
|
|
189
|
-
listPipelines() {
|
|
190
|
-
return Array.from(this.pipelines.values()).map((p) => p.entry);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Get WatcherSet for a specific pipeline (for log/status streaming). */
|
|
194
|
-
getPipelineWatcherSet(runId) {
|
|
195
|
-
return this.pipelines.get(runId)?.watcherSet || null;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Promote a pipeline's watcher to FULL tier (on user subscribe). */
|
|
199
|
-
promotePipeline(runId) {
|
|
200
|
-
const ws = this.pipelines.get(runId)?.watcherSet;
|
|
201
|
-
if (ws && ws.getTier() === TIER_POLLING) ws.setTier(TIER_FULL);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Demote a pipeline's watcher back to POLLING tier (on user unsubscribe). */
|
|
205
|
-
demotePipeline(runId) {
|
|
206
|
-
const ws = this.pipelines.get(runId)?.watcherSet;
|
|
207
|
-
if (ws && ws.getTier() === TIER_FULL) ws.setTier(TIER_POLLING);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** Destroy all pipeline watchers and close directory watcher. Idempotent. */
|
|
211
|
-
destroy() {
|
|
212
|
-
if (this._closed) return;
|
|
213
|
-
this._closed = true;
|
|
214
|
-
if (this._dirWatcher) {
|
|
215
|
-
try {
|
|
216
|
-
this._dirWatcher.close();
|
|
217
|
-
} catch {
|
|
218
|
-
// ignore
|
|
219
|
-
}
|
|
220
|
-
this._dirWatcher = null;
|
|
221
|
-
}
|
|
222
|
-
if (this._debounceTimer) {
|
|
223
|
-
clearTimeout(this._debounceTimer);
|
|
224
|
-
this._debounceTimer = null;
|
|
225
|
-
}
|
|
226
|
-
for (const { watcherSet } of this.pipelines.values()) {
|
|
227
|
-
if (watcherSet) {
|
|
228
|
-
try {
|
|
229
|
-
watcherSet.destroy();
|
|
230
|
-
} catch {
|
|
231
|
-
// ignore cleanup errors
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
this.pipelines.clear();
|
|
236
|
-
}
|
|
237
|
-
}
|