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/LICENSE +21 -0
- package/README.md +118 -0
- package/bin/cli.js +150 -0
- package/cli/init.js +87 -0
- package/cli/setup.js +105 -0
- package/cli/validate.js +201 -0
- package/dist/assets/index--J1uxrSo.js +34 -0
- package/dist/assets/index-DMqA9hDY.css +1 -0
- package/dist/assets/motion-Dmvx5jlk.js +25 -0
- package/dist/assets/yaml-NA7d4LV6.js +32 -0
- package/dist/conductor.svg +14 -0
- package/dist/index.html +17 -0
- package/package.json +63 -0
- package/server/server.js +349 -0
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
|
+
}
|
package/server/server.js
ADDED
|
@@ -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
|
+
}
|