karajan-code 3.12.1 → 3.12.3

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 (68) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -5
  3. package/packages/hu-board/package.json +1 -1
  4. package/packages/hu-board/src/plan-mutations.js +4 -4
  5. package/packages/hu-board/src/routes/api.js +1 -1
  6. package/packages/hu-board/src/routes/rag.js +1 -1
  7. package/packages/hu-board/src/routes/wiki.js +1 -1
  8. package/packages/hu-board/src/run-tracker.js +1 -1
  9. package/packages/hu-board/src/server.js +2 -2
  10. package/packages/hu-board/src/sync.js +1 -1
  11. package/scripts/build-sea.mjs +23 -1
  12. package/scripts/esbuild-sea.config.mjs +22 -1
  13. package/scripts/verify-pack.mjs +38 -6
  14. package/src/audit/osv-findings.js +1 -1
  15. package/src/brain/standby-scheduler.js +2 -2
  16. package/src/brain/standby-store.js +2 -2
  17. package/src/commands/init.js +4 -3
  18. package/src/commands/onboard.js +2 -1
  19. package/src/git/hu-snapshot.js +2 -2
  20. package/src/orchestrator/config-init.js +2 -1
  21. package/src/plan/plan-hu-ops.js +2 -2
  22. package/src/plan/plan-id.js +2 -2
  23. package/src/plan/plan-validation.js +2 -2
  24. package/src/prompts/prompt-resolver.js +2 -9
  25. package/src/rag/vec-store.js +2 -2
  26. package/src/utils/atomic-write.js +3 -3
  27. package/src/utils/install-hints.js +5 -1
  28. package/src/utils/paths.js +2 -2
  29. package/src/utils/port-check.js +3 -3
  30. package/src/utils/process.js +2 -2
  31. package/src/utils/run-registry.js +2 -2
  32. package/src/utils/shared-paths.js +3 -3
  33. package/src/utils/templates-root.js +61 -0
  34. package/src/utils/update-check.js +78 -15
  35. package/node_modules/@karajan/core/README.md +0 -42
  36. package/node_modules/@karajan/core/package.json +0 -49
  37. package/node_modules/@karajan/core/src/atomic-write.js +0 -43
  38. package/node_modules/@karajan/core/src/cost/index.js +0 -102
  39. package/node_modules/@karajan/core/src/hu-snapshot.js +0 -105
  40. package/node_modules/@karajan/core/src/index.js +0 -18
  41. package/node_modules/@karajan/core/src/paths.js +0 -123
  42. package/node_modules/@karajan/core/src/plan-hu-ops.js +0 -338
  43. package/node_modules/@karajan/core/src/plan-id.js +0 -45
  44. package/node_modules/@karajan/core/src/plan-validation.js +0 -82
  45. package/node_modules/@karajan/core/src/port-check.js +0 -45
  46. package/node_modules/@karajan/core/src/pricing/index.js +0 -64
  47. package/node_modules/@karajan/core/src/pricing/model-pricing.json +0 -28
  48. package/node_modules/@karajan/core/src/process.js +0 -188
  49. package/node_modules/@karajan/core/src/run-registry.js +0 -115
  50. package/node_modules/@karajan/core/src/shared-paths.js +0 -41
  51. package/node_modules/@karajan/core/src/standby-scheduler.js +0 -92
  52. package/node_modules/@karajan/core/src/standby-store.js +0 -168
  53. package/node_modules/@karajan/core/src/vec-store.js +0 -253
  54. package/node_modules/@karajan/core/tests/atomic-write.test.js +0 -30
  55. package/node_modules/@karajan/core/tests/cost/index.test.js +0 -89
  56. package/node_modules/@karajan/core/tests/hu-snapshot.test.js +0 -39
  57. package/node_modules/@karajan/core/tests/paths.test.js +0 -51
  58. package/node_modules/@karajan/core/tests/plan-hu-ops.test.js +0 -68
  59. package/node_modules/@karajan/core/tests/plan-id.test.js +0 -21
  60. package/node_modules/@karajan/core/tests/plan-validation.test.js +0 -39
  61. package/node_modules/@karajan/core/tests/port-check.test.js +0 -47
  62. package/node_modules/@karajan/core/tests/pricing/index.test.js +0 -95
  63. package/node_modules/@karajan/core/tests/process.test.js +0 -22
  64. package/node_modules/@karajan/core/tests/run-registry.test.js +0 -71
  65. package/node_modules/@karajan/core/tests/shared-paths.test.js +0 -30
  66. package/node_modules/@karajan/core/tests/standby.test.js +0 -66
  67. package/node_modules/@karajan/core/tests/vec-store.test.js +0 -98
  68. package/node_modules/@karajan/core/vitest.config.js +0 -12
