karajan-code 2.18.1 → 2.19.1

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.
Files changed (57) hide show
  1. package/README.md +53 -34
  2. package/docs/README.es.md +13 -2
  3. package/package.json +9 -1
  4. package/packages/hu-board/README.md +69 -0
  5. package/packages/hu-board/package.json +23 -0
  6. package/packages/hu-board/public/app.js +4038 -0
  7. package/packages/hu-board/public/index.html +55 -0
  8. package/packages/hu-board/public/pipeline.css +132 -0
  9. package/packages/hu-board/public/pipeline.html +48 -0
  10. package/packages/hu-board/public/pipeline.js +200 -0
  11. package/packages/hu-board/public/styles.css +918 -0
  12. package/packages/hu-board/src/auth.js +92 -0
  13. package/packages/hu-board/src/boot-info.js +16 -0
  14. package/packages/hu-board/src/cleanup-zombies.js +142 -0
  15. package/packages/hu-board/src/command-runner.js +134 -0
  16. package/packages/hu-board/src/config-yaml.js +285 -0
  17. package/packages/hu-board/src/db.js +739 -0
  18. package/packages/hu-board/src/ephemeral-cleaner.js +198 -0
  19. package/packages/hu-board/src/event-bus.js +58 -0
  20. package/packages/hu-board/src/format.js +83 -0
  21. package/packages/hu-board/src/hu-zombie-reaper.js +103 -0
  22. package/packages/hu-board/src/journal-parser.js +180 -0
  23. package/packages/hu-board/src/plan-mutations.js +422 -0
  24. package/packages/hu-board/src/preflight.js +285 -0
  25. package/packages/hu-board/src/routes/api.js +1271 -0
  26. package/packages/hu-board/src/routes/pipeline.js +45 -0
  27. package/packages/hu-board/src/run-tracker.js +121 -0
  28. package/packages/hu-board/src/server.js +435 -0
  29. package/packages/hu-board/src/sync.js +626 -0
  30. package/packages/hu-board/src/token-store.js +57 -0
  31. package/packages/hu-board/src/zombie-reaper.js +207 -0
  32. package/src/audit/webperf-input.js +4 -7
  33. package/src/brain/standby-store.js +6 -4
  34. package/src/checks/dir-setup.js +39 -3
  35. package/src/cli/register-pipeline.js +1 -0
  36. package/src/cli/register-plan.js +1 -0
  37. package/src/cli.js +13 -0
  38. package/src/commands/plan/generate.js +15 -0
  39. package/src/commands/run.js +19 -1
  40. package/src/commands/webperf.js +4 -6
  41. package/src/config/role-resolver.js +3 -1
  42. package/src/mcp/handlers/direct-handlers.js +3 -0
  43. package/src/mcp/handlers/run-handler.js +5 -0
  44. package/src/mcp/tools.js +4 -2
  45. package/src/plan/plan-store.js +7 -27
  46. package/src/prompts/spec-reviewer.js +66 -0
  47. package/src/roles/perf-role.js +4 -4
  48. package/src/roles/spec-reviewer-role.js +106 -0
  49. package/src/spec-review/refine-loop.js +58 -0
  50. package/src/spec-review/run-spec-review.js +70 -0
  51. package/src/utils/board-prompt-bridge.js +4 -5
  52. package/src/utils/display/spec-findings.js +44 -0
  53. package/src/utils/garbage-collector.js +8 -6
  54. package/src/utils/home-migration.js +87 -0
  55. package/src/utils/paths.js +85 -17
  56. package/src/utils/run-registry.js +4 -4
  57. package/templates/roles/spec-reviewer.md +38 -0
