conductor-board 1.0.0 → 1.2.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/README.md +1 -1
- package/bin/cli.js +2 -1
- package/cli/setup.js +15 -4
- package/dist/assets/index-C0erDplC.css +1 -0
- package/dist/assets/index-D02IYg9C.js +34 -0
- package/dist/index.html +2 -2
- package/package.json +3 -2
- package/server/server.js +313 -61
- package/dist/assets/index--J1uxrSo.js +0 -34
- package/dist/assets/index-DMqA9hDY.css +0 -1
package/dist/index.html
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<meta name="theme-color" content="#0a0a0f" />
|
|
8
8
|
<title>Agent Conductor — Board</title>
|
|
9
|
-
<script type="module" crossorigin src="./assets/index
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-D02IYg9C.js"></script>
|
|
10
10
|
<link rel="modulepreload" crossorigin href="./assets/motion-Dmvx5jlk.js">
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/yaml-NA7d4LV6.js">
|
|
12
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
12
|
+
<link rel="stylesheet" crossorigin href="./assets/index-C0erDplC.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-board",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Gated workflows for AI agents — live Kanban board included",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "mettafive",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
9
|
-
"conductor-board": "bin/cli.js"
|
|
9
|
+
"conductor-board": "bin/cli.js",
|
|
10
|
+
"3042": "bin/cli.js"
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"bin",
|
package/server/server.js
CHANGED
|
@@ -13,6 +13,60 @@ import http from "node:http";
|
|
|
13
13
|
import fs from "node:fs";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
|
+
import yaml from "js-yaml";
|
|
17
|
+
import { validateConductor } from "../cli/validate.js";
|
|
18
|
+
|
|
19
|
+
const readBody = (req) =>
|
|
20
|
+
new Promise((resolve) => {
|
|
21
|
+
let data = "";
|
|
22
|
+
req.on("data", (c) => (data += c));
|
|
23
|
+
req.on("end", () => resolve(data));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Apply optimization suggestions to a parsed conductor doc (in place). */
|
|
27
|
+
function applyMutations(doc, suggestions) {
|
|
28
|
+
doc.steps = Array.isArray(doc.steps) ? doc.steps : [];
|
|
29
|
+
const byId = new Map(doc.steps.map((s) => [s.id, s]));
|
|
30
|
+
for (const sug of suggestions) {
|
|
31
|
+
const step = sug.step ? byId.get(sug.step) : null;
|
|
32
|
+
switch (sug.type) {
|
|
33
|
+
case "instruction":
|
|
34
|
+
if (step) {
|
|
35
|
+
if (sug.current && typeof step.instruction === "string" && step.instruction.includes(sug.current)) {
|
|
36
|
+
step.instruction = step.instruction.replace(sug.current, sug.proposed ?? "");
|
|
37
|
+
} else if (sug.proposed) {
|
|
38
|
+
step.instruction = sug.proposed;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
break;
|
|
42
|
+
case "gate":
|
|
43
|
+
if (step && Array.isArray(step.gate)) {
|
|
44
|
+
const i = step.gate.findIndex((g) => g === sug.current);
|
|
45
|
+
if (i >= 0 && sug.proposed != null) step.gate[i] = sug.proposed;
|
|
46
|
+
else if (sug.proposed != null) step.gate.push(sug.proposed);
|
|
47
|
+
}
|
|
48
|
+
break;
|
|
49
|
+
case "new_gate":
|
|
50
|
+
if (step && sug.proposed != null) {
|
|
51
|
+
step.gate = Array.isArray(step.gate) ? step.gate : [];
|
|
52
|
+
step.gate.push(sug.proposed);
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
case "new_step":
|
|
56
|
+
doc.steps.push({
|
|
57
|
+
id: sug.step || `step-${doc.steps.length + 1}`,
|
|
58
|
+
instruction: sug.proposed || "TODO",
|
|
59
|
+
gate: ["TODO: add a gate criterion"],
|
|
60
|
+
});
|
|
61
|
+
break;
|
|
62
|
+
case "remove_step":
|
|
63
|
+
if (sug.step) doc.steps = doc.steps.filter((s) => s.id !== sug.step);
|
|
64
|
+
break;
|
|
65
|
+
// `reorder` is not auto-applied in v1
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return doc;
|
|
69
|
+
}
|
|
16
70
|
|
|
17
71
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
72
|
const DIST = path.resolve(__dirname, "..", "dist");
|
|
@@ -207,89 +261,279 @@ function serveStatic(req, res) {
|
|
|
207
261
|
fs.createReadStream(filePath).pipe(res);
|
|
208
262
|
}
|
|
209
263
|
|
|
264
|
+
/** Best-effort workflow name without parsing YAML (keeps the server dep-free). */
|
|
265
|
+
function workflowName(statusPath, conductorPath, dir) {
|
|
266
|
+
try {
|
|
267
|
+
if (fs.existsSync(statusPath)) {
|
|
268
|
+
const s = JSON.parse(fs.readFileSync(statusPath, "utf8"));
|
|
269
|
+
if (s && typeof s.workflow === "string") return s.workflow;
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
/* ignore */
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
if (conductorPath && fs.existsSync(conductorPath)) {
|
|
276
|
+
const m = fs.readFileSync(conductorPath, "utf8").match(/^name:\s*(.+)$/m);
|
|
277
|
+
if (m) return m[1].trim();
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
/* ignore */
|
|
281
|
+
}
|
|
282
|
+
return path.basename(dir);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Discover workflows under the .conductor root. Supports both the flat layout
|
|
287
|
+
* (.conductor/status.json — a single workflow, for v1 backwards compatibility)
|
|
288
|
+
* and the subdirectory layout (.conductor/<name>/status.json — many workflows).
|
|
289
|
+
*/
|
|
290
|
+
function discoverWorkflows(conductorDir, explicitStatus, explicitConductor) {
|
|
291
|
+
const found = [];
|
|
292
|
+
const seen = new Set();
|
|
293
|
+
const add = (name, dir, statusPath, conductorPath) => {
|
|
294
|
+
if (seen.has(name)) return;
|
|
295
|
+
seen.add(name);
|
|
296
|
+
found.push({
|
|
297
|
+
name,
|
|
298
|
+
dir,
|
|
299
|
+
statusPath,
|
|
300
|
+
conductorPath,
|
|
301
|
+
historyDir: path.join(dir, "history"),
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// flat / explicit --path
|
|
306
|
+
const flatStatus = explicitStatus || path.join(conductorDir, "status.json");
|
|
307
|
+
const flatConductor = discoverConductor(flatStatus, explicitConductor);
|
|
308
|
+
if (fs.existsSync(flatStatus) || (flatConductor && fs.existsSync(flatConductor))) {
|
|
309
|
+
add(workflowName(flatStatus, flatConductor, conductorDir), conductorDir, flatStatus, flatConductor);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// subdirectories
|
|
313
|
+
if (fs.existsSync(conductorDir)) {
|
|
314
|
+
for (const entry of fs.readdirSync(conductorDir, { withFileTypes: true })) {
|
|
315
|
+
if (!entry.isDirectory() || entry.name === "history") continue;
|
|
316
|
+
const dir = path.join(conductorDir, entry.name);
|
|
317
|
+
const sp = path.join(dir, "status.json");
|
|
318
|
+
const cp = fs.existsSync(path.join(dir, "conductor.yaml"))
|
|
319
|
+
? path.join(dir, "conductor.yaml")
|
|
320
|
+
: discoverConductor(sp, null);
|
|
321
|
+
if (fs.existsSync(sp) || (cp && fs.existsSync(cp))) {
|
|
322
|
+
add(workflowName(sp, cp, dir), dir, sp, cp);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return found;
|
|
327
|
+
}
|
|
328
|
+
|
|
210
329
|
export function startServer({ statusPath, conductorPath: explicitConductor, port }) {
|
|
211
330
|
const absStatus = path.resolve(process.cwd(), statusPath);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const watchDir = path.dirname(absStatus);
|
|
215
|
-
const historyDir = path.join(watchDir, "history");
|
|
331
|
+
const conductorDir = path.dirname(absStatus);
|
|
216
332
|
|
|
217
333
|
/** @type {Set<http.ServerResponse>} */
|
|
218
334
|
const clients = new Set();
|
|
335
|
+
/** @type {Map<string, Set<string>>} per-workflow archived run ids */
|
|
336
|
+
const archivedByWf = new Map();
|
|
337
|
+
|
|
338
|
+
const archivedSetFor = (wf) => {
|
|
339
|
+
let set = archivedByWf.get(wf.name);
|
|
340
|
+
if (!set) {
|
|
341
|
+
set = new Set();
|
|
342
|
+
for (const r of listHistory(wf.historyDir)) if (r.run_id) set.add(r.run_id);
|
|
343
|
+
archivedByWf.set(wf.name, set);
|
|
344
|
+
}
|
|
345
|
+
return set;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const snapshotFor = (wf) => {
|
|
349
|
+
const snap = readSnapshot(wf.statusPath, wf.conductorPath);
|
|
350
|
+
snap.workflow = wf.name;
|
|
351
|
+
return snap;
|
|
352
|
+
};
|
|
219
353
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
for (const r of listHistory(historyDir)) if (r.run_id) archivedRunIds.add(r.run_id);
|
|
354
|
+
const findWf = (name) =>
|
|
355
|
+
discoverWorkflows(conductorDir, absStatus, explicitConductor).find((w) => w.name === name);
|
|
223
356
|
|
|
224
|
-
const
|
|
225
|
-
const
|
|
226
|
-
for (const res of clients) res.write(
|
|
357
|
+
const sendAll = (event, data) => {
|
|
358
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
359
|
+
for (const res of clients) res.write(payload);
|
|
227
360
|
};
|
|
228
361
|
|
|
229
362
|
const broadcast = () => {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
363
|
+
for (const wf of discoverWorkflows(conductorDir, absStatus, explicitConductor)) {
|
|
364
|
+
const snap = snapshotFor(wf);
|
|
365
|
+
sendAll("update", snap);
|
|
366
|
+
if (archiveIfDone(wf.historyDir, snap, archivedSetFor(wf))) {
|
|
367
|
+
sendAll("history", { workflow: wf.name, runs: listHistory(wf.historyDir) });
|
|
368
|
+
}
|
|
236
369
|
}
|
|
237
|
-
if (archiveIfDone(historyDir, snapshot, archivedRunIds)) broadcastHistory();
|
|
238
370
|
};
|
|
239
371
|
|
|
240
|
-
//
|
|
241
|
-
// watching a single file that gets atomically replaced). Debounced.
|
|
372
|
+
// Recursively watch the .conductor root so subdirectory status files are seen.
|
|
242
373
|
let timer = null;
|
|
243
374
|
const schedule = () => {
|
|
244
375
|
clearTimeout(timer);
|
|
245
376
|
timer = setTimeout(broadcast, 80);
|
|
246
377
|
};
|
|
247
378
|
try {
|
|
248
|
-
fs.mkdirSync(
|
|
249
|
-
|
|
379
|
+
fs.mkdirSync(conductorDir, { recursive: true });
|
|
380
|
+
try {
|
|
381
|
+
fs.watch(conductorDir, { recursive: true }, schedule);
|
|
382
|
+
} catch {
|
|
383
|
+
fs.watch(conductorDir, schedule); // platforms without recursive watch
|
|
384
|
+
}
|
|
250
385
|
} catch (e) {
|
|
251
|
-
console.warn(`[
|
|
386
|
+
console.warn(`[conductor-board] watch failed: ${e.message}`);
|
|
252
387
|
}
|
|
253
388
|
|
|
389
|
+
const sendSnapshotsTo = (res) => {
|
|
390
|
+
for (const wf of discoverWorkflows(conductorDir, absStatus, explicitConductor)) {
|
|
391
|
+
res.write(`event: update\ndata: ${JSON.stringify(snapshotFor(wf))}\n\n`);
|
|
392
|
+
res.write(
|
|
393
|
+
`event: history\ndata: ${JSON.stringify({
|
|
394
|
+
workflow: wf.name,
|
|
395
|
+
runs: listHistory(wf.historyDir),
|
|
396
|
+
})}\n\n`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const json = (res, code, body) => {
|
|
402
|
+
res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
|
|
403
|
+
res.end(JSON.stringify(body));
|
|
404
|
+
};
|
|
405
|
+
|
|
254
406
|
const server = http.createServer((req, res) => {
|
|
255
407
|
const url = (req.url || "/").split("?")[0];
|
|
408
|
+
const wfs = () => discoverWorkflows(conductorDir, absStatus, explicitConductor);
|
|
256
409
|
|
|
257
410
|
if (url === "/health") {
|
|
258
|
-
res
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
411
|
+
return json(res, 200, {
|
|
412
|
+
status: "ok",
|
|
413
|
+
version: VERSION,
|
|
414
|
+
watching: path.relative(process.cwd(), conductorDir) || conductorDir,
|
|
415
|
+
port: server.address()?.port ?? null,
|
|
416
|
+
workflows: wfs().map((w) => w.name),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---- multi-workflow API ----
|
|
421
|
+
if (url === "/api/workflows") {
|
|
422
|
+
return json(
|
|
423
|
+
res,
|
|
424
|
+
200,
|
|
425
|
+
wfs().map((wf) => {
|
|
426
|
+
const snap = snapshotFor(wf);
|
|
427
|
+
const st = snap.status?.status;
|
|
428
|
+
return {
|
|
429
|
+
name: wf.name,
|
|
430
|
+
status: st ?? "idle",
|
|
431
|
+
active: st === "running",
|
|
432
|
+
done: snap.status?.steps
|
|
433
|
+
? Object.values(snap.status.steps).filter((s) => s && s.status === "done").length
|
|
434
|
+
: 0,
|
|
435
|
+
total: snap.status?.steps ? Object.keys(snap.status.steps).length : 0,
|
|
436
|
+
started_at: snap.status?.started_at ?? null,
|
|
437
|
+
runs: listHistory(wf.historyDir).length,
|
|
438
|
+
hasConductor: !!snap.conductorYaml,
|
|
439
|
+
};
|
|
265
440
|
}),
|
|
266
441
|
);
|
|
267
|
-
return;
|
|
268
442
|
}
|
|
269
443
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
444
|
+
let m;
|
|
445
|
+
|
|
446
|
+
// apply optimization suggestions back to the conductor (mutate + backup + re-validate)
|
|
447
|
+
if (req.method === "POST" && (m = url.match(/^\/api\/workflow\/([^/]+)\/apply-suggestion$/))) {
|
|
448
|
+
const wf = findWf(decodeURIComponent(m[1]));
|
|
449
|
+
if (!wf) return json(res, 404, { error: "workflow not found" });
|
|
450
|
+
readBody(req).then((bodyStr) => {
|
|
451
|
+
let ids;
|
|
452
|
+
try {
|
|
453
|
+
ids = JSON.parse(bodyStr || "{}").suggestions;
|
|
454
|
+
} catch {
|
|
455
|
+
return json(res, 400, { error: "invalid request body" });
|
|
456
|
+
}
|
|
457
|
+
if (!Array.isArray(ids)) return json(res, 400, { error: "suggestions must be an array" });
|
|
458
|
+
if (!wf.conductorPath || !fs.existsSync(wf.conductorPath))
|
|
459
|
+
return json(res, 400, { error: "no conductor file for this workflow" });
|
|
460
|
+
|
|
461
|
+
let status;
|
|
462
|
+
try {
|
|
463
|
+
status = JSON.parse(fs.readFileSync(wf.statusPath, "utf8"));
|
|
464
|
+
} catch {
|
|
465
|
+
return json(res, 500, { error: "could not read status.json" });
|
|
466
|
+
}
|
|
467
|
+
const chosen = (status.suggestions || []).filter((s) => ids.includes(s.id));
|
|
468
|
+
if (chosen.length === 0) return json(res, 400, { error: "no matching suggestions" });
|
|
469
|
+
|
|
470
|
+
const original = fs.readFileSync(wf.conductorPath, "utf8");
|
|
471
|
+
const backup = `${wf.conductorPath}.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
472
|
+
try {
|
|
473
|
+
fs.writeFileSync(backup, original);
|
|
474
|
+
} catch {
|
|
475
|
+
/* backup best-effort */
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let doc;
|
|
479
|
+
try {
|
|
480
|
+
doc = yaml.load(original);
|
|
481
|
+
} catch (e) {
|
|
482
|
+
return json(res, 500, { error: `conductor parse error: ${e.message}` });
|
|
483
|
+
}
|
|
484
|
+
applyMutations(doc, chosen);
|
|
485
|
+
|
|
486
|
+
const errors = validateConductor(doc);
|
|
487
|
+
if (errors.length) {
|
|
488
|
+
// leave the original conductor untouched (we never wrote it)
|
|
489
|
+
return json(res, 422, { error: `would be invalid: ${errors[0]}`, errors });
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
fs.writeFileSync(wf.conductorPath, yaml.dump(doc, { lineWidth: 100 }));
|
|
493
|
+
} catch (e) {
|
|
494
|
+
try {
|
|
495
|
+
fs.writeFileSync(wf.conductorPath, original); // rollback
|
|
496
|
+
} catch {
|
|
497
|
+
/* ignore */
|
|
498
|
+
}
|
|
499
|
+
return json(res, 500, { error: `write failed, rolled back: ${e.message}` });
|
|
500
|
+
}
|
|
501
|
+
return json(res, 200, {
|
|
502
|
+
ok: true,
|
|
503
|
+
applied: chosen.map((s) => s.id),
|
|
504
|
+
backup: path.basename(backup),
|
|
505
|
+
});
|
|
506
|
+
});
|
|
274
507
|
return;
|
|
275
508
|
}
|
|
276
509
|
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
res
|
|
280
|
-
|
|
510
|
+
if ((m = url.match(/^\/api\/workflow\/([^/]+)\/state$/))) {
|
|
511
|
+
const wf = findWf(decodeURIComponent(m[1]));
|
|
512
|
+
return wf ? json(res, 200, snapshotFor(wf)) : json(res, 404, { error: "not found" });
|
|
513
|
+
}
|
|
514
|
+
if ((m = url.match(/^\/api\/workflow\/([^/]+)\/history$/))) {
|
|
515
|
+
const wf = findWf(decodeURIComponent(m[1]));
|
|
516
|
+
return wf ? json(res, 200, listHistory(wf.historyDir)) : json(res, 404, { error: "not found" });
|
|
517
|
+
}
|
|
518
|
+
if ((m = url.match(/^\/api\/workflow\/([^/]+)\/history\/(.+)$/))) {
|
|
519
|
+
const wf = findWf(decodeURIComponent(m[1]));
|
|
520
|
+
if (!wf) return json(res, 404, { error: "not found" });
|
|
521
|
+
const rec = getHistory(wf.historyDir, decodeURIComponent(m[2]));
|
|
522
|
+
return rec ? json(res, 200, rec) : json(res, 404, { error: "not found" });
|
|
281
523
|
}
|
|
282
524
|
|
|
525
|
+
// ---- backwards-compatible single-workflow API (primary = first found) ----
|
|
526
|
+
const primary = wfs()[0];
|
|
527
|
+
if (url === "/api/state") {
|
|
528
|
+
return json(res, 200, primary ? snapshotFor(primary) : { status: null, conductorYaml: null });
|
|
529
|
+
}
|
|
530
|
+
if (url === "/history") {
|
|
531
|
+
return json(res, 200, primary ? listHistory(primary.historyDir) : []);
|
|
532
|
+
}
|
|
283
533
|
if (url.startsWith("/history/")) {
|
|
284
534
|
const id = decodeURIComponent(url.slice("/history/".length));
|
|
285
|
-
const rec = getHistory(historyDir, id);
|
|
286
|
-
|
|
287
|
-
res.writeHead(404, { "content-type": "application/json" }).end('{"error":"not found"}');
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
291
|
-
res.end(JSON.stringify(rec));
|
|
292
|
-
return;
|
|
535
|
+
const rec = primary ? getHistory(primary.historyDir, id) : null;
|
|
536
|
+
return rec ? json(res, 200, rec) : json(res, 404, { error: "not found" });
|
|
293
537
|
}
|
|
294
538
|
|
|
295
539
|
if (url === "/events") {
|
|
@@ -299,17 +543,11 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
|
|
|
299
543
|
connection: "keep-alive",
|
|
300
544
|
});
|
|
301
545
|
res.write("retry: 2000\n\n");
|
|
302
|
-
|
|
303
|
-
res.write(
|
|
304
|
-
`event: update\ndata: ${JSON.stringify(
|
|
305
|
-
readSnapshot(absStatus, conductorPath),
|
|
306
|
-
)}\n\n`,
|
|
307
|
-
);
|
|
308
|
-
res.write(`event: history\ndata: ${JSON.stringify(listHistory(historyDir))}\n\n`);
|
|
546
|
+
sendSnapshotsTo(res);
|
|
309
547
|
clients.add(res);
|
|
310
|
-
const
|
|
548
|
+
const hb = setInterval(() => res.write(": ping\n\n"), 25000);
|
|
311
549
|
req.on("close", () => {
|
|
312
|
-
clearInterval(
|
|
550
|
+
clearInterval(hb);
|
|
313
551
|
clients.delete(res);
|
|
314
552
|
});
|
|
315
553
|
return;
|
|
@@ -318,15 +556,22 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
|
|
|
318
556
|
serveStatic(req, res);
|
|
319
557
|
});
|
|
320
558
|
|
|
321
|
-
|
|
322
|
-
// so a setup conductor's health check never has to hardcode a port.
|
|
323
|
-
const serverJsonPath = path.join(watchDir, "server.json");
|
|
559
|
+
const serverJsonPath = path.join(conductorDir, "server.json");
|
|
324
560
|
|
|
325
|
-
return new Promise((resolve) => {
|
|
561
|
+
return new Promise((resolve, reject) => {
|
|
562
|
+
// Reject on listen errors (e.g. EADDRINUSE) so the CLI can walk to the next
|
|
563
|
+
// port instead of crashing on an unhandled 'error' event.
|
|
564
|
+
const onError = (e) => {
|
|
565
|
+
server.off("error", onError);
|
|
566
|
+
reject(e);
|
|
567
|
+
};
|
|
568
|
+
server.once("error", onError);
|
|
326
569
|
server.listen(port, () => {
|
|
570
|
+
server.off("error", onError);
|
|
327
571
|
const actualPort = server.address().port;
|
|
572
|
+
const discovered = discoverWorkflows(conductorDir, absStatus, explicitConductor);
|
|
328
573
|
try {
|
|
329
|
-
fs.mkdirSync(
|
|
574
|
+
fs.mkdirSync(conductorDir, { recursive: true });
|
|
330
575
|
fs.writeFileSync(
|
|
331
576
|
serverJsonPath,
|
|
332
577
|
JSON.stringify(
|
|
@@ -335,6 +580,7 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
|
|
|
335
580
|
url: `http://localhost:${actualPort}`,
|
|
336
581
|
pid: process.pid,
|
|
337
582
|
started_at: new Date().toISOString(),
|
|
583
|
+
workflows: discovered.map((w) => w.name),
|
|
338
584
|
},
|
|
339
585
|
null,
|
|
340
586
|
2,
|
|
@@ -343,7 +589,13 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
|
|
|
343
589
|
} catch (e) {
|
|
344
590
|
console.warn(`[conductor-board] could not write server.json: ${e.message}`);
|
|
345
591
|
}
|
|
346
|
-
resolve({
|
|
592
|
+
resolve({
|
|
593
|
+
server,
|
|
594
|
+
serverJsonPath,
|
|
595
|
+
absStatus,
|
|
596
|
+
conductorPath: discovered[0]?.conductorPath ?? null,
|
|
597
|
+
workflows: discovered.map((w) => w.name),
|
|
598
|
+
});
|
|
347
599
|
});
|
|
348
600
|
});
|
|
349
601
|
}
|