conductor-board 1.3.0 → 2.1.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/bin/cli.js +101 -12
- package/cli/check.js +89 -0
- package/cli/clean.js +121 -0
- package/cli/complete.js +169 -0
- package/cli/discover.js +42 -0
- package/cli/ps.js +38 -0
- package/cli/setup.js +19 -6
- package/cli/stop.js +71 -0
- package/cli/validate.js +22 -3
- package/cli/writer.js +218 -0
- package/dist/assets/index-D0azgMCk.css +1 -0
- package/dist/assets/index-DV9PNUB3.js +34 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/server.js +277 -12
- package/dist/assets/index-Dauby4vm.js +0 -34
- package/dist/assets/index-nvl4ljRP.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-DV9PNUB3.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-D0azgMCk.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
package/package.json
CHANGED
package/server/server.js
CHANGED
|
@@ -200,6 +200,22 @@ function archiveIfDone(historyDir, snapshot, archived) {
|
|
|
200
200
|
const status = snapshot.status;
|
|
201
201
|
if (!status || (status.status !== "done" && status.status !== "failed")) return null;
|
|
202
202
|
|
|
203
|
+
// Mandatory suggestions: a successful run must capture what it learned before
|
|
204
|
+
// it's saved to history. A done run with no suggestions doesn't archive (the
|
|
205
|
+
// loop has to feed itself) — failed runs are exempt.
|
|
206
|
+
if (status.status === "done") {
|
|
207
|
+
const sug = status.suggestions;
|
|
208
|
+
if (!Array.isArray(sug) || sug.length === 0) {
|
|
209
|
+
if (!status._noSuggestionsWarned) {
|
|
210
|
+
console.warn(
|
|
211
|
+
`[conductor-board] "${status.workflow || "workflow"}" is done but has no suggestions — not archiving until it captures 3–5. (spec §9.2)`,
|
|
212
|
+
);
|
|
213
|
+
status._noSuggestionsWarned = true;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
203
219
|
const runId =
|
|
204
220
|
status.run_id ||
|
|
205
221
|
(status.started_at ? safeId(status.started_at).replace(/-\d+Z$/, "") : null);
|
|
@@ -240,6 +256,113 @@ function archiveIfDone(historyDir, snapshot, archived) {
|
|
|
240
256
|
}
|
|
241
257
|
}
|
|
242
258
|
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Insights ledger — a persistent, accumulating memory per workflow.
|
|
261
|
+
//
|
|
262
|
+
// insights.json is the source of truth (structured, with apply/dismiss state);
|
|
263
|
+
// insights.md is a human- and agent-readable view regenerated on every change.
|
|
264
|
+
// Suggestions from each completed run are merged in (deduped) as `open`; the
|
|
265
|
+
// board lets the user apply or dismiss them, which updates both files.
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
const insightsPaths = (wf) => ({
|
|
269
|
+
json: path.join(wf.dir, "insights.json"),
|
|
270
|
+
md: path.join(wf.dir, "insights.md"),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const insightKey = (s) =>
|
|
274
|
+
`${s.type || "note"}::${s.step || ""}::${String(s.title || "")
|
|
275
|
+
.trim()
|
|
276
|
+
.toLowerCase()
|
|
277
|
+
.replace(/\s+/g, " ")}`;
|
|
278
|
+
|
|
279
|
+
function loadInsights(wf) {
|
|
280
|
+
try {
|
|
281
|
+
const l = JSON.parse(fs.readFileSync(insightsPaths(wf).json, "utf8"));
|
|
282
|
+
if (l && Array.isArray(l.items)) return l;
|
|
283
|
+
} catch {
|
|
284
|
+
/* fresh ledger */
|
|
285
|
+
}
|
|
286
|
+
return { workflow: wf.name, items: [] };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function renderInsightsMd(ledger) {
|
|
290
|
+
const of = (st) => ledger.items.filter((i) => i.status === st);
|
|
291
|
+
const line = (i) =>
|
|
292
|
+
`- [${i.type}] ${i.title}${i.confidence ? ` · ${i.confidence}` : ""}${
|
|
293
|
+
i.provenance ? ` · _${i.provenance}_` : ""
|
|
294
|
+
}`;
|
|
295
|
+
const sect = (title, items) =>
|
|
296
|
+
`## ${title}\n${items.length ? items.map(line).join("\n") : "_none yet_"}\n`;
|
|
297
|
+
return (
|
|
298
|
+
`# Conductor insights — ${ledger.workflow}\n\n` +
|
|
299
|
+
`_Accumulated across runs. The agent reads this at the start of each run to carry ` +
|
|
300
|
+
`learnings forward; the board appends new insights and tracks which you apply or dismiss._\n\n` +
|
|
301
|
+
sect("Open", of("open")) +
|
|
302
|
+
`\n` +
|
|
303
|
+
sect("Applied", of("applied")) +
|
|
304
|
+
`\n` +
|
|
305
|
+
sect("Dismissed", of("dismissed"))
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function saveInsights(wf, ledger) {
|
|
310
|
+
const { json, md } = insightsPaths(wf);
|
|
311
|
+
try {
|
|
312
|
+
fs.mkdirSync(wf.dir, { recursive: true });
|
|
313
|
+
fs.writeFileSync(json, JSON.stringify(ledger, null, 2));
|
|
314
|
+
fs.writeFileSync(md, renderInsightsMd(ledger));
|
|
315
|
+
} catch (e) {
|
|
316
|
+
console.warn(`[conductor-board] could not write insights: ${e.message}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Merge a run's suggestions into the ledger as `open`, deduped by key. */
|
|
321
|
+
function mergeInsights(wf, suggestions, runId, at) {
|
|
322
|
+
if (!Array.isArray(suggestions) || suggestions.length === 0) return false;
|
|
323
|
+
const ledger = loadInsights(wf);
|
|
324
|
+
const have = new Set(ledger.items.map(insightKey));
|
|
325
|
+
let added = 0;
|
|
326
|
+
for (const s of suggestions) {
|
|
327
|
+
if (!s || !s.title) continue;
|
|
328
|
+
const key = insightKey(s);
|
|
329
|
+
if (have.has(key)) continue;
|
|
330
|
+
have.add(key);
|
|
331
|
+
ledger.items.push({
|
|
332
|
+
key,
|
|
333
|
+
type: s.type || "note",
|
|
334
|
+
step: s.step,
|
|
335
|
+
title: s.title,
|
|
336
|
+
rationale: s.rationale,
|
|
337
|
+
current: s.current,
|
|
338
|
+
proposed: s.proposed,
|
|
339
|
+
confidence: s.confidence,
|
|
340
|
+
source_heartbeat: s.source_heartbeat,
|
|
341
|
+
status: "open",
|
|
342
|
+
provenance: `run ${runId || "?"}`,
|
|
343
|
+
first_seen_at: at || new Date().toISOString(),
|
|
344
|
+
});
|
|
345
|
+
added += 1;
|
|
346
|
+
}
|
|
347
|
+
if (added) saveInsights(wf, ledger);
|
|
348
|
+
return added > 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Mark ledger items applied/dismissed/open by key. */
|
|
352
|
+
function decideInsights(wf, keys, status) {
|
|
353
|
+
const ledger = loadInsights(wf);
|
|
354
|
+
let changed = 0;
|
|
355
|
+
for (const it of ledger.items) {
|
|
356
|
+
if (keys.includes(it.key) && it.status !== status) {
|
|
357
|
+
it.status = status;
|
|
358
|
+
it.decided_at = new Date().toISOString();
|
|
359
|
+
changed += 1;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (changed) saveInsights(wf, ledger);
|
|
363
|
+
return changed;
|
|
364
|
+
}
|
|
365
|
+
|
|
243
366
|
function serveStatic(req, res) {
|
|
244
367
|
let urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
|
|
245
368
|
if (urlPath === "/") urlPath = "/index.html";
|
|
@@ -363,7 +486,11 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
|
|
|
363
486
|
for (const wf of discoverWorkflows(conductorDir, absStatus, explicitConductor)) {
|
|
364
487
|
const snap = snapshotFor(wf);
|
|
365
488
|
sendAll("update", snap);
|
|
366
|
-
|
|
489
|
+
const rec = archiveIfDone(wf.historyDir, snap, archivedSetFor(wf));
|
|
490
|
+
if (rec) {
|
|
491
|
+
if (mergeInsights(wf, rec.snapshot?.status?.suggestions, rec.run_id, rec.completed_at)) {
|
|
492
|
+
sendAll("insights", { workflow: wf.name, ledger: loadInsights(wf) });
|
|
493
|
+
}
|
|
367
494
|
sendAll("history", { workflow: wf.name, runs: listHistory(wf.historyDir) });
|
|
368
495
|
}
|
|
369
496
|
}
|
|
@@ -395,6 +522,12 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
|
|
|
395
522
|
runs: listHistory(wf.historyDir),
|
|
396
523
|
})}\n\n`,
|
|
397
524
|
);
|
|
525
|
+
res.write(
|
|
526
|
+
`event: insights\ndata: ${JSON.stringify({
|
|
527
|
+
workflow: wf.name,
|
|
528
|
+
ledger: loadInsights(wf),
|
|
529
|
+
})}\n\n`,
|
|
530
|
+
);
|
|
398
531
|
}
|
|
399
532
|
};
|
|
400
533
|
|
|
@@ -408,12 +541,53 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
|
|
|
408
541
|
const wfs = () => discoverWorkflows(conductorDir, absStatus, explicitConductor);
|
|
409
542
|
|
|
410
543
|
if (url === "/health") {
|
|
544
|
+
const workflows = {};
|
|
545
|
+
for (const wf of wfs()) {
|
|
546
|
+
let kb = 0;
|
|
547
|
+
let beats = 0;
|
|
548
|
+
let st = null;
|
|
549
|
+
try {
|
|
550
|
+
kb = Math.round(fs.statSync(wf.statusPath).size / 1024);
|
|
551
|
+
} catch {
|
|
552
|
+
/* no status file yet */
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
const s = JSON.parse(fs.readFileSync(wf.statusPath, "utf8"));
|
|
556
|
+
st = s.status ?? "idle";
|
|
557
|
+
beats = Object.values(s.steps || {}).reduce(
|
|
558
|
+
(n, x) => n + (Array.isArray(x && x.heartbeat) ? x.heartbeat.length : 0),
|
|
559
|
+
0,
|
|
560
|
+
);
|
|
561
|
+
} catch {
|
|
562
|
+
/* unreadable / not started */
|
|
563
|
+
}
|
|
564
|
+
let archiveLines = 0;
|
|
565
|
+
try {
|
|
566
|
+
archiveLines = fs
|
|
567
|
+
.readFileSync(path.join(wf.dir, "heartbeat-archive.jsonl"), "utf8")
|
|
568
|
+
.split("\n")
|
|
569
|
+
.filter(Boolean).length;
|
|
570
|
+
} catch {
|
|
571
|
+
/* no archive */
|
|
572
|
+
}
|
|
573
|
+
workflows[wf.name] = {
|
|
574
|
+
status: st ?? "idle",
|
|
575
|
+
status_file_kb: kb,
|
|
576
|
+
heartbeat_count: beats,
|
|
577
|
+
archive_lines: archiveLines,
|
|
578
|
+
history_count: listHistory(wf.historyDir).length,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
411
581
|
return json(res, 200, {
|
|
412
582
|
status: "ok",
|
|
413
583
|
version: VERSION,
|
|
414
|
-
|
|
584
|
+
pid: process.pid,
|
|
415
585
|
port: server.address()?.port ?? null,
|
|
416
|
-
|
|
586
|
+
uptime_seconds: Math.round(process.uptime()),
|
|
587
|
+
memory_mb: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
|
588
|
+
watching: path.relative(process.cwd(), conductorDir) || conductorDir,
|
|
589
|
+
workflows,
|
|
590
|
+
sse_connections: clients.size,
|
|
417
591
|
});
|
|
418
592
|
}
|
|
419
593
|
|
|
@@ -448,23 +622,32 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
|
|
|
448
622
|
const wf = findWf(decodeURIComponent(m[1]));
|
|
449
623
|
if (!wf) return json(res, 404, { error: "workflow not found" });
|
|
450
624
|
readBody(req).then((bodyStr) => {
|
|
451
|
-
let
|
|
625
|
+
let payload;
|
|
452
626
|
try {
|
|
453
|
-
|
|
627
|
+
payload = JSON.parse(bodyStr || "{}").suggestions;
|
|
454
628
|
} catch {
|
|
455
629
|
return json(res, 400, { error: "invalid request body" });
|
|
456
630
|
}
|
|
457
|
-
if (!Array.isArray(
|
|
631
|
+
if (!Array.isArray(payload) || payload.length === 0)
|
|
632
|
+
return json(res, 400, { error: "suggestions must be a non-empty array" });
|
|
458
633
|
if (!wf.conductorPath || !fs.existsSync(wf.conductorPath))
|
|
459
634
|
return json(res, 400, { error: "no conductor file for this workflow" });
|
|
460
635
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
636
|
+
// Accept either a list of ids (resolved against the live status) or full
|
|
637
|
+
// suggestion objects — the latter lets a past run's suggestions apply
|
|
638
|
+
// even after the live status has moved on to another run.
|
|
639
|
+
let chosen;
|
|
640
|
+
if (payload.every((x) => typeof x === "string")) {
|
|
641
|
+
let status;
|
|
642
|
+
try {
|
|
643
|
+
status = JSON.parse(fs.readFileSync(wf.statusPath, "utf8"));
|
|
644
|
+
} catch {
|
|
645
|
+
return json(res, 500, { error: "could not read status.json" });
|
|
646
|
+
}
|
|
647
|
+
chosen = (status.suggestions || []).filter((s) => payload.includes(s.id));
|
|
648
|
+
} else {
|
|
649
|
+
chosen = payload.filter((x) => x && typeof x === "object");
|
|
466
650
|
}
|
|
467
|
-
const chosen = (status.suggestions || []).filter((s) => ids.includes(s.id));
|
|
468
651
|
if (chosen.length === 0) return json(res, 400, { error: "no matching suggestions" });
|
|
469
652
|
|
|
470
653
|
const original = fs.readFileSync(wf.conductorPath, "utf8");
|
|
@@ -498,6 +681,9 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
|
|
|
498
681
|
}
|
|
499
682
|
return json(res, 500, { error: `write failed, rolled back: ${e.message}` });
|
|
500
683
|
}
|
|
684
|
+
// record the decision in the persistent ledger and push it to clients
|
|
685
|
+
decideInsights(wf, chosen.map(insightKey), "applied");
|
|
686
|
+
sendAll("insights", { workflow: wf.name, ledger: loadInsights(wf) });
|
|
501
687
|
return json(res, 200, {
|
|
502
688
|
ok: true,
|
|
503
689
|
applied: chosen.map((s) => s.id),
|
|
@@ -511,6 +697,80 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
|
|
|
511
697
|
const wf = findWf(decodeURIComponent(m[1]));
|
|
512
698
|
return wf ? json(res, 200, snapshotFor(wf)) : json(res, 404, { error: "not found" });
|
|
513
699
|
}
|
|
700
|
+
if (req.method === "GET" && (m = url.match(/^\/api\/workflow\/([^/]+)\/insights$/))) {
|
|
701
|
+
const wf = findWf(decodeURIComponent(m[1]));
|
|
702
|
+
return wf ? json(res, 200, loadInsights(wf)) : json(res, 404, { error: "not found" });
|
|
703
|
+
}
|
|
704
|
+
if (req.method === "POST" && (m = url.match(/^\/api\/workflow\/([^/]+)\/insights\/decide$/))) {
|
|
705
|
+
const wf = findWf(decodeURIComponent(m[1]));
|
|
706
|
+
if (!wf) return json(res, 404, { error: "not found" });
|
|
707
|
+
readBody(req).then((bodyStr) => {
|
|
708
|
+
let body;
|
|
709
|
+
try {
|
|
710
|
+
body = JSON.parse(bodyStr || "{}");
|
|
711
|
+
} catch {
|
|
712
|
+
return json(res, 400, { error: "invalid request body" });
|
|
713
|
+
}
|
|
714
|
+
const keys = Array.isArray(body.keys) ? body.keys : [];
|
|
715
|
+
if (!["open", "applied", "dismissed"].includes(body.status))
|
|
716
|
+
return json(res, 400, { error: "status must be open | applied | dismissed" });
|
|
717
|
+
const changed = decideInsights(wf, keys, body.status);
|
|
718
|
+
sendAll("insights", { workflow: wf.name, ledger: loadInsights(wf) });
|
|
719
|
+
return json(res, 200, { ok: true, changed });
|
|
720
|
+
});
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// human approval — record the decisions into status.json (§4.4)
|
|
725
|
+
if (req.method === "POST" && (m = url.match(/^\/api\/workflow\/([^/]+)\/approve$/))) {
|
|
726
|
+
const wf = findWf(decodeURIComponent(m[1]));
|
|
727
|
+
if (!wf) return json(res, 404, { error: "not found" });
|
|
728
|
+
readBody(req).then((bodyStr) => {
|
|
729
|
+
let body;
|
|
730
|
+
try {
|
|
731
|
+
body = JSON.parse(bodyStr || "{}");
|
|
732
|
+
} catch {
|
|
733
|
+
return json(res, 400, { error: "invalid request body" });
|
|
734
|
+
}
|
|
735
|
+
const stepId = body.step;
|
|
736
|
+
const decisions = Array.isArray(body.decisions) ? body.decisions : [];
|
|
737
|
+
let status;
|
|
738
|
+
try {
|
|
739
|
+
status = JSON.parse(fs.readFileSync(wf.statusPath, "utf8"));
|
|
740
|
+
} catch {
|
|
741
|
+
return json(res, 500, { error: "could not read status.json" });
|
|
742
|
+
}
|
|
743
|
+
const step = status.steps && status.steps[stepId];
|
|
744
|
+
if (!step) return json(res, 404, { error: `no such step "${stepId}"` });
|
|
745
|
+
|
|
746
|
+
step.approval = step.approval || {};
|
|
747
|
+
const items = Array.isArray(step.approval.items) ? step.approval.items : [];
|
|
748
|
+
const byLabel = new Map(items.map((i) => [i.label, i]));
|
|
749
|
+
for (const d of decisions) {
|
|
750
|
+
if (!d || !d.label) continue;
|
|
751
|
+
const dec = d.decision === "approved" ? "approved" : "rejected";
|
|
752
|
+
if (byLabel.has(d.label)) byLabel.get(d.label).decision = dec;
|
|
753
|
+
else {
|
|
754
|
+
const it = { label: d.label, decision: dec };
|
|
755
|
+
items.push(it);
|
|
756
|
+
byLabel.set(d.label, it);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
step.approval.items = items;
|
|
760
|
+
step.approval.decided_at = new Date().toISOString();
|
|
761
|
+
const anyRejected = items.some((i) => i.decision === "rejected");
|
|
762
|
+
step.approval.resolution = anyRejected ? "rejected" : "approved";
|
|
763
|
+
step.gate = anyRejected ? "rejected" : "approved";
|
|
764
|
+
|
|
765
|
+
try {
|
|
766
|
+
fs.writeFileSync(wf.statusPath, JSON.stringify(status, null, 2));
|
|
767
|
+
} catch (e) {
|
|
768
|
+
return json(res, 500, { error: `write failed: ${e.message}` });
|
|
769
|
+
}
|
|
770
|
+
return json(res, 200, { ok: true, resolution: step.approval.resolution });
|
|
771
|
+
});
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
514
774
|
if ((m = url.match(/^\/api\/workflow\/([^/]+)\/history$/))) {
|
|
515
775
|
const wf = findWf(decodeURIComponent(m[1]));
|
|
516
776
|
return wf ? json(res, 200, listHistory(wf.historyDir)) : json(res, 404, { error: "not found" });
|
|
@@ -537,6 +797,11 @@ export function startServer({ statusPath, conductorPath: explicitConductor, port
|
|
|
537
797
|
}
|
|
538
798
|
|
|
539
799
|
if (url === "/events") {
|
|
800
|
+
if (clients.size >= 5) {
|
|
801
|
+
// cap concurrent streams so stray tabs can't silently pile up memory
|
|
802
|
+
res.writeHead(429, { "content-type": "text/plain" });
|
|
803
|
+
return res.end("Too many board connections (max 5). Close a tab and reload.");
|
|
804
|
+
}
|
|
540
805
|
res.writeHead(200, {
|
|
541
806
|
"content-type": "text/event-stream",
|
|
542
807
|
"cache-control": "no-cache",
|