conductor-board 1.0.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/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "conductor-board",
3
+ "version": "1.0.0",
4
+ "description": "Gated workflows for AI agents — live Kanban board included",
5
+ "license": "MIT",
6
+ "author": "mettafive",
7
+ "type": "module",
8
+ "bin": {
9
+ "conductor-board": "bin/cli.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "cli",
14
+ "server",
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "dev": "vite",
19
+ "build": "tsc -b && vite build",
20
+ "preview": "vite preview",
21
+ "start": "node bin/cli.js",
22
+ "simulate": "node scripts/simulate.js",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "keywords": [
29
+ "ai",
30
+ "agent",
31
+ "workflow",
32
+ "kanban",
33
+ "orchestration",
34
+ "gates",
35
+ "conductor",
36
+ "claude",
37
+ "codex"
38
+ ],
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/mettafive/agent-conductor.git"
42
+ },
43
+ "homepage": "https://mettafive.github.io/agent-conductor",
44
+ "bugs": {
45
+ "url": "https://github.com/mettafive/agent-conductor/issues"
46
+ },
47
+ "dependencies": {
48
+ "js-yaml": "^4.1.0"
49
+ },
50
+ "devDependencies": {
51
+ "@tailwindcss/vite": "^4.0.0",
52
+ "@types/js-yaml": "^4.0.9",
53
+ "@types/react": "^19.0.0",
54
+ "@types/react-dom": "^19.0.0",
55
+ "@vitejs/plugin-react": "^4.3.4",
56
+ "framer-motion": "^11.15.0",
57
+ "react": "^19.0.0",
58
+ "react-dom": "^19.0.0",
59
+ "tailwindcss": "^4.0.0",
60
+ "typescript": "^5.7.2",
61
+ "vite": "^6.0.5"
62
+ }
63
+ }
@@ -0,0 +1,349 @@
1
+ // Zero-dependency board server.
2
+ //
3
+ // Responsibilities:
4
+ // 1. Serve the built React app (dist/) over plain HTTP.
5
+ // 2. Expose GET /api/state — a snapshot of { conductorYaml, status }.
6
+ // 3. Stream GET /events (Server-Sent Events) — pushes a fresh snapshot every
7
+ // time .conductor/status.json (or the conductor file) changes on disk.
8
+ //
9
+ // The server never parses YAML (keeps it dependency-free) — it ships the raw
10
+ // conductor text to the browser, which parses it client-side.
11
+
12
+ import http from "node:http";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ const DIST = path.resolve(__dirname, "..", "dist");
19
+
20
+ let VERSION = "0.0.0";
21
+ try {
22
+ VERSION = JSON.parse(
23
+ fs.readFileSync(path.resolve(__dirname, "..", "package.json"), "utf8"),
24
+ ).version;
25
+ } catch {
26
+ /* version is best-effort */
27
+ }
28
+
29
+ const MIME = {
30
+ ".html": "text/html; charset=utf-8",
31
+ ".js": "text/javascript; charset=utf-8",
32
+ ".css": "text/css; charset=utf-8",
33
+ ".svg": "image/svg+xml",
34
+ ".json": "application/json; charset=utf-8",
35
+ ".woff2": "font/woff2",
36
+ ".ico": "image/x-icon",
37
+ };
38
+
39
+ /** Find the conductor definition file that pairs with a status.json. */
40
+ function discoverConductor(statusPath, explicit) {
41
+ if (explicit) return fs.existsSync(explicit) ? explicit : null;
42
+ const dir = path.dirname(statusPath);
43
+ const candidates = [
44
+ path.join(dir, "conductor.yaml"),
45
+ path.join(dir, "conductor.yml"),
46
+ ];
47
+ for (const c of candidates) if (fs.existsSync(c)) return c;
48
+ // any *.yaml / *.yml sitting next to the status file
49
+ if (fs.existsSync(dir)) {
50
+ const yaml = fs
51
+ .readdirSync(dir)
52
+ .find((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
53
+ if (yaml) return path.join(dir, yaml);
54
+ }
55
+ // fall back to a conductor file in the working directory
56
+ for (const c of ["conductor.yaml", "conductor.yml"]) {
57
+ const p = path.resolve(process.cwd(), c);
58
+ if (fs.existsSync(p)) return p;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function readSnapshot(statusPath, conductorPath) {
64
+ let status = null;
65
+ let conductorYaml = null;
66
+ try {
67
+ if (fs.existsSync(statusPath)) {
68
+ status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
69
+ }
70
+ } catch (e) {
71
+ status = { _error: `Could not parse status.json: ${e.message}` };
72
+ }
73
+ try {
74
+ if (conductorPath && fs.existsSync(conductorPath)) {
75
+ conductorYaml = fs.readFileSync(conductorPath, "utf8");
76
+ }
77
+ } catch {
78
+ /* conductor optional — board degrades gracefully */
79
+ }
80
+ return {
81
+ status,
82
+ conductorYaml,
83
+ statusPath,
84
+ conductorPath: conductorPath ?? null,
85
+ };
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // History — completed/failed runs are archived as self-contained records.
90
+ // ---------------------------------------------------------------------------
91
+
92
+ const safeId = (s) => String(s).replace(/[^a-zA-Z0-9._-]/g, "-");
93
+
94
+ function summarize(record) {
95
+ // everything except the heavy snapshot — for the sidebar list
96
+ const { snapshot, ...rest } = record;
97
+ void snapshot;
98
+ return rest;
99
+ }
100
+
101
+ function listHistory(historyDir) {
102
+ if (!fs.existsSync(historyDir)) return [];
103
+ const out = [];
104
+ for (const f of fs.readdirSync(historyDir)) {
105
+ if (!f.endsWith(".json")) continue;
106
+ try {
107
+ const rec = JSON.parse(fs.readFileSync(path.join(historyDir, f), "utf8"));
108
+ out.push({ ...summarize(rec), filename: f });
109
+ } catch {
110
+ /* skip a corrupt archive */
111
+ }
112
+ }
113
+ // newest first
114
+ const key = (r) => r.completed_at || r.archived_at || r.started_at || "";
115
+ return out.sort((a, b) => String(key(b)).localeCompare(String(key(a))));
116
+ }
117
+
118
+ /** Resolve a history record by filename (with or without .json) or by run_id. */
119
+ function getHistory(historyDir, id) {
120
+ if (!fs.existsSync(historyDir)) return null;
121
+ const read = (f) => {
122
+ try {
123
+ return JSON.parse(fs.readFileSync(path.join(historyDir, f), "utf8"));
124
+ } catch {
125
+ return null;
126
+ }
127
+ };
128
+ // exact filename
129
+ for (const cand of [id, `${id}.json`]) {
130
+ if (cand.endsWith(".json") && fs.existsSync(path.join(historyDir, cand))) {
131
+ const rec = read(cand);
132
+ if (rec) return rec;
133
+ }
134
+ }
135
+ // scan: match run_id or filename
136
+ for (const f of fs.readdirSync(historyDir)) {
137
+ if (!f.endsWith(".json")) continue;
138
+ const rec = read(f);
139
+ if (rec && (rec.run_id === id || f === id || f === `${id}.json`)) return rec;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /** Archive a run once it is done/failed. Returns the record if newly written. */
145
+ function archiveIfDone(historyDir, snapshot, archived) {
146
+ const status = snapshot.status;
147
+ if (!status || (status.status !== "done" && status.status !== "failed")) return null;
148
+
149
+ const runId =
150
+ status.run_id ||
151
+ (status.started_at ? safeId(status.started_at).replace(/-\d+Z$/, "") : null);
152
+ if (!runId || archived.has(runId)) return null;
153
+
154
+ const workflow = status.workflow || "workflow";
155
+ // {run_id}_{workflow}.json — e.g. 2026-06-03T14-30-00_treatment-page.json
156
+ const file = path.join(historyDir, `${safeId(runId)}_${safeId(workflow)}.json`);
157
+ if (fs.existsSync(file)) {
158
+ archived.add(runId);
159
+ return null;
160
+ }
161
+
162
+ const steps = status.steps || {};
163
+ const total = Object.keys(steps).length;
164
+ const done = Object.values(steps).filter((s) => s && s.status === "done").length;
165
+
166
+ const record = {
167
+ run_id: runId,
168
+ workflow: status.workflow || "workflow",
169
+ status: status.status,
170
+ started_at: status.started_at || null,
171
+ completed_at: status.completed_at || new Date().toISOString(),
172
+ archived_at: new Date().toISOString(),
173
+ done,
174
+ total,
175
+ snapshot: { status, conductorYaml: snapshot.conductorYaml ?? null },
176
+ };
177
+
178
+ try {
179
+ fs.mkdirSync(historyDir, { recursive: true });
180
+ fs.writeFileSync(file, JSON.stringify(record, null, 2));
181
+ archived.add(runId);
182
+ return record;
183
+ } catch (e) {
184
+ console.warn(`[agent-conductor] could not archive ${runId}: ${e.message}`);
185
+ return null;
186
+ }
187
+ }
188
+
189
+ function serveStatic(req, res) {
190
+ let urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
191
+ if (urlPath === "/") urlPath = "/index.html";
192
+ let filePath = path.join(DIST, path.normalize(urlPath));
193
+ // contain within DIST
194
+ if (!filePath.startsWith(DIST)) {
195
+ res.writeHead(403).end("Forbidden");
196
+ return;
197
+ }
198
+ if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
199
+ filePath = path.join(DIST, "index.html"); // SPA fallback
200
+ }
201
+ if (!fs.existsSync(filePath)) {
202
+ res.writeHead(404).end("Board not built. Run `npm run build` first.");
203
+ return;
204
+ }
205
+ const ext = path.extname(filePath);
206
+ res.writeHead(200, { "content-type": MIME[ext] || "application/octet-stream" });
207
+ fs.createReadStream(filePath).pipe(res);
208
+ }
209
+
210
+ export function startServer({ statusPath, conductorPath: explicitConductor, port }) {
211
+ 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");
216
+
217
+ /** @type {Set<http.ServerResponse>} */
218
+ const clients = new Set();
219
+
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);
223
+
224
+ const broadcastHistory = () => {
225
+ const list = JSON.stringify(listHistory(historyDir));
226
+ for (const res of clients) res.write(`event: history\ndata: ${list}\n\n`);
227
+ };
228
+
229
+ 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`);
236
+ }
237
+ if (archiveIfDone(historyDir, snapshot, archivedRunIds)) broadcastHistory();
238
+ };
239
+
240
+ // Watch the directory that holds the status file (more reliable than
241
+ // watching a single file that gets atomically replaced). Debounced.
242
+ let timer = null;
243
+ const schedule = () => {
244
+ clearTimeout(timer);
245
+ timer = setTimeout(broadcast, 80);
246
+ };
247
+ try {
248
+ fs.mkdirSync(watchDir, { recursive: true });
249
+ fs.watch(watchDir, schedule);
250
+ } catch (e) {
251
+ console.warn(`[agent-conductor] watch failed: ${e.message}`);
252
+ }
253
+
254
+ const server = http.createServer((req, res) => {
255
+ const url = (req.url || "/").split("?")[0];
256
+
257
+ 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,
265
+ }),
266
+ );
267
+ return;
268
+ }
269
+
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)));
274
+ return;
275
+ }
276
+
277
+ if (url === "/history") {
278
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
279
+ res.end(JSON.stringify(listHistory(historyDir)));
280
+ return;
281
+ }
282
+
283
+ if (url.startsWith("/history/")) {
284
+ 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;
293
+ }
294
+
295
+ if (url === "/events") {
296
+ res.writeHead(200, {
297
+ "content-type": "text/event-stream",
298
+ "cache-control": "no-cache",
299
+ connection: "keep-alive",
300
+ });
301
+ 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`);
309
+ clients.add(res);
310
+ const heartbeat = setInterval(() => res.write(": ping\n\n"), 25000);
311
+ req.on("close", () => {
312
+ clearInterval(heartbeat);
313
+ clients.delete(res);
314
+ });
315
+ return;
316
+ }
317
+
318
+ serveStatic(req, res);
319
+ });
320
+
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");
324
+
325
+ return new Promise((resolve) => {
326
+ server.listen(port, () => {
327
+ const actualPort = server.address().port;
328
+ try {
329
+ fs.mkdirSync(watchDir, { recursive: true });
330
+ fs.writeFileSync(
331
+ serverJsonPath,
332
+ JSON.stringify(
333
+ {
334
+ port: actualPort,
335
+ url: `http://localhost:${actualPort}`,
336
+ pid: process.pid,
337
+ started_at: new Date().toISOString(),
338
+ },
339
+ null,
340
+ 2,
341
+ ),
342
+ );
343
+ } catch (e) {
344
+ console.warn(`[conductor-board] could not write server.json: ${e.message}`);
345
+ }
346
+ resolve({ server, conductorPath, absStatus, serverJsonPath });
347
+ });
348
+ });
349
+ }