@kici-dev/agent 0.1.26 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/ensure-init-runner.d.ts +23 -22
- package/dist/bootstrap/payload-source.d.ts +32 -0
- package/dist/bootstrap/probe-platform.d.ts +21 -0
- package/dist/bootstrap/restage-agent.d.ts +43 -0
- package/dist/bootstrap/run-restage.d.ts +12 -0
- package/dist/bootstrap/s3-payload-source.d.ts +35 -0
- package/dist/bootstrap/ssh-exec.d.ts +14 -0
- package/dist/bootstrap/stage-agent-payload.d.ts +41 -0
- package/dist/checkout/changed-files.d.ts +34 -0
- package/dist/config.d.ts +42 -14
- package/dist/container-ts-loader-hook.js +147710 -0
- package/dist/execution/artifacts/artifact-engine.d.ts +51 -0
- package/dist/execution/dep-installer.d.ts +3 -3
- package/dist/execution/init-runner.d.ts +4 -4
- package/dist/execution/job-runner.d.ts +41 -6
- package/dist/execution/log-streamer.d.ts +17 -2
- package/dist/execution/rule-evaluator.d.ts +1 -12
- package/dist/execution/sandbox/container-hardening.d.ts +80 -0
- package/dist/execution/sandbox/container-sandbox.d.ts +54 -0
- package/dist/execution/sandbox/container-ts-loader-hook.d.ts +26 -0
- package/dist/execution/sandbox/env-sanitizer.d.ts +12 -3
- package/dist/execution/sandbox/fork-runner.d.ts +14 -0
- package/dist/execution/sandbox/index.d.ts +2 -1
- package/dist/execution/sandbox/ipc-protocol.d.ts +82 -6
- package/dist/execution/sandbox/step-loop.d.ts +4 -0
- package/dist/execution/sandbox/types.d.ts +25 -4
- package/dist/execution/sandbox/workflow-runner.d.ts +111 -5
- package/dist/execution/streaming-zx-log.d.ts +38 -0
- package/dist/execution/tmp-gc.d.ts +22 -7
- package/dist/execution/workflow-loader.d.ts +32 -1
- package/dist/index.js +50 -45
- package/dist/provenance/statement-builder.d.ts +3 -2
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1360 -193
- package/dist/workflow-runner-bundle.js +215393 -0
- package/dist/workflow-runner.js +937 -256
- package/dist/ws/orchestrator-client.d.ts +105 -1
- package/package.json +14 -12
- package/sbom.spdx.json +1092 -1723
package/dist/workflow-runner.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import { register } from "node:module";
|
|
1
|
+
import { createRequire, register } from "node:module";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import crypto$1, { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
-
import fsPromises, { access, cp, lstat, mkdir,
|
|
6
|
-
import
|
|
5
|
+
import fsPromises, { access, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
7
|
import path, { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
8
|
import { $ } from "zx";
|
|
9
9
|
import { createLogger, deriveSharedSecret, initZx, normalizeLineEndings, sha256, sha256File, toErrorMessage } from "@kici-dev/shared";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { createTempScope, makeTempDir } from "@kici-dev/core/tmp";
|
|
11
|
+
import { CacheOutcome, CacheStepType, CheckMode, CheckStepOutcome, ExecutionJobStatus, ExecutionStepStatus, LogStream, StepConcurrencyKind, TimeoutReason, artifactInvalidNameError, checkArtifactName } from "@kici-dev/engine";
|
|
12
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
13
|
+
import { buildKiciApi, buildNeedsContext, createRuleContext, createStepSecrets, evaluateRules, isDynamicJobFn, isEventDefinition, isParallelGroup, normalizeApproval, normalizeCacheSpecs, provenanceSubjectIsPath, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
|
|
12
14
|
import { OIDC_TOKEN_REQUEST_METHOD } from "@kici-dev/engine/protocol/messages/oidc-token-relay";
|
|
13
|
-
import { computeBackoffDelay, sha256File as sha256File$1 } from "@kici-dev/core";
|
|
15
|
+
import { HOME_ANCHOR, REPO_ANCHOR, computeBackoffDelay, sha256File as sha256File$1 } from "@kici-dev/core";
|
|
14
16
|
import { calculateJwkThumbprint, decodeJwt, exportJWK, generateKeyPair } from "jose";
|
|
15
17
|
import { IN_TOTO_PAYLOAD_TYPE, KICI_PROVENANCE_AUDIENCE, KICI_PROVENANCE_BUNDLE_MEDIA_TYPE } from "@kici-dev/engine/provenance/bundle";
|
|
16
18
|
import { computeStatementHash } from "@kici-dev/engine/provenance/statement-hash";
|
|
@@ -25,7 +27,6 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
25
27
|
import { c, x } from "tar";
|
|
26
28
|
import { runIdempotentStep } from "@kici-dev/core/idempotency";
|
|
27
29
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
28
|
-
import { execFile } from "node:child_process";
|
|
29
30
|
import { promisify } from "node:util";
|
|
30
31
|
import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, YarnFlavor, detectPackageManagerFromManifests, detectYarnFlavor } from "@kici-dev/shared/package-manager";
|
|
31
32
|
import { parse, stringify } from "yaml";
|
|
@@ -48,11 +49,307 @@ var __exportAll = (all, no_symbols) => {
|
|
|
48
49
|
return target;
|
|
49
50
|
};
|
|
50
51
|
//#endregion
|
|
52
|
+
//#region src/execution/streaming-zx-log.ts
|
|
53
|
+
/**
|
|
54
|
+
* Shared factory for the zx `log` callback that streams a subprocess's
|
|
55
|
+
* stdout/stderr into the captured/streamed run log, line by line, via `emit`.
|
|
56
|
+
*
|
|
57
|
+
* zx does NOT write child stdout/stderr to `process.stdout`; it pipes the
|
|
58
|
+
* child stdio to an internal VoidStream and surfaces each chunk through the
|
|
59
|
+
* shell's `log` callback as `{ kind: 'stdout' | 'stderr', data, verbose }`.
|
|
60
|
+
* The `verbose` flag is zx's per-invocation quiet/verbose decision:
|
|
61
|
+
*
|
|
62
|
+
* - stdout entries: `verbose = !piped && (snapshot.verbose && !snapshot.quiet)`
|
|
63
|
+
* - stderr entries: `verbose = !snapshot.quiet`
|
|
64
|
+
*
|
|
65
|
+
* so a step that opts into `$({ quiet: true })` (e.g. a `sops -d` decrypt of a
|
|
66
|
+
* credential) produces `verbose: false` entries. This factory HONORS that flag
|
|
67
|
+
* — it skips `verbose: false` entries — which is what makes `{ quiet: true }`
|
|
68
|
+
* actually suppress sensitive output from the run log. Without the gate, a
|
|
69
|
+
* decrypted-secret line leaks into the persisted/streamed log (zx's own default
|
|
70
|
+
* log function gates on the same flag: `if (!entry.verbose) return`).
|
|
71
|
+
*
|
|
72
|
+
* IMPORTANT: the shell that installs this callback MUST be constructed with
|
|
73
|
+
* `verbose: true`. With `verbose: true` zx flags ordinary (non-quiet)
|
|
74
|
+
* subprocess output `verbose: true` (captured) and a `{ quiet: true }` call
|
|
75
|
+
* `verbose: false` (suppressed). A `verbose: false` base would flag ordinary
|
|
76
|
+
* output `verbose: false` too, and this gate would then drop every line.
|
|
77
|
+
*
|
|
78
|
+
* The returned callback owns one line buffer PER STREAM, so partial chunks are
|
|
79
|
+
* coalesced into whole lines before `emit` is called. The buffers are separate
|
|
80
|
+
* because the two streams are independent pipes: a stdout chunk ending mid-line
|
|
81
|
+
* and a stderr chunk arriving next would otherwise concatenate into a single
|
|
82
|
+
* spliced line attributed to whichever kind completed it.
|
|
83
|
+
*
|
|
84
|
+
* `emit` receives the originating stream alongside the line so a diagnostic
|
|
85
|
+
* written to stderr stays distinguishable from ordinary progress output all the
|
|
86
|
+
* way to the persisted run log.
|
|
87
|
+
*/
|
|
88
|
+
function makeStreamingZxLog(emit) {
|
|
89
|
+
const lineBufs = {
|
|
90
|
+
[LogStream.enum.stdout]: "",
|
|
91
|
+
[LogStream.enum.stderr]: ""
|
|
92
|
+
};
|
|
93
|
+
return (entry) => {
|
|
94
|
+
const e = entry;
|
|
95
|
+
if (e.kind !== "stdout" && e.kind !== "stderr") return;
|
|
96
|
+
if (!e.verbose) return;
|
|
97
|
+
const stream = e.kind === "stderr" ? LogStream.enum.stderr : LogStream.enum.stdout;
|
|
98
|
+
const text = typeof e.data === "string" ? e.data : String(e.data ?? "");
|
|
99
|
+
const lines = (lineBufs[stream] + text).split("\n");
|
|
100
|
+
lineBufs[stream] = lines.pop();
|
|
101
|
+
for (const line of lines) if (line) emit(line, stream);
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/checkout/ssh-auth.ts
|
|
106
|
+
/**
|
|
107
|
+
* Materialize an SSH private key (and optional pinned known_hosts) into a
|
|
108
|
+
* tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
|
|
109
|
+
*
|
|
110
|
+
* Permissions:
|
|
111
|
+
* - private key mode 0o600 (required by OpenSSH — refuses to use world-
|
|
112
|
+
* readable keys).
|
|
113
|
+
* - known_hosts mode 0o600.
|
|
114
|
+
* - tempdir mode 0o700.
|
|
115
|
+
*
|
|
116
|
+
* SSH flags composed:
|
|
117
|
+
* - `-i <keyfile>` — identity file.
|
|
118
|
+
* - `-o IdentitiesOnly=yes` — don't try other keys from ssh-agent / ~/.ssh.
|
|
119
|
+
* - `-o BatchMode=yes` — never prompt for passwords / passphrases.
|
|
120
|
+
* - host-key checking flags based on `hostKeyPolicy`.
|
|
121
|
+
*/
|
|
122
|
+
async function setupSshAuth(opts) {
|
|
123
|
+
if (opts.hostKeyPolicy === "pinned" && !opts.knownHosts) throw new Error("pinned hostKeyPolicy requires knownHosts content");
|
|
124
|
+
const { path: tempDir, cleanup } = await makeTempDir("ssh");
|
|
125
|
+
const keyPath = join(tempDir, "id");
|
|
126
|
+
await writeFile(keyPath, opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`, { mode: 384 });
|
|
127
|
+
const knownHostsPath = join(tempDir, "known_hosts");
|
|
128
|
+
await writeFile(knownHostsPath, opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "", { mode: 384 });
|
|
129
|
+
const parts = [
|
|
130
|
+
"ssh",
|
|
131
|
+
"-i",
|
|
132
|
+
escapeShellArg(keyPath),
|
|
133
|
+
"-o",
|
|
134
|
+
"IdentitiesOnly=yes",
|
|
135
|
+
"-o",
|
|
136
|
+
"BatchMode=yes",
|
|
137
|
+
"-o",
|
|
138
|
+
`UserKnownHostsFile=${escapeShellArg(knownHostsPath)}`
|
|
139
|
+
];
|
|
140
|
+
if (opts.hostKeyPolicy === "pinned") parts.push("-o", "StrictHostKeyChecking=yes");
|
|
141
|
+
else parts.push("-o", "StrictHostKeyChecking=accept-new");
|
|
142
|
+
return {
|
|
143
|
+
gitSshCommand: parts.join(" "),
|
|
144
|
+
tempDir,
|
|
145
|
+
cleanup
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Quote a path for inclusion in `GIT_SSH_COMMAND`. We use single-quote
|
|
150
|
+
* wrapping so backslashes and spaces survive git's shell-parse of the
|
|
151
|
+
* command value.
|
|
152
|
+
*/
|
|
153
|
+
function escapeShellArg(value) {
|
|
154
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
157
|
+
//#region src/checkout/changed-files.ts
|
|
158
|
+
const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
159
|
+
const ZERO_SHA = /^0+$/;
|
|
160
|
+
const MAX_DEEPEN = 4;
|
|
161
|
+
const DEEPEN_STEP = 50;
|
|
162
|
+
const BASE_GIT_ARGS = [
|
|
163
|
+
"-c",
|
|
164
|
+
"safe.directory=*",
|
|
165
|
+
"-c",
|
|
166
|
+
"core.quotePath=false"
|
|
167
|
+
];
|
|
168
|
+
/** Build the auth context for the fetches, mirroring git-clone.ts's auth. */
|
|
169
|
+
async function buildAuthCtx(auth) {
|
|
170
|
+
if (!auth) return { args: [] };
|
|
171
|
+
if (auth.kind === "basic") {
|
|
172
|
+
const user = auth.user ?? "x-access-token";
|
|
173
|
+
return { args: ["-c", `http.extraHeader=Authorization: Basic ${Buffer.from(`${user}:${auth.secret}`).toString("base64")}`] };
|
|
174
|
+
}
|
|
175
|
+
const sshSetup = await setupSshAuth({
|
|
176
|
+
privateKey: auth.secret,
|
|
177
|
+
hostKeyPolicy: auth.sshHostKeyPolicy,
|
|
178
|
+
knownHosts: auth.sshKnownHostsPem
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
args: [],
|
|
182
|
+
env: { GIT_SSH_COMMAND: sshSetup.gitSshCommand },
|
|
183
|
+
cleanup: () => sshSetup.cleanup()
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function git(workDir, args, ctx) {
|
|
187
|
+
return execFileSync("git", [
|
|
188
|
+
...ctx.args,
|
|
189
|
+
...BASE_GIT_ARGS,
|
|
190
|
+
"-C",
|
|
191
|
+
workDir,
|
|
192
|
+
...args
|
|
193
|
+
], {
|
|
194
|
+
encoding: "utf8",
|
|
195
|
+
stdio: [
|
|
196
|
+
"ignore",
|
|
197
|
+
"pipe",
|
|
198
|
+
"pipe"
|
|
199
|
+
],
|
|
200
|
+
...ctx.env && { env: {
|
|
201
|
+
...process.env,
|
|
202
|
+
...ctx.env
|
|
203
|
+
} }
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
function tryGit(workDir, args, ctx) {
|
|
207
|
+
try {
|
|
208
|
+
git(workDir, args, ctx);
|
|
209
|
+
return true;
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function parseNameOnly(out) {
|
|
215
|
+
return out.split("\n").map((s) => s.replace(/\r$/, "")).filter((s) => s.length > 0);
|
|
216
|
+
}
|
|
217
|
+
/** Ensure `commitish` exists locally; fetch / deepen (bounded) if not. */
|
|
218
|
+
function ensureCommit(workDir, commitish, ctx) {
|
|
219
|
+
if (tryGit(workDir, [
|
|
220
|
+
"cat-file",
|
|
221
|
+
"-e",
|
|
222
|
+
`${commitish}^{commit}`
|
|
223
|
+
], ctx)) return true;
|
|
224
|
+
if (tryGit(workDir, [
|
|
225
|
+
"fetch",
|
|
226
|
+
"--depth",
|
|
227
|
+
"1",
|
|
228
|
+
"origin",
|
|
229
|
+
commitish
|
|
230
|
+
], ctx)) {
|
|
231
|
+
if (tryGit(workDir, [
|
|
232
|
+
"cat-file",
|
|
233
|
+
"-e",
|
|
234
|
+
`${commitish}^{commit}`
|
|
235
|
+
], ctx)) return true;
|
|
236
|
+
}
|
|
237
|
+
for (let i = 0; i < MAX_DEEPEN; i++) {
|
|
238
|
+
if (!tryGit(workDir, [
|
|
239
|
+
"fetch",
|
|
240
|
+
`--deepen=${DEEPEN_STEP}`,
|
|
241
|
+
"origin"
|
|
242
|
+
], ctx)) break;
|
|
243
|
+
if (tryGit(workDir, [
|
|
244
|
+
"cat-file",
|
|
245
|
+
"-e",
|
|
246
|
+
`${commitish}^{commit}`
|
|
247
|
+
], ctx)) return true;
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
function pushDiff(workDir, before, ctx) {
|
|
252
|
+
const isZero = !before || ZERO_SHA.test(before);
|
|
253
|
+
const baseRef = isZero ? EMPTY_TREE_SHA : before;
|
|
254
|
+
if (!isZero && !ensureCommit(workDir, before, ctx)) return {
|
|
255
|
+
files: [],
|
|
256
|
+
status: "unavailable"
|
|
257
|
+
};
|
|
258
|
+
return {
|
|
259
|
+
files: parseNameOnly(git(workDir, [
|
|
260
|
+
"diff",
|
|
261
|
+
"--name-only",
|
|
262
|
+
baseRef,
|
|
263
|
+
"HEAD"
|
|
264
|
+
], ctx)),
|
|
265
|
+
status: "fetched"
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
function prDiff(workDir, base, ctx) {
|
|
269
|
+
const candidates = [
|
|
270
|
+
base,
|
|
271
|
+
`origin/${base}`,
|
|
272
|
+
"FETCH_HEAD"
|
|
273
|
+
];
|
|
274
|
+
const resolveBase = () => candidates.find((c) => tryGit(workDir, [
|
|
275
|
+
"rev-parse",
|
|
276
|
+
"--verify",
|
|
277
|
+
`${c}^{commit}`
|
|
278
|
+
], ctx));
|
|
279
|
+
let baseRef = resolveBase();
|
|
280
|
+
if (!baseRef) {
|
|
281
|
+
if (!ensureCommit(workDir, base, ctx)) return {
|
|
282
|
+
files: [],
|
|
283
|
+
status: "unavailable"
|
|
284
|
+
};
|
|
285
|
+
baseRef = resolveBase();
|
|
286
|
+
}
|
|
287
|
+
if (!baseRef) return {
|
|
288
|
+
files: [],
|
|
289
|
+
status: "unavailable"
|
|
290
|
+
};
|
|
291
|
+
for (let i = 0; i <= MAX_DEEPEN; i++) {
|
|
292
|
+
if (tryGit(workDir, [
|
|
293
|
+
"merge-base",
|
|
294
|
+
baseRef,
|
|
295
|
+
"HEAD"
|
|
296
|
+
], ctx)) return {
|
|
297
|
+
files: parseNameOnly(git(workDir, [
|
|
298
|
+
"diff",
|
|
299
|
+
"--name-only",
|
|
300
|
+
`${baseRef}...HEAD`
|
|
301
|
+
], ctx)),
|
|
302
|
+
status: "fetched"
|
|
303
|
+
};
|
|
304
|
+
if (!tryGit(workDir, [
|
|
305
|
+
"fetch",
|
|
306
|
+
`--deepen=${DEEPEN_STEP}`,
|
|
307
|
+
"origin"
|
|
308
|
+
], ctx)) break;
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
files: [],
|
|
312
|
+
status: "unavailable"
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Compute the changed-files list from the agent's local clone (HEAD is the
|
|
317
|
+
* checked-out head commit). Ground truth for job/step rule evaluation. `auth`
|
|
318
|
+
* (the same credentials used for the clone) authenticates the deepen / fetch
|
|
319
|
+
* calls so a private remote resolves. Returns `unavailable` for diff-less
|
|
320
|
+
* events (schedule/tag/manual) or any git failure — never throws.
|
|
321
|
+
*/
|
|
322
|
+
async function computeChangedFiles(workDir, event, auth) {
|
|
323
|
+
let ctx;
|
|
324
|
+
try {
|
|
325
|
+
if (event.type !== "push" && event.type !== "pull_request") return {
|
|
326
|
+
files: [],
|
|
327
|
+
status: "unavailable"
|
|
328
|
+
};
|
|
329
|
+
ctx = await buildAuthCtx(auth);
|
|
330
|
+
if (event.type === "push") return pushDiff(workDir, event.payload?.before ?? "", ctx);
|
|
331
|
+
const base = event.baseBranch ?? event.targetBranch;
|
|
332
|
+
if (!base) return {
|
|
333
|
+
files: [],
|
|
334
|
+
status: "unavailable"
|
|
335
|
+
};
|
|
336
|
+
return prDiff(workDir, base, ctx);
|
|
337
|
+
} catch {
|
|
338
|
+
return {
|
|
339
|
+
files: [],
|
|
340
|
+
status: "unavailable"
|
|
341
|
+
};
|
|
342
|
+
} finally {
|
|
343
|
+
if (ctx?.cleanup) await ctx.cleanup().catch(() => {});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
//#endregion
|
|
51
347
|
//#region src/provenance/statement-builder.ts
|
|
52
348
|
/**
|
|
53
349
|
* Build a SLSA v1.0 in-toto provenance statement from the server-truth identity
|
|
54
350
|
* token claims plus the caller-supplied subject. The build context comes
|
|
55
|
-
* entirely from the JWT claims (
|
|
351
|
+
* entirely from the JWT claims (minted server-side by the orchestrator,
|
|
352
|
+
* unforgeable), so the
|
|
56
353
|
* statement's identity equals the token's identity by construction.
|
|
57
354
|
*/
|
|
58
355
|
/**
|
|
@@ -384,7 +681,7 @@ async function excludeScratchFromGit(repoWorkDir) {
|
|
|
384
681
|
const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
385
682
|
await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
|
|
386
683
|
} catch (err) {
|
|
387
|
-
logger$
|
|
684
|
+
logger$5.warn("Failed to register scratch dir glob in .git/info/exclude", {
|
|
388
685
|
excludePath,
|
|
389
686
|
error: err instanceof Error ? err.message : String(err)
|
|
390
687
|
});
|
|
@@ -445,7 +742,7 @@ async function cleanupScratch(scratchDir) {
|
|
|
445
742
|
force: true
|
|
446
743
|
});
|
|
447
744
|
} catch (cleanupErr) {
|
|
448
|
-
logger$
|
|
745
|
+
logger$5.warn("Scratch dir cleanup failed (orphan left behind)", {
|
|
449
746
|
scratchDir,
|
|
450
747
|
error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
|
|
451
748
|
});
|
|
@@ -470,7 +767,7 @@ async function cleanupScratch(scratchDir) {
|
|
|
470
767
|
*/
|
|
471
768
|
async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
472
769
|
depsUrl = resolveOrchestratorUrl(depsUrl);
|
|
473
|
-
logger$
|
|
770
|
+
logger$5.info("Downloading dependency tarball", { url: depsUrl });
|
|
474
771
|
const kiciDir = join(workDir, ".kici");
|
|
475
772
|
if (depsUrl.startsWith("file://")) {
|
|
476
773
|
const localPath = fileURLToPath(depsUrl);
|
|
@@ -484,7 +781,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
484
781
|
await moveScratchIntoRepo(scratchDir, workDir);
|
|
485
782
|
await cleanupScratch(scratchDir);
|
|
486
783
|
const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
|
|
487
|
-
logger$
|
|
784
|
+
logger$5.info("Dependencies restored from cache (file)", {
|
|
488
785
|
sizeMB,
|
|
489
786
|
targetDir: workDir
|
|
490
787
|
});
|
|
@@ -493,7 +790,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
493
790
|
if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
|
|
494
791
|
let lastError;
|
|
495
792
|
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
496
|
-
if (attempt > 0) logger$
|
|
793
|
+
if (attempt > 0) logger$5.warn("Retrying dep tarball download", {
|
|
497
794
|
attempt,
|
|
498
795
|
url: depsUrl
|
|
499
796
|
});
|
|
@@ -502,11 +799,11 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
502
799
|
if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
|
|
503
800
|
await moveScratchIntoRepo(scratchDir, workDir);
|
|
504
801
|
await cleanupScratch(scratchDir);
|
|
505
|
-
logger$
|
|
802
|
+
logger$5.info("Dependencies restored from cache (stream)", { targetDir: workDir });
|
|
506
803
|
return;
|
|
507
804
|
} catch (err) {
|
|
508
805
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
509
|
-
logger$
|
|
806
|
+
logger$5.warn("Dep tarball download failed", {
|
|
510
807
|
attempt,
|
|
511
808
|
error: lastError.message
|
|
512
809
|
});
|
|
@@ -514,9 +811,9 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
514
811
|
}
|
|
515
812
|
throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
|
|
516
813
|
}
|
|
517
|
-
var logger$
|
|
814
|
+
var logger$5, DOWNLOAD_TIMEOUT_MS$2, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
|
|
518
815
|
var init_dep_restore = __esmMin((() => {
|
|
519
|
-
logger$
|
|
816
|
+
logger$5 = createLogger({ prefix: "dep-restore" });
|
|
520
817
|
DOWNLOAD_TIMEOUT_MS$2 = 300 * 1e3;
|
|
521
818
|
SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
|
|
522
819
|
SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
|
|
@@ -621,13 +918,9 @@ init_download();
|
|
|
621
918
|
* into place so a partial restore never leaves half-written paths in the live
|
|
622
919
|
* tree (mirrors dep-restore).
|
|
623
920
|
*/
|
|
624
|
-
const logger$
|
|
921
|
+
const logger$4 = createLogger({ prefix: "cache-engine" });
|
|
625
922
|
/** Download timeout for a presigned cache GET: 5 minutes. */
|
|
626
923
|
const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
|
|
627
|
-
/** Anchor prefix for repo-root-relative cache entries inside the tar. */
|
|
628
|
-
const REPO_ANCHOR = "__repo__";
|
|
629
|
-
/** Anchor prefix for home-relative (`~`) cache entries inside the tar. */
|
|
630
|
-
const HOME_ANCHOR = "__home__";
|
|
631
924
|
/**
|
|
632
925
|
* Resolve a cache path. `~`-prefixed -> home root; otherwise repo-root-relative.
|
|
633
926
|
* Rejects absolute paths and `..` escapes so a workflow cannot read or clobber
|
|
@@ -668,7 +961,7 @@ function anchorEntries(workDir, paths, roots) {
|
|
|
668
961
|
*/
|
|
669
962
|
async function packCachePaths(workDir, paths, roots) {
|
|
670
963
|
const entries = anchorEntries(workDir, paths, roots);
|
|
671
|
-
const staging = await
|
|
964
|
+
const { path: staging, cleanup } = await makeTempDir("cache-pack");
|
|
672
965
|
try {
|
|
673
966
|
const topLevel = /* @__PURE__ */ new Set();
|
|
674
967
|
for (const e of entries) {
|
|
@@ -689,7 +982,7 @@ async function packCachePaths(workDir, paths, roots) {
|
|
|
689
982
|
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
|
690
983
|
const tarball = Buffer.concat(chunks);
|
|
691
984
|
const hash = sha256(tarball);
|
|
692
|
-
logger$
|
|
985
|
+
logger$4.info("packed user cache", {
|
|
693
986
|
sizeBytes: tarball.length,
|
|
694
987
|
hash: hash.slice(0, 12),
|
|
695
988
|
paths
|
|
@@ -699,10 +992,7 @@ async function packCachePaths(workDir, paths, roots) {
|
|
|
699
992
|
hash
|
|
700
993
|
};
|
|
701
994
|
} finally {
|
|
702
|
-
await
|
|
703
|
-
recursive: true,
|
|
704
|
-
force: true
|
|
705
|
-
});
|
|
995
|
+
await cleanup();
|
|
706
996
|
}
|
|
707
997
|
}
|
|
708
998
|
/** Move the extracted `__repo__` / `__home__` groups from a scratch dir into place. */
|
|
@@ -738,17 +1028,14 @@ async function downloadAndExtractCache(url, workDir, expectedHash, roots) {
|
|
|
738
1028
|
cb(null, chunk);
|
|
739
1029
|
} });
|
|
740
1030
|
await mkdir(workDir, { recursive: true });
|
|
741
|
-
const scratch = await
|
|
1031
|
+
const { path: scratch, cleanup } = await makeTempDir("cache-extract");
|
|
742
1032
|
try {
|
|
743
1033
|
await pipeline(Readable.fromWeb(response.body), hashTransform, createGunzip(), x({ cwd: scratch }));
|
|
744
1034
|
const digest = hash.digest("hex");
|
|
745
1035
|
if (digest !== expectedHash) throw new Error(`Cache tarball checksum mismatch on download: expected ${expectedHash}, got ${digest}`);
|
|
746
1036
|
await moveAnchoredGroups(scratch, workDir, home);
|
|
747
1037
|
} finally {
|
|
748
|
-
await
|
|
749
|
-
recursive: true,
|
|
750
|
-
force: true
|
|
751
|
-
});
|
|
1038
|
+
await cleanup();
|
|
752
1039
|
}
|
|
753
1040
|
}
|
|
754
1041
|
/** Build the imperative `ctx.cache` API bound to a workDir + transport. */
|
|
@@ -758,7 +1045,7 @@ function createCacheApi(workDir, transport, roots) {
|
|
|
758
1045
|
const r = await transport.restore(spec.key, spec.restoreKeys);
|
|
759
1046
|
if (!r.hit || !r.downloadUrl || !r.tarHash) return { hit: false };
|
|
760
1047
|
await downloadAndExtractCache(r.downloadUrl, workDir, r.tarHash, roots);
|
|
761
|
-
logger$
|
|
1048
|
+
logger$4.info("user cache restored", {
|
|
762
1049
|
key: spec.key,
|
|
763
1050
|
matchedKey: r.matchedKey
|
|
764
1051
|
});
|
|
@@ -770,14 +1057,14 @@ function createCacheApi(workDir, transport, roots) {
|
|
|
770
1057
|
async save(spec) {
|
|
771
1058
|
const begin = await transport.beginSave(spec.key);
|
|
772
1059
|
if (begin.skip || !begin.uploadUrl) {
|
|
773
|
-
logger$
|
|
1060
|
+
logger$4.info("user cache save skipped (key exists)", { key: spec.key });
|
|
774
1061
|
return;
|
|
775
1062
|
}
|
|
776
1063
|
const { tarball, hash } = await packCachePaths(workDir, spec.paths, roots);
|
|
777
1064
|
const { uploadToPresignedUrl } = await Promise.resolve().then(() => (init_download(), download_exports));
|
|
778
1065
|
await uploadToPresignedUrl(begin.uploadUrl, tarball);
|
|
779
1066
|
await transport.completeSave(spec.key, hash, tarball.length);
|
|
780
|
-
logger$
|
|
1067
|
+
logger$4.info("user cache saved", {
|
|
781
1068
|
key: spec.key,
|
|
782
1069
|
sizeBytes: tarball.length
|
|
783
1070
|
});
|
|
@@ -902,6 +1189,98 @@ async function saveCacheSpecs(specs, restoreResults, deps, ownerStepIndex) {
|
|
|
902
1189
|
}
|
|
903
1190
|
}
|
|
904
1191
|
//#endregion
|
|
1192
|
+
//#region src/execution/artifacts/artifact-engine.ts
|
|
1193
|
+
/**
|
|
1194
|
+
* User-facing artifacts engine (sandbox-side).
|
|
1195
|
+
*
|
|
1196
|
+
* Packs `ctx.artifacts.upload(name, paths)` into a gzip tarball and uploads it
|
|
1197
|
+
* under a run-scoped, named key; `ctx.artifacts.download(name, destDir)` streams
|
|
1198
|
+
* the tarball back with on-the-fly SHA-256 verification and extracts it. Reuses
|
|
1199
|
+
* the cache engine's pack/extract/anchor primitives (`packCachePaths`,
|
|
1200
|
+
* `downloadAndExtractCache`) — the tar layout, path-safety, and multi-root
|
|
1201
|
+
* anchoring are identical; only the addressing (named + immutable-per-run
|
|
1202
|
+
* instead of content-keyed) and the transport differ.
|
|
1203
|
+
*
|
|
1204
|
+
* Drives the orchestrator over an injected request-response transport
|
|
1205
|
+
* (IPC -> agent WS -> orchestrator); the agent never holds bucket credentials
|
|
1206
|
+
* (presigned URLs only). A rejected upload (duplicate name, size cap, run cap,
|
|
1207
|
+
* org quota) throws with the orchestrator's reason verbatim; a missing artifact
|
|
1208
|
+
* on download throws a clear not-found error. When the refusal is not an
|
|
1209
|
+
* enforcement gate — artifacts are unconfigured, the run is unresolvable, or the
|
|
1210
|
+
* store errored — the orchestrator sends a safe free-text detail that both paths
|
|
1211
|
+
* surface as `artifact "<name>": <detail>`, so a misconfiguration is never
|
|
1212
|
+
* mistaken for a quota rejection or a missing artifact.
|
|
1213
|
+
*
|
|
1214
|
+
* A name that violates the artifact-name contract is refused here, before the
|
|
1215
|
+
* wire, and rendered in that same `artifact "<name>": <detail>` shape from the
|
|
1216
|
+
* shared engine builder — so the author reads one sentence whether the sandbox
|
|
1217
|
+
* or the orchestrator caught it.
|
|
1218
|
+
*/
|
|
1219
|
+
const logger$3 = createLogger({ prefix: "artifact-engine" });
|
|
1220
|
+
/** Human-readable message for an upload rejection reason or internal-failure detail. */
|
|
1221
|
+
function rejectionMessage(name, reason, error) {
|
|
1222
|
+
switch (reason) {
|
|
1223
|
+
case "duplicate_name": return `artifact "${name}" already uploaded in this run (artifacts are immutable per run)`;
|
|
1224
|
+
case "size_cap": return `artifact "${name}" exceeds the per-artifact size cap`;
|
|
1225
|
+
case "run_cap": return `artifact "${name}" would exceed this run's artifact count cap`;
|
|
1226
|
+
case "org_quota": return `artifact "${name}" would exceed the organization's artifact storage quota`;
|
|
1227
|
+
default: return error ? `artifact "${name}": ${error}` : `artifact "${name}" upload was rejected`;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Reject a name that violates the shared contract with the same sentence the
|
|
1232
|
+
* orchestrator would have produced.
|
|
1233
|
+
*
|
|
1234
|
+
* The `artifact "<name>": ` prefix is added here deliberately. On the
|
|
1235
|
+
* orchestrator path that prefix comes from {@link rejectionMessage}'s default
|
|
1236
|
+
* branch; the sandbox throws directly and bypasses it, so it has to supply the
|
|
1237
|
+
* prefix itself for the two paths to render one string.
|
|
1238
|
+
*/
|
|
1239
|
+
function assertArtifactName(name) {
|
|
1240
|
+
const detail = checkArtifactName(name);
|
|
1241
|
+
if (detail) throw new Error(`artifact "${name}": ${artifactInvalidNameError(detail)}`);
|
|
1242
|
+
}
|
|
1243
|
+
/** Build the imperative `ctx.artifacts` API bound to a workDir + transport. */
|
|
1244
|
+
function createArtifactsApi(workDir, transport, roots) {
|
|
1245
|
+
return {
|
|
1246
|
+
async upload(name, paths) {
|
|
1247
|
+
assertArtifactName(name);
|
|
1248
|
+
if (paths.length === 0) throw new Error(`artifact "${name}" upload requires at least one path`);
|
|
1249
|
+
const { tarball, hash } = await packCachePaths(workDir, paths, roots);
|
|
1250
|
+
const grant = await transport.beginUpload(name, tarball.length);
|
|
1251
|
+
if (grant.outcome === "rejected" || !grant.uploadUrl || !grant.storageKey) throw new Error(rejectionMessage(name, grant.reason, grant.error));
|
|
1252
|
+
const { uploadToPresignedUrl } = await Promise.resolve().then(() => (init_download(), download_exports));
|
|
1253
|
+
await uploadToPresignedUrl(grant.uploadUrl, tarball);
|
|
1254
|
+
await transport.completeUpload(name, tarball.length, hash, grant.storageKey);
|
|
1255
|
+
logger$3.info("artifact uploaded", {
|
|
1256
|
+
name,
|
|
1257
|
+
sizeBytes: tarball.length,
|
|
1258
|
+
sha256: hash.slice(0, 12)
|
|
1259
|
+
});
|
|
1260
|
+
return {
|
|
1261
|
+
size: tarball.length,
|
|
1262
|
+
sha256: hash
|
|
1263
|
+
};
|
|
1264
|
+
},
|
|
1265
|
+
async download(name, destDir) {
|
|
1266
|
+
assertArtifactName(name);
|
|
1267
|
+
const lookup = await transport.download(name);
|
|
1268
|
+
if (lookup.outcome === "not_found" || !lookup.downloadUrl || !lookup.sha256) throw new Error(lookup.error ? `artifact "${name}": ${lookup.error}` : `artifact "${name}" was not found in this run`);
|
|
1269
|
+
const dest = destDir ?? workDir;
|
|
1270
|
+
await downloadAndExtractCache(lookup.downloadUrl, dest, lookup.sha256, roots);
|
|
1271
|
+
logger$3.info("artifact downloaded", {
|
|
1272
|
+
name,
|
|
1273
|
+
destDir: dest,
|
|
1274
|
+
sizeBytes: lookup.sizeBytes
|
|
1275
|
+
});
|
|
1276
|
+
return {
|
|
1277
|
+
size: lookup.sizeBytes ?? 0,
|
|
1278
|
+
sha256: lookup.sha256
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
//#endregion
|
|
905
1284
|
//#region src/execution/sandbox/log-masker.ts
|
|
906
1285
|
/**
|
|
907
1286
|
* Secret value masking for log lines.
|
|
@@ -1060,7 +1439,7 @@ function parsePathFileContent(content) {
|
|
|
1060
1439
|
}
|
|
1061
1440
|
/** Create fresh, empty env + path files inside a private temp dir under `baseDir`. */
|
|
1062
1441
|
async function createEnvFiles(baseDir) {
|
|
1063
|
-
const dir = await
|
|
1442
|
+
const { path: dir } = await makeTempDir("env", { base: baseDir });
|
|
1064
1443
|
const envFile = join(dir, "env");
|
|
1065
1444
|
const pathFile = join(dir, "path");
|
|
1066
1445
|
await writeFile(envFile, "");
|
|
@@ -1200,25 +1579,6 @@ async function executeHook(opts) {
|
|
|
1200
1579
|
//#endregion
|
|
1201
1580
|
//#region src/execution/rule-evaluator.ts
|
|
1202
1581
|
initZx();
|
|
1203
|
-
/**
|
|
1204
|
-
* Create RuleContext for agent-side rule evaluation.
|
|
1205
|
-
*
|
|
1206
|
-
* @param event - Event payload from the dispatch message
|
|
1207
|
-
* @param changedFiles - List of files changed in this event
|
|
1208
|
-
* @param env - Merged environment variables
|
|
1209
|
-
* @param dispatchInputs - Operator dispatch inputs (`ctx.dispatchInputs`)
|
|
1210
|
-
* @param fanout - Fan-out position (`ctx.fanout`); undefined on a non-fan-out job
|
|
1211
|
-
*/
|
|
1212
|
-
function createRuleContext(event, changedFiles = [], env = {}, dispatchInputs = {}, fanout) {
|
|
1213
|
-
return {
|
|
1214
|
-
event,
|
|
1215
|
-
changedFiles,
|
|
1216
|
-
env,
|
|
1217
|
-
dispatchInputs,
|
|
1218
|
-
...fanout && { fanout },
|
|
1219
|
-
$
|
|
1220
|
-
};
|
|
1221
|
-
}
|
|
1222
1582
|
//#endregion
|
|
1223
1583
|
//#region src/execution/sandbox/parallel-scheduler.ts
|
|
1224
1584
|
/**
|
|
@@ -1440,6 +1800,58 @@ async function runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn, opt
|
|
|
1440
1800
|
...res.drift != null && { drift: res.drift }
|
|
1441
1801
|
};
|
|
1442
1802
|
}
|
|
1803
|
+
/** Most characters of a failing step's error message that reach the run log. */
|
|
1804
|
+
const STEP_FAILURE_LOG_MAX_CHARS = 8192;
|
|
1805
|
+
/**
|
|
1806
|
+
* Emit a failing step's error message into the step's own log stream.
|
|
1807
|
+
*
|
|
1808
|
+
* A step failure is otherwise reported only on `step.complete` (which persists
|
|
1809
|
+
* to the step row), so a reader of the run log sees a step go red with nothing
|
|
1810
|
+
* explaining why. Subprocess wrappers routinely pack the real diagnosis —
|
|
1811
|
+
* captured stderr, exit code, kill signal — into the thrown error's message, so
|
|
1812
|
+
* that message is the diagnosis and it belongs in the log.
|
|
1813
|
+
*
|
|
1814
|
+
* Tagged `stderr`: it is a failure, not progress. Bounded to
|
|
1815
|
+
* {@link STEP_FAILURE_LOG_MAX_LINES} lines / {@link STEP_FAILURE_LOG_MAX_CHARS}
|
|
1816
|
+
* characters, because a wrapped process error can carry a subprocess's entire
|
|
1817
|
+
* output; what is dropped is stated in the log rather than silently cut. The
|
|
1818
|
+
* full untruncated message still travels on `step.complete`.
|
|
1819
|
+
*
|
|
1820
|
+
* Goes through the same `sendFn` as every other log line, so the runner's
|
|
1821
|
+
* secret masking applies exactly as it does to the step's ordinary output.
|
|
1822
|
+
*
|
|
1823
|
+
* One consequence to know: a `$({ quiet: true })` command's output is kept out
|
|
1824
|
+
* of the log by the `verbose` gate in `streaming-zx-log.ts`, but when such a
|
|
1825
|
+
* command FAILS, zx packs its captured output into the thrown error's message —
|
|
1826
|
+
* which this function then writes to the log. That text is already persisted
|
|
1827
|
+
* unmasked on `step.complete` (the step row the dashboard renders), so the copy
|
|
1828
|
+
* written here is the more protected of the two, and surfacing it is the whole
|
|
1829
|
+
* point: a quiet command that fails is exactly the failure an operator cannot
|
|
1830
|
+
* otherwise diagnose. Registered secret values are masked; anything the masker
|
|
1831
|
+
* has never been told about is not.
|
|
1832
|
+
*/
|
|
1833
|
+
function emitStepFailureLog(stepName, stepIndex, message, sendFn) {
|
|
1834
|
+
const prefix = `[kici] Step '${stepName}' failed: `;
|
|
1835
|
+
const kept = (prefix + (message.length > 8192 ? message.slice(0, STEP_FAILURE_LOG_MAX_CHARS) : message)).split("\n").slice(0, 100);
|
|
1836
|
+
for (const line of kept) if (line) sendFn({
|
|
1837
|
+
type: "log.line",
|
|
1838
|
+
stepIndex,
|
|
1839
|
+
line,
|
|
1840
|
+
stream: LogStream.enum.stderr
|
|
1841
|
+
});
|
|
1842
|
+
const emittedChars = Math.max(0, kept.join("\n").length - prefix.length);
|
|
1843
|
+
const omittedChars = message.length - emittedChars;
|
|
1844
|
+
if (omittedChars > 0) {
|
|
1845
|
+
let omittedLines = 0;
|
|
1846
|
+
for (let i = emittedChars; i < message.length; i++) if (message.charCodeAt(i) === 10) omittedLines++;
|
|
1847
|
+
sendFn({
|
|
1848
|
+
type: "log.line",
|
|
1849
|
+
stepIndex,
|
|
1850
|
+
line: `[kici] … error message truncated (${omittedLines} more line(s), ${omittedChars} more character(s)); see the step's recorded error for the full text`,
|
|
1851
|
+
stream: LogStream.enum.stderr
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1443
1855
|
/**
|
|
1444
1856
|
* Execute a single step with timeout enforcement.
|
|
1445
1857
|
*
|
|
@@ -1530,6 +1942,7 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
|
|
|
1530
1942
|
}
|
|
1531
1943
|
const exitCode = extractExitCode(e);
|
|
1532
1944
|
const signal = extractSignal(e);
|
|
1945
|
+
emitStepFailureLog(step.name, stepIndex, error.message, sendFn);
|
|
1533
1946
|
const secretsAccessed = getSecretsAccessLog?.(stepIndex);
|
|
1534
1947
|
emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
|
|
1535
1948
|
sendFn({
|
|
@@ -1580,14 +1993,60 @@ function extractSignal(error) {
|
|
|
1580
1993
|
if (error && typeof error === "object" && "signal" in error && typeof error.signal === "string") return error.signal;
|
|
1581
1994
|
}
|
|
1582
1995
|
/**
|
|
1583
|
-
* Evaluate step-level rules. Returns
|
|
1584
|
-
*
|
|
1996
|
+
* Evaluate step-level rules. Returns null when the step should run normally.
|
|
1997
|
+
* Otherwise returns a terminal `StepIterationOutcome`:
|
|
1998
|
+
*
|
|
1999
|
+
* - a rule's `check()` **threw** (`evaluationError`) → FAIL the step (`failed`
|
|
2000
|
+
* status + error surfaced, `shouldBreak: true`): the gate could not be
|
|
2001
|
+
* evaluated, so silently skipping would be a false green.
|
|
2002
|
+
* - a rule cleanly returned `false` → clean skip (`skipped` status,
|
|
2003
|
+
* `shouldBreak: false`), the loop continues to the next step (unchanged).
|
|
1585
2004
|
*/
|
|
1586
2005
|
async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
|
|
1587
2006
|
if (!step.rules || step.rules.length === 0) return null;
|
|
1588
|
-
const
|
|
2007
|
+
const ev = opts.event;
|
|
2008
|
+
const ruleCtx = createRuleContext({
|
|
2009
|
+
event: opts.event,
|
|
2010
|
+
changedFiles: ev.changedFiles,
|
|
2011
|
+
changedFilesStatus: ev.changedFilesStatus,
|
|
2012
|
+
env: opts.env,
|
|
2013
|
+
dispatchInputs: opts.dispatchInputs ?? {},
|
|
2014
|
+
fanout: opts.fanout
|
|
2015
|
+
});
|
|
1589
2016
|
const ruleResult = await evaluateRules(step.rules, ruleCtx, step.name);
|
|
1590
2017
|
if (ruleResult.allPassed) return null;
|
|
2018
|
+
if (ruleResult.evaluationError) {
|
|
2019
|
+
const message = `rule '${ruleResult.evaluationError.label}' errored: ${ruleResult.evaluationError.message}`;
|
|
2020
|
+
opts.sendIpc({
|
|
2021
|
+
type: "step.start",
|
|
2022
|
+
stepIndex,
|
|
2023
|
+
stepName: step.name
|
|
2024
|
+
});
|
|
2025
|
+
opts.sendIpc({
|
|
2026
|
+
type: "step.complete",
|
|
2027
|
+
stepIndex,
|
|
2028
|
+
status: ExecutionStepStatus.enum.failed,
|
|
2029
|
+
durationMs: 0,
|
|
2030
|
+
error: { message }
|
|
2031
|
+
});
|
|
2032
|
+
opts.sendIpc({
|
|
2033
|
+
type: "log.line",
|
|
2034
|
+
stepIndex,
|
|
2035
|
+
line: `[kici] Step '${step.name}' failed: ${message}`,
|
|
2036
|
+
stream: LogStream.enum.stderr
|
|
2037
|
+
});
|
|
2038
|
+
return {
|
|
2039
|
+
result: {
|
|
2040
|
+
name: step.name,
|
|
2041
|
+
stepIndex,
|
|
2042
|
+
status: ExecutionStepStatus.enum.failed,
|
|
2043
|
+
durationMs: 0,
|
|
2044
|
+
error: { message }
|
|
2045
|
+
},
|
|
2046
|
+
shouldBreak: true,
|
|
2047
|
+
failedStepName: step.name
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
1591
2050
|
opts.sendIpc({
|
|
1592
2051
|
type: "step.start",
|
|
1593
2052
|
stepIndex,
|
|
@@ -1605,10 +2064,13 @@ async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
|
|
|
1605
2064
|
line: `[kici] Step '${step.name}' skipped: rule '${ruleResult.results.find((r) => !r.passed)?.label}' did not pass`
|
|
1606
2065
|
});
|
|
1607
2066
|
return {
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
2067
|
+
result: {
|
|
2068
|
+
name: step.name,
|
|
2069
|
+
stepIndex,
|
|
2070
|
+
status: ExecutionStepStatus.enum.skipped,
|
|
2071
|
+
durationMs: 0
|
|
2072
|
+
},
|
|
2073
|
+
shouldBreak: false
|
|
1612
2074
|
};
|
|
1613
2075
|
}
|
|
1614
2076
|
/**
|
|
@@ -1658,7 +2120,8 @@ async function maybeGateStepApproval(step, stepIndex, opts) {
|
|
|
1658
2120
|
opts.sendIpc({
|
|
1659
2121
|
type: "log.line",
|
|
1660
2122
|
stepIndex,
|
|
1661
|
-
line: `[kici] Step '${step.name}' ${why}
|
|
2123
|
+
line: `[kici] Step '${step.name}' ${why}.`,
|
|
2124
|
+
stream: LogStream.enum.stderr
|
|
1662
2125
|
});
|
|
1663
2126
|
await opts.disposeStepResources?.(stepIndex);
|
|
1664
2127
|
return {
|
|
@@ -1699,7 +2162,8 @@ async function runObserverHook(args) {
|
|
|
1699
2162
|
if (!hookResult.success) opts.sendIpc({
|
|
1700
2163
|
type: "log.line",
|
|
1701
2164
|
stepIndex,
|
|
1702
|
-
line: `[kici] ${hookType} hook failed: ${hookResult.error} (continuing -- hooks are observers)
|
|
2165
|
+
line: `[kici] ${hookType} hook failed: ${hookResult.error} (continuing -- hooks are observers)`,
|
|
2166
|
+
stream: LogStream.enum.stderr
|
|
1703
2167
|
});
|
|
1704
2168
|
}
|
|
1705
2169
|
/**
|
|
@@ -1739,20 +2203,18 @@ async function runStepWithRetry(step, stepIndex, ctx, timeoutMs, opts) {
|
|
|
1739
2203
|
opts.sendIpc({
|
|
1740
2204
|
type: "log.line",
|
|
1741
2205
|
stepIndex,
|
|
1742
|
-
line: `[kici] Step '${step.name}' attempt ${n}/${max} failed: ${err.message}; retrying in ${delay}ms
|
|
2206
|
+
line: `[kici] Step '${step.name}' attempt ${n}/${max} failed: ${err.message}; retrying in ${delay}ms`,
|
|
2207
|
+
stream: LogStream.enum.stderr
|
|
1743
2208
|
});
|
|
1744
2209
|
await new Promise((r) => setTimeout(r, delay));
|
|
1745
2210
|
}
|
|
1746
2211
|
return result;
|
|
1747
2212
|
}
|
|
1748
2213
|
async function runStepIteration(step, stepIndex, opts) {
|
|
1749
|
-
const
|
|
1750
|
-
if (
|
|
2214
|
+
const ruleOutcome = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
|
|
2215
|
+
if (ruleOutcome) {
|
|
1751
2216
|
await opts.disposeStepResources?.(stepIndex);
|
|
1752
|
-
return
|
|
1753
|
-
result: skippedResult,
|
|
1754
|
-
shouldBreak: false
|
|
1755
|
-
};
|
|
2217
|
+
return ruleOutcome;
|
|
1756
2218
|
}
|
|
1757
2219
|
const gate = await maybeGateStepApproval(step, stepIndex, opts);
|
|
1758
2220
|
if (gate) return gate;
|
|
@@ -2385,63 +2847,6 @@ function armJobDeadline(timeoutMs, onTimeout) {
|
|
|
2385
2847
|
return { clear: () => clearTimeout(timer) };
|
|
2386
2848
|
}
|
|
2387
2849
|
//#endregion
|
|
2388
|
-
//#region src/checkout/ssh-auth.ts
|
|
2389
|
-
/**
|
|
2390
|
-
* Materialize an SSH private key (and optional pinned known_hosts) into a
|
|
2391
|
-
* tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
|
|
2392
|
-
*
|
|
2393
|
-
* Permissions:
|
|
2394
|
-
* - private key mode 0o600 (required by OpenSSH — refuses to use world-
|
|
2395
|
-
* readable keys).
|
|
2396
|
-
* - known_hosts mode 0o600.
|
|
2397
|
-
* - tempdir mode 0o700.
|
|
2398
|
-
*
|
|
2399
|
-
* SSH flags composed:
|
|
2400
|
-
* - `-i <keyfile>` — identity file.
|
|
2401
|
-
* - `-o IdentitiesOnly=yes` — don't try other keys from ssh-agent / ~/.ssh.
|
|
2402
|
-
* - `-o BatchMode=yes` — never prompt for passwords / passphrases.
|
|
2403
|
-
* - host-key checking flags based on `hostKeyPolicy`.
|
|
2404
|
-
*/
|
|
2405
|
-
async function setupSshAuth(opts) {
|
|
2406
|
-
if (opts.hostKeyPolicy === "pinned" && !opts.knownHosts) throw new Error("pinned hostKeyPolicy requires knownHosts content");
|
|
2407
|
-
const tempDir = await mkdtemp(join(tmpdir(), "kici-ssh-"));
|
|
2408
|
-
const keyPath = join(tempDir, "id");
|
|
2409
|
-
await writeFile(keyPath, opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`, { mode: 384 });
|
|
2410
|
-
const knownHostsPath = join(tempDir, "known_hosts");
|
|
2411
|
-
await writeFile(knownHostsPath, opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "", { mode: 384 });
|
|
2412
|
-
const parts = [
|
|
2413
|
-
"ssh",
|
|
2414
|
-
"-i",
|
|
2415
|
-
escapeShellArg(keyPath),
|
|
2416
|
-
"-o",
|
|
2417
|
-
"IdentitiesOnly=yes",
|
|
2418
|
-
"-o",
|
|
2419
|
-
"BatchMode=yes",
|
|
2420
|
-
"-o",
|
|
2421
|
-
`UserKnownHostsFile=${escapeShellArg(knownHostsPath)}`
|
|
2422
|
-
];
|
|
2423
|
-
if (opts.hostKeyPolicy === "pinned") parts.push("-o", "StrictHostKeyChecking=yes");
|
|
2424
|
-
else parts.push("-o", "StrictHostKeyChecking=accept-new");
|
|
2425
|
-
return {
|
|
2426
|
-
gitSshCommand: parts.join(" "),
|
|
2427
|
-
tempDir,
|
|
2428
|
-
async cleanup() {
|
|
2429
|
-
await rm(tempDir, {
|
|
2430
|
-
recursive: true,
|
|
2431
|
-
force: true
|
|
2432
|
-
});
|
|
2433
|
-
}
|
|
2434
|
-
};
|
|
2435
|
-
}
|
|
2436
|
-
/**
|
|
2437
|
-
* Quote a path for inclusion in `GIT_SSH_COMMAND`. We use single-quote
|
|
2438
|
-
* wrapping so backslashes and spaces survive git's shell-parse of the
|
|
2439
|
-
* command value.
|
|
2440
|
-
*/
|
|
2441
|
-
function escapeShellArg(value) {
|
|
2442
|
-
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
2443
|
-
}
|
|
2444
|
-
//#endregion
|
|
2445
2850
|
//#region src/checkout/git-clone.ts
|
|
2446
2851
|
/**
|
|
2447
2852
|
* Strip auth credentials from git error messages to prevent token leakage.
|
|
@@ -2481,19 +2886,16 @@ async function gitClone(options) {
|
|
|
2481
2886
|
let needsCustomEnv = false;
|
|
2482
2887
|
let safeDirCleanup;
|
|
2483
2888
|
if (repoUrl.startsWith("file://")) {
|
|
2484
|
-
const {
|
|
2485
|
-
const { tmpdir } = await import("node:os");
|
|
2889
|
+
const { writeFile } = await import("node:fs/promises");
|
|
2486
2890
|
const path = await import("node:path");
|
|
2487
|
-
const
|
|
2891
|
+
const { makeTempDir } = await import("@kici-dev/core/tmp");
|
|
2892
|
+
const { path: dir, cleanup } = await makeTempDir("gitcfg");
|
|
2488
2893
|
const cfgPath = path.join(dir, "config");
|
|
2489
2894
|
await writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
|
|
2490
2895
|
envEntries.GIT_CONFIG_GLOBAL = cfgPath;
|
|
2491
2896
|
needsCustomEnv = true;
|
|
2492
2897
|
safeDirCleanup = async () => {
|
|
2493
|
-
await
|
|
2494
|
-
recursive: true,
|
|
2495
|
-
force: true
|
|
2496
|
-
}).catch(() => {});
|
|
2898
|
+
await cleanup().catch(() => {});
|
|
2497
2899
|
};
|
|
2498
2900
|
}
|
|
2499
2901
|
let sshSetup;
|
|
@@ -2788,7 +3190,7 @@ async function applyYarnrcBerryConfig(args) {
|
|
|
2788
3190
|
const hasPrivateRegistry = registries.length > 0 || Object.keys(installEnvSecrets).length > 0;
|
|
2789
3191
|
const yarnrcPath = join(args.kiciDir, ".yarnrc.yml");
|
|
2790
3192
|
const { raw: original, doc } = await readOriginalYarnrc(yarnrcPath);
|
|
2791
|
-
const cacheFolder = await
|
|
3193
|
+
const { path: cacheFolder, cleanup: cleanupCache } = await makeTempDir("yarn-berry-cache");
|
|
2792
3194
|
const merged = {
|
|
2793
3195
|
...doc,
|
|
2794
3196
|
nodeLinker: "node-modules",
|
|
@@ -2825,10 +3227,7 @@ async function applyYarnrcBerryConfig(args) {
|
|
|
2825
3227
|
if (original === null) await unlink(yarnrcPath).catch(() => {});
|
|
2826
3228
|
else await writeFile(yarnrcPath, original, { encoding: "utf8" });
|
|
2827
3229
|
} catch {}
|
|
2828
|
-
await
|
|
2829
|
-
recursive: true,
|
|
2830
|
-
force: true
|
|
2831
|
-
}).catch(() => {});
|
|
3230
|
+
await cleanupCache().catch(() => {});
|
|
2832
3231
|
};
|
|
2833
3232
|
return {
|
|
2834
3233
|
extraEnv: {
|
|
@@ -3143,9 +3542,9 @@ async function detectKiciYarnFlavor(repoRoot, kiciDir) {
|
|
|
3143
3542
|
* Install `.kici/` dependencies inline with the repo's package manager.
|
|
3144
3543
|
*
|
|
3145
3544
|
* Falls back to this when the dep cache is unavailable or a download fails.
|
|
3146
|
-
* The install runs with an isolated cache/store directory (
|
|
3147
|
-
* `
|
|
3148
|
-
* is removed after installation.
|
|
3545
|
+
* The install runs with an isolated cache/store directory (allocated under the
|
|
3546
|
+
* global temp base, which honors `KICI_TMPDIR`) to prevent cache poisoning
|
|
3547
|
+
* between build jobs; the directory is removed after installation.
|
|
3149
3548
|
*
|
|
3150
3549
|
* If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, a job-scoped
|
|
3151
3550
|
* `.kici/.npmrc` overlay is synthesized for the install, restored in `finally`,
|
|
@@ -3234,7 +3633,7 @@ function envWithNodeOnPath(extraEnv, nodeDir) {
|
|
|
3234
3633
|
/** Run `npm install` in `.kici/` with an isolated cache directory. */
|
|
3235
3634
|
async function runNpmInstall(args) {
|
|
3236
3635
|
const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
|
|
3237
|
-
const cacheDir = await
|
|
3636
|
+
const { path: cacheDir, cleanup } = await makeTempDir("npm-cache");
|
|
3238
3637
|
const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
|
|
3239
3638
|
const buildArgs = (...prefix) => {
|
|
3240
3639
|
const a = [
|
|
@@ -3259,10 +3658,7 @@ async function runNpmInstall(args) {
|
|
|
3259
3658
|
maxBuffer: INSTALL_MAX_BUFFER
|
|
3260
3659
|
});
|
|
3261
3660
|
} finally {
|
|
3262
|
-
await
|
|
3263
|
-
recursive: true,
|
|
3264
|
-
force: true
|
|
3265
|
-
}).catch(() => {});
|
|
3661
|
+
await cleanup().catch(() => {});
|
|
3266
3662
|
}
|
|
3267
3663
|
}
|
|
3268
3664
|
/**
|
|
@@ -3276,7 +3672,7 @@ async function runNpmInstall(args) {
|
|
|
3276
3672
|
async function runPnpmInstall(args) {
|
|
3277
3673
|
await assertPnpmAvailable();
|
|
3278
3674
|
const { nodeDir } = resolveNpm();
|
|
3279
|
-
const storeDir = await
|
|
3675
|
+
const { path: storeDir, cleanup } = await makeTempDir("pnpm-store");
|
|
3280
3676
|
const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
|
|
3281
3677
|
const argv = [
|
|
3282
3678
|
"install",
|
|
@@ -3296,10 +3692,7 @@ async function runPnpmInstall(args) {
|
|
|
3296
3692
|
maxBuffer: INSTALL_MAX_BUFFER
|
|
3297
3693
|
});
|
|
3298
3694
|
} finally {
|
|
3299
|
-
await
|
|
3300
|
-
recursive: true,
|
|
3301
|
-
force: true
|
|
3302
|
-
}).catch(() => {});
|
|
3695
|
+
await cleanup().catch(() => {});
|
|
3303
3696
|
}
|
|
3304
3697
|
}
|
|
3305
3698
|
/** Pure: argv for `yarn install` with an isolated cache folder. */
|
|
@@ -3325,7 +3718,7 @@ function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
|
|
|
3325
3718
|
async function runYarnInstall(args) {
|
|
3326
3719
|
await assertYarnAvailable();
|
|
3327
3720
|
const { nodeDir } = resolveNpm();
|
|
3328
|
-
const cacheDir = await
|
|
3721
|
+
const { path: cacheDir, cleanup } = await makeTempDir("yarn-cache");
|
|
3329
3722
|
const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
|
|
3330
3723
|
const argv = buildYarnInstallArgs(cacheDir, args.hasPrivateRegistry);
|
|
3331
3724
|
try {
|
|
@@ -3337,10 +3730,7 @@ async function runYarnInstall(args) {
|
|
|
3337
3730
|
maxBuffer: INSTALL_MAX_BUFFER
|
|
3338
3731
|
});
|
|
3339
3732
|
} finally {
|
|
3340
|
-
await
|
|
3341
|
-
recursive: true,
|
|
3342
|
-
force: true
|
|
3343
|
-
}).catch(() => {});
|
|
3733
|
+
await cleanup().catch(() => {});
|
|
3344
3734
|
}
|
|
3345
3735
|
}
|
|
3346
3736
|
/** Pure: argv for a berry `yarn install`. Cache + linker live in .yarnrc.yml. */
|
|
@@ -3493,19 +3883,65 @@ function logSubprocessStreams(e, tokens) {
|
|
|
3493
3883
|
* no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
|
|
3494
3884
|
* Node's normal ESM lookup against `.kici/node_modules/`.
|
|
3495
3885
|
*/
|
|
3496
|
-
const AGENT_SDK_VERSION = "0.1.26";
|
|
3497
|
-
const AGENT_SDK_BUNDLE_HASH = "e06bd1acf349ba365bdb357f3d865e0cbb4ad4604a0270b707a681a4f440954e";
|
|
3498
3886
|
/**
|
|
3499
|
-
*
|
|
3500
|
-
*
|
|
3501
|
-
*
|
|
3502
|
-
*
|
|
3503
|
-
*
|
|
3887
|
+
* Resolve the `@kici-dev/sdk` instance the workflow module itself imports.
|
|
3888
|
+
*
|
|
3889
|
+
* The workflow's `.result` proxies read the module-global step-outputs map of
|
|
3890
|
+
* whichever SDK copy the workflow file resolves — which is generally a
|
|
3891
|
+
* different physical module than the agent's bundled SDK (the workflow is
|
|
3892
|
+
* imported from the cloned source tree and resolves its deps against that
|
|
3893
|
+
* tree's `node_modules`). Resolving via `createRequire(workflowFilePath)` walks
|
|
3894
|
+
* `node_modules` from the workflow file exactly the way the workflow's own
|
|
3895
|
+
* `import '@kici-dev/sdk'` does — including any hoisted copy — so the returned
|
|
3896
|
+
* setters mutate the SAME module-global map object the proxies read. Node caches
|
|
3897
|
+
* ESM modules by resolved URL, so importing that path yields the workflow's live
|
|
3898
|
+
* singleton, not a fresh copy.
|
|
3899
|
+
*
|
|
3900
|
+
* Falls back to the agent's bundled setters when resolution fails (mirrors
|
|
3901
|
+
* `resolveSdkSetters` in the compiler's test runner).
|
|
3902
|
+
*/
|
|
3903
|
+
async function resolveWorkflowSdkSetters(workflowFilePath) {
|
|
3904
|
+
try {
|
|
3905
|
+
const sdk = await import(pathToFileURL(createRequire(workflowFilePath).resolve("@kici-dev/sdk")).href);
|
|
3906
|
+
if (typeof sdk.setStepOutputsMap === "function" && typeof sdk.setStepRefMap === "function" && typeof sdk.setJobOutputsMap === "function") return {
|
|
3907
|
+
setStepOutputsMap: sdk.setStepOutputsMap,
|
|
3908
|
+
setStepRefMap: sdk.setStepRefMap,
|
|
3909
|
+
setJobOutputsMap: sdk.setJobOutputsMap
|
|
3910
|
+
};
|
|
3911
|
+
} catch {}
|
|
3912
|
+
return {
|
|
3913
|
+
setStepOutputsMap,
|
|
3914
|
+
setStepRefMap,
|
|
3915
|
+
setJobOutputsMap
|
|
3916
|
+
};
|
|
3917
|
+
}
|
|
3918
|
+
const AGENT_SDK_VERSION = "0.2.0";
|
|
3919
|
+
const AGENT_SDK_BUNDLE_HASH = "fcd0a836aa1cb92debd93e0254450614d123b3748511aa4148d956fd72a7c29f";
|
|
3920
|
+
/**
|
|
3921
|
+
* Register the ESM loader hook that transforms `.ts` / `.tsx` files on the fly
|
|
3922
|
+
* for subsequent dynamic `import()` calls. Idempotent at our level via the
|
|
3923
|
+
* `hookRegistered` flag; Node also tolerates repeated `register()` calls by
|
|
3924
|
+
* stacking layers, but we avoid the noise.
|
|
3925
|
+
*
|
|
3926
|
+
* Two registration branches:
|
|
3927
|
+
*
|
|
3928
|
+
* - **Container path** — when `KICI_TS_LOADER_HOOK_PATH` is set (only
|
|
3929
|
+
* `ContainerSandbox` sets it, pointing at a pure-JS hook bundle mounted into
|
|
3930
|
+
* the job container), register that on-disk hook by absolute `file://` URL.
|
|
3931
|
+
* `module.register` resolves the specifier on disk in a worker thread, so a
|
|
3932
|
+
* bare package specifier would need a `node_modules` tree next to the runner
|
|
3933
|
+
* bundle — which does not exist in a bare customer container. An absolute
|
|
3934
|
+
* `file://` URL sidesteps resolution entirely.
|
|
3935
|
+
* - **Fork / firecracker path** — when the env var is unset, register the
|
|
3936
|
+
* `@kici-dev/core/ts-loader-hook` oxc-transform hook by bare specifier,
|
|
3937
|
+
* resolved via the workspace `node_modules` those sandboxes bind read-only.
|
|
3504
3938
|
*/
|
|
3505
3939
|
let hookRegistered = false;
|
|
3506
3940
|
function ensureLoaderHookRegistered() {
|
|
3507
3941
|
if (hookRegistered) return;
|
|
3508
|
-
|
|
3942
|
+
const hookPath = process.env.KICI_TS_LOADER_HOOK_PATH;
|
|
3943
|
+
if (hookPath) register(pathToFileURL(hookPath).href, import.meta.url);
|
|
3944
|
+
else register("@kici-dev/core/ts-loader-hook", import.meta.url);
|
|
3509
3945
|
hookRegistered = true;
|
|
3510
3946
|
}
|
|
3511
3947
|
/**
|
|
@@ -3560,7 +3996,10 @@ async function loadWorkflowSource(workDir, sourceFile, expectedContentHash, reso
|
|
|
3560
3996
|
const actualHash = computeContentHash(rawSource, assetDigest);
|
|
3561
3997
|
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.`);
|
|
3562
3998
|
}
|
|
3563
|
-
return {
|
|
3999
|
+
return {
|
|
4000
|
+
module: await import(pathToFileURL(filePath).href + `?t=${Date.now()}`),
|
|
4001
|
+
sdkSetters: await resolveWorkflowSdkSetters(filePath)
|
|
4002
|
+
};
|
|
3564
4003
|
}
|
|
3565
4004
|
/**
|
|
3566
4005
|
* Type guard for Workflow shape (discriminant: `_tag === 'Workflow'`).
|
|
@@ -3757,7 +4196,7 @@ function decryptBuffer(encrypted, aesKey) {
|
|
|
3757
4196
|
*/
|
|
3758
4197
|
async function applyOverlay(config) {
|
|
3759
4198
|
const { tarballUrl, cliPublicKey, orchestratorPrivateKey, repoDir } = config;
|
|
3760
|
-
const tmpDir = await
|
|
4199
|
+
const { path: tmpDir, cleanup } = await makeTempDir("overlay");
|
|
3761
4200
|
try {
|
|
3762
4201
|
logger.info("Downloading overlay tarball", { url: tarballUrl.replace(/\?.*$/, "?[redacted]") });
|
|
3763
4202
|
let encryptedData;
|
|
@@ -3831,10 +4270,7 @@ async function applyOverlay(config) {
|
|
|
3831
4270
|
verified: true
|
|
3832
4271
|
};
|
|
3833
4272
|
} finally {
|
|
3834
|
-
await
|
|
3835
|
-
recursive: true,
|
|
3836
|
-
force: true
|
|
3837
|
-
}).catch(() => {});
|
|
4273
|
+
await cleanup().catch(() => {});
|
|
3838
4274
|
}
|
|
3839
4275
|
}
|
|
3840
4276
|
//#endregion
|
|
@@ -3856,7 +4292,7 @@ async function applyOverlay(config) {
|
|
|
3856
4292
|
*/
|
|
3857
4293
|
init_download();
|
|
3858
4294
|
init_dep_restore();
|
|
3859
|
-
const AGENT_VERSION = "0.
|
|
4295
|
+
const AGENT_VERSION = "0.2.0";
|
|
3860
4296
|
process.on("uncaughtException", (err) => {
|
|
3861
4297
|
process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
|
|
3862
4298
|
if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
|
|
@@ -3943,7 +4379,8 @@ function installOutputCapture() {
|
|
|
3943
4379
|
for (const line of lines) if (line) captureSendFn({
|
|
3944
4380
|
type: "log.line",
|
|
3945
4381
|
stepIndex: stepIdx,
|
|
3946
|
-
line
|
|
4382
|
+
line,
|
|
4383
|
+
stream: LogStream.enum.stderr
|
|
3947
4384
|
});
|
|
3948
4385
|
}
|
|
3949
4386
|
return origStderrWrite(chunk, encodingOrCb, cb);
|
|
@@ -4196,6 +4633,33 @@ function waitForProvenanceResponse(requestId) {
|
|
|
4196
4633
|
});
|
|
4197
4634
|
});
|
|
4198
4635
|
}
|
|
4636
|
+
/**
|
|
4637
|
+
* Pending promises for artifacts.response messages from the agent.
|
|
4638
|
+
* Key: requestId (correlates artifacts.request -> artifacts.response).
|
|
4639
|
+
*/
|
|
4640
|
+
const pendingArtifactResponses = /* @__PURE__ */ new Map();
|
|
4641
|
+
/** Wait for an artifacts.response from the agent with the given requestId. */
|
|
4642
|
+
function waitForArtifactResponse(requestId) {
|
|
4643
|
+
return new Promise((resolve, reject) => {
|
|
4644
|
+
const timer = setTimeout(() => {
|
|
4645
|
+
pendingArtifactResponses.delete(requestId);
|
|
4646
|
+
reject(/* @__PURE__ */ new Error(`Artifact request timed out after ${CACHE_RESPONSE_TIMEOUT_MS}ms`));
|
|
4647
|
+
}, CACHE_RESPONSE_TIMEOUT_MS);
|
|
4648
|
+
pendingArtifactResponses.set(requestId, {
|
|
4649
|
+
resolve: (response) => {
|
|
4650
|
+
clearTimeout(timer);
|
|
4651
|
+
pendingArtifactResponses.delete(requestId);
|
|
4652
|
+
resolve(response);
|
|
4653
|
+
},
|
|
4654
|
+
reject: (err) => {
|
|
4655
|
+
clearTimeout(timer);
|
|
4656
|
+
pendingArtifactResponses.delete(requestId);
|
|
4657
|
+
reject(err);
|
|
4658
|
+
},
|
|
4659
|
+
timer
|
|
4660
|
+
});
|
|
4661
|
+
});
|
|
4662
|
+
}
|
|
4199
4663
|
/** Wait for a cache.response from the agent with the given requestId. */
|
|
4200
4664
|
function waitForCacheResponse(requestId) {
|
|
4201
4665
|
return new Promise((resolve, reject) => {
|
|
@@ -4350,6 +4814,68 @@ function buildCacheTransport() {
|
|
|
4350
4814
|
}
|
|
4351
4815
|
};
|
|
4352
4816
|
}
|
|
4817
|
+
/**
|
|
4818
|
+
* Build the {@link ArtifactTransport} the sandbox-side artifacts engine uses to
|
|
4819
|
+
* reach the orchestrator. Each method sends an `artifacts.request` IPC (relayed
|
|
4820
|
+
* by the agent over the WS as an `artifacts.upload.*` / `artifacts.download.*`
|
|
4821
|
+
* message) and awaits the matching `artifacts.response`. Mirrors
|
|
4822
|
+
* {@link buildCacheTransport}.
|
|
4823
|
+
*/
|
|
4824
|
+
function buildArtifactTransport() {
|
|
4825
|
+
return {
|
|
4826
|
+
async beginUpload(name, declaredSizeBytes) {
|
|
4827
|
+
const requestId = randomUUID();
|
|
4828
|
+
sendMessage({
|
|
4829
|
+
type: "artifacts.request",
|
|
4830
|
+
requestId,
|
|
4831
|
+
op: "beginUpload",
|
|
4832
|
+
name,
|
|
4833
|
+
declaredSizeBytes
|
|
4834
|
+
});
|
|
4835
|
+
const response = await waitForArtifactResponse(requestId);
|
|
4836
|
+
if (response.error) throw new Error(`Artifact upload failed: ${response.error}`);
|
|
4837
|
+
return {
|
|
4838
|
+
outcome: response.uploadOutcome ?? "rejected",
|
|
4839
|
+
...response.uploadUrl && { uploadUrl: response.uploadUrl },
|
|
4840
|
+
...response.storageKey && { storageKey: response.storageKey },
|
|
4841
|
+
...response.reason && { reason: response.reason },
|
|
4842
|
+
...response.rejectionDetail && { error: response.rejectionDetail }
|
|
4843
|
+
};
|
|
4844
|
+
},
|
|
4845
|
+
async completeUpload(name, sizeBytes, sha256, storageKey) {
|
|
4846
|
+
const requestId = randomUUID();
|
|
4847
|
+
sendMessage({
|
|
4848
|
+
type: "artifacts.request",
|
|
4849
|
+
requestId,
|
|
4850
|
+
op: "completeUpload",
|
|
4851
|
+
name,
|
|
4852
|
+
sizeBytes,
|
|
4853
|
+
sha256,
|
|
4854
|
+
storageKey
|
|
4855
|
+
});
|
|
4856
|
+
const response = await waitForArtifactResponse(requestId);
|
|
4857
|
+
if (response.error) throw new Error(`Artifact upload-complete failed: ${response.error}`);
|
|
4858
|
+
},
|
|
4859
|
+
async download(name) {
|
|
4860
|
+
const requestId = randomUUID();
|
|
4861
|
+
sendMessage({
|
|
4862
|
+
type: "artifacts.request",
|
|
4863
|
+
requestId,
|
|
4864
|
+
op: "download",
|
|
4865
|
+
name
|
|
4866
|
+
});
|
|
4867
|
+
const response = await waitForArtifactResponse(requestId);
|
|
4868
|
+
if (response.error) throw new Error(`Artifact download failed: ${response.error}`);
|
|
4869
|
+
return {
|
|
4870
|
+
outcome: response.downloadOutcome ?? "not_found",
|
|
4871
|
+
...response.downloadUrl && { downloadUrl: response.downloadUrl },
|
|
4872
|
+
...response.sizeBytes !== void 0 && { sizeBytes: response.sizeBytes },
|
|
4873
|
+
...response.sha256 && { sha256: response.sha256 },
|
|
4874
|
+
...response.rejectionDetail && { error: response.rejectionDetail }
|
|
4875
|
+
};
|
|
4876
|
+
}
|
|
4877
|
+
};
|
|
4878
|
+
}
|
|
4353
4879
|
/** Send a `provenance.request` IPC and await the matching `provenance.response`. */
|
|
4354
4880
|
async function relayProvenanceIpc(request) {
|
|
4355
4881
|
const requestId = randomUUID();
|
|
@@ -4505,6 +5031,9 @@ function dispatchAgentMessage(msg) {
|
|
|
4505
5031
|
} else if (msg.type === "provenance.response") {
|
|
4506
5032
|
const pending = pendingProvenanceResponses.get(msg.requestId);
|
|
4507
5033
|
if (pending) pending.resolve(msg);
|
|
5034
|
+
} else if (msg.type === "artifacts.response") {
|
|
5035
|
+
const pending = pendingArtifactResponses.get(msg.requestId);
|
|
5036
|
+
if (pending) pending.resolve(msg);
|
|
4508
5037
|
} else if (msg.type === "approval.resolved") {
|
|
4509
5038
|
const pending = pendingApprovalResolutions.get(msg.requestId);
|
|
4510
5039
|
if (pending) pending.resolve(msg);
|
|
@@ -4649,13 +5178,13 @@ function envelopeChildOutputs(envelope) {
|
|
|
4649
5178
|
*/
|
|
4650
5179
|
function buildStepSecrets(request, masker, onMaskerSecretsAdded) {
|
|
4651
5180
|
const mergedFlat = buildMergedFlatSecrets(request.secrets ?? {}, request.namespacedSecrets ?? {});
|
|
4652
|
-
let
|
|
5181
|
+
let stepTmpHandle = null;
|
|
4653
5182
|
const exposedEnvVars = /* @__PURE__ */ new Set();
|
|
4654
5183
|
let mountCounter = 0;
|
|
4655
5184
|
const env = process.env;
|
|
4656
5185
|
async function ensureTmpdir() {
|
|
4657
|
-
if (
|
|
4658
|
-
return
|
|
5186
|
+
if (stepTmpHandle === null) stepTmpHandle = await makeTempDir("secret-files");
|
|
5187
|
+
return stepTmpHandle.path;
|
|
4659
5188
|
}
|
|
4660
5189
|
return createStepSecrets(mergedFlat, env, request.secretMeta, {
|
|
4661
5190
|
host: {
|
|
@@ -4679,16 +5208,14 @@ function buildStepSecrets(request, masker, onMaskerSecretsAdded) {
|
|
|
4679
5208
|
delete process.env[envVar];
|
|
4680
5209
|
}
|
|
4681
5210
|
exposedEnvVars.clear();
|
|
4682
|
-
if (
|
|
4683
|
-
await
|
|
4684
|
-
|
|
4685
|
-
force: true
|
|
4686
|
-
});
|
|
4687
|
-
stepTmpdir = null;
|
|
5211
|
+
if (stepTmpHandle !== null) {
|
|
5212
|
+
await stepTmpHandle.cleanup();
|
|
5213
|
+
stepTmpHandle = null;
|
|
4688
5214
|
}
|
|
4689
5215
|
},
|
|
4690
5216
|
onDisposeError: (err) => {
|
|
4691
|
-
|
|
5217
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5218
|
+
origStderrWrite(`[workflow-runner] secret-file cleanup error: ${message}\n`);
|
|
4692
5219
|
}
|
|
4693
5220
|
});
|
|
4694
5221
|
}
|
|
@@ -4712,14 +5239,25 @@ function createSecretMasker(request) {
|
|
|
4712
5239
|
* before spawning this process). This is the single shell-construction code
|
|
4713
5240
|
* path shared by step execution (`createSandboxStepContext`) and the per-job
|
|
4714
5241
|
* init phase (`runInitPhase`), so init commands run through the identical shell
|
|
4715
|
-
* steps use — same cwd, same env
|
|
5242
|
+
* steps use — same cwd, same live sanitized env, same masked log streaming.
|
|
5243
|
+
*
|
|
5244
|
+
* The shell binds `process.env` by reference (not a copy), so a same-step
|
|
5245
|
+
* ctx.setEnv / ctx.addPath mutation — which applyEnvDelta writes to live
|
|
5246
|
+
* process.env — is visible to that step's own subprocesses, matching the
|
|
5247
|
+
* "visible to this step" SDK contract.
|
|
4716
5248
|
*
|
|
4717
5249
|
* Intercept zx subprocess output via the log callback: zx does NOT write child
|
|
4718
5250
|
* stdout/stderr to process.stdout — it pipes to an internal VoidStream and only
|
|
4719
|
-
* calls $.log() with { kind: 'stdout'|'stderr' }.
|
|
4720
|
-
*
|
|
4721
|
-
*
|
|
4722
|
-
*
|
|
5251
|
+
* calls $.log() with { kind: 'stdout'|'stderr', verbose }. We override the log
|
|
5252
|
+
* function (`makeStreamingZxLog`) to capture both kinds and send them as masked
|
|
5253
|
+
* IPC log.line messages tagged with `stepIndex`.
|
|
5254
|
+
*
|
|
5255
|
+
* The shell is built with `verbose: true` so ordinary subprocess output is
|
|
5256
|
+
* flagged `verbose: true` (captured) while a step that opts into
|
|
5257
|
+
* `$({ quiet: true })` — e.g. a `sops -d` credential decrypt — is flagged
|
|
5258
|
+
* `verbose: false` and dropped by `makeStreamingZxLog`. That gate is what keeps
|
|
5259
|
+
* a decrypted secret out of the streamed run log; a `verbose: false` base would
|
|
5260
|
+
* flag ordinary output `verbose: false` too and drop every line.
|
|
4723
5261
|
*
|
|
4724
5262
|
* IMPORTANT: The log function must be passed in the zx$() config, not set on the
|
|
4725
5263
|
* returned function. zx$() returns a plain function (not the proxy $), so setting
|
|
@@ -4727,36 +5265,82 @@ function createSecretMasker(request) {
|
|
|
4727
5265
|
* AsyncLocalStorage store that zx uses for ProcessPromise snapshots.
|
|
4728
5266
|
*/
|
|
4729
5267
|
function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
|
|
4730
|
-
let zxLineBuf = "";
|
|
4731
5268
|
return $({
|
|
4732
5269
|
cwd,
|
|
4733
|
-
env:
|
|
4734
|
-
verbose:
|
|
5270
|
+
env: process.env,
|
|
5271
|
+
verbose: true,
|
|
4735
5272
|
quiet: false,
|
|
4736
|
-
log: ((
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
for (const line of lines) if (line) maskedSendFn({
|
|
4743
|
-
type: "log.line",
|
|
4744
|
-
stepIndex,
|
|
4745
|
-
line
|
|
4746
|
-
});
|
|
4747
|
-
return;
|
|
4748
|
-
}
|
|
4749
|
-
})
|
|
5273
|
+
log: makeStreamingZxLog((line, stream) => maskedSendFn({
|
|
5274
|
+
type: "log.line",
|
|
5275
|
+
stepIndex,
|
|
5276
|
+
line,
|
|
5277
|
+
stream
|
|
5278
|
+
}))
|
|
4750
5279
|
});
|
|
4751
5280
|
}
|
|
4752
5281
|
/**
|
|
5282
|
+
* Resolve the on-the-wire event name for `ctx.emit`. Accepts either an ad-hoc
|
|
5283
|
+
* event-name string or a `defineEvent()` definition object (passed by the typed
|
|
5284
|
+
* emit overload); a definition resolves to its `.name`, a string passes through.
|
|
5285
|
+
*/
|
|
5286
|
+
function resolveEmitEventName(nameOrDefinition) {
|
|
5287
|
+
return isEventDefinition(nameOrDefinition) ? nameOrDefinition.name : nameOrDefinition;
|
|
5288
|
+
}
|
|
5289
|
+
/**
|
|
5290
|
+
* Sanitize a raw identifier into a valid temp label: lowercase, every
|
|
5291
|
+
* non-`[a-z0-9-]` char to `-`, falling back to `'step'` when the result is
|
|
5292
|
+
* empty. Applied to both the caller-supplied `ctx.mktemp(label)` and the
|
|
5293
|
+
* default step-id label so a friendly-but-irregular label never rejects at the
|
|
5294
|
+
* scope. Mirrors the SDK test builder's `sanitizeTempLabel`.
|
|
5295
|
+
*/
|
|
5296
|
+
function sanitizeTempLabel(raw) {
|
|
5297
|
+
const cleaned = raw.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
5298
|
+
return cleaned.length > 0 ? cleaned : "step";
|
|
5299
|
+
}
|
|
5300
|
+
/**
|
|
5301
|
+
* Drain the job-scoped temp allocator once at job end.
|
|
5302
|
+
*
|
|
5303
|
+
* Called after the step loop and cancel-path hooks, immediately before the
|
|
5304
|
+
* terminal `job.complete` emit, so a single call reclaims every `ctx.mktemp` /
|
|
5305
|
+
* `ctx.mktempFile` allocation on success, failure, and cancel/timeout alike. A
|
|
5306
|
+
* cleanup failure must NEVER change the job's terminal status, so any error is
|
|
5307
|
+
* swallowed into `warn` rather than thrown out of `main()`.
|
|
5308
|
+
*/
|
|
5309
|
+
async function drainJobTempScope(scope, warn) {
|
|
5310
|
+
try {
|
|
5311
|
+
await scope.disposeAll();
|
|
5312
|
+
} catch (err) {
|
|
5313
|
+
warn(`job temp scope cleanup failed: ${toErrorMessage(err)}`);
|
|
5314
|
+
}
|
|
5315
|
+
}
|
|
5316
|
+
/**
|
|
5317
|
+
* Job-end wrapper around {@link drainJobTempScope} that routes a cleanup
|
|
5318
|
+
* warning to the run log as a job-level (`stepIndex: -1`) line. Kept out of
|
|
5319
|
+
* `main()` so the single drain call site stays one line.
|
|
5320
|
+
*/
|
|
5321
|
+
async function drainJobTempScopeToLog(scope, send) {
|
|
5322
|
+
await drainJobTempScope(scope, (message) => send({
|
|
5323
|
+
type: "log.line",
|
|
5324
|
+
stepIndex: -1,
|
|
5325
|
+
line: `[kici] ${message}`
|
|
5326
|
+
}));
|
|
5327
|
+
}
|
|
5328
|
+
/**
|
|
5329
|
+
* Map the step loop's terminal status onto the initial job-level status —
|
|
5330
|
+
* `success` iff every step succeeded, else `failed`. `main()` may override this
|
|
5331
|
+
* afterward for the cancel/timeout paths.
|
|
5332
|
+
*/
|
|
5333
|
+
function initialJobStatus(loopStatus) {
|
|
5334
|
+
return loopStatus === ExecutionStepStatus.enum.success ? ExecutionJobStatus.enum.success : ExecutionJobStatus.enum.failed;
|
|
5335
|
+
}
|
|
5336
|
+
/**
|
|
4753
5337
|
* Create a StepContext natively inside the workflow runner.
|
|
4754
5338
|
*
|
|
4755
5339
|
* The context is reconstructed from the environment and IPC request fields --
|
|
4756
5340
|
* NOT serialized across the process boundary. This means zx $ runs natively
|
|
4757
5341
|
* inside this process with full shell access.
|
|
4758
5342
|
*/
|
|
4759
|
-
function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets, masker, signal) {
|
|
5343
|
+
function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets, masker, signal, jobTempScope) {
|
|
4760
5344
|
const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
|
|
4761
5345
|
const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
|
|
4762
5346
|
const rawPayload = rawPayloadFromEvent(request.event);
|
|
@@ -4800,9 +5384,11 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
4800
5384
|
runsOn: request.runsOn
|
|
4801
5385
|
},
|
|
4802
5386
|
isTestRun: request.isTestRun ?? false,
|
|
4803
|
-
|
|
5387
|
+
context: request.context,
|
|
4804
5388
|
cache: createCacheApi(workDir, buildCacheTransport()),
|
|
4805
|
-
|
|
5389
|
+
artifacts: createArtifactsApi(workDir, buildArtifactTransport()),
|
|
5390
|
+
emit: async (nameOrDefinition, payload, options) => {
|
|
5391
|
+
const eventName = resolveEmitEventName(nameOrDefinition);
|
|
4806
5392
|
const reqId = randomUUID();
|
|
4807
5393
|
sendMessage({
|
|
4808
5394
|
type: "event.emit",
|
|
@@ -4824,6 +5410,8 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
4824
5410
|
setSecretOutput: (key, value) => {
|
|
4825
5411
|
secretOutputs.set(key, value);
|
|
4826
5412
|
},
|
|
5413
|
+
mktemp: (label) => jobTempScope.mktemp(sanitizeTempLabel(label ?? stepName)),
|
|
5414
|
+
mktempFile: (label, opts) => jobTempScope.mktempFile(sanitizeTempLabel(label ?? stepName), opts),
|
|
4827
5415
|
kici,
|
|
4828
5416
|
attestProvenance: buildAttestProvenanceFn(request, workDir, (o) => kici.oidc.token(o)),
|
|
4829
5417
|
...rawPayload && { rawPayload },
|
|
@@ -5008,14 +5596,15 @@ async function applyOverlayIfRequested(request, workflowDir) {
|
|
|
5008
5596
|
* Mirroring the `file://`-clone fix in checkout/git-clone.ts, we point
|
|
5009
5597
|
* `GIT_CONFIG_GLOBAL` at a temp config carrying `safe.directory = *`. Setting it
|
|
5010
5598
|
* on `process.env` here (before the step loop) means every step subprocess —
|
|
5011
|
-
* each zx `$`
|
|
5599
|
+
* each zx `$` reads live `process.env` at spawn — inherits it, so git
|
|
5012
5600
|
* works in steps exactly as it does locally. We also register the dep-restore
|
|
5013
5601
|
* scratch-dir exclude now that a real `.git` exists in the workspace.
|
|
5014
5602
|
*/
|
|
5015
5603
|
async function makeOverlayGitUsable(request, workspaceDir) {
|
|
5016
5604
|
if (!request.fullRepo) return;
|
|
5017
5605
|
if (!existsSync(join(workspaceDir, ".git"))) return;
|
|
5018
|
-
const
|
|
5606
|
+
const { path: cfgDir } = await makeTempDir("gitcfg");
|
|
5607
|
+
const cfgPath = join(cfgDir, "config");
|
|
5019
5608
|
await fsPromises.writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
|
|
5020
5609
|
process.env.GIT_CONFIG_GLOBAL = cfgPath;
|
|
5021
5610
|
trace(`fullRepo git safe.directory configured via GIT_CONFIG_GLOBAL=${cfgPath}`);
|
|
@@ -5206,7 +5795,7 @@ async function evaluateConcurrencyGroupIfPresent(workflow, request) {
|
|
|
5206
5795
|
stepIndex: -1,
|
|
5207
5796
|
line: `[kici] Concurrency: queued${ack.reason ? ` (${ack.reason})` : ""}, waiting for slot to free`
|
|
5208
5797
|
});
|
|
5209
|
-
const waitCapMs = Number.parseInt(process.env.KICI_CONCURRENCY_WAIT_TIMEOUT_MS ?? "", 10) || 36e5;
|
|
5798
|
+
const waitCapMs = request.concurrencyWaitTimeoutMs ?? (Number.parseInt(process.env.KICI_CONCURRENCY_WAIT_TIMEOUT_MS ?? "", 10) || 36e5);
|
|
5210
5799
|
try {
|
|
5211
5800
|
ack = await waitForConcurrencyAck(waitCapMs);
|
|
5212
5801
|
} catch (waitErr) {
|
|
@@ -5395,6 +5984,46 @@ async function runCancelPathHooks(args) {
|
|
|
5395
5984
|
};
|
|
5396
5985
|
}
|
|
5397
5986
|
/**
|
|
5987
|
+
* Coerce one raw entry (bare function or Step) into a normalized Step, naming
|
|
5988
|
+
* anonymous steps `step-N` with a counter shared across the whole flattened
|
|
5989
|
+
* sequence (parallel children inline) — matching the compiler's transformSteps
|
|
5990
|
+
* enumeration so the flat-stepIndex invariant holds agent ↔ orchestrator.
|
|
5991
|
+
*
|
|
5992
|
+
* Unnamed steps are named by MUTATING the original object so the SDK's
|
|
5993
|
+
* late-bound `.result` proxy (which reads `step.name` at access time) resolves.
|
|
5994
|
+
* A step object reused twice in one pass gets a clone with a fresh name on the
|
|
5995
|
+
* repeat encounter — mirroring the compiler's per-occurrence naming so lock
|
|
5996
|
+
* parity holds; the original keeps its first binding.
|
|
5997
|
+
*/
|
|
5998
|
+
function coerceStep(stepOrFn, state) {
|
|
5999
|
+
if (typeof stepOrFn === "function") {
|
|
6000
|
+
state.counter++;
|
|
6001
|
+
const name = `step-${state.counter}`;
|
|
6002
|
+
state.refMap.set(stepOrFn, name);
|
|
6003
|
+
return {
|
|
6004
|
+
_tag: "Step",
|
|
6005
|
+
name,
|
|
6006
|
+
run: stepOrFn,
|
|
6007
|
+
outputs: void 0
|
|
6008
|
+
};
|
|
6009
|
+
}
|
|
6010
|
+
const s = stepOrFn;
|
|
6011
|
+
if (!s.name) {
|
|
6012
|
+
state.counter++;
|
|
6013
|
+
s.name = `step-${state.counter}`;
|
|
6014
|
+
state.autoNamed.add(s);
|
|
6015
|
+
return s;
|
|
6016
|
+
}
|
|
6017
|
+
if (state.autoNamed.has(s)) {
|
|
6018
|
+
state.counter++;
|
|
6019
|
+
return {
|
|
6020
|
+
...s,
|
|
6021
|
+
name: `step-${state.counter}`
|
|
6022
|
+
};
|
|
6023
|
+
}
|
|
6024
|
+
return s;
|
|
6025
|
+
}
|
|
6026
|
+
/**
|
|
5398
6027
|
* Phase 5 — Extract steps for the requested job (re-evaluating the dynamic
|
|
5399
6028
|
* factory when `dynamicSource` is set, otherwise looking up the static job)
|
|
5400
6029
|
* and normalise the result into the `Step[]` shape the step loop consumes.
|
|
@@ -5410,30 +6039,13 @@ async function extractAndNormalizeSteps(workflow, request, apiTransport) {
|
|
|
5410
6039
|
driftDroppedJobs = dynamicResult.droppedJobs;
|
|
5411
6040
|
if (driftDroppedJobs.length > 0) trace(`Determinism drift: ${driftDroppedJobs.length} job(s) dropped: ${driftDroppedJobs.join(", ")}`);
|
|
5412
6041
|
} else rawSteps = extractSteps(workflow, request.jobName);
|
|
5413
|
-
const
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
stepCounter++;
|
|
5418
|
-
const name = `step-${stepCounter}`;
|
|
5419
|
-
refMap.set(stepOrFn, name);
|
|
5420
|
-
return {
|
|
5421
|
-
_tag: "Step",
|
|
5422
|
-
name,
|
|
5423
|
-
run: stepOrFn,
|
|
5424
|
-
outputs: void 0
|
|
5425
|
-
};
|
|
5426
|
-
}
|
|
5427
|
-
const s = stepOrFn;
|
|
5428
|
-
if (!s.name) {
|
|
5429
|
-
stepCounter++;
|
|
5430
|
-
return {
|
|
5431
|
-
...s,
|
|
5432
|
-
name: `step-${stepCounter}`
|
|
5433
|
-
};
|
|
5434
|
-
}
|
|
5435
|
-
return s;
|
|
6042
|
+
const coerceState = {
|
|
6043
|
+
counter: 0,
|
|
6044
|
+
refMap: /* @__PURE__ */ new WeakMap(),
|
|
6045
|
+
autoNamed: /* @__PURE__ */ new WeakSet()
|
|
5436
6046
|
};
|
|
6047
|
+
const refMap = coerceState.refMap;
|
|
6048
|
+
const coerce = (stepOrFn) => coerceStep(stepOrFn, coerceState);
|
|
5437
6049
|
const normalizedSteps = [];
|
|
5438
6050
|
const nodes = [];
|
|
5439
6051
|
let flatIndex = 0;
|
|
@@ -5480,9 +6092,13 @@ async function extractAndNormalizeSteps(workflow, request, apiTransport) {
|
|
|
5480
6092
|
* Phase 6 — Build the output infrastructure: the per-step operator-secret
|
|
5481
6093
|
* key set (used for setEnv override protection), and the output / job-output
|
|
5482
6094
|
* maps that back `.result` proxies and `ctx.outputsOf()` / `ctx.jobOutputs()`.
|
|
5483
|
-
* Sets the SDK module globals as a side effect
|
|
6095
|
+
* Sets the SDK module globals as a side effect — on BOTH the agent's bundled
|
|
6096
|
+
* SDK and the workflow module's own SDK instance (`workflowSdkSetters`), with
|
|
6097
|
+
* the same map objects. The workflow's within-job `.result` proxies read that
|
|
6098
|
+
* instance's module-global step-outputs map, so wiring only the agent's bundled
|
|
6099
|
+
* SDK would leave the proxies reading an always-empty map.
|
|
5484
6100
|
*/
|
|
5485
|
-
function buildOutputInfrastructure(request, refMap) {
|
|
6101
|
+
function buildOutputInfrastructure(request, refMap, workflowSdkSetters) {
|
|
5486
6102
|
const operatorSecretKeys = /* @__PURE__ */ new Set();
|
|
5487
6103
|
if (request.secrets) for (const key of Object.keys(request.secrets)) operatorSecretKeys.add(key);
|
|
5488
6104
|
if (request.namespacedSecrets) for (const ctx of Object.values(request.namespacedSecrets)) for (const key of Object.keys(ctx)) operatorSecretKeys.add(key);
|
|
@@ -5490,9 +6106,12 @@ function buildOutputInfrastructure(request, refMap) {
|
|
|
5490
6106
|
const secretOutputs = /* @__PURE__ */ new Map();
|
|
5491
6107
|
setStepOutputsMap(outputsMap);
|
|
5492
6108
|
setStepRefMap(refMap);
|
|
6109
|
+
workflowSdkSetters?.setStepOutputsMap(outputsMap);
|
|
6110
|
+
workflowSdkSetters?.setStepRefMap(refMap);
|
|
5493
6111
|
const jobOutputsMap = /* @__PURE__ */ new Map();
|
|
5494
6112
|
if (request.upstreamJobOutputs) for (const [jobName, outputs] of Object.entries(request.upstreamJobOutputs)) jobOutputsMap.set(jobName, outputs);
|
|
5495
6113
|
setJobOutputsMap(jobOutputsMap);
|
|
6114
|
+
workflowSdkSetters?.setJobOutputsMap(jobOutputsMap);
|
|
5496
6115
|
return {
|
|
5497
6116
|
operatorSecretKeys,
|
|
5498
6117
|
outputsMap,
|
|
@@ -5501,27 +6120,82 @@ function buildOutputInfrastructure(request, refMap) {
|
|
|
5501
6120
|
};
|
|
5502
6121
|
}
|
|
5503
6122
|
/**
|
|
5504
|
-
*
|
|
5505
|
-
*
|
|
5506
|
-
*
|
|
6123
|
+
* Resolve `event.changedFiles` for rule evaluation before job/step rules run.
|
|
6124
|
+
* Ground truth is the agent's own clone (`computeChangedFiles`); the
|
|
6125
|
+
* orchestrator's already-fetched list (status `'fetched'`) is a free fast-path.
|
|
6126
|
+
* Only runs when a rule could read `ctx.changedFiles` — rule-less jobs skip the
|
|
6127
|
+
* git cost. Diff-less events (schedule/tag/manual) resolve to `'unavailable'`.
|
|
5507
6128
|
*/
|
|
5508
|
-
async function
|
|
5509
|
-
if (!
|
|
5510
|
-
|
|
5511
|
-
|
|
6129
|
+
async function resolveChangedFilesForRules(request, workDir, hasRules) {
|
|
6130
|
+
if (!hasRules) return;
|
|
6131
|
+
request.event ??= {};
|
|
6132
|
+
const ev = request.event;
|
|
6133
|
+
if (ev.changedFilesStatus === "fetched") return;
|
|
6134
|
+
const auth = request.sourceAuth ?? request.workflowAuth ?? (request.token ? {
|
|
6135
|
+
kind: "basic",
|
|
6136
|
+
user: "x-access-token",
|
|
6137
|
+
secret: request.token
|
|
6138
|
+
} : void 0);
|
|
6139
|
+
const resolved = await computeChangedFiles(workDir, request.event, auth);
|
|
6140
|
+
ev.changedFiles = resolved.files;
|
|
6141
|
+
ev.changedFilesStatus = resolved.status;
|
|
6142
|
+
}
|
|
6143
|
+
/**
|
|
6144
|
+
* Decide the terminal `job.complete` for job-level rule evaluation, or `null`
|
|
6145
|
+
* when the job should proceed to step execution.
|
|
6146
|
+
*
|
|
6147
|
+
* - `allPassed` → `null` (run the steps).
|
|
6148
|
+
* - a rule's `check()` **threw** (`evaluationError`) → FAIL: the gate could not
|
|
6149
|
+
* be evaluated, so treating "couldn't decide" as "don't run" would be a false
|
|
6150
|
+
* green. Report `status: failed` with the error surfaced.
|
|
6151
|
+
* - a rule cleanly returned `false` → clean skip: `status: success` with every
|
|
6152
|
+
* step marked skipped (a gate that evaluated and said "don't run").
|
|
6153
|
+
*
|
|
6154
|
+
* Pure and exported so the decision is unit-testable without `process.exit`.
|
|
6155
|
+
*/
|
|
6156
|
+
function buildJobRuleCompletion(ruleResult, normalizedSteps) {
|
|
6157
|
+
if (ruleResult.allPassed) return null;
|
|
5512
6158
|
const skippedResults = normalizedSteps.map((s, i) => ({
|
|
5513
6159
|
name: s.name,
|
|
5514
6160
|
stepIndex: i,
|
|
5515
6161
|
status: ExecutionStepStatus.enum.skipped,
|
|
5516
6162
|
durationMs: 0
|
|
5517
6163
|
}));
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
6164
|
+
if (ruleResult.evaluationError) return {
|
|
6165
|
+
type: "job.complete",
|
|
6166
|
+
status: ExecutionJobStatus.enum.failed,
|
|
6167
|
+
stepResults: skippedResults,
|
|
6168
|
+
error: `rule '${ruleResult.evaluationError.label}' errored: ${ruleResult.evaluationError.message}`
|
|
6169
|
+
};
|
|
6170
|
+
return {
|
|
5521
6171
|
type: "job.complete",
|
|
5522
6172
|
status: ExecutionJobStatus.enum.success,
|
|
5523
6173
|
stepResults: skippedResults
|
|
6174
|
+
};
|
|
6175
|
+
}
|
|
6176
|
+
/**
|
|
6177
|
+
* Phase 7 — Evaluate job-level rules. A clean rule failure sends
|
|
6178
|
+
* `job.complete{success}` with all steps skipped; a rule whose `check()` threw
|
|
6179
|
+
* sends `job.complete{failed}` with the error surfaced (never a silent
|
|
6180
|
+
* success-skip). Returns false when the caller should continue to step
|
|
6181
|
+
* execution.
|
|
6182
|
+
*/
|
|
6183
|
+
async function maybeSkipJobOnRules(job, request, normalizedSteps) {
|
|
6184
|
+
if (!job?.rules || job.rules.length === 0) return false;
|
|
6185
|
+
const ev = request.event ?? {};
|
|
6186
|
+
const ruleCtx = createRuleContext({
|
|
6187
|
+
event: request.event ?? {},
|
|
6188
|
+
changedFiles: ev.changedFiles,
|
|
6189
|
+
changedFilesStatus: ev.changedFilesStatus,
|
|
6190
|
+
env: process.env,
|
|
6191
|
+
dispatchInputs: request.dispatchInputs ?? {},
|
|
6192
|
+
fanout: deriveFanout(request)
|
|
5524
6193
|
});
|
|
6194
|
+
const completion = buildJobRuleCompletion(await evaluateRules(job.rules, ruleCtx, request.jobName), normalizedSteps);
|
|
6195
|
+
if (!completion) return false;
|
|
6196
|
+
flushOutputCapture();
|
|
6197
|
+
capturePrepareActive = false;
|
|
6198
|
+
sendMessage(completion);
|
|
5525
6199
|
process.exit(0);
|
|
5526
6200
|
}
|
|
5527
6201
|
/**
|
|
@@ -5571,8 +6245,8 @@ async function applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend) {
|
|
|
5571
6245
|
* Build the step loop's KICI_ENV/KICI_PATH callbacks with a per-step delta-file
|
|
5572
6246
|
* pair (keyed by step index). `beforeStepEnvFiles(stepIndex)` lazily creates the
|
|
5573
6247
|
* step's pair and points the runner's process.env at it (each step's zx $
|
|
5574
|
-
*
|
|
5575
|
-
*
|
|
6248
|
+
* reads live process.env at spawn, which happens AFTER this before-hook, so
|
|
6249
|
+
* the shell sees them; the pre-fork env allowlist does not
|
|
5576
6250
|
* re-filter runtime-set vars). `afterStepApplyEnvFiles(stepIndex)` applies that
|
|
5577
6251
|
* step's delta and releases the pair.
|
|
5578
6252
|
*
|
|
@@ -5645,12 +6319,13 @@ async function runInitPhaseOrFailJob(args) {
|
|
|
5645
6319
|
})
|
|
5646
6320
|
});
|
|
5647
6321
|
if (initSpecs.length === 0) return;
|
|
6322
|
+
const initCache = createCacheApi(stepCwd, buildCacheTransport());
|
|
5648
6323
|
const initResult = await runInitPhase({
|
|
5649
6324
|
specs: initSpecs,
|
|
5650
6325
|
shellFor: (_spec, i) => buildSandboxShell(stepCwd, INIT_STEP_INDEX_BASE + i, maskedSend),
|
|
5651
6326
|
sendIpc: maskedSend,
|
|
5652
6327
|
stepIndexBase: INIT_STEP_INDEX_BASE,
|
|
5653
|
-
cache:
|
|
6328
|
+
cache: initCache,
|
|
5654
6329
|
env: {
|
|
5655
6330
|
beginCapture: async () => {
|
|
5656
6331
|
process.env.KICI_ENV = envFiles.envFile;
|
|
@@ -5723,7 +6398,8 @@ async function main() {
|
|
|
5723
6398
|
await installDependenciesIfNeeded(workflowDir, request);
|
|
5724
6399
|
if (aborted) abortAndExit("aborted after deps");
|
|
5725
6400
|
await restoreSourceTarballIfRequested(workflowDir, request);
|
|
5726
|
-
const
|
|
6401
|
+
const loaded = await loadWorkflowModuleWithCapture(workflowDir, request, isGlobal, maskedSend);
|
|
6402
|
+
const module = loaded.module;
|
|
5727
6403
|
const workflow = extractWorkflow(module, request.workflowName);
|
|
5728
6404
|
await evaluateConcurrencyGroupIfPresent(workflow, request);
|
|
5729
6405
|
const apiTransport = async (method, params) => {
|
|
@@ -5737,8 +6413,11 @@ async function main() {
|
|
|
5737
6413
|
return waitForApiResponse(reqId);
|
|
5738
6414
|
};
|
|
5739
6415
|
const { normalizedSteps, nodes, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
|
|
5740
|
-
const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap);
|
|
6416
|
+
const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap, loaded.sdkSetters);
|
|
5741
6417
|
const job = findJob(workflow, request.jobName);
|
|
6418
|
+
const jobHasRules = (job?.rules?.length ?? 0) > 0;
|
|
6419
|
+
const anyStepHasRules = normalizedSteps.some((s) => (s.rules?.length ?? 0) > 0);
|
|
6420
|
+
await resolveChangedFilesForRules(request, sourceDir, jobHasRules || anyStepHasRules);
|
|
5742
6421
|
await maybeSkipJobOnRules(job, request, normalizedSteps);
|
|
5743
6422
|
if (aborted) abortAndExit("aborted after rules");
|
|
5744
6423
|
const jobHooks = collectJobHooks(job);
|
|
@@ -5756,6 +6435,7 @@ async function main() {
|
|
|
5756
6435
|
const { cachePhaseDeps, jobCacheSpecs, jobCacheRestore } = await setupJobCache(stepCwd, normalizedSteps.length, job?.cache, maskedSend);
|
|
5757
6436
|
const stepTasks = new StepTaskRegistry();
|
|
5758
6437
|
const stepAbortControllers = /* @__PURE__ */ new Map();
|
|
6438
|
+
const jobTempScope = createTempScope();
|
|
5759
6439
|
const createStepCtxWithCapture = (stepIndex, stepName) => {
|
|
5760
6440
|
const stepAbort = new AbortController();
|
|
5761
6441
|
stepAbortControllers.set(stepIndex, stepAbort);
|
|
@@ -5769,7 +6449,7 @@ async function main() {
|
|
|
5769
6449
|
secrets: handle.secrets,
|
|
5770
6450
|
dispose: handle.dispose
|
|
5771
6451
|
});
|
|
5772
|
-
const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets, masker, signal);
|
|
6452
|
+
const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets, masker, signal, jobTempScope);
|
|
5773
6453
|
if (globalRepoInfo) {
|
|
5774
6454
|
ctx.workflowRepo = globalRepoInfo.workflowRepo;
|
|
5775
6455
|
ctx.sourceRepo = globalRepoInfo.sourceRepo;
|
|
@@ -5814,7 +6494,7 @@ async function main() {
|
|
|
5814
6494
|
});
|
|
5815
6495
|
jobDeadline.clear();
|
|
5816
6496
|
await maybeSaveJobCache(jobCacheSpecs, jobCacheRestore, cachePhaseDeps, !aborted && loopResult.status === ExecutionStepStatus.enum.success);
|
|
5817
|
-
let finalStatus = loopResult.status
|
|
6497
|
+
let finalStatus = initialJobStatus(loopResult.status);
|
|
5818
6498
|
let cancelFailureReason;
|
|
5819
6499
|
if (aborted) {
|
|
5820
6500
|
const cancelResult = await runCancelPathHooks({
|
|
@@ -5835,6 +6515,7 @@ async function main() {
|
|
|
5835
6515
|
cancelFailureReason = cancelResult.cancelFailureReason;
|
|
5836
6516
|
}
|
|
5837
6517
|
if (jobTimedOut) finalStatus = ExecutionJobStatus.enum.failed;
|
|
6518
|
+
await drainJobTempScopeToLog(jobTempScope, maskedSend);
|
|
5838
6519
|
emitJobComplete({
|
|
5839
6520
|
finalStatus,
|
|
5840
6521
|
loopResult,
|
|
@@ -5894,6 +6575,6 @@ main().catch((error) => {
|
|
|
5894
6575
|
setTimeout(() => process.exit(1), 100);
|
|
5895
6576
|
});
|
|
5896
6577
|
//#endregion
|
|
5897
|
-
export { buildStepEnvFileHooks, buildStepNeedsContext, createSandboxStepContext, deriveFanout, rawPayloadFromEvent };
|
|
6578
|
+
export { buildJobRuleCompletion, buildSandboxShell, buildStepEnvFileHooks, buildStepNeedsContext, coerceStep, createSandboxStepContext, deriveFanout, drainJobTempScope, rawPayloadFromEvent, resolveChangedFilesForRules, resolveEmitEventName, sanitizeTempLabel };
|
|
5898
6579
|
|
|
5899
6580
|
//# sourceMappingURL=workflow-runner.js.map
|