@kal-elsam/kairo-runtime 0.6.0 → 0.7.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.
Files changed (31) hide show
  1. package/README.md +24 -0
  2. package/package.json +1 -1
  3. package/scripts/cockpit-smoke.mjs +1 -1
  4. package/src/cli.js +59 -0
  5. package/src/global/ink/cockpit-controller.js +10 -0
  6. package/src/global/ink/cockpit-focus.js +18 -3
  7. package/src/global/ink/cockpit-models.js +8 -1
  8. package/src/global/ink/cockpit-reviews.js +62 -0
  9. package/src/global/ink/cockpit-runs.js +11 -2
  10. package/src/global/ink/cockpit-views.js +19 -2
  11. package/src/global/ink/orchestrator-app.js +23 -3
  12. package/src/global/ink/orchestrator-state.js +2 -0
  13. package/src/global/ink/use-orchestrator-data.js +30 -0
  14. package/src/global/paths.js +1 -0
  15. package/src/global/runtime/execution-adapters/codex.js +2 -1
  16. package/src/global/runtime/execution-adapters/create-execution-adapter.js +1 -0
  17. package/src/global/runtime/execution-adapters/index.js +1 -0
  18. package/src/global/runtime/execution-adapters/pi.js +2 -1
  19. package/src/global/runtime/review/index.js +37 -0
  20. package/src/global/runtime/review/review-cli.js +150 -0
  21. package/src/global/runtime/review/review-codex.js +132 -0
  22. package/src/global/runtime/review/review-exec.js +136 -0
  23. package/src/global/runtime/review/review-fs.js +52 -0
  24. package/src/global/runtime/review/review-git.js +212 -0
  25. package/src/global/runtime/review/review-patch.js +122 -0
  26. package/src/global/runtime/review/review-pi.js +168 -0
  27. package/src/global/runtime/review/review-receipts.js +128 -0
  28. package/src/global/runtime/review/review-runner.js +119 -0
  29. package/src/global/runtime/review/review-types.js +108 -0
  30. package/src/global/runtime/review/review-validate.js +280 -0
  31. package/src/global/runtime/write-atomic-json.js +24 -20
