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/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--J1uxrSo.js"></script>
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-DMqA9hDY.css">
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.0.0",
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
- let conductorPath = discoverConductor(absStatus, explicitConductor);
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
- // Seed the archived set from disk so a restart doesn't re-archive past runs.
221
- const archivedRunIds = new Set();
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 broadcastHistory = () => {
225
- const list = JSON.stringify(listHistory(historyDir));
226
- for (const res of clients) res.write(`event: history\ndata: ${list}\n\n`);
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
- // conductor may appear after the server starts — re-discover if missing
231
- if (!conductorPath) conductorPath = discoverConductor(absStatus, explicitConductor);
232
- const snapshot = readSnapshot(absStatus, conductorPath);
233
- const payload = JSON.stringify(snapshot);
234
- for (const res of clients) {
235
- res.write(`event: update\ndata: ${payload}\n\n`);
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
- // Watch the directory that holds the status file (more reliable than
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(watchDir, { recursive: true });
249
- fs.watch(watchDir, schedule);
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(`[agent-conductor] watch failed: ${e.message}`);
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.writeHead(200, { "content-type": "application/json; charset=utf-8" });
259
- res.end(
260
- JSON.stringify({
261
- status: "ok",
262
- version: VERSION,
263
- watching: path.relative(process.cwd(), absStatus) || absStatus,
264
- port: server.address()?.port ?? null,
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
- if (url === "/api/state") {
271
- if (!conductorPath) conductorPath = discoverConductor(absStatus, explicitConductor);
272
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
273
- res.end(JSON.stringify(readSnapshot(absStatus, conductorPath)));
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 (url === "/history") {
278
- res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
279
- res.end(JSON.stringify(listHistory(historyDir)));
280
- return;
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
- if (!rec) {
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
- // send an immediate snapshot + history so the board paints on connect
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 heartbeat = setInterval(() => res.write(": ping\n\n"), 25000);
548
+ const hb = setInterval(() => res.write(": ping\n\n"), 25000);
311
549
  req.on("close", () => {
312
- clearInterval(heartbeat);
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
- // .conductor/server.json — the source of truth for which port we landed on,
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(watchDir, { recursive: true });
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({ server, conductorPath, absStatus, serverJsonPath });
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
  }