karajan-code 3.12.0 → 3.12.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/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/verify-pack.mjs +38 -6
- package/src/brain/standby-scheduler.js +2 -2
- package/src/brain/standby-store.js +2 -2
- package/src/cli/register-meta.js +5 -20
- package/src/git/hu-snapshot.js +2 -2
- 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/rag/vec-store.js +2 -2
- package/src/utils/atomic-write.js +3 -3
- 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/update-check.js +47 -0
- 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,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plan + HU ID generation.
|
|
3
|
-
* Globally unique IDs that never collide across plans.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Generate a unique plan ID: plan-<YYYYMMDDHHMMSS>-<4-char random>
|
|
8
|
-
*/
|
|
9
|
-
export function generatePlanId() {
|
|
10
|
-
const now = new Date();
|
|
11
|
-
const ts = now.toISOString().replaceAll(/[-:T]/g, "").replace(/\.\d+Z$/, "");
|
|
12
|
-
const rand = Math.random().toString(36).slice(2, 6);
|
|
13
|
-
return `plan-${ts}-${rand}`;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Generate a globally unique HU ID scoped to a plan.
|
|
18
|
-
* Format: hu_<planId>_<3-digit sequence>
|
|
19
|
-
* @param {string} planId
|
|
20
|
-
* @param {number} seq - 1-based sequence number
|
|
21
|
-
* @returns {string}
|
|
22
|
-
*/
|
|
23
|
-
export function generateHuId(planId, seq) {
|
|
24
|
-
return `hu_${planId}_${String(seq).padStart(3, "0")}`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Normalise a human-friendly name into a CLI-typeable alias.
|
|
29
|
-
* "Greta App" → "greta-app", "Linux Assistant Orchestrator" → "linux-assistant-orchestrator".
|
|
30
|
-
* Acotado a 60 chars para que no se vuelva tan ilegible como el planId.
|
|
31
|
-
*
|
|
32
|
-
* @param {string|null|undefined} name
|
|
33
|
-
* @returns {string|null} alias o null si name está vacío.
|
|
34
|
-
*/
|
|
35
|
-
export function normaliseAlias(name) {
|
|
36
|
-
if (!name || typeof name !== "string") return null;
|
|
37
|
-
const slug = name
|
|
38
|
-
.normalize("NFKD")
|
|
39
|
-
.replaceAll(/[̀-ͯ]/g, "") // strip diacritics
|
|
40
|
-
.toLowerCase()
|
|
41
|
-
.replaceAll(/[^a-z0-9]+/g, "-") // non-alphanum → guion
|
|
42
|
-
.replaceAll(/^-+|-+$/g, "") // trim guiones extremos
|
|
43
|
-
.slice(0, 60);
|
|
44
|
-
return slug || null;
|
|
45
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
// KJC-TSK-0401: validación estructural de cambios en blocked_by desde
|
|
2
|
-
// el board. Se usa antes de persistir un PATCH /api/stories/:id para
|
|
3
|
-
// que el usuario no pueda crear ciclos ni referencias huérfanas. La UI
|
|
4
|
-
// preview la usa para deshabilitar las opciones que romperían el plan.
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Verifica que `newBlockedBy` para `huId` no rompa la estructura del plan.
|
|
8
|
-
*
|
|
9
|
-
* Reglas:
|
|
10
|
-
* 1. Cada ref en newBlockedBy debe existir como hu.id en el plan.
|
|
11
|
-
* 2. No se permite auto-dependencia (huId ∈ newBlockedBy).
|
|
12
|
-
* 3. Aplicar el cambio NO debe crear un ciclo en el grafo blocked_by.
|
|
13
|
-
*
|
|
14
|
-
* @param {object} plan
|
|
15
|
-
* @param {string} huId
|
|
16
|
-
* @param {string[]} newBlockedBy
|
|
17
|
-
* @returns {{ ok: true } | { ok: false, error: string, cycle?: string[] }}
|
|
18
|
-
*/
|
|
19
|
-
export function validateBlockedByChange(plan, huId, newBlockedBy) {
|
|
20
|
-
if (!plan || !Array.isArray(plan.hus)) {
|
|
21
|
-
return { ok: false, error: "plan inválido" };
|
|
22
|
-
}
|
|
23
|
-
const hu = plan.hus.find((h) => h.id === huId);
|
|
24
|
-
if (!hu) return { ok: false, error: `HU no encontrada: ${huId}` };
|
|
25
|
-
if (!Array.isArray(newBlockedBy)) {
|
|
26
|
-
return { ok: false, error: "blocked_by debe ser un array" };
|
|
27
|
-
}
|
|
28
|
-
const ids = new Set(plan.hus.map((h) => h.id));
|
|
29
|
-
for (const ref of newBlockedBy) {
|
|
30
|
-
if (typeof ref !== "string") {
|
|
31
|
-
return { ok: false, error: "blocked_by contiene un valor no-string" };
|
|
32
|
-
}
|
|
33
|
-
if (ref === huId) {
|
|
34
|
-
return { ok: false, error: `auto-dependencia no permitida: ${huId}` };
|
|
35
|
-
}
|
|
36
|
-
if (!ids.has(ref)) {
|
|
37
|
-
return { ok: false, error: `HU referenciada inexistente: ${ref}` };
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
// Simular el cambio sobre una copia del grafo y buscar ciclos.
|
|
41
|
-
const graph = new Map();
|
|
42
|
-
for (const h of plan.hus) {
|
|
43
|
-
graph.set(h.id, h.id === huId ? [...newBlockedBy] : (Array.isArray(h.blocked_by) ? [...h.blocked_by] : []));
|
|
44
|
-
}
|
|
45
|
-
const cycle = findCycleContaining(graph, huId);
|
|
46
|
-
if (cycle) {
|
|
47
|
-
return { ok: false, error: `Ciclo detectado: ${cycle.join(" → ")} → ${cycle[0]}`, cycle };
|
|
48
|
-
}
|
|
49
|
-
return { ok: true };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* DFS desde `start` siguiendo aristas blocked_by. Devuelve el primer
|
|
54
|
-
* ciclo encontrado que pase por `start`, o null. La búsqueda no es
|
|
55
|
-
* exhaustiva — basta con detectar UN ciclo para rechazar el cambio.
|
|
56
|
-
*
|
|
57
|
-
* @param {Map<string,string[]>} graph
|
|
58
|
-
* @param {string} start
|
|
59
|
-
* @returns {string[]|null}
|
|
60
|
-
*/
|
|
61
|
-
function findCycleContaining(graph, start) {
|
|
62
|
-
const onStack = new Set();
|
|
63
|
-
const visited = new Set();
|
|
64
|
-
const stack = [];
|
|
65
|
-
function dfs(node) {
|
|
66
|
-
if (onStack.has(node)) {
|
|
67
|
-
const idx = stack.indexOf(node);
|
|
68
|
-
if (idx >= 0) return stack.slice(idx);
|
|
69
|
-
return [node];
|
|
70
|
-
}
|
|
71
|
-
if (visited.has(node)) return null;
|
|
72
|
-
onStack.add(node); stack.push(node); visited.add(node);
|
|
73
|
-
for (const dep of graph.get(node) || []) {
|
|
74
|
-
if (!graph.has(dep)) continue;
|
|
75
|
-
const found = dfs(dep);
|
|
76
|
-
if (found) return found;
|
|
77
|
-
}
|
|
78
|
-
stack.pop(); onStack.delete(node);
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
return dfs(start);
|
|
82
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TCP port availability helpers.
|
|
3
|
-
*
|
|
4
|
-
* Originally lived in packages/hu-board/src/server.js. Moved here so the
|
|
5
|
-
* doctor/preflight subsystem (src/checks/) and hu-board share a single
|
|
6
|
-
* implementation.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { createServer } from "node:net";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Check whether a TCP port is available to bind on localhost.
|
|
13
|
-
*
|
|
14
|
-
* @param {number} port
|
|
15
|
-
* @returns {Promise<boolean>}
|
|
16
|
-
*/
|
|
17
|
-
export function isPortAvailable(port) {
|
|
18
|
-
return new Promise((resolve) => {
|
|
19
|
-
const server = createServer();
|
|
20
|
-
server.once("error", () => resolve(false));
|
|
21
|
-
server.once("listening", () => {
|
|
22
|
-
server.close();
|
|
23
|
-
resolve(true);
|
|
24
|
-
});
|
|
25
|
-
server.listen(port);
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Find the first available port starting from `startPort`.
|
|
31
|
-
*
|
|
32
|
-
* @param {number} startPort - first port to try
|
|
33
|
-
* @param {number} [maxAttempts=11] - how many consecutive ports to probe
|
|
34
|
-
* @param {(port: number) => void} [onBusy] - called for each busy port seen
|
|
35
|
-
* @returns {Promise<number>} - resolves with the first free port found
|
|
36
|
-
* @throws {Error} if no port is free within the range
|
|
37
|
-
*/
|
|
38
|
-
export async function findAvailablePort(startPort, maxAttempts = 11, onBusy) {
|
|
39
|
-
for (let i = 0; i < maxAttempts; i++) {
|
|
40
|
-
const port = startPort + i;
|
|
41
|
-
if (await isPortAvailable(port)) return port;
|
|
42
|
-
if (onBusy) onBusy(port);
|
|
43
|
-
}
|
|
44
|
-
throw new Error(`No available port found between ${startPort} and ${startPort + maxAttempts - 1}`);
|
|
45
|
-
}
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Model pricing registry (Cost A — KJC-TSK-0512).
|
|
3
|
-
*
|
|
4
|
-
* Static $-per-1M-tokens lookup. The cost-aggregator (Cost B) turns
|
|
5
|
-
* `{tokens_in, tokens_out, model}` events from the orchestrator into
|
|
6
|
-
* USD without calling external pricing APIs at runtime.
|
|
7
|
-
*
|
|
8
|
-
* Unknown models return `null` — the aggregator records the run as
|
|
9
|
-
* `cost: "unknown"` instead of throwing, so a brand-new model id
|
|
10
|
-
* never blocks a kj run.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { dirname, join } from "node:path";
|
|
16
|
-
|
|
17
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
-
const REGISTRY_PATH = join(__dirname, "model-pricing.json");
|
|
19
|
-
|
|
20
|
-
let _cached = null;
|
|
21
|
-
|
|
22
|
-
function loadRegistry() {
|
|
23
|
-
if (_cached) return _cached;
|
|
24
|
-
const raw = readFileSync(REGISTRY_PATH, "utf8");
|
|
25
|
-
_cached = JSON.parse(raw);
|
|
26
|
-
return _cached;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Look up pricing for a model id.
|
|
31
|
-
*
|
|
32
|
-
* @param {string} modelId — e.g. "claude-opus-4-7", "gpt-4o".
|
|
33
|
-
* @returns {{inputPerMTok:number, outputPerMTok:number, currency:string, source:string, updatedAt:string} | null}
|
|
34
|
-
*/
|
|
35
|
-
export function getModelPricing(modelId) {
|
|
36
|
-
if (typeof modelId !== "string" || modelId.length === 0) return null;
|
|
37
|
-
const reg = loadRegistry();
|
|
38
|
-
const entry = reg[modelId];
|
|
39
|
-
if (!entry || modelId.startsWith("_")) return null;
|
|
40
|
-
return {
|
|
41
|
-
inputPerMTok: entry.inputPerMTok,
|
|
42
|
-
outputPerMTok: entry.outputPerMTok,
|
|
43
|
-
currency: reg._meta.currency,
|
|
44
|
-
source: entry.source,
|
|
45
|
-
updatedAt: reg._meta.updatedAt,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* List every known model id (no metadata). Useful for diagnostics
|
|
51
|
-
* and future `kj doctor` checks.
|
|
52
|
-
*/
|
|
53
|
-
export function listKnownModels() {
|
|
54
|
-
const reg = loadRegistry();
|
|
55
|
-
return Object.keys(reg).filter((k) => !k.startsWith("_"));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Reset the in-memory cache. Tests use this to re-read the JSON
|
|
60
|
-
* after mutating it; production code never needs to call it.
|
|
61
|
-
*/
|
|
62
|
-
export function _resetCache() {
|
|
63
|
-
_cached = null;
|
|
64
|
-
}
|
|
@@ -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
|
-
}
|