karajan-code 2.19.0 → 2.19.2
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/README.md +4 -0
- package/package.json +9 -1
- package/packages/hu-board/README.md +69 -0
- package/packages/hu-board/package.json +23 -0
- package/packages/hu-board/public/app.js +4038 -0
- package/packages/hu-board/public/index.html +55 -0
- package/packages/hu-board/public/pipeline.css +132 -0
- package/packages/hu-board/public/pipeline.html +48 -0
- package/packages/hu-board/public/pipeline.js +200 -0
- package/packages/hu-board/public/styles.css +918 -0
- package/packages/hu-board/src/auth.js +92 -0
- package/packages/hu-board/src/boot-info.js +16 -0
- package/packages/hu-board/src/cleanup-zombies.js +142 -0
- package/packages/hu-board/src/command-runner.js +134 -0
- package/packages/hu-board/src/config-yaml.js +285 -0
- package/packages/hu-board/src/db.js +739 -0
- package/packages/hu-board/src/ephemeral-cleaner.js +198 -0
- package/packages/hu-board/src/event-bus.js +58 -0
- package/packages/hu-board/src/format.js +83 -0
- package/packages/hu-board/src/hu-zombie-reaper.js +103 -0
- package/packages/hu-board/src/journal-parser.js +180 -0
- package/packages/hu-board/src/plan-mutations.js +422 -0
- package/packages/hu-board/src/preflight.js +285 -0
- package/packages/hu-board/src/routes/api.js +1271 -0
- package/packages/hu-board/src/routes/pipeline.js +45 -0
- package/packages/hu-board/src/run-tracker.js +121 -0
- package/packages/hu-board/src/server.js +435 -0
- package/packages/hu-board/src/sync.js +626 -0
- package/packages/hu-board/src/token-store.js +57 -0
- package/packages/hu-board/src/zombie-reaper.js +207 -0
- package/src/audit/webperf-input.js +4 -7
- package/src/checks/dir-setup.js +5 -3
- package/src/cli/register-pipeline.js +1 -0
- package/src/cli/register-plan.js +1 -0
- package/src/commands/plan/generate.js +15 -0
- package/src/commands/run.js +19 -1
- package/src/commands/webperf.js +4 -6
- package/src/config/role-resolver.js +3 -1
- package/src/mcp/handlers/direct-handlers.js +3 -0
- package/src/mcp/handlers/run-handler.js +5 -0
- package/src/mcp/tools.js +4 -2
- package/src/prompts/spec-reviewer.js +66 -0
- package/src/roles/perf-role.js +4 -4
- package/src/roles/spec-reviewer-role.js +106 -0
- package/src/sonar/api.js +17 -3
- package/src/sonar/token-recovery.js +67 -0
- package/src/spec-review/refine-loop.js +58 -0
- package/src/spec-review/run-spec-review.js +70 -0
- package/src/utils/board-prompt-bridge.js +4 -5
- package/src/utils/display/spec-findings.js +44 -0
- package/src/utils/paths.js +15 -0
- package/src/utils/run-registry.js +4 -4
- package/templates/roles/spec-reviewer.md +38 -0
|
@@ -0,0 +1,1271 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn as spawnChild } from 'node:child_process';
|
|
6
|
+
import {
|
|
7
|
+
getDashboardStats,
|
|
8
|
+
getProjects,
|
|
9
|
+
getStoriesByProject,
|
|
10
|
+
getStoryDetail,
|
|
11
|
+
getSessionsByProject,
|
|
12
|
+
getSessionDetail,
|
|
13
|
+
deleteProject,
|
|
14
|
+
deleteStory,
|
|
15
|
+
deleteSession,
|
|
16
|
+
getKjHome,
|
|
17
|
+
getHuBoardRunsDir,
|
|
18
|
+
getStoryRow,
|
|
19
|
+
listPlanIdsForProject,
|
|
20
|
+
updateStoryStatus,
|
|
21
|
+
setProjectIsTest,
|
|
22
|
+
} from '../db.js';
|
|
23
|
+
import { fullScan } from '../sync.js';
|
|
24
|
+
import { setHuStatus, setHuFields, markPlanReady, runPlan, renameProject, revertHuFromSnapshot } from '../plan-mutations.js';
|
|
25
|
+
import { getActiveRuns } from '../run-tracker.js';
|
|
26
|
+
import { subscribe as subscribeEvents } from '../event-bus.js';
|
|
27
|
+
import { runKjCommand, listSupportedCommands } from '../command-runner.js';
|
|
28
|
+
import { runPreflight } from '../preflight.js';
|
|
29
|
+
import { readConfig, writeConfigPatch } from '../config-yaml.js';
|
|
30
|
+
import { BOOT_TIME, PKG_VERSION } from '../boot-info.js';
|
|
31
|
+
import { addTombstone, getDb, isTombstoned as isTombstonedFn, removeTombstone, listTombstones } from '../db.js';
|
|
32
|
+
import { publish as publishEvent } from '../event-bus.js';
|
|
33
|
+
|
|
34
|
+
const router = Router();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* GET /api/version - Server version + boot timestamp.
|
|
38
|
+
* The client polls this every 30s; if `boot_time` differs from the first
|
|
39
|
+
* response, the server restarted and the client triggers a reload to pick
|
|
40
|
+
* up any new HTML/JS (KJC-TSK-0379).
|
|
41
|
+
*/
|
|
42
|
+
router.get('/version', (_req, res) => {
|
|
43
|
+
res.set('Cache-Control', 'no-store');
|
|
44
|
+
res.json({ version: PKG_VERSION, boot_time: BOOT_TIME });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* GET /api/standby — KJC-TSK-0414 PR4. Lista sesiones hibernadas.
|
|
49
|
+
* Devuelve [{ sessionId, planId, huId, role, cooldownUntil, reason, ... }]
|
|
50
|
+
* El front renderiza un badge "💤 standby · resume en HH:MM:SS" por sesión.
|
|
51
|
+
*/
|
|
52
|
+
router.get('/standby', async (_req, res) => {
|
|
53
|
+
try {
|
|
54
|
+
const { listPendingStandby } = await import('../../../../src/brain/standby-store.js');
|
|
55
|
+
const sessions = listPendingStandby();
|
|
56
|
+
res.set('Cache-Control', 'no-store');
|
|
57
|
+
res.json({ sessions });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
res.status(500).json({ error: err.message });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the hu-stories dir where batch.json files live.
|
|
65
|
+
*/
|
|
66
|
+
function huStoriesDir() {
|
|
67
|
+
return path.join(getKjHome(), 'hu-stories');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Best-effort removal of the hu-stories/<id>/ directory.
|
|
72
|
+
*/
|
|
73
|
+
function removeBatchDir(batchId) {
|
|
74
|
+
try {
|
|
75
|
+
const dir = path.join(huStoriesDir(), batchId);
|
|
76
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* GET /api/dashboard - Global dashboard statistics.
|
|
85
|
+
*/
|
|
86
|
+
router.get('/dashboard', (_req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
const stats = getDashboardStats();
|
|
89
|
+
res.json(stats);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
res.status(500).json({ error: err.message });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* GET /api/projects - List all projects with story counts.
|
|
97
|
+
*/
|
|
98
|
+
router.get('/projects', (_req, res) => {
|
|
99
|
+
try {
|
|
100
|
+
const projects = getProjects();
|
|
101
|
+
res.json(projects);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
res.status(500).json({ error: err.message });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* GET /api/projects/:id - Project detail.
|
|
109
|
+
*/
|
|
110
|
+
router.get('/projects/:id', (req, res) => {
|
|
111
|
+
try {
|
|
112
|
+
const projects = getProjects();
|
|
113
|
+
const project = projects.find((p) => p.id === req.params.id);
|
|
114
|
+
if (!project) return res.status(404).json({ error: 'Project not found' });
|
|
115
|
+
res.json(project);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
res.status(500).json({ error: err.message });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* PUT /api/projects/:id/name — PR-G: rename a project.
|
|
123
|
+
*
|
|
124
|
+
* Updates plan.name on every plan JSON belonging to the project AND
|
|
125
|
+
* the projects.name column directly so the UI reflects the new name
|
|
126
|
+
* on the next render. Body: { name: "Linux Assistant Orchestrator" }.
|
|
127
|
+
*/
|
|
128
|
+
router.put('/projects/:id/name', (req, res) => {
|
|
129
|
+
try {
|
|
130
|
+
const result = renameProject({ projectId: req.params.id, name: req.body?.name });
|
|
131
|
+
if (!result.ok) {
|
|
132
|
+
const code = /no encontrado/i.test(result.error) ? 404 : 400;
|
|
133
|
+
return res.status(code).json({ error: result.error });
|
|
134
|
+
}
|
|
135
|
+
res.json(result);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
res.status(500).json({ error: err.message });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* PATCH /api/projects/:id/is-test — KJC-TSK-0371 (board polish #3).
|
|
143
|
+
*
|
|
144
|
+
* Sets/clears the per-project ephemeral-cleanup override. The
|
|
145
|
+
* heuristic (id under tmp_/test_/demo_/kj-test- AND last_activity
|
|
146
|
+
* older than 24h → auto-delete on next board start) is the default.
|
|
147
|
+
* `is_test` lets the user override per-project:
|
|
148
|
+
* - body { is_test: 1 } → always treat as ephemeral
|
|
149
|
+
* - body { is_test: 0 } → NEVER auto-clean (keep this one)
|
|
150
|
+
* - body { is_test: null } → revert to the default heuristic
|
|
151
|
+
*
|
|
152
|
+
* Sending anything else is a 400 — explicit ternary, no truthy/
|
|
153
|
+
* falsy coercion, because that exact distinction is what the
|
|
154
|
+
* cleaner relies on.
|
|
155
|
+
*/
|
|
156
|
+
router.patch('/projects/:id/is-test', (req, res) => {
|
|
157
|
+
const raw = req.body?.is_test;
|
|
158
|
+
// Accept the three legal sentinel values explicitly — undefined
|
|
159
|
+
// (key missing) is rejected because this endpoint only exists to
|
|
160
|
+
// set or clear, not to "leave as-is".
|
|
161
|
+
const allowed = raw === 0 || raw === 1 || raw === null;
|
|
162
|
+
if (!allowed) {
|
|
163
|
+
return res.status(400).json({ error: 'is_test must be 0, 1, or null' });
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const ok = setProjectIsTest(req.params.id, raw);
|
|
167
|
+
if (!ok) return res.status(404).json({ error: 'project not found' });
|
|
168
|
+
res.json({ ok: true, project_id: req.params.id, is_test: raw });
|
|
169
|
+
} catch (err) {
|
|
170
|
+
res.status(500).json({ error: err.message });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* GET /api/projects/:id/preflight — Project readiness checks.
|
|
176
|
+
*
|
|
177
|
+
* Returns the same shape regardless of the project's state. Each
|
|
178
|
+
* check carries a status (ok|warn|fail|info), a plain-Spanish
|
|
179
|
+
* detail and (when relevant) a `consequence` describing what the
|
|
180
|
+
* user loses if it stays unfixed. Powers both:
|
|
181
|
+
* - the panel at the top of the board (always visible)
|
|
182
|
+
* - the safety modal that appears before ▶ Run plan / ▶ Run HU
|
|
183
|
+
* when there are blockers or warnings
|
|
184
|
+
*
|
|
185
|
+
* The route resolves projectDir by reading the first plan it can
|
|
186
|
+
* find for this project (plans carry projectDir since v2.7.4). If
|
|
187
|
+
* the project has no plans on disk we still return the env-only
|
|
188
|
+
* checks (Node, Karajan, agents, config) so the user gets feedback
|
|
189
|
+
* even before they generate their first plan.
|
|
190
|
+
*/
|
|
191
|
+
router.get('/projects/:id/preflight', async (req, res) => {
|
|
192
|
+
try {
|
|
193
|
+
const projectId = req.params.id;
|
|
194
|
+
let projectDir = null;
|
|
195
|
+
try {
|
|
196
|
+
// Plans live at ~/.kj/plans/<slug>/ when KJ_HOME is unset, or
|
|
197
|
+
// $KJ_HOME/plans/<slug>/ when set. db.js::getKjHome() returns
|
|
198
|
+
// the .karajan root, NOT the .kj root — they are sister dirs.
|
|
199
|
+
// Mirror plan-mutations.js::plansRoot() exactly.
|
|
200
|
+
const plansBase = process.env.KJ_HOME
|
|
201
|
+
? path.join(process.env.KJ_HOME, 'plans')
|
|
202
|
+
: path.join(process.env.HOME || '', '.kj', 'plans');
|
|
203
|
+
const plansDir = path.join(plansBase, projectId);
|
|
204
|
+
if (fs.existsSync(plansDir)) {
|
|
205
|
+
const files = fs.readdirSync(plansDir).filter((f) => f.endsWith('.json'));
|
|
206
|
+
for (const f of files) {
|
|
207
|
+
try {
|
|
208
|
+
const plan = JSON.parse(fs.readFileSync(path.join(plansDir, f), 'utf8'));
|
|
209
|
+
if (plan?.projectDir) { projectDir = plan.projectDir; break; }
|
|
210
|
+
} catch { /* try next */ }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} catch { /* projectDir stays null → preflight will report it */ }
|
|
214
|
+
const result = await runPreflight({ projectId, projectDir });
|
|
215
|
+
res.json(result);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
res.status(500).json({ error: err.message });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* GET /api/projects/:id/plans-outcome — Plan-level rollups (PR3).
|
|
223
|
+
*
|
|
224
|
+
* Reads every plan JSON for the project and returns the lightweight
|
|
225
|
+
* shape the board needs to render the "Plan finalizado · 8 done · 2
|
|
226
|
+
* failed · 15 min" banner. Plans that haven't finished yet have a
|
|
227
|
+
* null outcome — the front handles that by hiding the banner.
|
|
228
|
+
*/
|
|
229
|
+
router.get('/projects/:id/plans-outcome', (req, res) => {
|
|
230
|
+
try {
|
|
231
|
+
const projectId = req.params.id;
|
|
232
|
+
const plansBase = process.env.KJ_HOME
|
|
233
|
+
? path.join(process.env.KJ_HOME, 'plans')
|
|
234
|
+
: path.join(process.env.HOME || '', '.kj', 'plans');
|
|
235
|
+
const plansDir = path.join(plansBase, projectId);
|
|
236
|
+
if (!fs.existsSync(plansDir)) return res.json({ projectId, plans: [] });
|
|
237
|
+
const out = [];
|
|
238
|
+
for (const f of fs.readdirSync(plansDir).filter(n => n.endsWith('.json'))) {
|
|
239
|
+
try {
|
|
240
|
+
const plan = JSON.parse(fs.readFileSync(path.join(plansDir, f), 'utf8'));
|
|
241
|
+
if (plan?.version !== 2) continue;
|
|
242
|
+
out.push({
|
|
243
|
+
planId: plan.planId,
|
|
244
|
+
name: plan.name || (plan.task ? plan.task.slice(0, 80) : plan.planId),
|
|
245
|
+
status: plan.status || 'draft',
|
|
246
|
+
updatedAt: plan.updatedAt || null,
|
|
247
|
+
outcome: plan.outcome || null,
|
|
248
|
+
huCount: Array.isArray(plan.hus) ? plan.hus.length : 0,
|
|
249
|
+
});
|
|
250
|
+
} catch { /* skip unreadable plan */ }
|
|
251
|
+
}
|
|
252
|
+
res.json({ projectId, plans: out });
|
|
253
|
+
} catch (err) {
|
|
254
|
+
res.status(500).json({ error: err.message });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* GET /api/projects/:id/stories - Stories for a project.
|
|
260
|
+
*/
|
|
261
|
+
router.get('/projects/:id/stories', (req, res) => {
|
|
262
|
+
try {
|
|
263
|
+
const stories = getStoriesByProject(req.params.id);
|
|
264
|
+
res.json(stories);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
res.status(500).json({ error: err.message });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* GET /api/stories/:id - Story detail with quality scores and context requests.
|
|
272
|
+
*/
|
|
273
|
+
router.get('/stories/:id', (req, res) => {
|
|
274
|
+
try {
|
|
275
|
+
const story = getStoryDetail(req.params.id);
|
|
276
|
+
if (!story) return res.status(404).json({ error: 'Story not found' });
|
|
277
|
+
res.json(story);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
res.status(500).json({ error: err.message });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* GET /api/projects/:id/sessions - Sessions for a project.
|
|
285
|
+
*/
|
|
286
|
+
router.get('/projects/:id/sessions', (req, res) => {
|
|
287
|
+
try {
|
|
288
|
+
const sessions = getSessionsByProject(req.params.id);
|
|
289
|
+
res.json(sessions);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
res.status(500).json({ error: err.message });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* GET /api/sessions/:id - Session detail with stages, commits, duration.
|
|
297
|
+
*/
|
|
298
|
+
router.get('/sessions/:id', (req, res) => {
|
|
299
|
+
try {
|
|
300
|
+
const session = getSessionDetail(req.params.id);
|
|
301
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
302
|
+
// Parse JSON fields for the response
|
|
303
|
+
const parsed = { ...session };
|
|
304
|
+
for (const field of ['checkpoints', 'llm_calls', 'config_snapshot', 'budget', 'commits', 'stages_completed']) {
|
|
305
|
+
if (parsed[field] && typeof parsed[field] === 'string') {
|
|
306
|
+
try { parsed[field] = JSON.parse(parsed[field]); } catch { /* keep as string */ }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
res.json(parsed);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
res.status(500).json({ error: err.message });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* GET /api/sessions - All sessions across all projects.
|
|
317
|
+
*/
|
|
318
|
+
router.get('/sessions', (_req, res) => {
|
|
319
|
+
try {
|
|
320
|
+
const allProjects = getProjects();
|
|
321
|
+
const sessions = [];
|
|
322
|
+
for (const p of allProjects) {
|
|
323
|
+
sessions.push(...getSessionsByProject(p.id));
|
|
324
|
+
}
|
|
325
|
+
// Also get default project sessions
|
|
326
|
+
sessions.push(...getSessionsByProject('default'));
|
|
327
|
+
// Deduplicate
|
|
328
|
+
const seen = new Set();
|
|
329
|
+
const unique = sessions.filter((s) => {
|
|
330
|
+
if (seen.has(s.id)) return false;
|
|
331
|
+
seen.add(s.id);
|
|
332
|
+
return true;
|
|
333
|
+
});
|
|
334
|
+
unique.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
|
|
335
|
+
res.json(unique);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
res.status(500).json({ error: err.message });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* DELETE /api/projects/:id - Cascade delete project + its stories + sessions.
|
|
343
|
+
* Also removes the hu-stories/<id>/ directory from disk so the next sync
|
|
344
|
+
* does not re-import it.
|
|
345
|
+
*/
|
|
346
|
+
router.delete('/projects/:id', (req, res) => {
|
|
347
|
+
try {
|
|
348
|
+
const id = req.params.id;
|
|
349
|
+
// Gather filesystem paths to remove BEFORE deleting DB rows so we
|
|
350
|
+
// can stop the next sync from resurrecting anything.
|
|
351
|
+
const db = getDb();
|
|
352
|
+
const storyIds = db.prepare('SELECT id FROM stories WHERE project_id = ?').all(id).map((r) => r.id);
|
|
353
|
+
const sessionIds = db.prepare('SELECT id FROM sessions WHERE project_id = ?').all(id).map((r) => r.id);
|
|
354
|
+
// KJC-BUG-0055: honour KJ_PLANS_DIR (the env override the sync /
|
|
355
|
+
// cleanup-zombies layers already respect). Previously hardcoded to
|
|
356
|
+
// ~/.kj/plans relative to KJ_HOME's parent, which broke when the
|
|
357
|
+
// two homes were configured separately and left orphan plan dirs
|
|
358
|
+
// on disk after a 🗑️ delete.
|
|
359
|
+
const plansRoot = process.env.KJ_PLANS_DIR
|
|
360
|
+
|| path.join(os.homedir(), '.kj', 'plans');
|
|
361
|
+
const fsPaths = [
|
|
362
|
+
...storyIds.map((sid) => path.join(huStoriesDir(), sid)),
|
|
363
|
+
...sessionIds.map((sid) => path.join(getKjHome(), 'sessions', sid)),
|
|
364
|
+
path.join(plansRoot, id),
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
const existed = deleteProject(id);
|
|
368
|
+
if (!existed) return res.status(404).json({ error: 'Project not found' });
|
|
369
|
+
|
|
370
|
+
// Tombstone the project AND its children so chokidar / fullScan
|
|
371
|
+
// can't bring them back via syncStoryFile / syncSessionFile.
|
|
372
|
+
addTombstone('project', id, { source: 'api', fsPaths });
|
|
373
|
+
for (const sid of storyIds) addTombstone('story', sid, { source: 'api' });
|
|
374
|
+
for (const sid of sessionIds) addTombstone('session', sid, { source: 'api' });
|
|
375
|
+
|
|
376
|
+
// Best-effort fs cleanup; failure does NOT undo the tombstones.
|
|
377
|
+
let pathsRemoved = 0;
|
|
378
|
+
for (const p of fsPaths) {
|
|
379
|
+
try { fs.rmSync(p, { recursive: true, force: true }); pathsRemoved++; } catch { /* */ }
|
|
380
|
+
}
|
|
381
|
+
res.json({ deleted: true, pathsRemoved, tombstoned: 1 + storyIds.length + sessionIds.length });
|
|
382
|
+
} catch (err) {
|
|
383
|
+
res.status(500).json({ error: err.message });
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* DELETE /api/prompts/:promptId - Discard a pending prompt the runner left
|
|
389
|
+
* behind (KJC-TSK-0380). Removes the RPC files from disk, tombstones the
|
|
390
|
+
* prompt id so a stale chokidar event can't recreate it, and emits a
|
|
391
|
+
* `prompt:resolved` event so all open boards close the modal.
|
|
392
|
+
*/
|
|
393
|
+
router.delete('/prompts/:promptId', (req, res) => {
|
|
394
|
+
try {
|
|
395
|
+
const promptId = req.params.promptId;
|
|
396
|
+
const dir = promptsDir();
|
|
397
|
+
const main = path.join(dir, `${promptId}.json`);
|
|
398
|
+
const answer = path.join(dir, `${promptId}.answer.json`);
|
|
399
|
+
let removed = 0;
|
|
400
|
+
for (const p of [main, answer]) {
|
|
401
|
+
try { fs.unlinkSync(p); removed++; } catch { /* missing is fine */ }
|
|
402
|
+
}
|
|
403
|
+
addTombstone('prompt', promptId, { source: 'api', fsPaths: [main, answer] });
|
|
404
|
+
// Re-use the same event the runner emits when answered, so any open
|
|
405
|
+
// modal in any connected board client closes itself.
|
|
406
|
+
publishEvent({ type: 'prompt-resolved', promptId });
|
|
407
|
+
res.json({ deleted: true, filesRemoved: removed });
|
|
408
|
+
} catch (err) {
|
|
409
|
+
res.status(500).json({ error: err.message });
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* DELETE /api/stories/:id - Delete a single story.
|
|
415
|
+
*
|
|
416
|
+
* Tombstones the story (KJC-TSK-0380) and removes its batch directory
|
|
417
|
+
* from `~/.karajan/hu-stories/<id>/` so the next sync does NOT re-import
|
|
418
|
+
* it. Pre-tombstones this endpoint was a DB-only soft hide that the next
|
|
419
|
+
* chokidar event undid silently.
|
|
420
|
+
*/
|
|
421
|
+
router.delete('/stories/:id', (req, res) => {
|
|
422
|
+
try {
|
|
423
|
+
const id = req.params.id;
|
|
424
|
+
const dir = path.join(huStoriesDir(), id);
|
|
425
|
+
const ok = deleteStory(id);
|
|
426
|
+
if (!ok) return res.status(404).json({ error: 'Story not found' });
|
|
427
|
+
addTombstone('story', id, { source: 'api', fsPaths: [dir] });
|
|
428
|
+
let dirRemoved = false;
|
|
429
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); dirRemoved = true; } catch { /* */ }
|
|
430
|
+
res.json({ deleted: true, dirRemoved });
|
|
431
|
+
} catch (err) {
|
|
432
|
+
res.status(500).json({ error: err.message });
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* DELETE /api/sessions/:id - Delete a single session.
|
|
438
|
+
*
|
|
439
|
+
* Tombstones + removes `~/.karajan/sessions/<id>/` (KJC-TSK-0380).
|
|
440
|
+
*/
|
|
441
|
+
router.delete('/sessions/:id', (req, res) => {
|
|
442
|
+
try {
|
|
443
|
+
const id = req.params.id;
|
|
444
|
+
const dir = path.join(getKjHome(), 'sessions', id);
|
|
445
|
+
const ok = deleteSession(id);
|
|
446
|
+
if (!ok) return res.status(404).json({ error: 'Session not found' });
|
|
447
|
+
addTombstone('session', id, { source: 'api', fsPaths: [dir] });
|
|
448
|
+
let dirRemoved = false;
|
|
449
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); dirRemoved = true; } catch { /* */ }
|
|
450
|
+
res.json({ deleted: true, dirRemoved });
|
|
451
|
+
} catch (err) {
|
|
452
|
+
res.status(500).json({ error: err.message });
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* DELETE /api/plans/:planId - Delete a single plan file.
|
|
458
|
+
*
|
|
459
|
+
* Removes `~/.kj/plans/<projectSlug>/plan-<planId>.json` and tombstones
|
|
460
|
+
* the plan id so the next watcher event doesn't re-sync it. Does NOT
|
|
461
|
+
* delete the project: a project can have multiple plans, deleting one
|
|
462
|
+
* plan only removes that plan's HUs from the board.
|
|
463
|
+
*/
|
|
464
|
+
router.delete('/plans/:planId', (req, res) => {
|
|
465
|
+
try {
|
|
466
|
+
const planId = req.params.planId;
|
|
467
|
+
// Locate the plan file by scanning the plans dir for filename match.
|
|
468
|
+
const plansRoot = process.env.KJ_PLANS_DIR || path.join(getKjHome(), '..', '.kj', 'plans');
|
|
469
|
+
let foundPath = null;
|
|
470
|
+
try {
|
|
471
|
+
for (const projSlug of fs.readdirSync(plansRoot)) {
|
|
472
|
+
const candidate = path.join(plansRoot, projSlug, `plan-${planId}.json`);
|
|
473
|
+
if (fs.existsSync(candidate)) { foundPath = candidate; break; }
|
|
474
|
+
}
|
|
475
|
+
} catch { /* plansRoot missing */ }
|
|
476
|
+
addTombstone('plan', planId, { source: 'api', fsPaths: foundPath ? [foundPath] : [] });
|
|
477
|
+
if (foundPath) { try { fs.unlinkSync(foundPath); } catch { /* */ } }
|
|
478
|
+
res.json({ deleted: true, fileRemoved: !!foundPath });
|
|
479
|
+
} catch (err) {
|
|
480
|
+
res.status(500).json({ error: err.message });
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* GET /api/tombstones - List every active tombstone for diagnostics.
|
|
486
|
+
*
|
|
487
|
+
* Returns rows ordered by deleted_at DESC; useful for the diagnostics
|
|
488
|
+
* pane and for the cleanup script (KJC-TSK-0380 PR-C).
|
|
489
|
+
*/
|
|
490
|
+
router.get('/tombstones', (_req, res) => {
|
|
491
|
+
try {
|
|
492
|
+
res.json(listTombstones());
|
|
493
|
+
} catch (err) {
|
|
494
|
+
res.status(500).json({ error: err.message });
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* POST /api/tombstones/:type/:id/restore - Bring a buried id back to
|
|
500
|
+
* life. The next sync will then re-import the resource if its filesystem
|
|
501
|
+
* source still exists. Idempotent: returns false when nothing to remove.
|
|
502
|
+
*/
|
|
503
|
+
router.post('/tombstones/:type/:id/restore', (req, res) => {
|
|
504
|
+
try {
|
|
505
|
+
const removed = removeTombstone(req.params.type, req.params.id);
|
|
506
|
+
if (!removed) return res.status(404).json({ error: 'Tombstone not found' });
|
|
507
|
+
res.json({ restored: true });
|
|
508
|
+
} catch (err) {
|
|
509
|
+
res.status(500).json({ error: err.message });
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* PATCH /api/stories/:id - Edit a single HU.
|
|
515
|
+
*
|
|
516
|
+
* Accepts two shapes in the same call (both optional, either or both
|
|
517
|
+
* can be present):
|
|
518
|
+
*
|
|
519
|
+
* { status: "pending" | "certified" | "done" | "needs_context" }
|
|
520
|
+
* → changes the lifecycle state (original PATCH semantics).
|
|
521
|
+
*
|
|
522
|
+
* { title?, scope?, task_type?, acceptance_criteria?,
|
|
523
|
+
* acceptance_tests?, blocked_by? }
|
|
524
|
+
* → edits the user-facing fields in place. Whitelist mirrors
|
|
525
|
+
* plan-hu-ops::updateHu so CLI and board stay aligned.
|
|
526
|
+
*
|
|
527
|
+
* When both are present we apply status last — the status change
|
|
528
|
+
* transitions the plan lifecycle and can rewrite plan.status, whereas
|
|
529
|
+
* field edits are local to the HU and should never fight the status
|
|
530
|
+
* auto-promotion.
|
|
531
|
+
*
|
|
532
|
+
* The row id on the board is `${projectId}::${huId}` — split here to
|
|
533
|
+
* feed the plan-mutation helpers. The source-of-truth plan JSON is
|
|
534
|
+
* rewritten before we ack, then re-synced into SQLite, so a reload
|
|
535
|
+
* renders the committed state.
|
|
536
|
+
*/
|
|
537
|
+
// Status que el USUARIO puede setear manualmente vía el dropdown del
|
|
538
|
+
// modal. NO incluimos coding/reviewing/running — esos son lifecycle
|
|
539
|
+
// del orquestador (settearlos a mano genera zombies). Tampoco
|
|
540
|
+
// incluimos los canonicales (`running`) por la misma razón. `failed`
|
|
541
|
+
// y `blocked` son útiles para que el usuario marque manualmente HUs
|
|
542
|
+
// que no quiere correr ahora.
|
|
543
|
+
// KJC-TSK-0403: 'failed' eliminado — el orquestador estampa result=fail
|
|
544
|
+
// dejando status=pending. Setearlo a mano vía PATCH ya no tiene sentido.
|
|
545
|
+
const ALLOWED_STORY_STATUSES = new Set(['pending', 'certified', 'done', 'blocked', 'needs_context']);
|
|
546
|
+
// KJC-TSK-0406: coder_model y reviewer_model son editables desde el
|
|
547
|
+
// modal del board. Independientes — el usuario puede subir el reviewer
|
|
548
|
+
// y bajar el coder sin afectar al resto del plan.
|
|
549
|
+
const EDITABLE_HU_FIELDS = ['title', 'scope', 'task_type', 'acceptance_criteria', 'acceptance_tests', 'blocked_by', 'coder_model', 'reviewer_model', 'coder_provider', 'reviewer_provider'];
|
|
550
|
+
|
|
551
|
+
router.patch('/stories/:id', (req, res) => {
|
|
552
|
+
try {
|
|
553
|
+
const body = req.body || {};
|
|
554
|
+
const hasStatus = Object.prototype.hasOwnProperty.call(body, 'status');
|
|
555
|
+
const fieldPatch = {};
|
|
556
|
+
for (const k of EDITABLE_HU_FIELDS) {
|
|
557
|
+
if (Object.prototype.hasOwnProperty.call(body, k)) fieldPatch[k] = body[k];
|
|
558
|
+
}
|
|
559
|
+
const hasFieldEdit = Object.keys(fieldPatch).length > 0;
|
|
560
|
+
|
|
561
|
+
if (!hasStatus && !hasFieldEdit) {
|
|
562
|
+
return res.status(400).json({
|
|
563
|
+
error: `Body must include status or one of: ${EDITABLE_HU_FIELDS.join(', ')}`,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
if (hasStatus && !ALLOWED_STORY_STATUSES.has(body.status)) {
|
|
567
|
+
return res.status(400).json({
|
|
568
|
+
error: `status must be one of: ${[...ALLOWED_STORY_STATUSES].join(', ')}`,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const row = getStoryRow(req.params.id);
|
|
573
|
+
if (!row) return res.status(404).json({ error: 'Story not found' });
|
|
574
|
+
if (!row.plan_id) {
|
|
575
|
+
return res.status(409).json({
|
|
576
|
+
error: 'Story is not backed by a plan file (legacy row). Re-run `kj plan` to re-import it.',
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
const huId = req.params.id.includes('::')
|
|
580
|
+
? req.params.id.split('::').slice(1).join('::')
|
|
581
|
+
: req.params.id;
|
|
582
|
+
|
|
583
|
+
let updatedHu;
|
|
584
|
+
if (hasFieldEdit) {
|
|
585
|
+
const fieldResult = setHuFields({
|
|
586
|
+
planId: row.plan_id,
|
|
587
|
+
huId,
|
|
588
|
+
patch: fieldPatch,
|
|
589
|
+
projectId: row.project_id,
|
|
590
|
+
});
|
|
591
|
+
if (!fieldResult.ok) {
|
|
592
|
+
// KJC-TSK-0401: validación de blocked_by devuelve 400 con cycle?
|
|
593
|
+
// info para que el board destaque la cadena en la UI. Resto de
|
|
594
|
+
// fallos siguen devolviendo 404 (plan / hu no encontrados).
|
|
595
|
+
if (fieldResult.code === 'INVALID_BLOCKED_BY') {
|
|
596
|
+
return res.status(400).json({ error: fieldResult.error, cycle: fieldResult.cycle });
|
|
597
|
+
}
|
|
598
|
+
return res.status(404).json({ error: fieldResult.error });
|
|
599
|
+
}
|
|
600
|
+
updatedHu = fieldResult.hu;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
let statusResult;
|
|
604
|
+
if (hasStatus) {
|
|
605
|
+
statusResult = setHuStatus({
|
|
606
|
+
planId: row.plan_id,
|
|
607
|
+
huId,
|
|
608
|
+
status: body.status,
|
|
609
|
+
projectId: row.project_id,
|
|
610
|
+
});
|
|
611
|
+
if (!statusResult.ok) return res.status(404).json({ error: statusResult.error });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
res.json({
|
|
615
|
+
updated: true,
|
|
616
|
+
id: req.params.id,
|
|
617
|
+
status: statusResult ? statusResult.status : undefined,
|
|
618
|
+
planStatus: statusResult ? statusResult.planStatus : undefined,
|
|
619
|
+
hu: updatedHu,
|
|
620
|
+
});
|
|
621
|
+
} catch (err) {
|
|
622
|
+
res.status(500).json({ error: err.message });
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* POST /api/stories/:id/undo — KJC-TSK-0408: revert a HU.
|
|
628
|
+
*
|
|
629
|
+
* Restaura los ficheros del workspace al snapshot pre-run guardado por
|
|
630
|
+
* la pipeline. Marca status=pending + result=null + outcome.reverted=true
|
|
631
|
+
* para que el usuario pueda relanzar la HU con otros modelos.
|
|
632
|
+
*
|
|
633
|
+
* Falla con 400 si la HU no tiene snapshot (HUs anteriores al hook).
|
|
634
|
+
* Falla con 404 si plan/HU no existe.
|
|
635
|
+
*/
|
|
636
|
+
router.post('/stories/:id/undo', async (req, res) => {
|
|
637
|
+
try {
|
|
638
|
+
const row = getStoryRow(req.params.id);
|
|
639
|
+
if (!row) return res.status(404).json({ error: 'Story not found' });
|
|
640
|
+
if (!row.plan_id) {
|
|
641
|
+
return res.status(409).json({ error: 'Story sin plan_id — legacy row no soportada.' });
|
|
642
|
+
}
|
|
643
|
+
const huId = req.params.id.includes('::')
|
|
644
|
+
? req.params.id.split('::').slice(1).join('::')
|
|
645
|
+
: req.params.id;
|
|
646
|
+
const result = await revertHuFromSnapshot({ planId: row.plan_id, huId, projectId: row.project_id });
|
|
647
|
+
if (!result.ok) {
|
|
648
|
+
const code = /not found|no encontrado/i.test(result.error) ? 404 : 400;
|
|
649
|
+
return res.status(code).json({ error: result.error });
|
|
650
|
+
}
|
|
651
|
+
res.json({ ok: true, id: req.params.id, sha: result.sha });
|
|
652
|
+
} catch (err) {
|
|
653
|
+
res.status(500).json({ error: err.message });
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* POST /api/plans/:planId/ready - Bulk-certify every pending HU of a plan.
|
|
659
|
+
* Equivalent to the CLI `kj plan ready <planId>`. Body is optional — if the
|
|
660
|
+
* client knows the projectId we use it as a fast path, otherwise we fall
|
|
661
|
+
* back to scanning the plans dir.
|
|
662
|
+
*/
|
|
663
|
+
router.post('/plans/:planId/ready', (req, res) => {
|
|
664
|
+
try {
|
|
665
|
+
const { projectId } = req.body || {};
|
|
666
|
+
const result = markPlanReady({ planId: req.params.planId, projectId });
|
|
667
|
+
if (!result.ok) return res.status(404).json({ error: result.error });
|
|
668
|
+
res.json({ ready: true, planId: req.params.planId, count: result.count, planStatus: result.planStatus });
|
|
669
|
+
} catch (err) {
|
|
670
|
+
res.status(500).json({ error: err.message });
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* POST /api/projects/:id/ready - Sugar: mark every plan of a project ready
|
|
676
|
+
* in one call. Lets the board expose a single "Mark all certified" button
|
|
677
|
+
* per project view without the UI having to enumerate plan ids first.
|
|
678
|
+
*/
|
|
679
|
+
router.post('/projects/:id/ready', (req, res) => {
|
|
680
|
+
try {
|
|
681
|
+
const planIds = listPlanIdsForProject(req.params.id);
|
|
682
|
+
if (planIds.length === 0) {
|
|
683
|
+
return res.status(404).json({ error: 'No plan-backed stories for this project' });
|
|
684
|
+
}
|
|
685
|
+
const results = [];
|
|
686
|
+
for (const planId of planIds) {
|
|
687
|
+
const r = markPlanReady({ planId, projectId: req.params.id });
|
|
688
|
+
results.push({ planId, ...r });
|
|
689
|
+
}
|
|
690
|
+
const totalCertified = results
|
|
691
|
+
.filter((r) => r.ok)
|
|
692
|
+
.reduce((acc, r) => acc + r.count, 0);
|
|
693
|
+
res.json({
|
|
694
|
+
ready: true,
|
|
695
|
+
projectId: req.params.id,
|
|
696
|
+
plans: results,
|
|
697
|
+
totalCertified,
|
|
698
|
+
});
|
|
699
|
+
} catch (err) {
|
|
700
|
+
res.status(500).json({ error: err.message });
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Resolve the prompts directory the runner writes RPC packets to.
|
|
706
|
+
* Same convention as src/utils/board-prompt-bridge.js on the runner.
|
|
707
|
+
*/
|
|
708
|
+
function promptsDir() {
|
|
709
|
+
return path.join(getKjHome(), 'prompts');
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* GET /api/prompts - List currently pending prompts. The runner is
|
|
714
|
+
* blocked on each one waiting for an answer; the board reads this on
|
|
715
|
+
* boot to surface modals for prompts that were already pending when
|
|
716
|
+
* the page loaded (the SSE event fires only on new arrivals).
|
|
717
|
+
*/
|
|
718
|
+
// KJC-BUG-0038: prompts whose runner crashed leave the .json behind in
|
|
719
|
+
// ~/.kj/prompts/. Without a TTL, every board reload re-shows the zombie
|
|
720
|
+
// modal "Karajan needs an answer" with no runner behind it — the user
|
|
721
|
+
// can answer but nothing happens. 30 minutes covers the longest legitimate
|
|
722
|
+
// Solomon escalation; anything older is a crashed runner.
|
|
723
|
+
const PROMPT_ZOMBIE_TTL_MS = 30 * 60 * 1000;
|
|
724
|
+
|
|
725
|
+
router.get('/prompts', (_req, res) => {
|
|
726
|
+
try {
|
|
727
|
+
const dir = promptsDir();
|
|
728
|
+
if (!fs.existsSync(dir)) return res.json([]);
|
|
729
|
+
const entries = fs.readdirSync(dir);
|
|
730
|
+
const result = [];
|
|
731
|
+
const now = Date.now();
|
|
732
|
+
for (const name of entries) {
|
|
733
|
+
if (!name.endsWith('.json') || name.endsWith('.answer.json')) continue;
|
|
734
|
+
const promptId = name.replace(/\.json$/, '');
|
|
735
|
+
const mainPath = path.join(dir, name);
|
|
736
|
+
const answerPath = path.join(dir, `${promptId}.answer.json`);
|
|
737
|
+
// KJC-TSK-0380 — skip tombstoned prompts and clean their files.
|
|
738
|
+
if (isTombstonedFn('prompt', promptId)) {
|
|
739
|
+
try { fs.unlinkSync(mainPath); } catch { /* */ }
|
|
740
|
+
try { fs.unlinkSync(answerPath); } catch { /* */ }
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
let parsed;
|
|
744
|
+
try {
|
|
745
|
+
parsed = JSON.parse(fs.readFileSync(mainPath, 'utf-8'));
|
|
746
|
+
} catch { continue; /* malformed — skip */ }
|
|
747
|
+
// KJC-BUG-0038: zombie detection. Prefer parsed.createdAt; fall back
|
|
748
|
+
// to mtime if the runner skipped the timestamp (older formats).
|
|
749
|
+
const createdMs = Date.parse(parsed?.createdAt || '');
|
|
750
|
+
const ageRef = Number.isFinite(createdMs) ? createdMs : safeMtime(mainPath);
|
|
751
|
+
if (ageRef && (now - ageRef) > PROMPT_ZOMBIE_TTL_MS) {
|
|
752
|
+
try { fs.unlinkSync(mainPath); } catch { /* */ }
|
|
753
|
+
try { fs.unlinkSync(answerPath); } catch { /* */ }
|
|
754
|
+
addTombstone('prompt', promptId, { source: 'zombie-ttl', fsPaths: [mainPath, answerPath] });
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
result.push(parsed);
|
|
758
|
+
}
|
|
759
|
+
result.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
|
|
760
|
+
res.json(result);
|
|
761
|
+
} catch (err) {
|
|
762
|
+
res.status(500).json({ error: err.message });
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
function safeMtime(p) {
|
|
767
|
+
try { return fs.statSync(p).mtimeMs; } catch { return 0; }
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* POST /api/prompts/:promptId/answer - Body { answer }. Writes a
|
|
772
|
+
* sibling .answer.json the runner is polling for. The runner reads
|
|
773
|
+
* it, deletes both files, and resolves askQuestion. An answer of
|
|
774
|
+
* "stop" tells the runner to abort the session.
|
|
775
|
+
*/
|
|
776
|
+
router.post('/prompts/:promptId/answer', (req, res) => {
|
|
777
|
+
try {
|
|
778
|
+
const { answer } = req.body || {};
|
|
779
|
+
if (typeof answer !== 'string') {
|
|
780
|
+
return res.status(400).json({ error: 'Body must contain an "answer" string' });
|
|
781
|
+
}
|
|
782
|
+
const dir = promptsDir();
|
|
783
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
784
|
+
const promptPath = path.join(dir, `${req.params.promptId}.json`);
|
|
785
|
+
if (!fs.existsSync(promptPath)) {
|
|
786
|
+
return res.status(404).json({ error: `Prompt not found: ${req.params.promptId}` });
|
|
787
|
+
}
|
|
788
|
+
const answerPath = path.join(dir, `${req.params.promptId}.answer.json`);
|
|
789
|
+
fs.writeFileSync(
|
|
790
|
+
answerPath,
|
|
791
|
+
JSON.stringify({ answer, answeredAt: new Date().toISOString() }, null, 2),
|
|
792
|
+
'utf-8'
|
|
793
|
+
);
|
|
794
|
+
res.json({ ok: true });
|
|
795
|
+
} catch (err) {
|
|
796
|
+
res.status(500).json({ error: err.message });
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* GET /api/events - Server-Sent Events stream for live board updates.
|
|
802
|
+
*
|
|
803
|
+
* Replaces the old "sync → full page re-render" dance. The chokidar
|
|
804
|
+
* watcher publishes a small event on every sync (plan/session/batch);
|
|
805
|
+
* this endpoint pipes those events to every connected client. The
|
|
806
|
+
* client patches the affected card in place, so scroll position and
|
|
807
|
+
* any open modal survive the update.
|
|
808
|
+
*
|
|
809
|
+
* The connection stays open for the life of the tab. Browsers
|
|
810
|
+
* auto-reconnect on socket drop — we send a keep-alive comment every
|
|
811
|
+
* 25s so intermediate proxies don't kill the idle socket.
|
|
812
|
+
*/
|
|
813
|
+
router.get('/events', (req, res) => {
|
|
814
|
+
res.writeHead(200, {
|
|
815
|
+
'Content-Type': 'text/event-stream',
|
|
816
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
817
|
+
Connection: 'keep-alive',
|
|
818
|
+
// Disable Express compression on this route — SSE must be a raw
|
|
819
|
+
// stream, gzip buffers and breaks the "push on every event" model.
|
|
820
|
+
'X-Accel-Buffering': 'no',
|
|
821
|
+
});
|
|
822
|
+
res.flushHeaders?.();
|
|
823
|
+
// Initial hello so the client knows the stream is open. Useful for
|
|
824
|
+
// the integration test too: the EventSource-equivalent supertest
|
|
825
|
+
// client can wait on the first line.
|
|
826
|
+
res.write('retry: 2000\n');
|
|
827
|
+
res.write('event: hello\ndata: {"ok":true}\n\n');
|
|
828
|
+
|
|
829
|
+
const send = (event) => {
|
|
830
|
+
try {
|
|
831
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
832
|
+
} catch {
|
|
833
|
+
// Client gone — the close handler will clean up shortly.
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
const unsubscribe = subscribeEvents(send);
|
|
837
|
+
|
|
838
|
+
// 25s is well under the default 60-90s idle timeout of every sane
|
|
839
|
+
// reverse proxy, but high enough that it doesn't spam the log.
|
|
840
|
+
const keepAlive = setInterval(() => {
|
|
841
|
+
try { res.write(': keep-alive\n\n'); } catch { /* ignore */ }
|
|
842
|
+
}, 25000);
|
|
843
|
+
|
|
844
|
+
req.on('close', () => {
|
|
845
|
+
clearInterval(keepAlive);
|
|
846
|
+
unsubscribe();
|
|
847
|
+
res.end?.();
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* GET /api/plans/:planId/log - Tail the detached run's log file.
|
|
853
|
+
*
|
|
854
|
+
* Accepts ?offset=N so a polling UI can request only the new bytes
|
|
855
|
+
* since last read (the "tail -f" UX without SSE). Returns:
|
|
856
|
+
*
|
|
857
|
+
* { content: string, size: number, exists: boolean }
|
|
858
|
+
*
|
|
859
|
+
* - `exists` is false when the run hasn't started yet (the log path is
|
|
860
|
+
* deterministic, so we know where to look).
|
|
861
|
+
* - `size` is the current file length; clients pass `offset=size` next
|
|
862
|
+
* time to get only the delta.
|
|
863
|
+
*
|
|
864
|
+
* Path is the same one that `runPlan` wrote to:
|
|
865
|
+
* ~/.karajan/hu-board-runs/<planId>.log (honours KJ_HOME in tests).
|
|
866
|
+
*/
|
|
867
|
+
router.get('/plans/:planId/log', (req, res) => {
|
|
868
|
+
try {
|
|
869
|
+
const runsDir = getHuBoardRunsDir(); // KJC-TSK-0421
|
|
870
|
+
const logPath = path.join(runsDir, `${req.params.planId}.log`);
|
|
871
|
+
if (!fs.existsSync(logPath)) {
|
|
872
|
+
return res.json({ exists: false, size: 0, content: '' });
|
|
873
|
+
}
|
|
874
|
+
const stat = fs.statSync(logPath);
|
|
875
|
+
const offset = Math.max(0, Math.min(stat.size, Number(req.query.offset) || 0));
|
|
876
|
+
// Cap per-request read to 1 MiB so a huge log can't blow up the
|
|
877
|
+
// response. The client will keep polling and catch up.
|
|
878
|
+
const MAX_READ = 1024 * 1024;
|
|
879
|
+
const length = Math.min(MAX_READ, stat.size - offset);
|
|
880
|
+
let content = '';
|
|
881
|
+
if (length > 0) {
|
|
882
|
+
const fd = fs.openSync(logPath, 'r');
|
|
883
|
+
try {
|
|
884
|
+
const buf = Buffer.alloc(length);
|
|
885
|
+
fs.readSync(fd, buf, 0, length, offset);
|
|
886
|
+
content = buf.toString('utf-8');
|
|
887
|
+
} finally {
|
|
888
|
+
fs.closeSync(fd);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
res.json({ exists: true, size: stat.size, content });
|
|
892
|
+
} catch (err) {
|
|
893
|
+
res.status(500).json({ error: err.message });
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* POST /api/plans/:planId/run - Launch `kj run --plan <planId>` as a
|
|
899
|
+
* detached child. This is the "Run plan" button's endpoint — the board
|
|
900
|
+
* no longer requires the user to drop to a terminal to kick off the
|
|
901
|
+
* pipeline.
|
|
902
|
+
*
|
|
903
|
+
* Body: { projectId?: string, task?: string } — both optional. projectId
|
|
904
|
+
* makes the plan-file lookup O(1); task overrides plan.task as the
|
|
905
|
+
* pipeline's headline description.
|
|
906
|
+
*
|
|
907
|
+
* Returns the child's pid + the log path so a future "tail this run"
|
|
908
|
+
* viewer can attach.
|
|
909
|
+
*/
|
|
910
|
+
router.post('/plans/:planId/run', (req, res) => {
|
|
911
|
+
try {
|
|
912
|
+
const { projectId, task, huIds } = req.body || {};
|
|
913
|
+
// PR4: callers may pass `huIds: ["hu_x_001"]` to scope the run
|
|
914
|
+
// to a single HU (or comma-separated subset). When omitted, the
|
|
915
|
+
// whole plan runs as before.
|
|
916
|
+
const result = runPlan({
|
|
917
|
+
planId: req.params.planId,
|
|
918
|
+
projectId,
|
|
919
|
+
taskOverride: task,
|
|
920
|
+
huIds: Array.isArray(huIds) ? huIds : null,
|
|
921
|
+
});
|
|
922
|
+
if (!result.ok) {
|
|
923
|
+
// 404 for "plan not found" (preserves the contract the existing
|
|
924
|
+
// test asserts on), 400 for validation errors (unknown HU id,
|
|
925
|
+
// missing projectDir, etc.).
|
|
926
|
+
const code = /^plan not found/.test(result.error) ? 404 : 400;
|
|
927
|
+
return res.status(code).json({ error: result.error });
|
|
928
|
+
}
|
|
929
|
+
res.json({
|
|
930
|
+
launched: true,
|
|
931
|
+
planId: req.params.planId,
|
|
932
|
+
pid: result.pid,
|
|
933
|
+
logPath: result.logPath,
|
|
934
|
+
huIds: Array.isArray(huIds) && huIds.length > 0 ? huIds : null,
|
|
935
|
+
});
|
|
936
|
+
} catch (err) {
|
|
937
|
+
res.status(500).json({ error: err.message });
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* POST /api/hus/:huId/run — PR4: launch a single HU (or, when the
|
|
943
|
+
* caller passes additional ids in body.extraHuIds, a small bundle).
|
|
944
|
+
*
|
|
945
|
+
* The HU's plan is resolved from the SQLite stories table so the
|
|
946
|
+
* front doesn't have to know the planId — it only needs the story
|
|
947
|
+
* id from the card.
|
|
948
|
+
*/
|
|
949
|
+
router.post('/hus/:huId/run', (req, res) => {
|
|
950
|
+
try {
|
|
951
|
+
const huId = req.params.huId;
|
|
952
|
+
const row = getStoryRow(huId);
|
|
953
|
+
if (!row) return res.status(404).json({ error: `unknown HU: ${huId}` });
|
|
954
|
+
if (!row.plan_id) return res.status(400).json({ error: `HU ${huId} is not backed by a plan (legacy row)` });
|
|
955
|
+
|
|
956
|
+
// Story id on disk uses just the HU local id (hu_<plan>_NNN),
|
|
957
|
+
// not the "<projectId>::<huId>" composite the board persists.
|
|
958
|
+
// Strip the prefix back off before forwarding to runPlan.
|
|
959
|
+
const planLocalHuId = huId.includes('::') ? huId.split('::').pop() : huId;
|
|
960
|
+
const extraIds = Array.isArray(req.body?.extraHuIds) ? req.body.extraHuIds.map((x) => (x.includes('::') ? x.split('::').pop() : x)) : [];
|
|
961
|
+
const huIds = [planLocalHuId, ...extraIds];
|
|
962
|
+
|
|
963
|
+
const result = runPlan({ planId: row.plan_id, projectId: row.project_id, huIds });
|
|
964
|
+
if (!result.ok) return res.status(400).json({ error: result.error });
|
|
965
|
+
|
|
966
|
+
// Move the launched HU(s) to "coding" in BOTH the SQLite mirror and the
|
|
967
|
+
// source-of-truth plan JSON. The plan JSON write is what makes it stick
|
|
968
|
+
// — the next /api/sync re-reads the plan file and would otherwise
|
|
969
|
+
// overwrite the SQLite update back to "certified". Without this the
|
|
970
|
+
// card flashes to Running for ~1s and snaps back, which the user reads
|
|
971
|
+
// as "nothing happened".
|
|
972
|
+
//
|
|
973
|
+
// The orchestrator owns the lifecycle once it boots (it'll move the HU
|
|
974
|
+
// through coding → reviewing → done/failed and persist back to the plan
|
|
975
|
+
// JSON), so this is just the bootstrap nudge to give the user immediate
|
|
976
|
+
// visual feedback.
|
|
977
|
+
const allHuIds = [planLocalHuId, ...extraIds];
|
|
978
|
+
for (const localId of allHuIds) {
|
|
979
|
+
try { setHuStatus({ planId: row.plan_id, huId: localId, status: 'coding', projectId: row.project_id }); } catch { /* non-fatal */ }
|
|
980
|
+
// Also stamp the SQLite row so the next renderBoard() (before any
|
|
981
|
+
// sync tick) shows it in Running.
|
|
982
|
+
const compositeId = localId.includes('::') ? localId : `${row.project_id}::${localId}`;
|
|
983
|
+
try { updateStoryStatus(compositeId, 'coding'); } catch { /* non-fatal */ }
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
res.json({
|
|
987
|
+
launched: true,
|
|
988
|
+
planId: row.plan_id,
|
|
989
|
+
huIds,
|
|
990
|
+
pid: result.pid,
|
|
991
|
+
logPath: result.logPath,
|
|
992
|
+
});
|
|
993
|
+
} catch (err) {
|
|
994
|
+
res.status(500).json({ error: err.message });
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* POST /api/projects/:id/run - Sugar: launch every plan of a project. For
|
|
1000
|
+
* the common case where each project has exactly one active plan this
|
|
1001
|
+
* behaves the same as POST /plans/:planId/run; with multiple plans it
|
|
1002
|
+
* kicks each one in its own detached child.
|
|
1003
|
+
*/
|
|
1004
|
+
router.post('/projects/:id/run', (req, res) => {
|
|
1005
|
+
try {
|
|
1006
|
+
const planIds = listPlanIdsForProject(req.params.id);
|
|
1007
|
+
if (planIds.length === 0) {
|
|
1008
|
+
return res.status(404).json({ error: 'No plan-backed stories for this project' });
|
|
1009
|
+
}
|
|
1010
|
+
const results = [];
|
|
1011
|
+
for (const planId of planIds) {
|
|
1012
|
+
const r = runPlan({ planId, projectId: req.params.id });
|
|
1013
|
+
results.push({ planId, ...r });
|
|
1014
|
+
}
|
|
1015
|
+
const launched = results.filter((r) => r.ok).length;
|
|
1016
|
+
res.json({ launched, total: planIds.length, results });
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
res.status(500).json({ error: err.message });
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* GET /api/config — read the editable subset of ~/.karajan/kj.config.yml.
|
|
1024
|
+
* Powers the board's settings modal; non-editable keys in the yml are
|
|
1025
|
+
* left untouched on PUT (see writeConfigPatch's whitelist semantics).
|
|
1026
|
+
*/
|
|
1027
|
+
router.get('/config', (_req, res) => {
|
|
1028
|
+
try {
|
|
1029
|
+
const data = readConfig();
|
|
1030
|
+
res.json(data);
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
res.status(500).json({ error: err.message });
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* PUT /api/config — apply a patch to the yml. Body is `{ patch: { key: value } }`
|
|
1038
|
+
* with the keys defined in EDITABLE_FIELDS. Unknown keys are 400'd; type
|
|
1039
|
+
* errors are 400'd; validation errors are 400'd. Writes are atomic
|
|
1040
|
+
* (tmp + rename) and a `.bak` is kept of the previous content.
|
|
1041
|
+
*/
|
|
1042
|
+
router.put('/config', (req, res) => {
|
|
1043
|
+
try {
|
|
1044
|
+
const patch = req.body?.patch;
|
|
1045
|
+
const result = writeConfigPatch(patch);
|
|
1046
|
+
if (!result.written) {
|
|
1047
|
+
return res.status(400).json({ error: 'No se pudo guardar la configuración', details: result });
|
|
1048
|
+
}
|
|
1049
|
+
res.json(result);
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
res.status(500).json({ error: err.message });
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* GET /api/commands - List the kj commands the board can launch.
|
|
1057
|
+
* The UI uses this to render the Actions panel without hardcoding
|
|
1058
|
+
* the command list on the client.
|
|
1059
|
+
*/
|
|
1060
|
+
router.get('/commands', (_req, res) => {
|
|
1061
|
+
res.json({ commands: listSupportedCommands() });
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* POST /api/commands/:command - Spawn a kj <command> as a detached
|
|
1066
|
+
* child. Body fields depend on the command (see command-runner.js
|
|
1067
|
+
* for the per-command input schema). Returns a commandId the client
|
|
1068
|
+
* can pass to GET /api/runs/:commandId/log to tail the output.
|
|
1069
|
+
*
|
|
1070
|
+
* The board never executes free-form arg arrays — the command name
|
|
1071
|
+
* must be in the whitelist and each command builds its argv from a
|
|
1072
|
+
* typed schema. Anything outside that fails 400.
|
|
1073
|
+
*/
|
|
1074
|
+
router.post('/commands/:command', (req, res) => {
|
|
1075
|
+
try {
|
|
1076
|
+
const result = runKjCommand({ command: req.params.command, input: req.body || {} });
|
|
1077
|
+
if (!result.ok) return res.status(400).json({ error: result.error });
|
|
1078
|
+
res.json({
|
|
1079
|
+
ok: true,
|
|
1080
|
+
commandId: result.commandId,
|
|
1081
|
+
pid: result.pid,
|
|
1082
|
+
logPath: result.logPath,
|
|
1083
|
+
});
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
res.status(500).json({ error: err.message });
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* GET /api/runs/:commandId/log - Generalisation of the per-plan log
|
|
1091
|
+
* tail. The original `/api/plans/:planId/log` reads
|
|
1092
|
+
* `<runsDir>/<planId>.log`; this one reads `<runsDir>/<commandId>.log`
|
|
1093
|
+
* where `commandId` is what `/api/commands/:command` returned.
|
|
1094
|
+
*
|
|
1095
|
+
* Same offset semantics + 1 MiB cap as the plans endpoint.
|
|
1096
|
+
*/
|
|
1097
|
+
/**
|
|
1098
|
+
* GET /api/runs/:planId/active - Devuelve los runs activos (PIDs vivos)
|
|
1099
|
+
* de un plan. Usado por el frontend para decidir si mostrar el botón
|
|
1100
|
+
* ⏹ Stop. Respuesta: { active: [{pid, huIds, startedAt}, ...] }.
|
|
1101
|
+
*
|
|
1102
|
+
* KJC-TSK-0396.
|
|
1103
|
+
*/
|
|
1104
|
+
router.get('/runs/:planId/active', (req, res) => {
|
|
1105
|
+
try {
|
|
1106
|
+
const active = getActiveRuns(req.params.planId);
|
|
1107
|
+
res.json({ active });
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
res.status(500).json({ error: err.message });
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* POST /api/runs/:planId/stop - Mata todos los procesos del orquestador
|
|
1115
|
+
* asociados a este plan. SIGTERM primero, espera 5s, escala a SIGKILL
|
|
1116
|
+
* si el proceso no respondió. Devuelve { stopped, killed, errors }.
|
|
1117
|
+
*
|
|
1118
|
+
* Tras matar el proceso, las HUs que estaban en coding/reviewing se
|
|
1119
|
+
* resetean a pending (no a failed) vía hu-zombie-reaper logic — pero
|
|
1120
|
+
* eso lo hace el reaper en el siguiente arranque del board. Para
|
|
1121
|
+
* efecto inmediato, marcamos aquí las HUs running del plan a pending.
|
|
1122
|
+
*
|
|
1123
|
+
* KJC-TSK-0396.
|
|
1124
|
+
*/
|
|
1125
|
+
router.post('/runs/:planId/stop', async (req, res) => {
|
|
1126
|
+
const { planId } = req.params;
|
|
1127
|
+
const stopTimeoutMs = Number(req.body?.timeoutMs ?? 5000);
|
|
1128
|
+
try {
|
|
1129
|
+
const errors = [];
|
|
1130
|
+
let stopped = 0;
|
|
1131
|
+
let killed = 0;
|
|
1132
|
+
const active = getActiveRuns(planId);
|
|
1133
|
+
// Fase 1: SIGTERM a todos los procesos trackeados (si los hay).
|
|
1134
|
+
for (const run of active) {
|
|
1135
|
+
try { process.kill(run.pid, 'SIGTERM'); stopped += 1; }
|
|
1136
|
+
catch (err) { errors.push({ pid: run.pid, phase: 'SIGTERM', error: err.message }); }
|
|
1137
|
+
}
|
|
1138
|
+
// Fase 2: esperar `stopTimeoutMs` y escalar a SIGKILL los que sigan
|
|
1139
|
+
// vivos. Saltamos la espera si no había nada que matar.
|
|
1140
|
+
if (active.length > 0) {
|
|
1141
|
+
await new Promise((resolve) => setTimeout(resolve, stopTimeoutMs));
|
|
1142
|
+
const stillAlive = getActiveRuns(planId);
|
|
1143
|
+
for (const run of stillAlive) {
|
|
1144
|
+
try { process.kill(run.pid, 'SIGKILL'); killed += 1; }
|
|
1145
|
+
catch (err) { errors.push({ pid: run.pid, phase: 'SIGKILL', error: err.message }); }
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
// Fase 3: SIEMPRE reset de HUs en coding/reviewing del plan → pending.
|
|
1149
|
+
// El reset corre aunque no hubiera PIDs trackeados (caso: el run lo
|
|
1150
|
+
// lanzaste manualmente desde la terminal, killeaste tú mismo el
|
|
1151
|
+
// proceso, pero la DB sigue mostrando HUs en coding y necesitas
|
|
1152
|
+
// dejarlas en pending para relanzar limpio).
|
|
1153
|
+
let hu_reset_count = 0;
|
|
1154
|
+
try {
|
|
1155
|
+
const db = getDb();
|
|
1156
|
+
const result = db.prepare(
|
|
1157
|
+
"UPDATE stories SET status = 'pending', updated_at = ? WHERE plan_id = ? AND status IN ('coding', 'reviewing', 'running')"
|
|
1158
|
+
).run(new Date().toISOString(), planId);
|
|
1159
|
+
hu_reset_count = result.changes;
|
|
1160
|
+
} catch (err) {
|
|
1161
|
+
errors.push({ phase: 'hu_reset', error: err.message });
|
|
1162
|
+
}
|
|
1163
|
+
const out = { stopped, killed, errors, hu_reset_count };
|
|
1164
|
+
if (stopped === 0 && killed === 0 && hu_reset_count === 0) {
|
|
1165
|
+
out.message = 'No active runs and no stuck HUs for this plan.';
|
|
1166
|
+
}
|
|
1167
|
+
res.json(out);
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
res.status(500).json({ error: err.message });
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
router.get('/runs/:commandId/log', (req, res) => {
|
|
1174
|
+
try {
|
|
1175
|
+
const runsDir = getHuBoardRunsDir(); // KJC-TSK-0421
|
|
1176
|
+
const logPath = path.join(runsDir, `${req.params.commandId}.log`);
|
|
1177
|
+
if (!fs.existsSync(logPath)) {
|
|
1178
|
+
return res.json({ exists: false, size: 0, content: '' });
|
|
1179
|
+
}
|
|
1180
|
+
const stat = fs.statSync(logPath);
|
|
1181
|
+
const offset = Math.max(0, Math.min(stat.size, Number(req.query.offset) || 0));
|
|
1182
|
+
const MAX_READ = 1024 * 1024;
|
|
1183
|
+
const length = Math.min(MAX_READ, stat.size - offset);
|
|
1184
|
+
let content = '';
|
|
1185
|
+
if (length > 0) {
|
|
1186
|
+
const fd = fs.openSync(logPath, 'r');
|
|
1187
|
+
try {
|
|
1188
|
+
const buf = Buffer.alloc(length);
|
|
1189
|
+
fs.readSync(fd, buf, 0, length, offset);
|
|
1190
|
+
content = buf.toString('utf-8');
|
|
1191
|
+
} finally {
|
|
1192
|
+
fs.closeSync(fd);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
res.json({ exists: true, size: stat.size, content });
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
res.status(500).json({ error: err.message });
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* POST /api/board/restart - Respawn the board server in place. The
|
|
1203
|
+
* server can't truly restart itself (it'd kill its own process), so
|
|
1204
|
+
* we use the detached-child + parent-exit pattern: spawn a copy with
|
|
1205
|
+
* the same argv + cwd + env, unref it, ack the client, then exit
|
|
1206
|
+
* after a 250 ms delay so the response actually flushes.
|
|
1207
|
+
*
|
|
1208
|
+
* The new server overwrites the PID file and bindes the same port
|
|
1209
|
+
* (or the next free one if the old one hasn't released yet — handled
|
|
1210
|
+
* by the existing find-available-port logic in startBoard).
|
|
1211
|
+
*
|
|
1212
|
+
* The browser tab's EventSource auto-reconnects, so the user sees a
|
|
1213
|
+
* brief blip and the board comes back live.
|
|
1214
|
+
*/
|
|
1215
|
+
/**
|
|
1216
|
+
* POST /api/board/shutdown - Tell the server to exit cleanly. Used
|
|
1217
|
+
* by `kj board stop` as a fallback when the PID file is missing or
|
|
1218
|
+
* stale (typical after the user has been hot-pulling Karajan and
|
|
1219
|
+
* the running server is from a build that didn't write the PID
|
|
1220
|
+
* file). The body of the response is sent before we exit, then a
|
|
1221
|
+
* 250 ms delay to let the response flush, then process.exit(0).
|
|
1222
|
+
*/
|
|
1223
|
+
router.post('/board/shutdown', (_req, res) => {
|
|
1224
|
+
if (process.env.KJ_BOARD_RESTART_MODE === 'echo') {
|
|
1225
|
+
return res.json({ ok: true, mode: 'echo' });
|
|
1226
|
+
}
|
|
1227
|
+
res.json({ ok: true, shuttingDownInMs: 250 });
|
|
1228
|
+
setTimeout(() => process.exit(0), 250);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
router.post('/board/restart', (_req, res) => {
|
|
1232
|
+
// Tests / programmatic callers can short-circuit the actual self-
|
|
1233
|
+
// exec via env so the suite doesn't have its node process killed
|
|
1234
|
+
// mid-test by a misfire.
|
|
1235
|
+
if (process.env.KJ_BOARD_RESTART_MODE === 'echo') {
|
|
1236
|
+
return res.json({ ok: true, restartingInMs: 0, mode: 'echo' });
|
|
1237
|
+
}
|
|
1238
|
+
try {
|
|
1239
|
+
res.json({ ok: true, restartingInMs: 250 });
|
|
1240
|
+
setTimeout(() => {
|
|
1241
|
+
try {
|
|
1242
|
+
const child = spawnChild(
|
|
1243
|
+
process.execPath,
|
|
1244
|
+
process.argv.slice(1),
|
|
1245
|
+
{ cwd: process.cwd(), detached: true, stdio: 'ignore', env: process.env }
|
|
1246
|
+
);
|
|
1247
|
+
child.unref();
|
|
1248
|
+
} catch { /* if respawn fails, the user can `kj board start` manually */ }
|
|
1249
|
+
process.exit(0);
|
|
1250
|
+
}, 250);
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
res.status(500).json({ error: err.message });
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* POST /api/sync - Force a full re-scan of hu-stories/ and sessions/ on disk.
|
|
1258
|
+
* Useful when chokidar's file watcher misses new subdirectories created after
|
|
1259
|
+
* the board started (known limitation with glob patterns).
|
|
1260
|
+
*/
|
|
1261
|
+
router.post('/sync', (_req, res) => {
|
|
1262
|
+
try {
|
|
1263
|
+
fullScan();
|
|
1264
|
+
const projects = getProjects();
|
|
1265
|
+
res.json({ synced: true, projects: projects.length });
|
|
1266
|
+
} catch (err) {
|
|
1267
|
+
res.status(500).json({ error: err.message });
|
|
1268
|
+
}
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
export default router;
|