@kal-elsam/kairo-runtime 0.6.0 → 0.8.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 +36 -0
- package/global-template/components/catalog.json +4 -1
- package/global-template/components/orchestrator/extensions/pi/kairo-minion.js +604 -0
- package/package.json +1 -1
- package/scripts/cockpit-smoke.mjs +1 -1
- package/src/cli.js +69 -1
- package/src/global/adapters/pi.js +1 -1
- package/src/global/ink/cockpit-controller.js +10 -0
- package/src/global/ink/cockpit-focus.js +18 -3
- package/src/global/ink/cockpit-models.js +8 -1
- package/src/global/ink/cockpit-reviews.js +62 -0
- package/src/global/ink/cockpit-runs.js +11 -2
- package/src/global/ink/cockpit-views.js +19 -2
- package/src/global/ink/orchestrator-app.js +23 -3
- package/src/global/ink/orchestrator-state.js +2 -0
- package/src/global/ink/use-orchestrator-data.js +30 -0
- package/src/global/paths.js +1 -0
- package/src/global/runtime/execution-adapters/codex.js +2 -1
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +1 -0
- package/src/global/runtime/execution-adapters/index.js +1 -0
- package/src/global/runtime/execution-adapters/pi.js +14 -3
- package/src/global/runtime/orchestration/index.js +23 -0
- package/src/global/runtime/orchestration/orch-receipts.js +234 -0
- package/src/global/runtime/orchestration/orch-types.js +173 -0
- package/src/global/runtime/orchestration/orch-validate.js +63 -0
- package/src/global/runtime/review/index.js +37 -0
- package/src/global/runtime/review/review-cli.js +150 -0
- package/src/global/runtime/review/review-codex.js +132 -0
- package/src/global/runtime/review/review-exec.js +136 -0
- package/src/global/runtime/review/review-fs.js +52 -0
- package/src/global/runtime/review/review-git.js +212 -0
- package/src/global/runtime/review/review-patch.js +122 -0
- package/src/global/runtime/review/review-pi.js +168 -0
- package/src/global/runtime/review/review-receipts.js +128 -0
- package/src/global/runtime/review/review-runner.js +119 -0
- package/src/global/runtime/review/review-types.js +108 -0
- package/src/global/runtime/review/review-validate.js +280 -0
- package/src/global/runtime/run-cli.js +1 -0
- package/src/global/runtime/run-manager.js +59 -4
- package/src/global/runtime/run-strategy.js +71 -0
- package/src/global/runtime/run-supervisor.js +22 -2
- package/src/global/runtime/run-types.js +5 -1
- 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
|
+
}
|
|
@@ -45,6 +45,7 @@ export async function runGlobalRun(options, packageManifest, { startRunImpl = st
|
|
|
45
45
|
captureTranscript,
|
|
46
46
|
cliVersion: packageManifest.version,
|
|
47
47
|
profile: profileResolved,
|
|
48
|
+
strategy: options.strategy ?? "direct",
|
|
48
49
|
follow: options.follow,
|
|
49
50
|
timeoutMs: options.timeoutMs,
|
|
50
51
|
wait: options.wait !== false
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
appendRunEvent,
|
|
6
6
|
appendRunStartedEvent,
|
|
7
7
|
createRunRecord,
|
|
8
|
+
listRunRecords,
|
|
8
9
|
readRunState,
|
|
9
10
|
reconcileActiveRuns,
|
|
10
11
|
writeRunState
|
|
@@ -22,6 +23,20 @@ import {
|
|
|
22
23
|
readSupervisorLockForRun,
|
|
23
24
|
supervisePreparedRun
|
|
24
25
|
} from "./run-supervisor.js";
|
|
26
|
+
import {
|
|
27
|
+
assertManagedMinionExtension,
|
|
28
|
+
assertOrchestratedAgent,
|
|
29
|
+
createRootRunLineage,
|
|
30
|
+
normalizeRunStrategy,
|
|
31
|
+
RUN_STRATEGIES
|
|
32
|
+
} from "./run-strategy.js";
|
|
33
|
+
import {
|
|
34
|
+
DAG_NODE_STATES,
|
|
35
|
+
createDagNode,
|
|
36
|
+
createOrchState,
|
|
37
|
+
reconcileOrchState,
|
|
38
|
+
saveOrchState
|
|
39
|
+
} from "./orchestration/index.js";
|
|
25
40
|
|
|
26
41
|
const activeProcesses = new Map();
|
|
27
42
|
const cancelledRuns = new Set();
|
|
@@ -55,6 +70,19 @@ export async function recoverRuns(homeDir) {
|
|
|
55
70
|
exceptRunIds: listActiveRunIds(),
|
|
56
71
|
isRunAliveImpl: isRunSupervisedAlive
|
|
57
72
|
});
|
|
73
|
+
for (const run of await listRunRecords(homeDir)) {
|
|
74
|
+
if (normalizeRunStrategy(run.strategy ?? RUN_STRATEGIES.DIRECT) !== RUN_STRATEGIES.ORCHESTRATED) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (await isRunSupervisedAlive(homeDir, run)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
await reconcileOrchState(run.runId, { homeDir });
|
|
82
|
+
} catch {
|
|
83
|
+
// Fail closed per root: never invent receipt evidence from corrupt state.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
58
86
|
return interrupted;
|
|
59
87
|
}
|
|
60
88
|
|
|
@@ -67,8 +95,10 @@ async function prepareRun({
|
|
|
67
95
|
permissions = [],
|
|
68
96
|
captureTranscript = false,
|
|
69
97
|
cliVersion,
|
|
70
|
-
profile = null
|
|
98
|
+
profile = null,
|
|
99
|
+
strategy = "direct"
|
|
71
100
|
}) {
|
|
101
|
+
const normalizedStrategy = assertOrchestratedAgent(agentId, strategy);
|
|
72
102
|
const adapter = resolveExecutionAdapter(agentId);
|
|
73
103
|
const availability = adapter.availability({ cwd });
|
|
74
104
|
|
|
@@ -83,7 +113,12 @@ async function prepareRun({
|
|
|
83
113
|
);
|
|
84
114
|
}
|
|
85
115
|
|
|
116
|
+
if (normalizedStrategy === RUN_STRATEGIES.ORCHESTRATED) {
|
|
117
|
+
await assertManagedMinionExtension(homeDir);
|
|
118
|
+
}
|
|
119
|
+
|
|
86
120
|
const runId = createRunId();
|
|
121
|
+
const lineage = createRootRunLineage(runId);
|
|
87
122
|
const metadata = createRunMetadata({
|
|
88
123
|
runId,
|
|
89
124
|
agentId,
|
|
@@ -94,7 +129,9 @@ async function prepareRun({
|
|
|
94
129
|
permissions,
|
|
95
130
|
captureTranscript,
|
|
96
131
|
cliVersion,
|
|
97
|
-
profileSources: profile?.sources ?? null
|
|
132
|
+
profileSources: profile?.sources ?? null,
|
|
133
|
+
strategy: normalizedStrategy,
|
|
134
|
+
lineage
|
|
98
135
|
});
|
|
99
136
|
|
|
100
137
|
await createRunRecord(homeDir, metadata);
|
|
@@ -107,9 +144,25 @@ async function prepareRun({
|
|
|
107
144
|
permissions,
|
|
108
145
|
captureTranscript,
|
|
109
146
|
cliVersion,
|
|
110
|
-
profile: profile?.profile ?? null
|
|
147
|
+
profile: profile?.profile ?? null,
|
|
148
|
+
strategy: normalizedStrategy
|
|
111
149
|
});
|
|
112
150
|
|
|
151
|
+
if (normalizedStrategy === RUN_STRATEGIES.ORCHESTRATED) {
|
|
152
|
+
await saveOrchState(createOrchState({
|
|
153
|
+
rootRunId: runId,
|
|
154
|
+
strategy: normalizedStrategy,
|
|
155
|
+
lineage,
|
|
156
|
+
nodes: [createDagNode({
|
|
157
|
+
taskId: lineage.taskId,
|
|
158
|
+
runId,
|
|
159
|
+
depth: 0,
|
|
160
|
+
state: DAG_NODE_STATES.RUNNING
|
|
161
|
+
})],
|
|
162
|
+
cliVersion
|
|
163
|
+
}), { homeDir });
|
|
164
|
+
}
|
|
165
|
+
|
|
113
166
|
return { runId, metadata };
|
|
114
167
|
}
|
|
115
168
|
|
|
@@ -123,6 +176,7 @@ export async function startRun({
|
|
|
123
176
|
captureTranscript = false,
|
|
124
177
|
cliVersion,
|
|
125
178
|
profile = null,
|
|
179
|
+
strategy = "direct",
|
|
126
180
|
follow = false,
|
|
127
181
|
timeoutMs = null,
|
|
128
182
|
wait = true,
|
|
@@ -138,7 +192,8 @@ export async function startRun({
|
|
|
138
192
|
permissions,
|
|
139
193
|
captureTranscript,
|
|
140
194
|
cliVersion,
|
|
141
|
-
profile
|
|
195
|
+
profile,
|
|
196
|
+
strategy: normalizeRunStrategy(strategy)
|
|
142
197
|
});
|
|
143
198
|
|
|
144
199
|
if (!wait) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
RUN_STRATEGIES,
|
|
6
|
+
normalizeRunStrategy,
|
|
7
|
+
createOrchLineage,
|
|
8
|
+
resolveKairoMinionExtensionPath
|
|
9
|
+
} from "./orchestration/index.js";
|
|
10
|
+
|
|
11
|
+
export { RUN_STRATEGIES, normalizeRunStrategy };
|
|
12
|
+
|
|
13
|
+
const ORCH_MODULE_PATH = join(dirname(fileURLToPath(import.meta.url)), "orchestration", "index.js");
|
|
14
|
+
|
|
15
|
+
export const ORCH_RUNTIME_ENV = Object.freeze({
|
|
16
|
+
HOME: "KAIRO_ORCH_HOME",
|
|
17
|
+
ROOT_RUN_ID: "KAIRO_ORCH_ROOT_RUN_ID",
|
|
18
|
+
ROOT_TASK_ID: "KAIRO_ORCH_ROOT_TASK_ID",
|
|
19
|
+
CLI_VERSION: "KAIRO_ORCH_CLI_VERSION",
|
|
20
|
+
MODULE: "KAIRO_ORCH_MODULE"
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** Reject orchestrated for non-Pi before any run I/O. */
|
|
24
|
+
export function assertOrchestratedAgent(agentId, strategy) {
|
|
25
|
+
const normalized = normalizeRunStrategy(strategy);
|
|
26
|
+
if (normalized === RUN_STRATEGIES.ORCHESTRATED && agentId !== "pi") {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Strategy "orchestrated" requires agent "pi" (got "${agentId}").`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return normalized;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fail closed when managed extension is missing or not a regular file. */
|
|
35
|
+
export async function assertManagedMinionExtension(homeDir) {
|
|
36
|
+
const extensionPath = resolveKairoMinionExtensionPath(homeDir);
|
|
37
|
+
let info;
|
|
38
|
+
try {
|
|
39
|
+
info = await stat(extensionPath);
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error(`Managed Kairo minion extension missing: ${extensionPath}`);
|
|
42
|
+
}
|
|
43
|
+
if (!info.isFile()) {
|
|
44
|
+
throw new Error(`Managed Kairo minion extension is not a regular file: ${extensionPath}`);
|
|
45
|
+
}
|
|
46
|
+
return extensionPath;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createRootRunLineage(runId) {
|
|
50
|
+
return createOrchLineage({ rootRunId: runId, parentRunId: null, depth: 0 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Supervisor derives extension path from homeDir only — never from CLI/handoff. */
|
|
54
|
+
export function resolveOrchestratedExtensionPath(homeDir, strategy) {
|
|
55
|
+
if (normalizeRunStrategy(strategy) !== RUN_STRATEGIES.ORCHESTRATED) return null;
|
|
56
|
+
return resolveKairoMinionExtensionPath(homeDir);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildOrchestratedRuntimeEnv({
|
|
60
|
+
homeDir, rootRunId, rootTaskId, cliVersion = null,
|
|
61
|
+
strategy = RUN_STRATEGIES.DIRECT, baseEnv = process.env
|
|
62
|
+
} = {}) {
|
|
63
|
+
const env = { ...baseEnv };
|
|
64
|
+
if (normalizeRunStrategy(strategy) !== RUN_STRATEGIES.ORCHESTRATED) return env;
|
|
65
|
+
env[ORCH_RUNTIME_ENV.HOME] = homeDir;
|
|
66
|
+
env[ORCH_RUNTIME_ENV.ROOT_RUN_ID] = rootRunId;
|
|
67
|
+
env[ORCH_RUNTIME_ENV.ROOT_TASK_ID] = rootTaskId;
|
|
68
|
+
env[ORCH_RUNTIME_ENV.CLI_VERSION] = cliVersion == null ? "" : String(cliVersion);
|
|
69
|
+
env[ORCH_RUNTIME_ENV.MODULE] = ORCH_MODULE_PATH;
|
|
70
|
+
return env;
|
|
71
|
+
}
|
|
@@ -18,6 +18,12 @@ import { consumeRunHandoff } from "./run-handoff.js";
|
|
|
18
18
|
import { isRunCancelRequested } from "./run-cancel-signal.js";
|
|
19
19
|
import { readSupervisorLock, touchSupervisorLock, writeSupervisorLock } from "./run-supervisor-lock.js";
|
|
20
20
|
import { shouldPersistTranscript } from "./run-redact.js";
|
|
21
|
+
import {
|
|
22
|
+
buildOrchestratedRuntimeEnv,
|
|
23
|
+
normalizeRunStrategy,
|
|
24
|
+
resolveOrchestratedExtensionPath
|
|
25
|
+
} from "./run-strategy.js";
|
|
26
|
+
import { finalizeOrchState, RUN_STRATEGIES } from "./orchestration/index.js";
|
|
21
27
|
|
|
22
28
|
async function shouldPreserveCancelledState(homeDir, runId) {
|
|
23
29
|
const fresh = await readRunState(homeDir, runId);
|
|
@@ -66,12 +72,16 @@ export async function supervisePreparedRun({
|
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
const captureTranscript = handoff.captureTranscript === true;
|
|
75
|
+
const strategy = normalizeRunStrategy(handoff.strategy ?? metadata.strategy ?? "direct");
|
|
76
|
+
const extensionPath = resolveOrchestratedExtensionPath(homeDir, strategy);
|
|
69
77
|
const launch = adapter.buildLaunch({
|
|
70
78
|
task: handoff.task,
|
|
71
79
|
cwd: handoff.cwd,
|
|
72
80
|
model: handoff.model,
|
|
73
81
|
permissions: handoff.permissions ?? [],
|
|
74
|
-
profile: handoff.profile ?? null
|
|
82
|
+
profile: handoff.profile ?? null,
|
|
83
|
+
strategy,
|
|
84
|
+
extensionPath
|
|
75
85
|
});
|
|
76
86
|
|
|
77
87
|
metadata = {
|
|
@@ -90,7 +100,14 @@ export async function supervisePreparedRun({
|
|
|
90
100
|
|
|
91
101
|
const child = spawnImpl(launch.command, launch.args, {
|
|
92
102
|
cwd: launch.cwd,
|
|
93
|
-
env:
|
|
103
|
+
env: buildOrchestratedRuntimeEnv({
|
|
104
|
+
homeDir,
|
|
105
|
+
rootRunId: runId,
|
|
106
|
+
rootTaskId: metadata.lineage?.taskId,
|
|
107
|
+
cliVersion: metadata.cliVersion,
|
|
108
|
+
strategy,
|
|
109
|
+
baseEnv: launch.env ?? process.env
|
|
110
|
+
}),
|
|
94
111
|
stdio: ["ignore", "pipe", "pipe"]
|
|
95
112
|
});
|
|
96
113
|
activeProcesses?.set(runId, child);
|
|
@@ -244,6 +261,9 @@ export async function supervisePreparedRun({
|
|
|
244
261
|
type: failed ? "run.failed" : "run.completed",
|
|
245
262
|
data: { exitCode }
|
|
246
263
|
}), { captureTranscript: shouldPersistTranscript(captureTranscript) });
|
|
264
|
+
if (!failed && strategy === RUN_STRATEGIES.ORCHESTRATED) {
|
|
265
|
+
await finalizeOrchState(runId, { homeDir, recovered: false });
|
|
266
|
+
}
|
|
247
267
|
resolve(metadata);
|
|
248
268
|
});
|
|
249
269
|
} catch (error) {
|
|
@@ -83,7 +83,9 @@ export function createRunMetadata({
|
|
|
83
83
|
permissions = [],
|
|
84
84
|
captureTranscript = false,
|
|
85
85
|
cliVersion,
|
|
86
|
-
profileSources = null
|
|
86
|
+
profileSources = null,
|
|
87
|
+
strategy = "direct",
|
|
88
|
+
lineage = null
|
|
87
89
|
}) {
|
|
88
90
|
const { taskDigest, taskLength } = createTaskFingerprint(task);
|
|
89
91
|
const now = new Date().toISOString();
|
|
@@ -100,6 +102,8 @@ export function createRunMetadata({
|
|
|
100
102
|
captureTranscript,
|
|
101
103
|
cliVersion,
|
|
102
104
|
profileSources,
|
|
105
|
+
strategy,
|
|
106
|
+
lineage,
|
|
103
107
|
state: RUN_STATES.PENDING,
|
|
104
108
|
pid: null,
|
|
105
109
|
supervisorPid: null,
|
|
@@ -1,30 +1,36 @@
|
|
|
1
|
-
import {
|
|
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}.${
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
}
|