beadcyte 0.4.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/CHANGELOG.md +73 -0
- package/LICENSE +661 -0
- package/README.md +386 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/bin/beadcyte.mjs +60 -0
- package/package.json +77 -0
- package/src/changelog-cli.mjs +105 -0
- package/src/changelog.mjs +196 -0
- package/src/cli.mjs +577 -0
- package/src/estimator.mjs +314 -0
- package/src/format.mjs +22 -0
- package/src/history-walk.mjs +170 -0
- package/src/index.mjs +5 -0
- package/src/mutate.mjs +193 -0
- package/src/projects.mjs +120 -0
- package/src/provenance.mjs +75 -0
- package/src/review-hours.mjs +117 -0
- package/src/roster-path.mjs +24 -0
- package/src/scheduler.mjs +424 -0
- package/src/serve.mjs +411 -0
- package/src/server-state.mjs +105 -0
- package/src/ship.mjs +178 -0
- package/src/stage-waits.mjs +69 -0
- package/src/start.mjs +111 -0
- package/src/stop.mjs +66 -0
- package/src/velocity.mjs +209 -0
- package/src/web/App.vue +691 -0
- package/src/web/app.css +54 -0
- package/src/web/assets/favicon.svg +12 -0
- package/src/web/avatar.ts +53 -0
- package/src/web/bead-detail.ts +240 -0
- package/src/web/changelog-view.ts +41 -0
- package/src/web/components/BeadDrawer.vue +1883 -0
- package/src/web/components/BeadSubGraph.vue +326 -0
- package/src/web/components/BeadSubGraphOverlay.vue +192 -0
- package/src/web/components/BeadTooltip.vue +516 -0
- package/src/web/components/BeadcyteMark.vue +64 -0
- package/src/web/components/BeadsGantt.vue +2125 -0
- package/src/web/components/BeadsGrid.vue +468 -0
- package/src/web/components/BeadsIncytes.vue +567 -0
- package/src/web/components/BeadsMine.vue +325 -0
- package/src/web/components/BeadsTable.vue +335 -0
- package/src/web/components/ChangelogOverlay.vue +198 -0
- package/src/web/components/ContextMenu.vue +386 -0
- package/src/web/components/ControlsPanel.vue +476 -0
- package/src/web/components/CostTrend.vue +206 -0
- package/src/web/components/FilterPopover.vue +245 -0
- package/src/web/components/GroupProgress.vue +274 -0
- package/src/web/components/LoadMeter.vue +144 -0
- package/src/web/components/MineRow.vue +28 -0
- package/src/web/components/OptionsMenu.vue +825 -0
- package/src/web/components/PriorityChip.vue +105 -0
- package/src/web/components/ScoreStrip.vue +131 -0
- package/src/web/components/SearchPalette.vue +210 -0
- package/src/web/components/ShipTrend.vue +510 -0
- package/src/web/components/ShortcutsOverlay.vue +164 -0
- package/src/web/components/Term.vue +177 -0
- package/src/web/components/Toast.vue +50 -0
- package/src/web/components/TriageMeters.vue +426 -0
- package/src/web/components/TypeChip.vue +96 -0
- package/src/web/components/Walkthrough.vue +209 -0
- package/src/web/components/WhatIfPanel.vue +206 -0
- package/src/web/components/WipBullets.vue +191 -0
- package/src/web/components/filter-option.ts +9 -0
- package/src/web/composables/url-codec.ts +136 -0
- package/src/web/composables/useBeadTooltip.ts +148 -0
- package/src/web/composables/useKeyboard.ts +97 -0
- package/src/web/composables/useLiveRefresh.ts +69 -0
- package/src/web/composables/useTheme.ts +125 -0
- package/src/web/composables/useUrlState.ts +208 -0
- package/src/web/controls-scope.ts +83 -0
- package/src/web/cost.ts +251 -0
- package/src/web/dep-headings.ts +62 -0
- package/src/web/economics.ts +440 -0
- package/src/web/env.d.ts +85 -0
- package/src/web/frontier.ts +208 -0
- package/src/web/gantt-viewport.ts +99 -0
- package/src/web/highlights.ts +124 -0
- package/src/web/index.html +46 -0
- package/src/web/insights.ts +107 -0
- package/src/web/keybindings.ts +200 -0
- package/src/web/load-meter.ts +72 -0
- package/src/web/main.ts +20 -0
- package/src/web/markdown.ts +14 -0
- package/src/web/mine.ts +137 -0
- package/src/web/mutations.ts +21 -0
- package/src/web/person.ts +102 -0
- package/src/web/projects-text.ts +15 -0
- package/src/web/projects.ts +188 -0
- package/src/web/refresh.ts +47 -0
- package/src/web/search.ts +50 -0
- package/src/web/shortcuts.ts +113 -0
- package/src/web/status-filter.ts +48 -0
- package/src/web/store.ts +1378 -0
- package/src/web/style-audit.mjs +346 -0
- package/src/web/styles-alt.css +111 -0
- package/src/web/styles-ported.css +270 -0
- package/src/web/subgraph.ts +362 -0
- package/src/web/table.ts +201 -0
- package/src/web/theme.ts +88 -0
- package/src/web/tokens.css +168 -0
- package/src/web/triage.ts +914 -0
- package/src/web/view-model.ts +717 -0
- package/src/web/walkthrough.ts +133 -0
- package/src/web/watchlist.ts +47 -0
- package/src/web/whatif.ts +291 -0
- package/src/web/window.ts +73 -0
- package/src/web/wip.ts +83 -0
package/src/serve.mjs
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// beadcyte serve — local Vue dev server + companion /api/beads endpoint.
|
|
2
|
+
//
|
|
3
|
+
// Boots Vite programmatically on the beadcyte web root (`src/web/`) and
|
|
4
|
+
// attaches a `/api/beads` middleware that shells `bd list --all --limit 0
|
|
5
|
+
// --json` from the CWD, cached ~30s. Same port for both, so the browser fetches
|
|
6
|
+
// the API from its own origin and CORS never comes up.
|
|
7
|
+
//
|
|
8
|
+
// Localhost-only. No auth. This is a dev tool, not a service — same posture as
|
|
9
|
+
// bv, and same threat model.
|
|
10
|
+
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
import { createServer as createViteServer } from "vite";
|
|
15
|
+
import vuePlugin from "@vitejs/plugin-vue";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { createAllowlist, inspectProject } from "./projects.mjs";
|
|
19
|
+
import { resolveRosterPath, ROSTER_CANDIDATES } from "./roster-path.mjs";
|
|
20
|
+
import { clearState, writeState } from "./server-state.mjs";
|
|
21
|
+
import { createHistoryWalker } from "./history-walk.mjs";
|
|
22
|
+
import { mergeHistory } from "./review-hours.mjs";
|
|
23
|
+
import { validateMutation, bdArgsFor, describeMutation, createdIdFrom } from "./mutate.mjs";
|
|
24
|
+
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const webRoot = join(__dirname, "web");
|
|
28
|
+
|
|
29
|
+
// ── args ──────────────────────────────────────────────────────────────────
|
|
30
|
+
const args = parseArgs(process.argv.slice(2));
|
|
31
|
+
if (args.help || args.h) {
|
|
32
|
+
printHelp();
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
const port = parseInt(args.port ?? "4173", 10);
|
|
36
|
+
// Undefined means "use the convention", which now spans two directory
|
|
37
|
+
// names — see roster-path.mjs.
|
|
38
|
+
const rosterPath = args.roster;
|
|
39
|
+
const cacheTtlMs = parseInt(args["cache-ttl"] ?? "30000", 10);
|
|
40
|
+
const cwd = process.cwd();
|
|
41
|
+
// Set by `beadcyte start`: where to record the port once we are listening, so
|
|
42
|
+
// `beadcyte stop` can find this process. Unset when run in the foreground.
|
|
43
|
+
const stateFile = process.env.BEADCYTE_STATE_FILE;
|
|
44
|
+
|
|
45
|
+
// ── projects ──────────────────────────────────────────────────────────────
|
|
46
|
+
// The launch directory is seeded so a first run is never empty; everything
|
|
47
|
+
// else has to be registered by the client before it can be read.
|
|
48
|
+
const allowlist = createAllowlist([cwd]);
|
|
49
|
+
|
|
50
|
+
// ── beads cache ───────────────────────────────────────────────────────────
|
|
51
|
+
// The shell-out to `bd` is the one non-trivial cost. Cached per project —
|
|
52
|
+
// a single global would serve one project's beads for another for up to the
|
|
53
|
+
// TTL after a switch, or force a refetch on every toggle.
|
|
54
|
+
//
|
|
55
|
+
// `lastMs` is retained per project so the client's load meter can fill
|
|
56
|
+
// against a real prior measurement instead of a fabricated percentage; the
|
|
57
|
+
// subprocess is ~90% of load time and reports no progress of its own.
|
|
58
|
+
const caches = new Map(); // project -> { generated_at, payload, ts, lastMs }
|
|
59
|
+
|
|
60
|
+
// ── history walk (bp-67g.57) ─────────────────────────────────────────────
|
|
61
|
+
// hours_in_review lives only in `bd history`, one shell-out per bead (~0.4s,
|
|
62
|
+
// ~0.4MB each on this repo), so it is walked in the background and cached;
|
|
63
|
+
// the request path only merges what the walk already knows. When a walk
|
|
64
|
+
// changes something, the cached payload is re-merged and re-stamped so the
|
|
65
|
+
// next poll picks it up without another `bd list`.
|
|
66
|
+
//
|
|
67
|
+
// bd serialises its calls, so the walk keeps out of the request path's way:
|
|
68
|
+
// it waits for any `bd list` in flight before each history call, and it does
|
|
69
|
+
// not start until WALK_START_DELAY_MS after a load, so the boot-time prefetch
|
|
70
|
+
// below and the first real request are both answered first. Measured before
|
|
71
|
+
// this: a first request racing the walk took 4.9–7.4s instead of 1.0s.
|
|
72
|
+
const WALK_START_DELAY_MS = 5_000;
|
|
73
|
+
let listInFlight = null; // Promise while a `bd list` runs, else null
|
|
74
|
+
const historyWalker = createHistoryWalker({
|
|
75
|
+
log: (msg) => console.error(`[beadcyte serve] ${msg}`),
|
|
76
|
+
beforeEach: () => listInFlight ?? Promise.resolve(),
|
|
77
|
+
});
|
|
78
|
+
const walkTimers = new Map(); // project -> timeout
|
|
79
|
+
function scheduleWalk(project, beads) {
|
|
80
|
+
clearTimeout(walkTimers.get(project));
|
|
81
|
+
walkTimers.set(
|
|
82
|
+
project,
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
walkTimers.delete(project);
|
|
85
|
+
void historyWalker.refresh(project, beads);
|
|
86
|
+
}, WALK_START_DELAY_MS),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const historyListeners = new Set();
|
|
90
|
+
function watchHistory(project) {
|
|
91
|
+
if (historyListeners.has(project)) return;
|
|
92
|
+
historyListeners.add(project);
|
|
93
|
+
historyWalker.onChange(project, () => {
|
|
94
|
+
const hit = caches.get(project);
|
|
95
|
+
if (!hit?.payload) return;
|
|
96
|
+
mergeHistory(hit.payload.beads, historyWalker.cache(project));
|
|
97
|
+
const generated_at = new Date().toISOString();
|
|
98
|
+
hit.generated_at = generated_at;
|
|
99
|
+
hit.payload.generated_at = generated_at;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function loadBeads(project) {
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
const hit = caches.get(project);
|
|
106
|
+
if (hit?.payload && now - hit.ts < cacheTtlMs) return hit;
|
|
107
|
+
|
|
108
|
+
const started = Date.now();
|
|
109
|
+
const listing = execFileAsync(
|
|
110
|
+
"bd",
|
|
111
|
+
["list", "--all", "--limit", "0", "--json"],
|
|
112
|
+
{ cwd: project, maxBuffer: 256 * 1024 * 1024 },
|
|
113
|
+
);
|
|
114
|
+
// Held for the history walk to yield to; cleared however the list ends.
|
|
115
|
+
listInFlight = listing.then(
|
|
116
|
+
() => {
|
|
117
|
+
listInFlight = null;
|
|
118
|
+
},
|
|
119
|
+
() => {
|
|
120
|
+
listInFlight = null;
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
const { stdout } = await listing;
|
|
124
|
+
const beads = JSON.parse(stdout);
|
|
125
|
+
mergeHistory(beads, historyWalker.cache(project));
|
|
126
|
+
watchHistory(project);
|
|
127
|
+
scheduleWalk(project, beads);
|
|
128
|
+
const roster = loadRoster(beads, project);
|
|
129
|
+
const took_ms = Date.now() - started;
|
|
130
|
+
const generated_at = new Date().toISOString();
|
|
131
|
+
const entry = {
|
|
132
|
+
generated_at,
|
|
133
|
+
payload: { generated_at, beads, roster, project, took_ms },
|
|
134
|
+
ts: now,
|
|
135
|
+
lastMs: took_ms,
|
|
136
|
+
};
|
|
137
|
+
caches.set(project, entry);
|
|
138
|
+
return entry;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function loadRoster(beads, project) {
|
|
142
|
+
const found = resolveRosterPath(project, rosterPath);
|
|
143
|
+
if (found) {
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(readFileSync(found.path, "utf8"));
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.error(`[beadcyte serve] roster ${found.path} failed to parse: ${e.message}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (rosterPath) {
|
|
151
|
+
console.error(
|
|
152
|
+
`[beadcyte serve] --roster ${rosterPath} does not exist; deriving from beads`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const seen = new Set();
|
|
156
|
+
for (const b of beads) if (b.assignee) seen.add(b.assignee);
|
|
157
|
+
return {
|
|
158
|
+
cap: 5,
|
|
159
|
+
humans: [...seen].map((handle) => ({ handle })),
|
|
160
|
+
_derived: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── vite server ───────────────────────────────────────────────────────────
|
|
165
|
+
// The /api/beads route must be registered BEFORE Vite's own middlewares
|
|
166
|
+
// (transformIndexHtml SPA fallback), otherwise every request returns
|
|
167
|
+
// index.html. `configureServer` without a returned function runs pre-internal.
|
|
168
|
+
/**
|
|
169
|
+
* Live data must never be reused from a cache we don't control.
|
|
170
|
+
*
|
|
171
|
+
* These responses carried no Cache-Control at all — just a Date — and with
|
|
172
|
+
* no directives and no validators a browser applies heuristic freshness and
|
|
173
|
+
* may serve a stored copy without revalidating. That quietly defeats the
|
|
174
|
+
* whole point of polling: the client asks again and the browser answers
|
|
175
|
+
* from its own store. `no-store` rather than `no-cache` because there is no
|
|
176
|
+
* value in keeping a copy of a snapshot at all.
|
|
177
|
+
*/
|
|
178
|
+
function noStore(res) {
|
|
179
|
+
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Collect a request body. Small by construction — one JSON path. */
|
|
183
|
+
function readBody(req, limit = 64 * 1024) {
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
let out = "";
|
|
186
|
+
req.on("data", (chunk) => {
|
|
187
|
+
out += chunk;
|
|
188
|
+
if (out.length > limit) reject(new Error("body too large"));
|
|
189
|
+
});
|
|
190
|
+
req.on("end", () => resolve(out));
|
|
191
|
+
req.on("error", reject);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const beadsApiPlugin = {
|
|
196
|
+
name: "bp-beads-api",
|
|
197
|
+
configureServer(server) {
|
|
198
|
+
// Registration: validate a path and admit it. This is the ONLY way a
|
|
199
|
+
// directory becomes readable, which is what keeps /api/beads from
|
|
200
|
+
// doubling as a filesystem probe for any page that can reach localhost.
|
|
201
|
+
server.middlewares.use("/api/projects", async (req, res) => {
|
|
202
|
+
const json = (code, body) => {
|
|
203
|
+
res.statusCode = code;
|
|
204
|
+
res.setHeader("Content-Type", "application/json");
|
|
205
|
+
noStore(res);
|
|
206
|
+
res.end(JSON.stringify(body));
|
|
207
|
+
};
|
|
208
|
+
if (req.method === "GET") {
|
|
209
|
+
// What this server already trusts, so a client with a stale
|
|
210
|
+
// registry can tell what it still has access to.
|
|
211
|
+
return json(200, { launch: cwd, projects: allowlist.list() });
|
|
212
|
+
}
|
|
213
|
+
if (req.method !== "POST") return json(405, { error: "use GET or POST" });
|
|
214
|
+
try {
|
|
215
|
+
const raw = await readBody(req);
|
|
216
|
+
const { path } = raw ? JSON.parse(raw) : {};
|
|
217
|
+
const seen = allowlist.register(path);
|
|
218
|
+
if (!seen.ok) return json(400, { ok: false, reason: seen.reason });
|
|
219
|
+
return json(200, { ok: true, path: seen.path, label: seen.label });
|
|
220
|
+
} catch (e) {
|
|
221
|
+
return json(400, { ok: false, error: String(e.message ?? e) });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Writes (bp-ocs): one bead, one whitelisted action, one bd command,
|
|
226
|
+
// argv not shell. The project must be registered, exactly like reads;
|
|
227
|
+
// the list cache for it is dropped so the next poll shows the change;
|
|
228
|
+
// the fresh bead comes back so the client can replace its optimistic
|
|
229
|
+
// guess with what bd actually did.
|
|
230
|
+
server.middlewares.use("/api/mutate", async (req, res) => {
|
|
231
|
+
const json = (code, body) => {
|
|
232
|
+
res.statusCode = code;
|
|
233
|
+
res.setHeader("Content-Type", "application/json");
|
|
234
|
+
noStore(res);
|
|
235
|
+
res.end(JSON.stringify(body));
|
|
236
|
+
};
|
|
237
|
+
if (req.method !== "POST") return json(405, { error: "use POST" });
|
|
238
|
+
let m;
|
|
239
|
+
let project;
|
|
240
|
+
try {
|
|
241
|
+
const raw = await readBody(req);
|
|
242
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
243
|
+
project = typeof body.project === "string" && body.project ? body.project : cwd;
|
|
244
|
+
if (!allowlist.allows(project)) return json(403, { error: "project not registered for this session", project });
|
|
245
|
+
m = validateMutation(body);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
return json(400, { error: String(e.message ?? e) });
|
|
248
|
+
}
|
|
249
|
+
const command = describeMutation(m);
|
|
250
|
+
try {
|
|
251
|
+
const ran = await execFileAsync("bd", bdArgsFor(m), { cwd: project, maxBuffer: 8 * 1024 * 1024 });
|
|
252
|
+
caches.delete(project);
|
|
253
|
+
// create has no id until bd names one (bp-2fq).
|
|
254
|
+
const id = m.action === "create" ? createdIdFrom(ran.stdout) : m.id;
|
|
255
|
+
if (!id) return json(200, { ok: true, command, bead: null });
|
|
256
|
+
const { stdout } = await execFileAsync("bd", ["show", id, "--json"], { cwd: project, maxBuffer: 8 * 1024 * 1024 });
|
|
257
|
+
const shown = JSON.parse(stdout);
|
|
258
|
+
const bead = Array.isArray(shown) ? shown[0] ?? null : shown;
|
|
259
|
+
console.error(`[beadcyte serve] mutate: ${command}`);
|
|
260
|
+
return json(200, { ok: true, command, bead });
|
|
261
|
+
} catch (e) {
|
|
262
|
+
const detail = String(e?.stderr || e?.message || e).trim().split("\n").slice(-3).join(" ");
|
|
263
|
+
console.error(`[beadcyte serve] mutate failed: ${command}: ${detail}`);
|
|
264
|
+
return json(500, { ok: false, command, error: detail });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
server.middlewares.use("/api/beads", async (req, res) => {
|
|
269
|
+
try {
|
|
270
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
271
|
+
const since = url.searchParams.get("since");
|
|
272
|
+
// Absent `project` means the launch directory, so an old client and
|
|
273
|
+
// a plain curl both keep working.
|
|
274
|
+
const project = url.searchParams.get("project") ?? cwd;
|
|
275
|
+
if (!allowlist.allows(project)) {
|
|
276
|
+
// Refused on the allowlist alone — no stat, no realpath, nothing
|
|
277
|
+
// that could reveal whether the path exists.
|
|
278
|
+
res.statusCode = 403;
|
|
279
|
+
res.setHeader("Content-Type", "application/json");
|
|
280
|
+
noStore(res);
|
|
281
|
+
res.end(
|
|
282
|
+
JSON.stringify({
|
|
283
|
+
error: "project not registered for this session",
|
|
284
|
+
project,
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const data = await loadBeads(project);
|
|
290
|
+
// Short-circuit for polling: if `since` matches current generated_at,
|
|
291
|
+
// return an empty body with 304-style semantics (via a `stale=false`
|
|
292
|
+
// flag so the client doesn't need to interpret the status code).
|
|
293
|
+
if (since && since === data.generated_at) {
|
|
294
|
+
res.setHeader("Content-Type", "application/json");
|
|
295
|
+
noStore(res);
|
|
296
|
+
res.end(
|
|
297
|
+
JSON.stringify({
|
|
298
|
+
stale: false,
|
|
299
|
+
generated_at: data.generated_at,
|
|
300
|
+
project,
|
|
301
|
+
last_ms: data.lastMs ?? null,
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
res.setHeader("Content-Type", "application/json");
|
|
307
|
+
noStore(res);
|
|
308
|
+
res.end(JSON.stringify({ stale: true, ...data.payload }));
|
|
309
|
+
} catch (e) {
|
|
310
|
+
res.statusCode = 500;
|
|
311
|
+
res.setHeader("Content-Type", "application/json");
|
|
312
|
+
noStore(res);
|
|
313
|
+
res.end(JSON.stringify({ error: String(e.message ?? e) }));
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Expose the package version to the app as `import.meta.env.VITE_BEADCYTE_VERSION`.
|
|
320
|
+
const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8"));
|
|
321
|
+
process.env.VITE_BEADCYTE_VERSION = packageJson.version;
|
|
322
|
+
// The in-app changelog's link out (bp-67g.46). Deployment-specific — a
|
|
323
|
+
// private group's repo is not something to hardcode — so it is an env var,
|
|
324
|
+
// and the link is absent when unset.
|
|
325
|
+
if (process.env.BEADCYTE_CHANGELOG_URL) {
|
|
326
|
+
process.env.VITE_BEADCYTE_CHANGELOG_URL = process.env.BEADCYTE_CHANGELOG_URL;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const vite = await createViteServer({
|
|
330
|
+
root: webRoot,
|
|
331
|
+
server: {
|
|
332
|
+
port,
|
|
333
|
+
strictPort: false,
|
|
334
|
+
host: "127.0.0.1",
|
|
335
|
+
},
|
|
336
|
+
plugins: [beadsApiPlugin, vuePlugin()],
|
|
337
|
+
clearScreen: false,
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await vite.listen(port);
|
|
341
|
+
|
|
342
|
+
const info = vite.config.server;
|
|
343
|
+
const actualPort = vite.httpServer?.address()?.port ?? info.port ?? port;
|
|
344
|
+
console.log(`\n beadcyte serve — http://127.0.0.1:${actualPort}/`);
|
|
345
|
+
console.log(` data at http://127.0.0.1:${actualPort}/api/beads`);
|
|
346
|
+
console.log(` cwd ${cwd}`);
|
|
347
|
+
// Nothing is loaded at boot, so the banner states the path rather than
|
|
348
|
+
// reporting on a cache entry that cannot exist yet.
|
|
349
|
+
console.log(
|
|
350
|
+
` roster ${rosterPath ?? ROSTER_CANDIDATES.join(" or ")} (per project; derived from beads when absent)`,
|
|
351
|
+
);
|
|
352
|
+
console.log(` projects registered on demand — ${cwd} seeded`);
|
|
353
|
+
console.log(stateFile ? ` stop with beadcyte stop\n` : ` Ctrl-C to stop\n`);
|
|
354
|
+
|
|
355
|
+
if (stateFile) {
|
|
356
|
+
// Written after the banner so `start` can print the banner from the log.
|
|
357
|
+
writeState(stateFile, {
|
|
358
|
+
pid: process.pid,
|
|
359
|
+
port: actualPort,
|
|
360
|
+
url: `http://127.0.0.1:${actualPort}/`,
|
|
361
|
+
cwd,
|
|
362
|
+
startedAt: new Date().toISOString(),
|
|
363
|
+
log: process.env.BEADCYTE_LOG_FILE ?? null,
|
|
364
|
+
});
|
|
365
|
+
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
|
|
366
|
+
process.on(sig, () => {
|
|
367
|
+
clearState(stateFile);
|
|
368
|
+
vite.close().finally(() => process.exit(0));
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Warm the cache so the first browser request is instant.
|
|
374
|
+
// Warm the launch project only. Other projects load when first selected —
|
|
375
|
+
// prefetching a registry the browser holds isn't possible at boot anyway.
|
|
376
|
+
loadBeads(cwd).catch((e) =>
|
|
377
|
+
console.error(`[beadcyte serve] initial bd load failed: ${e.message}`),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// ── helpers ───────────────────────────────────────────────────────────────
|
|
381
|
+
function parseArgs(argv) {
|
|
382
|
+
const out = {};
|
|
383
|
+
for (let i = 0; i < argv.length; i++) {
|
|
384
|
+
const a = argv[i];
|
|
385
|
+
if (!a.startsWith("--")) continue;
|
|
386
|
+
const k = a.slice(2);
|
|
387
|
+
const v = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
388
|
+
out[k] = v;
|
|
389
|
+
}
|
|
390
|
+
return out;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function printHelp() {
|
|
394
|
+
console.log(`beadcyte serve — the interactive app, in the foreground (Ctrl-C to stop)
|
|
395
|
+
|
|
396
|
+
Usage:
|
|
397
|
+
beadcyte serve [--port N] [--roster PATH] [--cache-ttl MS]
|
|
398
|
+
beadcyte start [same flags] the same server in the background; returns
|
|
399
|
+
beadcyte stop end a background server for this directory
|
|
400
|
+
|
|
401
|
+
Boots a Vite dev server for the interactive Gantt at 127.0.0.1:<port>. Same
|
|
402
|
+
port serves /api/beads which shells 'bd list --all --limit 0 --json' from the
|
|
403
|
+
CURRENT WORKING DIRECTORY, cached for --cache-ttl ms (default 30000).
|
|
404
|
+
|
|
405
|
+
Flags:
|
|
406
|
+
--port N Listen port (default: 4173; falls through if taken)
|
|
407
|
+
--roster PATH Roster JSON path (default: .beadcyte/roster.json)
|
|
408
|
+
--cache-ttl MS Beads cache TTL in ms (default: 30000)
|
|
409
|
+
-h, --help This message
|
|
410
|
+
`);
|
|
411
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// server-state.mjs — where `beadcyte start` records the background server.
|
|
2
|
+
//
|
|
3
|
+
// `beadcyte start` launches `beadcyte serve` detached and returns the shell to
|
|
4
|
+
// the user; `beadcyte stop` has to find that process again later, from the same
|
|
5
|
+
// project directory, possibly from a different terminal. The record that joins
|
|
6
|
+
// the two is a small JSON file keyed on the project directory.
|
|
7
|
+
//
|
|
8
|
+
// It lives under the OS temp dir, not inside the project, for two reasons: a
|
|
9
|
+
// user's repository should not need a .gitignore entry to use a viewer, and a
|
|
10
|
+
// leftover record after a crash then costs nothing but a stale file in /tmp.
|
|
11
|
+
// The server's log sits beside it under the same key, so `start` can print
|
|
12
|
+
// where the output went.
|
|
13
|
+
//
|
|
14
|
+
// Pure in the sense this repo means it: nothing runs on import, and every
|
|
15
|
+
// function takes the paths it touches as arguments (with a tmpdir default) so
|
|
16
|
+
// a test can point the whole thing at a scratch directory.
|
|
17
|
+
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import {
|
|
20
|
+
existsSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
unlinkSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { join, resolve } from "node:path";
|
|
28
|
+
|
|
29
|
+
/** Directory that holds every project's record and log. */
|
|
30
|
+
export function stateDir(base = tmpdir()) {
|
|
31
|
+
return join(base, "beadcyte");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Stable key for a project directory: a prefix of the sha1 of its absolute
|
|
36
|
+
* path. Two terminals in the same directory agree; two projects never collide
|
|
37
|
+
* in practice.
|
|
38
|
+
*/
|
|
39
|
+
export function projectKey(projectDir) {
|
|
40
|
+
return createHash("sha1").update(resolve(projectDir)).digest("hex").slice(0, 16);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Path of the JSON record for a project. */
|
|
44
|
+
export function stateFileFor(projectDir, base = tmpdir()) {
|
|
45
|
+
return join(stateDir(base), `${projectKey(projectDir)}.json`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Path of the server log for a project — beside its record. */
|
|
49
|
+
export function logFileFor(projectDir, base = tmpdir()) {
|
|
50
|
+
return join(stateDir(base), `${projectKey(projectDir)}.log`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read a record. Returns null when the file is missing or unparsable — a
|
|
55
|
+
* corrupt record is treated as "not running" rather than as an error, because
|
|
56
|
+
* the only recovery either way is to start again.
|
|
57
|
+
*
|
|
58
|
+
* @returns {{ pid: number, port: number, url: string, cwd: string, startedAt: string, log: string|null } | null}
|
|
59
|
+
*/
|
|
60
|
+
export function readState(file) {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
63
|
+
if (typeof parsed?.pid !== "number") return null;
|
|
64
|
+
return parsed;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Write a record, creating the directory on first use. */
|
|
71
|
+
export function writeState(file, state) {
|
|
72
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
73
|
+
writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Remove a record if present. Missing is not an error. */
|
|
77
|
+
export function clearState(file) {
|
|
78
|
+
if (existsSync(file)) unlinkSync(file);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Is there a process with this pid? Signal 0 probes without delivering.
|
|
83
|
+
* EPERM means it exists but belongs to someone else, which still counts as
|
|
84
|
+
* alive: `stop` will then fail loudly on the real signal instead of quietly
|
|
85
|
+
* deleting a record for a server it cannot reach.
|
|
86
|
+
*/
|
|
87
|
+
export function isAlive(pid) {
|
|
88
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
89
|
+
try {
|
|
90
|
+
process.kill(pid, 0);
|
|
91
|
+
return true;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return e?.code === "EPERM";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The lines `start` prints once the server is up. */
|
|
98
|
+
export function describeRunning(state) {
|
|
99
|
+
return [
|
|
100
|
+
`beadcyte is running for ${state.cwd}`,
|
|
101
|
+
` ${state.url} (pid ${state.pid})`,
|
|
102
|
+
...(state.log ? [` log ${state.log}`] : []),
|
|
103
|
+
` stop with: beadcyte stop`,
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
package/src/ship.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// ship.mjs — the one place that answers "did this close actually ship?"
|
|
2
|
+
//
|
|
3
|
+
// Three models hang off that answer: velocity (and therefore every
|
|
4
|
+
// calibrated WIP cap), the estimator's duration and lead-time samples, and
|
|
5
|
+
// the ships-per-week trend. The Gantt also draws `shipped` vs `closedNoShip`
|
|
6
|
+
// from it. Until this module existed the rule was written out five times —
|
|
7
|
+
// `!!b.metadata?.ship?.mr_url` in the estimator (twice), velocity, the SVG
|
|
8
|
+
// CLI and the web view-model — which is precisely how two of them end up
|
|
9
|
+
// disagreeing about what a ship is a year from now.
|
|
10
|
+
//
|
|
11
|
+
// ## Why a commit counts
|
|
12
|
+
//
|
|
13
|
+
// `mr_url` was the only accepted evidence, which quietly restricted the tool
|
|
14
|
+
// to teams working through merge requests. beadcyte's own repo is the case in
|
|
15
|
+
// point: real work, 41 closed beads, commits that name the bead they close,
|
|
16
|
+
// and a velocity of zero — not because nothing shipped but because the
|
|
17
|
+
// evidence took a form the tool refused to read.
|
|
18
|
+
//
|
|
19
|
+
// ## Evidence, not a checkbox
|
|
20
|
+
//
|
|
21
|
+
// docs/economics.md argues that `mr_url` is trustworthy downstream precisely
|
|
22
|
+
// because the close command *refuses to close without it*. Widening the
|
|
23
|
+
// accepted forms must not widen that gate, so:
|
|
24
|
+
//
|
|
25
|
+
// - `ship: {}` is not evidence. Neither is `ship: { mr_url: "" }`.
|
|
26
|
+
// - a commit must look like a commit. "TODO", "yes" and "see MR" are not
|
|
27
|
+
// SHAs, and accepting them would let a placeholder manufacture a ship —
|
|
28
|
+
// the one failure mode that would make every number downstream worse
|
|
29
|
+
// rather than merely absent.
|
|
30
|
+
//
|
|
31
|
+
// A rejected-but-present record is reported rather than silently treated as
|
|
32
|
+
// absent, because "you wrote something and it didn't count" needs to be
|
|
33
|
+
// visible or it will be written wrong forever.
|
|
34
|
+
//
|
|
35
|
+
// ## SHAs are trusted, not verified
|
|
36
|
+
//
|
|
37
|
+
// beadcyte never assumes the beads DB and the code live in the same place: it
|
|
38
|
+
// reads `bd list --json`, `beadcyte serve` is normally a global install pointed at
|
|
39
|
+
// some other directory, and beads sync over `refs/dolt/data` independent of
|
|
40
|
+
// any working tree. So there is frequently no repo at hand to check against,
|
|
41
|
+
// and where there is, a SHA that fails to resolve is far more likely to mean
|
|
42
|
+
// a shallow clone, an unfetched branch or a monorepo split than a fabricated
|
|
43
|
+
// record. Verification would also cost one `git cat-file` per closed bead
|
|
44
|
+
// inside a request path that is already cached because a single `bd list` is
|
|
45
|
+
// slow. So the shape is checked and the value is taken on trust.
|
|
46
|
+
|
|
47
|
+
import { readProvenance, RECORDED } from "./provenance.mjs";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Abbreviated-to-full git object names: 7–40 hex for SHA-1, up to 64 for the
|
|
51
|
+
* SHA-256 transition. Deliberately not anchored at 40 — plenty of workflows
|
|
52
|
+
* record `git rev-parse --short HEAD`, and refusing those would push people
|
|
53
|
+
* back to having no recordable evidence at all.
|
|
54
|
+
*/
|
|
55
|
+
const SHA_RE = /^[0-9a-f]{7,64}$/i;
|
|
56
|
+
|
|
57
|
+
/** How many characters of a SHA to show when a UI wants a compact one. */
|
|
58
|
+
const SHORT_SHA_LEN = 8;
|
|
59
|
+
|
|
60
|
+
function trimmed(v) {
|
|
61
|
+
return typeof v === "string" && v.trim() ? v.trim() : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The ship block, or a reason it is unreadable.
|
|
66
|
+
*
|
|
67
|
+
* The string case is not hypothetical: `bd update --set-metadata
|
|
68
|
+
* ship={"commit":"..."}` stores the value as a JSON *string*, so a record
|
|
69
|
+
* that reads correctly in `bd show` is not an object at all and counts for
|
|
70
|
+
* nothing. Naming that beats treating it as absent — it is a typo in the
|
|
71
|
+
* command, and nothing else in the UI would ever hint at it.
|
|
72
|
+
*
|
|
73
|
+
* @param {{ metadata?: unknown }} bead
|
|
74
|
+
* @returns {{ ship: Record<string, unknown> } | { malformed: string | null }}
|
|
75
|
+
*/
|
|
76
|
+
function readShip(bead) {
|
|
77
|
+
const md = /** @type {{ ship?: unknown } | null | undefined} */ (bead?.metadata);
|
|
78
|
+
const ship = md?.ship;
|
|
79
|
+
if (ship === undefined || ship === null) return { malformed: null };
|
|
80
|
+
if (typeof ship !== "object" || Array.isArray(ship)) {
|
|
81
|
+
return {
|
|
82
|
+
malformed:
|
|
83
|
+
`ship record is a ${Array.isArray(ship) ? "list" : typeof ship}, not an object` +
|
|
84
|
+
// Plain prose: this string is printed straight into the drawer and
|
|
85
|
+
// the tooltip, neither of which renders markdown.
|
|
86
|
+
(typeof ship === "string"
|
|
87
|
+
? " — bd update --set-metadata stores JSON as a string; use --metadata"
|
|
88
|
+
: ""),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return { ship: /** @type {Record<string, unknown>} */ (ship) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @typedef {Object} ShipEvidence
|
|
96
|
+
* @property {string[]} forms which kinds of evidence were found, in
|
|
97
|
+
* precedence order: "mr" before "commit". Never empty.
|
|
98
|
+
* @property {string | null} mrUrl
|
|
99
|
+
* @property {string | null} commit full value as recorded
|
|
100
|
+
* @property {string | null} shortCommit first 8 chars, for display
|
|
101
|
+
* @property {string | null} rejected a present-but-unusable field, if any
|
|
102
|
+
* accompanied a usable one (e.g. a valid MR URL and a junk commit)
|
|
103
|
+
* @property {import("./provenance.mjs").Provenance} provenance
|
|
104
|
+
* how the evidence was obtained. A SHA recovered from a `Closes <id>`
|
|
105
|
+
* trailer is a fact; the claim that it is THE closing commit is an
|
|
106
|
+
* inference, and this is what separates the two. Reads the form that
|
|
107
|
+
* carried the evidence — MR first, since it is the stronger record.
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The ship evidence on a bead, or null if it has none that counts.
|
|
112
|
+
*
|
|
113
|
+
* Says nothing about `status` — a bead can carry a ship record before its
|
|
114
|
+
* close lands, and callers that mean "shipped and closed" already test the
|
|
115
|
+
* status themselves. Keeping the two apart is what lets the drawer show the
|
|
116
|
+
* evidence on an in-review bead.
|
|
117
|
+
*
|
|
118
|
+
* @param {{ metadata?: unknown }} bead a bd list --json bead
|
|
119
|
+
* @returns {ShipEvidence | null}
|
|
120
|
+
*/
|
|
121
|
+
export function shipEvidence(bead) {
|
|
122
|
+
const { ship } = readShip(bead);
|
|
123
|
+
if (!ship) return null;
|
|
124
|
+
|
|
125
|
+
const mrUrl = trimmed(ship.mr_url);
|
|
126
|
+
const rawCommit = trimmed(ship.commit);
|
|
127
|
+
const commit = rawCommit && SHA_RE.test(rawCommit) ? rawCommit : null;
|
|
128
|
+
|
|
129
|
+
const forms = [];
|
|
130
|
+
if (mrUrl) forms.push("mr");
|
|
131
|
+
if (commit) forms.push("commit");
|
|
132
|
+
if (forms.length === 0) return null;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
forms,
|
|
136
|
+
mrUrl,
|
|
137
|
+
commit,
|
|
138
|
+
shortCommit: commit ? commit.slice(0, SHORT_SHA_LEN) : null,
|
|
139
|
+
rejected: rawCommit && !commit ? `commit is not a SHA: ${rawCommit}` : null,
|
|
140
|
+
provenance:
|
|
141
|
+
readProvenance(ship.provenance, forms[0] === "mr" ? "mr_url" : "commit") ??
|
|
142
|
+
RECORDED,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Did this bead ship? The predicate every model should use.
|
|
148
|
+
*
|
|
149
|
+
* @param {{ metadata?: unknown }} bead
|
|
150
|
+
* @returns {boolean}
|
|
151
|
+
*/
|
|
152
|
+
export function isShipped(bead) {
|
|
153
|
+
return shipEvidence(bead) !== null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Why a bead that looks like it tried to record a ship doesn't count, or null
|
|
158
|
+
* when there is nothing to explain — no record at all, or a valid one.
|
|
159
|
+
*
|
|
160
|
+
* Three distinguishable states, and a UI that collapses them is lying about
|
|
161
|
+
* one of them: nothing recorded, something recorded that isn't evidence, and
|
|
162
|
+
* evidence.
|
|
163
|
+
*
|
|
164
|
+
* @param {{ metadata?: unknown }} bead
|
|
165
|
+
* @returns {string | null}
|
|
166
|
+
*/
|
|
167
|
+
export function shipRecordProblem(bead) {
|
|
168
|
+
const { ship, malformed } = readShip(bead);
|
|
169
|
+
if (!ship) return malformed ?? null;
|
|
170
|
+
|
|
171
|
+
const evidence = shipEvidence(bead);
|
|
172
|
+
if (evidence) return evidence.rejected;
|
|
173
|
+
|
|
174
|
+
const rawCommit = trimmed(ship.commit);
|
|
175
|
+
if (rawCommit) return `commit is not a SHA: ${rawCommit}`;
|
|
176
|
+
if (Object.keys(ship).length === 0) return "ship record is empty";
|
|
177
|
+
return "ship record carries no mr_url and no commit";
|
|
178
|
+
}
|