@kici-dev/agent 0.1.27 → 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 +36 -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/job-runner.d.ts +24 -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/fork-runner.d.ts +14 -0
- package/dist/execution/sandbox/index.d.ts +2 -1
- package/dist/execution/sandbox/ipc-protocol.d.ts +79 -3
- 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 +11 -3
- package/dist/execution/tmp-gc.d.ts +22 -7
- package/dist/execution/workflow-loader.d.ts +32 -1
- package/dist/index.js +44 -45
- package/dist/provenance/statement-builder.d.ts +3 -2
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1256 -171
- package/dist/workflow-runner-bundle.js +215393 -0
- package/dist/workflow-runner.js +886 -244
- package/dist/ws/orchestrator-client.d.ts +105 -1
- package/package.json +14 -12
- package/sbom.spdx.json +1090 -1721
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";
|
|
@@ -74,28 +75,281 @@ var __exportAll = (all, no_symbols) => {
|
|
|
74
75
|
* `verbose: false` (suppressed). A `verbose: false` base would flag ordinary
|
|
75
76
|
* output `verbose: false` too, and this gate would then drop every line.
|
|
76
77
|
*
|
|
77
|
-
* The returned callback owns
|
|
78
|
-
* coalesced into whole lines before `emit` is called.
|
|
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.
|
|
79
87
|
*/
|
|
80
88
|
function makeStreamingZxLog(emit) {
|
|
81
|
-
|
|
89
|
+
const lineBufs = {
|
|
90
|
+
[LogStream.enum.stdout]: "",
|
|
91
|
+
[LogStream.enum.stderr]: ""
|
|
92
|
+
};
|
|
82
93
|
return (entry) => {
|
|
83
94
|
const e = entry;
|
|
84
95
|
if (e.kind !== "stdout" && e.kind !== "stderr") return;
|
|
85
96
|
if (!e.verbose) return;
|
|
97
|
+
const stream = e.kind === "stderr" ? LogStream.enum.stderr : LogStream.enum.stdout;
|
|
86
98
|
const text = typeof e.data === "string" ? e.data : String(e.data ?? "");
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
for (const line of lines) if (line) emit(line);
|
|
99
|
+
const lines = (lineBufs[stream] + text).split("\n");
|
|
100
|
+
lineBufs[stream] = lines.pop();
|
|
101
|
+
for (const line of lines) if (line) emit(line, stream);
|
|
91
102
|
};
|
|
92
103
|
}
|
|
93
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
|
|
94
347
|
//#region src/provenance/statement-builder.ts
|
|
95
348
|
/**
|
|
96
349
|
* Build a SLSA v1.0 in-toto provenance statement from the server-truth identity
|
|
97
350
|
* token claims plus the caller-supplied subject. The build context comes
|
|
98
|
-
* entirely from the JWT claims (
|
|
351
|
+
* entirely from the JWT claims (minted server-side by the orchestrator,
|
|
352
|
+
* unforgeable), so the
|
|
99
353
|
* statement's identity equals the token's identity by construction.
|
|
100
354
|
*/
|
|
101
355
|
/**
|
|
@@ -427,7 +681,7 @@ async function excludeScratchFromGit(repoWorkDir) {
|
|
|
427
681
|
const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
428
682
|
await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
|
|
429
683
|
} catch (err) {
|
|
430
|
-
logger$
|
|
684
|
+
logger$5.warn("Failed to register scratch dir glob in .git/info/exclude", {
|
|
431
685
|
excludePath,
|
|
432
686
|
error: err instanceof Error ? err.message : String(err)
|
|
433
687
|
});
|
|
@@ -488,7 +742,7 @@ async function cleanupScratch(scratchDir) {
|
|
|
488
742
|
force: true
|
|
489
743
|
});
|
|
490
744
|
} catch (cleanupErr) {
|
|
491
|
-
logger$
|
|
745
|
+
logger$5.warn("Scratch dir cleanup failed (orphan left behind)", {
|
|
492
746
|
scratchDir,
|
|
493
747
|
error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
|
|
494
748
|
});
|
|
@@ -513,7 +767,7 @@ async function cleanupScratch(scratchDir) {
|
|
|
513
767
|
*/
|
|
514
768
|
async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
515
769
|
depsUrl = resolveOrchestratorUrl(depsUrl);
|
|
516
|
-
logger$
|
|
770
|
+
logger$5.info("Downloading dependency tarball", { url: depsUrl });
|
|
517
771
|
const kiciDir = join(workDir, ".kici");
|
|
518
772
|
if (depsUrl.startsWith("file://")) {
|
|
519
773
|
const localPath = fileURLToPath(depsUrl);
|
|
@@ -527,7 +781,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
527
781
|
await moveScratchIntoRepo(scratchDir, workDir);
|
|
528
782
|
await cleanupScratch(scratchDir);
|
|
529
783
|
const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
|
|
530
|
-
logger$
|
|
784
|
+
logger$5.info("Dependencies restored from cache (file)", {
|
|
531
785
|
sizeMB,
|
|
532
786
|
targetDir: workDir
|
|
533
787
|
});
|
|
@@ -536,7 +790,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
536
790
|
if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
|
|
537
791
|
let lastError;
|
|
538
792
|
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
539
|
-
if (attempt > 0) logger$
|
|
793
|
+
if (attempt > 0) logger$5.warn("Retrying dep tarball download", {
|
|
540
794
|
attempt,
|
|
541
795
|
url: depsUrl
|
|
542
796
|
});
|
|
@@ -545,11 +799,11 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
545
799
|
if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
|
|
546
800
|
await moveScratchIntoRepo(scratchDir, workDir);
|
|
547
801
|
await cleanupScratch(scratchDir);
|
|
548
|
-
logger$
|
|
802
|
+
logger$5.info("Dependencies restored from cache (stream)", { targetDir: workDir });
|
|
549
803
|
return;
|
|
550
804
|
} catch (err) {
|
|
551
805
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
552
|
-
logger$
|
|
806
|
+
logger$5.warn("Dep tarball download failed", {
|
|
553
807
|
attempt,
|
|
554
808
|
error: lastError.message
|
|
555
809
|
});
|
|
@@ -557,9 +811,9 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
557
811
|
}
|
|
558
812
|
throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
|
|
559
813
|
}
|
|
560
|
-
var logger$
|
|
814
|
+
var logger$5, DOWNLOAD_TIMEOUT_MS$2, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
|
|
561
815
|
var init_dep_restore = __esmMin((() => {
|
|
562
|
-
logger$
|
|
816
|
+
logger$5 = createLogger({ prefix: "dep-restore" });
|
|
563
817
|
DOWNLOAD_TIMEOUT_MS$2 = 300 * 1e3;
|
|
564
818
|
SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
|
|
565
819
|
SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
|
|
@@ -664,13 +918,9 @@ init_download();
|
|
|
664
918
|
* into place so a partial restore never leaves half-written paths in the live
|
|
665
919
|
* tree (mirrors dep-restore).
|
|
666
920
|
*/
|
|
667
|
-
const logger$
|
|
921
|
+
const logger$4 = createLogger({ prefix: "cache-engine" });
|
|
668
922
|
/** Download timeout for a presigned cache GET: 5 minutes. */
|
|
669
923
|
const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
|
|
670
|
-
/** Anchor prefix for repo-root-relative cache entries inside the tar. */
|
|
671
|
-
const REPO_ANCHOR = "__repo__";
|
|
672
|
-
/** Anchor prefix for home-relative (`~`) cache entries inside the tar. */
|
|
673
|
-
const HOME_ANCHOR = "__home__";
|
|
674
924
|
/**
|
|
675
925
|
* Resolve a cache path. `~`-prefixed -> home root; otherwise repo-root-relative.
|
|
676
926
|
* Rejects absolute paths and `..` escapes so a workflow cannot read or clobber
|
|
@@ -711,7 +961,7 @@ function anchorEntries(workDir, paths, roots) {
|
|
|
711
961
|
*/
|
|
712
962
|
async function packCachePaths(workDir, paths, roots) {
|
|
713
963
|
const entries = anchorEntries(workDir, paths, roots);
|
|
714
|
-
const staging = await
|
|
964
|
+
const { path: staging, cleanup } = await makeTempDir("cache-pack");
|
|
715
965
|
try {
|
|
716
966
|
const topLevel = /* @__PURE__ */ new Set();
|
|
717
967
|
for (const e of entries) {
|
|
@@ -732,7 +982,7 @@ async function packCachePaths(workDir, paths, roots) {
|
|
|
732
982
|
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
|
733
983
|
const tarball = Buffer.concat(chunks);
|
|
734
984
|
const hash = sha256(tarball);
|
|
735
|
-
logger$
|
|
985
|
+
logger$4.info("packed user cache", {
|
|
736
986
|
sizeBytes: tarball.length,
|
|
737
987
|
hash: hash.slice(0, 12),
|
|
738
988
|
paths
|
|
@@ -742,10 +992,7 @@ async function packCachePaths(workDir, paths, roots) {
|
|
|
742
992
|
hash
|
|
743
993
|
};
|
|
744
994
|
} finally {
|
|
745
|
-
await
|
|
746
|
-
recursive: true,
|
|
747
|
-
force: true
|
|
748
|
-
});
|
|
995
|
+
await cleanup();
|
|
749
996
|
}
|
|
750
997
|
}
|
|
751
998
|
/** Move the extracted `__repo__` / `__home__` groups from a scratch dir into place. */
|
|
@@ -781,17 +1028,14 @@ async function downloadAndExtractCache(url, workDir, expectedHash, roots) {
|
|
|
781
1028
|
cb(null, chunk);
|
|
782
1029
|
} });
|
|
783
1030
|
await mkdir(workDir, { recursive: true });
|
|
784
|
-
const scratch = await
|
|
1031
|
+
const { path: scratch, cleanup } = await makeTempDir("cache-extract");
|
|
785
1032
|
try {
|
|
786
1033
|
await pipeline(Readable.fromWeb(response.body), hashTransform, createGunzip(), x({ cwd: scratch }));
|
|
787
1034
|
const digest = hash.digest("hex");
|
|
788
1035
|
if (digest !== expectedHash) throw new Error(`Cache tarball checksum mismatch on download: expected ${expectedHash}, got ${digest}`);
|
|
789
1036
|
await moveAnchoredGroups(scratch, workDir, home);
|
|
790
1037
|
} finally {
|
|
791
|
-
await
|
|
792
|
-
recursive: true,
|
|
793
|
-
force: true
|
|
794
|
-
});
|
|
1038
|
+
await cleanup();
|
|
795
1039
|
}
|
|
796
1040
|
}
|
|
797
1041
|
/** Build the imperative `ctx.cache` API bound to a workDir + transport. */
|
|
@@ -801,7 +1045,7 @@ function createCacheApi(workDir, transport, roots) {
|
|
|
801
1045
|
const r = await transport.restore(spec.key, spec.restoreKeys);
|
|
802
1046
|
if (!r.hit || !r.downloadUrl || !r.tarHash) return { hit: false };
|
|
803
1047
|
await downloadAndExtractCache(r.downloadUrl, workDir, r.tarHash, roots);
|
|
804
|
-
logger$
|
|
1048
|
+
logger$4.info("user cache restored", {
|
|
805
1049
|
key: spec.key,
|
|
806
1050
|
matchedKey: r.matchedKey
|
|
807
1051
|
});
|
|
@@ -813,14 +1057,14 @@ function createCacheApi(workDir, transport, roots) {
|
|
|
813
1057
|
async save(spec) {
|
|
814
1058
|
const begin = await transport.beginSave(spec.key);
|
|
815
1059
|
if (begin.skip || !begin.uploadUrl) {
|
|
816
|
-
logger$
|
|
1060
|
+
logger$4.info("user cache save skipped (key exists)", { key: spec.key });
|
|
817
1061
|
return;
|
|
818
1062
|
}
|
|
819
1063
|
const { tarball, hash } = await packCachePaths(workDir, spec.paths, roots);
|
|
820
1064
|
const { uploadToPresignedUrl } = await Promise.resolve().then(() => (init_download(), download_exports));
|
|
821
1065
|
await uploadToPresignedUrl(begin.uploadUrl, tarball);
|
|
822
1066
|
await transport.completeSave(spec.key, hash, tarball.length);
|
|
823
|
-
logger$
|
|
1067
|
+
logger$4.info("user cache saved", {
|
|
824
1068
|
key: spec.key,
|
|
825
1069
|
sizeBytes: tarball.length
|
|
826
1070
|
});
|
|
@@ -945,6 +1189,98 @@ async function saveCacheSpecs(specs, restoreResults, deps, ownerStepIndex) {
|
|
|
945
1189
|
}
|
|
946
1190
|
}
|
|
947
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
|
|
948
1284
|
//#region src/execution/sandbox/log-masker.ts
|
|
949
1285
|
/**
|
|
950
1286
|
* Secret value masking for log lines.
|
|
@@ -1103,7 +1439,7 @@ function parsePathFileContent(content) {
|
|
|
1103
1439
|
}
|
|
1104
1440
|
/** Create fresh, empty env + path files inside a private temp dir under `baseDir`. */
|
|
1105
1441
|
async function createEnvFiles(baseDir) {
|
|
1106
|
-
const dir = await
|
|
1442
|
+
const { path: dir } = await makeTempDir("env", { base: baseDir });
|
|
1107
1443
|
const envFile = join(dir, "env");
|
|
1108
1444
|
const pathFile = join(dir, "path");
|
|
1109
1445
|
await writeFile(envFile, "");
|
|
@@ -1243,25 +1579,6 @@ async function executeHook(opts) {
|
|
|
1243
1579
|
//#endregion
|
|
1244
1580
|
//#region src/execution/rule-evaluator.ts
|
|
1245
1581
|
initZx();
|
|
1246
|
-
/**
|
|
1247
|
-
* Create RuleContext for agent-side rule evaluation.
|
|
1248
|
-
*
|
|
1249
|
-
* @param event - Event payload from the dispatch message
|
|
1250
|
-
* @param changedFiles - List of files changed in this event
|
|
1251
|
-
* @param env - Merged environment variables
|
|
1252
|
-
* @param dispatchInputs - Operator dispatch inputs (`ctx.dispatchInputs`)
|
|
1253
|
-
* @param fanout - Fan-out position (`ctx.fanout`); undefined on a non-fan-out job
|
|
1254
|
-
*/
|
|
1255
|
-
function createRuleContext(event, changedFiles = [], env = {}, dispatchInputs = {}, fanout) {
|
|
1256
|
-
return {
|
|
1257
|
-
event,
|
|
1258
|
-
changedFiles,
|
|
1259
|
-
env,
|
|
1260
|
-
dispatchInputs,
|
|
1261
|
-
...fanout && { fanout },
|
|
1262
|
-
$
|
|
1263
|
-
};
|
|
1264
|
-
}
|
|
1265
1582
|
//#endregion
|
|
1266
1583
|
//#region src/execution/sandbox/parallel-scheduler.ts
|
|
1267
1584
|
/**
|
|
@@ -1483,6 +1800,58 @@ async function runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn, opt
|
|
|
1483
1800
|
...res.drift != null && { drift: res.drift }
|
|
1484
1801
|
};
|
|
1485
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
|
+
}
|
|
1486
1855
|
/**
|
|
1487
1856
|
* Execute a single step with timeout enforcement.
|
|
1488
1857
|
*
|
|
@@ -1573,6 +1942,7 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
|
|
|
1573
1942
|
}
|
|
1574
1943
|
const exitCode = extractExitCode(e);
|
|
1575
1944
|
const signal = extractSignal(e);
|
|
1945
|
+
emitStepFailureLog(step.name, stepIndex, error.message, sendFn);
|
|
1576
1946
|
const secretsAccessed = getSecretsAccessLog?.(stepIndex);
|
|
1577
1947
|
emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
|
|
1578
1948
|
sendFn({
|
|
@@ -1623,14 +1993,60 @@ function extractSignal(error) {
|
|
|
1623
1993
|
if (error && typeof error === "object" && "signal" in error && typeof error.signal === "string") return error.signal;
|
|
1624
1994
|
}
|
|
1625
1995
|
/**
|
|
1626
|
-
* Evaluate step-level rules. Returns
|
|
1627
|
-
*
|
|
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).
|
|
1628
2004
|
*/
|
|
1629
2005
|
async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
|
|
1630
2006
|
if (!step.rules || step.rules.length === 0) return null;
|
|
1631
|
-
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
|
+
});
|
|
1632
2016
|
const ruleResult = await evaluateRules(step.rules, ruleCtx, step.name);
|
|
1633
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
|
+
}
|
|
1634
2050
|
opts.sendIpc({
|
|
1635
2051
|
type: "step.start",
|
|
1636
2052
|
stepIndex,
|
|
@@ -1648,10 +2064,13 @@ async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
|
|
|
1648
2064
|
line: `[kici] Step '${step.name}' skipped: rule '${ruleResult.results.find((r) => !r.passed)?.label}' did not pass`
|
|
1649
2065
|
});
|
|
1650
2066
|
return {
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
2067
|
+
result: {
|
|
2068
|
+
name: step.name,
|
|
2069
|
+
stepIndex,
|
|
2070
|
+
status: ExecutionStepStatus.enum.skipped,
|
|
2071
|
+
durationMs: 0
|
|
2072
|
+
},
|
|
2073
|
+
shouldBreak: false
|
|
1655
2074
|
};
|
|
1656
2075
|
}
|
|
1657
2076
|
/**
|
|
@@ -1701,7 +2120,8 @@ async function maybeGateStepApproval(step, stepIndex, opts) {
|
|
|
1701
2120
|
opts.sendIpc({
|
|
1702
2121
|
type: "log.line",
|
|
1703
2122
|
stepIndex,
|
|
1704
|
-
line: `[kici] Step '${step.name}' ${why}
|
|
2123
|
+
line: `[kici] Step '${step.name}' ${why}.`,
|
|
2124
|
+
stream: LogStream.enum.stderr
|
|
1705
2125
|
});
|
|
1706
2126
|
await opts.disposeStepResources?.(stepIndex);
|
|
1707
2127
|
return {
|
|
@@ -1742,7 +2162,8 @@ async function runObserverHook(args) {
|
|
|
1742
2162
|
if (!hookResult.success) opts.sendIpc({
|
|
1743
2163
|
type: "log.line",
|
|
1744
2164
|
stepIndex,
|
|
1745
|
-
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
|
|
1746
2167
|
});
|
|
1747
2168
|
}
|
|
1748
2169
|
/**
|
|
@@ -1782,20 +2203,18 @@ async function runStepWithRetry(step, stepIndex, ctx, timeoutMs, opts) {
|
|
|
1782
2203
|
opts.sendIpc({
|
|
1783
2204
|
type: "log.line",
|
|
1784
2205
|
stepIndex,
|
|
1785
|
-
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
|
|
1786
2208
|
});
|
|
1787
2209
|
await new Promise((r) => setTimeout(r, delay));
|
|
1788
2210
|
}
|
|
1789
2211
|
return result;
|
|
1790
2212
|
}
|
|
1791
2213
|
async function runStepIteration(step, stepIndex, opts) {
|
|
1792
|
-
const
|
|
1793
|
-
if (
|
|
2214
|
+
const ruleOutcome = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
|
|
2215
|
+
if (ruleOutcome) {
|
|
1794
2216
|
await opts.disposeStepResources?.(stepIndex);
|
|
1795
|
-
return
|
|
1796
|
-
result: skippedResult,
|
|
1797
|
-
shouldBreak: false
|
|
1798
|
-
};
|
|
2217
|
+
return ruleOutcome;
|
|
1799
2218
|
}
|
|
1800
2219
|
const gate = await maybeGateStepApproval(step, stepIndex, opts);
|
|
1801
2220
|
if (gate) return gate;
|
|
@@ -2428,63 +2847,6 @@ function armJobDeadline(timeoutMs, onTimeout) {
|
|
|
2428
2847
|
return { clear: () => clearTimeout(timer) };
|
|
2429
2848
|
}
|
|
2430
2849
|
//#endregion
|
|
2431
|
-
//#region src/checkout/ssh-auth.ts
|
|
2432
|
-
/**
|
|
2433
|
-
* Materialize an SSH private key (and optional pinned known_hosts) into a
|
|
2434
|
-
* tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
|
|
2435
|
-
*
|
|
2436
|
-
* Permissions:
|
|
2437
|
-
* - private key mode 0o600 (required by OpenSSH — refuses to use world-
|
|
2438
|
-
* readable keys).
|
|
2439
|
-
* - known_hosts mode 0o600.
|
|
2440
|
-
* - tempdir mode 0o700.
|
|
2441
|
-
*
|
|
2442
|
-
* SSH flags composed:
|
|
2443
|
-
* - `-i <keyfile>` — identity file.
|
|
2444
|
-
* - `-o IdentitiesOnly=yes` — don't try other keys from ssh-agent / ~/.ssh.
|
|
2445
|
-
* - `-o BatchMode=yes` — never prompt for passwords / passphrases.
|
|
2446
|
-
* - host-key checking flags based on `hostKeyPolicy`.
|
|
2447
|
-
*/
|
|
2448
|
-
async function setupSshAuth(opts) {
|
|
2449
|
-
if (opts.hostKeyPolicy === "pinned" && !opts.knownHosts) throw new Error("pinned hostKeyPolicy requires knownHosts content");
|
|
2450
|
-
const tempDir = await mkdtemp(join(tmpdir(), "kici-ssh-"));
|
|
2451
|
-
const keyPath = join(tempDir, "id");
|
|
2452
|
-
await writeFile(keyPath, opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`, { mode: 384 });
|
|
2453
|
-
const knownHostsPath = join(tempDir, "known_hosts");
|
|
2454
|
-
await writeFile(knownHostsPath, opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "", { mode: 384 });
|
|
2455
|
-
const parts = [
|
|
2456
|
-
"ssh",
|
|
2457
|
-
"-i",
|
|
2458
|
-
escapeShellArg(keyPath),
|
|
2459
|
-
"-o",
|
|
2460
|
-
"IdentitiesOnly=yes",
|
|
2461
|
-
"-o",
|
|
2462
|
-
"BatchMode=yes",
|
|
2463
|
-
"-o",
|
|
2464
|
-
`UserKnownHostsFile=${escapeShellArg(knownHostsPath)}`
|
|
2465
|
-
];
|
|
2466
|
-
if (opts.hostKeyPolicy === "pinned") parts.push("-o", "StrictHostKeyChecking=yes");
|
|
2467
|
-
else parts.push("-o", "StrictHostKeyChecking=accept-new");
|
|
2468
|
-
return {
|
|
2469
|
-
gitSshCommand: parts.join(" "),
|
|
2470
|
-
tempDir,
|
|
2471
|
-
async cleanup() {
|
|
2472
|
-
await rm(tempDir, {
|
|
2473
|
-
recursive: true,
|
|
2474
|
-
force: true
|
|
2475
|
-
});
|
|
2476
|
-
}
|
|
2477
|
-
};
|
|
2478
|
-
}
|
|
2479
|
-
/**
|
|
2480
|
-
* Quote a path for inclusion in `GIT_SSH_COMMAND`. We use single-quote
|
|
2481
|
-
* wrapping so backslashes and spaces survive git's shell-parse of the
|
|
2482
|
-
* command value.
|
|
2483
|
-
*/
|
|
2484
|
-
function escapeShellArg(value) {
|
|
2485
|
-
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
2486
|
-
}
|
|
2487
|
-
//#endregion
|
|
2488
2850
|
//#region src/checkout/git-clone.ts
|
|
2489
2851
|
/**
|
|
2490
2852
|
* Strip auth credentials from git error messages to prevent token leakage.
|
|
@@ -2524,19 +2886,16 @@ async function gitClone(options) {
|
|
|
2524
2886
|
let needsCustomEnv = false;
|
|
2525
2887
|
let safeDirCleanup;
|
|
2526
2888
|
if (repoUrl.startsWith("file://")) {
|
|
2527
|
-
const {
|
|
2528
|
-
const { tmpdir } = await import("node:os");
|
|
2889
|
+
const { writeFile } = await import("node:fs/promises");
|
|
2529
2890
|
const path = await import("node:path");
|
|
2530
|
-
const
|
|
2891
|
+
const { makeTempDir } = await import("@kici-dev/core/tmp");
|
|
2892
|
+
const { path: dir, cleanup } = await makeTempDir("gitcfg");
|
|
2531
2893
|
const cfgPath = path.join(dir, "config");
|
|
2532
2894
|
await writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
|
|
2533
2895
|
envEntries.GIT_CONFIG_GLOBAL = cfgPath;
|
|
2534
2896
|
needsCustomEnv = true;
|
|
2535
2897
|
safeDirCleanup = async () => {
|
|
2536
|
-
await
|
|
2537
|
-
recursive: true,
|
|
2538
|
-
force: true
|
|
2539
|
-
}).catch(() => {});
|
|
2898
|
+
await cleanup().catch(() => {});
|
|
2540
2899
|
};
|
|
2541
2900
|
}
|
|
2542
2901
|
let sshSetup;
|
|
@@ -2831,7 +3190,7 @@ async function applyYarnrcBerryConfig(args) {
|
|
|
2831
3190
|
const hasPrivateRegistry = registries.length > 0 || Object.keys(installEnvSecrets).length > 0;
|
|
2832
3191
|
const yarnrcPath = join(args.kiciDir, ".yarnrc.yml");
|
|
2833
3192
|
const { raw: original, doc } = await readOriginalYarnrc(yarnrcPath);
|
|
2834
|
-
const cacheFolder = await
|
|
3193
|
+
const { path: cacheFolder, cleanup: cleanupCache } = await makeTempDir("yarn-berry-cache");
|
|
2835
3194
|
const merged = {
|
|
2836
3195
|
...doc,
|
|
2837
3196
|
nodeLinker: "node-modules",
|
|
@@ -2868,10 +3227,7 @@ async function applyYarnrcBerryConfig(args) {
|
|
|
2868
3227
|
if (original === null) await unlink(yarnrcPath).catch(() => {});
|
|
2869
3228
|
else await writeFile(yarnrcPath, original, { encoding: "utf8" });
|
|
2870
3229
|
} catch {}
|
|
2871
|
-
await
|
|
2872
|
-
recursive: true,
|
|
2873
|
-
force: true
|
|
2874
|
-
}).catch(() => {});
|
|
3230
|
+
await cleanupCache().catch(() => {});
|
|
2875
3231
|
};
|
|
2876
3232
|
return {
|
|
2877
3233
|
extraEnv: {
|
|
@@ -3186,9 +3542,9 @@ async function detectKiciYarnFlavor(repoRoot, kiciDir) {
|
|
|
3186
3542
|
* Install `.kici/` dependencies inline with the repo's package manager.
|
|
3187
3543
|
*
|
|
3188
3544
|
* Falls back to this when the dep cache is unavailable or a download fails.
|
|
3189
|
-
* The install runs with an isolated cache/store directory (
|
|
3190
|
-
* `
|
|
3191
|
-
* 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.
|
|
3192
3548
|
*
|
|
3193
3549
|
* If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, a job-scoped
|
|
3194
3550
|
* `.kici/.npmrc` overlay is synthesized for the install, restored in `finally`,
|
|
@@ -3277,7 +3633,7 @@ function envWithNodeOnPath(extraEnv, nodeDir) {
|
|
|
3277
3633
|
/** Run `npm install` in `.kici/` with an isolated cache directory. */
|
|
3278
3634
|
async function runNpmInstall(args) {
|
|
3279
3635
|
const { npmCliPath, nodeExe, nodeDir } = resolveNpm();
|
|
3280
|
-
const cacheDir = await
|
|
3636
|
+
const { path: cacheDir, cleanup } = await makeTempDir("npm-cache");
|
|
3281
3637
|
const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
|
|
3282
3638
|
const buildArgs = (...prefix) => {
|
|
3283
3639
|
const a = [
|
|
@@ -3302,10 +3658,7 @@ async function runNpmInstall(args) {
|
|
|
3302
3658
|
maxBuffer: INSTALL_MAX_BUFFER
|
|
3303
3659
|
});
|
|
3304
3660
|
} finally {
|
|
3305
|
-
await
|
|
3306
|
-
recursive: true,
|
|
3307
|
-
force: true
|
|
3308
|
-
}).catch(() => {});
|
|
3661
|
+
await cleanup().catch(() => {});
|
|
3309
3662
|
}
|
|
3310
3663
|
}
|
|
3311
3664
|
/**
|
|
@@ -3319,7 +3672,7 @@ async function runNpmInstall(args) {
|
|
|
3319
3672
|
async function runPnpmInstall(args) {
|
|
3320
3673
|
await assertPnpmAvailable();
|
|
3321
3674
|
const { nodeDir } = resolveNpm();
|
|
3322
|
-
const storeDir = await
|
|
3675
|
+
const { path: storeDir, cleanup } = await makeTempDir("pnpm-store");
|
|
3323
3676
|
const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
|
|
3324
3677
|
const argv = [
|
|
3325
3678
|
"install",
|
|
@@ -3339,10 +3692,7 @@ async function runPnpmInstall(args) {
|
|
|
3339
3692
|
maxBuffer: INSTALL_MAX_BUFFER
|
|
3340
3693
|
});
|
|
3341
3694
|
} finally {
|
|
3342
|
-
await
|
|
3343
|
-
recursive: true,
|
|
3344
|
-
force: true
|
|
3345
|
-
}).catch(() => {});
|
|
3695
|
+
await cleanup().catch(() => {});
|
|
3346
3696
|
}
|
|
3347
3697
|
}
|
|
3348
3698
|
/** Pure: argv for `yarn install` with an isolated cache folder. */
|
|
@@ -3368,7 +3718,7 @@ function buildYarnInstallArgs(cacheDir, hasPrivateRegistry) {
|
|
|
3368
3718
|
async function runYarnInstall(args) {
|
|
3369
3719
|
await assertYarnAvailable();
|
|
3370
3720
|
const { nodeDir } = resolveNpm();
|
|
3371
|
-
const cacheDir = await
|
|
3721
|
+
const { path: cacheDir, cleanup } = await makeTempDir("yarn-cache");
|
|
3372
3722
|
const env = envWithNodeOnPath(args.registryConfig.extraEnv, nodeDir);
|
|
3373
3723
|
const argv = buildYarnInstallArgs(cacheDir, args.hasPrivateRegistry);
|
|
3374
3724
|
try {
|
|
@@ -3380,10 +3730,7 @@ async function runYarnInstall(args) {
|
|
|
3380
3730
|
maxBuffer: INSTALL_MAX_BUFFER
|
|
3381
3731
|
});
|
|
3382
3732
|
} finally {
|
|
3383
|
-
await
|
|
3384
|
-
recursive: true,
|
|
3385
|
-
force: true
|
|
3386
|
-
}).catch(() => {});
|
|
3733
|
+
await cleanup().catch(() => {});
|
|
3387
3734
|
}
|
|
3388
3735
|
}
|
|
3389
3736
|
/** Pure: argv for a berry `yarn install`. Cache + linker live in .yarnrc.yml. */
|
|
@@ -3536,19 +3883,65 @@ function logSubprocessStreams(e, tokens) {
|
|
|
3536
3883
|
* no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
|
|
3537
3884
|
* Node's normal ESM lookup against `.kici/node_modules/`.
|
|
3538
3885
|
*/
|
|
3539
|
-
const AGENT_SDK_VERSION = "0.1.27";
|
|
3540
|
-
const AGENT_SDK_BUNDLE_HASH = "e06bd1acf349ba365bdb357f3d865e0cbb4ad4604a0270b707a681a4f440954e";
|
|
3541
3886
|
/**
|
|
3542
|
-
*
|
|
3543
|
-
*
|
|
3544
|
-
*
|
|
3545
|
-
*
|
|
3546
|
-
*
|
|
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.
|
|
3547
3938
|
*/
|
|
3548
3939
|
let hookRegistered = false;
|
|
3549
3940
|
function ensureLoaderHookRegistered() {
|
|
3550
3941
|
if (hookRegistered) return;
|
|
3551
|
-
|
|
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);
|
|
3552
3945
|
hookRegistered = true;
|
|
3553
3946
|
}
|
|
3554
3947
|
/**
|
|
@@ -3603,7 +3996,10 @@ async function loadWorkflowSource(workDir, sourceFile, expectedContentHash, reso
|
|
|
3603
3996
|
const actualHash = computeContentHash(rawSource, assetDigest);
|
|
3604
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.`);
|
|
3605
3998
|
}
|
|
3606
|
-
return {
|
|
3999
|
+
return {
|
|
4000
|
+
module: await import(pathToFileURL(filePath).href + `?t=${Date.now()}`),
|
|
4001
|
+
sdkSetters: await resolveWorkflowSdkSetters(filePath)
|
|
4002
|
+
};
|
|
3607
4003
|
}
|
|
3608
4004
|
/**
|
|
3609
4005
|
* Type guard for Workflow shape (discriminant: `_tag === 'Workflow'`).
|
|
@@ -3800,7 +4196,7 @@ function decryptBuffer(encrypted, aesKey) {
|
|
|
3800
4196
|
*/
|
|
3801
4197
|
async function applyOverlay(config) {
|
|
3802
4198
|
const { tarballUrl, cliPublicKey, orchestratorPrivateKey, repoDir } = config;
|
|
3803
|
-
const tmpDir = await
|
|
4199
|
+
const { path: tmpDir, cleanup } = await makeTempDir("overlay");
|
|
3804
4200
|
try {
|
|
3805
4201
|
logger.info("Downloading overlay tarball", { url: tarballUrl.replace(/\?.*$/, "?[redacted]") });
|
|
3806
4202
|
let encryptedData;
|
|
@@ -3874,10 +4270,7 @@ async function applyOverlay(config) {
|
|
|
3874
4270
|
verified: true
|
|
3875
4271
|
};
|
|
3876
4272
|
} finally {
|
|
3877
|
-
await
|
|
3878
|
-
recursive: true,
|
|
3879
|
-
force: true
|
|
3880
|
-
}).catch(() => {});
|
|
4273
|
+
await cleanup().catch(() => {});
|
|
3881
4274
|
}
|
|
3882
4275
|
}
|
|
3883
4276
|
//#endregion
|
|
@@ -3899,7 +4292,7 @@ async function applyOverlay(config) {
|
|
|
3899
4292
|
*/
|
|
3900
4293
|
init_download();
|
|
3901
4294
|
init_dep_restore();
|
|
3902
|
-
const AGENT_VERSION = "0.
|
|
4295
|
+
const AGENT_VERSION = "0.2.0";
|
|
3903
4296
|
process.on("uncaughtException", (err) => {
|
|
3904
4297
|
process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
|
|
3905
4298
|
if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
|
|
@@ -3986,7 +4379,8 @@ function installOutputCapture() {
|
|
|
3986
4379
|
for (const line of lines) if (line) captureSendFn({
|
|
3987
4380
|
type: "log.line",
|
|
3988
4381
|
stepIndex: stepIdx,
|
|
3989
|
-
line
|
|
4382
|
+
line,
|
|
4383
|
+
stream: LogStream.enum.stderr
|
|
3990
4384
|
});
|
|
3991
4385
|
}
|
|
3992
4386
|
return origStderrWrite(chunk, encodingOrCb, cb);
|
|
@@ -4239,6 +4633,33 @@ function waitForProvenanceResponse(requestId) {
|
|
|
4239
4633
|
});
|
|
4240
4634
|
});
|
|
4241
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
|
+
}
|
|
4242
4663
|
/** Wait for a cache.response from the agent with the given requestId. */
|
|
4243
4664
|
function waitForCacheResponse(requestId) {
|
|
4244
4665
|
return new Promise((resolve, reject) => {
|
|
@@ -4393,6 +4814,68 @@ function buildCacheTransport() {
|
|
|
4393
4814
|
}
|
|
4394
4815
|
};
|
|
4395
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
|
+
}
|
|
4396
4879
|
/** Send a `provenance.request` IPC and await the matching `provenance.response`. */
|
|
4397
4880
|
async function relayProvenanceIpc(request) {
|
|
4398
4881
|
const requestId = randomUUID();
|
|
@@ -4548,6 +5031,9 @@ function dispatchAgentMessage(msg) {
|
|
|
4548
5031
|
} else if (msg.type === "provenance.response") {
|
|
4549
5032
|
const pending = pendingProvenanceResponses.get(msg.requestId);
|
|
4550
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);
|
|
4551
5037
|
} else if (msg.type === "approval.resolved") {
|
|
4552
5038
|
const pending = pendingApprovalResolutions.get(msg.requestId);
|
|
4553
5039
|
if (pending) pending.resolve(msg);
|
|
@@ -4692,13 +5178,13 @@ function envelopeChildOutputs(envelope) {
|
|
|
4692
5178
|
*/
|
|
4693
5179
|
function buildStepSecrets(request, masker, onMaskerSecretsAdded) {
|
|
4694
5180
|
const mergedFlat = buildMergedFlatSecrets(request.secrets ?? {}, request.namespacedSecrets ?? {});
|
|
4695
|
-
let
|
|
5181
|
+
let stepTmpHandle = null;
|
|
4696
5182
|
const exposedEnvVars = /* @__PURE__ */ new Set();
|
|
4697
5183
|
let mountCounter = 0;
|
|
4698
5184
|
const env = process.env;
|
|
4699
5185
|
async function ensureTmpdir() {
|
|
4700
|
-
if (
|
|
4701
|
-
return
|
|
5186
|
+
if (stepTmpHandle === null) stepTmpHandle = await makeTempDir("secret-files");
|
|
5187
|
+
return stepTmpHandle.path;
|
|
4702
5188
|
}
|
|
4703
5189
|
return createStepSecrets(mergedFlat, env, request.secretMeta, {
|
|
4704
5190
|
host: {
|
|
@@ -4722,16 +5208,14 @@ function buildStepSecrets(request, masker, onMaskerSecretsAdded) {
|
|
|
4722
5208
|
delete process.env[envVar];
|
|
4723
5209
|
}
|
|
4724
5210
|
exposedEnvVars.clear();
|
|
4725
|
-
if (
|
|
4726
|
-
await
|
|
4727
|
-
|
|
4728
|
-
force: true
|
|
4729
|
-
});
|
|
4730
|
-
stepTmpdir = null;
|
|
5211
|
+
if (stepTmpHandle !== null) {
|
|
5212
|
+
await stepTmpHandle.cleanup();
|
|
5213
|
+
stepTmpHandle = null;
|
|
4731
5214
|
}
|
|
4732
5215
|
},
|
|
4733
5216
|
onDisposeError: (err) => {
|
|
4734
|
-
|
|
5217
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
5218
|
+
origStderrWrite(`[workflow-runner] secret-file cleanup error: ${message}\n`);
|
|
4735
5219
|
}
|
|
4736
5220
|
});
|
|
4737
5221
|
}
|
|
@@ -4755,7 +5239,12 @@ function createSecretMasker(request) {
|
|
|
4755
5239
|
* before spawning this process). This is the single shell-construction code
|
|
4756
5240
|
* path shared by step execution (`createSandboxStepContext`) and the per-job
|
|
4757
5241
|
* init phase (`runInitPhase`), so init commands run through the identical shell
|
|
4758
|
-
* 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.
|
|
4759
5248
|
*
|
|
4760
5249
|
* Intercept zx subprocess output via the log callback: zx does NOT write child
|
|
4761
5250
|
* stdout/stderr to process.stdout — it pipes to an internal VoidStream and only
|
|
@@ -4778,24 +5267,80 @@ function createSecretMasker(request) {
|
|
|
4778
5267
|
function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
|
|
4779
5268
|
return $({
|
|
4780
5269
|
cwd,
|
|
4781
|
-
env:
|
|
5270
|
+
env: process.env,
|
|
4782
5271
|
verbose: true,
|
|
4783
5272
|
quiet: false,
|
|
4784
|
-
log: makeStreamingZxLog((line) => maskedSendFn({
|
|
5273
|
+
log: makeStreamingZxLog((line, stream) => maskedSendFn({
|
|
4785
5274
|
type: "log.line",
|
|
4786
5275
|
stepIndex,
|
|
4787
|
-
line
|
|
5276
|
+
line,
|
|
5277
|
+
stream
|
|
4788
5278
|
}))
|
|
4789
5279
|
});
|
|
4790
5280
|
}
|
|
4791
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
|
+
/**
|
|
4792
5337
|
* Create a StepContext natively inside the workflow runner.
|
|
4793
5338
|
*
|
|
4794
5339
|
* The context is reconstructed from the environment and IPC request fields --
|
|
4795
5340
|
* NOT serialized across the process boundary. This means zx $ runs natively
|
|
4796
5341
|
* inside this process with full shell access.
|
|
4797
5342
|
*/
|
|
4798
|
-
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) {
|
|
4799
5344
|
const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
|
|
4800
5345
|
const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
|
|
4801
5346
|
const rawPayload = rawPayloadFromEvent(request.event);
|
|
@@ -4841,7 +5386,9 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
4841
5386
|
isTestRun: request.isTestRun ?? false,
|
|
4842
5387
|
context: request.context,
|
|
4843
5388
|
cache: createCacheApi(workDir, buildCacheTransport()),
|
|
4844
|
-
|
|
5389
|
+
artifacts: createArtifactsApi(workDir, buildArtifactTransport()),
|
|
5390
|
+
emit: async (nameOrDefinition, payload, options) => {
|
|
5391
|
+
const eventName = resolveEmitEventName(nameOrDefinition);
|
|
4845
5392
|
const reqId = randomUUID();
|
|
4846
5393
|
sendMessage({
|
|
4847
5394
|
type: "event.emit",
|
|
@@ -4863,6 +5410,8 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
4863
5410
|
setSecretOutput: (key, value) => {
|
|
4864
5411
|
secretOutputs.set(key, value);
|
|
4865
5412
|
},
|
|
5413
|
+
mktemp: (label) => jobTempScope.mktemp(sanitizeTempLabel(label ?? stepName)),
|
|
5414
|
+
mktempFile: (label, opts) => jobTempScope.mktempFile(sanitizeTempLabel(label ?? stepName), opts),
|
|
4866
5415
|
kici,
|
|
4867
5416
|
attestProvenance: buildAttestProvenanceFn(request, workDir, (o) => kici.oidc.token(o)),
|
|
4868
5417
|
...rawPayload && { rawPayload },
|
|
@@ -5047,14 +5596,15 @@ async function applyOverlayIfRequested(request, workflowDir) {
|
|
|
5047
5596
|
* Mirroring the `file://`-clone fix in checkout/git-clone.ts, we point
|
|
5048
5597
|
* `GIT_CONFIG_GLOBAL` at a temp config carrying `safe.directory = *`. Setting it
|
|
5049
5598
|
* on `process.env` here (before the step loop) means every step subprocess —
|
|
5050
|
-
* each zx `$`
|
|
5599
|
+
* each zx `$` reads live `process.env` at spawn — inherits it, so git
|
|
5051
5600
|
* works in steps exactly as it does locally. We also register the dep-restore
|
|
5052
5601
|
* scratch-dir exclude now that a real `.git` exists in the workspace.
|
|
5053
5602
|
*/
|
|
5054
5603
|
async function makeOverlayGitUsable(request, workspaceDir) {
|
|
5055
5604
|
if (!request.fullRepo) return;
|
|
5056
5605
|
if (!existsSync(join(workspaceDir, ".git"))) return;
|
|
5057
|
-
const
|
|
5606
|
+
const { path: cfgDir } = await makeTempDir("gitcfg");
|
|
5607
|
+
const cfgPath = join(cfgDir, "config");
|
|
5058
5608
|
await fsPromises.writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
|
|
5059
5609
|
process.env.GIT_CONFIG_GLOBAL = cfgPath;
|
|
5060
5610
|
trace(`fullRepo git safe.directory configured via GIT_CONFIG_GLOBAL=${cfgPath}`);
|
|
@@ -5245,7 +5795,7 @@ async function evaluateConcurrencyGroupIfPresent(workflow, request) {
|
|
|
5245
5795
|
stepIndex: -1,
|
|
5246
5796
|
line: `[kici] Concurrency: queued${ack.reason ? ` (${ack.reason})` : ""}, waiting for slot to free`
|
|
5247
5797
|
});
|
|
5248
|
-
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);
|
|
5249
5799
|
try {
|
|
5250
5800
|
ack = await waitForConcurrencyAck(waitCapMs);
|
|
5251
5801
|
} catch (waitErr) {
|
|
@@ -5434,6 +5984,46 @@ async function runCancelPathHooks(args) {
|
|
|
5434
5984
|
};
|
|
5435
5985
|
}
|
|
5436
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
|
+
/**
|
|
5437
6027
|
* Phase 5 — Extract steps for the requested job (re-evaluating the dynamic
|
|
5438
6028
|
* factory when `dynamicSource` is set, otherwise looking up the static job)
|
|
5439
6029
|
* and normalise the result into the `Step[]` shape the step loop consumes.
|
|
@@ -5449,30 +6039,13 @@ async function extractAndNormalizeSteps(workflow, request, apiTransport) {
|
|
|
5449
6039
|
driftDroppedJobs = dynamicResult.droppedJobs;
|
|
5450
6040
|
if (driftDroppedJobs.length > 0) trace(`Determinism drift: ${driftDroppedJobs.length} job(s) dropped: ${driftDroppedJobs.join(", ")}`);
|
|
5451
6041
|
} else rawSteps = extractSteps(workflow, request.jobName);
|
|
5452
|
-
const
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
stepCounter++;
|
|
5457
|
-
const name = `step-${stepCounter}`;
|
|
5458
|
-
refMap.set(stepOrFn, name);
|
|
5459
|
-
return {
|
|
5460
|
-
_tag: "Step",
|
|
5461
|
-
name,
|
|
5462
|
-
run: stepOrFn,
|
|
5463
|
-
outputs: void 0
|
|
5464
|
-
};
|
|
5465
|
-
}
|
|
5466
|
-
const s = stepOrFn;
|
|
5467
|
-
if (!s.name) {
|
|
5468
|
-
stepCounter++;
|
|
5469
|
-
return {
|
|
5470
|
-
...s,
|
|
5471
|
-
name: `step-${stepCounter}`
|
|
5472
|
-
};
|
|
5473
|
-
}
|
|
5474
|
-
return s;
|
|
6042
|
+
const coerceState = {
|
|
6043
|
+
counter: 0,
|
|
6044
|
+
refMap: /* @__PURE__ */ new WeakMap(),
|
|
6045
|
+
autoNamed: /* @__PURE__ */ new WeakSet()
|
|
5475
6046
|
};
|
|
6047
|
+
const refMap = coerceState.refMap;
|
|
6048
|
+
const coerce = (stepOrFn) => coerceStep(stepOrFn, coerceState);
|
|
5476
6049
|
const normalizedSteps = [];
|
|
5477
6050
|
const nodes = [];
|
|
5478
6051
|
let flatIndex = 0;
|
|
@@ -5519,9 +6092,13 @@ async function extractAndNormalizeSteps(workflow, request, apiTransport) {
|
|
|
5519
6092
|
* Phase 6 — Build the output infrastructure: the per-step operator-secret
|
|
5520
6093
|
* key set (used for setEnv override protection), and the output / job-output
|
|
5521
6094
|
* maps that back `.result` proxies and `ctx.outputsOf()` / `ctx.jobOutputs()`.
|
|
5522
|
-
* 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.
|
|
5523
6100
|
*/
|
|
5524
|
-
function buildOutputInfrastructure(request, refMap) {
|
|
6101
|
+
function buildOutputInfrastructure(request, refMap, workflowSdkSetters) {
|
|
5525
6102
|
const operatorSecretKeys = /* @__PURE__ */ new Set();
|
|
5526
6103
|
if (request.secrets) for (const key of Object.keys(request.secrets)) operatorSecretKeys.add(key);
|
|
5527
6104
|
if (request.namespacedSecrets) for (const ctx of Object.values(request.namespacedSecrets)) for (const key of Object.keys(ctx)) operatorSecretKeys.add(key);
|
|
@@ -5529,9 +6106,12 @@ function buildOutputInfrastructure(request, refMap) {
|
|
|
5529
6106
|
const secretOutputs = /* @__PURE__ */ new Map();
|
|
5530
6107
|
setStepOutputsMap(outputsMap);
|
|
5531
6108
|
setStepRefMap(refMap);
|
|
6109
|
+
workflowSdkSetters?.setStepOutputsMap(outputsMap);
|
|
6110
|
+
workflowSdkSetters?.setStepRefMap(refMap);
|
|
5532
6111
|
const jobOutputsMap = /* @__PURE__ */ new Map();
|
|
5533
6112
|
if (request.upstreamJobOutputs) for (const [jobName, outputs] of Object.entries(request.upstreamJobOutputs)) jobOutputsMap.set(jobName, outputs);
|
|
5534
6113
|
setJobOutputsMap(jobOutputsMap);
|
|
6114
|
+
workflowSdkSetters?.setJobOutputsMap(jobOutputsMap);
|
|
5535
6115
|
return {
|
|
5536
6116
|
operatorSecretKeys,
|
|
5537
6117
|
outputsMap,
|
|
@@ -5540,27 +6120,82 @@ function buildOutputInfrastructure(request, refMap) {
|
|
|
5540
6120
|
};
|
|
5541
6121
|
}
|
|
5542
6122
|
/**
|
|
5543
|
-
*
|
|
5544
|
-
*
|
|
5545
|
-
*
|
|
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'`.
|
|
5546
6128
|
*/
|
|
5547
|
-
async function
|
|
5548
|
-
if (!
|
|
5549
|
-
|
|
5550
|
-
|
|
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;
|
|
5551
6158
|
const skippedResults = normalizedSteps.map((s, i) => ({
|
|
5552
6159
|
name: s.name,
|
|
5553
6160
|
stepIndex: i,
|
|
5554
6161
|
status: ExecutionStepStatus.enum.skipped,
|
|
5555
6162
|
durationMs: 0
|
|
5556
6163
|
}));
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
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 {
|
|
5560
6171
|
type: "job.complete",
|
|
5561
6172
|
status: ExecutionJobStatus.enum.success,
|
|
5562
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)
|
|
5563
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);
|
|
5564
6199
|
process.exit(0);
|
|
5565
6200
|
}
|
|
5566
6201
|
/**
|
|
@@ -5610,8 +6245,8 @@ async function applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend) {
|
|
|
5610
6245
|
* Build the step loop's KICI_ENV/KICI_PATH callbacks with a per-step delta-file
|
|
5611
6246
|
* pair (keyed by step index). `beforeStepEnvFiles(stepIndex)` lazily creates the
|
|
5612
6247
|
* step's pair and points the runner's process.env at it (each step's zx $
|
|
5613
|
-
*
|
|
5614
|
-
*
|
|
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
|
|
5615
6250
|
* re-filter runtime-set vars). `afterStepApplyEnvFiles(stepIndex)` applies that
|
|
5616
6251
|
* step's delta and releases the pair.
|
|
5617
6252
|
*
|
|
@@ -5684,12 +6319,13 @@ async function runInitPhaseOrFailJob(args) {
|
|
|
5684
6319
|
})
|
|
5685
6320
|
});
|
|
5686
6321
|
if (initSpecs.length === 0) return;
|
|
6322
|
+
const initCache = createCacheApi(stepCwd, buildCacheTransport());
|
|
5687
6323
|
const initResult = await runInitPhase({
|
|
5688
6324
|
specs: initSpecs,
|
|
5689
6325
|
shellFor: (_spec, i) => buildSandboxShell(stepCwd, INIT_STEP_INDEX_BASE + i, maskedSend),
|
|
5690
6326
|
sendIpc: maskedSend,
|
|
5691
6327
|
stepIndexBase: INIT_STEP_INDEX_BASE,
|
|
5692
|
-
cache:
|
|
6328
|
+
cache: initCache,
|
|
5693
6329
|
env: {
|
|
5694
6330
|
beginCapture: async () => {
|
|
5695
6331
|
process.env.KICI_ENV = envFiles.envFile;
|
|
@@ -5762,7 +6398,8 @@ async function main() {
|
|
|
5762
6398
|
await installDependenciesIfNeeded(workflowDir, request);
|
|
5763
6399
|
if (aborted) abortAndExit("aborted after deps");
|
|
5764
6400
|
await restoreSourceTarballIfRequested(workflowDir, request);
|
|
5765
|
-
const
|
|
6401
|
+
const loaded = await loadWorkflowModuleWithCapture(workflowDir, request, isGlobal, maskedSend);
|
|
6402
|
+
const module = loaded.module;
|
|
5766
6403
|
const workflow = extractWorkflow(module, request.workflowName);
|
|
5767
6404
|
await evaluateConcurrencyGroupIfPresent(workflow, request);
|
|
5768
6405
|
const apiTransport = async (method, params) => {
|
|
@@ -5776,8 +6413,11 @@ async function main() {
|
|
|
5776
6413
|
return waitForApiResponse(reqId);
|
|
5777
6414
|
};
|
|
5778
6415
|
const { normalizedSteps, nodes, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
|
|
5779
|
-
const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap);
|
|
6416
|
+
const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap, loaded.sdkSetters);
|
|
5780
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);
|
|
5781
6421
|
await maybeSkipJobOnRules(job, request, normalizedSteps);
|
|
5782
6422
|
if (aborted) abortAndExit("aborted after rules");
|
|
5783
6423
|
const jobHooks = collectJobHooks(job);
|
|
@@ -5795,6 +6435,7 @@ async function main() {
|
|
|
5795
6435
|
const { cachePhaseDeps, jobCacheSpecs, jobCacheRestore } = await setupJobCache(stepCwd, normalizedSteps.length, job?.cache, maskedSend);
|
|
5796
6436
|
const stepTasks = new StepTaskRegistry();
|
|
5797
6437
|
const stepAbortControllers = /* @__PURE__ */ new Map();
|
|
6438
|
+
const jobTempScope = createTempScope();
|
|
5798
6439
|
const createStepCtxWithCapture = (stepIndex, stepName) => {
|
|
5799
6440
|
const stepAbort = new AbortController();
|
|
5800
6441
|
stepAbortControllers.set(stepIndex, stepAbort);
|
|
@@ -5808,7 +6449,7 @@ async function main() {
|
|
|
5808
6449
|
secrets: handle.secrets,
|
|
5809
6450
|
dispose: handle.dispose
|
|
5810
6451
|
});
|
|
5811
|
-
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);
|
|
5812
6453
|
if (globalRepoInfo) {
|
|
5813
6454
|
ctx.workflowRepo = globalRepoInfo.workflowRepo;
|
|
5814
6455
|
ctx.sourceRepo = globalRepoInfo.sourceRepo;
|
|
@@ -5853,7 +6494,7 @@ async function main() {
|
|
|
5853
6494
|
});
|
|
5854
6495
|
jobDeadline.clear();
|
|
5855
6496
|
await maybeSaveJobCache(jobCacheSpecs, jobCacheRestore, cachePhaseDeps, !aborted && loopResult.status === ExecutionStepStatus.enum.success);
|
|
5856
|
-
let finalStatus = loopResult.status
|
|
6497
|
+
let finalStatus = initialJobStatus(loopResult.status);
|
|
5857
6498
|
let cancelFailureReason;
|
|
5858
6499
|
if (aborted) {
|
|
5859
6500
|
const cancelResult = await runCancelPathHooks({
|
|
@@ -5874,6 +6515,7 @@ async function main() {
|
|
|
5874
6515
|
cancelFailureReason = cancelResult.cancelFailureReason;
|
|
5875
6516
|
}
|
|
5876
6517
|
if (jobTimedOut) finalStatus = ExecutionJobStatus.enum.failed;
|
|
6518
|
+
await drainJobTempScopeToLog(jobTempScope, maskedSend);
|
|
5877
6519
|
emitJobComplete({
|
|
5878
6520
|
finalStatus,
|
|
5879
6521
|
loopResult,
|
|
@@ -5933,6 +6575,6 @@ main().catch((error) => {
|
|
|
5933
6575
|
setTimeout(() => process.exit(1), 100);
|
|
5934
6576
|
});
|
|
5935
6577
|
//#endregion
|
|
5936
|
-
export { buildStepEnvFileHooks, buildStepNeedsContext, createSandboxStepContext, deriveFanout, rawPayloadFromEvent };
|
|
6578
|
+
export { buildJobRuleCompletion, buildSandboxShell, buildStepEnvFileHooks, buildStepNeedsContext, coerceStep, createSandboxStepContext, deriveFanout, drainJobTempScope, rawPayloadFromEvent, resolveChangedFilesForRules, resolveEmitEventName, sanitizeTempLabel };
|
|
5937
6579
|
|
|
5938
6580
|
//# sourceMappingURL=workflow-runner.js.map
|