@@ -0,0 +1,45 @@
1
+ /**
2
+ * /api/pipeline/* — routes backing the pipeline observability view
3
+ * (TSK-0325). Reads the session journal on every request; the UI polls
4
+ * this endpoint every 1-2 s. No WebSocket, no SSE — polling keeps the
5
+ * surface minimal and test-friendly.
6
+ */
7
+
8
+ import { Router } from "express";
9
+ import { listSessions, readJournal } from "../journal-parser.js";
10
+
11
+ const router = Router();
12
+
13
+ /**
14
+ * GET /api/pipeline/sessions
15
+ * List every session with a journal directory. Most recent first.
16
+ */
17
+ router.get("/sessions", (_req, res) => {
18
+ try {
19
+ const sessions = listSessions().map((s) => ({
20
+ id: s.id,
21
+ mtime: new Date(s.mtimeMs).toISOString(),
22
+ }));
23
+ res.json({ sessions });
24
+ } catch (err) {
25
+ res.status(500).json({ error: err.message });
26
+ }
27
+ });
28
+
29
+ /**
30
+ * GET /api/pipeline/sessions/:id
31
+ * Full parsed journal for a given session.
32
+ */
33
+ router.get("/sessions/:id", (req, res) => {
34
+ try {
35
+ const journal = readJournal(req.params.id);
36
+ if (!journal.exists) {
37
+ return res.status(404).json({ error: `session "${req.params.id}" not found` });
38
+ }
39
+ res.json(journal);
40
+ } catch (err) {
41
+ res.status(500).json({ error: err.message });
42
+ }
43
+ });
44
+
45
+ export default router;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Run tracker — registro híbrido (memoria + disco) de procesos kj run.
3
+ *
4
+ * KJC-TSK-0396: el botón ⏹ Stop del board combina dos fuentes:
5
+ *
6
+ * 1. **Memoria** (`runs` Map) — runs lanzados desde el board vía
7
+ * `runPlan` en plan-mutations.js. Bajo coste, sin I/O.
8
+ *
9
+ * 2. **Disco** (`~/.karajan/runs/<runId>.json` vía run-registry.js) —
10
+ * runs lanzados desde la terminal externa (cualquier `kj run`). El
11
+ * CLI escribe el registro al arranque y lo borra al final. Esto da
12
+ * bidireccionalidad terminal ↔ board.
13
+ *
14
+ * `getActiveRuns(planId)` une las dos fuentes y dedupe por PID. Los
15
+ * registros con PID muerto se filtran automáticamente y los del disco
16
+ * se borran como cleanup (responsabilidad de run-registry).
17
+ */
18
+
19
+ import { listActiveRuns as fsList } from "../../../src/utils/run-registry.js";
20
+
21
+ const runs = new Map();
22
+ let reaperTimer = null;
23
+
24
+ /**
25
+ * Registra un run lanzado desde el board (memoria).
26
+ *
27
+ * @param {string} planId
28
+ * @param {{ pid: number, huIds?: string[]|null }} info
29
+ */
30
+ export function trackRun(planId, info) {
31
+ if (!planId || !info?.pid) return;
32
+ const set = runs.get(planId) || new Set();
33
+ set.add({ pid: info.pid, huIds: info.huIds || null, startedAt: Date.now() });
34
+ runs.set(planId, set);
35
+ ensureReaper();
36
+ }
37
+
38
+ /**
39
+ * Devuelve los runs activos para un planId. Une memoria + FS y dedupe
40
+ * por PID. Filtra PIDs muertos.
41
+ */
42
+ export function getActiveRuns(planId) {
43
+ const seen = new Set();
44
+ const out = [];
45
+ // Memoria
46
+ const set = runs.get(planId);
47
+ if (set) {
48
+ for (const r of set) {
49
+ if (isAlive(r.pid)) {
50
+ seen.add(r.pid);
51
+ out.push({ pid: r.pid, huIds: r.huIds, startedAt: r.startedAt, source: "board" });
52
+ } else set.delete(r);
53
+ }
54
+ if (set.size === 0) runs.delete(planId);
55
+ }
56
+ // Disco
57
+ for (const entry of fsList({ planId })) {
58
+ if (seen.has(entry.pid)) continue;
59
+ seen.add(entry.pid);
60
+ out.push({
61
+ pid: entry.pid,
62
+ huIds: entry.huIds,
63
+ startedAt: entry.startedAt,
64
+ source: entry.source || "cli",
65
+ projectDir: entry.projectDir,
66
+ });
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Igual que getActiveRuns pero para todos los planes (memoria + FS).
73
+ */
74
+ export function getAllActiveRuns() {
75
+ const out = {};
76
+ // Memoria
77
+ for (const planId of runs.keys()) {
78
+ const alive = getActiveRuns(planId);
79
+ if (alive.length > 0) out[planId] = alive;
80
+ }
81
+ // FS — añadir planes que solo viven en disco (no se vieron arriba).
82
+ for (const entry of fsList()) {
83
+ if (!entry.planId) continue;
84
+ if (out[entry.planId]) continue;
85
+ out[entry.planId] = getActiveRuns(entry.planId);
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * Quita el registro de un PID de memoria. Los registros FS los limpia
92
+ * el propio CLI (vía registry.unregisterRun en su finally).
93
+ */
94
+ export function untrack(planId, pid) {
95
+ const set = runs.get(planId);
96
+ if (!set) return;
97
+ for (const r of set) {
98
+ if (r.pid === pid) set.delete(r);
99
+ }
100
+ if (set.size === 0) runs.delete(planId);
101
+ }
102
+
103
+ function isAlive(pid) {
104
+ if (!pid) return false;
105
+ try { process.kill(pid, 0); return true; }
106
+ catch (err) { return err.code === "EPERM"; }
107
+ }
108
+
109
+ /** Test-only reset. */
110
+ export function _resetForTests() {
111
+ runs.clear();
112
+ if (reaperTimer) { clearInterval(reaperTimer); reaperTimer = null; }
113
+ }
114
+
115
+ function ensureReaper() {
116
+ if (reaperTimer || process.env.VITEST) return;
117
+ reaperTimer = setInterval(() => {
118
+ for (const planId of [...runs.keys()]) getActiveRuns(planId);
119
+ }, 30_000);
120
+ reaperTimer.unref?.();
121
+ }
@@ -0,0 +1,435 @@
1
+ import express from 'express';
2
+ import helmet from 'helmet';
3
+ import rateLimit from 'express-rate-limit';
4
+ import { join, dirname } from 'node:path';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { writeFileSync, rmSync, mkdirSync, realpathSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { initDb, closeDb } from './db.js';
9
+ import { fullScan, startWatcher } from './sync.js';
10
+ import apiRoutes from './routes/api.js';
11
+ import pipelineRoutes from './routes/pipeline.js';
12
+ import { authMiddleware } from './auth.js';
13
+ import { getOrCreateToken, getTokenPath } from './token-store.js';
14
+ import { reapZombieSessions } from './zombie-reaper.js';
15
+ import { reapZombieHus } from './hu-zombie-reaper.js';
16
+ import { setHuStatus as setHuStatusPlanMutation, setHuFailResult as setHuFailResultPlanMutation } from './plan-mutations.js';
17
+ import { cleanupEphemeralProjects } from './ephemeral-cleaner.js';
18
+ import { findAvailablePort as findAvailablePortBase } from '../../../src/utils/port-check.js';
19
+
20
+ /**
21
+ * Path to the PID file the CLI's `kj board start` / `kj board stop`
22
+ * also use. Pre-v2.7.5 this was written ONLY by the CLI launcher,
23
+ * so a self-respawn (the 🔁 button on the board) left a stale PID
24
+ * pointing at a dead process — and the next `kj plan` saw that, gave
25
+ * up on the existing board, and spawned a new one on port 4001.
26
+ *
27
+ * Now the server writes its own PID on every start so both the CLI
28
+ * launch and the in-place restart keep the file accurate.
29
+ */
30
+ // KJC-TSK-0420: delegate to db.js::getKjHome so KARAJAN_HOME / KJ_HOME /
31
+ // VITEST all share one precedence chain.
32
+ import { getKjHome } from './db.js';
33
+ const PID_FILE = join(getKjHome(), 'hu-board.pid');
34
+
35
+ const __dirname = dirname(fileURLToPath(import.meta.url));
36
+ const PUBLIC_DIR = join(__dirname, '..', 'public');
37
+
38
+ /**
39
+ * Cache-Control headers that tell the browser to revalidate every fetch.
40
+ * Applied to HTML / JS / CSS so a server restart can't be hidden by stale
41
+ * in-memory cache. Assets with content-hash in filename can override this
42
+ * later with `immutable` if we adopt a build step.
43
+ */
44
+ function noStoreHeaders(res, filePath) {
45
+ if (/\.(html|js|css)$/i.test(filePath)) {
46
+ res.setHeader('Cache-Control', 'no-store, must-revalidate');
47
+ }
48
+ }
49
+
50
+ /** Default bind: loopback only. Override with BIND_HOST=0.0.0.0 (or any IP). */
51
+ const DEFAULT_BIND_HOST = '127.0.0.1';
52
+
53
+ /** Loopback addresses that don't need a security warning at startup. */
54
+ const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', 'localhost', '::1']);
55
+
56
+ /**
57
+ * Finds an available port starting from the given port, logging each busy
58
+ * hop. Delegates to the shared util in src/utils/port-check.js.
59
+ * @param {number} startPort
60
+ * @param {number} maxAttempts
61
+ * @returns {Promise<number>}
62
+ */
63
+ function findAvailablePort(startPort, maxAttempts = 11) {
64
+ return findAvailablePortBase(startPort, maxAttempts, (port) => {
65
+ console.log(`[server] Port ${port} is busy, trying ${port + 1}...`);
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Parses the desired port from env var or --port flag.
71
+ * @returns {number}
72
+ */
73
+ function getDesiredPort() {
74
+ const portArg = process.argv.find((arg, i, arr) => arr[i - 1] === '--port');
75
+ if (portArg) return parseInt(portArg, 10);
76
+ return parseInt(process.env.PORT || '4000', 10);
77
+ }
78
+
79
+ /**
80
+ * Resolve the bind host. Precedence:
81
+ * 1. `BIND_HOST` env var (set by `kj board start --bind <host>`)
82
+ * 2. Default: 127.0.0.1 (loopback only)
83
+ * @returns {string}
84
+ */
85
+ function getBindHost() {
86
+ return process.env.BIND_HOST || DEFAULT_BIND_HOST;
87
+ }
88
+
89
+ /**
90
+ * Build the security middleware stack used before any route handler.
91
+ * Exposed as a function so tests can mount the same stack on a
92
+ * disposable Express app.
93
+ * @returns {import('express').RequestHandler[]}
94
+ */
95
+ export function buildSecurityMiddleware() {
96
+ const stack = [
97
+ // Default helmet config sets X-Content-Type-Options, X-DNS-Prefetch-Control,
98
+ // X-Frame-Options, Strict-Transport-Security (when over HTTPS), and a
99
+ // conservative Content-Security-Policy. We keep CSP loose enough for the
100
+ // existing inline scripts in public/index.html to run.
101
+ helmet({
102
+ contentSecurityPolicy: {
103
+ useDefaults: true,
104
+ directives: {
105
+ // Allow inline scripts, styles AND inline event handlers
106
+ // (onclick="..."). Helmet's default puts `script-src-attr
107
+ // 'none'` which silently blocks every onclick in app.js —
108
+ // detected during the 2026-05-07 dogfooding session when
109
+ // `kj board` Sessions cards became un-clickable.
110
+ //
111
+ // Tightening this is a follow-up: app.js would need to
112
+ // migrate every inline handler to addEventListener.
113
+ "script-src": ["'self'", "'unsafe-inline'"],
114
+ "script-src-attr": ["'unsafe-inline'"],
115
+ // Google Fonts (CSS + woff2) used by index.html.
116
+ "style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
117
+ "font-src": ["'self'", "https://fonts.gstatic.com", "data:"],
118
+ "img-src": ["'self'", "data:"],
119
+ },
120
+ },
121
+ // The board is a same-origin SPA; cross-origin embedders should
122
+ // never load it, but COEP/CORP defaults sometimes break dev tools
123
+ // proxies. Keep them off until there's a concrete need.
124
+ crossOriginEmbedderPolicy: false,
125
+ }),
126
+ ];
127
+ return stack;
128
+ }
129
+
130
+ /**
131
+ * Build the rate-limit middleware for `/api`.
132
+ *
133
+ * Default is 600 req/min per IP (raised from 300 in KJC-BUG-0039 —
134
+ * the first-load fanout of the dashboard + multiple tabs + SSE
135
+ * reconnects after a refresh could push past 300/min and hit the user
136
+ * with a 429 on the very first page they see).
137
+ *
138
+ * Override via env var `HU_BOARD_RATE_LIMIT=<n>` for unusual setups
139
+ * (load tests, multi-board farms behind a shared NAT).
140
+ *
141
+ * SSE (`/api/events`) is exempt — a single persistent stream should
142
+ * not count toward a per-minute request budget, and tab refreshes
143
+ * legitimately create new SSE connections.
144
+ *
145
+ * @returns {import('express').RequestHandler}
146
+ */
147
+ export function buildRateLimiter() {
148
+ const raw = Number(process.env.HU_BOARD_RATE_LIMIT);
149
+ const limit = Number.isFinite(raw) && raw > 0 ? raw : 600;
150
+ return rateLimit({
151
+ windowMs: 60 * 1000,
152
+ limit,
153
+ standardHeaders: 'draft-7', // RateLimit-* headers
154
+ legacyHeaders: false, // drop X-RateLimit-*
155
+ skip: (req) => req.path === '/events' || req.path.startsWith('/events/'),
156
+ message: {
157
+ error: 'Too Many Requests',
158
+ message: 'Rate limit exceeded. Try again in a few seconds.',
159
+ },
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Main entry point: initializes database, syncs data, and starts the server.
165
+ */
166
+ async function main() {
167
+ // Initialize SQLite. better-sqlite3 is a native module — when its
168
+ // prebuilt binary is missing or ABI-mismatched (Node was upgraded),
169
+ // initDb() throws. Surface an actionable message instead of a bare
170
+ // stack trace so the user knows exactly what to run.
171
+ console.log('[server] Initializing database...');
172
+ try {
173
+ initDb();
174
+ } catch (err) {
175
+ console.error('[server] FATAL: could not initialize the database.');
176
+ console.error('[server] The native module better-sqlite3 failed to load, or the DB file could not be opened.');
177
+ console.error('[server] Fix: run `npm rebuild better-sqlite3` (or reinstall karajan-code).');
178
+ console.error('[server] Original error:', err?.stack || String(err));
179
+ process.exit(1);
180
+ }
181
+
182
+ // Full scan of existing files BEFORE the reaper runs. Otherwise
183
+ // fullScan would read every session.json from disk and overwrite
184
+ // the reap-failed status the reaper just set, resurrecting the
185
+ // zombie on every boot. Order matters here.
186
+ console.log('[server] Running full scan of JSON files...');
187
+ fullScan();
188
+
189
+ // Reap any session that the orchestrator left in a non-terminal
190
+ // state. Updates BOTH the DB row AND the session.json on disk so
191
+ // the next boot's fullScan sees a stable `failed` status and never
192
+ // resurrects the zombi.
193
+ try {
194
+ const dbHandle = (await import('./db.js')).getDb();
195
+ const reaped = reapZombieSessions({ db: dbHandle });
196
+ if (reaped.length > 0) {
197
+ console.log(`[zombie-reaper] reaped ${reaped.length} stale session(s):`);
198
+ for (const r of reaped) {
199
+ const diskNote = r.disk_persisted ? '' : ' (disk write failed — DB-only)';
200
+ console.log(` - ${r.id} (was ${r.status_before}; ${r.reason})${diskNote}`);
201
+ }
202
+ } else {
203
+ console.log('[zombie-reaper] no zombie sessions detected');
204
+ }
205
+ } catch (err) {
206
+ // Never block startup on the reaper. Log and move on.
207
+ console.warn(`[zombie-reaper] skipped: ${err.message}`);
208
+ }
209
+
210
+ // KJC-TSK-0394 step 3 — resetear a `pending` HUs colgadas en
211
+ // coding/reviewing >30min (orquestador crashed mid-iteration). NO
212
+ // a `failed` porque no sabemos el outcome; `result` se preserva.
213
+ try {
214
+ const dbHandle = (await import('./db.js')).getDb();
215
+ const reapedHus = reapZombieHus({
216
+ db: dbHandle,
217
+ setHuStatus: setHuStatusPlanMutation,
218
+ setHuFailResult: setHuFailResultPlanMutation,
219
+ });
220
+ if (reapedHus.length > 0) {
221
+ console.log(`[hu-zombie-reaper] reset ${reapedHus.length} stuck HU(s) to pending:`);
222
+ for (const r of reapedHus) {
223
+ const planNote = r.plan_persisted ? '' : ' (plan write failed — DB-only)';
224
+ console.log(` - ${r.id} (was ${r.status_before}; ${r.reason})${planNote}`);
225
+ }
226
+ } else {
227
+ console.log('[hu-zombie-reaper] no stuck HUs detected');
228
+ }
229
+ } catch (err) {
230
+ console.warn(`[hu-zombie-reaper] skipped: ${err.message}`);
231
+ }
232
+
233
+ // KJC-TSK-0414 PR2 — reconciliación de sesiones hibernadas al arrancar.
234
+ // Para cada session en ~/.kj/standby/<id>.json:
235
+ // - cooldownUntil <= now → spawn `kj resume <id>` inmediato
236
+ // - cooldownUntil > now → re-programa setTimeout que dispara en cooldown
237
+ // Idempotente (lockfile per session). Cero polling.
238
+ try {
239
+ const { reconcileAll } = await import('../../../src/brain/standby-scheduler.js');
240
+ const { spawn } = await import('node:child_process');
241
+ const { join, dirname } = await import('node:path');
242
+ const { fileURLToPath } = await import('node:url');
243
+ const KJ_CLI = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'src', 'cli.js');
244
+ const onResume = (sessionId) => {
245
+ console.log(`[standby] reanudando sesión ${sessionId} (spawn kj standby resume)`);
246
+ try {
247
+ const child = spawn(process.execPath, [KJ_CLI, 'standby', 'resume', sessionId], {
248
+ detached: true, stdio: 'ignore', env: process.env,
249
+ });
250
+ child.unref();
251
+ } catch (err) {
252
+ console.warn(`[standby] spawn resume falló para ${sessionId}: ${err.message}`);
253
+ }
254
+ };
255
+ const r = reconcileAll({ onResume, logger: { info: console.log.bind(console), warn: console.warn.bind(console) } });
256
+ if (r.total > 0) console.log(`[standby] reconcile: ${r.total} session(es) — ${r.immediate} resume inmediato, ${r.scheduled} programadas`);
257
+ } catch (err) {
258
+ console.warn(`[standby] reconcile skipped: ${err.message}`);
259
+ }
260
+
261
+ // KJC-TSK-0371 (board polish #3) — clean up ephemeral projects
262
+ // (`/tmp/`-rooted, `tmp_*`/`test_*`/`demo_*`/`kj-test-*` prefixed)
263
+ // that have been inactive for >24h. Runs AFTER the zombie-reaper
264
+ // so any session that just got marked failed counts toward
265
+ // last_activity correctly. The user can override per-project via
266
+ // PATCH /api/projects/:id/is-test.
267
+ try {
268
+ const dbHandle = (await import('./db.js')).getDb();
269
+ const cleaned = cleanupEphemeralProjects({ db: dbHandle });
270
+ if (cleaned.length > 0) {
271
+ console.log(`[ephemeral-cleaner] removed ${cleaned.length} ephemeral project(s):`);
272
+ for (const c of cleaned) {
273
+ console.log(` - ${c.id} (${c.reason}; ${c.stories_deleted} stories, ${c.sessions_deleted} sessions cascaded)`);
274
+ }
275
+ } else {
276
+ console.log('[ephemeral-cleaner] no ephemeral projects detected');
277
+ }
278
+ } catch (err) {
279
+ console.warn(`[ephemeral-cleaner] skipped: ${err.message}`);
280
+ }
281
+
282
+ // Start file watcher
283
+ const watcher = startWatcher();
284
+
285
+ // Bootstrap the auth token before mounting routes. The token is
286
+ // only ENFORCED for non-loopback peers (see auth.js), but we set
287
+ // it unconditionally so flipping `--bind 0.0.0.0` later doesn't
288
+ // require a manual file fiddle.
289
+ if (!process.env.HU_BOARD_TOKEN) {
290
+ const token = getOrCreateToken();
291
+ if (token) process.env.HU_BOARD_TOKEN = token;
292
+ }
293
+
294
+ // Create Express app
295
+ const app = express();
296
+ app.use(...buildSecurityMiddleware());
297
+ app.use(express.json());
298
+ app.use(express.static(PUBLIC_DIR, { setHeaders: noStoreHeaders, etag: false, lastModified: false }));
299
+ app.use('/api', buildRateLimiter(), authMiddleware(), apiRoutes);
300
+ app.use('/api/pipeline', authMiddleware(), pipelineRoutes);
301
+
302
+ // SPA fallback: serve index.html for non-API, non-static routes
303
+ app.get('/{*splat}', (_req, res) => {
304
+ res.setHeader('Cache-Control', 'no-store, must-revalidate');
305
+ res.sendFile(join(PUBLIC_DIR, 'index.html'));
306
+ });
307
+
308
+ // Find available port (always probed against loopback so the
309
+ // result is meaningful regardless of bind host).
310
+ const desiredPort = getDesiredPort();
311
+ const port = await findAvailablePort(desiredPort);
312
+ const bindHost = getBindHost();
313
+ const isLoopback = LOOPBACK_ADDRESSES.has(bindHost);
314
+
315
+ const server = app.listen(port, bindHost, () => {
316
+ // Write the PID file so the CLI's `kj board status / stop` and
317
+ // the next `kj plan`'s startBoard() check find this server. The
318
+ // file used to be written only by `kj board start`'s launcher,
319
+ // which meant the 🔁 self-respawn left a stale PID and the next
320
+ // auto-start spawned a duplicate board on the next free port.
321
+ try {
322
+ mkdirSync(dirname(PID_FILE), { recursive: true });
323
+ writeFileSync(PID_FILE, String(process.pid), 'utf8');
324
+ } catch (err) {
325
+ console.warn(`[server] could not write PID file: ${err.message}`);
326
+ }
327
+ const visibleHost = isLoopback ? 'localhost' : bindHost;
328
+ console.log(`\n Karajan HU Board`);
329
+ console.log(` -----------------`);
330
+ console.log(` PID: ${process.pid}`);
331
+ console.log(` Running at: http://${visibleHost}:${port}`);
332
+ console.log(` Data dir: ${process.env.KJ_HOME || '~/.karajan'}`);
333
+ if (!isLoopback) {
334
+ const token = process.env.HU_BOARD_TOKEN;
335
+ console.log('');
336
+ console.log(' ⚠️ Bound to non-loopback interface.');
337
+ console.log(` Token required for non-loopback peers: ${getTokenPath()}`);
338
+ if (token) {
339
+ console.log(` URL with token: http://${bindHost}:${port}/?token=${token}`);
340
+ }
341
+ }
342
+ console.log('');
343
+ });
344
+
345
+ // Graceful shutdown
346
+ //
347
+ // Two pre-2026-05-07 footguns this fixes:
348
+ //
349
+ // 1) `server.close()` was called LAST, so for ~1s after `kj board
350
+ // stop` the HTTP server stayed up while watcher/DB cleanup ran.
351
+ // A browser refresh during that window — exactly the user's
352
+ // "1 segundo después se carga el board" report — saw a working
353
+ // board because the old keep-alive connections were still
354
+ // being served. Now we tell the server to stop accepting new
355
+ // connections IMMEDIATELY.
356
+ //
357
+ // 2) `server.close()` only waits for active connections to drain.
358
+ // The browser's HTTP/1.1 keep-alive connections can stay open
359
+ // for minutes, so the daemon process never actually exited
360
+ // after stop. `closeAllConnections()` (Node 18.2+) force-kills
361
+ // every active socket so the daemon can exit promptly.
362
+ //
363
+ // 3) A 5s hard deadline guarantees exit even if something goes
364
+ // wrong with the cleanup. SIGKILL-by-the-OS is the cure if
365
+ // the user really wants out, but a clean exit is the goal.
366
+ let shuttingDown = false;
367
+ const shutdown = () => {
368
+ if (shuttingDown) return;
369
+ shuttingDown = true;
370
+ console.log('\n[server] Shutting down...');
371
+ // 1. Stop accepting new connections + force-close existing ones.
372
+ server.close(() => {
373
+ // The cleanup happens in the close callback so we don't run it
374
+ // before the server has detached from its listening socket.
375
+ try { watcher.close(); } catch { /* ignore */ }
376
+ try { closeDb(); } catch { /* ignore */ }
377
+ try { rmSync(PID_FILE, { force: true }); } catch { /* ignore */ }
378
+ console.log('[server] Shutdown complete.');
379
+ process.exit(0);
380
+ });
381
+ if (typeof server.closeAllConnections === 'function') {
382
+ server.closeAllConnections();
383
+ }
384
+ // 5s hard deadline: if the close callback hasn't fired by then,
385
+ // something is wedged — exit anyway. unref so this timer doesn't
386
+ // itself keep the loop alive.
387
+ setTimeout(() => {
388
+ console.warn('[server] Forced exit after 5s shutdown deadline.');
389
+ process.exit(1);
390
+ }, 5000).unref();
391
+ };
392
+
393
+ process.on('SIGINT', shutdown);
394
+ process.on('SIGTERM', shutdown);
395
+ }
396
+
397
+ /**
398
+ * Whether this module runs as the board daemon. The legacy check
399
+ * `import.meta.url === ` + "`file://${process.argv[1]}`" + ` silently
400
+ * broke the daemon on Windows (backslashes), linked / global installs
401
+ * (import.meta.url resolves symlinks, process.argv[1] does not) and
402
+ * paths with spaces (percent-encoding): main() never ran, the process
403
+ * exited 0 and nothing ever reached hu-board.log. The launcher now
404
+ * sets KJ_BOARD_DAEMON=1, which we trust first; the normalised URL
405
+ * comparison (symlinks resolved on both sides) is the fallback.
406
+ * Exported so tests can exercise it without spawning a process.
407
+ * @returns {boolean}
408
+ */
409
+ export function isDaemonEntryPoint() {
410
+ if (process.env.KJ_BOARD_DAEMON === '1') return true;
411
+ try {
412
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
413
+ } catch {
414
+ return false;
415
+ }
416
+ }
417
+
418
+ // Only run main() when this file is the entry point. Tests import
419
+ // the helpers above without spinning up the server.
420
+ if (isDaemonEntryPoint()) {
421
+ // A dying daemon must never die in silence: the launcher wires
422
+ // stderr to hu-board.log, so log the cause before exiting non-zero.
423
+ process.on('uncaughtException', (err) => {
424
+ console.error('[server] FATAL uncaught exception:', err?.stack || String(err));
425
+ process.exit(1);
426
+ });
427
+ process.on('unhandledRejection', (reason) => {
428
+ console.error('[server] FATAL unhandled rejection:', reason?.stack || String(reason));
429
+ process.exit(1);
430
+ });
431
+ main().catch((err) => {
432
+ console.error('[server] Fatal error:', err?.stack || err);
433
+ process.exit(1);
434
+ });
435
+ }