@hjr15/blaze-board 0.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/AGENTS.md +141 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +38 -0
- package/scripts/cli.mjs +31 -0
- package/scripts/commit-or-queue.mjs +22 -0
- package/scripts/commit-runner.mjs +40 -0
- package/scripts/config.mjs +135 -0
- package/scripts/edit-runner.mjs +18 -0
- package/scripts/edit.mjs +85 -0
- package/scripts/event-bus.mjs +16 -0
- package/scripts/log-runner.mjs +38 -0
- package/scripts/log.mjs +44 -0
- package/scripts/loops/groomer.mjs +215 -0
- package/scripts/migrate/audit.mjs +92 -0
- package/scripts/migrate/jira-client.mjs +26 -0
- package/scripts/migrate/jira-import.mjs +96 -0
- package/scripts/migrate/map.mjs +110 -0
- package/scripts/migrate/merge.mjs +103 -0
- package/scripts/migrate/normalize.mjs +60 -0
- package/scripts/migrate/report.mjs +67 -0
- package/scripts/migrate/restructure.mjs +49 -0
- package/scripts/migrate-runner.mjs +51 -0
- package/scripts/model/.gitkeep +0 -0
- package/scripts/model/ids.mjs +32 -0
- package/scripts/model/index.mjs +63 -0
- package/scripts/model/move-plan.mjs +25 -0
- package/scripts/model/rollup.mjs +55 -0
- package/scripts/model/rules.mjs +64 -0
- package/scripts/model/schema.mjs +30 -0
- package/scripts/model/ticket.mjs +136 -0
- package/scripts/model/time.mjs +38 -0
- package/scripts/model/workflows.mjs +54 -0
- package/scripts/move-runner.mjs +18 -0
- package/scripts/move.mjs +56 -0
- package/scripts/new-runner.mjs +43 -0
- package/scripts/new.mjs +54 -0
- package/scripts/pending-ledger.mjs +36 -0
- package/scripts/reconcile.mjs +181 -0
- package/scripts/reindex.mjs +22 -0
- package/scripts/resolve-runner.mjs +17 -0
- package/scripts/resolve.mjs +21 -0
- package/scripts/rollup-runner.mjs +53 -0
- package/scripts/serve-commit.mjs +18 -0
- package/scripts/serve.mjs +658 -0
- package/scripts/supervisor.mjs +192 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// supervisor.mjs — boots the Blaze app: serves the board + activity feed and runs
|
|
3
|
+
// the loops. All loop effects go through git on the board repo.
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { loadConfig, listProjects, resolveRoots } from "./config.mjs";
|
|
9
|
+
import { pageHtml, contentHash } from "./serve.mjs";
|
|
10
|
+
import { createBus } from "./event-bus.mjs";
|
|
11
|
+
import { reconcile } from "./reconcile.mjs";
|
|
12
|
+
import { groomOnce } from "./loops/groomer.mjs";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
16
|
+
|
|
17
|
+
// ---- the control strip + activity feed injected into the board page ----
|
|
18
|
+
const CONTROLS_HTML = `
|
|
19
|
+
<section id="blaze-app">
|
|
20
|
+
<div class="ctl-strip">
|
|
21
|
+
<strong>Loops</strong>
|
|
22
|
+
<span class="ctl-group" data-loop="reconcile">reconcile
|
|
23
|
+
<button data-act="start">▶</button><button data-act="stop">⏸</button><button data-act="run">run</button>
|
|
24
|
+
</span>
|
|
25
|
+
<span class="ctl-group" data-loop="groomer">groomer
|
|
26
|
+
<button data-act="start">▶</button><button data-act="stop">⏸</button><button data-act="run">run</button>
|
|
27
|
+
</span>
|
|
28
|
+
<span id="conn" class="sub">● live</span>
|
|
29
|
+
</div>
|
|
30
|
+
<ol id="activity" class="activity"></ol>
|
|
31
|
+
</section>
|
|
32
|
+
<style>
|
|
33
|
+
#blaze-app { padding: 0 20px 8px; }
|
|
34
|
+
.ctl-strip { display:flex; align-items:center; gap:12px; flex-wrap:wrap;
|
|
35
|
+
padding:8px 10px; background:#161b22; border:1px solid #21262d; border-radius:8px; }
|
|
36
|
+
.ctl-group { color:#adbac7; font-size:12px; }
|
|
37
|
+
.ctl-strip button { appearance:none; border:0; cursor:pointer; font:inherit; font-size:11px;
|
|
38
|
+
margin-left:3px; padding:2px 8px; border-radius:6px; color:var(--charcoal); background:var(--blaze-orange); }
|
|
39
|
+
.ctl-strip button:hover { background:var(--blaze-red); color:var(--neutral); }
|
|
40
|
+
#conn { margin-left:auto; color:var(--blaze-orange); }
|
|
41
|
+
.activity { list-style:none; margin:8px 0 0; padding:0; max-height:180px; overflow:auto;
|
|
42
|
+
font-size:12px; font-family:ui-monospace, monospace; }
|
|
43
|
+
.activity li { padding:4px 8px; border-bottom:1px solid #21262d; color:#adbac7; display:flex; gap:8px; }
|
|
44
|
+
.activity .revert { margin-left:auto; cursor:pointer; color:var(--blaze-orange); background:none; border:0; font:inherit; }
|
|
45
|
+
</style>`;
|
|
46
|
+
|
|
47
|
+
const ACTIVITY_SCRIPT = `
|
|
48
|
+
<script>
|
|
49
|
+
const act = document.getElementById("activity");
|
|
50
|
+
const conn = document.getElementById("conn");
|
|
51
|
+
function line(e) {
|
|
52
|
+
const li = document.createElement("li");
|
|
53
|
+
let txt = e.type;
|
|
54
|
+
if (e.type === "reconcile") txt = e.id + ": " + e.from + " → " + e.to;
|
|
55
|
+
else if (e.type === "groom") txt = e.error ? ("groom " + e.id + " failed: " + e.error)
|
|
56
|
+
: e.noop ? ("groom " + e.id + ": no change") : ("groom " + e.id + " (" + (e.files||[]).length + " file)");
|
|
57
|
+
else if (e.type === "status") txt = e.loop + " " + e.state;
|
|
58
|
+
else if (e.type === "error") txt = (e.loop||"") + " error: " + e.message;
|
|
59
|
+
li.innerHTML = "<span>" + (e.ts||"") + "</span><span>" + txt + "</span>";
|
|
60
|
+
if (e.type === "groom" && e.sha) {
|
|
61
|
+
const b = document.createElement("button");
|
|
62
|
+
b.className = "revert"; b.textContent = "↩ revert";
|
|
63
|
+
b.onclick = () => fetch("/control/revert", { method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({ sha: e.sha }) });
|
|
64
|
+
li.appendChild(b);
|
|
65
|
+
}
|
|
66
|
+
act.prepend(li);
|
|
67
|
+
while (act.children.length > 100) act.removeChild(act.lastChild);
|
|
68
|
+
}
|
|
69
|
+
const es = new EventSource("/events");
|
|
70
|
+
es.onmessage = (m) => { try { line(JSON.parse(m.data)); } catch {} };
|
|
71
|
+
es.onerror = () => { conn.textContent = "● offline"; };
|
|
72
|
+
es.onopen = () => { conn.textContent = "● live"; };
|
|
73
|
+
document.querySelectorAll(".ctl-group").forEach((g) =>
|
|
74
|
+
g.querySelectorAll("button").forEach((b) =>
|
|
75
|
+
b.addEventListener("click", () =>
|
|
76
|
+
fetch("/control/" + g.dataset.loop + "/" + b.dataset.act, { method: "POST" }))));
|
|
77
|
+
</script>`;
|
|
78
|
+
|
|
79
|
+
export function createApp(cfg, { root = resolveRoots().dataRoot } = {}) {
|
|
80
|
+
const bus = createBus();
|
|
81
|
+
|
|
82
|
+
const loops = { reconcile: { timer: null, busy: false }, groomer: { timer: null, busy: false } };
|
|
83
|
+
|
|
84
|
+
function runReconcile() {
|
|
85
|
+
if (!listProjects(cfg).length || loops.reconcile.busy) return;
|
|
86
|
+
loops.reconcile.busy = true;
|
|
87
|
+
try {
|
|
88
|
+
const r = reconcile({ fetch: true, commit: true, push: true });
|
|
89
|
+
if (r && r.ok && r.changes) {
|
|
90
|
+
for (const c of r.changes) bus.publish({ type: "reconcile", id: c.id, from: c.from, to: c.to, moved: c.moved, ts: today() });
|
|
91
|
+
} else if (r && !r.ok) {
|
|
92
|
+
bus.publish({ type: "error", loop: "reconcile", message: r.error, ts: today() });
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
bus.publish({ type: "error", loop: "reconcile", message: e.message, ts: today() });
|
|
96
|
+
} finally {
|
|
97
|
+
loops.reconcile.busy = false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function runGroomer() {
|
|
102
|
+
if (loops.groomer.busy) return;
|
|
103
|
+
loops.groomer.busy = true;
|
|
104
|
+
try {
|
|
105
|
+
let agentsMd = "";
|
|
106
|
+
try { agentsMd = readFileSync(join(root, "AGENTS.md"), "utf8"); } catch {}
|
|
107
|
+
const evt = groomOnce({ root, cfg, agentsMd, today: today() });
|
|
108
|
+
if (evt) bus.publish(evt);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
bus.publish({ type: "error", loop: "groomer", message: e.message, ts: today() });
|
|
111
|
+
} finally {
|
|
112
|
+
loops.groomer.busy = false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function startLoop(name) {
|
|
117
|
+
const fn = name === "reconcile" ? runReconcile : runGroomer;
|
|
118
|
+
if (loops[name].timer) return;
|
|
119
|
+
fn();
|
|
120
|
+
loops[name].timer = setInterval(fn, cfg.loops[name].intervalSec * 1000);
|
|
121
|
+
bus.publish({ type: "status", loop: name, state: "started", ts: today() });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function stopLoop(name) {
|
|
125
|
+
if (loops[name].timer) { clearInterval(loops[name].timer); loops[name].timer = null; }
|
|
126
|
+
bus.publish({ type: "status", loop: name, state: "stopped", ts: today() });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const server = createServer((req, res) => {
|
|
130
|
+
if (req.url === "/api/hash") {
|
|
131
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
132
|
+
res.end(contentHash());
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (req.url === "/events") {
|
|
136
|
+
res.writeHead(200, {
|
|
137
|
+
"content-type": "text/event-stream",
|
|
138
|
+
"cache-control": "no-cache",
|
|
139
|
+
connection: "keep-alive",
|
|
140
|
+
});
|
|
141
|
+
res.write(": connected\n\n");
|
|
142
|
+
const off = bus.subscribe((evt) => res.write(`data: ${JSON.stringify(evt)}\n\n`));
|
|
143
|
+
const hb = setInterval(() => res.write(": hb\n\n"), 15000);
|
|
144
|
+
req.on("close", () => { clearInterval(hb); off(); });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (req.url === "/" || req.url === "") {
|
|
148
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
149
|
+
res.end(pageHtml({ afterHeader: CONTROLS_HTML, beforeBodyEnd: ACTIVITY_SCRIPT }));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const ctl = req.url && req.url.match(/^\/control\/(reconcile|groomer)\/(start|stop|run)$/);
|
|
153
|
+
if (ctl && req.method === "POST") {
|
|
154
|
+
const [, name, action] = ctl;
|
|
155
|
+
if (action === "start") startLoop(name);
|
|
156
|
+
else if (action === "stop") stopLoop(name);
|
|
157
|
+
else (name === "reconcile" ? runReconcile : runGroomer)();
|
|
158
|
+
res.writeHead(204); res.end();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (req.url === "/control/revert" && req.method === "POST") {
|
|
162
|
+
let body = "";
|
|
163
|
+
req.on("data", (c) => (body += c));
|
|
164
|
+
req.on("end", () => {
|
|
165
|
+
try {
|
|
166
|
+
const { sha } = JSON.parse(body || "{}");
|
|
167
|
+
execFileSync("git", ["-C", root, "revert", "--no-edit", sha]);
|
|
168
|
+
bus.publish({ type: "status", loop: "groomer", state: `reverted ${sha.slice(0, 7)}`, ts: today() });
|
|
169
|
+
} catch (e) {
|
|
170
|
+
bus.publish({ type: "error", loop: "groomer", message: `revert failed: ${e.message}`, ts: today() });
|
|
171
|
+
}
|
|
172
|
+
res.writeHead(204); res.end();
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
177
|
+
res.end("not found");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return { server, bus, startLoop, stopLoop, runReconcile, runGroomer };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
184
|
+
const cfg = loadConfig({ root: resolveRoots().dataRoot });
|
|
185
|
+
const app = createApp(cfg);
|
|
186
|
+
const port = Number(process.env.PORT) || cfg.port;
|
|
187
|
+
app.server.listen(port, "127.0.0.1", () => {
|
|
188
|
+
console.log(`${cfg.boardTitle} app → http://localhost:${port}`);
|
|
189
|
+
if (cfg.loops.reconcile.enabled && listProjects(cfg).length) app.startLoop("reconcile");
|
|
190
|
+
if (cfg.loops.groomer.enabled) app.startLoop("groomer");
|
|
191
|
+
});
|
|
192
|
+
}
|