@@ -0,0 +1,280 @@
1
+ import {
2
+ REVIEW_SEVERITIES,
3
+ ReviewSnapshotError,
4
+ assertReviewPathSafe,
5
+ createFindingId
6
+ } from "./review-types.js";
7
+
8
+ export const REVIEW_VALIDATION_ERROR_CODES = Object.freeze({
9
+ INVALID_OUTPUT: "invalid_output",
10
+ INVALID_FINDING: "invalid_finding",
11
+ PATH_OUT_OF_SCOPE: "path_out_of_scope",
12
+ FORBIDDEN_FIELD: "forbidden_field",
13
+ RECEIPT_EXISTS: "receipt_exists"
14
+ });
15
+
16
+ const SEVERITY_SET = new Set(Object.values(REVIEW_SEVERITIES));
17
+ const FORBIDDEN_KEYS = new Set([
18
+ "prompt", "diff", "transcript", "raw", "rawOutput", "stdout", "stderr",
19
+ "output", "message", "messages", "content", "secret", "secrets", "token", "apiKey"
20
+ ]);
21
+
22
+ const RECEIPT_SHAPE = Object.freeze({
23
+ version: "number",
24
+ reviewId: "string",
25
+ agentId: "string",
26
+ model: "string?",
27
+ state: "string",
28
+ snapshot: {
29
+ mode: "string",
30
+ headSha: "string",
31
+ base: "string?",
32
+ commit: "string?",
33
+ fingerprint: "string",
34
+ totals: { fileCount: "number", changedLines: "number", diffBytes: "number" },
35
+ files: [{ path: "string", sourcePath: "string?", status: "string", hash: "string", changedLines: "number" }],
36
+ excluded: [{ path: "string", reason: "string" }]
37
+ },
38
+ findings: [{
39
+ id: "string",
40
+ severity: "string",
41
+ title: "string",
42
+ path: "string",
43
+ line: "number?",
44
+ problem: "string",
45
+ recommendation: "string"
46
+ }],
47
+ warnings: ["string"],
48
+ usage: {
49
+ inputTokens: "number?",
50
+ outputTokens: "number?",
51
+ totalTokens: "number?",
52
+ cost: "number?"
53
+ },
54
+ timings: { startedAt: "string?", finishedAt: "string?", durationMs: "number?" },
55
+ cliVersion: "string?",
56
+ createdAt: "string"
57
+ });
58
+
59
+ export class ReviewValidationError extends Error {
60
+ constructor(message, { code, details = null } = {}) {
61
+ super(message);
62
+ this.name = "ReviewValidationError";
63
+ this.code = code;
64
+ this.details = details;
65
+ }
66
+ }
67
+
68
+ function asObject(value, label) {
69
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
70
+ throw new ReviewValidationError(`Invalid ${label}: expected object.`, {
71
+ code: REVIEW_VALIDATION_ERROR_CODES.INVALID_OUTPUT
72
+ });
73
+ }
74
+ return value;
75
+ }
76
+
77
+ function normalizeLine(line) {
78
+ if (line == null || line === "") return null;
79
+ if (!Number.isInteger(line) || line < 1) {
80
+ throw new ReviewValidationError(`Invalid finding line "${line}".`, {
81
+ code: REVIEW_VALIDATION_ERROR_CODES.INVALID_FINDING,
82
+ details: { line }
83
+ });
84
+ }
85
+ return line;
86
+ }
87
+
88
+ function requireNonEmptyString(value, field) {
89
+ if (typeof value !== "string" || value.trim() === "") {
90
+ throw new ReviewValidationError(`Finding missing ${field}.`, {
91
+ code: REVIEW_VALIDATION_ERROR_CODES.INVALID_FINDING,
92
+ details: { field }
93
+ });
94
+ }
95
+ return value.trim();
96
+ }
97
+
98
+ function isOptionalScalar(spec) {
99
+ return typeof spec === "string" && spec.endsWith("?");
100
+ }
101
+
102
+ function scalarType(spec) {
103
+ return isOptionalScalar(spec) ? spec.slice(0, -1) : spec;
104
+ }
105
+
106
+ function assertNoForbiddenKeys(value, path) {
107
+ if (Array.isArray(value)) {
108
+ value.forEach((entry, index) => assertNoForbiddenKeys(entry, `${path}[${index}]`));
109
+ return;
110
+ }
111
+ if (!value || typeof value !== "object") return;
112
+ for (const [key, child] of Object.entries(value)) {
113
+ if (FORBIDDEN_KEYS.has(key)) {
114
+ throw new ReviewValidationError(`Receipt must not include "${key}" at ${path}.`, {
115
+ code: REVIEW_VALIDATION_ERROR_CODES.FORBIDDEN_FIELD,
116
+ details: { key, path }
117
+ });
118
+ }
119
+ assertNoForbiddenKeys(child, `${path}.${key}`);
120
+ }
121
+ }
122
+
123
+ function assertMatchesShape(value, shape, path) {
124
+ if (shape === null) {
125
+ if (value !== null) {
126
+ throw new ReviewValidationError(`Expected null at ${path}.`, {
127
+ code: REVIEW_VALIDATION_ERROR_CODES.INVALID_OUTPUT, details: { path }
128
+ });
129
+ }
130
+ return;
131
+ }
132
+
133
+ if (typeof shape === "string") {
134
+ if (value == null) {
135
+ if (isOptionalScalar(shape) || shape === "null") return;
136
+ throw new ReviewValidationError(`Missing value at ${path}.`, {
137
+ code: REVIEW_VALIDATION_ERROR_CODES.INVALID_OUTPUT, details: { path }
138
+ });
139
+ }
140
+ const expected = scalarType(shape);
141
+ if (expected === "number" && typeof value === "number" && Number.isFinite(value)) return;
142
+ if (expected === "string" && typeof value === "string") return;
143
+ throw new ReviewValidationError(`Invalid type at ${path}.`, {
144
+ code: REVIEW_VALIDATION_ERROR_CODES.INVALID_OUTPUT, details: { path, expected }
145
+ });
146
+ }
147
+
148
+ if (Array.isArray(shape)) {
149
+ if (!Array.isArray(value)) {
150
+ throw new ReviewValidationError(`Expected array at ${path}.`, {
151
+ code: REVIEW_VALIDATION_ERROR_CODES.INVALID_OUTPUT, details: { path }
152
+ });
153
+ }
154
+ const itemShape = shape[0];
155
+ value.forEach((entry, index) => assertMatchesShape(entry, itemShape, `${path}[${index}]`));
156
+ return;
157
+ }
158
+
159
+ if (value == null) {
160
+ // Optional object fields (usage/timings) may be null.
161
+ return;
162
+ }
163
+
164
+ const body = asObject(value, path);
165
+ const allowed = new Set(Object.keys(shape));
166
+ for (const key of Object.keys(body)) {
167
+ if (!allowed.has(key)) {
168
+ throw new ReviewValidationError(`Unexpected field "${key}" at ${path}.`, {
169
+ code: REVIEW_VALIDATION_ERROR_CODES.FORBIDDEN_FIELD,
170
+ details: { key, path }
171
+ });
172
+ }
173
+ }
174
+ for (const [key, childShape] of Object.entries(shape)) {
175
+ if (!(key in body)) {
176
+ throw new ReviewValidationError(`Missing field "${key}" at ${path}.`, {
177
+ code: REVIEW_VALIDATION_ERROR_CODES.INVALID_OUTPUT, details: { path, key }
178
+ });
179
+ }
180
+ assertMatchesShape(body[key], childShape, `${path}.${key}`);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Fail-closed parse of agent review JSON against a snapshot scope.
186
+ * Never accepts paths outside snapshot.files.
187
+ */
188
+ export function validateReviewOutput(raw, snapshot) {
189
+ let parsed = raw;
190
+ if (typeof raw === "string") {
191
+ try {
192
+ parsed = JSON.parse(raw);
193
+ } catch (error) {
194
+ throw new ReviewValidationError(`Broken review JSON: ${error.message}`, {
195
+ code: REVIEW_VALIDATION_ERROR_CODES.INVALID_OUTPUT
196
+ });
197
+ }
198
+ }
199
+
200
+ const body = asObject(parsed, "review output");
201
+ assertNoForbiddenKeys(body, "output");
202
+ const findingsIn = Array.isArray(body.findings) ? body.findings : null;
203
+ if (!findingsIn) {
204
+ throw new ReviewValidationError("Review output missing findings array.", {
205
+ code: REVIEW_VALIDATION_ERROR_CODES.INVALID_OUTPUT
206
+ });
207
+ }
208
+
209
+ const allowed = new Set((snapshot?.files ?? []).map((f) => f.path));
210
+ const findings = [];
211
+
212
+ for (const entry of findingsIn) {
213
+ const item = asObject(entry, "finding");
214
+ assertNoForbiddenKeys(item, "finding");
215
+ const severity = requireNonEmptyString(item.severity, "severity").toLowerCase();
216
+ if (!SEVERITY_SET.has(severity)) {
217
+ throw new ReviewValidationError(`Unknown severity "${item.severity}".`, {
218
+ code: REVIEW_VALIDATION_ERROR_CODES.INVALID_FINDING,
219
+ details: { severity: item.severity }
220
+ });
221
+ }
222
+
223
+ let path;
224
+ try {
225
+ path = assertReviewPathSafe(requireNonEmptyString(item.path, "path"));
226
+ } catch (error) {
227
+ if (error instanceof ReviewSnapshotError) {
228
+ throw new ReviewValidationError(error.message, {
229
+ code: REVIEW_VALIDATION_ERROR_CODES.INVALID_FINDING,
230
+ details: error.details
231
+ });
232
+ }
233
+ throw error;
234
+ }
235
+
236
+ if (!allowed.has(path)) {
237
+ throw new ReviewValidationError(`Finding path "${path}" is outside the review snapshot.`, {
238
+ code: REVIEW_VALIDATION_ERROR_CODES.PATH_OUT_OF_SCOPE,
239
+ details: { path }
240
+ });
241
+ }
242
+
243
+ const title = requireNonEmptyString(item.title, "title");
244
+ const problem = requireNonEmptyString(item.problem, "problem");
245
+ const recommendation = requireNonEmptyString(item.recommendation, "recommendation");
246
+ const line = normalizeLine(item.line);
247
+ const id = createFindingId({ severity, title, path, line, problem });
248
+ findings.push({ id, severity, title, path, line, problem, recommendation });
249
+ }
250
+
251
+ const warnings = Array.isArray(body.warnings)
252
+ ? body.warnings.filter((w) => typeof w === "string" && w.trim()).map((w) => w.trim())
253
+ : [];
254
+
255
+ return {
256
+ findings,
257
+ warnings,
258
+ model: typeof body.model === "string" ? body.model : null,
259
+ usage: sanitizeUsage(body.usage)
260
+ };
261
+ }
262
+
263
+ function sanitizeUsage(usage) {
264
+ if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
265
+ assertNoForbiddenKeys(usage, "usage");
266
+ return {
267
+ inputTokens: Number.isFinite(usage.inputTokens) ? usage.inputTokens : null,
268
+ outputTokens: Number.isFinite(usage.outputTokens) ? usage.outputTokens : null,
269
+ totalTokens: Number.isFinite(usage.totalTokens) ? usage.totalTokens : null,
270
+ cost: Number.isFinite(usage.cost) ? usage.cost : null
271
+ };
272
+ }
273
+
274
+ /** Recursive forbidden-key + allowlisted shape check before persistence. */
275
+ export function assertReceiptSecretFree(receipt) {
276
+ const body = asObject(receipt, "receipt");
277
+ assertNoForbiddenKeys(body, "receipt");
278
+ assertMatchesShape(body, RECEIPT_SHAPE, "receipt");
279
+ return body;
280
+ }
@@ -1,30 +1,36 @@
1
- import { open as fsOpen, rename as fsRename, unlink as fsUnlink } from "node:fs/promises";
1
+ import {
2
+ open as fsOpen, rename as fsRename, unlink as fsUnlink, link as fsLink
3
+ } from "node:fs/promises";
2
4
  import { constants } from "node:fs";
3
5
  import { basename, dirname, join } from "node:path";
4
6
  import { randomBytes } from "node:crypto";
5
7
 
6
8
  function defaultTempPath(targetPath) {
7
- const id = randomBytes(8).toString("hex");
8
9
  return join(
9
10
  dirname(targetPath),
10
- `.${basename(targetPath)}.${process.pid}.${id}.tmp`
11
+ `.${basename(targetPath)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`
11
12
  );
12
13
  }
13
14
 
15
+ async function bestEffort(fn) {
16
+ try { await fn(); } catch { /* ignore */ }
17
+ }
18
+
14
19
  /**
15
- * Atomically replace targetPath with pretty-printed JSON.
16
- * Creates a unique temp in the same directory (O_EXCL), writes + fsync,
17
- * renames over the destination, and deletes the temp on any failure.
20
+ * Atomic JSON write. Default rename-replace; createExclusive uses link (EEXIST).
21
+ * link/rename are commit points; post-commit temp cleanup is best-effort.
18
22
  */
19
23
  export async function writeAtomicJson(targetPath, value, deps = {}) {
20
24
  const open = deps.open ?? fsOpen;
21
25
  const rename = deps.rename ?? fsRename;
22
26
  const unlink = deps.unlink ?? fsUnlink;
27
+ const link = deps.link ?? fsLink;
23
28
  const createTempPath = deps.createTempPath ?? defaultTempPath;
24
-
29
+ const createExclusive = deps.createExclusive === true;
25
30
  const payload = `${JSON.stringify(value, null, 2)}\n`;
26
31
  const tempPath = createTempPath(targetPath);
27
32
  let handle;
33
+ let committed = false;
28
34
 
29
35
  try {
30
36
  handle = await open(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
@@ -32,20 +38,18 @@ export async function writeAtomicJson(targetPath, value, deps = {}) {
32
38
  await handle.sync();
33
39
  await handle.close();
34
40
  handle = undefined;
35
- await rename(tempPath, targetPath);
36
- } catch (error) {
37
- if (handle) {
38
- try {
39
- await handle.close();
40
- } catch {
41
- // Best-effort close before temp cleanup.
42
- }
43
- }
44
- try {
45
- await unlink(tempPath);
46
- } catch {
47
- // Temp may not exist yet or already renamed.
41
+ if (createExclusive) {
42
+ await link(tempPath, targetPath);
43
+ committed = true;
44
+ await bestEffort(() => unlink(tempPath));
45
+ } else {
46
+ await rename(tempPath, targetPath);
47
+ committed = true;
48
48
  }
49
+ } catch (error) {
50
+ if (committed) return;
51
+ if (handle) await bestEffort(() => handle.close());
52
+ await bestEffort(() => unlink(tempPath));
49
53
  throw error;
50
54
  }
51
55
  }