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.
- package/README.md +1 -1
- package/package.json +2 -5
- package/packages/hu-board/package.json +1 -1
- package/packages/hu-board/src/plan-mutations.js +4 -4
- package/packages/hu-board/src/routes/api.js +1 -1
- package/packages/hu-board/src/routes/rag.js +1 -1
- package/packages/hu-board/src/routes/wiki.js +1 -1
- package/packages/hu-board/src/run-tracker.js +1 -1
- package/packages/hu-board/src/server.js +2 -2
- package/packages/hu-board/src/sync.js +1 -1
- package/scripts/build-sea.mjs +23 -1
- package/scripts/esbuild-sea.config.mjs +22 -1
- package/scripts/verify-pack.mjs +38 -6
- package/src/audit/osv-findings.js +1 -1
- package/src/brain/standby-scheduler.js +2 -2
- package/src/brain/standby-store.js +2 -2
- package/src/commands/init.js +4 -3
- package/src/commands/onboard.js +2 -1
- package/src/git/hu-snapshot.js +2 -2
- package/src/orchestrator/config-init.js +2 -1
- package/src/plan/plan-hu-ops.js +2 -2
- package/src/plan/plan-id.js +2 -2
- package/src/plan/plan-validation.js +2 -2
- package/src/prompts/prompt-resolver.js +2 -9
- package/src/rag/vec-store.js +2 -2
- package/src/utils/atomic-write.js +3 -3
- package/src/utils/install-hints.js +5 -1
- package/src/utils/paths.js +2 -2
- package/src/utils/port-check.js +3 -3
- package/src/utils/process.js +2 -2
- package/src/utils/run-registry.js +2 -2
- package/src/utils/shared-paths.js +3 -3
- package/src/utils/templates-root.js +61 -0
- package/src/utils/update-check.js +78 -15
- package/node_modules/@karajan/core/README.md +0 -42
- package/node_modules/@karajan/core/package.json +0 -49
- package/node_modules/@karajan/core/src/atomic-write.js +0 -43
- package/node_modules/@karajan/core/src/cost/index.js +0 -102
- package/node_modules/@karajan/core/src/hu-snapshot.js +0 -105
- package/node_modules/@karajan/core/src/index.js +0 -18
- package/node_modules/@karajan/core/src/paths.js +0 -123
- package/node_modules/@karajan/core/src/plan-hu-ops.js +0 -338
- package/node_modules/@karajan/core/src/plan-id.js +0 -45
- package/node_modules/@karajan/core/src/plan-validation.js +0 -82
- package/node_modules/@karajan/core/src/port-check.js +0 -45
- package/node_modules/@karajan/core/src/pricing/index.js +0 -64
- package/node_modules/@karajan/core/src/pricing/model-pricing.json +0 -28
- package/node_modules/@karajan/core/src/process.js +0 -188
- package/node_modules/@karajan/core/src/run-registry.js +0 -115
- package/node_modules/@karajan/core/src/shared-paths.js +0 -41
- package/node_modules/@karajan/core/src/standby-scheduler.js +0 -92
- package/node_modules/@karajan/core/src/standby-store.js +0 -168
- package/node_modules/@karajan/core/src/vec-store.js +0 -253
- package/node_modules/@karajan/core/tests/atomic-write.test.js +0 -30
- package/node_modules/@karajan/core/tests/cost/index.test.js +0 -89
- package/node_modules/@karajan/core/tests/hu-snapshot.test.js +0 -39
- package/node_modules/@karajan/core/tests/paths.test.js +0 -51
- package/node_modules/@karajan/core/tests/plan-hu-ops.test.js +0 -68
- package/node_modules/@karajan/core/tests/plan-id.test.js +0 -21
- package/node_modules/@karajan/core/tests/plan-validation.test.js +0 -39
- package/node_modules/@karajan/core/tests/port-check.test.js +0 -47
- package/node_modules/@karajan/core/tests/pricing/index.test.js +0 -95
- package/node_modules/@karajan/core/tests/process.test.js +0 -22
- package/node_modules/@karajan/core/tests/run-registry.test.js +0 -71
- package/node_modules/@karajan/core/tests/shared-paths.test.js +0 -30
- package/node_modules/@karajan/core/tests/standby.test.js +0 -66
- package/node_modules/@karajan/core/tests/vec-store.test.js +0 -98
- 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
|
-
}
|