@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
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline process lifecycle management.
|
|
3
|
+
* Handles starting, stopping, and restarting pipeline processes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import {
|
|
9
|
+
closeSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
openSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
writeSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
/** Byte threshold — must match claude_cli.py _ARG_INLINE_LIMIT */
|
|
22
|
+
const ARG_INLINE_LIMIT = 128 * 1024;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Write content to a temp file with restricted permissions (0o600) and return its path.
|
|
26
|
+
* Used to avoid E2BIG when passing large prompts as CLI arguments.
|
|
27
|
+
* @param {string} content
|
|
28
|
+
* @returns {string} path to the temp file
|
|
29
|
+
*/
|
|
30
|
+
function writePromptFile(content) {
|
|
31
|
+
const name = `worca_prompt_${randomBytes(8).toString('hex')}.md`;
|
|
32
|
+
const filePath = join(tmpdir(), name);
|
|
33
|
+
const fd = openSync(filePath, 'w', 0o600);
|
|
34
|
+
try {
|
|
35
|
+
writeSync(fd, content, 0, 'utf8');
|
|
36
|
+
} finally {
|
|
37
|
+
closeSync(fd);
|
|
38
|
+
}
|
|
39
|
+
return filePath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Try to delete a temp prompt file. Silently ignores errors.
|
|
44
|
+
* @param {string|null} filePath
|
|
45
|
+
*/
|
|
46
|
+
function cleanupPromptFile(filePath) {
|
|
47
|
+
if (!filePath) return;
|
|
48
|
+
try {
|
|
49
|
+
unlinkSync(filePath);
|
|
50
|
+
} catch {
|
|
51
|
+
/* ignore */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Pipeline process lifecycle manager.
|
|
57
|
+
* Encapsulates all process management for a single project's .worca directory.
|
|
58
|
+
*/
|
|
59
|
+
export class ProcessManager {
|
|
60
|
+
/**
|
|
61
|
+
* @param {{ worcaDir: string, projectRoot?: string }} options
|
|
62
|
+
*/
|
|
63
|
+
constructor({ worcaDir, projectRoot }) {
|
|
64
|
+
this.worcaDir = worcaDir;
|
|
65
|
+
this.projectRoot = projectRoot || process.cwd();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if a pipeline is currently running.
|
|
70
|
+
* @returns {{ pid: number } | null}
|
|
71
|
+
*/
|
|
72
|
+
getRunningPid() {
|
|
73
|
+
const pidPath = join(this.worcaDir, 'pipeline.pid');
|
|
74
|
+
if (!existsSync(pidPath)) return null;
|
|
75
|
+
try {
|
|
76
|
+
const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
|
|
77
|
+
if (Number.isNaN(pid) || pid <= 0) {
|
|
78
|
+
try {
|
|
79
|
+
unlinkSync(pidPath);
|
|
80
|
+
} catch {
|
|
81
|
+
/* ignore */
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
process.kill(pid, 0); // throws if dead
|
|
86
|
+
return { pid };
|
|
87
|
+
} catch {
|
|
88
|
+
// Stale PID file — clean up
|
|
89
|
+
try {
|
|
90
|
+
unlinkSync(pidPath);
|
|
91
|
+
} catch {
|
|
92
|
+
/* ignore */
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Reconcile stale "running" status when the pipeline process is dead.
|
|
100
|
+
* Checks the active run's status.json — if pipeline_status is "running"
|
|
101
|
+
* but no process is alive, transitions to "failed" with stop_reason="stale".
|
|
102
|
+
* Preserves any existing stop_reason (e.g. "signal" set by Layer 1).
|
|
103
|
+
*
|
|
104
|
+
* @returns {boolean} true if status was fixed
|
|
105
|
+
*/
|
|
106
|
+
reconcileStatus() {
|
|
107
|
+
const running = this.getRunningPid();
|
|
108
|
+
if (running) return false; // process is alive, nothing to fix
|
|
109
|
+
|
|
110
|
+
const activeRunPath = join(this.worcaDir, 'active_run');
|
|
111
|
+
if (!existsSync(activeRunPath)) return false;
|
|
112
|
+
|
|
113
|
+
let runId;
|
|
114
|
+
try {
|
|
115
|
+
runId = readFileSync(activeRunPath, 'utf8').trim();
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
if (!runId) return false;
|
|
120
|
+
|
|
121
|
+
const statusPath = join(this.worcaDir, 'runs', runId, 'status.json');
|
|
122
|
+
if (!existsSync(statusPath)) return false;
|
|
123
|
+
|
|
124
|
+
let status;
|
|
125
|
+
try {
|
|
126
|
+
status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (status.pipeline_status !== 'running') return false;
|
|
132
|
+
|
|
133
|
+
status.pipeline_status = 'failed';
|
|
134
|
+
if (!status.stop_reason) {
|
|
135
|
+
status.stop_reason = 'stale';
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
writeFileSync(statusPath, `${JSON.stringify(status, null, 2)}\n`, 'utf8');
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Start a new pipeline run.
|
|
148
|
+
* @param {{ inputType?: string, inputValue?: string, msize?: number, mloops?: number, planFile?: string, resume?: boolean, projectRoot?: string }} opts
|
|
149
|
+
* @returns {Promise<{ pid: number }>}
|
|
150
|
+
*/
|
|
151
|
+
async startPipeline(opts = {}) {
|
|
152
|
+
const cwd = opts.projectRoot || this.projectRoot;
|
|
153
|
+
const scriptPath = join(cwd, '.claude/worca/scripts/run_pipeline.py');
|
|
154
|
+
if (!existsSync(scriptPath)) {
|
|
155
|
+
const err = new Error(`Pipeline script not found at ${scriptPath}`);
|
|
156
|
+
err.code = 'script_not_found';
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const args = ['.claude/worca/scripts/run_pipeline.py'];
|
|
161
|
+
let promptFilePath = null; // track for cleanup on spawn failure
|
|
162
|
+
|
|
163
|
+
if (opts.resume) {
|
|
164
|
+
args.push('--resume');
|
|
165
|
+
if (opts.runId) {
|
|
166
|
+
args.push('--status-dir', join(this.worcaDir, 'runs', opts.runId));
|
|
167
|
+
}
|
|
168
|
+
} else if (opts.sourceType !== undefined) {
|
|
169
|
+
// New format: separate source and prompt args
|
|
170
|
+
if (opts.sourceType === 'source') args.push('--source', opts.sourceValue);
|
|
171
|
+
else if (opts.sourceType === 'spec')
|
|
172
|
+
args.push('--spec', opts.sourceValue);
|
|
173
|
+
if (opts.prompt) {
|
|
174
|
+
if (Buffer.byteLength(opts.prompt, 'utf8') > ARG_INLINE_LIMIT) {
|
|
175
|
+
promptFilePath = writePromptFile(opts.prompt);
|
|
176
|
+
args.push('--prompt-file', promptFilePath);
|
|
177
|
+
} else {
|
|
178
|
+
args.push('--prompt', opts.prompt);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
// Legacy format: inputType/inputValue
|
|
183
|
+
const flag =
|
|
184
|
+
opts.inputType === 'source'
|
|
185
|
+
? '--source'
|
|
186
|
+
: opts.inputType === 'spec'
|
|
187
|
+
? '--spec'
|
|
188
|
+
: '--prompt';
|
|
189
|
+
if (
|
|
190
|
+
flag === '--prompt' &&
|
|
191
|
+
opts.inputValue &&
|
|
192
|
+
Buffer.byteLength(opts.inputValue, 'utf8') > ARG_INLINE_LIMIT
|
|
193
|
+
) {
|
|
194
|
+
promptFilePath = writePromptFile(opts.inputValue);
|
|
195
|
+
args.push('--prompt-file', promptFilePath);
|
|
196
|
+
} else {
|
|
197
|
+
args.push(flag, opts.inputValue);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (opts.msize && opts.msize > 1) {
|
|
202
|
+
args.push('--msize', String(opts.msize));
|
|
203
|
+
}
|
|
204
|
+
if (opts.mloops && opts.mloops > 1) {
|
|
205
|
+
args.push('--mloops', String(opts.mloops));
|
|
206
|
+
}
|
|
207
|
+
if (opts.planFile) {
|
|
208
|
+
args.push('--plan', opts.planFile);
|
|
209
|
+
}
|
|
210
|
+
if (opts.branch) {
|
|
211
|
+
args.push('--branch', opts.branch);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const env = { ...process.env };
|
|
215
|
+
delete env.CLAUDECODE;
|
|
216
|
+
|
|
217
|
+
return new Promise((resolve, reject) => {
|
|
218
|
+
const child = spawn('python3', args, {
|
|
219
|
+
detached: true,
|
|
220
|
+
stdio: 'ignore',
|
|
221
|
+
cwd,
|
|
222
|
+
env,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const timeout = setTimeout(() => {
|
|
226
|
+
cleanup();
|
|
227
|
+
child.unref();
|
|
228
|
+
resolve({ pid: child.pid });
|
|
229
|
+
}, 2000);
|
|
230
|
+
|
|
231
|
+
function cleanup() {
|
|
232
|
+
clearTimeout(timeout);
|
|
233
|
+
child.removeAllListeners('error');
|
|
234
|
+
child.removeAllListeners('exit');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
child.on('error', (spawnErr) => {
|
|
238
|
+
cleanup();
|
|
239
|
+
cleanupPromptFile(promptFilePath);
|
|
240
|
+
const err = new Error(`Failed to start pipeline: ${spawnErr.message}`);
|
|
241
|
+
err.code = 'spawn_error';
|
|
242
|
+
reject(err);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
child.on('exit', (code, signal) => {
|
|
246
|
+
cleanup();
|
|
247
|
+
if (code !== null && code !== 0) {
|
|
248
|
+
cleanupPromptFile(promptFilePath);
|
|
249
|
+
const err = new Error(
|
|
250
|
+
`Pipeline exited immediately with code ${code}`,
|
|
251
|
+
);
|
|
252
|
+
err.code = 'spawn_error';
|
|
253
|
+
reject(err);
|
|
254
|
+
} else if (signal) {
|
|
255
|
+
cleanupPromptFile(promptFilePath);
|
|
256
|
+
const err = new Error(`Pipeline killed by signal ${signal}`);
|
|
257
|
+
err.code = 'spawn_error';
|
|
258
|
+
reject(err);
|
|
259
|
+
}
|
|
260
|
+
// code === 0 or code === null (still running) — resolve
|
|
261
|
+
// run_pipeline.py handles prompt file cleanup after reading
|
|
262
|
+
child.unref();
|
|
263
|
+
resolve({ pid: child.pid });
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Stop a running pipeline.
|
|
270
|
+
* PID file is the sole source of truth — no pgrep fallback.
|
|
271
|
+
* @returns {{ pid: number, stopped: boolean }}
|
|
272
|
+
*/
|
|
273
|
+
stopPipeline() {
|
|
274
|
+
let pid = null;
|
|
275
|
+
const pidPath = join(this.worcaDir, 'pipeline.pid');
|
|
276
|
+
|
|
277
|
+
if (existsSync(pidPath)) {
|
|
278
|
+
try {
|
|
279
|
+
pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
|
|
280
|
+
process.kill(pid, 0); // verify alive
|
|
281
|
+
} catch {
|
|
282
|
+
try {
|
|
283
|
+
unlinkSync(pidPath);
|
|
284
|
+
} catch {
|
|
285
|
+
/* ignore */
|
|
286
|
+
}
|
|
287
|
+
pid = null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!pid) {
|
|
292
|
+
const err = new Error('No running pipeline found');
|
|
293
|
+
err.code = 'not_running';
|
|
294
|
+
throw err;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Belt-and-suspenders: write control.json so the orchestrator gets a clean signal
|
|
298
|
+
const activeRunPath = join(this.worcaDir, 'active_run');
|
|
299
|
+
if (existsSync(activeRunPath)) {
|
|
300
|
+
try {
|
|
301
|
+
const runId = readFileSync(activeRunPath, 'utf8').trim();
|
|
302
|
+
if (runId) {
|
|
303
|
+
const controlDir = join(this.worcaDir, 'runs', runId);
|
|
304
|
+
mkdirSync(controlDir, { recursive: true });
|
|
305
|
+
writeFileSync(
|
|
306
|
+
join(controlDir, 'control.json'),
|
|
307
|
+
`${JSON.stringify(
|
|
308
|
+
{
|
|
309
|
+
action: 'stop',
|
|
310
|
+
requested_at: new Date().toISOString(),
|
|
311
|
+
source: 'ui',
|
|
312
|
+
},
|
|
313
|
+
null,
|
|
314
|
+
2,
|
|
315
|
+
)}\n`,
|
|
316
|
+
'utf8',
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
/* non-fatal */
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
process.kill(pid, 'SIGTERM');
|
|
326
|
+
} catch (e) {
|
|
327
|
+
try {
|
|
328
|
+
unlinkSync(pidPath);
|
|
329
|
+
} catch {
|
|
330
|
+
/* ignore */
|
|
331
|
+
}
|
|
332
|
+
const err = new Error(`Failed to stop pipeline: ${e.message}`);
|
|
333
|
+
err.code = 'not_running';
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Watchdog: SIGKILL after 10s if still alive, then reconcile status
|
|
338
|
+
const worcaDir = this.worcaDir;
|
|
339
|
+
const watchdog = setTimeout(() => {
|
|
340
|
+
try {
|
|
341
|
+
process.kill(pid, 0); // check alive
|
|
342
|
+
process.kill(pid, 'SIGKILL');
|
|
343
|
+
// Give the OS a moment to reap the process, then fix stale status
|
|
344
|
+
setTimeout(() => reconcileStatus(worcaDir), 500);
|
|
345
|
+
} catch {
|
|
346
|
+
// Already dead — reconcile in case signal handler didn't save
|
|
347
|
+
reconcileStatus(worcaDir);
|
|
348
|
+
}
|
|
349
|
+
}, 10000);
|
|
350
|
+
watchdog.unref();
|
|
351
|
+
|
|
352
|
+
// Clean up PID file
|
|
353
|
+
try {
|
|
354
|
+
unlinkSync(pidPath);
|
|
355
|
+
} catch {
|
|
356
|
+
/* ignore */
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { pid, stopped: true };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Pause a running pipeline by writing a control file.
|
|
364
|
+
* @param {string} runId - Pipeline run identifier
|
|
365
|
+
* @returns {{ runId: string, paused: boolean }}
|
|
366
|
+
*/
|
|
367
|
+
pausePipeline(runId) {
|
|
368
|
+
const controlDir = join(this.worcaDir, 'runs', runId);
|
|
369
|
+
mkdirSync(controlDir, { recursive: true });
|
|
370
|
+
writeFileSync(
|
|
371
|
+
join(controlDir, 'control.json'),
|
|
372
|
+
`${JSON.stringify(
|
|
373
|
+
{
|
|
374
|
+
action: 'pause',
|
|
375
|
+
requested_at: new Date().toISOString(),
|
|
376
|
+
source: 'ui',
|
|
377
|
+
},
|
|
378
|
+
null,
|
|
379
|
+
2,
|
|
380
|
+
)}\n`,
|
|
381
|
+
'utf8',
|
|
382
|
+
);
|
|
383
|
+
return { runId, paused: true };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Restart a failed stage by resetting it and spawning with --resume.
|
|
388
|
+
* @param {string} stageKey - The stage key to restart
|
|
389
|
+
* @param {{ projectRoot?: string }} opts
|
|
390
|
+
* @returns {Promise<{ pid: number, stage: string }>}
|
|
391
|
+
*/
|
|
392
|
+
async restartStage(stageKey, opts = {}) {
|
|
393
|
+
const running = this.getRunningPid();
|
|
394
|
+
if (running) {
|
|
395
|
+
const err = new Error(`Pipeline already running (PID ${running.pid})`);
|
|
396
|
+
err.code = 'already_running';
|
|
397
|
+
throw err;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const cwd = opts.projectRoot || this.projectRoot;
|
|
401
|
+
const scriptPath = join(cwd, '.claude/worca/scripts/run_pipeline.py');
|
|
402
|
+
if (!existsSync(scriptPath)) {
|
|
403
|
+
const err = new Error(`Pipeline script not found at ${scriptPath}`);
|
|
404
|
+
err.code = 'script_not_found';
|
|
405
|
+
throw err;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Find status.json — check active_run first, then legacy
|
|
409
|
+
let statusPath = null;
|
|
410
|
+
const activeRunPath = join(this.worcaDir, 'active_run');
|
|
411
|
+
if (existsSync(activeRunPath)) {
|
|
412
|
+
try {
|
|
413
|
+
const runId = readFileSync(activeRunPath, 'utf8').trim();
|
|
414
|
+
const candidate = join(this.worcaDir, 'runs', runId, 'status.json');
|
|
415
|
+
if (existsSync(candidate)) statusPath = candidate;
|
|
416
|
+
} catch {
|
|
417
|
+
/* ignore */
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (!statusPath) {
|
|
421
|
+
const legacy = join(this.worcaDir, 'status.json');
|
|
422
|
+
if (existsSync(legacy)) statusPath = legacy;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (!statusPath) {
|
|
426
|
+
const err = new Error('No status.json found');
|
|
427
|
+
err.code = 'no_status';
|
|
428
|
+
throw err;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
432
|
+
|
|
433
|
+
if (!status.stages || !status.stages[stageKey]) {
|
|
434
|
+
const err = new Error(`Stage "${stageKey}" not found`);
|
|
435
|
+
err.code = 'stage_not_found';
|
|
436
|
+
throw err;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (status.stages[stageKey].status !== 'error') {
|
|
440
|
+
const err = new Error(
|
|
441
|
+
`Stage "${stageKey}" is not in error state (current: ${status.stages[stageKey].status})`,
|
|
442
|
+
);
|
|
443
|
+
err.code = 'stage_not_error';
|
|
444
|
+
throw err;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Reset the stage
|
|
448
|
+
status.stages[stageKey].status = 'pending';
|
|
449
|
+
delete status.stages[stageKey].error;
|
|
450
|
+
delete status.stages[stageKey].completed_at;
|
|
451
|
+
writeFileSync(statusPath, `${JSON.stringify(status, null, 2)}\n`, 'utf8');
|
|
452
|
+
|
|
453
|
+
// Spawn with --resume
|
|
454
|
+
const env = { ...process.env };
|
|
455
|
+
delete env.CLAUDECODE;
|
|
456
|
+
|
|
457
|
+
return new Promise((resolve, reject) => {
|
|
458
|
+
const child = spawn(
|
|
459
|
+
'python3',
|
|
460
|
+
['.claude/worca/scripts/run_pipeline.py', '--resume'],
|
|
461
|
+
{
|
|
462
|
+
detached: true,
|
|
463
|
+
stdio: 'ignore',
|
|
464
|
+
cwd,
|
|
465
|
+
env,
|
|
466
|
+
},
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const timeout = setTimeout(() => {
|
|
470
|
+
cleanup();
|
|
471
|
+
child.unref();
|
|
472
|
+
resolve({ pid: child.pid, stage: stageKey });
|
|
473
|
+
}, 2000);
|
|
474
|
+
|
|
475
|
+
function cleanup() {
|
|
476
|
+
clearTimeout(timeout);
|
|
477
|
+
child.removeAllListeners('error');
|
|
478
|
+
child.removeAllListeners('exit');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
child.on('error', (spawnErr) => {
|
|
482
|
+
cleanup();
|
|
483
|
+
const err = new Error(`Failed to restart stage: ${spawnErr.message}`);
|
|
484
|
+
err.code = 'spawn_error';
|
|
485
|
+
reject(err);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
child.on('exit', (code, signal) => {
|
|
489
|
+
cleanup();
|
|
490
|
+
if (code !== null && code !== 0) {
|
|
491
|
+
const err = new Error(
|
|
492
|
+
`Pipeline exited immediately with code ${code}`,
|
|
493
|
+
);
|
|
494
|
+
err.code = 'spawn_error';
|
|
495
|
+
reject(err);
|
|
496
|
+
} else if (signal) {
|
|
497
|
+
const err = new Error(`Pipeline killed by signal ${signal}`);
|
|
498
|
+
err.code = 'spawn_error';
|
|
499
|
+
reject(err);
|
|
500
|
+
}
|
|
501
|
+
child.unref();
|
|
502
|
+
resolve({ pid: child.pid, stage: stageKey });
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ─── Backward-compatible free-function exports ──────────────────────────────
|
|
509
|
+
// These delegate to a one-off ProcessManager instance so existing callers
|
|
510
|
+
// (app.js, ws.js, tests) continue to work without changes during Phase 0.
|
|
511
|
+
|
|
512
|
+
/** @param {string} worcaDir */
|
|
513
|
+
export function getRunningPid(worcaDir) {
|
|
514
|
+
return new ProcessManager({ worcaDir }).getRunningPid();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** @param {string} worcaDir */
|
|
518
|
+
export function reconcileStatus(worcaDir) {
|
|
519
|
+
return new ProcessManager({ worcaDir }).reconcileStatus();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** @param {string} worcaDir @param {object} opts */
|
|
523
|
+
export async function startPipeline(worcaDir, opts = {}) {
|
|
524
|
+
return new ProcessManager({
|
|
525
|
+
worcaDir,
|
|
526
|
+
projectRoot: opts.projectRoot,
|
|
527
|
+
}).startPipeline(opts);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** @param {string} worcaDir */
|
|
531
|
+
export function stopPipeline(worcaDir) {
|
|
532
|
+
return new ProcessManager({ worcaDir }).stopPipeline();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** @param {string} worcaDir @param {string} runId */
|
|
536
|
+
export function pausePipeline(worcaDir, runId) {
|
|
537
|
+
return new ProcessManager({ worcaDir }).pausePipeline(runId);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** @param {string} worcaDir @param {string} stageKey @param {object} opts */
|
|
541
|
+
export async function restartStage(worcaDir, stageKey, opts = {}) {
|
|
542
|
+
return new ProcessManager({
|
|
543
|
+
worcaDir,
|
|
544
|
+
projectRoot: opts.projectRoot,
|
|
545
|
+
}).restartStage(stageKey, opts);
|
|
546
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project registry — manages multi-project entries in ~/.worca/projects.d/
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
unlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from 'node:fs';
|
|
13
|
+
import { basename, isAbsolute, join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
export const SLUG_RE = /^[a-z0-9_-]{1,64}$/i;
|
|
16
|
+
const DEFAULT_MAX_PROJECTS = 20;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Slugify a project name: lowercase, replace non-alphanumeric (except _ and -)
|
|
20
|
+
* with hyphens, collapse consecutive hyphens, truncate to 64 chars.
|
|
21
|
+
*/
|
|
22
|
+
export function slugify(name) {
|
|
23
|
+
return name
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9_-]/g, '-')
|
|
26
|
+
.replace(/-{2,}/g, '-')
|
|
27
|
+
.slice(0, 64);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate a project entry { name, path }.
|
|
32
|
+
* Returns { valid: true } or { valid: false, error: string }.
|
|
33
|
+
*/
|
|
34
|
+
export function validateProjectEntry(entry) {
|
|
35
|
+
if (!entry || typeof entry.name !== 'string' || !entry.name) {
|
|
36
|
+
return { valid: false, error: 'name is required' };
|
|
37
|
+
}
|
|
38
|
+
if (!SLUG_RE.test(entry.name)) {
|
|
39
|
+
return {
|
|
40
|
+
valid: false,
|
|
41
|
+
error: `name must match ${SLUG_RE} (got "${entry.name}")`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (!entry.path || typeof entry.path !== 'string') {
|
|
45
|
+
return { valid: false, error: 'path is required' };
|
|
46
|
+
}
|
|
47
|
+
if (!isAbsolute(entry.path)) {
|
|
48
|
+
return { valid: false, error: 'path must be absolute' };
|
|
49
|
+
}
|
|
50
|
+
return { valid: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read all project entries from {prefsDir}/projects.d/*.json.
|
|
55
|
+
* Skips malformed files. Returns sorted by name.
|
|
56
|
+
*/
|
|
57
|
+
export function readProjects(prefsDir) {
|
|
58
|
+
const dir = join(prefsDir, 'projects.d');
|
|
59
|
+
if (!existsSync(dir)) return [];
|
|
60
|
+
|
|
61
|
+
const entries = [];
|
|
62
|
+
for (const file of readdirSync(dir)) {
|
|
63
|
+
if (!file.endsWith('.json')) continue;
|
|
64
|
+
try {
|
|
65
|
+
const raw = readFileSync(join(dir, file), 'utf8');
|
|
66
|
+
const data = JSON.parse(raw);
|
|
67
|
+
if (
|
|
68
|
+
data &&
|
|
69
|
+
typeof data.name === 'string' &&
|
|
70
|
+
typeof data.path === 'string'
|
|
71
|
+
) {
|
|
72
|
+
entries.push(data);
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// skip malformed
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Write a project entry to {prefsDir}/projects.d/{name}.json.
|
|
83
|
+
* Creates projects.d/ if needed. Validates entry. Enforces max limit.
|
|
84
|
+
*/
|
|
85
|
+
export function writeProject(prefsDir, entry) {
|
|
86
|
+
const validation = validateProjectEntry(entry);
|
|
87
|
+
if (!validation.valid) {
|
|
88
|
+
throw new Error(`Invalid project entry: ${validation.error}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const dir = join(prefsDir, 'projects.d');
|
|
92
|
+
mkdirSync(dir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
// Check max limit (only for new projects, not overwrites)
|
|
95
|
+
const filePath = join(dir, `${entry.name}.json`);
|
|
96
|
+
if (!existsSync(filePath)) {
|
|
97
|
+
const existing = readProjects(prefsDir);
|
|
98
|
+
const max = getMaxProjects(prefsDir);
|
|
99
|
+
if (existing.length >= max) {
|
|
100
|
+
throw new Error(`Max projects limit reached (${max})`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
writeFileSync(filePath, `${JSON.stringify(entry, null, 2)}\n`, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Remove a project entry. No-op if missing.
|
|
109
|
+
*/
|
|
110
|
+
export function removeProject(prefsDir, name) {
|
|
111
|
+
const filePath = join(prefsDir, 'projects.d', `${name}.json`);
|
|
112
|
+
try {
|
|
113
|
+
unlinkSync(filePath);
|
|
114
|
+
} catch {
|
|
115
|
+
// no-op if missing
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Synthesize a default project from a project root directory.
|
|
121
|
+
* Used when no projects.d/ exists (single-project mode).
|
|
122
|
+
*/
|
|
123
|
+
export function synthesizeDefaultProject(projectRoot) {
|
|
124
|
+
const name = basename(projectRoot);
|
|
125
|
+
return {
|
|
126
|
+
name,
|
|
127
|
+
path: projectRoot,
|
|
128
|
+
worcaDir: join(projectRoot, '.worca'),
|
|
129
|
+
settingsPath: join(projectRoot, '.claude', 'settings.json'),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read max projects from {prefsDir}/config.json. Defaults to 20.
|
|
135
|
+
*/
|
|
136
|
+
export function getMaxProjects(prefsDir) {
|
|
137
|
+
try {
|
|
138
|
+
const raw = readFileSync(join(prefsDir, 'config.json'), 'utf8');
|
|
139
|
+
const config = JSON.parse(raw);
|
|
140
|
+
if (typeof config.maxProjects === 'number') return config.maxProjects;
|
|
141
|
+
} catch {
|
|
142
|
+
// missing or invalid
|
|
143
|
+
}
|
|
144
|
+
return DEFAULT_MAX_PROJECTS;
|
|
145
|
+
}
|