@@ -1,28 +0,0 @@
1
- {
2
- "_meta": {
3
- "currency": "USD",
4
- "unit": "per 1M tokens",
5
- "updatedAt": "2026-06-07",
6
- "sources": {
7
- "anthropic": "https://www.anthropic.com/pricing",
8
- "openai": "https://openai.com/api/pricing/",
9
- "google": "https://ai.google.dev/pricing"
10
- },
11
- "notes": "Static registry. Cache-write/cache-read tiers ignored on purpose: aggregator uses input/output averages. Unknown models return null so the pipeline does not break."
12
- },
13
- "claude-opus-4-7": { "inputPerMTok": 15.00, "outputPerMTok": 75.00, "source": "anthropic" },
14
- "claude-opus-4-6": { "inputPerMTok": 15.00, "outputPerMTok": 75.00, "source": "anthropic" },
15
- "claude-sonnet-4-6": { "inputPerMTok": 3.00, "outputPerMTok": 15.00, "source": "anthropic" },
16
- "claude-haiku-4-5": { "inputPerMTok": 1.00, "outputPerMTok": 5.00, "source": "anthropic" },
17
- "claude-haiku-4-5-20251001": { "inputPerMTok": 1.00, "outputPerMTok": 5.00, "source": "anthropic" },
18
- "gpt-4o": { "inputPerMTok": 2.50, "outputPerMTok": 10.00, "source": "openai" },
19
- "gpt-4o-mini": { "inputPerMTok": 0.15, "outputPerMTok": 0.60, "source": "openai" },
20
- "gpt-4.1": { "inputPerMTok": 2.00, "outputPerMTok": 8.00, "source": "openai" },
21
- "gpt-4.1-mini": { "inputPerMTok": 0.40, "outputPerMTok": 1.60, "source": "openai" },
22
- "o1": { "inputPerMTok": 15.00, "outputPerMTok": 60.00, "source": "openai" },
23
- "o1-mini": { "inputPerMTok": 1.10, "outputPerMTok": 4.40, "source": "openai" },
24
- "o3-mini": { "inputPerMTok": 1.10, "outputPerMTok": 4.40, "source": "openai" },
25
- "gemini-1.5-pro": { "inputPerMTok": 1.25, "outputPerMTok": 5.00, "source": "google" },
26
- "gemini-1.5-flash": { "inputPerMTok": 0.075, "outputPerMTok": 0.30, "source": "google" },
27
- "gemini-2.0-flash": { "inputPerMTok": 0.10, "outputPerMTok": 0.40, "source": "google" }
28
- }
@@ -1,188 +0,0 @@
1
- import { execa } from "execa";
2
-
3
- const isWin = process.platform === "win32";
4
- const KILL_SIGNAL = isWin ? "SIGTERM" : "SIGKILL";
5
-
6
- function buildSilenceKilledResult(silenceTimeoutMs, stdout, signal) {
7
- return {
8
- exitCode: 143,
9
- stdout,
10
- stderr: `Command killed after ${Number(silenceTimeoutMs)}ms without output`,
11
- timedOut: true,
12
- signal: signal || KILL_SIGNAL
13
- };
14
- }
15
-
16
- function setupOutputStreaming(subprocess, onOutput, partialOutputFlushMs) {
17
- const flushMs = Number(partialOutputFlushMs) > 0 ? Number(partialOutputFlushMs) : 2000;
18
- const streams = {};
19
- const makeHandler = (stream) => {
20
- const state = { partial: "", dirty: false };
21
- streams[stream] = state;
22
- return (chunk) => {
23
- state.partial += chunk.toString();
24
- const lines = state.partial.split(/\r\n|\n|\r/);
25
- state.partial = lines.pop() ?? "";
26
- state.dirty = state.partial.length > 0;
27
- for (const line of lines) {
28
- if (line) onOutput({ stream, line });
29
- }
30
- };
31
- };
32
-
33
- const flushPartials = () => {
34
- for (const [stream, state] of Object.entries(streams)) {
35
- if (!state.dirty || !state.partial) continue;
36
- onOutput({ stream, line: state.partial });
37
- state.partial = "";
38
- state.dirty = false;
39
- }
40
- };
41
-
42
- if (subprocess.stdout) subprocess.stdout.on("data", makeHandler("stdout"));
43
- if (subprocess.stderr) subprocess.stderr.on("data", makeHandler("stderr"));
44
- const flushInterval = setInterval(flushPartials, flushMs);
45
- flushInterval.unref?.();
46
-
47
- subprocess.finally(() => {
48
- flushPartials();
49
- clearInterval(flushInterval);
50
- });
51
- }
52
-
53
- async function awaitWithTimeout(subprocess, timeout, getStdout) {
54
- let timer = null;
55
- const timeoutResult = new Promise((resolve) => {
56
- timer = setTimeout(() => {
57
- try {
58
- subprocess.kill(KILL_SIGNAL, { forceKillAfterDelay: 1000 });
59
- } catch {
60
- // no-op
61
- }
62
- resolve({
63
- exitCode: 143,
64
- stdout: getStdout(),
65
- stderr: `Command timed out after ${timeout}ms`,
66
- timedOut: true,
67
- signal: KILL_SIGNAL
68
- });
69
- }, timeout);
70
- });
71
-
72
- const result = await Promise.race([subprocess, timeoutResult]);
73
- if (timer) clearTimeout(timer);
74
- return result;
75
- }
76
-
77
- export async function runCommand(command, args = [], options = {}) {
78
- const { timeout, onOutput, silenceTimeoutMs, partialOutputFlushMs, ...rest } = options;
79
- // Detach stdin unless the caller provides input (e.g. Codex uses input: prompt).
80
- // Without this, a subprocess prompting for input hangs forever since there is no TTY.
81
- if (!rest.stdin && !rest.input) rest.stdin = "ignore";
82
- const subprocess = execa(command, args, { reject: false, ...rest });
83
-
84
- let stdoutAccum = "";
85
- let stderrAccum = "";
86
- let outputSilenceTimer = null;
87
- let silenceTimedOut = false;
88
-
89
- function clearSilenceTimer() {
90
- if (outputSilenceTimer) {
91
- clearTimeout(outputSilenceTimer);
92
- outputSilenceTimer = null;
93
- }
94
- }
95
-
96
- function armSilenceTimer() {
97
- const ms = Number(silenceTimeoutMs);
98
- if (!Number.isFinite(ms) || ms <= 0 || silenceTimedOut) return;
99
- clearSilenceTimer();
100
- outputSilenceTimer = setTimeout(() => {
101
- silenceTimedOut = true;
102
- try {
103
- subprocess.kill(KILL_SIGNAL, { forceKillAfterDelay: 1000 });
104
- } catch {
105
- // no-op
106
- }
107
- }, ms);
108
- }
109
-
110
- if (subprocess.stdout) {
111
- subprocess.stdout.on("data", (chunk) => {
112
- stdoutAccum += chunk.toString();
113
- armSilenceTimer();
114
- });
115
- }
116
- if (subprocess.stderr) {
117
- subprocess.stderr.on("data", (chunk) => {
118
- stderrAccum += chunk.toString();
119
- armSilenceTimer();
120
- });
121
- }
122
-
123
- if (onOutput) {
124
- setupOutputStreaming(subprocess, onOutput, partialOutputFlushMs);
125
- }
126
- armSilenceTimer();
127
-
128
- try {
129
- const result = timeout
130
- ? await awaitWithTimeout(subprocess, timeout, () => stdoutAccum)
131
- : await subprocess;
132
- clearSilenceTimer();
133
- if (silenceTimedOut) return buildSilenceKilledResult(silenceTimeoutMs, stdoutAccum);
134
- return enrichResult(result, stdoutAccum, stderrAccum);
135
- } catch (error) {
136
- clearSilenceTimer();
137
- if (silenceTimedOut) return buildSilenceKilledResult(silenceTimeoutMs, error?.stdout || stdoutAccum, error?.signal);
138
-
139
- const details = [error?.shortMessage, error?.originalMessage, error?.stderr, error?.stdout, error?.message]
140
- .filter(Boolean)
141
- .join("\n");
142
-
143
- return {
144
- exitCode: 1,
145
- stdout: error?.stdout || stdoutAccum,
146
- stderr: details || String(error),
147
- signal: error?.signal || null
148
- };
149
- }
150
- }
151
-
152
- function enrichResult(result, stdoutAccum, _stderrAccum) {
153
- if (result.timedOut) return result;
154
-
155
- // execa with reject:false RESOLVES (does not throw) on a spawn
156
- // failure — ENOENT (binary not in PATH), EACCES, etc. The result
157
- // then carries exitCode null/undefined, an empty stderr, and the
158
- // real cause only in `code` / `shortMessage`. Surface it: a missing
159
- // agent CLI used to fail with a completely empty error message,
160
- // leaving the user with "coder failed" and no idea what to install.
161
- if (result.failed && result.exitCode == null && !result.signal) {
162
- const reason = result.shortMessage || result.message
163
- || `spawn failed${result.code ? ` (${result.code})` : ""}`;
164
- return {
165
- ...result,
166
- exitCode: 1,
167
- stdout: result.stdout || stdoutAccum,
168
- stderr: result.stderr || reason,
169
- spawnError: result.code || true,
170
- };
171
- }
172
-
173
- const killed = result.killed || !!result.signal;
174
- const signal = result.signal || null;
175
-
176
- if (killed && !result.stderr) {
177
- return {
178
- ...result,
179
- stdout: result.stdout || stdoutAccum,
180
- stderr: signal
181
- ? `Process killed by signal ${signal}`
182
- : "Process was killed externally",
183
- signal
184
- };
185
- }
186
-
187
- return result;
188
- }
@@ -1,115 +0,0 @@
1
- /**
2
- * Run registry — registro persistente de `kj run` activos.
3
- *
4
- * KJC-TSK-0396 (extensión): el HU Board necesita ver runs lanzados desde
5
- * la terminal externa (no solo los lanzados desde el propio board). Para
6
- * eso, cada `kj run` escribe su PID + planId + huIds + projectDir a un
7
- * archivo en `~/.karajan/runs/<runId>.json` al arrancar, y lo borra al
8
- * terminar (success o crash con cleanup vía finally).
9
- *
10
- * El board lee ese directorio en startup + cada vez que sirve el endpoint
11
- * `/api/runs/:planId/active`, descartando los registros con PID muerto
12
- * (filtrado con `process.kill(pid, 0)`). Resultado: bidireccionalidad
13
- * total terminal ↔ board.
14
- *
15
- * Compartido entre el CLI (src/commands/run.js) y el board
16
- * (packages/hu-board/src/run-tracker.js) vía `@karajan/core/run-registry`.
17
- */
18
-
19
- import fs from "node:fs";
20
- import path from "node:path";
21
- import { writeJsonAtomicSync } from "./atomic-write.js";
22
- import { getRunsDir } from "./paths.js";
23
-
24
- /**
25
- * Directorio raíz donde viven los registros de run.
26
- * Delega en `getRunsDir()` (KJC-TSK-0420) — respeta KARAJAN_HOME,
27
- * KJ_HOME (con warning de deprecación), VITEST tmp y default.
28
- */
29
- export function runsDir() {
30
- return getRunsDir();
31
- }
32
-
33
- /**
34
- * Registra un nuevo run en disco. El runId es derivado del PID + timestamp
35
- * para ser único por proceso.
36
- *
37
- * @param {{ pid: number, planId?: string|null, huIds?: string[]|null, projectDir?: string|null, source?: string }} info
38
- * @returns {string|null} runId o null si la escritura falló (no bloquea al caller).
39
- */
40
- export function registerRun(info = {}) {
41
- if (!info?.pid) return null;
42
- try {
43
- fs.mkdirSync(runsDir(), { recursive: true });
44
- // Sufijo random para evitar colisiones cuando 2 runs del mismo PID
45
- // se registran en el mismo ms (raro en CLI, posible en tests).
46
- const runId = `run-${Date.now()}-${info.pid}-${Math.random().toString(36).slice(2, 8)}`;
47
- const filePath = path.join(runsDir(), `${runId}.json`);
48
- const entry = {
49
- runId,
50
- pid: info.pid,
51
- planId: info.planId || null,
52
- huIds: Array.isArray(info.huIds) ? info.huIds : null,
53
- projectDir: info.projectDir || null,
54
- source: info.source || "cli",
55
- startedAt: new Date().toISOString(),
56
- };
57
- writeJsonAtomicSync(filePath, entry);
58
- return runId;
59
- } catch {
60
- return null;
61
- }
62
- }
63
-
64
- /**
65
- * Borra el registro de un run. Idempotente — silencia ENOENT.
66
- *
67
- * @param {string} runId
68
- */
69
- export function unregisterRun(runId) {
70
- if (!runId) return;
71
- try {
72
- fs.unlinkSync(path.join(runsDir(), `${runId}.json`));
73
- } catch {
74
- // Idempotente
75
- }
76
- }
77
-
78
- /**
79
- * Lista todos los runs activos en disco, filtrando los con PID muerto.
80
- * Los muertos se borran del FS (auto-cleanup defensivo).
81
- *
82
- * @param {{ planId?: string }} [filter] - si se pasa planId, devuelve solo los runs de ese plan.
83
- * @returns {Array<{runId, pid, planId, huIds, projectDir, source, startedAt}>}
84
- */
85
- export function listActiveRuns(filter = {}) {
86
- const dir = runsDir();
87
- if (!fs.existsSync(dir)) return [];
88
- const out = [];
89
- let files;
90
- try { files = fs.readdirSync(dir); } catch { return []; }
91
- for (const f of files) {
92
- if (!f.endsWith(".json")) continue;
93
- const filePath = path.join(dir, f);
94
- let entry;
95
- try { entry = JSON.parse(fs.readFileSync(filePath, "utf8")); }
96
- catch { try { fs.unlinkSync(filePath); } catch { /* swallow */ } continue; }
97
- if (!isAlive(entry.pid)) {
98
- try { fs.unlinkSync(filePath); } catch { /* swallow */ }
99
- continue;
100
- }
101
- if (filter.planId && entry.planId !== filter.planId) continue;
102
- out.push(entry);
103
- }
104
- return out;
105
- }
106
-
107
- /**
108
- * Verifica si un PID sigue vivo. `process.kill(pid, 0)` NO envía
109
- * señal — solo lanza ESRCH si el proceso no existe.
110
- */
111
- function isAlive(pid) {
112
- if (!pid) return false;
113
- try { process.kill(pid, 0); return true; }
114
- catch (err) { return err.code === "EPERM"; }
115
- }
@@ -1,41 +0,0 @@
1
- /**
2
- * Team-shared HU Board paths — KJC-PRP-0002 / v2.31.0 PR1.
3
- *
4
- * The shared root lives INSIDE the project, not in `~/.karajan/`, so a team
5
- * can commit it to git (or sync via Drive/SyncThing) and share plans + HU
6
- * stories without each member regenerating them locally.
7
- *
8
- * <projectDir>/.karajan-shared/
9
- * plans/<planId>.json ← `kj plan share <planId>` copies here
10
- * hu-stories/<huId>.md ← reserved for PR2+ (board reads it)
11
- *
12
- * Keep the dir name in lockstep with the HU Board sync layer
13
- * (`packages/hu-board/src/sync.js`) — diverging the constant breaks the
14
- * watcher silently. One source of truth lives here.
15
- */
16
-
17
- import path from "node:path";
18
-
19
- export const SHARED_DIR_NAME = ".karajan-shared";
20
-
21
- export function getSharedRoot(projectDir) {
22
- return path.join(projectDir, SHARED_DIR_NAME);
23
- }
24
-
25
- export function getSharedPlansDir(projectDir) {
26
- return path.join(getSharedRoot(projectDir), "plans");
27
- }
28
-
29
- export function getSharedHuStoriesDir(projectDir) {
30
- return path.join(getSharedRoot(projectDir), "hu-stories");
31
- }
32
-
33
- /**
34
- * Quick check used by callers that decide whether a plan came from local
35
- * (`~/.karajan/plans/`) or from the shared dir. The HU Board uses this to
36
- * stamp a `source` column on each record so the UI can render a 🌐 badge.
37
- */
38
- export function isSharedPath(p) {
39
- if (!p) return false;
40
- return p.split(path.sep).includes(SHARED_DIR_NAME);
41
- }
@@ -1,92 +0,0 @@
1
- // KJC-TSK-0414 PR 1: scheduler event-driven para hibernación.
2
- //
3
- // Diseño: setTimeout único por sesión, dispara EXACTAMENTE en cooldownUntil.
4
- // Cero polling. Si el proceso muere antes del fire, reconcileAll() al
5
- // arrancar el board re-programa los timeouts pendientes (o reanuda
6
- // inmediatamente los expirados).
7
-
8
- import { listPendingStandby, acquireStandbyLock, markStandbyDone } from "./standby-store.js";
9
-
10
- // Mapa sessionId → NodeJS.Timeout — accesible para test/cancel.
11
- const _scheduled = new Map();
12
-
13
- export function _resetScheduledForTests() {
14
- for (const t of _scheduled.values()) clearTimeout(t);
15
- _scheduled.clear();
16
- }
17
-
18
- /**
19
- * Programa un único setTimeout que dispara `onResume(sessionId)` en cooldownUntil.
20
- *
21
- * @param {object} args
22
- * @param {string} args.sessionId
23
- * @param {string} args.cooldownUntil - ISO timestamp
24
- * @param {Function} args.onResume - callback(sessionId) cuando llega el momento
25
- * @param {object} [args.logger]
26
- * @returns {{ scheduled: boolean, msUntil?: number, immediate?: boolean }}
27
- */
28
- export function scheduleResume({ sessionId, cooldownUntil, onResume, logger }) {
29
- if (!sessionId || !cooldownUntil || typeof onResume !== "function") {
30
- return { scheduled: false };
31
- }
32
- cancelScheduled(sessionId);
33
- const targetMs = new Date(cooldownUntil).getTime();
34
- if (Number.isNaN(targetMs)) return { scheduled: false };
35
- const delay = targetMs - Date.now();
36
- if (delay <= 0) {
37
- // Ya expiró — reanudar inmediato (caso: board reiniciado tras llegar la hora).
38
- logger?.info?.(`[standby-scheduler] ${sessionId}: cooldown ya expirado, resume inmediato`);
39
- Promise.resolve().then(() => onResume(sessionId)).catch(() => { /* swallow */ });
40
- return { scheduled: true, immediate: true };
41
- }
42
- const timer = setTimeout(() => {
43
- _scheduled.delete(sessionId);
44
- logger?.info?.(`[standby-scheduler] ${sessionId}: cooldown alcanzado, disparando resume`);
45
- try { onResume(sessionId); } catch (err) { logger?.warn?.(`[standby-scheduler] onResume threw: ${err.message}`); }
46
- }, delay);
47
- if (typeof timer.unref === "function") timer.unref();
48
- _scheduled.set(sessionId, timer);
49
- logger?.info?.(`[standby-scheduler] ${sessionId}: scheduled in ${Math.round(delay / 1000)}s (until ${cooldownUntil})`);
50
- return { scheduled: true, msUntil: delay };
51
- }
52
-
53
- /** Cancela el setTimeout de una sesión (caso: usuario hizo kj resume manual). */
54
- export function cancelScheduled(sessionId) {
55
- const t = _scheduled.get(sessionId);
56
- if (!t) return false;
57
- clearTimeout(t);
58
- _scheduled.delete(sessionId);
59
- return true;
60
- }
61
-
62
- /**
63
- * Reconciliación al arrancar el board / kj. Lee todas las sesiones pendientes
64
- * y para cada una:
65
- * - cooldownUntil < now → onResume inmediato
66
- * - cooldownUntil > now → re-programa setTimeout
67
- *
68
- * Idempotente: el lockfile en standby-store previene dobles spawns si
69
- * reconcileAll() corre dos veces concurrentes (board reload).
70
- *
71
- * @param {object} args
72
- * @param {Function} args.onResume - callback(sessionId)
73
- * @param {object} [args.logger]
74
- * @returns {{ scheduled: number, immediate: number, total: number }}
75
- */
76
- export function reconcileAll({ onResume, logger }) {
77
- const sessions = listPendingStandby();
78
- let scheduled = 0;
79
- let immediate = 0;
80
- for (const s of sessions) {
81
- if (!s?.sessionId || !s?.cooldownUntil) continue;
82
- const r = scheduleResume({ sessionId: s.sessionId, cooldownUntil: s.cooldownUntil, onResume, logger });
83
- if (r.scheduled && r.immediate) immediate += 1;
84
- else if (r.scheduled) scheduled += 1;
85
- }
86
- logger?.info?.(`[standby-scheduler] reconcile: ${sessions.length} sesiones (${immediate} resume inmediato, ${scheduled} programadas)`);
87
- return { scheduled, immediate, total: sessions.length };
88
- }
89
-
90
- // Re-export para que callers no tengan que importar standby-store si solo
91
- // necesitan el flow scheduler → lock → done.
92
- export { acquireStandbyLock, markStandbyDone };
@@ -1,168 +0,0 @@
1
- // KJC-TSK-0414 PR 1: persistencia de sesiones hibernadas al disco.
2
- //
3
- // Cuando Brain decide hibernar (QUOTA_EXHAUSTED_DAILY con retryAfter > 5min),
4
- // serializa el estado completo del run en ~/.kj/standby/<sessionId>.json.
5
- // El proceso de kj run muere (exit 0) tras persistir; el board (o kj resume)
6
- // lo reanuda cuando llega cooldownUntil.
7
- //
8
- // Layout en disco:
9
- // ~/.kj/standby/
10
- // <sessionId>.json ← sesiones pendientes (scheduler les mete setTimeout)
11
- // <sessionId>.lock ← lockfile durante spawn de kj resume
12
- // done/
13
- // <sessionId>.json ← finalizadas (consumibles por GC en TSK-0414 PR3)
14
-
15
- import fs from "node:fs";
16
- import path from "node:path";
17
- import { writeJsonAtomicSync } from "./atomic-write.js";
18
- import { resolveHome } from "./paths.js";
19
-
20
- const FILENAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
21
-
22
- // Delegates to the unified resolver. PR 3 of KJC-PCS-0047 switched
23
- // the default to `.karajan` — hibernated sessions now persist under
24
- // `~/.karajan/standby/`, migrated automatically from the legacy
25
- // `~/.kj/standby/` on the next `kj` invocation.
26
- function getKjHome() {
27
- return resolveHome({ defaultSegment: ".karajan" });
28
- }
29
-
30
- export function standbyDir() {
31
- return path.join(getKjHome(), "standby");
32
- }
33
-
34
- export function standbyDoneDir() {
35
- return path.join(standbyDir(), "done");
36
- }
37
-
38
- function sanitizeId(sessionId) {
39
- if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId requerido");
40
- return sessionId.replaceAll(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120);
41
- }
42
-
43
- /**
44
- * Persiste el estado completo de un run hibernado.
45
- *
46
- * @param {object} state - shape:
47
- * { sessionId, planId?, huId?, role, iteration?, prompt, agentConfig,
48
- * retryCount?, cooldownUntil, reason, snapshot_sha?, outcome_so_far? }
49
- * @returns {string} path absoluto del JSON escrito
50
- */
51
- export function persistStandby(state) {
52
- if (!state || !state.sessionId) throw new Error("state.sessionId requerido");
53
- if (!state.cooldownUntil) throw new Error("state.cooldownUntil requerido");
54
- const dir = standbyDir();
55
- fs.mkdirSync(dir, { recursive: true });
56
- const file = path.join(dir, `${sanitizeId(state.sessionId)}.json`);
57
- const payload = {
58
- ...state,
59
- createdAt: state.createdAt || new Date().toISOString(),
60
- persistedAt: new Date().toISOString(),
61
- };
62
- writeJsonAtomicSync(file, payload);
63
- return file;
64
- }
65
-
66
- // Only these env vars are carried into the standby snapshot. NEVER
67
- // persist the whole `process.env` — that would leak API keys and other
68
- // secrets into ~/.kj/standby/*.json. `kj standby resume` re-spawns with
69
- // the live env merged on top, so the snapshot only needs what is needed
70
- // to relaunch from the right place.
71
- const STANDBY_ENV_ALLOWLIST = ["KJ_HOME", "HOME", "PATH"];
72
-
73
- /**
74
- * Builds the `sessionState` object that `withBrainRecovery` persists when
75
- * a run hibernates. Pulls the session id, the relaunch metadata and an
76
- * allowlisted env subset so `kj standby resume` can re-spawn the command.
77
- * @param {object} args
78
- * @param {object} args.session - the pipeline session ({ id, plan_id? })
79
- * @param {object} [args.config] - resolved config ({ projectDir?, plan? })
80
- * @param {string|null} [args.huId]
81
- * @returns {object|null} sessionState, or null when there is no session id
82
- */
83
- export function buildStandbyState({ session, config = {}, huId = null } = {}) {
84
- if (!session?.id) return null;
85
- const env = {};
86
- for (const key of STANDBY_ENV_ALLOWLIST) {
87
- if (process.env[key] != null) env[key] = process.env[key];
88
- }
89
- for (const [key, value] of Object.entries(process.env)) {
90
- if (key.startsWith("KJ_") && value != null) env[key] = value;
91
- }
92
- return {
93
- sessionId: session.id,
94
- planId: session.plan_id || config.plan || null,
95
- huId: huId || null,
96
- argv: process.argv.slice(2),
97
- cwd: config.projectDir || process.cwd(),
98
- env,
99
- };
100
- }
101
-
102
- /**
103
- * @param {string} sessionId
104
- * @returns {object|null}
105
- */
106
- export function loadStandby(sessionId) {
107
- const file = path.join(standbyDir(), `${sanitizeId(sessionId)}.json`);
108
- if (!fs.existsSync(file)) return null;
109
- try {
110
- return JSON.parse(fs.readFileSync(file, "utf-8"));
111
- } catch {
112
- return null;
113
- }
114
- }
115
-
116
- /**
117
- * Lista todas las sesiones pendientes (no las de done/).
118
- * @returns {object[]}
119
- */
120
- export function listPendingStandby() {
121
- const dir = standbyDir();
122
- if (!fs.existsSync(dir)) return [];
123
- return fs.readdirSync(dir)
124
- .filter((f) => FILENAME_RE.test(f))
125
- .map((f) => {
126
- try {
127
- return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
128
- } catch {
129
- return null;
130
- }
131
- })
132
- .filter(Boolean);
133
- }
134
-
135
- /**
136
- * Mueve una sesión a done/ — consumible por GC en TSK-0414 PR3.
137
- * @param {string} sessionId
138
- * @returns {boolean} true si se movió
139
- */
140
- export function markStandbyDone(sessionId) {
141
- const id = sanitizeId(sessionId);
142
- const src = path.join(standbyDir(), `${id}.json`);
143
- if (!fs.existsSync(src)) return false;
144
- const doneDir = standbyDoneDir();
145
- fs.mkdirSync(doneDir, { recursive: true });
146
- const dst = path.join(doneDir, `${id}-${Date.now()}.json`);
147
- fs.renameSync(src, dst);
148
- return true;
149
- }
150
-
151
- /**
152
- * Lockfile para evitar dobles spawns de kj resume sobre la misma sesión.
153
- * Devuelve { acquired: true, release: () => void } o { acquired: false } si ya hay lock.
154
- *
155
- * @param {string} sessionId
156
- * @returns {{ acquired: boolean, release?: Function }}
157
- */
158
- export function acquireStandbyLock(sessionId) {
159
- const id = sanitizeId(sessionId);
160
- const lockFile = path.join(standbyDir(), `${id}.lock`);
161
- try {
162
- fs.mkdirSync(standbyDir(), { recursive: true });
163
- fs.writeFileSync(lockFile, String(process.pid), { flag: "wx" });
164
- return { acquired: true, release: () => { try { fs.unlinkSync(lockFile); } catch { /* */ } } };
165
- } catch {
166
- return { acquired: false };
167
- }
168
- }