@worca/ui 0.1.0-rc.1
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/index.html +23 -0
- package/app/main.bundle.js +5738 -0
- package/app/main.bundle.js.map +7 -0
- package/app/styles.css +3897 -0
- package/app/vendor/shoelace-dark.css +483 -0
- package/app/vendor/shoelace-light.css +484 -0
- package/app/vendor/xterm.css +285 -0
- package/bin/worca-ui.js +540 -0
- package/package.json +71 -0
- package/scripts/build-frontend.js +49 -0
- package/server/app.js +421 -0
- package/server/beads-reader.js +199 -0
- package/server/index.js +131 -0
- package/server/log-tailer.js +156 -0
- package/server/multi-watcher.js +237 -0
- package/server/preferences.js +17 -0
- package/server/process-manager.js +546 -0
- package/server/project-registry.js +145 -0
- package/server/project-routes.js +1265 -0
- package/server/settings-merge.js +83 -0
- package/server/settings-reader.js +23 -0
- package/server/settings-validator.js +506 -0
- package/server/watcher-set.js +286 -0
- package/server/watcher.js +357 -0
- package/server/webhook-inbox.js +59 -0
- package/server/worca-setup.js +114 -0
- package/server/ws-beads-watcher.js +62 -0
- package/server/ws-broadcaster.js +106 -0
- package/server/ws-client-manager.js +129 -0
- package/server/ws-event-watcher.js +124 -0
- package/server/ws-log-watcher.js +299 -0
- package/server/ws-message-router.js +870 -0
- package/server/ws-modular.js +309 -0
- package/server/ws-status-watcher.js +259 -0
- package/server/ws.js +5 -0
package/server/index.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// server/index.js
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { homedir, platform } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createApp } from './app.js';
|
|
7
|
+
import { attachWsServer } from './ws.js';
|
|
8
|
+
|
|
9
|
+
// Parse argv
|
|
10
|
+
let port = parseInt(process.env.PORT, 10) || 3400;
|
|
11
|
+
let host = process.env.HOST || '127.0.0.1';
|
|
12
|
+
let isGlobal = false;
|
|
13
|
+
for (let i = 0; i < process.argv.length; i++) {
|
|
14
|
+
if (process.argv[i] === '--port' && process.argv[i + 1])
|
|
15
|
+
port = parseInt(process.argv[++i], 10);
|
|
16
|
+
if (process.argv[i] === '--host' && process.argv[i + 1])
|
|
17
|
+
host = process.argv[++i];
|
|
18
|
+
if (process.argv[i] === '--global') isGlobal = true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Resolve project root: walk up from cwd until we find .claude/settings.json
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { dirname } from 'node:path';
|
|
24
|
+
|
|
25
|
+
function findProjectRoot(startDir) {
|
|
26
|
+
let dir = startDir;
|
|
27
|
+
while (dir !== dirname(dir)) {
|
|
28
|
+
if (existsSync(join(dir, '.claude', 'settings.json'))) return dir;
|
|
29
|
+
dir = dirname(dir);
|
|
30
|
+
}
|
|
31
|
+
return startDir; // fallback
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
import { createInbox } from './webhook-inbox.js';
|
|
35
|
+
|
|
36
|
+
let projectRoot, worcaDir, settingsPath;
|
|
37
|
+
|
|
38
|
+
if (isGlobal) {
|
|
39
|
+
// Global mode: no project root needed
|
|
40
|
+
projectRoot = null;
|
|
41
|
+
worcaDir = null;
|
|
42
|
+
settingsPath = null;
|
|
43
|
+
} else {
|
|
44
|
+
// Per-project mode: resolve from cwd
|
|
45
|
+
projectRoot = findProjectRoot(process.cwd());
|
|
46
|
+
worcaDir = join(projectRoot, '.worca');
|
|
47
|
+
settingsPath = join(projectRoot, '.claude', 'settings.json');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const prefsDir = join(homedir(), '.worca');
|
|
51
|
+
const webhookInbox = createInbox();
|
|
52
|
+
const app = createApp({
|
|
53
|
+
settingsPath,
|
|
54
|
+
worcaDir,
|
|
55
|
+
projectRoot,
|
|
56
|
+
webhookInbox,
|
|
57
|
+
prefsDir,
|
|
58
|
+
});
|
|
59
|
+
const server = createServer(app);
|
|
60
|
+
|
|
61
|
+
// Register error handler BEFORE attachWsServer — the WSS constructor adds its
|
|
62
|
+
// own error forwarder on the HTTP server, so our handler must be first in line.
|
|
63
|
+
server.on('error', (err) => {
|
|
64
|
+
if (err.code === 'EADDRINUSE') {
|
|
65
|
+
console.error(
|
|
66
|
+
`\n Error: Port ${port} is already in use (${host}:${port})\n`,
|
|
67
|
+
);
|
|
68
|
+
console.error(' To fix this, either:\n');
|
|
69
|
+
console.error(
|
|
70
|
+
` 1. Start on a different port: worca-ui start --port ${port + 1}`,
|
|
71
|
+
);
|
|
72
|
+
console.error(
|
|
73
|
+
` Or directly: PORT=${port + 1} npm start\n`,
|
|
74
|
+
);
|
|
75
|
+
console.error(' 2. Stop the existing server: worca-ui stop');
|
|
76
|
+
if (!isGlobal) {
|
|
77
|
+
console.error(
|
|
78
|
+
' Or the global server: worca-ui stop --global',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
console.error(`\n 3. Find what's using the port: lsof -i :${port}\n`);
|
|
82
|
+
} else {
|
|
83
|
+
console.error(`\n Error: Failed to start server — ${err.message}\n`);
|
|
84
|
+
}
|
|
85
|
+
process.exit(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const { broadcast, scheduleRefresh, resolveRunProject } = attachWsServer(
|
|
89
|
+
server,
|
|
90
|
+
{
|
|
91
|
+
worcaDir,
|
|
92
|
+
settingsPath,
|
|
93
|
+
prefsPath: join(homedir(), '.worca', 'preferences.json'),
|
|
94
|
+
prefsDir,
|
|
95
|
+
webhookInbox,
|
|
96
|
+
projectRoot,
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Expose broadcast, scheduleRefresh, and resolveRunProject to REST route handlers
|
|
101
|
+
app.locals.broadcast = broadcast;
|
|
102
|
+
app.locals.scheduleRefresh = scheduleRefresh;
|
|
103
|
+
app.locals.resolveRunProject = resolveRunProject;
|
|
104
|
+
|
|
105
|
+
// ─── inotify budget check (Linux only) ─────────────────────────────────
|
|
106
|
+
if (platform() === 'linux') {
|
|
107
|
+
try {
|
|
108
|
+
const max = parseInt(
|
|
109
|
+
readFileSync('/proc/sys/fs/inotify/max_user_watches', 'utf8').trim(),
|
|
110
|
+
10,
|
|
111
|
+
);
|
|
112
|
+
if (Number.isFinite(max)) {
|
|
113
|
+
if (max < 8192) {
|
|
114
|
+
console.warn(
|
|
115
|
+
`[inotify] max_user_watches=${max} is very low. ` +
|
|
116
|
+
`Run: sudo sysctl fs.inotify.max_user_watches=524288`,
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
console.log(`[inotify] max_user_watches=${max}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// /proc not available or not readable — skip
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
server.listen(port, host, () => {
|
|
128
|
+
console.log(
|
|
129
|
+
`worca-ui${isGlobal ? ' (global)' : ''} running at http://${host}:${port}`,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
openSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readSync,
|
|
8
|
+
statSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { STAGE_ORDER_WITH_ORCHESTRATOR } from '../app/utils/stage-order.js';
|
|
12
|
+
|
|
13
|
+
/** Re-export for consumers (includes orchestrator). */
|
|
14
|
+
export const STAGE_ORDER = STAGE_ORDER_WITH_ORCHESTRATOR;
|
|
15
|
+
|
|
16
|
+
export function resolveLogPath(worcaDir, stage, iteration = null) {
|
|
17
|
+
if (!stage) return join(worcaDir, 'logs', 'orchestrator.log');
|
|
18
|
+
if (iteration !== null) {
|
|
19
|
+
return join(worcaDir, 'logs', stage, `iter-${iteration}.log`);
|
|
20
|
+
}
|
|
21
|
+
return join(worcaDir, 'logs', stage);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveIterationLogPath(worcaDir, stage, iteration) {
|
|
25
|
+
return join(worcaDir, 'logs', stage, `iter-${iteration}.log`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function listIterationFiles(worcaDir, stage) {
|
|
29
|
+
const stageDir = join(worcaDir, 'logs', stage);
|
|
30
|
+
if (!existsSync(stageDir)) return [];
|
|
31
|
+
try {
|
|
32
|
+
return readdirSync(stageDir)
|
|
33
|
+
.filter((f) => /^iter-\d+\.log$/.test(f))
|
|
34
|
+
.sort((a, b) => {
|
|
35
|
+
const an = parseInt(a.match(/\d+/)[0], 10);
|
|
36
|
+
const bn = parseInt(b.match(/\d+/)[0], 10);
|
|
37
|
+
return an - bn;
|
|
38
|
+
})
|
|
39
|
+
.map((f) => ({
|
|
40
|
+
iteration: parseInt(f.match(/\d+/)[0], 10),
|
|
41
|
+
path: join(stageDir, f),
|
|
42
|
+
}));
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readLastLines(filePath, n) {
|
|
49
|
+
if (!existsSync(filePath)) return [];
|
|
50
|
+
try {
|
|
51
|
+
const content = readFileSync(filePath, 'utf8');
|
|
52
|
+
const lines = content.split('\n').filter((l) => l.length > 0);
|
|
53
|
+
return lines.slice(-n);
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function countLines(filePath) {
|
|
60
|
+
if (!existsSync(filePath)) return 0;
|
|
61
|
+
try {
|
|
62
|
+
const content = readFileSync(filePath, 'utf8');
|
|
63
|
+
return content.split('\n').filter((l) => l.length > 0).length;
|
|
64
|
+
} catch {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function readLinesFrom(filePath, startLine) {
|
|
70
|
+
if (!existsSync(filePath)) return [];
|
|
71
|
+
try {
|
|
72
|
+
const content = readFileSync(filePath, 'utf8');
|
|
73
|
+
const lines = content.split('\n').filter((l) => l.length > 0);
|
|
74
|
+
return lines.slice(startLine);
|
|
75
|
+
} catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Return the byte length of a file (0 if missing/unreadable).
|
|
82
|
+
* Used as the initial offset when starting to tail a log file.
|
|
83
|
+
*/
|
|
84
|
+
export function fileByteLength(filePath) {
|
|
85
|
+
try {
|
|
86
|
+
return statSync(filePath).size;
|
|
87
|
+
} catch {
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read new lines from a file starting at `byteOffset`.
|
|
94
|
+
* Returns `{ lines: string[], newOffset: number }`.
|
|
95
|
+
* Only the bytes after the offset are read, making this O(delta) instead of O(n).
|
|
96
|
+
*/
|
|
97
|
+
export function readNewLines(filePath, byteOffset) {
|
|
98
|
+
try {
|
|
99
|
+
const size = statSync(filePath).size;
|
|
100
|
+
if (size <= byteOffset) return { lines: [], newOffset: byteOffset };
|
|
101
|
+
const fd = openSync(filePath, 'r');
|
|
102
|
+
try {
|
|
103
|
+
const len = size - byteOffset;
|
|
104
|
+
const buf = Buffer.alloc(len);
|
|
105
|
+
readSync(fd, buf, 0, len, byteOffset);
|
|
106
|
+
const text = buf.toString('utf8');
|
|
107
|
+
const lines = text.split('\n').filter((l) => l.length > 0);
|
|
108
|
+
return { lines, newOffset: size };
|
|
109
|
+
} finally {
|
|
110
|
+
closeSync(fd);
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
return { lines: [], newOffset: byteOffset };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function listLogFiles(worcaDir) {
|
|
118
|
+
const logsDir = join(worcaDir, 'logs');
|
|
119
|
+
if (!existsSync(logsDir)) return [];
|
|
120
|
+
try {
|
|
121
|
+
const entries = readdirSync(logsDir, { withFileTypes: true });
|
|
122
|
+
const files = [];
|
|
123
|
+
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (entry.isFile() && entry.name.endsWith('.log')) {
|
|
126
|
+
// Legacy flat file (e.g., orchestrator.log)
|
|
127
|
+
files.push({
|
|
128
|
+
stage: entry.name.replace('.log', ''),
|
|
129
|
+
path: join(logsDir, entry.name),
|
|
130
|
+
});
|
|
131
|
+
} else if (entry.isDirectory()) {
|
|
132
|
+
// Nested stage directory — list iteration files
|
|
133
|
+
const iters = listIterationFiles(worcaDir, entry.name);
|
|
134
|
+
for (const iter of iters) {
|
|
135
|
+
files.push({
|
|
136
|
+
stage: entry.name,
|
|
137
|
+
iteration: iter.iteration,
|
|
138
|
+
path: iter.path,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Sort by pipeline stage order, then by iteration
|
|
145
|
+
files.sort((a, b) => {
|
|
146
|
+
const ai = STAGE_ORDER.indexOf(a.stage);
|
|
147
|
+
const bi = STAGE_ORDER.indexOf(b.stage);
|
|
148
|
+
const orderDiff = (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
149
|
+
if (orderDiff !== 0) return orderDiff;
|
|
150
|
+
return (a.iteration || 0) - (b.iteration || 0);
|
|
151
|
+
});
|
|
152
|
+
return files;
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DEFAULTS = { theme: 'light' };
|
|
5
|
+
|
|
6
|
+
export function readPreferences(path) {
|
|
7
|
+
try {
|
|
8
|
+
return { ...DEFAULTS, ...JSON.parse(readFileSync(path, 'utf8')) };
|
|
9
|
+
} catch {
|
|
10
|
+
return { ...DEFAULTS };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function writePreferences(prefs, path) {
|
|
15
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
16
|
+
writeFileSync(path, `${JSON.stringify(prefs, null, 2)}\n`);
|
|
17
|
+
}
|