@kici-dev/agent 0.0.0 → 0.1.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/LICENSE +661 -0
- package/README.md +1 -6
- package/dist/checkout/git-clone.d.ts +59 -0
- package/dist/checkout/ssh-auth.d.ts +34 -0
- package/dist/config.d.ts +109 -0
- package/dist/execution/console-capture.d.ts +35 -0
- package/dist/execution/dep-installer.d.ts +44 -0
- package/dist/execution/dep-packer.d.ts +25 -0
- package/dist/execution/dep-restore.d.ts +85 -0
- package/dist/execution/download.d.ts +29 -0
- package/dist/execution/dynamic-job-serializer.d.ts +51 -0
- package/dist/execution/hook-executor.d.ts +46 -0
- package/dist/execution/init-runner.d.ts +33 -0
- package/dist/execution/job-runner.d.ts +266 -0
- package/dist/execution/log-streamer.d.ts +126 -0
- package/dist/execution/npm-registry-config.d.ts +63 -0
- package/dist/execution/npm-resolver.d.ts +40 -0
- package/dist/execution/overlay-applier.d.ts +51 -0
- package/dist/execution/rule-evaluator.d.ts +11 -0
- package/dist/execution/sandbox/bare-metal-sandbox.d.ts +69 -0
- package/dist/execution/sandbox/container-sandbox.d.ts +100 -0
- package/dist/execution/sandbox/env-sanitizer.d.ts +43 -0
- package/dist/execution/sandbox/firecracker-sandbox.d.ts +65 -0
- package/dist/execution/sandbox/fork-runner.d.ts +94 -0
- package/dist/execution/sandbox/index.d.ts +14 -0
- package/dist/execution/sandbox/ipc-protocol.d.ts +311 -0
- package/dist/execution/sandbox/log-masker.d.ts +45 -0
- package/dist/execution/sandbox/secret-encryption.d.ts +37 -0
- package/dist/execution/sandbox/secret-merge.d.ts +18 -0
- package/dist/execution/sandbox/step-loop.d.ts +77 -0
- package/dist/execution/sandbox/types.d.ts +142 -0
- package/dist/execution/sandbox/workflow-runner.d.ts +17 -0
- package/dist/execution/source-packer.d.ts +18 -0
- package/dist/execution/source-restore.d.ts +23 -0
- package/dist/execution/timeout-util.d.ts +11 -0
- package/dist/execution/workflow-loader.d.ts +70 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +128 -0
- package/dist/metrics/metrics-reporter.d.ts +32 -0
- package/dist/metrics/prometheus.d.ts +95 -0
- package/dist/routes/health.d.ts +27 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +5347 -0
- package/dist/workflow-runner.js +2978 -0
- package/dist/ws/event-buffer.d.ts +16 -0
- package/dist/ws/log-buffer.d.ts +15 -0
- package/dist/ws/orchestrator-client.d.ts +269 -0
- package/package.json +59 -7
- package/sbom.spdx.json +10125 -0
- package/index.js +0 -3
|
@@ -0,0 +1,2978 @@
|
|
|
1
|
+
import { fileURLToPath as __cjs_fileURLToPath } from "node:url";
|
|
2
|
+
import { dirname as __cjs_dirname } from "node:path";
|
|
3
|
+
__cjs_dirname(__cjs_fileURLToPath(import.meta.url));
|
|
4
|
+
import { register } from "node:module";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import crypto, { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import fsPromises, { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
|
9
|
+
import os, { tmpdir } from "node:os";
|
|
10
|
+
import path, { dirname, join } from "node:path";
|
|
11
|
+
import { $ } from "zx";
|
|
12
|
+
import { createLogger, deriveSharedSecret, initZx, normalizeLineEndings, sha256, sha256File, toErrorMessage } from "@kici-dev/shared";
|
|
13
|
+
import { ExecutionJobStatus, ExecutionStepStatus } from "@kici-dev/engine";
|
|
14
|
+
import { buildKiciApi, createStepSecrets, evaluateRules, isDynamicJobFn, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
|
|
15
|
+
import "node:child_process";
|
|
16
|
+
import { Readable, Transform } from "node:stream";
|
|
17
|
+
import { pipeline } from "node:stream/promises";
|
|
18
|
+
import { createGunzip } from "node:zlib";
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
+
import { x } from "tar";
|
|
21
|
+
import https from "node:https";
|
|
22
|
+
import http from "node:http";
|
|
23
|
+
import.meta.url;
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/execution/sandbox/log-masker.ts
|
|
26
|
+
/**
|
|
27
|
+
* Secret value masking for log lines.
|
|
28
|
+
*
|
|
29
|
+
* Replaces all occurrences of registered secret values with '***' in log output.
|
|
30
|
+
* Used by the workflow runner to prevent secret leaks in IPC log messages.
|
|
31
|
+
*
|
|
32
|
+
* Performance: Builds a single combined regex from all secret values, so each
|
|
33
|
+
* log line is scanned in a single pass (not O(secrets * lines)).
|
|
34
|
+
*/
|
|
35
|
+
/** Minimum length for a secret value to be maskable (avoids false positives). */
|
|
36
|
+
const MIN_MASK_LENGTH = 3;
|
|
37
|
+
/**
|
|
38
|
+
* Escape regex special characters in a string.
|
|
39
|
+
*/
|
|
40
|
+
function escapeRegExp(s) {
|
|
41
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Masks secret values in log lines.
|
|
45
|
+
*
|
|
46
|
+
* Usage:
|
|
47
|
+
* ```ts
|
|
48
|
+
* const masker = new LogMasker();
|
|
49
|
+
* masker.registerSecrets({ TOKEN: 'abc123', SHORT: 'ab' });
|
|
50
|
+
* masker.mask('Token is abc123'); // 'Token is ***'
|
|
51
|
+
* // 'ab' is NOT masked (< 3 chars)
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
var LogMasker = class {
|
|
55
|
+
pattern = null;
|
|
56
|
+
/**
|
|
57
|
+
* Register secret values to be masked in log output.
|
|
58
|
+
*
|
|
59
|
+
* Values shorter than 3 characters are skipped to avoid false positives.
|
|
60
|
+
* Base64-encoded variants of each qualifying secret are also registered,
|
|
61
|
+
* preventing leaks when secrets appear base64-encoded in logs (e.g.,
|
|
62
|
+
* Authorization: Basic headers, base64-encoded config values).
|
|
63
|
+
* Values are sorted by length descending so longer values are matched first
|
|
64
|
+
* (prevents partial masking when one secret is a substring of another).
|
|
65
|
+
*/
|
|
66
|
+
registerSecrets(secrets) {
|
|
67
|
+
const seen = /* @__PURE__ */ new Set();
|
|
68
|
+
const values = [];
|
|
69
|
+
for (const value of Object.values(secrets)) if (value.length >= MIN_MASK_LENGTH && !seen.has(value)) {
|
|
70
|
+
seen.add(value);
|
|
71
|
+
values.push(value);
|
|
72
|
+
const b64 = Buffer.from(value).toString("base64");
|
|
73
|
+
if (b64.length >= MIN_MASK_LENGTH && !seen.has(b64)) {
|
|
74
|
+
seen.add(b64);
|
|
75
|
+
values.push(b64);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (values.length === 0) {
|
|
79
|
+
this.pattern = null;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
values.sort((a, b) => b.length - a.length);
|
|
83
|
+
this.pattern = new RegExp(values.map((v) => escapeRegExp(v)).join("|"), "g");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Mask all registered secret values in a log line.
|
|
87
|
+
*
|
|
88
|
+
* Returns the line unchanged if no secrets are registered.
|
|
89
|
+
*/
|
|
90
|
+
mask(line) {
|
|
91
|
+
if (!this.pattern) return line;
|
|
92
|
+
this.pattern.lastIndex = 0;
|
|
93
|
+
return line.replace(this.pattern, "***");
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Returns true if any maskable secrets are registered.
|
|
97
|
+
*/
|
|
98
|
+
hasSecrets() {
|
|
99
|
+
return this.pattern !== null;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/execution/sandbox/secret-merge.ts
|
|
104
|
+
/**
|
|
105
|
+
* Secret merging utilities for the workflow runner.
|
|
106
|
+
*
|
|
107
|
+
* Separated from workflow-runner.ts to allow unit testing without
|
|
108
|
+
* triggering the runner's top-level side effects (process handlers, main()).
|
|
109
|
+
*/
|
|
110
|
+
/**
|
|
111
|
+
* Merge orchestrator-level secrets with auto-flattened context keys.
|
|
112
|
+
*
|
|
113
|
+
* Precedence (last wins):
|
|
114
|
+
* 1. Orchestrator-level secrets (lowest)
|
|
115
|
+
* 2. Context-flattened keys in declaration order (each context's keys overlay previous)
|
|
116
|
+
*
|
|
117
|
+
* This means: context-flattened keys override orchestrator-level secrets,
|
|
118
|
+
* and for collisions between contexts, last declared context wins.
|
|
119
|
+
*/
|
|
120
|
+
function buildMergedFlatSecrets(orchestratorSecrets, namespacedSecrets) {
|
|
121
|
+
const merged = { ...orchestratorSecrets };
|
|
122
|
+
for (const contextSecrets of Object.values(namespacedSecrets)) Object.assign(merged, contextSecrets);
|
|
123
|
+
return merged;
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/execution/hook-executor.ts
|
|
127
|
+
/** Default hook timeout: 5 minutes */
|
|
128
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 300 * 1e3;
|
|
129
|
+
/**
|
|
130
|
+
* Build outcome metadata from execution state.
|
|
131
|
+
*
|
|
132
|
+
* Duration is calculated as elapsed time since startTime.
|
|
133
|
+
*/
|
|
134
|
+
function buildOutcomeMetadata(opts) {
|
|
135
|
+
return {
|
|
136
|
+
status: opts.status,
|
|
137
|
+
reason: opts.reason,
|
|
138
|
+
failedStep: opts.failedStep,
|
|
139
|
+
stepOutputs: opts.stepOutputs,
|
|
140
|
+
duration: Date.now() - opts.startTime
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Normalize a HookInput (bare function, { run, timeout }, or HookConfig) into a HookConfig.
|
|
145
|
+
*/
|
|
146
|
+
function normalizeHook(hook, hookType) {
|
|
147
|
+
if (typeof hook === "object" && "name" in hook && "type" in hook) return hook;
|
|
148
|
+
if (typeof hook === "function") return {
|
|
149
|
+
name: hookType,
|
|
150
|
+
type: hookType,
|
|
151
|
+
run: hook
|
|
152
|
+
};
|
|
153
|
+
if (typeof hook === "object" && "run" in hook) return {
|
|
154
|
+
name: hookType,
|
|
155
|
+
type: hookType,
|
|
156
|
+
run: hook.run,
|
|
157
|
+
timeout: hook.timeout
|
|
158
|
+
};
|
|
159
|
+
throw new Error(`Invalid hook input for ${hookType}`);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Execute a single hook with timeout enforcement and IPC reporting.
|
|
163
|
+
*
|
|
164
|
+
* Sends step.start and step.complete IPC messages with step_type = 'hook:{hookType}'.
|
|
165
|
+
* The hook runs in the same sandbox context as regular steps.
|
|
166
|
+
*/
|
|
167
|
+
async function executeHook(opts) {
|
|
168
|
+
const { stepContext, outcome, hookType, stepIndex, sendIpc } = opts;
|
|
169
|
+
const normalized = normalizeHook(opts.hook, hookType);
|
|
170
|
+
const timeoutMs = normalized.timeout ?? opts.timeout ?? DEFAULT_HOOK_TIMEOUT_MS;
|
|
171
|
+
sendIpc({
|
|
172
|
+
type: "step.start",
|
|
173
|
+
stepIndex,
|
|
174
|
+
stepName: normalized.name,
|
|
175
|
+
step_type: `hook:${hookType}`
|
|
176
|
+
});
|
|
177
|
+
const startTime = Date.now();
|
|
178
|
+
const mergedCtx = {
|
|
179
|
+
...stepContext,
|
|
180
|
+
outcome
|
|
181
|
+
};
|
|
182
|
+
const abortController = new AbortController();
|
|
183
|
+
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
|
184
|
+
try {
|
|
185
|
+
await Promise.race([normalized.run(mergedCtx), new Promise((_, reject) => {
|
|
186
|
+
abortController.signal.addEventListener("abort", () => {
|
|
187
|
+
reject(/* @__PURE__ */ new Error(`Hook '${normalized.name}' timed out after ${timeoutMs}ms`));
|
|
188
|
+
});
|
|
189
|
+
})]);
|
|
190
|
+
clearTimeout(timeoutId);
|
|
191
|
+
sendIpc({
|
|
192
|
+
type: "step.complete",
|
|
193
|
+
stepIndex,
|
|
194
|
+
status: "success",
|
|
195
|
+
durationMs: Date.now() - startTime,
|
|
196
|
+
step_type: `hook:${hookType}`
|
|
197
|
+
});
|
|
198
|
+
return { success: true };
|
|
199
|
+
} catch (e) {
|
|
200
|
+
clearTimeout(timeoutId);
|
|
201
|
+
const durationMs = Date.now() - startTime;
|
|
202
|
+
const error = toErrorMessage(e);
|
|
203
|
+
sendIpc({
|
|
204
|
+
type: "step.complete",
|
|
205
|
+
stepIndex,
|
|
206
|
+
status: "failed",
|
|
207
|
+
durationMs,
|
|
208
|
+
error: { message: error },
|
|
209
|
+
step_type: `hook:${hookType}`
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
success: false,
|
|
213
|
+
error
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/execution/rule-evaluator.ts
|
|
219
|
+
initZx();
|
|
220
|
+
/**
|
|
221
|
+
* Create RuleContext for agent-side rule evaluation.
|
|
222
|
+
*
|
|
223
|
+
* @param event - Event payload from the dispatch message
|
|
224
|
+
* @param changedFiles - List of files changed in this event
|
|
225
|
+
* @param env - Merged environment variables
|
|
226
|
+
*/
|
|
227
|
+
function createRuleContext(event, changedFiles = [], env = {}) {
|
|
228
|
+
return {
|
|
229
|
+
event,
|
|
230
|
+
changedFiles,
|
|
231
|
+
env,
|
|
232
|
+
$
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/execution/sandbox/step-loop.ts
|
|
237
|
+
/**
|
|
238
|
+
* Execute a single step with timeout enforcement.
|
|
239
|
+
*
|
|
240
|
+
* Timeout pattern using Promise.race + AbortController, with IPC status reporting.
|
|
241
|
+
*/
|
|
242
|
+
async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, outputsMap, getSecretsAccessLog, getSecretMountRecords) {
|
|
243
|
+
sendFn({
|
|
244
|
+
type: "step.start",
|
|
245
|
+
stepIndex,
|
|
246
|
+
stepName: step.name
|
|
247
|
+
});
|
|
248
|
+
const startTime = Date.now();
|
|
249
|
+
const abortController = new AbortController();
|
|
250
|
+
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
|
251
|
+
try {
|
|
252
|
+
const result = await Promise.race([step.run(ctx), new Promise((_, reject) => {
|
|
253
|
+
abortController.signal.addEventListener("abort", () => {
|
|
254
|
+
reject(/* @__PURE__ */ new Error(`Step '${step.name}' timed out after ${timeoutMs}ms`));
|
|
255
|
+
});
|
|
256
|
+
})]);
|
|
257
|
+
clearTimeout(timeoutId);
|
|
258
|
+
const durationMs = Date.now() - startTime;
|
|
259
|
+
const outputsPayload = result != null ? result : void 0;
|
|
260
|
+
if (outputsPayload) outputsMap.set(step.name, outputsPayload);
|
|
261
|
+
const secretsAccessed = getSecretsAccessLog?.();
|
|
262
|
+
emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
|
|
263
|
+
sendFn({
|
|
264
|
+
type: "step.complete",
|
|
265
|
+
stepIndex,
|
|
266
|
+
status: ExecutionStepStatus.enum.success,
|
|
267
|
+
durationMs,
|
|
268
|
+
...outputsPayload && { outputs: outputsPayload },
|
|
269
|
+
...secretsAccessed !== void 0 && { secretsAccessed }
|
|
270
|
+
});
|
|
271
|
+
return {
|
|
272
|
+
name: step.name,
|
|
273
|
+
stepIndex,
|
|
274
|
+
status: ExecutionStepStatus.enum.success,
|
|
275
|
+
durationMs,
|
|
276
|
+
...outputsPayload && { outputs: outputsPayload }
|
|
277
|
+
};
|
|
278
|
+
} catch (e) {
|
|
279
|
+
clearTimeout(timeoutId);
|
|
280
|
+
const durationMs = Date.now() - startTime;
|
|
281
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
282
|
+
const exitCode = extractExitCode(e);
|
|
283
|
+
const signal = extractSignal(e);
|
|
284
|
+
const secretsAccessed = getSecretsAccessLog?.();
|
|
285
|
+
emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
|
|
286
|
+
sendFn({
|
|
287
|
+
type: "step.complete",
|
|
288
|
+
stepIndex,
|
|
289
|
+
status: ExecutionStepStatus.enum.failed,
|
|
290
|
+
durationMs,
|
|
291
|
+
error: {
|
|
292
|
+
message: error.message,
|
|
293
|
+
...exitCode !== void 0 && { exitCode },
|
|
294
|
+
...signal !== void 0 && { signal }
|
|
295
|
+
},
|
|
296
|
+
...secretsAccessed !== void 0 && { secretsAccessed }
|
|
297
|
+
});
|
|
298
|
+
return {
|
|
299
|
+
name: step.name,
|
|
300
|
+
stepIndex,
|
|
301
|
+
status: ExecutionStepStatus.enum.failed,
|
|
302
|
+
durationMs,
|
|
303
|
+
error: {
|
|
304
|
+
message: error.message,
|
|
305
|
+
...exitCode !== void 0 && { exitCode },
|
|
306
|
+
...signal !== void 0 && { signal }
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Emit one `step.secret_mount` IPC event per `mountFile` / `exposeFile` call
|
|
313
|
+
* the step performed. Called from both the success and failure paths so the
|
|
314
|
+
* orchestrator's audit trail records every mount regardless of step outcome.
|
|
315
|
+
*/
|
|
316
|
+
function emitSecretMountEvents(records, stepIndex, sendFn) {
|
|
317
|
+
if (!records || records.length === 0) return;
|
|
318
|
+
for (const record of records) sendFn({
|
|
319
|
+
type: "step.secret_mount",
|
|
320
|
+
stepIndex,
|
|
321
|
+
sources: record.sources,
|
|
322
|
+
target: record.target,
|
|
323
|
+
kind: record.kind,
|
|
324
|
+
...record.envVar !== void 0 && { envVar: record.envVar }
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
function extractExitCode(error) {
|
|
328
|
+
if (error && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number") return error.exitCode;
|
|
329
|
+
}
|
|
330
|
+
function extractSignal(error) {
|
|
331
|
+
if (error && typeof error === "object" && "signal" in error && typeof error.signal === "string") return error.signal;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Evaluate step-level rules. Returns a 'skipped' result + emits IPC when a rule
|
|
335
|
+
* fails; returns null when the step should run normally.
|
|
336
|
+
*/
|
|
337
|
+
async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
|
|
338
|
+
if (!step.rules || step.rules.length === 0) return null;
|
|
339
|
+
const ruleCtx = createRuleContext(opts.event, [], opts.env);
|
|
340
|
+
const ruleResult = await evaluateRules(step.rules, ruleCtx, step.name);
|
|
341
|
+
if (ruleResult.allPassed) return null;
|
|
342
|
+
opts.sendIpc({
|
|
343
|
+
type: "step.start",
|
|
344
|
+
stepIndex,
|
|
345
|
+
stepName: step.name
|
|
346
|
+
});
|
|
347
|
+
opts.sendIpc({
|
|
348
|
+
type: "step.complete",
|
|
349
|
+
stepIndex,
|
|
350
|
+
status: ExecutionStepStatus.enum.failed,
|
|
351
|
+
durationMs: 0
|
|
352
|
+
});
|
|
353
|
+
opts.sendIpc({
|
|
354
|
+
type: "log.line",
|
|
355
|
+
stepIndex,
|
|
356
|
+
line: `[kici] Step '${step.name}' skipped: rule '${ruleResult.results.find((r) => !r.passed)?.label}' did not pass`
|
|
357
|
+
});
|
|
358
|
+
return {
|
|
359
|
+
name: step.name,
|
|
360
|
+
stepIndex,
|
|
361
|
+
status: ExecutionStepStatus.enum.skipped,
|
|
362
|
+
durationMs: 0
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Run a single observer hook (beforeStep / afterStep). Failures only emit a
|
|
367
|
+
* log line — they never change job status. Centralises the per-call boilerplate
|
|
368
|
+
* so the per-step body can stay flat.
|
|
369
|
+
*/
|
|
370
|
+
async function runObserverHook(args) {
|
|
371
|
+
const { hook, hookType, step, stepIndex, hookStepIndex, failedStep, opts } = args;
|
|
372
|
+
const outcome = buildOutcomeMetadata({
|
|
373
|
+
status: failedStep !== void 0 ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
|
|
374
|
+
stepOutputs: Object.fromEntries(opts.outputsMap),
|
|
375
|
+
startTime: opts.startTime ?? Date.now(),
|
|
376
|
+
...failedStep !== void 0 && { failedStep }
|
|
377
|
+
});
|
|
378
|
+
const hookResult = await executeHook({
|
|
379
|
+
hook,
|
|
380
|
+
stepContext: opts.createStepContext(stepIndex, step.name),
|
|
381
|
+
outcome,
|
|
382
|
+
hookType,
|
|
383
|
+
stepIndex: hookStepIndex,
|
|
384
|
+
sendIpc: opts.sendIpc,
|
|
385
|
+
timeout: 3e5
|
|
386
|
+
});
|
|
387
|
+
if (!hookResult.success) opts.sendIpc({
|
|
388
|
+
type: "log.line",
|
|
389
|
+
stepIndex,
|
|
390
|
+
line: `[kici] ${hookType} hook failed: ${hookResult.error} (continuing -- hooks are observers)`
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Execute one iteration of the step loop: step rules → beforeStep → execute →
|
|
395
|
+
* afterStep → failure-handling. Returns a typed outcome the loop uses to
|
|
396
|
+
* accumulate results, decide whether to break, and remember the failed step.
|
|
397
|
+
*
|
|
398
|
+
* Wraps the per-step lifecycle in a `try / finally` that calls
|
|
399
|
+
* `opts.disposeStepResources()` so per-step state (the
|
|
400
|
+
* `ctx.secrets.mountFile` tmpdir, any env vars set via `exposeFile`) is
|
|
401
|
+
* removed even when the step throws, times out, or rule-skips.
|
|
402
|
+
*/
|
|
403
|
+
async function runStepIteration(step, stepIndex, opts) {
|
|
404
|
+
const skippedResult = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
|
|
405
|
+
if (skippedResult) {
|
|
406
|
+
await opts.disposeStepResources?.();
|
|
407
|
+
return {
|
|
408
|
+
result: skippedResult,
|
|
409
|
+
shouldBreak: false
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (opts.jobHooks?.beforeStep) await runObserverHook({
|
|
413
|
+
hook: opts.jobHooks.beforeStep,
|
|
414
|
+
hookType: "beforeStep",
|
|
415
|
+
step,
|
|
416
|
+
stepIndex,
|
|
417
|
+
hookStepIndex: opts.steps.length + stepIndex * 2,
|
|
418
|
+
opts
|
|
419
|
+
});
|
|
420
|
+
try {
|
|
421
|
+
const result = await executeStepInLoop(step, stepIndex, opts.createStepContext(stepIndex, step.name), step.timeout ?? opts.defaultTimeoutMs, opts.sendIpc, opts.outputsMap, opts.getSecretsAccessLog, opts.getSecretMountRecords);
|
|
422
|
+
if (opts.jobHooks?.afterStep) await runObserverHook({
|
|
423
|
+
hook: opts.jobHooks.afterStep,
|
|
424
|
+
hookType: "afterStep",
|
|
425
|
+
step,
|
|
426
|
+
stepIndex,
|
|
427
|
+
hookStepIndex: opts.steps.length + stepIndex * 2 + 1,
|
|
428
|
+
failedStep: result.status === ExecutionStepStatus.enum.failed ? step.name : void 0,
|
|
429
|
+
opts
|
|
430
|
+
});
|
|
431
|
+
if (result.status === ExecutionStepStatus.enum.failed) return {
|
|
432
|
+
result,
|
|
433
|
+
shouldBreak: !step.continueOnError,
|
|
434
|
+
failedStepName: step.name
|
|
435
|
+
};
|
|
436
|
+
return {
|
|
437
|
+
result,
|
|
438
|
+
shouldBreak: false
|
|
439
|
+
};
|
|
440
|
+
} finally {
|
|
441
|
+
await opts.disposeStepResources?.();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Execute one named completion hook (onSuccess / onFailure / cleanup) and
|
|
446
|
+
* return the updated `CompletionState`. Treated as the single source of truth
|
|
447
|
+
* for the "promote to failed + concat reason" pattern that the three completion
|
|
448
|
+
* hooks share.
|
|
449
|
+
*/
|
|
450
|
+
async function runCompletionHook(args) {
|
|
451
|
+
const { hook, hookType, hookStepIndex, outcome, state, promoteToFailed, opts } = args;
|
|
452
|
+
opts.sendIpc({
|
|
453
|
+
type: "log.line",
|
|
454
|
+
stepIndex: -1,
|
|
455
|
+
line: `[kici] Running ${hookType} hook...`
|
|
456
|
+
});
|
|
457
|
+
const hookResult = await executeHook({
|
|
458
|
+
hook,
|
|
459
|
+
stepContext: opts.createStepContext(hookStepIndex, hookType),
|
|
460
|
+
outcome,
|
|
461
|
+
hookType,
|
|
462
|
+
stepIndex: hookStepIndex,
|
|
463
|
+
sendIpc: opts.sendIpc
|
|
464
|
+
});
|
|
465
|
+
if (hookResult.success) {
|
|
466
|
+
opts.sendIpc({
|
|
467
|
+
type: "log.line",
|
|
468
|
+
stepIndex: -1,
|
|
469
|
+
line: `[kici] ${hookType} hook completed`
|
|
470
|
+
});
|
|
471
|
+
return state;
|
|
472
|
+
}
|
|
473
|
+
opts.sendIpc({
|
|
474
|
+
type: "log.line",
|
|
475
|
+
stepIndex: -1,
|
|
476
|
+
line: `[kici] ${hookType} hook failed: ${hookResult.error}`
|
|
477
|
+
});
|
|
478
|
+
const reasonFragment = `Hook ${hookType} failed: ${hookResult.error}`;
|
|
479
|
+
return {
|
|
480
|
+
failed: state.failed || promoteToFailed,
|
|
481
|
+
failedStepName: state.failedStepName,
|
|
482
|
+
failureReason: state.failureReason ? `${state.failureReason}; ${reasonFragment}` : reasonFragment
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Run the job-completion hook sequence after the per-step loop ends:
|
|
487
|
+
* onSuccess (or onFailure), then cleanup (always). The cleanup outcome is
|
|
488
|
+
* recomputed so it reflects any failures introduced by onSuccess/onFailure.
|
|
489
|
+
*/
|
|
490
|
+
async function runJobCompletionHooks(opts, initial, outputsMap, startTime) {
|
|
491
|
+
const { jobHooks } = opts;
|
|
492
|
+
const initialFinalStatus = initial.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success;
|
|
493
|
+
const jobOutcome = buildOutcomeMetadata({
|
|
494
|
+
status: initialFinalStatus,
|
|
495
|
+
stepOutputs: Object.fromEntries(outputsMap),
|
|
496
|
+
startTime,
|
|
497
|
+
...initial.failedStepName && {
|
|
498
|
+
failedStep: initial.failedStepName,
|
|
499
|
+
reason: `Step '${initial.failedStepName}' failed`
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
let state = initial;
|
|
503
|
+
let hookStepIndex = opts.steps.length;
|
|
504
|
+
if (initialFinalStatus === ExecutionStepStatus.enum.success && jobHooks?.onSuccess) {
|
|
505
|
+
state = await runCompletionHook({
|
|
506
|
+
hook: jobHooks.onSuccess,
|
|
507
|
+
hookType: "onSuccess",
|
|
508
|
+
hookStepIndex,
|
|
509
|
+
outcome: jobOutcome,
|
|
510
|
+
state,
|
|
511
|
+
promoteToFailed: true,
|
|
512
|
+
opts
|
|
513
|
+
});
|
|
514
|
+
hookStepIndex++;
|
|
515
|
+
} else if (initialFinalStatus === ExecutionStepStatus.enum.failed && jobHooks?.onFailure) {
|
|
516
|
+
state = await runCompletionHook({
|
|
517
|
+
hook: jobHooks.onFailure,
|
|
518
|
+
hookType: "onFailure",
|
|
519
|
+
hookStepIndex,
|
|
520
|
+
outcome: jobOutcome,
|
|
521
|
+
state,
|
|
522
|
+
promoteToFailed: false,
|
|
523
|
+
opts
|
|
524
|
+
});
|
|
525
|
+
hookStepIndex++;
|
|
526
|
+
}
|
|
527
|
+
if (jobHooks?.cleanup) {
|
|
528
|
+
const cleanupOutcome = buildOutcomeMetadata({
|
|
529
|
+
status: state.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
|
|
530
|
+
stepOutputs: Object.fromEntries(outputsMap),
|
|
531
|
+
startTime,
|
|
532
|
+
...state.failedStepName && { failedStep: state.failedStepName },
|
|
533
|
+
...state.failureReason && { reason: state.failureReason }
|
|
534
|
+
});
|
|
535
|
+
state = await runCompletionHook({
|
|
536
|
+
hook: jobHooks.cleanup,
|
|
537
|
+
hookType: "cleanup",
|
|
538
|
+
hookStepIndex,
|
|
539
|
+
outcome: cleanupOutcome,
|
|
540
|
+
state,
|
|
541
|
+
promoteToFailed: true,
|
|
542
|
+
opts
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
return state;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Execute the step loop with hook integration and step-level rule evaluation.
|
|
549
|
+
*
|
|
550
|
+
* Hook execution order:
|
|
551
|
+
* - beforeStep -> step -> afterStep (per step)
|
|
552
|
+
* - onSuccess or onFailure (after all steps)
|
|
553
|
+
* - cleanup (always, after onSuccess/onFailure)
|
|
554
|
+
*
|
|
555
|
+
* Hooks are observers: beforeStep/afterStep failures do NOT affect step execution.
|
|
556
|
+
* Only completion hooks (onSuccess/onFailure/cleanup) can change job status to failed.
|
|
557
|
+
*/
|
|
558
|
+
async function executeStepLoop(opts) {
|
|
559
|
+
const startTime = opts.startTime ?? Date.now();
|
|
560
|
+
const stepResults = [];
|
|
561
|
+
const state = { failed: false };
|
|
562
|
+
for (const [i, step] of opts.steps.entries()) {
|
|
563
|
+
if (opts.isAborted?.()) break;
|
|
564
|
+
const outcome = await runStepIteration(step, i, opts);
|
|
565
|
+
stepResults.push(outcome.result);
|
|
566
|
+
if (outcome.failedStepName) {
|
|
567
|
+
state.failed = true;
|
|
568
|
+
state.failedStepName = outcome.failedStepName;
|
|
569
|
+
}
|
|
570
|
+
if (outcome.shouldBreak) break;
|
|
571
|
+
}
|
|
572
|
+
if (opts.isAborted?.()) return {
|
|
573
|
+
status: "aborted",
|
|
574
|
+
stepResults,
|
|
575
|
+
failureReason: state.failureReason ?? (state.failed ? `Step '${state.failedStepName}' failed` : void 0)
|
|
576
|
+
};
|
|
577
|
+
const finalState = await runJobCompletionHooks(opts, state, opts.outputsMap, startTime);
|
|
578
|
+
return {
|
|
579
|
+
status: finalState.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
|
|
580
|
+
stepResults,
|
|
581
|
+
failureReason: finalState.failureReason
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
//#endregion
|
|
585
|
+
//#region src/checkout/ssh-auth.ts
|
|
586
|
+
/**
|
|
587
|
+
* Materialize an SSH private key (and optional pinned known_hosts) into a
|
|
588
|
+
* tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
|
|
589
|
+
*
|
|
590
|
+
* Permissions:
|
|
591
|
+
* - private key mode 0o600 (required by OpenSSH — refuses to use world-
|
|
592
|
+
* readable keys).
|
|
593
|
+
* - known_hosts mode 0o600.
|
|
594
|
+
* - tempdir mode 0o700.
|
|
595
|
+
*
|
|
596
|
+
* SSH flags composed:
|
|
597
|
+
* - `-i <keyfile>` — identity file.
|
|
598
|
+
* - `-o IdentitiesOnly=yes` — don't try other keys from ssh-agent / ~/.ssh.
|
|
599
|
+
* - `-o BatchMode=yes` — never prompt for passwords / passphrases.
|
|
600
|
+
* - host-key checking flags based on `hostKeyPolicy`.
|
|
601
|
+
*/
|
|
602
|
+
async function setupSshAuth(opts) {
|
|
603
|
+
if (opts.hostKeyPolicy === "pinned" && !opts.knownHosts) throw new Error("pinned hostKeyPolicy requires knownHosts content");
|
|
604
|
+
const tempDir = await mkdtemp(join(tmpdir(), "kici-ssh-"));
|
|
605
|
+
const keyPath = join(tempDir, "id");
|
|
606
|
+
await writeFile(keyPath, opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`, { mode: 384 });
|
|
607
|
+
const knownHostsPath = join(tempDir, "known_hosts");
|
|
608
|
+
await writeFile(knownHostsPath, opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "", { mode: 384 });
|
|
609
|
+
const parts = [
|
|
610
|
+
"ssh",
|
|
611
|
+
"-i",
|
|
612
|
+
escapeShellArg(keyPath),
|
|
613
|
+
"-o",
|
|
614
|
+
"IdentitiesOnly=yes",
|
|
615
|
+
"-o",
|
|
616
|
+
"BatchMode=yes",
|
|
617
|
+
"-o",
|
|
618
|
+
`UserKnownHostsFile=${escapeShellArg(knownHostsPath)}`
|
|
619
|
+
];
|
|
620
|
+
if (opts.hostKeyPolicy === "pinned") parts.push("-o", "StrictHostKeyChecking=yes");
|
|
621
|
+
else parts.push("-o", "StrictHostKeyChecking=accept-new");
|
|
622
|
+
return {
|
|
623
|
+
gitSshCommand: parts.join(" "),
|
|
624
|
+
tempDir,
|
|
625
|
+
async cleanup() {
|
|
626
|
+
await rm(tempDir, {
|
|
627
|
+
recursive: true,
|
|
628
|
+
force: true
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Quote a path for inclusion in `GIT_SSH_COMMAND`. We use single-quote
|
|
635
|
+
* wrapping so backslashes and spaces survive git's shell-parse of the
|
|
636
|
+
* command value.
|
|
637
|
+
*/
|
|
638
|
+
function escapeShellArg(value) {
|
|
639
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
640
|
+
}
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region src/checkout/git-clone.ts
|
|
643
|
+
/**
|
|
644
|
+
* Strip auth credentials from git error messages to prevent token leakage.
|
|
645
|
+
* Node's execFileSync includes the full command line (including -c http.extraHeader
|
|
646
|
+
* and GIT_SSH_COMMAND flags) in error messages, which would expose Base64-encoded
|
|
647
|
+
* tokens or the absolute path of a temporary SSH key file in logs.
|
|
648
|
+
*/
|
|
649
|
+
function sanitizeGitError(error) {
|
|
650
|
+
if (!(error instanceof Error)) return new Error(String(error));
|
|
651
|
+
const sanitized = new Error(redactSensitive(error.message));
|
|
652
|
+
sanitized.stack = error.stack ? redactSensitive(error.stack) : void 0;
|
|
653
|
+
return sanitized;
|
|
654
|
+
}
|
|
655
|
+
function redactSensitive(input) {
|
|
656
|
+
return input.replace(/http\.extraHeader=Authorization: Basic \S+/g, "http.extraHeader=Authorization: Basic [REDACTED]").replace(/'[^']*kici-ssh-[^']*'/g, "'[REDACTED_SSH_PATH]'").replace(/\S*kici-ssh-\S+/g, "[REDACTED_SSH_PATH]");
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Shallow-clone a git repository at a specific ref with optional token auth.
|
|
660
|
+
*
|
|
661
|
+
* Token authentication uses git's `-c http.extraHeader` mechanism which keeps
|
|
662
|
+
* the token out of the clone URL (not visible in `git remote -v` or logs).
|
|
663
|
+
*
|
|
664
|
+
* After clone, verifies that HEAD matches the expected SHA to prevent
|
|
665
|
+
* wrong-ref execution.
|
|
666
|
+
*
|
|
667
|
+
* @throws Error if clone fails or SHA does not match
|
|
668
|
+
*/
|
|
669
|
+
async function gitClone(options) {
|
|
670
|
+
const { repoUrl, ref, sha, workDir, token, gitAuth, depth = 1 } = options;
|
|
671
|
+
const auth = gitAuth ? gitAuth : token ? {
|
|
672
|
+
kind: "basic",
|
|
673
|
+
user: "x-access-token",
|
|
674
|
+
secret: token
|
|
675
|
+
} : void 0;
|
|
676
|
+
const args = [];
|
|
677
|
+
const envEntries = {};
|
|
678
|
+
let needsCustomEnv = false;
|
|
679
|
+
let safeDirCleanup;
|
|
680
|
+
if (repoUrl.startsWith("file://")) {
|
|
681
|
+
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
|
682
|
+
const { tmpdir } = await import("node:os");
|
|
683
|
+
const path = await import("node:path");
|
|
684
|
+
const dir = await mkdtemp(path.join(tmpdir(), "kici-gitcfg-"));
|
|
685
|
+
const cfgPath = path.join(dir, "config");
|
|
686
|
+
await writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
|
|
687
|
+
envEntries.GIT_CONFIG_GLOBAL = cfgPath;
|
|
688
|
+
needsCustomEnv = true;
|
|
689
|
+
safeDirCleanup = async () => {
|
|
690
|
+
await rm(dir, {
|
|
691
|
+
recursive: true,
|
|
692
|
+
force: true
|
|
693
|
+
}).catch(() => {});
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
let sshSetup;
|
|
697
|
+
try {
|
|
698
|
+
if (auth?.kind === "basic") {
|
|
699
|
+
const user = auth.user ?? "x-access-token";
|
|
700
|
+
const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
|
|
701
|
+
args.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
|
|
702
|
+
} else if (auth?.kind === "ssh") {
|
|
703
|
+
sshSetup = await setupSshAuth({
|
|
704
|
+
privateKey: auth.secret,
|
|
705
|
+
hostKeyPolicy: auth.sshHostKeyPolicy,
|
|
706
|
+
knownHosts: auth.sshKnownHostsPem
|
|
707
|
+
});
|
|
708
|
+
envEntries.GIT_SSH_COMMAND = sshSetup.gitSshCommand;
|
|
709
|
+
needsCustomEnv = true;
|
|
710
|
+
}
|
|
711
|
+
const env = needsCustomEnv ? {
|
|
712
|
+
...process.env,
|
|
713
|
+
...envEntries
|
|
714
|
+
} : void 0;
|
|
715
|
+
if (ref) args.push("clone", "--depth", String(depth), "--branch", ref, repoUrl, workDir);
|
|
716
|
+
else args.push("clone", "--depth", String(depth), repoUrl, workDir);
|
|
717
|
+
const { execFileSync } = await import("node:child_process");
|
|
718
|
+
try {
|
|
719
|
+
execFileSync("git", args, {
|
|
720
|
+
stdio: "pipe",
|
|
721
|
+
timeout: 12e4,
|
|
722
|
+
...env && { env }
|
|
723
|
+
});
|
|
724
|
+
} catch (err) {
|
|
725
|
+
throw sanitizeGitError(err);
|
|
726
|
+
}
|
|
727
|
+
if (!sha || sha === "HEAD") return;
|
|
728
|
+
const envOpts = env ? { env } : {};
|
|
729
|
+
if (!execFileSync("git", [
|
|
730
|
+
"-C",
|
|
731
|
+
workDir,
|
|
732
|
+
"rev-parse",
|
|
733
|
+
"HEAD"
|
|
734
|
+
], {
|
|
735
|
+
encoding: "utf-8",
|
|
736
|
+
timeout: 1e4,
|
|
737
|
+
...envOpts
|
|
738
|
+
}).trim().startsWith(sha)) {
|
|
739
|
+
const fetchArgs = [];
|
|
740
|
+
if (auth?.kind === "basic") {
|
|
741
|
+
const user = auth.user ?? "x-access-token";
|
|
742
|
+
const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
|
|
743
|
+
fetchArgs.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
|
|
744
|
+
}
|
|
745
|
+
fetchArgs.push("fetch", "--depth", "50", "origin", sha);
|
|
746
|
+
try {
|
|
747
|
+
execFileSync("git", [
|
|
748
|
+
"-C",
|
|
749
|
+
workDir,
|
|
750
|
+
...fetchArgs
|
|
751
|
+
], {
|
|
752
|
+
stdio: "pipe",
|
|
753
|
+
timeout: 12e4,
|
|
754
|
+
...envOpts
|
|
755
|
+
});
|
|
756
|
+
} catch (err) {
|
|
757
|
+
throw sanitizeGitError(err);
|
|
758
|
+
}
|
|
759
|
+
execFileSync("git", [
|
|
760
|
+
"-C",
|
|
761
|
+
workDir,
|
|
762
|
+
"checkout",
|
|
763
|
+
sha
|
|
764
|
+
], {
|
|
765
|
+
stdio: "pipe",
|
|
766
|
+
timeout: 3e4,
|
|
767
|
+
...envOpts
|
|
768
|
+
});
|
|
769
|
+
const recheckedSha = execFileSync("git", [
|
|
770
|
+
"-C",
|
|
771
|
+
workDir,
|
|
772
|
+
"rev-parse",
|
|
773
|
+
"HEAD"
|
|
774
|
+
], {
|
|
775
|
+
encoding: "utf-8",
|
|
776
|
+
timeout: 1e4,
|
|
777
|
+
...envOpts
|
|
778
|
+
}).trim();
|
|
779
|
+
if (!recheckedSha.startsWith(sha)) throw new Error(`SHA mismatch: expected ${sha}, got ${recheckedSha}`);
|
|
780
|
+
}
|
|
781
|
+
} finally {
|
|
782
|
+
if (sshSetup) await sshSetup.cleanup().catch(() => {});
|
|
783
|
+
if (safeDirCleanup) await safeDirCleanup();
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
//#endregion
|
|
787
|
+
//#region src/execution/dep-restore.ts
|
|
788
|
+
/**
|
|
789
|
+
* Dependency restoration from cached tarballs.
|
|
790
|
+
*
|
|
791
|
+
* Downloads a pre-built dependency tarball, verifies SHA-256 integrity,
|
|
792
|
+
* and extracts to .kici/node_modules/ in the work directory.
|
|
793
|
+
*
|
|
794
|
+
* HTTP/HTTPS downloads use a streaming pipeline (response -> hash transform ->
|
|
795
|
+
* gunzip -> tar extract) to avoid buffering entire tarballs in memory.
|
|
796
|
+
* file:// URLs use a buffer-based approach (local, no streaming benefit).
|
|
797
|
+
*
|
|
798
|
+
* Streaming downloads have a 5-minute timeout and up to 2 retries.
|
|
799
|
+
*/
|
|
800
|
+
const logger$3 = createLogger({ prefix: "dep-restore" });
|
|
801
|
+
/** Download timeout: 5 minutes. */
|
|
802
|
+
const DOWNLOAD_TIMEOUT_MS$1 = 300 * 1e3;
|
|
803
|
+
/**
|
|
804
|
+
* Compute SHA-256 hash of a buffer.
|
|
805
|
+
*/
|
|
806
|
+
function computeHash(data) {
|
|
807
|
+
return sha256(data);
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Extract a gzip tarball from a buffer into the target directory.
|
|
811
|
+
* Used for file:// URLs where streaming provides no benefit.
|
|
812
|
+
*/
|
|
813
|
+
async function extractTarball(data, targetDir) {
|
|
814
|
+
await mkdir(targetDir, { recursive: true });
|
|
815
|
+
const readable = Readable.from(data);
|
|
816
|
+
await new Promise((resolve, reject) => {
|
|
817
|
+
readable.pipe(x({
|
|
818
|
+
cwd: targetDir,
|
|
819
|
+
gzip: true
|
|
820
|
+
})).on("finish", resolve).on("error", reject);
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Stream download and extract an HTTP/HTTPS tarball.
|
|
825
|
+
*
|
|
826
|
+
* Computes SHA-256 hash on the fly via a Transform stream.
|
|
827
|
+
* Returns the computed hash of the compressed tarball data.
|
|
828
|
+
*/
|
|
829
|
+
async function streamFetchAndExtract(url, targetDir) {
|
|
830
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS$1) });
|
|
831
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
832
|
+
if (!response.body) throw new Error("No response body");
|
|
833
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
834
|
+
const hash = createHash("sha256");
|
|
835
|
+
const hashTransform = new Transform({ transform(chunk, _encoding, callback) {
|
|
836
|
+
hash.update(chunk);
|
|
837
|
+
callback(null, chunk);
|
|
838
|
+
} });
|
|
839
|
+
await mkdir(targetDir, { recursive: true });
|
|
840
|
+
await pipeline(nodeStream, hashTransform, createGunzip(), x({ cwd: targetDir }));
|
|
841
|
+
return hash.digest("hex");
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Path-relative-to-`.kici/` glob that matches every scratch dir
|
|
845
|
+
* `extractIntoScratch` may create. Surfaced so the clone phase can register
|
|
846
|
+
* it in `.git/info/exclude` (see `excludeScratchFromGit`) — keeping the glob
|
|
847
|
+
* and the exclude rule in the same file means future renames of the scratch
|
|
848
|
+
* dir prefix can't fall out of sync with the git-ignore wiring.
|
|
849
|
+
*/
|
|
850
|
+
const SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
|
|
851
|
+
/**
|
|
852
|
+
* Glob suitable for `.gitignore` / `.git/info/exclude` that matches every
|
|
853
|
+
* scratch dir created by `extractIntoScratch`, anchored to the workflow
|
|
854
|
+
* working tree's `.kici/` subdir.
|
|
855
|
+
*/
|
|
856
|
+
const SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
|
|
857
|
+
/**
|
|
858
|
+
* Extract the dep tarball into a per-attempt scratch dir so retries never race
|
|
859
|
+
* with still-draining I/O from a previous failed attempt.
|
|
860
|
+
*
|
|
861
|
+
* When `pipeline()` rejects on a network error or AbortSignal timeout, the
|
|
862
|
+
* underlying `tar.x` continues flushing pending file writes for an unbounded
|
|
863
|
+
* window after the promise settles — `pipeline` does not block on async
|
|
864
|
+
* filesystem side effects. If the next retry then runs `rm -rf` on the same
|
|
865
|
+
* `node_modules/`, the walk races with those writes and `rmdir` fails with
|
|
866
|
+
* ENOTEMPTY (new files keep appearing under a directory we just emptied).
|
|
867
|
+
*
|
|
868
|
+
* We sidestep the race entirely by extracting each attempt into a unique
|
|
869
|
+
* scratch dir under `.kici/`. Failed attempts leave orphan scratch dirs whose
|
|
870
|
+
* draining writes are harmless — the next attempt does not touch them. On
|
|
871
|
+
* success we `fs.rename` `${scratchDir}/node_modules` into the final location
|
|
872
|
+
* (atomic on the same filesystem), then best-effort clean the scratch dir.
|
|
873
|
+
*
|
|
874
|
+
* Scratch dirs land inside the customer's cloned working tree, so the clone
|
|
875
|
+
* phase registers `SCRATCH_DIR_GIT_EXCLUDE_GLOB` in `.git/info/exclude` to
|
|
876
|
+
* keep them out of `git status` for any workflow step that shells out to git.
|
|
877
|
+
* See `excludeScratchFromGit`.
|
|
878
|
+
*/
|
|
879
|
+
async function extractIntoScratch(url, kiciDir, attempt) {
|
|
880
|
+
const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-${attempt}-${Date.now()}`);
|
|
881
|
+
await mkdir(scratchDir, { recursive: true });
|
|
882
|
+
return {
|
|
883
|
+
scratchDir,
|
|
884
|
+
hash: await streamFetchAndExtract(url, scratchDir)
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Append `SCRATCH_DIR_GIT_EXCLUDE_GLOB` to `${repoWorkDir}/.git/info/exclude`
|
|
889
|
+
* so any in-flight or orphaned dep-restore scratch dirs are invisible to
|
|
890
|
+
* `git status` / `git add` inside the customer's cloned working tree.
|
|
891
|
+
*
|
|
892
|
+
* Why `.git/info/exclude` and not `.gitignore`:
|
|
893
|
+
* - `.gitignore` lives in the customer's repo and is committed; we MUST NOT
|
|
894
|
+
* modify it. Doing so would surface the rule in their PRs and create a
|
|
895
|
+
* diff customers never asked for.
|
|
896
|
+
* - `.git/info/exclude` is per-clone, on-disk only, and exactly the git
|
|
897
|
+
* mechanism for "ignore these patterns in THIS working tree". Git creates
|
|
898
|
+
* an empty (template-commented) file on `git init` / `git clone`, so it
|
|
899
|
+
* already exists by the time we're called.
|
|
900
|
+
*
|
|
901
|
+
* Why this lives next to `extractIntoScratch`:
|
|
902
|
+
* - The exclude glob is tied 1:1 to the scratch dir naming convention. If
|
|
903
|
+
* the prefix ever changes, the rule must change too. Defining both in the
|
|
904
|
+
* same file means a rename touches one place, not two.
|
|
905
|
+
*
|
|
906
|
+
* Best-effort: if the exclude file is missing (e.g. caller sandbox blocked
|
|
907
|
+
* `git clone` and the dir layout differs) we log and continue — failing the
|
|
908
|
+
* job over a missing git ignore wiring would be worse than the cosmetic
|
|
909
|
+
* issue we're solving.
|
|
910
|
+
*
|
|
911
|
+
* Idempotent: callers may invoke this multiple times (dual-clone path, retry
|
|
912
|
+
* after partial setup). We skip the append if the glob is already present.
|
|
913
|
+
*
|
|
914
|
+
* @param repoWorkDir - The git working tree root (the dir that contains
|
|
915
|
+
* `.git/`). For normal workflows this is the agent's job workDir; for
|
|
916
|
+
* global workflows it is the workflow repo dir (whose `.kici/` carries
|
|
917
|
+
* the scratch dirs).
|
|
918
|
+
*/
|
|
919
|
+
async function excludeScratchFromGit(repoWorkDir) {
|
|
920
|
+
const excludePath = join(repoWorkDir, ".git", "info", "exclude");
|
|
921
|
+
try {
|
|
922
|
+
const existing = await fsPromises.readFile(excludePath, "utf-8").catch(() => "");
|
|
923
|
+
if (existing.split("\n").some((line) => line.trim() === SCRATCH_DIR_GIT_EXCLUDE_GLOB)) return;
|
|
924
|
+
const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
925
|
+
await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
|
|
926
|
+
} catch (err) {
|
|
927
|
+
logger$3.warn("Failed to register scratch dir glob in .git/info/exclude", {
|
|
928
|
+
excludePath,
|
|
929
|
+
error: err instanceof Error ? err.message : String(err)
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Rewrite localhost URLs to use the orchestrator host.
|
|
935
|
+
*
|
|
936
|
+
* The orchestrator rewrites file:// cache URLs to http://localhost:PORT/...
|
|
937
|
+
* but agent containers can't reach localhost. This utility replaces the
|
|
938
|
+
* host with the orchestrator's host derived from KICI_ORCHESTRATOR_URL.
|
|
939
|
+
*/
|
|
940
|
+
function resolveOrchestratorUrl(url) {
|
|
941
|
+
if (!url.match(/^https?:\/\/(localhost|127\.0\.0\.1)[:/]/)) return url;
|
|
942
|
+
const orchestratorUrl = process.env.KICI_ORCHESTRATOR_URL;
|
|
943
|
+
if (!orchestratorUrl) return url;
|
|
944
|
+
try {
|
|
945
|
+
const orchestratorParsed = new URL(orchestratorUrl.replace(/^ws/, "http"));
|
|
946
|
+
const parsed = new URL(url);
|
|
947
|
+
parsed.hostname = orchestratorParsed.hostname;
|
|
948
|
+
return parsed.toString();
|
|
949
|
+
} catch {
|
|
950
|
+
return url;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Restore dependencies from a cached tarball.
|
|
955
|
+
*
|
|
956
|
+
* For HTTP/HTTPS URLs: uses a streaming pipeline (response -> hash -> gunzip -> tar)
|
|
957
|
+
* with a 5-minute timeout and up to 2 retries. This avoids buffering entire tarballs
|
|
958
|
+
* in memory, eliminating memory spikes proportional to tarball size.
|
|
959
|
+
*
|
|
960
|
+
* For file:// URLs: uses a buffer-based approach (local, no streaming benefit).
|
|
961
|
+
*
|
|
962
|
+
* @param workDir - Root directory of the cloned repository
|
|
963
|
+
* @param depsUrl - URL to the dependency tarball (http://, https://, or file://)
|
|
964
|
+
* @param depsHash - Optional expected SHA-256 hash of the tarball
|
|
965
|
+
*/
|
|
966
|
+
async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
967
|
+
depsUrl = resolveOrchestratorUrl(depsUrl);
|
|
968
|
+
logger$3.info("Downloading dependency tarball", { url: depsUrl });
|
|
969
|
+
const kiciDir = join(workDir, ".kici");
|
|
970
|
+
if (depsUrl.startsWith("file://")) {
|
|
971
|
+
const localPath = fileURLToPath(depsUrl);
|
|
972
|
+
const data = await fsPromises.readFile(localPath);
|
|
973
|
+
if (depsHash) {
|
|
974
|
+
const actualHash = computeHash(data);
|
|
975
|
+
if (actualHash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${actualHash}`);
|
|
976
|
+
}
|
|
977
|
+
await extractTarball(data, kiciDir);
|
|
978
|
+
const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
|
|
979
|
+
logger$3.info("Dependencies restored from cache (file)", {
|
|
980
|
+
sizeMB,
|
|
981
|
+
targetDir: kiciDir
|
|
982
|
+
});
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
|
|
986
|
+
let lastError;
|
|
987
|
+
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
988
|
+
if (attempt > 0) logger$3.warn("Retrying dep tarball download", {
|
|
989
|
+
attempt,
|
|
990
|
+
url: depsUrl
|
|
991
|
+
});
|
|
992
|
+
try {
|
|
993
|
+
const { scratchDir, hash } = await extractIntoScratch(depsUrl, kiciDir, attempt);
|
|
994
|
+
if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
|
|
995
|
+
await fsPromises.rename(join(scratchDir, "node_modules"), join(kiciDir, "node_modules"));
|
|
996
|
+
try {
|
|
997
|
+
await fsPromises.rm(scratchDir, {
|
|
998
|
+
recursive: true,
|
|
999
|
+
force: true
|
|
1000
|
+
});
|
|
1001
|
+
} catch (cleanupErr) {
|
|
1002
|
+
logger$3.warn("Scratch dir cleanup failed (orphan left behind)", {
|
|
1003
|
+
scratchDir,
|
|
1004
|
+
error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
logger$3.info("Dependencies restored from cache (stream)", { targetDir: kiciDir });
|
|
1008
|
+
return;
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1011
|
+
logger$3.warn("Dep tarball download failed", {
|
|
1012
|
+
attempt,
|
|
1013
|
+
error: lastError.message
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
|
|
1018
|
+
}
|
|
1019
|
+
//#endregion
|
|
1020
|
+
//#region src/execution/npm-resolver.ts
|
|
1021
|
+
/**
|
|
1022
|
+
* Resolve the npm CLI path relative to the running Node.js binary.
|
|
1023
|
+
*
|
|
1024
|
+
* Used both at startup (builder role readiness check) and at install time
|
|
1025
|
+
* (dep-installer). Centralizes the resolution logic so it stays consistent.
|
|
1026
|
+
*
|
|
1027
|
+
* Resolution strategy:
|
|
1028
|
+
* 1. Check standard Node.js layout paths relative to process.execPath
|
|
1029
|
+
* 2. Fall back to bare 'npm' on PATH (development environments)
|
|
1030
|
+
*/
|
|
1031
|
+
/**
|
|
1032
|
+
* Resolve the npm CLI path from the current Node.js binary.
|
|
1033
|
+
*
|
|
1034
|
+
* Checks standard Node.js distribution layout paths:
|
|
1035
|
+
* - {nodeDir}/../lib/node_modules/npm/bin/npm-cli.js (Linux/macOS installed)
|
|
1036
|
+
* - {nodeDir}/node_modules/npm/bin/npm-cli.js (Windows / some layouts)
|
|
1037
|
+
*
|
|
1038
|
+
* Returns undefined npmCliPath if neither is found (caller can fall back to PATH).
|
|
1039
|
+
*/
|
|
1040
|
+
function resolveNpm() {
|
|
1041
|
+
const nodeExe = process.execPath;
|
|
1042
|
+
const nodeDir = dirname(nodeExe);
|
|
1043
|
+
return {
|
|
1044
|
+
npmCliPath: [join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"), join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js")].find((p) => existsSync(p)),
|
|
1045
|
+
nodeExe,
|
|
1046
|
+
nodeDir
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
//#endregion
|
|
1050
|
+
//#region src/execution/npm-registry-config.ts
|
|
1051
|
+
/**
|
|
1052
|
+
* Apply private-npm-registry auth to a workflow's `.kici/.npmrc` for the
|
|
1053
|
+
* lifetime of one `npm install` invocation, then restore the file on cleanup.
|
|
1054
|
+
*
|
|
1055
|
+
* Why a closure-cleanup pattern (mirrors `setupSshAuth.cleanup()` in
|
|
1056
|
+
* `packages/agent/src/checkout/git-clone.ts`): the customer's committed
|
|
1057
|
+
* `.kici/.npmrc` may carry literal `${VAR}` placeholders or an unrelated
|
|
1058
|
+
* scope mapping. We must NOT clobber it permanently — we just want to
|
|
1059
|
+
* append the agent-managed registry/auth lines for this single install,
|
|
1060
|
+
* then revert.
|
|
1061
|
+
*
|
|
1062
|
+
* Token bytes never end up in the on-disk `.npmrc`. Each registry's token
|
|
1063
|
+
* is exposed as a job-scoped env var (`KICI_NPM_TOKEN_${jobIdShort}_<i>`)
|
|
1064
|
+
* and the on-disk auth line carries the env var reference (`${VAR}`). npm
|
|
1065
|
+
* substitutes at read time. The job-scoped nonce makes the env var name
|
|
1066
|
+
* unguessable from outside the install subprocess.
|
|
1067
|
+
*
|
|
1068
|
+
* Merge order: customer-committed `.npmrc` lines come FIRST, agent-generated
|
|
1069
|
+
* lines come LAST. npm's last-wins semantics make the agent's line shadow
|
|
1070
|
+
* any literal `_authToken=...` the customer accidentally committed for a
|
|
1071
|
+
* registry KiCI manages — refuses to let a committed secret beat a managed
|
|
1072
|
+
* one.
|
|
1073
|
+
*
|
|
1074
|
+
* `installEnvSecrets` is a separate channel for customers who prefer the
|
|
1075
|
+
* "commit a `.kici/.npmrc` with `${MY_TOKEN}` and supply MY_TOKEN as a
|
|
1076
|
+
* scoped secret" pattern (Option C in the design doc). Each entry becomes
|
|
1077
|
+
* an env var on the install subprocess; the customer's existing `.npmrc`
|
|
1078
|
+
* uses it as `${MY_TOKEN}`.
|
|
1079
|
+
*/
|
|
1080
|
+
/** No-op result returned when nothing needs to be applied. */
|
|
1081
|
+
function noopResult() {
|
|
1082
|
+
return {
|
|
1083
|
+
extraEnv: {},
|
|
1084
|
+
tokensForRedaction: [],
|
|
1085
|
+
cleanup: async () => {}
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
/** Build the synthesized env-var name for registry index `i`. */
|
|
1089
|
+
function tokenEnvName(jobIdShort, index) {
|
|
1090
|
+
return `KICI_NPM_TOKEN_${jobIdShort}_${index}`;
|
|
1091
|
+
}
|
|
1092
|
+
/** Render the agent-managed block of `.npmrc` lines. */
|
|
1093
|
+
function renderAgentLines(registries, jobIdShort) {
|
|
1094
|
+
if (registries.length === 0) return "";
|
|
1095
|
+
const lines = [];
|
|
1096
|
+
for (let i = 0; i < registries.length; i++) {
|
|
1097
|
+
const reg = registries[i];
|
|
1098
|
+
const envVar = tokenEnvName(jobIdShort, i);
|
|
1099
|
+
const authKey = reg.url.replace(/^https?:/, "");
|
|
1100
|
+
if (reg.scope) lines.push(`${reg.scope}:registry=${reg.url}`);
|
|
1101
|
+
else lines.push(`registry=${reg.url}`);
|
|
1102
|
+
lines.push(`${authKey}:_authToken=\${${envVar}}`);
|
|
1103
|
+
if (reg.alwaysAuth) lines.push(`${authKey}:always-auth=true`);
|
|
1104
|
+
}
|
|
1105
|
+
return `# kici-managed: applied for one npm install only\n${lines.join("\n")}\n`;
|
|
1106
|
+
}
|
|
1107
|
+
/** Read original `.npmrc` bytes; null if the file does not exist. */
|
|
1108
|
+
async function readOriginalNpmrc(npmrcPath) {
|
|
1109
|
+
try {
|
|
1110
|
+
return await readFile(npmrcPath, "utf8");
|
|
1111
|
+
} catch (err) {
|
|
1112
|
+
if (err.code === "ENOENT") return null;
|
|
1113
|
+
throw err;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Apply the merged `.npmrc` and return env + redaction + cleanup. Caller
|
|
1118
|
+
* runs the npm install with `extraEnv` merged in, then awaits cleanup()
|
|
1119
|
+
* inside the install's `finally`.
|
|
1120
|
+
*/
|
|
1121
|
+
async function applyNpmRegistryConfig(args) {
|
|
1122
|
+
const registries = args.npmRegistries ?? [];
|
|
1123
|
+
const installEnvSecrets = args.installEnvSecrets ?? {};
|
|
1124
|
+
if (registries.length === 0 && Object.keys(installEnvSecrets).length === 0) return noopResult();
|
|
1125
|
+
const npmrcPath = join(args.kiciDir, ".npmrc");
|
|
1126
|
+
const original = await readOriginalNpmrc(npmrcPath);
|
|
1127
|
+
const tokenEnv = {};
|
|
1128
|
+
const tokensForRedaction = [];
|
|
1129
|
+
for (let i = 0; i < registries.length; i++) {
|
|
1130
|
+
tokenEnv[tokenEnvName(args.jobIdShort, i)] = registries[i].token;
|
|
1131
|
+
tokensForRedaction.push(registries[i].token);
|
|
1132
|
+
}
|
|
1133
|
+
for (const value of Object.values(installEnvSecrets)) if (value) tokensForRedaction.push(value);
|
|
1134
|
+
const agentBlock = renderAgentLines(registries, args.jobIdShort);
|
|
1135
|
+
const merged = `${original ?? ""}${original && !original.endsWith("\n") ? "\n" : ""}${agentBlock}`;
|
|
1136
|
+
if (agentBlock.length > 0) await writeFile(npmrcPath, merged, {
|
|
1137
|
+
encoding: "utf8",
|
|
1138
|
+
mode: 384
|
|
1139
|
+
});
|
|
1140
|
+
const cleanup = async () => {
|
|
1141
|
+
if (agentBlock.length === 0) return;
|
|
1142
|
+
try {
|
|
1143
|
+
if (original === null) await unlink(npmrcPath).catch(() => {});
|
|
1144
|
+
else await writeFile(npmrcPath, original, { encoding: "utf8" });
|
|
1145
|
+
} catch {}
|
|
1146
|
+
};
|
|
1147
|
+
return {
|
|
1148
|
+
extraEnv: {
|
|
1149
|
+
...installEnvSecrets,
|
|
1150
|
+
...tokenEnv
|
|
1151
|
+
},
|
|
1152
|
+
tokensForRedaction,
|
|
1153
|
+
cleanup
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
/** Mask every token in `tokensForRedaction` out of `input` before logging. */
|
|
1157
|
+
function redactNpmOutput(input, tokens) {
|
|
1158
|
+
if (!input) return input;
|
|
1159
|
+
let out = input;
|
|
1160
|
+
for (const token of tokens) {
|
|
1161
|
+
if (!token) continue;
|
|
1162
|
+
out = out.split(token).join("***REDACTED***");
|
|
1163
|
+
}
|
|
1164
|
+
return out;
|
|
1165
|
+
}
|
|
1166
|
+
//#endregion
|
|
1167
|
+
//#region src/execution/dep-installer.ts
|
|
1168
|
+
/**
|
|
1169
|
+
* Inline dependency installation for graceful degradation.
|
|
1170
|
+
*
|
|
1171
|
+
* When dep cache is unavailable or download fails, the agent falls back
|
|
1172
|
+
* to running npm install directly.
|
|
1173
|
+
*
|
|
1174
|
+
* Only npm is supported — it ships with every Node.js installation.
|
|
1175
|
+
* .kici/package.json is required — its presence signals deps should be installed.
|
|
1176
|
+
*
|
|
1177
|
+
* Security: npm runs with an isolated per-invocation cache directory to prevent
|
|
1178
|
+
* cache poisoning across build jobs. A malicious package.json in one repo cannot
|
|
1179
|
+
* taint the cache used by subsequent builds. The same pressure rules out letting
|
|
1180
|
+
* lifecycle scripts see synthesized auth env vars — npm runs with
|
|
1181
|
+
* `--ignore-scripts` whenever a private registry is configured.
|
|
1182
|
+
*/
|
|
1183
|
+
const logger$2 = createLogger({ prefix: "dep-installer" });
|
|
1184
|
+
/**
|
|
1185
|
+
* Install dependencies inline using npm.
|
|
1186
|
+
*
|
|
1187
|
+
* Falls back to this when the dep cache is unavailable or download fails.
|
|
1188
|
+
*
|
|
1189
|
+
* npm runs with an isolated cache directory (created in os.tmpdir()) to prevent
|
|
1190
|
+
* cache poisoning between build jobs. The cache is removed after installation.
|
|
1191
|
+
*
|
|
1192
|
+
* If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, the helper
|
|
1193
|
+
* synthesizes a job-scoped `.kici/.npmrc` overlay for the install, restores
|
|
1194
|
+
* the original file in `finally`, and runs npm with `--ignore-scripts` so
|
|
1195
|
+
* lifecycle scripts in committed `package.json` cannot exfiltrate the
|
|
1196
|
+
* synthesized token env vars.
|
|
1197
|
+
*
|
|
1198
|
+
* @param kiciDir - Path to the .kici/ directory containing package.json
|
|
1199
|
+
* @param opts - Optional registry / installEnv configuration. When absent,
|
|
1200
|
+
* behavior is identical to the pre-private-registry version.
|
|
1201
|
+
*/
|
|
1202
|
+
async function installDeps(kiciDir, opts = {}) {
|
|
1203
|
+
logger$2.info("Installing deps inline", {
|
|
1204
|
+
packageManager: "npm",
|
|
1205
|
+
dir: kiciDir
|
|
1206
|
+
});
|
|
1207
|
+
process.stderr.write(`[dep-installer:trace] starting install: pm=npm, cwd=${kiciDir}\n`);
|
|
1208
|
+
const startTime = Date.now();
|
|
1209
|
+
const hasPrivateRegistry = (opts.npmRegistries?.length ?? 0) > 0 || (opts.installEnvSecrets ? Object.keys(opts.installEnvSecrets).length > 0 : false);
|
|
1210
|
+
const registryConfig = await applyNpmRegistryConfig({
|
|
1211
|
+
kiciDir,
|
|
1212
|
+
npmRegistries: opts.npmRegistries,
|
|
1213
|
+
installEnvSecrets: opts.installEnvSecrets,
|
|
1214
|
+
jobIdShort: opts.jobIdShort ?? "00000000"
|
|
1215
|
+
});
|
|
1216
|
+
try {
|
|
1217
|
+
const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
|
|
1218
|
+
const npmCmd = "install";
|
|
1219
|
+
const cacheDir = await mkdtemp(join(tmpdir(), "kici-npm-cache-"));
|
|
1220
|
+
const { NODE_ENV: _, ...restEnv } = process.env;
|
|
1221
|
+
const envWithNode = {
|
|
1222
|
+
...restEnv,
|
|
1223
|
+
...registryConfig.extraEnv,
|
|
1224
|
+
PATH: `${nodeDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`
|
|
1225
|
+
};
|
|
1226
|
+
const { execFileSync: execFile } = await import("node:child_process");
|
|
1227
|
+
const buildArgs = (...prefix) => {
|
|
1228
|
+
const args = [
|
|
1229
|
+
...prefix,
|
|
1230
|
+
npmCmd,
|
|
1231
|
+
"--cache",
|
|
1232
|
+
cacheDir
|
|
1233
|
+
];
|
|
1234
|
+
if (hasPrivateRegistry) args.push("--ignore-scripts");
|
|
1235
|
+
return args;
|
|
1236
|
+
};
|
|
1237
|
+
try {
|
|
1238
|
+
if (npmCliPath) {
|
|
1239
|
+
process.stderr.write(`[dep-installer:trace] running: ${nodeExe} ${npmCliPath} ${npmCmd} --cache ${cacheDir}${hasPrivateRegistry ? " --ignore-scripts" : ""}\n`);
|
|
1240
|
+
execFile(nodeExe, buildArgs(npmCliPath), {
|
|
1241
|
+
cwd: kiciDir,
|
|
1242
|
+
env: envWithNode,
|
|
1243
|
+
timeout: 6e5,
|
|
1244
|
+
stdio: "pipe"
|
|
1245
|
+
});
|
|
1246
|
+
} else {
|
|
1247
|
+
process.stderr.write(`[dep-installer:trace] running: npm ${npmCmd} --cache ${cacheDir}${hasPrivateRegistry ? " --ignore-scripts" : ""}\n`);
|
|
1248
|
+
execFile("npm", buildArgs(), {
|
|
1249
|
+
cwd: kiciDir,
|
|
1250
|
+
env: envWithNode,
|
|
1251
|
+
timeout: 6e5,
|
|
1252
|
+
stdio: "pipe"
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
} finally {
|
|
1256
|
+
await rm(cacheDir, {
|
|
1257
|
+
recursive: true,
|
|
1258
|
+
force: true
|
|
1259
|
+
}).catch(() => {});
|
|
1260
|
+
}
|
|
1261
|
+
} catch (e) {
|
|
1262
|
+
const msg = toErrorMessage(e);
|
|
1263
|
+
const tokens = registryConfig.tokensForRedaction;
|
|
1264
|
+
process.stderr.write(`[dep-installer:trace] INSTALL FAILED: ${redactNpmOutput(msg, tokens)}\n`);
|
|
1265
|
+
if (e && typeof e === "object" && "stdout" in e) process.stderr.write(`[dep-installer:trace] stdout: ${redactNpmOutput(String(e.stdout), tokens).slice(0, 500)}\n`);
|
|
1266
|
+
if (e && typeof e === "object" && "stderr" in e) process.stderr.write(`[dep-installer:trace] stderr: ${redactNpmOutput(String(e.stderr), tokens).slice(0, 500)}\n`);
|
|
1267
|
+
throw e;
|
|
1268
|
+
} finally {
|
|
1269
|
+
await registryConfig.cleanup();
|
|
1270
|
+
}
|
|
1271
|
+
const durationMs = Date.now() - startTime;
|
|
1272
|
+
process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
|
|
1273
|
+
logger$2.info("Deps installed inline", {
|
|
1274
|
+
packageManager: "npm",
|
|
1275
|
+
durationMs
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
//#endregion
|
|
1279
|
+
//#region src/execution/workflow-loader.ts
|
|
1280
|
+
/**
|
|
1281
|
+
* Workflow module loading: transforms `.ts` workflow files on import via the
|
|
1282
|
+
* shared oxc-transform ESM loader hook. Customer workflow code is imported
|
|
1283
|
+
* directly from the cloned / extracted source tree — no intermediate bundle,
|
|
1284
|
+
* no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
|
|
1285
|
+
* Node's normal ESM lookup against `.kici/node_modules/`.
|
|
1286
|
+
*/
|
|
1287
|
+
const AGENT_SDK_VERSION = "0.1.1";
|
|
1288
|
+
const AGENT_SDK_BUNDLE_HASH = "675aafe4de03e677785ef2794d8f34951b14e2bd1e6f0ea825953d6c2abfe128";
|
|
1289
|
+
/**
|
|
1290
|
+
* Register the shared oxc-transform ESM loader hook so subsequent dynamic
|
|
1291
|
+
* `import()` calls for `.ts` / `.tsx` files transform on the fly. Idempotent
|
|
1292
|
+
* at our level via the `hookRegistered` flag; Node also tolerates repeated
|
|
1293
|
+
* `register()` calls by stacking layers, but we avoid the noise.
|
|
1294
|
+
*/
|
|
1295
|
+
let hookRegistered = false;
|
|
1296
|
+
function ensureLoaderHookRegistered() {
|
|
1297
|
+
if (hookRegistered) return;
|
|
1298
|
+
register("@kici-dev/shared/ts-loader-hook", import.meta.url);
|
|
1299
|
+
hookRegistered = true;
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Compute content hash for a workflow (same formula as `@kici-dev/compiler`
|
|
1303
|
+
* lockfile/hasher.ts). Used to verify the loaded source matches the lock
|
|
1304
|
+
* file's contentHash when `expectedContentHash` is provided.
|
|
1305
|
+
*
|
|
1306
|
+
* Line endings in `rawSource` (and inside `assetDigest`) are normalized to LF
|
|
1307
|
+
* before hashing. This matches the compiler-side normalization in
|
|
1308
|
+
* `@kici-dev/compiler` `lockfile/hasher.ts` so Windows agents — where Git's
|
|
1309
|
+
* `core.autocrlf=true` system default checks out text files with CRLF — agree
|
|
1310
|
+
* with lockfiles compiled on Linux (LF).
|
|
1311
|
+
*/
|
|
1312
|
+
function computeContentHash(rawSource, assetDigest) {
|
|
1313
|
+
let input = `5:${normalizeLineEndings(rawSource)}`;
|
|
1314
|
+
if (assetDigest !== void 0 && assetDigest.length > 0) input += `\0${normalizeLineEndings(assetDigest)}`;
|
|
1315
|
+
return sha256(input);
|
|
1316
|
+
}
|
|
1317
|
+
async function buildAssetDigestFromResolvedPaths(workDir, resolvedPaths) {
|
|
1318
|
+
const parts = [];
|
|
1319
|
+
for (const rel of resolvedPaths) {
|
|
1320
|
+
const abs = path.join(workDir, rel);
|
|
1321
|
+
try {
|
|
1322
|
+
const content = await fsPromises.readFile(abs, "utf-8");
|
|
1323
|
+
parts.push(`${rel}\n${content}`);
|
|
1324
|
+
} catch {
|
|
1325
|
+
parts.push(`${rel}\n`);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return parts.join("");
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Load a workflow module by dynamic-importing its source file.
|
|
1332
|
+
*
|
|
1333
|
+
* Registers the oxc-transform loader hook (idempotent), then dynamic-imports
|
|
1334
|
+
* the `.ts` file. Transitive imports resolve against the workspace's
|
|
1335
|
+
* `node_modules/` the same way any `tsx`-style runner would — so host-repo
|
|
1336
|
+
* helpers and `@kici-dev/sdk` Just Work.
|
|
1337
|
+
*
|
|
1338
|
+
* When `expectedContentHash` is provided, verifies the raw source matches
|
|
1339
|
+
* the hash in the lock file. Drift between source and lock file produces a
|
|
1340
|
+
* descriptive error that surfaces the baked agent SDK fingerprint (useful
|
|
1341
|
+
* when debugging "is the agent running a stale build?").
|
|
1342
|
+
*/
|
|
1343
|
+
async function loadWorkflowSource(workDir, sourceFile, expectedContentHash, resolvedHashFiles) {
|
|
1344
|
+
ensureLoaderHookRegistered();
|
|
1345
|
+
const filePath = path.join(workDir, sourceFile);
|
|
1346
|
+
if (expectedContentHash) {
|
|
1347
|
+
const rawSource = await fsPromises.readFile(filePath, "utf-8");
|
|
1348
|
+
let assetDigest;
|
|
1349
|
+
if (resolvedHashFiles?.length) assetDigest = await buildAssetDigestFromResolvedPaths(workDir, resolvedHashFiles);
|
|
1350
|
+
const actualHash = computeContentHash(rawSource, assetDigest);
|
|
1351
|
+
if (actualHash !== expectedContentHash) throw new Error(`Lock file is out of date: workflow source changed without regenerating kici.lock.json (expected contentHash ${expectedContentHash}, got ${actualHash}, agent baked @kici-dev/sdk@${AGENT_SDK_VERSION} bundleHash=${AGENT_SDK_BUNDLE_HASH}). Run 'kici compile' and commit the updated lock file.`);
|
|
1352
|
+
}
|
|
1353
|
+
return { module: await import(pathToFileURL(filePath).href + `?t=${Date.now()}`) };
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Type guard for Workflow shape (discriminant: `_tag === 'Workflow'`).
|
|
1357
|
+
*/
|
|
1358
|
+
function isWorkflow(value) {
|
|
1359
|
+
return typeof value === "object" && value !== null && "_tag" in value && value._tag === "Workflow";
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Extract a workflow by name from a module's exports.
|
|
1363
|
+
*
|
|
1364
|
+
* Searches:
|
|
1365
|
+
* 1. Default export (single Workflow or array of Workflows)
|
|
1366
|
+
* 2. Named exports
|
|
1367
|
+
*/
|
|
1368
|
+
function extractWorkflow(module, workflowName) {
|
|
1369
|
+
if (module.default) {
|
|
1370
|
+
const defaultExport = module.default;
|
|
1371
|
+
if (isWorkflow(defaultExport) && defaultExport.name === workflowName) return defaultExport;
|
|
1372
|
+
if (Array.isArray(defaultExport)) {
|
|
1373
|
+
const found = defaultExport.find((item) => isWorkflow(item) && item.name === workflowName);
|
|
1374
|
+
if (found) return found;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
for (const [, value] of Object.entries(module)) if (isWorkflow(value) && value.name === workflowName) return value;
|
|
1378
|
+
throw new Error(`Workflow '${workflowName}' not found in module exports`);
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Extract a dynamic job function from a workflow by index.
|
|
1382
|
+
*/
|
|
1383
|
+
function extractDynamicJobFn(workflow, index) {
|
|
1384
|
+
if (index < 0 || index >= workflow.jobs.length) throw new Error(`Job index ${index} out of bounds (workflow '${workflow.name}' has ${workflow.jobs.length} jobs)`);
|
|
1385
|
+
const item = workflow.jobs[index];
|
|
1386
|
+
if (!isDynamicJobFn(item)) throw new Error(`Job at index ${index} in workflow '${workflow.name}' is not a dynamic job fn`);
|
|
1387
|
+
return item;
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Extract steps from a static job within a workflow.
|
|
1391
|
+
*/
|
|
1392
|
+
function extractSteps(workflow, jobName) {
|
|
1393
|
+
for (const item of workflow.jobs) if (!isDynamicJobFn(item) && item.name === jobName) return item.steps;
|
|
1394
|
+
throw new Error(`Static job '${jobName}' not found in workflow '${workflow.name}'`);
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Extract steps from a job generated by a DynamicJobFn.
|
|
1398
|
+
*
|
|
1399
|
+
* Re-evaluates the DynamicJobFn to get the generated Job[] array, then finds
|
|
1400
|
+
* the job by name and returns its steps. This is necessary because
|
|
1401
|
+
* DynamicJobFn-generated jobs' step functions are closures that can only be
|
|
1402
|
+
* obtained by calling the DynamicJobFn again.
|
|
1403
|
+
*
|
|
1404
|
+
* The function must be deterministic: given the same event context, it should
|
|
1405
|
+
* return the same jobs with the same step functions. When `expectedJobNames`
|
|
1406
|
+
* is provided, the re-evaluated output is compared against the original eval.
|
|
1407
|
+
* A sibling mismatch logs a warning; a missing target job throws a clear
|
|
1408
|
+
* determinism error.
|
|
1409
|
+
*/
|
|
1410
|
+
async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event, env, apiTransport, expectedJobNames) {
|
|
1411
|
+
const dynamicFn = extractDynamicJobFn(workflow, dynamicIndex);
|
|
1412
|
+
const { $ } = await import("zx");
|
|
1413
|
+
const { createLogger } = await import("@kici-dev/shared");
|
|
1414
|
+
const { buildKiciApi } = await import("@kici-dev/sdk");
|
|
1415
|
+
const log = createLogger({ prefix: `dynamic-job-fn:${workflow.name}` });
|
|
1416
|
+
const kici = buildKiciApi(apiTransport ?? (() => Promise.reject(/* @__PURE__ */ new Error("Agent API not available during re-evaluation"))));
|
|
1417
|
+
const generatedJobs = await dynamicFn({
|
|
1418
|
+
$,
|
|
1419
|
+
ctx: {
|
|
1420
|
+
workflow: { name: workflow.name },
|
|
1421
|
+
event
|
|
1422
|
+
},
|
|
1423
|
+
log,
|
|
1424
|
+
env,
|
|
1425
|
+
kici
|
|
1426
|
+
});
|
|
1427
|
+
const actualNames = generatedJobs.map((j) => j.name);
|
|
1428
|
+
let droppedJobs = [];
|
|
1429
|
+
if (expectedJobNames) {
|
|
1430
|
+
const expectedSet = new Set(expectedJobNames);
|
|
1431
|
+
const actualSet = new Set(actualNames);
|
|
1432
|
+
const missing = expectedJobNames.filter((n) => !actualSet.has(n));
|
|
1433
|
+
const extra = actualNames.filter((n) => !expectedSet.has(n));
|
|
1434
|
+
droppedJobs = missing.filter((n) => n !== jobName);
|
|
1435
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
1436
|
+
const detail = (missing.length > 0 ? `missing: [${missing.join(", ")}]` : "") + (missing.length > 0 && extra.length > 0 ? "; " : "") + (extra.length > 0 ? `unexpected: [${extra.join(", ")}]` : "");
|
|
1437
|
+
if (missing.includes(jobName)) throw new Error(`DynamicJobFn non-deterministic re-evaluation: job '${jobName}' no longer exists (workflow '${workflow.name}', index ${dynamicIndex}). Original eval produced: [${expectedJobNames.join(", ")}], re-eval produced: [${actualNames.join(", ")}]. DynamicJobFn must return the same jobs given the same event context. See docs/architecture/dynamic-jobs.md for guidance.`);
|
|
1438
|
+
log.warn(`DynamicJobFn non-deterministic re-evaluation detected (workflow '${workflow.name}', index ${dynamicIndex}): ${detail}. Target job '${jobName}' still exists — proceeding. DynamicJobFn should return the same jobs given the same event context.`);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
for (const genJob of generatedJobs) if (genJob.name === jobName) return {
|
|
1442
|
+
steps: genJob.steps,
|
|
1443
|
+
droppedJobs
|
|
1444
|
+
};
|
|
1445
|
+
throw new Error(`Generated job '${jobName}' not found in DynamicJobFn output (workflow '${workflow.name}', index ${dynamicIndex}). Available: ${actualNames.join(", ")}`);
|
|
1446
|
+
}
|
|
1447
|
+
//#endregion
|
|
1448
|
+
//#region src/execution/download.ts
|
|
1449
|
+
/**
|
|
1450
|
+
* Shared HTTP/HTTPS download utility.
|
|
1451
|
+
*
|
|
1452
|
+
* Extracted from workflow-loader.ts to avoid duplication across
|
|
1453
|
+
* dep-restore.ts and workflow-loader.ts.
|
|
1454
|
+
*/
|
|
1455
|
+
/** Download timeout: 5 minutes. */
|
|
1456
|
+
const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
|
|
1457
|
+
/**
|
|
1458
|
+
* Download content from an HTTP/HTTPS URL.
|
|
1459
|
+
*
|
|
1460
|
+
* Includes a 5-minute timeout to prevent the agent from hanging indefinitely
|
|
1461
|
+
* on slow or unresponsive endpoints.
|
|
1462
|
+
*
|
|
1463
|
+
* @param url - The URL to download from
|
|
1464
|
+
* @returns The response body as a Buffer
|
|
1465
|
+
*/
|
|
1466
|
+
function downloadUrl(url) {
|
|
1467
|
+
return new Promise((resolve, reject) => {
|
|
1468
|
+
(url.startsWith("https:") ? https : http).get(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }, (res) => {
|
|
1469
|
+
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
|
1470
|
+
reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} downloading from ${url}`));
|
|
1471
|
+
res.resume();
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const chunks = [];
|
|
1475
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
1476
|
+
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1477
|
+
res.on("error", reject);
|
|
1478
|
+
}).on("error", reject);
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
//#endregion
|
|
1482
|
+
//#region src/execution/source-restore.ts
|
|
1483
|
+
/**
|
|
1484
|
+
* `.kici/` source tarball restoration for execution agents.
|
|
1485
|
+
*
|
|
1486
|
+
* Downloads a pre-built `.kici/` source tarball from the orchestrator's cache
|
|
1487
|
+
* and extracts it into `workDir/` so the workflow entry point becomes
|
|
1488
|
+
* importable. Mirrors the shape of `dep-restore.ts` but without the streaming
|
|
1489
|
+
* optimization — source tarballs are tiny (kilobytes, not the hundreds of
|
|
1490
|
+
* megabytes a `node_modules/` tarball carries).
|
|
1491
|
+
*
|
|
1492
|
+
* Note on integrity: `dispatch.sourceTarHash` is the workflow `contentHash`
|
|
1493
|
+
* (computed over the raw source per `workflow-loader.ts::computeContentHash`),
|
|
1494
|
+
* not the SHA-256 of the tarball bytes. The shared S3 cache key is derived
|
|
1495
|
+
* from that same contentHash, so a signed GET URL from the orchestrator
|
|
1496
|
+
* already establishes provenance for restored tarballs. Every
|
|
1497
|
+
* `loadWorkflowSource` call site — build, init, and dynamic eval — passes
|
|
1498
|
+
* the dispatched `contentHash` (and `resolvedHashFiles` when present) so
|
|
1499
|
+
* the lock-vs-source drift gate fires at each author-TS load site, not
|
|
1500
|
+
* only the build phase. That closes the corner cases where init or eval
|
|
1501
|
+
* runs without a preceding build (cache infrastructure unavailable, or a
|
|
1502
|
+
* build job that failed but left dynamic dispatch in flight).
|
|
1503
|
+
*/
|
|
1504
|
+
const logger$1 = createLogger({ prefix: "source-restore" });
|
|
1505
|
+
async function extractSourceTarball(data, targetDir) {
|
|
1506
|
+
await mkdir(targetDir, { recursive: true });
|
|
1507
|
+
const readable = Readable.from(data);
|
|
1508
|
+
await new Promise((resolve, reject) => {
|
|
1509
|
+
readable.pipe(x({
|
|
1510
|
+
cwd: targetDir,
|
|
1511
|
+
gzip: true
|
|
1512
|
+
})).on("finish", resolve).on("error", reject);
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
async function restoreSource(workDir, sourceTarUrl) {
|
|
1516
|
+
sourceTarUrl = resolveOrchestratorUrl(sourceTarUrl);
|
|
1517
|
+
logger$1.info("Restoring .kici/ source from tarball", { sourceTarUrl });
|
|
1518
|
+
const startTime = Date.now();
|
|
1519
|
+
let data;
|
|
1520
|
+
if (sourceTarUrl.startsWith("file://")) {
|
|
1521
|
+
const localPath = fileURLToPath(sourceTarUrl);
|
|
1522
|
+
data = await fsPromises.readFile(localPath);
|
|
1523
|
+
} else if (sourceTarUrl.startsWith("http://") || sourceTarUrl.startsWith("https://")) data = await downloadUrl(sourceTarUrl);
|
|
1524
|
+
else throw new Error(`Unsupported source tarball URL scheme: ${sourceTarUrl}`);
|
|
1525
|
+
await extractSourceTarball(data, workDir);
|
|
1526
|
+
const durationMs = Date.now() - startTime;
|
|
1527
|
+
logger$1.info(".kici/ source restored", {
|
|
1528
|
+
sizeKB: (data.length / 1024).toFixed(2),
|
|
1529
|
+
durationMs
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
//#endregion
|
|
1533
|
+
//#region src/execution/overlay-applier.ts
|
|
1534
|
+
/**
|
|
1535
|
+
* Agent-side overlay application.
|
|
1536
|
+
*
|
|
1537
|
+
* Downloads an encrypted tarball uploaded by the CLI, decrypts it using
|
|
1538
|
+
* X25519 ECDH shared secret, verifies file checksums from the manifest,
|
|
1539
|
+
* and applies the overlay (file additions/modifications + deletions)
|
|
1540
|
+
* on top of the cloned repository.
|
|
1541
|
+
*
|
|
1542
|
+
* Wire format: [12-byte IV][16-byte auth tag][ciphertext]
|
|
1543
|
+
* Same encryption scheme as packages/compiler/src/remote/encryption.ts.
|
|
1544
|
+
*/
|
|
1545
|
+
const logger = createLogger({ prefix: "overlay-applier" });
|
|
1546
|
+
const IV_LENGTH = 12;
|
|
1547
|
+
const AUTH_TAG_LENGTH = 16;
|
|
1548
|
+
/**
|
|
1549
|
+
* Decrypt an encrypted buffer using AES-256-GCM.
|
|
1550
|
+
*
|
|
1551
|
+
* Wire format: [12-byte IV][16-byte auth tag][ciphertext]
|
|
1552
|
+
*/
|
|
1553
|
+
function decryptBuffer(encrypted, aesKey) {
|
|
1554
|
+
if (encrypted.length < IV_LENGTH + AUTH_TAG_LENGTH) throw new Error(`Tarball decryption failed: encrypted data too short (${encrypted.length} bytes, minimum ${IV_LENGTH + AUTH_TAG_LENGTH} bytes)`);
|
|
1555
|
+
const iv = encrypted.subarray(0, IV_LENGTH);
|
|
1556
|
+
const authTag = encrypted.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
|
1557
|
+
const ciphertext = encrypted.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
|
1558
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", aesKey, iv);
|
|
1559
|
+
decipher.setAuthTag(authTag);
|
|
1560
|
+
try {
|
|
1561
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
1562
|
+
} catch (err) {
|
|
1563
|
+
throw new Error(`Tarball decryption failed: ${toErrorMessage(err)}`);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Apply an overlay tarball to a cloned repository.
|
|
1568
|
+
*
|
|
1569
|
+
* Flow:
|
|
1570
|
+
* 1. Download encrypted tarball from tarballUrl
|
|
1571
|
+
* 2. Derive ECDH shared secret from orchestratorPrivateKey + cliPublicKey
|
|
1572
|
+
* 3. Decrypt tarball using AES-256-GCM
|
|
1573
|
+
* 4. Extract tar.gz to temp directory
|
|
1574
|
+
* 5. Read manifest.json and verify checksums
|
|
1575
|
+
* 6. Copy files to repoDir preserving directory structure
|
|
1576
|
+
* 7. Apply deletions from manifest
|
|
1577
|
+
* 8. Clean up temp files
|
|
1578
|
+
*/
|
|
1579
|
+
async function applyOverlay(config) {
|
|
1580
|
+
const { tarballUrl, cliPublicKey, orchestratorPrivateKey, repoDir } = config;
|
|
1581
|
+
const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "kici-overlay-"));
|
|
1582
|
+
try {
|
|
1583
|
+
logger.info("Downloading overlay tarball", { url: tarballUrl.replace(/\?.*$/, "?[redacted]") });
|
|
1584
|
+
let encryptedData;
|
|
1585
|
+
try {
|
|
1586
|
+
encryptedData = await downloadUrl(tarballUrl);
|
|
1587
|
+
} catch (err) {
|
|
1588
|
+
throw new Error(`Overlay download failed from ${tarballUrl.replace(/\?.*$/, "?[redacted]")}: ${toErrorMessage(err)}`);
|
|
1589
|
+
}
|
|
1590
|
+
const cliPubKeyBuf = Buffer.from(cliPublicKey, "base64");
|
|
1591
|
+
const aesKey = deriveSharedSecret(Buffer.from(orchestratorPrivateKey, "base64"), cliPubKeyBuf);
|
|
1592
|
+
const decryptedData = decryptBuffer(encryptedData, aesKey);
|
|
1593
|
+
logger.info("Extracting overlay tarball", { size: decryptedData.length });
|
|
1594
|
+
const extractDir = path.join(tmpDir, "extracted");
|
|
1595
|
+
await fsPromises.mkdir(extractDir, { recursive: true });
|
|
1596
|
+
try {
|
|
1597
|
+
const readable = Readable.from(decryptedData);
|
|
1598
|
+
await new Promise((resolve, reject) => {
|
|
1599
|
+
readable.pipe(x({
|
|
1600
|
+
cwd: extractDir,
|
|
1601
|
+
gzip: true
|
|
1602
|
+
})).on("finish", resolve).on("error", reject);
|
|
1603
|
+
});
|
|
1604
|
+
} catch (err) {
|
|
1605
|
+
throw new Error(`Overlay extraction failed: ${toErrorMessage(err)}`);
|
|
1606
|
+
}
|
|
1607
|
+
const manifestPath = path.join(extractDir, ".kici-overlay-tmp", "manifest.json");
|
|
1608
|
+
let manifestContent;
|
|
1609
|
+
try {
|
|
1610
|
+
manifestContent = await fsPromises.readFile(manifestPath, "utf-8");
|
|
1611
|
+
} catch {
|
|
1612
|
+
throw new Error("Overlay manifest not found: expected .kici-overlay-tmp/manifest.json in tarball");
|
|
1613
|
+
}
|
|
1614
|
+
const manifest = JSON.parse(manifestContent);
|
|
1615
|
+
const checksumFiles = Object.keys(manifest.checksums);
|
|
1616
|
+
const failedChecksums = [];
|
|
1617
|
+
for (const file of checksumFiles) {
|
|
1618
|
+
const extractedPath = path.join(extractDir, file);
|
|
1619
|
+
try {
|
|
1620
|
+
const actualHash = await sha256File(extractedPath);
|
|
1621
|
+
if (actualHash !== manifest.checksums[file]) failedChecksums.push(`${file}: expected ${manifest.checksums[file]}, got ${actualHash}`);
|
|
1622
|
+
} catch {
|
|
1623
|
+
failedChecksums.push(`${file}: file not found in tarball`);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
if (failedChecksums.length > 0) throw new Error(`Overlay checksum verification failed for ${failedChecksums.length} file(s):\n` + failedChecksums.map((f) => ` - ${f}`).join("\n"));
|
|
1627
|
+
let filesApplied = 0;
|
|
1628
|
+
for (const file of checksumFiles) {
|
|
1629
|
+
const srcPath = path.join(extractDir, file);
|
|
1630
|
+
const destPath = path.join(repoDir, file);
|
|
1631
|
+
await fsPromises.mkdir(path.dirname(destPath), { recursive: true });
|
|
1632
|
+
await fsPromises.copyFile(srcPath, destPath);
|
|
1633
|
+
filesApplied++;
|
|
1634
|
+
}
|
|
1635
|
+
let filesDeleted = 0;
|
|
1636
|
+
for (const file of manifest.deletions) {
|
|
1637
|
+
const targetPath = path.join(repoDir, file);
|
|
1638
|
+
try {
|
|
1639
|
+
await fsPromises.unlink(targetPath);
|
|
1640
|
+
filesDeleted++;
|
|
1641
|
+
} catch {
|
|
1642
|
+
logger.debug("Deletion target not found, skipping", { file });
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
logger.info("Overlay applied successfully", {
|
|
1646
|
+
filesApplied,
|
|
1647
|
+
filesDeleted
|
|
1648
|
+
});
|
|
1649
|
+
return {
|
|
1650
|
+
filesApplied,
|
|
1651
|
+
filesDeleted,
|
|
1652
|
+
verified: true
|
|
1653
|
+
};
|
|
1654
|
+
} finally {
|
|
1655
|
+
await fsPromises.rm(tmpDir, {
|
|
1656
|
+
recursive: true,
|
|
1657
|
+
force: true
|
|
1658
|
+
}).catch(() => {});
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
//#endregion
|
|
1662
|
+
//#region src/execution/sandbox/workflow-runner.ts
|
|
1663
|
+
/**
|
|
1664
|
+
* Workflow Runner -- standalone entry point that runs INSIDE the sandbox.
|
|
1665
|
+
*
|
|
1666
|
+
* This is the code that actually executes customer workflows in isolation.
|
|
1667
|
+
* The agent process never loads or executes customer code -- only this runner does.
|
|
1668
|
+
*
|
|
1669
|
+
* Supports two IPC modes:
|
|
1670
|
+
* - Fork IPC: Used by bare-metal (bwrap) and Firecracker backends. Messages go
|
|
1671
|
+
* via Node.js IPC channel (process.send / process.on('message')).
|
|
1672
|
+
* - Stdio IPC: Used by container backend (docker exec). JSON-line messages flow
|
|
1673
|
+
* bidirectionally: agent writes to container stdin, runner writes to stdout.
|
|
1674
|
+
*
|
|
1675
|
+
* This file is compiled alongside the agent by rolldown (existing build), but
|
|
1676
|
+
* runs as a SEPARATE process spawned by the sandbox backend.
|
|
1677
|
+
*/
|
|
1678
|
+
process.on("uncaughtException", (err) => {
|
|
1679
|
+
process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
|
|
1680
|
+
if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
|
|
1681
|
+
process.exit(99);
|
|
1682
|
+
});
|
|
1683
|
+
process.on("unhandledRejection", (reason) => {
|
|
1684
|
+
const msg = toErrorMessage(reason);
|
|
1685
|
+
const stack = reason instanceof Error ? reason.stack : void 0;
|
|
1686
|
+
process.stderr.write(`[workflow-runner] UNHANDLED REJECTION: ${msg}\n`);
|
|
1687
|
+
if (stack) process.stderr.write(`[workflow-runner] Stack: ${stack}\n`);
|
|
1688
|
+
process.exit(98);
|
|
1689
|
+
});
|
|
1690
|
+
/** Whether we are running in fork IPC mode (Node IPC channel available). */
|
|
1691
|
+
const isForkMode = typeof process.send === "function";
|
|
1692
|
+
/**
|
|
1693
|
+
* Saved references to the original stdout/stderr write functions.
|
|
1694
|
+
* Used by sendMessage (container mode IPC) and output capture to avoid
|
|
1695
|
+
* infinite recursion when process.stdout.write is monkey-patched.
|
|
1696
|
+
*/
|
|
1697
|
+
const origStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
1698
|
+
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
1699
|
+
/**
|
|
1700
|
+
* Current step index for console.log/console.error capture.
|
|
1701
|
+
* When >= 0, process.stdout/stderr writes are intercepted and sent as
|
|
1702
|
+
* log.line IPC messages for the given step. Set to -1 outside step execution.
|
|
1703
|
+
*/
|
|
1704
|
+
let captureStepIndex = -1;
|
|
1705
|
+
/**
|
|
1706
|
+
* Workflow-level capture flag for the pre-step `prepare` phase
|
|
1707
|
+
* (module load, concurrency-group evaluation, rule evaluation).
|
|
1708
|
+
*
|
|
1709
|
+
* When true, process.stdout/stderr writes are captured and emitted as
|
|
1710
|
+
* log.line IPC messages with `stepIndex: -1` — the same bucket already used
|
|
1711
|
+
* by existing meta-messages like `[kici] Concurrency group: ...`. This means
|
|
1712
|
+
* user console.log inside module top-level, concurrency-group functions, and
|
|
1713
|
+
* rule check functions lands in the job's workflow-level log file
|
|
1714
|
+
* (`executions/{runId}/job-{name}/step--1.log`) alongside runner narration.
|
|
1715
|
+
*
|
|
1716
|
+
* Mutually exclusive with `captureStepIndex >= 0` — the step loop resets
|
|
1717
|
+
* this flag to false before setting `captureStepIndex` to a real step index.
|
|
1718
|
+
*/
|
|
1719
|
+
let capturePrepareActive = false;
|
|
1720
|
+
/** The maskedSend function used by the output capture. Set during main(). */
|
|
1721
|
+
let captureSendFn = null;
|
|
1722
|
+
function captureIsActive() {
|
|
1723
|
+
return (captureStepIndex >= 0 || capturePrepareActive) && captureSendFn !== null;
|
|
1724
|
+
}
|
|
1725
|
+
function captureTargetIndex() {
|
|
1726
|
+
return captureStepIndex >= 0 ? captureStepIndex : -1;
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* Install monkey-patches on process.stdout.write and process.stderr.write
|
|
1730
|
+
* so that console.log() / console.error() calls from customer step code
|
|
1731
|
+
* are captured and forwarded as log.line IPC messages.
|
|
1732
|
+
*
|
|
1733
|
+
* Without this, only subprocess output (via ctx.$) goes through zx's log
|
|
1734
|
+
* callback. Direct console.log() calls from pure JS/TS step code would be
|
|
1735
|
+
* lost (stdout goes to a pipe the fork-runner drains for debugging, but
|
|
1736
|
+
* never enters the log streaming pipeline).
|
|
1737
|
+
*/
|
|
1738
|
+
function installOutputCapture() {
|
|
1739
|
+
let stdoutBuf = "";
|
|
1740
|
+
let stderrBuf = "";
|
|
1741
|
+
process.stdout.write = ((chunk, encodingOrCb, cb) => {
|
|
1742
|
+
if (captureIsActive() && captureSendFn) {
|
|
1743
|
+
const text = typeof chunk === "string" ? chunk : chunk.toString(typeof encodingOrCb === "string" ? encodingOrCb : "utf8");
|
|
1744
|
+
stdoutBuf += text;
|
|
1745
|
+
const lines = stdoutBuf.split("\n");
|
|
1746
|
+
stdoutBuf = lines.pop();
|
|
1747
|
+
const stepIdx = captureTargetIndex();
|
|
1748
|
+
for (const line of lines) if (line) captureSendFn({
|
|
1749
|
+
type: "log.line",
|
|
1750
|
+
stepIndex: stepIdx,
|
|
1751
|
+
line
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
if (isForkMode) return origStdoutWrite(chunk, encodingOrCb, cb);
|
|
1755
|
+
const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
|
|
1756
|
+
if (callback) callback();
|
|
1757
|
+
return true;
|
|
1758
|
+
});
|
|
1759
|
+
process.stderr.write = ((chunk, encodingOrCb, cb) => {
|
|
1760
|
+
if (captureIsActive() && captureSendFn) {
|
|
1761
|
+
const text = typeof chunk === "string" ? chunk : chunk.toString(typeof encodingOrCb === "string" ? encodingOrCb : "utf8");
|
|
1762
|
+
stderrBuf += text;
|
|
1763
|
+
const lines = stderrBuf.split("\n");
|
|
1764
|
+
stderrBuf = lines.pop();
|
|
1765
|
+
const stepIdx = captureTargetIndex();
|
|
1766
|
+
for (const line of lines) if (line) captureSendFn({
|
|
1767
|
+
type: "log.line",
|
|
1768
|
+
stepIndex: stepIdx,
|
|
1769
|
+
line
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
return origStderrWrite(chunk, encodingOrCb, cb);
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Flush any remaining partial lines in the output capture buffers.
|
|
1777
|
+
* Called after each step completes to ensure no trailing output is lost.
|
|
1778
|
+
*/
|
|
1779
|
+
function flushOutputCapture() {
|
|
1780
|
+
if (captureIsActive()) {
|
|
1781
|
+
process.stdout.write("\n");
|
|
1782
|
+
process.stderr.write("\n");
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* Send a message from the runner to the agent.
|
|
1787
|
+
*
|
|
1788
|
+
* In fork mode: uses Node.js IPC channel (process.send).
|
|
1789
|
+
* In stdio mode: writes a JSON line to stdout via origStdoutWrite
|
|
1790
|
+
* (bypasses any monkey-patch on process.stdout.write).
|
|
1791
|
+
*/
|
|
1792
|
+
function sendMessage(msg) {
|
|
1793
|
+
if (isForkMode) process.send(msg);
|
|
1794
|
+
else origStdoutWrite(JSON.stringify(msg) + "\n");
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Readline interface for stdin in stdio (container) mode.
|
|
1798
|
+
* Created once and reused for both receiveRequest() and the response listener.
|
|
1799
|
+
*/
|
|
1800
|
+
let stdinRl = null;
|
|
1801
|
+
/**
|
|
1802
|
+
* Get or create the stdin readline interface for container/stdio mode.
|
|
1803
|
+
*/
|
|
1804
|
+
function getStdinRl() {
|
|
1805
|
+
if (!stdinRl) {
|
|
1806
|
+
process.stdin.setEncoding("utf-8");
|
|
1807
|
+
stdinRl = createInterface({
|
|
1808
|
+
input: process.stdin,
|
|
1809
|
+
crlfDelay: Infinity
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
return stdinRl;
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* Receive the execution request from the agent.
|
|
1816
|
+
*
|
|
1817
|
+
* In fork mode: listens for the first 'execute' message on the IPC channel.
|
|
1818
|
+
* In stdio mode: reads JSON-lines from stdin, resolves on the first 'execute' message.
|
|
1819
|
+
*
|
|
1820
|
+
* @returns The execute message containing the JobExecutionRequest.
|
|
1821
|
+
*/
|
|
1822
|
+
function receiveRequest() {
|
|
1823
|
+
if (isForkMode) return new Promise((resolve) => {
|
|
1824
|
+
const handler = (msg) => {
|
|
1825
|
+
if (msg.type === "execute") {
|
|
1826
|
+
process.removeListener("message", handler);
|
|
1827
|
+
resolve(msg);
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1830
|
+
process.on("message", handler);
|
|
1831
|
+
});
|
|
1832
|
+
else return new Promise((resolve, reject) => {
|
|
1833
|
+
const rl = getStdinRl();
|
|
1834
|
+
const handler = (line) => {
|
|
1835
|
+
try {
|
|
1836
|
+
const parsed = JSON.parse(line);
|
|
1837
|
+
if (parsed.type === "execute") {
|
|
1838
|
+
rl.removeListener("line", handler);
|
|
1839
|
+
resolve(parsed);
|
|
1840
|
+
}
|
|
1841
|
+
} catch (e) {
|
|
1842
|
+
rl.removeListener("line", handler);
|
|
1843
|
+
reject(/* @__PURE__ */ new Error(`Failed to parse stdin JSON: ${toErrorMessage(e)}`));
|
|
1844
|
+
}
|
|
1845
|
+
};
|
|
1846
|
+
rl.on("line", handler);
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
initZx();
|
|
1850
|
+
$.verbose = false;
|
|
1851
|
+
$.quiet = false;
|
|
1852
|
+
/** Global abort flag. Set when abort message or SIGTERM is received. */
|
|
1853
|
+
let aborted = false;
|
|
1854
|
+
/**
|
|
1855
|
+
* Force cancel flag. When true, skip all remaining hooks and exit immediately.
|
|
1856
|
+
* Set when abort IPC arrives with force=true, or on second cancel while already cancelling.
|
|
1857
|
+
*/
|
|
1858
|
+
let forceAborted = false;
|
|
1859
|
+
/**
|
|
1860
|
+
* Pending promises for event.emit requests awaiting responses from the agent.
|
|
1861
|
+
*
|
|
1862
|
+
* Key: requestId (correlates EventEmitRequest -> EventEmitResponse)
|
|
1863
|
+
* Value: resolve/reject pair for the waiting ctx.emit() call
|
|
1864
|
+
*/
|
|
1865
|
+
const pendingEmitResponses = /* @__PURE__ */ new Map();
|
|
1866
|
+
/** Default timeout for event.emit responses (5 seconds per research doc). */
|
|
1867
|
+
const EMIT_RESPONSE_TIMEOUT_MS = 5e3;
|
|
1868
|
+
/**
|
|
1869
|
+
* Wait for an event.emit.response from the agent with the given requestId.
|
|
1870
|
+
*
|
|
1871
|
+
* On timeout, resolves with a synthetic success receipt and logs a warning
|
|
1872
|
+
* (per research doc pitfall 5: event was already persisted by orchestrator,
|
|
1873
|
+
* so it will still be routed even if the ack is lost).
|
|
1874
|
+
*/
|
|
1875
|
+
function waitForEmitResponse(requestId) {
|
|
1876
|
+
return new Promise((resolve) => {
|
|
1877
|
+
const timer = setTimeout(() => {
|
|
1878
|
+
pendingEmitResponses.delete(requestId);
|
|
1879
|
+
process.stderr.write(`[workflow-runner] event.emit response timeout for requestId=${requestId} -- returning synthetic receipt\n`);
|
|
1880
|
+
resolve({
|
|
1881
|
+
type: "event.emit.response",
|
|
1882
|
+
requestId,
|
|
1883
|
+
deliveryId: `timeout-${requestId}`
|
|
1884
|
+
});
|
|
1885
|
+
}, EMIT_RESPONSE_TIMEOUT_MS);
|
|
1886
|
+
pendingEmitResponses.set(requestId, {
|
|
1887
|
+
resolve: (response) => {
|
|
1888
|
+
clearTimeout(timer);
|
|
1889
|
+
pendingEmitResponses.delete(requestId);
|
|
1890
|
+
resolve(response);
|
|
1891
|
+
},
|
|
1892
|
+
reject: (err) => {
|
|
1893
|
+
clearTimeout(timer);
|
|
1894
|
+
pendingEmitResponses.delete(requestId);
|
|
1895
|
+
resolve({
|
|
1896
|
+
type: "event.emit.response",
|
|
1897
|
+
requestId,
|
|
1898
|
+
error: err.message
|
|
1899
|
+
});
|
|
1900
|
+
},
|
|
1901
|
+
timer
|
|
1902
|
+
});
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Pending promise for the concurrency.ack response from the agent.
|
|
1907
|
+
* Only one outstanding concurrency report per job (one workflow = one group).
|
|
1908
|
+
*/
|
|
1909
|
+
let pendingConcurrencyAck = null;
|
|
1910
|
+
/**
|
|
1911
|
+
* Wait for a concurrency.ack from the agent (relayed from orchestrator).
|
|
1912
|
+
* The timeout is configurable (default 30s) and fails the job on expiry.
|
|
1913
|
+
*/
|
|
1914
|
+
function waitForConcurrencyAck(timeoutMs) {
|
|
1915
|
+
return new Promise((resolve, reject) => {
|
|
1916
|
+
const timer = setTimeout(() => {
|
|
1917
|
+
pendingConcurrencyAck = null;
|
|
1918
|
+
reject(/* @__PURE__ */ new Error(`Concurrency group ack timed out after ${timeoutMs}ms`));
|
|
1919
|
+
}, timeoutMs);
|
|
1920
|
+
pendingConcurrencyAck = {
|
|
1921
|
+
resolve: (ack) => {
|
|
1922
|
+
clearTimeout(timer);
|
|
1923
|
+
pendingConcurrencyAck = null;
|
|
1924
|
+
resolve(ack);
|
|
1925
|
+
},
|
|
1926
|
+
reject: (err) => {
|
|
1927
|
+
clearTimeout(timer);
|
|
1928
|
+
pendingConcurrencyAck = null;
|
|
1929
|
+
reject(err);
|
|
1930
|
+
},
|
|
1931
|
+
timer
|
|
1932
|
+
};
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* Pending promises for agent.api.response messages from the agent.
|
|
1937
|
+
* Key: requestId, Value: resolve/reject pair.
|
|
1938
|
+
*/
|
|
1939
|
+
const pendingApiResponses = /* @__PURE__ */ new Map();
|
|
1940
|
+
const API_RESPONSE_TIMEOUT_MS = 15e3;
|
|
1941
|
+
/**
|
|
1942
|
+
* Wait for an agent.api.response from the agent with the given requestId.
|
|
1943
|
+
*/
|
|
1944
|
+
function waitForApiResponse(requestId) {
|
|
1945
|
+
return new Promise((resolve, reject) => {
|
|
1946
|
+
const timer = setTimeout(() => {
|
|
1947
|
+
pendingApiResponses.delete(requestId);
|
|
1948
|
+
reject(/* @__PURE__ */ new Error(`Agent API request timed out after ${API_RESPONSE_TIMEOUT_MS}ms`));
|
|
1949
|
+
}, API_RESPONSE_TIMEOUT_MS);
|
|
1950
|
+
pendingApiResponses.set(requestId, {
|
|
1951
|
+
resolve: (result) => {
|
|
1952
|
+
clearTimeout(timer);
|
|
1953
|
+
pendingApiResponses.delete(requestId);
|
|
1954
|
+
resolve(result);
|
|
1955
|
+
},
|
|
1956
|
+
reject: (err) => {
|
|
1957
|
+
clearTimeout(timer);
|
|
1958
|
+
pendingApiResponses.delete(requestId);
|
|
1959
|
+
reject(err);
|
|
1960
|
+
},
|
|
1961
|
+
timer
|
|
1962
|
+
});
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* Dispatch an agent-to-runner message to the appropriate pending handler.
|
|
1967
|
+
* Shared between fork mode (IPC channel) and stdio mode (stdin readline).
|
|
1968
|
+
*/
|
|
1969
|
+
function dispatchAgentMessage(msg) {
|
|
1970
|
+
if (msg.type === "abort") {
|
|
1971
|
+
aborted = true;
|
|
1972
|
+
if (msg.force) forceAborted = true;
|
|
1973
|
+
} else if (msg.type === "event.emit.response") {
|
|
1974
|
+
const pending = pendingEmitResponses.get(msg.requestId);
|
|
1975
|
+
if (pending) pending.resolve(msg);
|
|
1976
|
+
} else if (msg.type === "concurrency.ack") {
|
|
1977
|
+
if (pendingConcurrencyAck) pendingConcurrencyAck.resolve(msg);
|
|
1978
|
+
} else if (msg.type === "agent.api.response") {
|
|
1979
|
+
const pending = pendingApiResponses.get(msg.requestId);
|
|
1980
|
+
if (pending) if (msg.error) pending.reject(new Error(msg.error));
|
|
1981
|
+
else pending.resolve(msg.result);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
if (isForkMode) process.on("message", (msg) => {
|
|
1985
|
+
dispatchAgentMessage(msg);
|
|
1986
|
+
});
|
|
1987
|
+
else getStdinRl().on("line", (line) => {
|
|
1988
|
+
try {
|
|
1989
|
+
const msg = JSON.parse(line);
|
|
1990
|
+
if (msg.type !== "execute") dispatchAgentMessage(msg);
|
|
1991
|
+
} catch {}
|
|
1992
|
+
});
|
|
1993
|
+
process.on("SIGTERM", () => {
|
|
1994
|
+
aborted = true;
|
|
1995
|
+
});
|
|
1996
|
+
/**
|
|
1997
|
+
* Create a Logger that sends log lines via IPC.
|
|
1998
|
+
*
|
|
1999
|
+
* Each log call formats the line and sends it as a log.line IPC message.
|
|
2000
|
+
* Format matches the existing createStepLogger: [stepName] [level?] message args...
|
|
2001
|
+
*/
|
|
2002
|
+
function createIpcLogger(stepIndex, stepName, sendFn) {
|
|
2003
|
+
const formatLine = (level, message, args) => {
|
|
2004
|
+
const formatted = args.length > 0 ? `${message} ${args.join(" ")}` : message;
|
|
2005
|
+
return `[${stepName}] ${level !== "info" ? `[${level}] ` : ""}${formatted}`;
|
|
2006
|
+
};
|
|
2007
|
+
return {
|
|
2008
|
+
info: (message, ...args) => {
|
|
2009
|
+
sendFn({
|
|
2010
|
+
type: "log.line",
|
|
2011
|
+
stepIndex,
|
|
2012
|
+
line: formatLine("info", message, args)
|
|
2013
|
+
});
|
|
2014
|
+
},
|
|
2015
|
+
warn: (message, ...args) => {
|
|
2016
|
+
sendFn({
|
|
2017
|
+
type: "log.line",
|
|
2018
|
+
stepIndex,
|
|
2019
|
+
line: formatLine("warn", message, args)
|
|
2020
|
+
});
|
|
2021
|
+
},
|
|
2022
|
+
error: (message, ...args) => {
|
|
2023
|
+
sendFn({
|
|
2024
|
+
type: "log.line",
|
|
2025
|
+
stepIndex,
|
|
2026
|
+
line: formatLine("error", message, args)
|
|
2027
|
+
});
|
|
2028
|
+
},
|
|
2029
|
+
debug: (message, ...args) => {
|
|
2030
|
+
if (process.env.KICI_LOG_LEVEL === "debug") sendFn({
|
|
2031
|
+
type: "log.line",
|
|
2032
|
+
stepIndex,
|
|
2033
|
+
line: formatLine("debug", message, args)
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
};
|
|
2037
|
+
}
|
|
2038
|
+
/**
|
|
2039
|
+
* Build StepSecrets from the job execution request, wired with the per-step
|
|
2040
|
+
* file-mount host (used by `ctx.secrets.mountFile` / `exposeFile`).
|
|
2041
|
+
*
|
|
2042
|
+
* Returns a `{ secrets, dispose }` handle: `secrets` is bound into the step
|
|
2043
|
+
* context, and `dispose` is invoked by the step-loop's `finally` to remove
|
|
2044
|
+
* the per-step tmpdir and clear any env vars set via `exposeFile`.
|
|
2045
|
+
*
|
|
2046
|
+
* The mounted-file content is registered with the active `LogMasker` so any
|
|
2047
|
+
* subprocess that echoes the credential gets `***`-replaced in the streamed
|
|
2048
|
+
* log -- same masker the rest of the runner uses, so masking is global, not
|
|
2049
|
+
* per-step.
|
|
2050
|
+
*/
|
|
2051
|
+
function buildStepSecrets(request, masker, onMaskerSecretsAdded) {
|
|
2052
|
+
const mergedFlat = buildMergedFlatSecrets(request.secrets ?? {}, request.namespacedSecrets ?? {});
|
|
2053
|
+
let stepTmpdir = null;
|
|
2054
|
+
const exposedEnvVars = /* @__PURE__ */ new Set();
|
|
2055
|
+
let mountCounter = 0;
|
|
2056
|
+
const env = process.env;
|
|
2057
|
+
async function ensureTmpdir() {
|
|
2058
|
+
if (stepTmpdir === null) stepTmpdir = await fsPromises.mkdtemp(join(tmpdir(), "kici-secret-files-"));
|
|
2059
|
+
return stepTmpdir;
|
|
2060
|
+
}
|
|
2061
|
+
return createStepSecrets(mergedFlat, env, request.secretMeta, {
|
|
2062
|
+
host: {
|
|
2063
|
+
async writeMountedFile(args) {
|
|
2064
|
+
const dir = await ensureTmpdir();
|
|
2065
|
+
mountCounter += 1;
|
|
2066
|
+
const filePath = join(dir, args.name ?? `secret-${mountCounter}`);
|
|
2067
|
+
await fsPromises.writeFile(filePath, args.content);
|
|
2068
|
+
await fsPromises.chmod(filePath, args.mode);
|
|
2069
|
+
masker.registerSecrets({ [`__mount_${mountCounter}__`]: args.content });
|
|
2070
|
+
onMaskerSecretsAdded();
|
|
2071
|
+
return filePath;
|
|
2072
|
+
},
|
|
2073
|
+
trackExposedEnv(envVar) {
|
|
2074
|
+
exposedEnvVars.add(envVar);
|
|
2075
|
+
}
|
|
2076
|
+
},
|
|
2077
|
+
cleanup: async () => {
|
|
2078
|
+
for (const envVar of exposedEnvVars) {
|
|
2079
|
+
delete env[envVar];
|
|
2080
|
+
delete process.env[envVar];
|
|
2081
|
+
}
|
|
2082
|
+
exposedEnvVars.clear();
|
|
2083
|
+
if (stepTmpdir !== null) {
|
|
2084
|
+
await fsPromises.rm(stepTmpdir, {
|
|
2085
|
+
recursive: true,
|
|
2086
|
+
force: true
|
|
2087
|
+
});
|
|
2088
|
+
stepTmpdir = null;
|
|
2089
|
+
}
|
|
2090
|
+
},
|
|
2091
|
+
onDisposeError: (err) => {
|
|
2092
|
+
origStderrWrite(`[workflow-runner] secret-file cleanup error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2093
|
+
}
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* Create a LogMasker initialized with all secret values from the request.
|
|
2098
|
+
*
|
|
2099
|
+
* Collects values from both flat secrets and all namespaced context secrets,
|
|
2100
|
+
* deduplicating before registration.
|
|
2101
|
+
*/
|
|
2102
|
+
function createSecretMasker(request) {
|
|
2103
|
+
const masker = new LogMasker();
|
|
2104
|
+
const allSecrets = {};
|
|
2105
|
+
if (request.secrets) Object.assign(allSecrets, request.secrets);
|
|
2106
|
+
if (request.namespacedSecrets) for (const contextSecrets of Object.values(request.namespacedSecrets)) Object.assign(allSecrets, contextSecrets);
|
|
2107
|
+
masker.registerSecrets(allSecrets);
|
|
2108
|
+
return masker;
|
|
2109
|
+
}
|
|
2110
|
+
/**
|
|
2111
|
+
* Create a StepContext natively inside the workflow runner.
|
|
2112
|
+
*
|
|
2113
|
+
* The context is reconstructed from the environment and IPC request fields --
|
|
2114
|
+
* NOT serialized across the process boundary. This means zx $ runs natively
|
|
2115
|
+
* inside this process with full shell access.
|
|
2116
|
+
*/
|
|
2117
|
+
function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets) {
|
|
2118
|
+
let zxLineBuf = "";
|
|
2119
|
+
const step$ = $({
|
|
2120
|
+
cwd: workDir,
|
|
2121
|
+
env: { ...process.env },
|
|
2122
|
+
verbose: false,
|
|
2123
|
+
quiet: false,
|
|
2124
|
+
log: ((entry) => {
|
|
2125
|
+
if (entry.kind === "stdout" || entry.kind === "stderr") {
|
|
2126
|
+
const text = typeof entry.data === "string" ? entry.data : String(entry.data ?? "");
|
|
2127
|
+
zxLineBuf += text;
|
|
2128
|
+
const lines = zxLineBuf.split("\n");
|
|
2129
|
+
zxLineBuf = lines.pop();
|
|
2130
|
+
for (const line of lines) if (line) maskedSendFn({
|
|
2131
|
+
type: "log.line",
|
|
2132
|
+
stepIndex,
|
|
2133
|
+
line
|
|
2134
|
+
});
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
})
|
|
2138
|
+
});
|
|
2139
|
+
const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
|
|
2140
|
+
return {
|
|
2141
|
+
$: step$,
|
|
2142
|
+
log,
|
|
2143
|
+
env: process.env,
|
|
2144
|
+
setEnv: (key, value) => {
|
|
2145
|
+
if (operatorSecretKeys.has(key)) {
|
|
2146
|
+
log.warn(`Cannot override operator secret "${key}" via setEnv — value preserved`);
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
process.env[key] = value;
|
|
2150
|
+
},
|
|
2151
|
+
addPath: (dir) => {
|
|
2152
|
+
process.env.PATH = dir + ":" + (process.env.PATH ?? "");
|
|
2153
|
+
},
|
|
2154
|
+
inputs: {},
|
|
2155
|
+
secrets,
|
|
2156
|
+
workflow: { name: request.workflowName },
|
|
2157
|
+
job: {
|
|
2158
|
+
name: request.jobName,
|
|
2159
|
+
runsOn: request.runsOn
|
|
2160
|
+
},
|
|
2161
|
+
isTestRun: request.isTestRun ?? false,
|
|
2162
|
+
environment: request.environment,
|
|
2163
|
+
emit: async (eventName, payload, options) => {
|
|
2164
|
+
const reqId = randomUUID();
|
|
2165
|
+
sendMessage({
|
|
2166
|
+
type: "event.emit",
|
|
2167
|
+
requestId: reqId,
|
|
2168
|
+
eventName,
|
|
2169
|
+
payload: payload ?? {},
|
|
2170
|
+
...options?.target && { target: options.target }
|
|
2171
|
+
});
|
|
2172
|
+
const response = await waitForEmitResponse(reqId);
|
|
2173
|
+
if (response.error) throw new Error(`Event emission failed: ${response.error}`);
|
|
2174
|
+
return { deliveryId: response.deliveryId };
|
|
2175
|
+
},
|
|
2176
|
+
outputsOf: (ref) => {
|
|
2177
|
+
return resolveStepOutputs(ref, outputsMap, refMap);
|
|
2178
|
+
},
|
|
2179
|
+
jobOutputs: (ref) => {
|
|
2180
|
+
return resolveJobOutputs(ref, jobOutputsMap);
|
|
2181
|
+
},
|
|
2182
|
+
setSecretOutput: (key, value) => {
|
|
2183
|
+
secretOutputs.set(key, value);
|
|
2184
|
+
},
|
|
2185
|
+
kici: buildKiciApi(async (method, params) => {
|
|
2186
|
+
const reqId = randomUUID();
|
|
2187
|
+
sendMessage({
|
|
2188
|
+
type: "agent.api.request",
|
|
2189
|
+
requestId: reqId,
|
|
2190
|
+
method,
|
|
2191
|
+
params: params ?? {}
|
|
2192
|
+
});
|
|
2193
|
+
return waitForApiResponse(reqId);
|
|
2194
|
+
}),
|
|
2195
|
+
...request.event && { rawPayload: request.event },
|
|
2196
|
+
...request.provider && { provider: request.provider }
|
|
2197
|
+
};
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Check if a file exists at the given path.
|
|
2201
|
+
*/
|
|
2202
|
+
function fileExists(p) {
|
|
2203
|
+
return existsSync(p);
|
|
2204
|
+
}
|
|
2205
|
+
function trace(msg) {
|
|
2206
|
+
origStderrWrite(`[workflow-runner:trace] ${msg}\n`);
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Send a job.complete + process.exit. Centralises the abort-mid-prepare path
|
|
2210
|
+
* (after clone / after deps / after rules) so the messages and exit code
|
|
2211
|
+
* stay consistent.
|
|
2212
|
+
*/
|
|
2213
|
+
function abortAndExit(reason) {
|
|
2214
|
+
trace(reason);
|
|
2215
|
+
flushOutputCapture();
|
|
2216
|
+
capturePrepareActive = false;
|
|
2217
|
+
sendMessage({
|
|
2218
|
+
type: "job.complete",
|
|
2219
|
+
status: ExecutionJobStatus.enum.failed,
|
|
2220
|
+
stepResults: []
|
|
2221
|
+
});
|
|
2222
|
+
process.exit(1);
|
|
2223
|
+
}
|
|
2224
|
+
/**
|
|
2225
|
+
* Phase 1 — Clone the source / workflow repos (or skip when checkout=false).
|
|
2226
|
+
* Three modes: full-repo overlay-only (no clone), global dual-clone
|
|
2227
|
+
* (workflow + source), or single-repo clone. Each mode emits the same
|
|
2228
|
+
* progress IPC log lines as before.
|
|
2229
|
+
*/
|
|
2230
|
+
async function cloneRepoIfRequested(request, workDir, workflowDir, sourceDir, isGlobal) {
|
|
2231
|
+
if (request.checkout === false) return;
|
|
2232
|
+
if (request.fullRepo) {
|
|
2233
|
+
trace("fullRepo mode -- skipping git clone, workspace from overlay");
|
|
2234
|
+
await fsPromises.mkdir(workDir, { recursive: true });
|
|
2235
|
+
sendMessage({
|
|
2236
|
+
type: "log.line",
|
|
2237
|
+
stepIndex: -1,
|
|
2238
|
+
line: "[workflow-runner] Full-repo mode: skipping git clone (workspace from overlay tarball)"
|
|
2239
|
+
});
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2242
|
+
if (isGlobal) {
|
|
2243
|
+
trace(`starting dual-clone (global workflow)`);
|
|
2244
|
+
await fsPromises.mkdir(workflowDir, { recursive: true });
|
|
2245
|
+
await fsPromises.mkdir(sourceDir, { recursive: true });
|
|
2246
|
+
const workflowAuth = request.workflowAuth ?? request.sourceAuth;
|
|
2247
|
+
const sourceAuth = request.sourceAuth ?? request.workflowAuth;
|
|
2248
|
+
sendMessage({
|
|
2249
|
+
type: "log.line",
|
|
2250
|
+
stepIndex: -1,
|
|
2251
|
+
line: `[workflow-runner] Global workflow: cloning workflow repo ${request.workflowRepoUrl} ref=${request.workflowRef} into ${workflowDir}`
|
|
2252
|
+
});
|
|
2253
|
+
await gitClone({
|
|
2254
|
+
repoUrl: request.workflowRepoUrl,
|
|
2255
|
+
ref: request.workflowRef ?? "",
|
|
2256
|
+
sha: request.workflowSha ?? "",
|
|
2257
|
+
workDir: workflowDir,
|
|
2258
|
+
gitAuth: workflowAuth,
|
|
2259
|
+
token: workflowAuth ? void 0 : request.token
|
|
2260
|
+
});
|
|
2261
|
+
trace("workflow repo clone complete");
|
|
2262
|
+
await excludeScratchFromGit(workflowDir);
|
|
2263
|
+
sendMessage({
|
|
2264
|
+
type: "log.line",
|
|
2265
|
+
stepIndex: -1,
|
|
2266
|
+
line: `[workflow-runner] Global workflow: cloning source repo ${request.repoUrl} ref=${request.ref} into ${sourceDir}`
|
|
2267
|
+
});
|
|
2268
|
+
await gitClone({
|
|
2269
|
+
repoUrl: request.repoUrl,
|
|
2270
|
+
ref: request.ref,
|
|
2271
|
+
sha: request.sha,
|
|
2272
|
+
workDir: sourceDir,
|
|
2273
|
+
gitAuth: sourceAuth,
|
|
2274
|
+
token: sourceAuth ? void 0 : request.token
|
|
2275
|
+
});
|
|
2276
|
+
trace("source repo clone complete");
|
|
2277
|
+
sendMessage({
|
|
2278
|
+
type: "log.line",
|
|
2279
|
+
stepIndex: -1,
|
|
2280
|
+
line: "[workflow-runner] Dual-clone complete"
|
|
2281
|
+
});
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
trace("starting git clone");
|
|
2285
|
+
sendMessage({
|
|
2286
|
+
type: "log.line",
|
|
2287
|
+
stepIndex: -1,
|
|
2288
|
+
line: `[workflow-runner] Cloning ${request.repoUrl} ref=${request.ref} into ${workDir}`
|
|
2289
|
+
});
|
|
2290
|
+
await gitClone({
|
|
2291
|
+
repoUrl: request.repoUrl,
|
|
2292
|
+
ref: request.ref,
|
|
2293
|
+
sha: request.sha,
|
|
2294
|
+
workDir,
|
|
2295
|
+
gitAuth: request.sourceAuth,
|
|
2296
|
+
token: request.sourceAuth ? void 0 : request.token
|
|
2297
|
+
});
|
|
2298
|
+
await excludeScratchFromGit(workDir);
|
|
2299
|
+
sendMessage({
|
|
2300
|
+
type: "log.line",
|
|
2301
|
+
stepIndex: -1,
|
|
2302
|
+
line: "[workflow-runner] Clone complete"
|
|
2303
|
+
});
|
|
2304
|
+
trace("git clone complete");
|
|
2305
|
+
}
|
|
2306
|
+
/**
|
|
2307
|
+
* Phase 1b — Apply the encrypted overlay tarball when present (test runs
|
|
2308
|
+
* with uncommitted changes). For global workflows the overlay applies to the
|
|
2309
|
+
* workflow repo (the one carrying `.kici/`).
|
|
2310
|
+
*/
|
|
2311
|
+
async function applyOverlayIfRequested(request, workflowDir) {
|
|
2312
|
+
if (!request.tarballUrl || !request.cliPublicKey || !request.orchestratorPrivateKey) return;
|
|
2313
|
+
trace("applying overlay tarball");
|
|
2314
|
+
sendMessage({
|
|
2315
|
+
type: "log.line",
|
|
2316
|
+
stepIndex: -1,
|
|
2317
|
+
line: "[workflow-runner] Applying overlay (uncommitted changes)"
|
|
2318
|
+
});
|
|
2319
|
+
const overlayResult = await applyOverlay({
|
|
2320
|
+
tarballUrl: request.tarballUrl,
|
|
2321
|
+
cliPublicKey: request.cliPublicKey,
|
|
2322
|
+
orchestratorPrivateKey: request.orchestratorPrivateKey,
|
|
2323
|
+
repoDir: workflowDir
|
|
2324
|
+
});
|
|
2325
|
+
sendMessage({
|
|
2326
|
+
type: "log.line",
|
|
2327
|
+
stepIndex: -1,
|
|
2328
|
+
line: `[workflow-runner] Overlay applied: ${overlayResult.filesApplied} files changed, ${overlayResult.filesDeleted} files deleted`
|
|
2329
|
+
});
|
|
2330
|
+
trace(`overlay applied: ${overlayResult.filesApplied} files, ${overlayResult.filesDeleted} deletions`);
|
|
2331
|
+
}
|
|
2332
|
+
/**
|
|
2333
|
+
* Phase 2 — Restore deps from cache (with hash-mismatch hard-fail) OR fall
|
|
2334
|
+
* back to inline install. Skipped when `.kici/package.json` doesn't exist.
|
|
2335
|
+
* For global workflows deps come from the workflow repo (where `.kici/` lives).
|
|
2336
|
+
*/
|
|
2337
|
+
async function installDependenciesIfNeeded(workflowDir, request) {
|
|
2338
|
+
const kiciDir = join(workflowDir, ".kici");
|
|
2339
|
+
const hasPackageJson = fileExists(join(kiciDir, "package.json"));
|
|
2340
|
+
trace(`deps: kiciDir=${kiciDir}, hasPackageJson=${hasPackageJson}, depsUrl=${request.depsUrl ?? "none"}`);
|
|
2341
|
+
if (!hasPackageJson) return;
|
|
2342
|
+
if (request.depsUrl) {
|
|
2343
|
+
trace("restoring deps from cache");
|
|
2344
|
+
sendMessage({
|
|
2345
|
+
type: "log.line",
|
|
2346
|
+
stepIndex: -1,
|
|
2347
|
+
line: `[workflow-runner] Restoring deps from ${request.depsUrl}`
|
|
2348
|
+
});
|
|
2349
|
+
try {
|
|
2350
|
+
await restoreDeps(workflowDir, request.depsUrl, request.depsHash);
|
|
2351
|
+
sendMessage({
|
|
2352
|
+
type: "log.line",
|
|
2353
|
+
stepIndex: -1,
|
|
2354
|
+
line: "[workflow-runner] Deps restored from cache"
|
|
2355
|
+
});
|
|
2356
|
+
trace("deps restored from cache");
|
|
2357
|
+
} catch (err) {
|
|
2358
|
+
if (err instanceof Error && err.message.includes("hash mismatch")) throw err;
|
|
2359
|
+
trace(`cache restore failed: ${toErrorMessage(err)}, falling back`);
|
|
2360
|
+
sendMessage({
|
|
2361
|
+
type: "log.line",
|
|
2362
|
+
stepIndex: -1,
|
|
2363
|
+
line: `[workflow-runner] Cache restore failed (${toErrorMessage(err)}), falling back to inline install`
|
|
2364
|
+
});
|
|
2365
|
+
await installDeps(kiciDir, {
|
|
2366
|
+
npmRegistries: request.npmRegistries,
|
|
2367
|
+
installEnvSecrets: request.installEnvSecrets,
|
|
2368
|
+
jobIdShort: request.jobIdShort
|
|
2369
|
+
});
|
|
2370
|
+
trace("fallback install complete");
|
|
2371
|
+
}
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
if (fileExists(join(kiciDir, "node_modules"))) {
|
|
2375
|
+
trace("node_modules already exists, skipping install");
|
|
2376
|
+
sendMessage({
|
|
2377
|
+
type: "log.line",
|
|
2378
|
+
stepIndex: -1,
|
|
2379
|
+
line: "[workflow-runner] Deps already present (node_modules exists), skipping install"
|
|
2380
|
+
});
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
trace("installing deps inline (no cache)");
|
|
2384
|
+
sendMessage({
|
|
2385
|
+
type: "log.line",
|
|
2386
|
+
stepIndex: -1,
|
|
2387
|
+
line: "[workflow-runner] Installing deps inline (no cache)"
|
|
2388
|
+
});
|
|
2389
|
+
try {
|
|
2390
|
+
await installDeps(kiciDir, {
|
|
2391
|
+
npmRegistries: request.npmRegistries,
|
|
2392
|
+
installEnvSecrets: request.installEnvSecrets,
|
|
2393
|
+
jobIdShort: request.jobIdShort
|
|
2394
|
+
});
|
|
2395
|
+
trace("installDeps() returned successfully");
|
|
2396
|
+
} catch (depErr) {
|
|
2397
|
+
const depMsg = toErrorMessage(depErr);
|
|
2398
|
+
const depStack = depErr instanceof Error ? depErr.stack : void 0;
|
|
2399
|
+
trace(`installDeps THREW: ${depMsg}`);
|
|
2400
|
+
if (depStack) trace(`installDeps stack: ${depStack}`);
|
|
2401
|
+
throw depErr;
|
|
2402
|
+
}
|
|
2403
|
+
sendMessage({
|
|
2404
|
+
type: "log.line",
|
|
2405
|
+
stepIndex: -1,
|
|
2406
|
+
line: "[workflow-runner] Deps installed"
|
|
2407
|
+
});
|
|
2408
|
+
trace("deps installed IPC sent");
|
|
2409
|
+
}
|
|
2410
|
+
/**
|
|
2411
|
+
* Phase 2b — Restore the cached `.kici/` source tarball over the cloned
|
|
2412
|
+
* workflow root so the loaded workflow's contentHash matches the lock file
|
|
2413
|
+
* exactly. No-op when no `sourceTarUrl` was provided.
|
|
2414
|
+
*/
|
|
2415
|
+
async function restoreSourceTarballIfRequested(workflowRoot, request) {
|
|
2416
|
+
if (!request.sourceTarUrl) return;
|
|
2417
|
+
trace(`restoring .kici/ source from tarball url=${request.sourceTarUrl}`);
|
|
2418
|
+
sendMessage({
|
|
2419
|
+
type: "log.line",
|
|
2420
|
+
stepIndex: -1,
|
|
2421
|
+
line: "[workflow-runner] Restoring .kici/ source from cached tarball"
|
|
2422
|
+
});
|
|
2423
|
+
await restoreSource(workflowRoot, request.sourceTarUrl);
|
|
2424
|
+
trace("source tarball restored");
|
|
2425
|
+
}
|
|
2426
|
+
/**
|
|
2427
|
+
* Phase 3 — Install the stdout/stderr capture, then load the workflow module
|
|
2428
|
+
* via the oxc-transform ESM loader. Capture is enabled for the prepare phase
|
|
2429
|
+
* (module load → concurrency → rules) so user `console.log` lands in the
|
|
2430
|
+
* workflow-level log bucket (stepIndex: -1). On load failure we flush + clear
|
|
2431
|
+
* the prepare flag before rethrowing so the catch handler in main() doesn't
|
|
2432
|
+
* see stale buffer state.
|
|
2433
|
+
*/
|
|
2434
|
+
async function loadWorkflowModuleWithCapture(workflowRoot, request, isGlobal, maskedSend) {
|
|
2435
|
+
let sourceFile = request.sourceFile ?? ".kici/workflows/ci.ts";
|
|
2436
|
+
if (sourceFile && !sourceFile.startsWith(".kici/")) sourceFile = `.kici/${sourceFile}`;
|
|
2437
|
+
trace(`loading workflow module, sourceFile=${sourceFile}, workflowRoot=${workflowRoot}, cachedSource=${!!request.sourceTarUrl}`);
|
|
2438
|
+
sendMessage({
|
|
2439
|
+
type: "log.line",
|
|
2440
|
+
stepIndex: -1,
|
|
2441
|
+
line: `[workflow-runner] Loading workflow module${isGlobal ? " (global=true, from workflow repo)" : ""}`
|
|
2442
|
+
});
|
|
2443
|
+
captureSendFn = maskedSend;
|
|
2444
|
+
installOutputCapture();
|
|
2445
|
+
capturePrepareActive = true;
|
|
2446
|
+
try {
|
|
2447
|
+
const loaded = await loadWorkflowSource(workflowRoot, sourceFile, request.contentHash, request.resolvedHashFiles);
|
|
2448
|
+
sendMessage({
|
|
2449
|
+
type: "log.line",
|
|
2450
|
+
stepIndex: -1,
|
|
2451
|
+
line: "[workflow-runner] Workflow module loaded"
|
|
2452
|
+
});
|
|
2453
|
+
trace(`workflow module loaded, exports=${Object.keys(loaded.module).join(",")}`);
|
|
2454
|
+
return loaded;
|
|
2455
|
+
} catch (err) {
|
|
2456
|
+
flushOutputCapture();
|
|
2457
|
+
capturePrepareActive = false;
|
|
2458
|
+
throw err;
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
/**
|
|
2462
|
+
* Phase 4b — Evaluate the user-defined `concurrency.group(...)` function with
|
|
2463
|
+
* a timeout, report the resulting key to the orchestrator, and act on the
|
|
2464
|
+
* returned ack. `wait` and `cancel` paths exit the process directly (the run
|
|
2465
|
+
* is over from the runner's perspective). Returns 'proceed' to the caller in
|
|
2466
|
+
* the success path.
|
|
2467
|
+
*
|
|
2468
|
+
* Returns 'failed' instead of exiting when group evaluation throws, so
|
|
2469
|
+
* main() can keep its single exit-on-error point.
|
|
2470
|
+
*/
|
|
2471
|
+
async function evaluateConcurrencyGroupIfPresent(workflow, request) {
|
|
2472
|
+
if (!workflow.concurrency?.group) return "proceed";
|
|
2473
|
+
trace("evaluating concurrency group function");
|
|
2474
|
+
const concurrencyTimeoutMs = request.concurrencyEvaluationTimeoutMs ?? 3e4;
|
|
2475
|
+
const groupCtx = {
|
|
2476
|
+
branch: request.branch ?? request.ref,
|
|
2477
|
+
event: request.event ?? {}
|
|
2478
|
+
};
|
|
2479
|
+
try {
|
|
2480
|
+
const ac = new AbortController();
|
|
2481
|
+
const concurrencyTimer = setTimeout(() => ac.abort(), concurrencyTimeoutMs);
|
|
2482
|
+
let groupKey;
|
|
2483
|
+
try {
|
|
2484
|
+
groupKey = await Promise.race([Promise.resolve(workflow.concurrency.group(groupCtx)), new Promise((_, reject) => {
|
|
2485
|
+
ac.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error(`Concurrency group evaluation timed out after ${concurrencyTimeoutMs}ms`)));
|
|
2486
|
+
})]);
|
|
2487
|
+
} finally {
|
|
2488
|
+
clearTimeout(concurrencyTimer);
|
|
2489
|
+
}
|
|
2490
|
+
trace(`concurrency group evaluated: ${groupKey}`);
|
|
2491
|
+
sendMessage({
|
|
2492
|
+
type: "log.line",
|
|
2493
|
+
stepIndex: -1,
|
|
2494
|
+
line: `[kici] Concurrency group: ${groupKey}`
|
|
2495
|
+
});
|
|
2496
|
+
sendMessage({
|
|
2497
|
+
type: "concurrency.report",
|
|
2498
|
+
group: groupKey
|
|
2499
|
+
});
|
|
2500
|
+
trace("waiting for concurrency ack");
|
|
2501
|
+
let ack = await waitForConcurrencyAck(concurrencyTimeoutMs);
|
|
2502
|
+
trace(`concurrency ack received: action=${ack.action}, reason=${ack.reason ?? "none"}`);
|
|
2503
|
+
if (ack.action === "proceed") {
|
|
2504
|
+
sendMessage({
|
|
2505
|
+
type: "log.line",
|
|
2506
|
+
stepIndex: -1,
|
|
2507
|
+
line: "[kici] Concurrency: proceeding with execution"
|
|
2508
|
+
});
|
|
2509
|
+
return "proceed";
|
|
2510
|
+
}
|
|
2511
|
+
if (ack.action === "wait") {
|
|
2512
|
+
sendMessage({
|
|
2513
|
+
type: "log.line",
|
|
2514
|
+
stepIndex: -1,
|
|
2515
|
+
line: `[kici] Concurrency: queued${ack.reason ? ` (${ack.reason})` : ""}, waiting for slot to free`
|
|
2516
|
+
});
|
|
2517
|
+
const waitCapMs = Number.parseInt(process.env.KICI_CONCURRENCY_WAIT_TIMEOUT_MS ?? "", 10) || 36e5;
|
|
2518
|
+
try {
|
|
2519
|
+
ack = await waitForConcurrencyAck(waitCapMs);
|
|
2520
|
+
} catch (waitErr) {
|
|
2521
|
+
const waitErrMsg = toErrorMessage(waitErr);
|
|
2522
|
+
sendMessage({
|
|
2523
|
+
type: "log.line",
|
|
2524
|
+
stepIndex: -1,
|
|
2525
|
+
line: `[kici] [error] Concurrency wait timed out: ${waitErrMsg}`
|
|
2526
|
+
});
|
|
2527
|
+
sendMessage({
|
|
2528
|
+
type: "job.complete",
|
|
2529
|
+
status: ExecutionJobStatus.enum.failed,
|
|
2530
|
+
stepResults: [],
|
|
2531
|
+
error: `Concurrency wait timed out after ${waitCapMs}ms: ${waitErrMsg}`
|
|
2532
|
+
});
|
|
2533
|
+
process.exit(1);
|
|
2534
|
+
}
|
|
2535
|
+
trace(`concurrency follow-up ack: action=${ack.action}, reason=${ack.reason ?? "none"}`);
|
|
2536
|
+
if (ack.action === "proceed") {
|
|
2537
|
+
sendMessage({
|
|
2538
|
+
type: "log.line",
|
|
2539
|
+
stepIndex: -1,
|
|
2540
|
+
line: "[kici] Concurrency: slot acquired, proceeding with execution"
|
|
2541
|
+
});
|
|
2542
|
+
return "proceed";
|
|
2543
|
+
}
|
|
2544
|
+
if (ack.action === "wait") {
|
|
2545
|
+
sendMessage({
|
|
2546
|
+
type: "log.line",
|
|
2547
|
+
stepIndex: -1,
|
|
2548
|
+
line: "[kici] [error] Concurrency: unexpected second `wait` ack; aborting"
|
|
2549
|
+
});
|
|
2550
|
+
sendMessage({
|
|
2551
|
+
type: "job.complete",
|
|
2552
|
+
status: ExecutionJobStatus.enum.failed,
|
|
2553
|
+
stepResults: [],
|
|
2554
|
+
error: "Concurrency: unexpected second wait ack"
|
|
2555
|
+
});
|
|
2556
|
+
process.exit(1);
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
sendMessage({
|
|
2560
|
+
type: "log.line",
|
|
2561
|
+
stepIndex: -1,
|
|
2562
|
+
line: `[kici] Concurrency: cancelled${ack.reason ? ` (${ack.reason})` : ""}`
|
|
2563
|
+
});
|
|
2564
|
+
sendMessage({
|
|
2565
|
+
type: "job.complete",
|
|
2566
|
+
status: ExecutionJobStatus.enum.failed,
|
|
2567
|
+
stepResults: [],
|
|
2568
|
+
error: `Cancelled by concurrency policy${ack.reason ? ": " + ack.reason : ""}`
|
|
2569
|
+
});
|
|
2570
|
+
process.exit(1);
|
|
2571
|
+
} catch (err) {
|
|
2572
|
+
const errMsg = toErrorMessage(err);
|
|
2573
|
+
trace(`concurrency group evaluation failed: ${errMsg}`);
|
|
2574
|
+
sendMessage({
|
|
2575
|
+
type: "log.line",
|
|
2576
|
+
stepIndex: -1,
|
|
2577
|
+
line: `[kici] [error] Concurrency group evaluation failed: ${errMsg}`
|
|
2578
|
+
});
|
|
2579
|
+
sendMessage({
|
|
2580
|
+
type: "job.complete",
|
|
2581
|
+
status: ExecutionJobStatus.enum.failed,
|
|
2582
|
+
stepResults: [],
|
|
2583
|
+
error: `Concurrency group evaluation failed: ${errMsg}`
|
|
2584
|
+
});
|
|
2585
|
+
process.exit(1);
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* Phase 5 — Inject env vars and build the `RepoInfo` pair that step contexts
|
|
2590
|
+
* receive when the job is a global workflow. No-op for normal jobs.
|
|
2591
|
+
*/
|
|
2592
|
+
function setupGlobalWorkflowEnv(request, isGlobal, workflowDir, sourceDir) {
|
|
2593
|
+
if (!isGlobal) return void 0;
|
|
2594
|
+
process.env.KICI_IS_GLOBAL_WORKFLOW = "true";
|
|
2595
|
+
process.env.KICI_WORKFLOW_REPO_PATH = workflowDir;
|
|
2596
|
+
process.env.KICI_SOURCE_REPO_PATH = sourceDir;
|
|
2597
|
+
const sourceRepoIdentifier = request.repoUrl.replace(/\.git$/, "").replace(/^https?:\/\/[^/]+\//, "");
|
|
2598
|
+
process.env.KICI_SOURCE_REPO = sourceRepoIdentifier;
|
|
2599
|
+
process.env.KICI_SOURCE_BRANCH = request.ref;
|
|
2600
|
+
process.env.KICI_SOURCE_SHA = request.sha;
|
|
2601
|
+
process.env.KICI_WORKFLOW_REPO = request.workflowRepoIdentifier ?? "";
|
|
2602
|
+
trace(`global workflow env vars injected: KICI_WORKFLOW_REPO_PATH=${workflowDir}, KICI_SOURCE_REPO_PATH=${sourceDir}`);
|
|
2603
|
+
return {
|
|
2604
|
+
workflowRepo: {
|
|
2605
|
+
identifier: request.workflowRepoIdentifier ?? "",
|
|
2606
|
+
path: workflowDir,
|
|
2607
|
+
ref: request.workflowRef,
|
|
2608
|
+
sha: request.workflowSha
|
|
2609
|
+
},
|
|
2610
|
+
sourceRepo: {
|
|
2611
|
+
identifier: sourceRepoIdentifier,
|
|
2612
|
+
path: sourceDir,
|
|
2613
|
+
ref: request.ref,
|
|
2614
|
+
sha: request.sha
|
|
2615
|
+
}
|
|
2616
|
+
};
|
|
2617
|
+
}
|
|
2618
|
+
/**
|
|
2619
|
+
* Phase 8 — Cancel-path hook execution. Runs the four cancel hooks
|
|
2620
|
+
* (step onCancel → step cleanup → job onCancel → job cleanup) inside-out,
|
|
2621
|
+
* accumulating any failure reasons into a compound `cancelFailureReason`.
|
|
2622
|
+
*
|
|
2623
|
+
* `forceAborted=true` short-circuits and runs none of the hooks; the caller
|
|
2624
|
+
* still flips finalStatus to failed (the fork-runner overrides it back to
|
|
2625
|
+
* `cancelled` based on its own state machine).
|
|
2626
|
+
*/
|
|
2627
|
+
async function runCancelPathHooks(args) {
|
|
2628
|
+
const { forceAborted, normalizedSteps, loopResult, jobHooks, jobStartTime, outputsMap, createStepCtxWithCapture, disposeStepResources, maskedSend } = args;
|
|
2629
|
+
if (forceAborted) {
|
|
2630
|
+
maskedSend({
|
|
2631
|
+
type: "log.line",
|
|
2632
|
+
stepIndex: -1,
|
|
2633
|
+
line: "[kici] Force cancel received, skipping all hooks"
|
|
2634
|
+
});
|
|
2635
|
+
return { finalStatus: ExecutionJobStatus.enum.failed };
|
|
2636
|
+
}
|
|
2637
|
+
maskedSend({
|
|
2638
|
+
type: "log.line",
|
|
2639
|
+
stepIndex: -1,
|
|
2640
|
+
line: "[kici] Cancel received, running cancel hooks..."
|
|
2641
|
+
});
|
|
2642
|
+
const cancelOutcome = buildOutcomeMetadata({
|
|
2643
|
+
status: ExecutionJobStatus.enum.cancelled,
|
|
2644
|
+
reason: "Job cancelled",
|
|
2645
|
+
stepOutputs: Object.fromEntries(outputsMap),
|
|
2646
|
+
startTime: jobStartTime
|
|
2647
|
+
});
|
|
2648
|
+
let hookStepIndex = normalizedSteps.length + loopResult.stepResults.length;
|
|
2649
|
+
let cancelFailureReason;
|
|
2650
|
+
const concatReason = (existing, fragment) => existing ? `${existing}; ${fragment}` : fragment;
|
|
2651
|
+
const runHook = async (hook, label, hookType, failedStep) => {
|
|
2652
|
+
maskedSend({
|
|
2653
|
+
type: "log.line",
|
|
2654
|
+
stepIndex: -1,
|
|
2655
|
+
line: `[kici] Running ${label} hook...`
|
|
2656
|
+
});
|
|
2657
|
+
const ctx = createStepCtxWithCapture(hookStepIndex, label);
|
|
2658
|
+
let hookResult;
|
|
2659
|
+
try {
|
|
2660
|
+
hookResult = await executeHook({
|
|
2661
|
+
hook,
|
|
2662
|
+
stepContext: ctx,
|
|
2663
|
+
outcome: failedStep ? {
|
|
2664
|
+
...cancelOutcome,
|
|
2665
|
+
failedStep
|
|
2666
|
+
} : cancelOutcome,
|
|
2667
|
+
hookType,
|
|
2668
|
+
stepIndex: hookStepIndex,
|
|
2669
|
+
sendIpc: maskedSend
|
|
2670
|
+
});
|
|
2671
|
+
} finally {
|
|
2672
|
+
await disposeStepResources();
|
|
2673
|
+
}
|
|
2674
|
+
hookStepIndex++;
|
|
2675
|
+
if (!hookResult.success) {
|
|
2676
|
+
const fragment = label === "onCancel" ? `cancelled (onCancel hook failed: ${hookResult.error})` : label.endsWith(":onCancel") ? `cancelled (step onCancel hook failed: ${hookResult.error})` : label.endsWith(":cleanup") ? `step cleanup hook failed: ${hookResult.error}` : label === "cleanup" ? `cleanup hook failed: ${hookResult.error}` : `${label} hook failed: ${hookResult.error}`;
|
|
2677
|
+
cancelFailureReason = concatReason(cancelFailureReason, fragment);
|
|
2678
|
+
maskedSend({
|
|
2679
|
+
type: "log.line",
|
|
2680
|
+
stepIndex: -1,
|
|
2681
|
+
line: `[kici] ${label} hook failed: ${hookResult.error}`
|
|
2682
|
+
});
|
|
2683
|
+
} else maskedSend({
|
|
2684
|
+
type: "log.line",
|
|
2685
|
+
stepIndex: -1,
|
|
2686
|
+
line: `[kici] ${label} hook completed`
|
|
2687
|
+
});
|
|
2688
|
+
};
|
|
2689
|
+
const lastStepIndex = loopResult.stepResults.length - 1;
|
|
2690
|
+
const lastStep = lastStepIndex >= 0 ? normalizedSteps[lastStepIndex] : void 0;
|
|
2691
|
+
if (lastStep?.onCancel) await runHook(lastStep.onCancel, `${lastStep.name}:onCancel`, "onCancel", lastStep.name);
|
|
2692
|
+
if (lastStep?.cleanup) await runHook(lastStep.cleanup, `${lastStep.name}:cleanup`, "cleanup", lastStep.name);
|
|
2693
|
+
if (jobHooks.onCancel) await runHook(jobHooks.onCancel, "onCancel", "onCancel");
|
|
2694
|
+
if (jobHooks.cleanup) await runHook(jobHooks.cleanup, "cleanup", "cleanup");
|
|
2695
|
+
maskedSend({
|
|
2696
|
+
type: "log.line",
|
|
2697
|
+
stepIndex: -1,
|
|
2698
|
+
line: `[kici] Cancel complete, job status: ${cancelFailureReason ? "failed" : "cancelled"}`
|
|
2699
|
+
});
|
|
2700
|
+
return {
|
|
2701
|
+
finalStatus: ExecutionJobStatus.enum.failed,
|
|
2702
|
+
cancelFailureReason
|
|
2703
|
+
};
|
|
2704
|
+
}
|
|
2705
|
+
/**
|
|
2706
|
+
* Phase 5 — Extract steps for the requested job (re-evaluating the dynamic
|
|
2707
|
+
* factory when `dynamicSource` is set, otherwise looking up the static job)
|
|
2708
|
+
* and normalise the result into the `Step[]` shape the step loop consumes.
|
|
2709
|
+
* Bare-function steps get auto-generated `step-N` names so the IPC reporting
|
|
2710
|
+
* and the StepRefMap (used by `.result` proxies) line up.
|
|
2711
|
+
*/
|
|
2712
|
+
async function extractAndNormalizeSteps(workflow, request, apiTransport) {
|
|
2713
|
+
let rawSteps;
|
|
2714
|
+
let driftDroppedJobs = [];
|
|
2715
|
+
if (request.dynamicSource) {
|
|
2716
|
+
const dynamicResult = await extractStepsFromDynamicJob(workflow, request.dynamicSource.index, request.jobName, request.dynamicSource.event, process.env, apiTransport, request.dynamicSource.expectedJobNames);
|
|
2717
|
+
rawSteps = dynamicResult.steps;
|
|
2718
|
+
driftDroppedJobs = dynamicResult.droppedJobs;
|
|
2719
|
+
if (driftDroppedJobs.length > 0) trace(`Determinism drift: ${driftDroppedJobs.length} job(s) dropped: ${driftDroppedJobs.join(", ")}`);
|
|
2720
|
+
} else rawSteps = extractSteps(workflow, request.jobName);
|
|
2721
|
+
const refMap = /* @__PURE__ */ new WeakMap();
|
|
2722
|
+
let stepCounter = 0;
|
|
2723
|
+
return {
|
|
2724
|
+
normalizedSteps: rawSteps.map((stepOrFn) => {
|
|
2725
|
+
if (typeof stepOrFn === "function") {
|
|
2726
|
+
stepCounter++;
|
|
2727
|
+
const name = `step-${stepCounter}`;
|
|
2728
|
+
refMap.set(stepOrFn, name);
|
|
2729
|
+
return {
|
|
2730
|
+
_tag: "Step",
|
|
2731
|
+
name,
|
|
2732
|
+
run: stepOrFn,
|
|
2733
|
+
outputs: void 0
|
|
2734
|
+
};
|
|
2735
|
+
}
|
|
2736
|
+
const s = stepOrFn;
|
|
2737
|
+
if (!s.name) {
|
|
2738
|
+
stepCounter++;
|
|
2739
|
+
return {
|
|
2740
|
+
...s,
|
|
2741
|
+
name: `step-${stepCounter}`
|
|
2742
|
+
};
|
|
2743
|
+
}
|
|
2744
|
+
return s;
|
|
2745
|
+
}),
|
|
2746
|
+
refMap,
|
|
2747
|
+
driftDroppedJobs
|
|
2748
|
+
};
|
|
2749
|
+
}
|
|
2750
|
+
/**
|
|
2751
|
+
* Phase 6 — Build the output infrastructure: the per-step operator-secret
|
|
2752
|
+
* key set (used for setEnv override protection), and the output / job-output
|
|
2753
|
+
* maps that back `.result` proxies and `ctx.outputsOf()` / `ctx.jobOutputs()`.
|
|
2754
|
+
* Sets the SDK module globals as a side effect.
|
|
2755
|
+
*/
|
|
2756
|
+
function buildOutputInfrastructure(request, refMap) {
|
|
2757
|
+
const operatorSecretKeys = /* @__PURE__ */ new Set();
|
|
2758
|
+
if (request.secrets) for (const key of Object.keys(request.secrets)) operatorSecretKeys.add(key);
|
|
2759
|
+
if (request.namespacedSecrets) for (const ctx of Object.values(request.namespacedSecrets)) for (const key of Object.keys(ctx)) operatorSecretKeys.add(key);
|
|
2760
|
+
const outputsMap = /* @__PURE__ */ new Map();
|
|
2761
|
+
const secretOutputs = /* @__PURE__ */ new Map();
|
|
2762
|
+
setStepOutputsMap(outputsMap);
|
|
2763
|
+
setStepRefMap(refMap);
|
|
2764
|
+
const jobOutputsMap = /* @__PURE__ */ new Map();
|
|
2765
|
+
if (request.upstreamJobOutputs) for (const [jobName, outputs] of Object.entries(request.upstreamJobOutputs)) jobOutputsMap.set(jobName, outputs);
|
|
2766
|
+
setJobOutputsMap(jobOutputsMap);
|
|
2767
|
+
return {
|
|
2768
|
+
operatorSecretKeys,
|
|
2769
|
+
outputsMap,
|
|
2770
|
+
secretOutputs,
|
|
2771
|
+
jobOutputsMap
|
|
2772
|
+
};
|
|
2773
|
+
}
|
|
2774
|
+
/**
|
|
2775
|
+
* Phase 7 — Evaluate job-level rules. When any rule fails, send a
|
|
2776
|
+
* `job.complete{success}` with all steps marked skipped and exit 0.
|
|
2777
|
+
* Returns false when the caller should continue to step execution.
|
|
2778
|
+
*/
|
|
2779
|
+
async function maybeSkipJobOnRules(job, request, normalizedSteps) {
|
|
2780
|
+
if (!job?.rules || job.rules.length === 0) return false;
|
|
2781
|
+
const ruleCtx = createRuleContext(request.event ?? {}, [], process.env);
|
|
2782
|
+
if ((await evaluateRules(job.rules, ruleCtx, request.jobName)).allPassed) return false;
|
|
2783
|
+
const skippedResults = normalizedSteps.map((s, i) => ({
|
|
2784
|
+
name: s.name,
|
|
2785
|
+
stepIndex: i,
|
|
2786
|
+
status: ExecutionStepStatus.enum.skipped,
|
|
2787
|
+
durationMs: 0
|
|
2788
|
+
}));
|
|
2789
|
+
flushOutputCapture();
|
|
2790
|
+
capturePrepareActive = false;
|
|
2791
|
+
sendMessage({
|
|
2792
|
+
type: "job.complete",
|
|
2793
|
+
status: ExecutionJobStatus.enum.success,
|
|
2794
|
+
stepResults: skippedResults
|
|
2795
|
+
});
|
|
2796
|
+
process.exit(0);
|
|
2797
|
+
}
|
|
2798
|
+
/**
|
|
2799
|
+
* Collect the six job-level hooks (beforeStep / afterStep / onSuccess /
|
|
2800
|
+
* onFailure / onCancel / cleanup) into a single typed object the step loop
|
|
2801
|
+
* and cancel-path consume.
|
|
2802
|
+
*/
|
|
2803
|
+
function collectJobHooks(job) {
|
|
2804
|
+
const jobHooks = {};
|
|
2805
|
+
if (!job) return jobHooks;
|
|
2806
|
+
if (job.beforeStep) jobHooks.beforeStep = job.beforeStep;
|
|
2807
|
+
if (job.afterStep) jobHooks.afterStep = job.afterStep;
|
|
2808
|
+
if (job.onSuccess) jobHooks.onSuccess = job.onSuccess;
|
|
2809
|
+
if (job.onFailure) jobHooks.onFailure = job.onFailure;
|
|
2810
|
+
if (job.onCancel) jobHooks.onCancel = job.onCancel;
|
|
2811
|
+
if (job.cleanup) jobHooks.cleanup = job.cleanup;
|
|
2812
|
+
return jobHooks;
|
|
2813
|
+
}
|
|
2814
|
+
/**
|
|
2815
|
+
* Run the complete job execution lifecycle.
|
|
2816
|
+
*
|
|
2817
|
+
* 1. Receive execution request
|
|
2818
|
+
* 2. Git clone (if checkout enabled)
|
|
2819
|
+
* 3. Dependency handling (cache restore or inline install)
|
|
2820
|
+
* 4. Load workflow module (from bundle or source)
|
|
2821
|
+
* 5. Extract workflow and steps
|
|
2822
|
+
* 6. Evaluate rules (if any)
|
|
2823
|
+
* 7. Execute steps sequentially with IPC reporting
|
|
2824
|
+
* 8. Send job.complete and exit
|
|
2825
|
+
*/
|
|
2826
|
+
async function main() {
|
|
2827
|
+
trace(`main() started, isForkMode=${isForkMode}, pid=${process.pid}`);
|
|
2828
|
+
sendMessage({ type: "ready" });
|
|
2829
|
+
trace("ready message sent");
|
|
2830
|
+
const request = (await receiveRequest()).request;
|
|
2831
|
+
trace(`execute request received: workDir=${request.workDir}, workflow=${request.workflowName}, job=${request.jobName}`);
|
|
2832
|
+
const workDir = request.workDir;
|
|
2833
|
+
const defaultTimeoutMs = request.defaultStepTimeoutMs ?? 1800 * 1e3;
|
|
2834
|
+
const isGlobal = request.isGlobalWorkflow === true;
|
|
2835
|
+
const workflowDir = isGlobal ? join(workDir, "workflow") : workDir;
|
|
2836
|
+
const sourceDir = isGlobal ? join(workDir, "source") : workDir;
|
|
2837
|
+
const masker = createSecretMasker(request);
|
|
2838
|
+
const maskedSend = (msg) => {
|
|
2839
|
+
if (msg.type === "log.line" && masker.hasSecrets()) sendMessage({
|
|
2840
|
+
...msg,
|
|
2841
|
+
line: masker.mask(msg.line)
|
|
2842
|
+
});
|
|
2843
|
+
else sendMessage(msg);
|
|
2844
|
+
};
|
|
2845
|
+
await cloneRepoIfRequested(request, workDir, workflowDir, sourceDir, isGlobal);
|
|
2846
|
+
await applyOverlayIfRequested(request, workflowDir);
|
|
2847
|
+
if (aborted) abortAndExit("aborted after clone");
|
|
2848
|
+
await installDependenciesIfNeeded(workflowDir, request);
|
|
2849
|
+
if (aborted) abortAndExit("aborted after deps");
|
|
2850
|
+
await restoreSourceTarballIfRequested(workflowDir, request);
|
|
2851
|
+
const module = (await loadWorkflowModuleWithCapture(workflowDir, request, isGlobal, maskedSend)).module;
|
|
2852
|
+
const workflow = extractWorkflow(module, request.workflowName);
|
|
2853
|
+
await evaluateConcurrencyGroupIfPresent(workflow, request);
|
|
2854
|
+
const apiTransport = async (method, params) => {
|
|
2855
|
+
const reqId = randomUUID();
|
|
2856
|
+
sendMessage({
|
|
2857
|
+
type: "agent.api.request",
|
|
2858
|
+
requestId: reqId,
|
|
2859
|
+
method,
|
|
2860
|
+
params: params ?? {}
|
|
2861
|
+
});
|
|
2862
|
+
return waitForApiResponse(reqId);
|
|
2863
|
+
};
|
|
2864
|
+
const { normalizedSteps, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
|
|
2865
|
+
const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap);
|
|
2866
|
+
const job = findJob(workflow, request.jobName);
|
|
2867
|
+
await maybeSkipJobOnRules(job, request, normalizedSteps);
|
|
2868
|
+
if (aborted) abortAndExit("aborted after rules");
|
|
2869
|
+
const jobHooks = collectJobHooks(job);
|
|
2870
|
+
const globalRepoInfo = setupGlobalWorkflowEnv(request, isGlobal, workflowDir, sourceDir);
|
|
2871
|
+
flushOutputCapture();
|
|
2872
|
+
capturePrepareActive = false;
|
|
2873
|
+
const stepCwd = sourceDir;
|
|
2874
|
+
let currentStepSecrets = null;
|
|
2875
|
+
let currentStepDispose = null;
|
|
2876
|
+
const createStepCtxWithCapture = (stepIndex, stepName) => {
|
|
2877
|
+
captureStepIndex = stepIndex;
|
|
2878
|
+
const handle = buildStepSecrets(request, masker, () => {});
|
|
2879
|
+
currentStepSecrets = handle.secrets;
|
|
2880
|
+
currentStepDispose = handle.dispose;
|
|
2881
|
+
const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets);
|
|
2882
|
+
if (globalRepoInfo) {
|
|
2883
|
+
ctx.workflowRepo = globalRepoInfo.workflowRepo;
|
|
2884
|
+
ctx.sourceRepo = globalRepoInfo.sourceRepo;
|
|
2885
|
+
}
|
|
2886
|
+
return ctx;
|
|
2887
|
+
};
|
|
2888
|
+
const jobStartTime = Date.now();
|
|
2889
|
+
const loopResult = await executeStepLoop({
|
|
2890
|
+
steps: normalizedSteps,
|
|
2891
|
+
createStepContext: createStepCtxWithCapture,
|
|
2892
|
+
sendIpc: maskedSend,
|
|
2893
|
+
defaultTimeoutMs,
|
|
2894
|
+
outputsMap,
|
|
2895
|
+
event: request.event ?? {},
|
|
2896
|
+
env: process.env,
|
|
2897
|
+
jobHooks,
|
|
2898
|
+
isAborted: () => aborted,
|
|
2899
|
+
startTime: jobStartTime,
|
|
2900
|
+
getSecretsAccessLog: () => {
|
|
2901
|
+
flushOutputCapture();
|
|
2902
|
+
captureStepIndex = -1;
|
|
2903
|
+
return currentStepSecrets?.getAccessLog() ?? [];
|
|
2904
|
+
},
|
|
2905
|
+
getSecretMountRecords: () => {
|
|
2906
|
+
return currentStepSecrets ? [...currentStepSecrets.getMountRecords()] : [];
|
|
2907
|
+
},
|
|
2908
|
+
disposeStepResources: async () => {
|
|
2909
|
+
const disposeFn = currentStepDispose;
|
|
2910
|
+
currentStepDispose = null;
|
|
2911
|
+
currentStepSecrets = null;
|
|
2912
|
+
if (disposeFn) await disposeFn();
|
|
2913
|
+
}
|
|
2914
|
+
});
|
|
2915
|
+
let finalStatus = loopResult.status === ExecutionStepStatus.enum.success ? ExecutionJobStatus.enum.success : ExecutionJobStatus.enum.failed;
|
|
2916
|
+
let cancelFailureReason;
|
|
2917
|
+
if (aborted) {
|
|
2918
|
+
const cancelResult = await runCancelPathHooks({
|
|
2919
|
+
forceAborted,
|
|
2920
|
+
normalizedSteps,
|
|
2921
|
+
loopResult,
|
|
2922
|
+
jobHooks,
|
|
2923
|
+
jobStartTime,
|
|
2924
|
+
outputsMap,
|
|
2925
|
+
createStepCtxWithCapture,
|
|
2926
|
+
disposeStepResources: async () => {
|
|
2927
|
+
const disposeFn = currentStepDispose;
|
|
2928
|
+
currentStepDispose = null;
|
|
2929
|
+
currentStepSecrets = null;
|
|
2930
|
+
if (disposeFn) await disposeFn();
|
|
2931
|
+
},
|
|
2932
|
+
maskedSend
|
|
2933
|
+
});
|
|
2934
|
+
finalStatus = cancelResult.finalStatus;
|
|
2935
|
+
cancelFailureReason = cancelResult.cancelFailureReason;
|
|
2936
|
+
}
|
|
2937
|
+
const aggregatedOutputs = {};
|
|
2938
|
+
for (const [stepName, outputs] of outputsMap) aggregatedOutputs[stepName] = outputs;
|
|
2939
|
+
sendMessage({
|
|
2940
|
+
type: "job.complete",
|
|
2941
|
+
status: finalStatus,
|
|
2942
|
+
stepResults: loopResult.stepResults,
|
|
2943
|
+
...Object.keys(aggregatedOutputs).length > 0 && { outputs: aggregatedOutputs },
|
|
2944
|
+
...secretOutputs.size > 0 && { secretOutputs: Object.fromEntries(secretOutputs) },
|
|
2945
|
+
...loopResult.failureReason && { error: loopResult.failureReason },
|
|
2946
|
+
...cancelFailureReason && { error: cancelFailureReason },
|
|
2947
|
+
...driftDroppedJobs.length > 0 && { droppedJobs: driftDroppedJobs }
|
|
2948
|
+
});
|
|
2949
|
+
process.exit(finalStatus === ExecutionJobStatus.enum.success ? 0 : 1);
|
|
2950
|
+
}
|
|
2951
|
+
/**
|
|
2952
|
+
* Find a static Job by name in the workflow.
|
|
2953
|
+
*/
|
|
2954
|
+
function findJob(workflow, jobName) {
|
|
2955
|
+
for (const item of workflow.jobs) if (!isDynamicJobFn(item) && item.name === jobName) return item;
|
|
2956
|
+
}
|
|
2957
|
+
main().catch((error) => {
|
|
2958
|
+
const message = toErrorMessage(error);
|
|
2959
|
+
const stack = error instanceof Error ? error.stack : void 0;
|
|
2960
|
+
process.stderr.write(`[workflow-runner] Fatal error: ${message}\n`);
|
|
2961
|
+
if (stack) process.stderr.write(`[workflow-runner] Stack: ${stack}\n`);
|
|
2962
|
+
sendMessage({
|
|
2963
|
+
type: "log.line",
|
|
2964
|
+
stepIndex: -1,
|
|
2965
|
+
line: `[workflow-runner] [error] Fatal: ${message}`
|
|
2966
|
+
});
|
|
2967
|
+
sendMessage({
|
|
2968
|
+
type: "job.complete",
|
|
2969
|
+
status: ExecutionJobStatus.enum.failed,
|
|
2970
|
+
stepResults: [],
|
|
2971
|
+
error: message
|
|
2972
|
+
});
|
|
2973
|
+
setTimeout(() => process.exit(1), 100);
|
|
2974
|
+
});
|
|
2975
|
+
//#endregion
|
|
2976
|
+
export {};
|
|
2977
|
+
|
|
2978
|
+
//# sourceMappingURL=workflow-runner.js.map
|