karajan-code 3.1.0 → 3.2.0
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 +12 -4
- package/package.json +6 -1
- package/packages/hu-board/package.json +3 -1
- package/packages/hu-board/public/index.html +1 -0
- package/packages/hu-board/public/styles.css +32 -0
- package/packages/hu-board/public/utils/board-view.js +10 -1
- package/packages/hu-board/public/utils/formatters.js +41 -0
- package/packages/hu-board/public/wiki.html +32 -0
- package/packages/hu-board/public/wiki.js +53 -0
- package/packages/hu-board/src/db.js +78 -0
- package/packages/hu-board/src/format.js +79 -0
- package/packages/hu-board/src/plan-mutations.js +4 -4
- package/packages/hu-board/src/routes/api.js +16 -1
- package/packages/hu-board/src/routes/rag.js +1 -1
- package/packages/hu-board/src/routes/wiki.js +43 -0
- package/packages/hu-board/src/run-tracker.js +1 -1
- package/packages/hu-board/src/server.js +4 -2
- package/packages/hu-board/src/sync.js +1 -1
- package/scripts/git-hooks/post-merge +13 -2
- package/src/audit/ai-slop-findings.js +102 -0
- package/src/audit/deterministic-summary.js +18 -0
- package/src/brain/standby-scheduler.js +10 -92
- package/src/brain/standby-store.js +12 -168
- package/src/checks/ai-trash.js +34 -0
- package/src/checks/qmd.js +41 -0
- package/src/checks/squeezr.js +36 -0
- package/src/cli/register-meta.js +21 -0
- package/src/cli/register-pipeline.js +3 -0
- package/src/commands/audit.js +6 -4
- package/src/commands/board.js +34 -0
- package/src/commands/doctor.js +6 -0
- package/src/commands/init.js +73 -8
- package/src/commands/qmd.js +86 -0
- package/src/commands/resume.js +6 -45
- package/src/commands/run.js +6 -50
- package/src/git/hu-snapshot.js +10 -105
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +16 -0
- package/src/orchestrator/drivers/run-hu-batch.js +5 -1
- package/src/orchestrator/hu-sub-pipeline.js +52 -8
- package/src/plan/plan-hu-ops.js +16 -338
- package/src/plan/plan-id.js +4 -45
- package/src/plan/plan-validation.js +4 -82
- package/src/rag/vec-store.js +17 -253
- package/src/roles/audit-role.js +11 -1
- package/src/utils/ai-trash.js +48 -0
- package/src/utils/atomic-write.js +6 -43
- package/src/utils/cli-ask-question.js +76 -0
- package/src/utils/doc-links.js +2 -0
- package/src/utils/os-detect.js +10 -0
- package/src/utils/paths.js +14 -123
- package/src/utils/port-check.js +6 -45
- package/src/utils/process.js +4 -188
- package/src/utils/qmd-collection.js +72 -0
- package/src/utils/qmd-detect.js +18 -0
- package/src/utils/qmd-install.js +37 -0
- package/src/utils/run-registry.js +9 -116
- package/src/utils/shared-paths.js +12 -41
- package/src/utils/squeezr-detect.js +18 -0
- package/src/utils/squeezr-install.js +37 -0
package/src/utils/port-check.js
CHANGED
|
@@ -1,45 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
+
// Shim — the real implementation now lives in @karajan/core/port-check.
|
|
2
|
+
// Kept here so CLI callers (src/**/*.js) keep importing
|
|
3
|
+
// "../utils/port-check.js" without churn during KJC-TSK-0511 (decouple
|
|
4
|
+
// @karajan/hu-board). Will be retired once every consumer migrates to
|
|
5
|
+
// the @karajan/core import.
|
|
6
|
+
export { isPortAvailable, findAvailablePort } from "@karajan/core/port-check";
|
package/src/utils/process.js
CHANGED
|
@@ -1,188 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
+
// Shim: process runner now lives in @karajan/core/process so the
|
|
2
|
+
// hu-board workspace can consume it without a relative dep on the CLI
|
|
3
|
+
// src tree. KJC-TSK-0511 PR6.
|
|
4
|
+
export { runCommand } from "@karajan/core/process";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { runCommand } from "./process.js";
|
|
4
|
+
|
|
5
|
+
export const QMD_DEFAULT_PATHS = ["docs", ".reviews", ".karajan/plans"];
|
|
6
|
+
|
|
7
|
+
export function projectSlug(projectDir) {
|
|
8
|
+
const base = path.basename(path.resolve(projectDir));
|
|
9
|
+
return base.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function planQmdCollections(projectDir) {
|
|
13
|
+
const slug = projectSlug(projectDir);
|
|
14
|
+
const result = [];
|
|
15
|
+
for (const rel of QMD_DEFAULT_PATHS) {
|
|
16
|
+
const abs = path.join(projectDir, rel);
|
|
17
|
+
if (!existsSync(abs)) continue;
|
|
18
|
+
try { if (!statSync(abs).isDirectory()) continue; } catch { continue; }
|
|
19
|
+
const suffix = rel.replace(/^\./, "").replace(/[/\\]+/g, "-");
|
|
20
|
+
result.push({ name: `${slug}-${suffix}`, path: abs });
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function listExistingCollections() {
|
|
26
|
+
try {
|
|
27
|
+
const res = await runCommand("qmd", ["collection", "list"]);
|
|
28
|
+
if (res.exitCode !== 0) return new Set();
|
|
29
|
+
const names = new Set();
|
|
30
|
+
for (const line of (res.stdout || "").split("\n")) {
|
|
31
|
+
const m = line.match(/^([\w.-]+)\s+\(qmd:\/\//);
|
|
32
|
+
if (m) names.add(m[1]);
|
|
33
|
+
}
|
|
34
|
+
return names;
|
|
35
|
+
} catch { return new Set(); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Register all default QMD collections for the project. Idempotent: skips
|
|
40
|
+
* collections already present in `qmd collection list`. qmd's CLI only
|
|
41
|
+
* accepts one path per `collection add`, so we register up to 3 sibling
|
|
42
|
+
* collections (`<slug>-docs`, `<slug>-reviews`, `<slug>-karajan-plans`).
|
|
43
|
+
*/
|
|
44
|
+
export async function registerQmdCollections(projectDir, logger) {
|
|
45
|
+
const planned = planQmdCollections(projectDir);
|
|
46
|
+
const result = { ok: true, added: [], skipped: [], errors: [] };
|
|
47
|
+
if (planned.length === 0) {
|
|
48
|
+
logger.info("QMD: no indexable folders yet (docs/, .reviews/, .karajan/plans/ all missing).");
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
const existing = await listExistingCollections();
|
|
52
|
+
for (const { name, path: abs } of planned) {
|
|
53
|
+
if (existing.has(name)) { result.skipped.push(name); continue; }
|
|
54
|
+
try {
|
|
55
|
+
const res = await runCommand("qmd", ["collection", "add", abs, "--name", name], { timeout: 60_000 });
|
|
56
|
+
if (res.exitCode === 0) {
|
|
57
|
+
result.added.push(name);
|
|
58
|
+
} else {
|
|
59
|
+
const err = (res.stderr || res.stdout || "").trim() || `exit code ${res.exitCode}`;
|
|
60
|
+
result.errors.push(`${name}: ${err}`);
|
|
61
|
+
result.ok = false;
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
result.errors.push(`${name}: ${err.message || String(err)}`);
|
|
65
|
+
result.ok = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (result.added.length) logger.info(`QMD: registered ${result.added.length} collection(s): ${result.added.join(", ")}.`);
|
|
69
|
+
if (result.skipped.length) logger.info(`QMD: ${result.skipped.length} collection(s) already present, skipped.`);
|
|
70
|
+
for (const err of result.errors) logger.warn(`QMD: collection registration failed — ${err}`);
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { runCommand } from "./process.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect whether QMD is installed and available.
|
|
5
|
+
* @returns {Promise<{ available: boolean, version: string|null }>}
|
|
6
|
+
*/
|
|
7
|
+
export async function detectQmd() {
|
|
8
|
+
try {
|
|
9
|
+
const result = await runCommand("qmd", ["--version"]);
|
|
10
|
+
if (result.exitCode === 0) {
|
|
11
|
+
const version = (result.stdout || "").trim() || null;
|
|
12
|
+
return { available: true, version };
|
|
13
|
+
}
|
|
14
|
+
return { available: false, version: null };
|
|
15
|
+
} catch { /* qmd binary not found */
|
|
16
|
+
return { available: false, version: null };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { runCommand } from "./process.js";
|
|
2
|
+
import { getInstallCommand } from "./os-detect.js";
|
|
3
|
+
import { detectQmd } from "./qmd-detect.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Install QMD using the OS-appropriate command.
|
|
7
|
+
* @param {object} logger - Logger with info/warn methods
|
|
8
|
+
* @returns {Promise<{ ok: boolean, version: string|null, error: string|null }>}
|
|
9
|
+
*/
|
|
10
|
+
export async function installQmd(logger) {
|
|
11
|
+
const command = getInstallCommand("qmd");
|
|
12
|
+
|
|
13
|
+
logger.info("Installing QMD...");
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const result = await runCommand("sh", ["-c", command], { timeout: 120_000 });
|
|
17
|
+
|
|
18
|
+
if (result.exitCode !== 0) {
|
|
19
|
+
const error = (result.stderr || "").trim() || `exit code ${result.exitCode}`;
|
|
20
|
+
logger.warn(`QMD install failed: ${error}`);
|
|
21
|
+
return { ok: false, version: null, error };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const check = await detectQmd();
|
|
25
|
+
if (check.available) {
|
|
26
|
+
logger.info(`QMD ${check.version || ""} installed successfully.`);
|
|
27
|
+
return { ok: true, version: check.version, error: null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
logger.warn("QMD install command succeeded but qmd binary not found in PATH.");
|
|
31
|
+
return { ok: false, version: null, error: "Binary not found after install" };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
const error = err.message || String(err);
|
|
34
|
+
logger.warn(`QMD install failed: ${error}`);
|
|
35
|
+
return { ok: false, version: null, error };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -1,116 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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). Vive en src/utils/ porque
|
|
17
|
-
* el board ya importa cosas de src/ sin problema.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import fs from "node:fs";
|
|
21
|
-
import path from "node:path";
|
|
22
|
-
import { writeJsonAtomicSync } from "./atomic-write.js";
|
|
23
|
-
import { getRunsDir } from "./paths.js";
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Directorio raíz donde viven los registros de run.
|
|
27
|
-
* Delega en `getRunsDir()` (KJC-TSK-0420) — respeta KARAJAN_HOME,
|
|
28
|
-
* KJ_HOME (con warning de deprecación), VITEST tmp y default.
|
|
29
|
-
*/
|
|
30
|
-
export function runsDir() {
|
|
31
|
-
return getRunsDir();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Registra un nuevo run en disco. El runId es derivado del PID + timestamp
|
|
36
|
-
* para ser único por proceso.
|
|
37
|
-
*
|
|
38
|
-
* @param {{ pid: number, planId?: string|null, huIds?: string[]|null, projectDir?: string|null, source?: string }} info
|
|
39
|
-
* @returns {string|null} runId o null si la escritura falló (no bloquea al caller).
|
|
40
|
-
*/
|
|
41
|
-
export function registerRun(info = {}) {
|
|
42
|
-
if (!info?.pid) return null;
|
|
43
|
-
try {
|
|
44
|
-
fs.mkdirSync(runsDir(), { recursive: true });
|
|
45
|
-
// Sufijo random para evitar colisiones cuando 2 runs del mismo PID
|
|
46
|
-
// se registran en el mismo ms (raro en CLI, posible en tests).
|
|
47
|
-
const runId = `run-${Date.now()}-${info.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
48
|
-
const filePath = path.join(runsDir(), `${runId}.json`);
|
|
49
|
-
const entry = {
|
|
50
|
-
runId,
|
|
51
|
-
pid: info.pid,
|
|
52
|
-
planId: info.planId || null,
|
|
53
|
-
huIds: Array.isArray(info.huIds) ? info.huIds : null,
|
|
54
|
-
projectDir: info.projectDir || null,
|
|
55
|
-
source: info.source || "cli",
|
|
56
|
-
startedAt: new Date().toISOString(),
|
|
57
|
-
};
|
|
58
|
-
writeJsonAtomicSync(filePath, entry);
|
|
59
|
-
return runId;
|
|
60
|
-
} catch {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Borra el registro de un run. Idempotente — silencia ENOENT.
|
|
67
|
-
*
|
|
68
|
-
* @param {string} runId
|
|
69
|
-
*/
|
|
70
|
-
export function unregisterRun(runId) {
|
|
71
|
-
if (!runId) return;
|
|
72
|
-
try {
|
|
73
|
-
fs.unlinkSync(path.join(runsDir(), `${runId}.json`));
|
|
74
|
-
} catch {
|
|
75
|
-
// Idempotente
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Lista todos los runs activos en disco, filtrando los con PID muerto.
|
|
81
|
-
* Los muertos se borran del FS (auto-cleanup defensivo).
|
|
82
|
-
*
|
|
83
|
-
* @param {{ planId?: string }} [filter] - si se pasa planId, devuelve solo los runs de ese plan.
|
|
84
|
-
* @returns {Array<{runId, pid, planId, huIds, projectDir, source, startedAt}>}
|
|
85
|
-
*/
|
|
86
|
-
export function listActiveRuns(filter = {}) {
|
|
87
|
-
const dir = runsDir();
|
|
88
|
-
if (!fs.existsSync(dir)) return [];
|
|
89
|
-
const out = [];
|
|
90
|
-
let files;
|
|
91
|
-
try { files = fs.readdirSync(dir); } catch { return []; }
|
|
92
|
-
for (const f of files) {
|
|
93
|
-
if (!f.endsWith(".json")) continue;
|
|
94
|
-
const filePath = path.join(dir, f);
|
|
95
|
-
let entry;
|
|
96
|
-
try { entry = JSON.parse(fs.readFileSync(filePath, "utf8")); }
|
|
97
|
-
catch { try { fs.unlinkSync(filePath); } catch { /* swallow */ } continue; }
|
|
98
|
-
if (!isAlive(entry.pid)) {
|
|
99
|
-
try { fs.unlinkSync(filePath); } catch { /* swallow */ }
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
if (filter.planId && entry.planId !== filter.planId) continue;
|
|
103
|
-
out.push(entry);
|
|
104
|
-
}
|
|
105
|
-
return out;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Verifica si un PID sigue vivo. `process.kill(pid, 0)` NO envía
|
|
110
|
-
* señal — solo lanza ESRCH si el proceso no existe.
|
|
111
|
-
*/
|
|
112
|
-
function isAlive(pid) {
|
|
113
|
-
if (!pid) return false;
|
|
114
|
-
try { process.kill(pid, 0); return true; }
|
|
115
|
-
catch (err) { return err.code === "EPERM"; }
|
|
116
|
-
}
|
|
1
|
+
// Shim: run-registry now lives in @karajan/core/run-registry so the
|
|
2
|
+
// hu-board workspace can consume it without a relative dep on the CLI
|
|
3
|
+
// src tree. KJC-TSK-0511 PR3.
|
|
4
|
+
export {
|
|
5
|
+
runsDir,
|
|
6
|
+
registerRun,
|
|
7
|
+
unregisterRun,
|
|
8
|
+
listActiveRuns,
|
|
9
|
+
} from "@karajan/core/run-registry";
|
|
@@ -1,41 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
// Shim — the real implementation now lives in @karajan/core/shared-paths.
|
|
2
|
+
// Kept here so CLI callers (src/**/*.js) keep importing
|
|
3
|
+
// "../utils/shared-paths.js" without churn during KJC-TSK-0511 (decouple
|
|
4
|
+
// @karajan/hu-board). Will be retired once every consumer migrates to
|
|
5
|
+
// the @karajan/core import.
|
|
6
|
+
export {
|
|
7
|
+
SHARED_DIR_NAME,
|
|
8
|
+
getSharedRoot,
|
|
9
|
+
getSharedPlansDir,
|
|
10
|
+
getSharedHuStoriesDir,
|
|
11
|
+
isSharedPath,
|
|
12
|
+
} from "@karajan/core/shared-paths";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { runCommand } from "./process.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect whether Squeezr is installed and available.
|
|
5
|
+
* @returns {Promise<{ available: boolean, version: string|null }>}
|
|
6
|
+
*/
|
|
7
|
+
export async function detectSqueezr() {
|
|
8
|
+
try {
|
|
9
|
+
const result = await runCommand("squeezr", ["--version"]);
|
|
10
|
+
if (result.exitCode === 0) {
|
|
11
|
+
const version = (result.stdout || "").trim() || null;
|
|
12
|
+
return { available: true, version };
|
|
13
|
+
}
|
|
14
|
+
return { available: false, version: null };
|
|
15
|
+
} catch { /* squeezr binary not found */
|
|
16
|
+
return { available: false, version: null };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { runCommand } from "./process.js";
|
|
2
|
+
import { getInstallCommand } from "./os-detect.js";
|
|
3
|
+
import { detectSqueezr } from "./squeezr-detect.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Install Squeezr using the OS-appropriate command.
|
|
7
|
+
* @param {object} logger - Logger with info/warn methods
|
|
8
|
+
* @returns {Promise<{ ok: boolean, version: string|null, error: string|null }>}
|
|
9
|
+
*/
|
|
10
|
+
export async function installSqueezr(logger) {
|
|
11
|
+
const command = getInstallCommand("squeezr");
|
|
12
|
+
|
|
13
|
+
logger.info("Installing Squeezr...");
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const result = await runCommand("sh", ["-c", command], { timeout: 120_000 });
|
|
17
|
+
|
|
18
|
+
if (result.exitCode !== 0) {
|
|
19
|
+
const error = (result.stderr || "").trim() || `exit code ${result.exitCode}`;
|
|
20
|
+
logger.warn(`Squeezr install failed: ${error}`);
|
|
21
|
+
return { ok: false, version: null, error };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const check = await detectSqueezr();
|
|
25
|
+
if (check.available) {
|
|
26
|
+
logger.info(`Squeezr ${check.version || ""} installed successfully.`);
|
|
27
|
+
return { ok: true, version: check.version, error: null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
logger.warn("Squeezr install command succeeded but squeezr binary not found in PATH.");
|
|
31
|
+
return { ok: false, version: null, error: "Binary not found after install" };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
const error = err.message || String(err);
|
|
34
|
+
logger.warn(`Squeezr install failed: ${error}`);
|
|
35
|
+
return { ok: false, version: null, error };
|
|
36
|
+
}
|
|
37
|
+
}
|