karajan-code 1.2.3 → 1.3.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/package.json +1 -1
- package/src/config.js +9 -1
- package/src/planning-game/client.js +27 -17
- package/src/plugins/loader.js +67 -0
- package/src/session-cleanup.js +63 -0
- package/src/sonar/api.js +16 -3
- package/src/utils/retry.js +88 -0
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -114,10 +114,18 @@ const DEFAULTS = {
|
|
|
114
114
|
max_sonar_retries: 3,
|
|
115
115
|
max_reviewer_retries: 3,
|
|
116
116
|
max_tester_retries: 1,
|
|
117
|
-
max_security_retries: 1
|
|
117
|
+
max_security_retries: 1,
|
|
118
|
+
expiry_days: 30
|
|
118
119
|
},
|
|
119
120
|
failFast: {
|
|
120
121
|
repeatThreshold: 2
|
|
122
|
+
},
|
|
123
|
+
retry: {
|
|
124
|
+
max_attempts: 3,
|
|
125
|
+
initial_backoff_ms: 1000,
|
|
126
|
+
max_backoff_ms: 30000,
|
|
127
|
+
backoff_multiplier: 2,
|
|
128
|
+
jitter_factor: 0.1
|
|
121
129
|
}
|
|
122
130
|
};
|
|
123
131
|
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* Requires planning_game.api_url in config or PG_API_URL env var.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { withRetry, isTransientError } from "../utils/retry.js";
|
|
13
|
+
|
|
12
14
|
const DEFAULT_API_URL = "http://localhost:3000/api";
|
|
13
15
|
const DEFAULT_TIMEOUT_MS = 10000;
|
|
14
16
|
|
|
@@ -20,10 +22,20 @@ async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_M
|
|
|
20
22
|
const controller = new AbortController();
|
|
21
23
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
22
24
|
try {
|
|
23
|
-
|
|
25
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const err = new Error(`Planning Game API error: ${response.status} ${response.statusText}`);
|
|
28
|
+
err.httpStatus = response.status;
|
|
29
|
+
err.retryAfter = response.headers?.get?.("retry-after") || null;
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
return response;
|
|
24
33
|
} catch (error) {
|
|
34
|
+
if (error?.httpStatus) throw error;
|
|
25
35
|
if (error?.name === "AbortError") {
|
|
26
|
-
|
|
36
|
+
const err = new Error(`Planning Game API timeout after ${timeoutMs}ms`);
|
|
37
|
+
err.httpStatus = 408;
|
|
38
|
+
throw err;
|
|
27
39
|
}
|
|
28
40
|
throw new Error(`Planning Game network error: ${error?.message || "unknown error"}`);
|
|
29
41
|
} finally {
|
|
@@ -31,6 +43,13 @@ async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_M
|
|
|
31
43
|
}
|
|
32
44
|
}
|
|
33
45
|
|
|
46
|
+
async function fetchWithRetry(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS, retryOpts = {}) {
|
|
47
|
+
return withRetry(
|
|
48
|
+
() => fetchWithTimeout(url, options, timeoutMs),
|
|
49
|
+
{ maxAttempts: 3, initialBackoffMs: 1000, ...retryOpts }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
async function parseJsonResponse(response) {
|
|
35
54
|
try {
|
|
36
55
|
return await response.json();
|
|
@@ -41,10 +60,7 @@ async function parseJsonResponse(response) {
|
|
|
41
60
|
|
|
42
61
|
export async function fetchCard({ projectId, cardId, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
43
62
|
const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards/${encodeURIComponent(cardId)}`;
|
|
44
|
-
const response = await
|
|
45
|
-
if (!response.ok) {
|
|
46
|
-
throw new Error(`Planning Game API error: ${response.status} ${response.statusText}`);
|
|
47
|
-
}
|
|
63
|
+
const response = await fetchWithRetry(url, {}, timeoutMs);
|
|
48
64
|
const data = await parseJsonResponse(response);
|
|
49
65
|
return data?.card || data;
|
|
50
66
|
}
|
|
@@ -55,27 +71,21 @@ export async function getCard({ projectId, cardId, timeoutMs = DEFAULT_TIMEOUT_M
|
|
|
55
71
|
|
|
56
72
|
export async function listCards({ projectId, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
57
73
|
const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards`;
|
|
58
|
-
const response = await
|
|
59
|
-
if (!response.ok) {
|
|
60
|
-
throw new Error(`Planning Game API error: ${response.status} ${response.statusText}`);
|
|
61
|
-
}
|
|
74
|
+
const response = await fetchWithRetry(url, {}, timeoutMs);
|
|
62
75
|
const data = await parseJsonResponse(response);
|
|
63
76
|
return data?.cards || data;
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
export async function updateCard({ projectId, cardId, firebaseId, updates, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
67
80
|
const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards/${encodeURIComponent(firebaseId)}`;
|
|
68
|
-
const response = await
|
|
81
|
+
const response = await fetchWithRetry(
|
|
69
82
|
url,
|
|
70
83
|
{
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
84
|
+
method: "PATCH",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ updates })
|
|
74
87
|
},
|
|
75
88
|
timeoutMs
|
|
76
89
|
);
|
|
77
|
-
if (!response.ok) {
|
|
78
|
-
throw new Error(`Planning Game API error: ${response.status} ${response.statusText}`);
|
|
79
|
-
}
|
|
80
90
|
return parseJsonResponse(response);
|
|
81
91
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin loader: discovers and loads plugins from .karajan/plugins/ directories.
|
|
3
|
+
*
|
|
4
|
+
* Plugins are JS files that export a `register(api)` function.
|
|
5
|
+
* The `api` object provides: registerAgent, registerModel.
|
|
6
|
+
*
|
|
7
|
+
* Discovery order (all are loaded, not first-wins):
|
|
8
|
+
* 1. <project>/.karajan/plugins/*.js
|
|
9
|
+
* 2. ~/.karajan/plugins/*.js
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
15
|
+
import { registerAgent } from "../agents/index.js";
|
|
16
|
+
|
|
17
|
+
async function listPluginFiles(dir) {
|
|
18
|
+
try {
|
|
19
|
+
const { readdir } = await import("node:fs/promises");
|
|
20
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
21
|
+
return entries
|
|
22
|
+
.filter((e) => e.isFile() && e.name.endsWith(".js"))
|
|
23
|
+
.map((e) => path.join(dir, e.name));
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function loadPlugin(filePath, api, logger) {
|
|
30
|
+
try {
|
|
31
|
+
const mod = await import(pathToFileURL(filePath).href);
|
|
32
|
+
const registerFn = mod.register || mod.default?.register;
|
|
33
|
+
if (typeof registerFn !== "function") {
|
|
34
|
+
logger?.warn?.(`Plugin ${filePath}: no register() export found, skipping`);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const meta = registerFn(api);
|
|
38
|
+
const name = meta?.name || path.basename(filePath, ".js");
|
|
39
|
+
logger?.debug?.(`Plugin loaded: ${name} (${filePath})`);
|
|
40
|
+
return { name, path: filePath, meta };
|
|
41
|
+
} catch (error) {
|
|
42
|
+
logger?.warn?.(`Plugin ${filePath} failed to load: ${error.message}`);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function loadPlugins({ projectDir, logger } = {}) {
|
|
48
|
+
const dirs = [];
|
|
49
|
+
|
|
50
|
+
if (projectDir) {
|
|
51
|
+
dirs.push(path.join(projectDir, ".karajan", "plugins"));
|
|
52
|
+
}
|
|
53
|
+
dirs.push(path.join(getKarajanHome(), "plugins"));
|
|
54
|
+
|
|
55
|
+
const api = { registerAgent };
|
|
56
|
+
|
|
57
|
+
const loaded = [];
|
|
58
|
+
for (const dir of dirs) {
|
|
59
|
+
const files = await listPluginFiles(dir);
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
const result = await loadPlugin(file, api, logger);
|
|
62
|
+
if (result) loaded.push(result);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return loaded;
|
|
67
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic cleanup of expired sessions.
|
|
3
|
+
* Removes session directories older than session.expiry_days (default: 30).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { getSessionRoot } from "./utils/paths.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_EXPIRY_DAYS = 30;
|
|
11
|
+
|
|
12
|
+
export async function cleanupExpiredSessions({ config, logger } = {}) {
|
|
13
|
+
const expiryDays = config?.session?.expiry_days ?? DEFAULT_EXPIRY_DAYS;
|
|
14
|
+
if (expiryDays <= 0) return { removed: 0, errors: [] };
|
|
15
|
+
|
|
16
|
+
const sessionRoot = getSessionRoot();
|
|
17
|
+
const cutoff = Date.now() - expiryDays * 24 * 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = await fs.readdir(sessionRoot, { withFileTypes: true });
|
|
22
|
+
} catch {
|
|
23
|
+
return { removed: 0, errors: [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const dirs = entries.filter((e) => e.isDirectory() && e.name.startsWith("s_"));
|
|
27
|
+
const removed = [];
|
|
28
|
+
const errors = [];
|
|
29
|
+
|
|
30
|
+
for (const dir of dirs) {
|
|
31
|
+
const sessionDir = path.join(sessionRoot, dir.name);
|
|
32
|
+
const sessionFile = path.join(sessionDir, "session.json");
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const raw = await fs.readFile(sessionFile, "utf8");
|
|
36
|
+
const session = JSON.parse(raw);
|
|
37
|
+
const updatedAt = new Date(session.updated_at || session.created_at).getTime();
|
|
38
|
+
|
|
39
|
+
if (updatedAt < cutoff) {
|
|
40
|
+
await fs.rm(sessionDir, { recursive: true, force: true });
|
|
41
|
+
removed.push(dir.name);
|
|
42
|
+
logger?.debug?.(`Session expired and removed: ${dir.name}`);
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const stat = await fs.stat(sessionDir).catch(() => null);
|
|
46
|
+
if (stat && stat.mtimeMs < cutoff) {
|
|
47
|
+
try {
|
|
48
|
+
await fs.rm(sessionDir, { recursive: true, force: true });
|
|
49
|
+
removed.push(dir.name);
|
|
50
|
+
logger?.debug?.(`Orphan session dir removed: ${dir.name}`);
|
|
51
|
+
} catch (rmErr) {
|
|
52
|
+
errors.push({ session: dir.name, error: rmErr.message });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (removed.length > 0) {
|
|
59
|
+
logger?.info?.(`Cleaned up ${removed.length} expired session(s)`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { removed: removed.length, errors };
|
|
63
|
+
}
|
package/src/sonar/api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runCommand } from "../utils/process.js";
|
|
2
|
+
import { withRetry } from "../utils/retry.js";
|
|
2
3
|
import { resolveSonarProjectKey } from "./project-key.js";
|
|
3
4
|
|
|
4
5
|
export class SonarApiError extends Error {
|
|
@@ -22,16 +23,18 @@ function parseHttpResponse(stdout) {
|
|
|
22
23
|
return { httpCode, body };
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
async function
|
|
26
|
+
async function sonarFetchOnce(config, urlPath) {
|
|
26
27
|
const token = tokenFromConfig(config);
|
|
27
28
|
const url = `${config.sonarqube.host}${urlPath}`;
|
|
28
29
|
const res = await runCommand("curl", ["-s", "-w", "\n%{http_code}", "-u", `${token}:`, url]);
|
|
29
30
|
|
|
30
31
|
if (res.exitCode !== 0) {
|
|
31
|
-
|
|
32
|
+
const err = new SonarApiError(
|
|
32
33
|
`SonarQube is not reachable at ${config.sonarqube.host}. Check that SonarQube is running ('kj sonar start').`,
|
|
33
34
|
{ url, hint: "Run 'kj sonar start' or verify Docker is running." }
|
|
34
35
|
);
|
|
36
|
+
err.httpStatus = 503;
|
|
37
|
+
throw err;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
const { httpCode, body } = parseHttpResponse(res.stdout);
|
|
@@ -44,15 +47,25 @@ async function sonarFetch(config, urlPath) {
|
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
if (httpCode >= 400) {
|
|
47
|
-
|
|
50
|
+
const err = new SonarApiError(
|
|
48
51
|
`SonarQube API returned HTTP ${httpCode} for ${url}.`,
|
|
49
52
|
{ url, httpStatus: httpCode }
|
|
50
53
|
);
|
|
54
|
+
err.httpStatus = httpCode;
|
|
55
|
+
throw err;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
return body;
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
async function sonarFetch(config, urlPath) {
|
|
62
|
+
const maxAttempts = config.sonarqube?.max_scan_retries ?? 3;
|
|
63
|
+
return withRetry(
|
|
64
|
+
() => sonarFetchOnce(config, urlPath),
|
|
65
|
+
{ maxAttempts, initialBackoffMs: 2000, maxBackoffMs: 15000 }
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
export async function getQualityGateStatus(config, projectKey = null) {
|
|
57
70
|
const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey });
|
|
58
71
|
const body = await sonarFetch(config, `/api/qualitygates/project_status?projectKey=${effectiveProjectKey}`);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic retry utility with exponential backoff and jitter.
|
|
3
|
+
* Handles transient errors (429, 502, 503, timeouts) automatically.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const TRANSIENT_HTTP_CODES = new Set([408, 429, 500, 502, 503, 504]);
|
|
7
|
+
|
|
8
|
+
const TRANSIENT_ERROR_PATTERNS = [
|
|
9
|
+
"ETIMEDOUT", "ECONNREFUSED", "ECONNRESET", "EPIPE",
|
|
10
|
+
"ENETUNREACH", "EAI_AGAIN", "EHOSTUNREACH",
|
|
11
|
+
"socket hang up", "network error", "fetch failed"
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const DEFAULT_OPTIONS = {
|
|
15
|
+
maxAttempts: 3,
|
|
16
|
+
initialBackoffMs: 1000,
|
|
17
|
+
maxBackoffMs: 30000,
|
|
18
|
+
backoffMultiplier: 2,
|
|
19
|
+
jitterFactor: 0.1,
|
|
20
|
+
onRetry: null
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function isTransientError(error) {
|
|
24
|
+
if (!error) return false;
|
|
25
|
+
|
|
26
|
+
if (error.httpStatus && TRANSIENT_HTTP_CODES.has(error.httpStatus)) return true;
|
|
27
|
+
if (error.status && TRANSIENT_HTTP_CODES.has(error.status)) return true;
|
|
28
|
+
|
|
29
|
+
const msg = (error.message || String(error)).toLowerCase();
|
|
30
|
+
return TRANSIENT_ERROR_PATTERNS.some((p) => msg.includes(p.toLowerCase()));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseRetryAfter(headerValue) {
|
|
34
|
+
if (!headerValue) return null;
|
|
35
|
+
const seconds = Number(headerValue);
|
|
36
|
+
if (!Number.isNaN(seconds) && seconds > 0) return seconds * 1000;
|
|
37
|
+
|
|
38
|
+
const date = Date.parse(headerValue);
|
|
39
|
+
if (!Number.isNaN(date)) {
|
|
40
|
+
const delayMs = date - Date.now();
|
|
41
|
+
return delayMs > 0 ? delayMs : null;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function calculateBackoff(attempt, options = {}) {
|
|
47
|
+
const { initialBackoffMs = 1000, maxBackoffMs = 30000, backoffMultiplier = 2, jitterFactor = 0.1 } = options;
|
|
48
|
+
|
|
49
|
+
const base = initialBackoffMs * Math.pow(backoffMultiplier, attempt);
|
|
50
|
+
const capped = Math.min(base, maxBackoffMs);
|
|
51
|
+
const jitter = capped * jitterFactor * (Math.random() * 2 - 1);
|
|
52
|
+
return Math.max(0, Math.round(capped + jitter));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sleep(ms) {
|
|
56
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function withRetry(fn, options = {}) {
|
|
60
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
61
|
+
let lastError;
|
|
62
|
+
|
|
63
|
+
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
|
|
64
|
+
try {
|
|
65
|
+
return await fn(attempt);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
lastError = error;
|
|
68
|
+
|
|
69
|
+
if (attempt >= opts.maxAttempts - 1) break;
|
|
70
|
+
if (!isTransientError(error)) break;
|
|
71
|
+
|
|
72
|
+
let delayMs = calculateBackoff(attempt, opts);
|
|
73
|
+
|
|
74
|
+
const retryAfterMs = parseRetryAfter(error.retryAfter || error.headers?.get?.("retry-after"));
|
|
75
|
+
if (retryAfterMs) {
|
|
76
|
+
delayMs = Math.min(retryAfterMs, opts.maxBackoffMs);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (opts.onRetry) {
|
|
80
|
+
opts.onRetry({ attempt, error, delayMs, maxAttempts: opts.maxAttempts });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await sleep(delayMs);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
throw lastError;
|
|
88
|
+
}
|