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.
- package/README.md +53 -34
- package/docs/README.es.md +13 -2
- 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/brain/standby-store.js +6 -4
- package/src/checks/dir-setup.js +39 -3
- package/src/cli/register-pipeline.js +1 -0
- package/src/cli/register-plan.js +1 -0
- package/src/cli.js +13 -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/plan/plan-store.js +7 -27
- 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/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/garbage-collector.js +8 -6
- package/src/utils/home-migration.js +87 -0
- package/src/utils/paths.js +85 -17
- package/src/utils/run-registry.js +4 -4
- package/templates/roles/spec-reviewer.md +38 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side mutations for plan HUs triggered from the board UI.
|
|
3
|
+
*
|
|
4
|
+
* The source of truth is the plan JSON at `~/.kj/plans/<slug>/<planId>.json`.
|
|
5
|
+
* Every mutation in this module follows the same protocol:
|
|
6
|
+
*
|
|
7
|
+
* 1. Load the plan JSON from disk.
|
|
8
|
+
* 2. Mutate it in-memory using the helpers from `src/plan/plan-hu-ops.js`
|
|
9
|
+
* (so CLI `kj plan ready` and board "Mark ready" stay in lock-step).
|
|
10
|
+
* 3. Write the JSON back.
|
|
11
|
+
* 4. Re-sync the plan into SQLite via `syncPlanFile` so the board
|
|
12
|
+
* reflects the change on the next render without a manual refresh.
|
|
13
|
+
*
|
|
14
|
+
* Steps 2-4 must share a single on-disk write: we never ack the mutation to
|
|
15
|
+
* the UI without persisting it, otherwise a reload would silently revert.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, existsSync, readdirSync, mkdirSync, openSync } from 'node:fs';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { writeJsonAtomicSync } from '../../../src/utils/atomic-write.js';
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { trackRun, untrack } from './run-tracker.js';
|
|
23
|
+
import { getHuBoardRunsDir } from './db.js';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { updateHuStatus, certifyAllHus, updateHu } from '../../../src/plan/plan-hu-ops.js';
|
|
26
|
+
import { validateBlockedByChange } from '../../../src/plan/plan-validation.js';
|
|
27
|
+
import { syncPlanFile } from './sync.js';
|
|
28
|
+
import { getDb } from './db.js';
|
|
29
|
+
|
|
30
|
+
// Repo root → so we can spawn `node <repo>/src/cli.js run ...` without
|
|
31
|
+
// depending on `kj` being in $PATH of the board process.
|
|
32
|
+
const REPO_ROOT = (() => {
|
|
33
|
+
const here = dirname(fileURLToPath(import.meta.url)); // packages/hu-board/src
|
|
34
|
+
return join(here, '..', '..', '..'); // → repo root
|
|
35
|
+
})();
|
|
36
|
+
const KJ_CLI = join(REPO_ROOT, 'src', 'cli.js');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Root directory where `kj plan` writes `<slug>/<planId>.json`. Honours
|
|
40
|
+
* `KJ_PLANS_DIR` (tests) then `KJ_HOME/plans` (power users) then
|
|
41
|
+
* `~/.kj/plans/` (default) — same precedence as `sync.js::fullScan`.
|
|
42
|
+
*/
|
|
43
|
+
function plansRoot() {
|
|
44
|
+
if (process.env.KJ_PLANS_DIR) return process.env.KJ_PLANS_DIR;
|
|
45
|
+
if (process.env.KJ_HOME) return join(process.env.KJ_HOME, 'plans');
|
|
46
|
+
return join(homedir(), '.kj', 'plans');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Locate `<planId>.json` on disk. If `projectId` is known we go straight
|
|
51
|
+
* to that subdir; otherwise we fall back to scanning every project dir
|
|
52
|
+
* (costly, only used when the HU row pre-dates the `plan_id` migration
|
|
53
|
+
* and we have nothing else to go on).
|
|
54
|
+
*
|
|
55
|
+
* @param {string} planId
|
|
56
|
+
* @param {string|undefined} projectId
|
|
57
|
+
* @returns {string|null}
|
|
58
|
+
*/
|
|
59
|
+
function findPlanFilePath(planId, projectId) {
|
|
60
|
+
const root = plansRoot();
|
|
61
|
+
if (!existsSync(root)) return null;
|
|
62
|
+
const fileName = `${planId}.json`;
|
|
63
|
+
if (projectId) {
|
|
64
|
+
const direct = join(root, projectId, fileName);
|
|
65
|
+
if (existsSync(direct)) return direct;
|
|
66
|
+
}
|
|
67
|
+
// Fall back to a scan so legacy rows without plan_id still work.
|
|
68
|
+
let dirs;
|
|
69
|
+
try { dirs = readdirSync(root); } catch { return null; }
|
|
70
|
+
for (const d of dirs) {
|
|
71
|
+
const candidate = join(root, d, fileName);
|
|
72
|
+
if (existsSync(candidate)) return candidate;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Load + validate a plan file in one step. Callers pass the on-disk path
|
|
79
|
+
* returned by `findPlanFilePath`.
|
|
80
|
+
* @param {string} filePath
|
|
81
|
+
*/
|
|
82
|
+
function readPlan(filePath) {
|
|
83
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
84
|
+
return JSON.parse(raw);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writePlan(filePath, plan) {
|
|
88
|
+
writeJsonAtomicSync(filePath, plan);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Change a single HU's status inside its plan JSON and re-sync into
|
|
93
|
+
* SQLite. Returns the new status, so callers can 200-reply with the
|
|
94
|
+
* committed value (not a shadow copy that might differ from disk).
|
|
95
|
+
*
|
|
96
|
+
* Valid statuses mirror the CLI: pending, certified, done. Anything else
|
|
97
|
+
* is rejected up the stack — we don't silently coerce.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} args
|
|
100
|
+
* @param {string} args.planId
|
|
101
|
+
* @param {string} args.huId
|
|
102
|
+
* @param {string} args.status - new status
|
|
103
|
+
* @param {string} [args.projectId] - optional hint to avoid the scan
|
|
104
|
+
* @returns {{ ok: true, status: string, planStatus: string } | { ok: false, error: string }}
|
|
105
|
+
*/
|
|
106
|
+
export function setHuStatus({ planId, huId, status, projectId }) {
|
|
107
|
+
const filePath = findPlanFilePath(planId, projectId);
|
|
108
|
+
if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
|
|
109
|
+
|
|
110
|
+
const plan = readPlan(filePath);
|
|
111
|
+
if (!Array.isArray(plan.hus)) return { ok: false, error: 'plan has no hus[]' };
|
|
112
|
+
|
|
113
|
+
const existed = updateHuStatus(plan, huId, status);
|
|
114
|
+
if (!existed) return { ok: false, error: `hu not found: ${huId}` };
|
|
115
|
+
|
|
116
|
+
// When every remaining HU is certified, auto-advance the plan status
|
|
117
|
+
// to "ready" so the CLI hint `kj run --plan <id>` matches reality.
|
|
118
|
+
// Conversely, downgrading the last certified HU back to pending must
|
|
119
|
+
// roll the plan back to "draft" — otherwise `kj run` would refuse to
|
|
120
|
+
// reload a supposedly-ready plan whose HUs disagree.
|
|
121
|
+
if (plan.hus.every((h) => h.status === 'certified' || h.status === 'done')) {
|
|
122
|
+
plan.status = 'ready';
|
|
123
|
+
} else if (plan.hus.some((h) => h.status === 'pending')) {
|
|
124
|
+
plan.status = 'draft';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
writePlan(filePath, plan);
|
|
128
|
+
syncPlanFile(filePath);
|
|
129
|
+
return { ok: true, status, planStatus: plan.status };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Edit one HU's user-facing fields in place. Whitelist mirrors
|
|
134
|
+
* `updateHu` in plan-hu-ops so the CLI and the board stay in lock-step:
|
|
135
|
+
* title, task_type, scope, acceptance_criteria, acceptance_tests,
|
|
136
|
+
* blocked_by. Unknown keys are silently ignored.
|
|
137
|
+
*
|
|
138
|
+
* The caller passes a `patch` object with whichever subset of fields
|
|
139
|
+
* the user actually changed — pushing the whole HU every time would
|
|
140
|
+
* make optimistic concurrency harder to add later.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} args
|
|
143
|
+
* @param {string} args.planId
|
|
144
|
+
* @param {string} args.huId
|
|
145
|
+
* @param {object} args.patch
|
|
146
|
+
* @param {string} [args.projectId]
|
|
147
|
+
* @returns {{ ok: true, hu: object } | { ok: false, error: string }}
|
|
148
|
+
*/
|
|
149
|
+
export function setHuFields({ planId, huId, patch, projectId }) {
|
|
150
|
+
const filePath = findPlanFilePath(planId, projectId);
|
|
151
|
+
if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
|
|
152
|
+
|
|
153
|
+
const plan = readPlan(filePath);
|
|
154
|
+
if (!Array.isArray(plan.hus)) return { ok: false, error: 'plan has no hus[]' };
|
|
155
|
+
|
|
156
|
+
// KJC-TSK-0401: si el patch toca blocked_by, validar antes de
|
|
157
|
+
// persistir. Rechazar refs huérfanas, auto-deps y ciclos.
|
|
158
|
+
if (patch && Object.prototype.hasOwnProperty.call(patch, 'blocked_by')) {
|
|
159
|
+
const result = validateBlockedByChange(plan, huId, patch.blocked_by);
|
|
160
|
+
if (!result.ok) {
|
|
161
|
+
return { ok: false, error: result.error, code: 'INVALID_BLOCKED_BY', cycle: result.cycle };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const updated = updateHu(plan, huId, patch || {});
|
|
166
|
+
if (!updated) return { ok: false, error: `hu not found: ${huId}` };
|
|
167
|
+
|
|
168
|
+
writePlan(filePath, plan);
|
|
169
|
+
syncPlanFile(filePath);
|
|
170
|
+
return { ok: true, hu: updated };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* KJC-TSK-0404: marcar una HU como fallida con blocker y timestamp
|
|
175
|
+
* (sin tocar status). Usado por el zombie-reaper al detectar timeout
|
|
176
|
+
* para que el siguiente `kj run` sepa el motivo del último fallo.
|
|
177
|
+
*
|
|
178
|
+
* @param {object} args
|
|
179
|
+
* @param {string} args.planId
|
|
180
|
+
* @param {string} args.huId
|
|
181
|
+
* @param {string} args.blocker - razón del fallo
|
|
182
|
+
* @param {string} [args.projectId]
|
|
183
|
+
* @returns {{ ok: boolean, hu?: object, error?: string }}
|
|
184
|
+
*/
|
|
185
|
+
export function setHuFailResult({ planId, huId, blocker, projectId }) {
|
|
186
|
+
const filePath = findPlanFilePath(planId, projectId);
|
|
187
|
+
if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
|
|
188
|
+
const plan = readPlan(filePath);
|
|
189
|
+
if (!Array.isArray(plan.hus)) return { ok: false, error: 'plan has no hus[]' };
|
|
190
|
+
const hu = plan.hus.find(h => h.id === huId);
|
|
191
|
+
if (!hu) return { ok: false, error: `hu not found: ${huId}` };
|
|
192
|
+
|
|
193
|
+
hu.result = 'fail';
|
|
194
|
+
hu.updatedAt = new Date().toISOString();
|
|
195
|
+
const existing = (hu.outcome && Array.isArray(hu.outcome.blockers)) ? hu.outcome.blockers : [];
|
|
196
|
+
hu.outcome = {
|
|
197
|
+
...(hu.outcome || {}),
|
|
198
|
+
blockers: blocker ? [...existing, blocker] : existing,
|
|
199
|
+
failedAt: new Date().toISOString(),
|
|
200
|
+
};
|
|
201
|
+
writePlan(filePath, plan);
|
|
202
|
+
syncPlanFile(filePath);
|
|
203
|
+
return { ok: true, hu };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* KJC-TSK-0408 step 2/3: deshace los cambios de una HU restaurando
|
|
208
|
+
* el snapshot git tomado pre-run. Marca status=pending, result=null
|
|
209
|
+
* y outcome.reverted=true para que la UI muestre "deshecha".
|
|
210
|
+
*
|
|
211
|
+
* @param {object} args
|
|
212
|
+
* @param {string} args.planId
|
|
213
|
+
* @param {string} args.huId
|
|
214
|
+
* @param {string} [args.projectId]
|
|
215
|
+
* @returns {Promise<{ ok: boolean, sha?: string, error?: string }>}
|
|
216
|
+
*/
|
|
217
|
+
export async function revertHuFromSnapshot({ planId, huId, projectId }) {
|
|
218
|
+
const filePath = findPlanFilePath(planId, projectId);
|
|
219
|
+
if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
|
|
220
|
+
const plan = readPlan(filePath);
|
|
221
|
+
const hu = plan.hus?.find(h => h.id === huId);
|
|
222
|
+
if (!hu) return { ok: false, error: `hu not found: ${huId}` };
|
|
223
|
+
const snapshotSha = hu.outcome?.snapshot_sha;
|
|
224
|
+
if (!snapshotSha) {
|
|
225
|
+
return { ok: false, error: 'esta HU no tiene snapshot — solo HUs ejecutadas con KJC-TSK-0408 pueden deshacerse' };
|
|
226
|
+
}
|
|
227
|
+
const projectDir = plan.projectDir;
|
|
228
|
+
if (!projectDir) return { ok: false, error: 'plan sin projectDir' };
|
|
229
|
+
|
|
230
|
+
const { restoreHuSnapshot } = await import('../../../src/git/hu-snapshot.js');
|
|
231
|
+
const restore = await restoreHuSnapshot({ projectDir, huId });
|
|
232
|
+
if (!restore.ok) return { ok: false, error: `restore falló: ${restore.error}` };
|
|
233
|
+
|
|
234
|
+
hu.status = 'pending';
|
|
235
|
+
hu.result = null;
|
|
236
|
+
hu.outcome = { ...(hu.outcome || {}), reverted: true, revertedAt: new Date().toISOString() };
|
|
237
|
+
hu.updatedAt = new Date().toISOString();
|
|
238
|
+
writePlan(filePath, plan);
|
|
239
|
+
syncPlanFile(filePath);
|
|
240
|
+
return { ok: true, sha: restore.sha };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Bulk certify: equivalent to `kj plan ready <planId>` over HTTP. Flips
|
|
245
|
+
* every pending HU to certified and sets `plan.status = "ready"`. No-op
|
|
246
|
+
* on already-ready plans (idempotent, returns count=0).
|
|
247
|
+
*
|
|
248
|
+
* @param {object} args
|
|
249
|
+
* @param {string} args.planId
|
|
250
|
+
* @param {string} [args.projectId]
|
|
251
|
+
* @returns {{ ok: true, count: number, planStatus: string } | { ok: false, error: string }}
|
|
252
|
+
*/
|
|
253
|
+
/**
|
|
254
|
+
* PR-G: rename a project from the board.
|
|
255
|
+
*
|
|
256
|
+
* "Project" on the board is just a row in the projects table that
|
|
257
|
+
* groups stories. The source of truth for the human-readable name
|
|
258
|
+
* is `plan.name` inside each plan JSON for that project (PR-B made
|
|
259
|
+
* sync.js prefer it over the cwd basename). So a rename has to:
|
|
260
|
+
*
|
|
261
|
+
* 1. Update plan.name on every plan JSON belonging to this project,
|
|
262
|
+
* so the next sync doesn't clobber the rename back to the
|
|
263
|
+
* basename derivation.
|
|
264
|
+
* 2. Update the projects.name column directly so the UI updates
|
|
265
|
+
* without waiting for a full re-scan.
|
|
266
|
+
*
|
|
267
|
+
* Validates name length (1–120) and rejects empty/whitespace-only.
|
|
268
|
+
*
|
|
269
|
+
* @returns {{ ok: true, name, plansUpdated } | { ok: false, error }}
|
|
270
|
+
*/
|
|
271
|
+
export function renameProject({ projectId, name }) {
|
|
272
|
+
const trimmed = String(name || '').trim();
|
|
273
|
+
if (!trimmed) return { ok: false, error: 'El nombre no puede estar vacío.' };
|
|
274
|
+
if (trimmed.length > 120) return { ok: false, error: 'El nombre no puede superar los 120 caracteres.' };
|
|
275
|
+
|
|
276
|
+
const root = plansRoot();
|
|
277
|
+
let plansUpdated = 0;
|
|
278
|
+
if (existsSync(root)) {
|
|
279
|
+
const projDir = join(root, projectId);
|
|
280
|
+
if (existsSync(projDir)) {
|
|
281
|
+
let files;
|
|
282
|
+
try { files = readdirSync(projDir); } catch { files = []; }
|
|
283
|
+
for (const f of files) {
|
|
284
|
+
if (!f.endsWith('.json')) continue;
|
|
285
|
+
const filePath = join(projDir, f);
|
|
286
|
+
try {
|
|
287
|
+
const plan = readPlan(filePath);
|
|
288
|
+
plan.name = trimmed;
|
|
289
|
+
plan.updatedAt = new Date().toISOString();
|
|
290
|
+
writePlan(filePath, plan);
|
|
291
|
+
plansUpdated += 1;
|
|
292
|
+
} catch { /* skip unreadable plan */ }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Update the project row directly so the UI reflects the new name
|
|
298
|
+
// without waiting for the chokidar watcher to fire on the plan
|
|
299
|
+
// edits above.
|
|
300
|
+
try {
|
|
301
|
+
const stmt = getDb().prepare('UPDATE projects SET name = ? WHERE id = ?');
|
|
302
|
+
const res = stmt.run(trimmed, projectId);
|
|
303
|
+
if (res.changes === 0 && plansUpdated === 0) {
|
|
304
|
+
return { ok: false, error: `Proyecto no encontrado: ${projectId}` };
|
|
305
|
+
}
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return { ok: false, error: `Error actualizando la base de datos: ${err.message}` };
|
|
308
|
+
}
|
|
309
|
+
return { ok: true, name: trimmed, plansUpdated };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function markPlanReady({ planId, projectId }) {
|
|
313
|
+
const filePath = findPlanFilePath(planId, projectId);
|
|
314
|
+
if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
|
|
315
|
+
|
|
316
|
+
const plan = readPlan(filePath);
|
|
317
|
+
if (!Array.isArray(plan.hus) || plan.hus.length === 0) {
|
|
318
|
+
return { ok: false, error: 'plan has no hus[]' };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const count = certifyAllHus(plan);
|
|
322
|
+
writePlan(filePath, plan);
|
|
323
|
+
syncPlanFile(filePath);
|
|
324
|
+
return { ok: true, count, planStatus: plan.status };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Launch `kj run --plan <planId>` as a detached child. This is the
|
|
329
|
+
* "Run plan" button's server-side handler: the board no longer asks the
|
|
330
|
+
* user to drop to the terminal to kick off execution.
|
|
331
|
+
*
|
|
332
|
+
* We spawn against the repo's own CLI (`node <repo>/src/cli.js`) instead
|
|
333
|
+
* of a `kj` binary on $PATH — the board is always shipped with the repo
|
|
334
|
+
* that hosts it, so that entry point is guaranteed to exist. Tests set
|
|
335
|
+
* `KJ_RUN_SPAWN_MODE=echo` to replace the spawn with a no-op that just
|
|
336
|
+
* returns a fake pid, so we can assert payload shape without booting the
|
|
337
|
+
* whole orchestrator inside vitest.
|
|
338
|
+
*
|
|
339
|
+
* Output is redirected to `~/.karajan/hu-board-runs/<planId>.log` so the
|
|
340
|
+
* board can tail it later if we wire up a live log viewer — for now it's
|
|
341
|
+
* just a durable trail. stdin is `ignore` (coder subprocess requirement
|
|
342
|
+
* documented in CLAUDE.md).
|
|
343
|
+
*
|
|
344
|
+
* @param {object} args
|
|
345
|
+
* @param {string} args.planId
|
|
346
|
+
* @param {string} [args.projectId]
|
|
347
|
+
* @param {string} [args.taskOverride] - optional custom task string
|
|
348
|
+
* @returns {{ ok: true, pid: number, logPath: string } | { ok: false, error: string }}
|
|
349
|
+
*/
|
|
350
|
+
export function runPlan({ planId, projectId, taskOverride, huIds = null } = {}) {
|
|
351
|
+
const filePath = findPlanFilePath(planId, projectId);
|
|
352
|
+
if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
|
|
353
|
+
|
|
354
|
+
const plan = readPlan(filePath);
|
|
355
|
+
if (!Array.isArray(plan.hus) || plan.hus.length === 0) {
|
|
356
|
+
return { ok: false, error: 'plan has no hus[]' };
|
|
357
|
+
}
|
|
358
|
+
const projectDir = plan.projectDir;
|
|
359
|
+
if (!projectDir) {
|
|
360
|
+
return {
|
|
361
|
+
ok: false,
|
|
362
|
+
error:
|
|
363
|
+
'plan has no projectDir stamped — re-run `kj plan` to regenerate it, '
|
|
364
|
+
+ 'then try again (plans pre-v2.7.4 did not persist the dir).',
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// PR4: when huIds is set, validate they exist before spawning so
|
|
369
|
+
// we surface "unknown HU" as an HTTP 400 instead of letting the
|
|
370
|
+
// CLI fail mid-startup with a less obvious message.
|
|
371
|
+
let huFlag = null;
|
|
372
|
+
if (Array.isArray(huIds) && huIds.length > 0) {
|
|
373
|
+
const validIds = new Set(plan.hus.map((h) => h.id));
|
|
374
|
+
const unknown = huIds.filter((id) => !validIds.has(id));
|
|
375
|
+
if (unknown.length > 0) {
|
|
376
|
+
return { ok: false, error: `unknown HU id(s) in plan ${planId}: ${unknown.join(', ')}` };
|
|
377
|
+
}
|
|
378
|
+
huFlag = huIds.join(',');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const runsDir = getHuBoardRunsDir(); // KJC-TSK-0421 — centralised in db.js
|
|
382
|
+
mkdirSync(runsDir, { recursive: true });
|
|
383
|
+
// PR4: when running a single HU, suffix the log with the HU id so
|
|
384
|
+
// a subsequent full-plan run doesn't overwrite the per-HU log.
|
|
385
|
+
const logName = huFlag
|
|
386
|
+
? `${planId}--hu-${huIds.join('-').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 60)}.log`
|
|
387
|
+
: `${planId}.log`;
|
|
388
|
+
const logPath = join(runsDir, logName);
|
|
389
|
+
|
|
390
|
+
const task = taskOverride || plan.task || `run plan ${planId}`;
|
|
391
|
+
const args = [KJ_CLI, 'run', '--plan', planId];
|
|
392
|
+
if (huFlag) args.push('--hu', huFlag);
|
|
393
|
+
args.push(task);
|
|
394
|
+
|
|
395
|
+
// Escape hatch for tests — hard-kill the spawn so vitest doesn't boot
|
|
396
|
+
// the full orchestrator. The shape of the response is what we assert.
|
|
397
|
+
if (process.env.KJ_RUN_SPAWN_MODE === 'echo') {
|
|
398
|
+
return { ok: true, pid: 0, logPath, argv: [process.execPath, ...args], projectDir, huIds: huIds || null };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const out = openSync(logPath, 'a');
|
|
402
|
+
const err = openSync(logPath, 'a');
|
|
403
|
+
const child = spawn(process.execPath, args, {
|
|
404
|
+
cwd: projectDir,
|
|
405
|
+
detached: true,
|
|
406
|
+
stdio: ['ignore', out, err],
|
|
407
|
+
// Strip CLAUDECODE so the Claude agent subprocess trick in
|
|
408
|
+
// claude-agent.js doesn't misbehave (documented in CLAUDE.md).
|
|
409
|
+
env: { ...process.env, CLAUDECODE: undefined },
|
|
410
|
+
});
|
|
411
|
+
child.unref();
|
|
412
|
+
// KJC-TSK-0396: registrar el PID para que el botón ⏹ Stop del board
|
|
413
|
+
// sepa qué procesos matar. trackRun usa Set, así que llamadas
|
|
414
|
+
// duplicadas no inflan el registro. Cuando el proceso muera, el
|
|
415
|
+
// event 'exit' lo des-registra (defensa adicional: reaper periódico
|
|
416
|
+
// que limpia PIDs muertos cada 30s).
|
|
417
|
+
if (child.pid) {
|
|
418
|
+
trackRun(planId, { pid: child.pid, huIds: huIds || null });
|
|
419
|
+
child.on('exit', () => { untrack(planId, child.pid); });
|
|
420
|
+
}
|
|
421
|
+
return { ok: true, pid: child.pid ?? 0, logPath };
|
|
422
|
+
}
|