@kici-dev/agent 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/dist/diagnostics/mini-bundle.d.ts +9 -0
- package/dist/execution/cache/cache-engine.d.ts +62 -0
- package/dist/execution/cache/cache-phase.d.ts +29 -0
- package/dist/execution/cache/index.d.ts +9 -0
- package/dist/execution/dynamic-job-serializer.d.ts +12 -1
- package/dist/execution/env-init/init-phase.d.ts +64 -0
- package/dist/execution/init-runner.d.ts +1 -1
- package/dist/execution/job-runner.d.ts +30 -0
- package/dist/execution/sandbox/env-delta.d.ts +44 -0
- package/dist/execution/sandbox/env-file.d.ts +39 -0
- package/dist/execution/sandbox/index.d.ts +1 -1
- package/dist/execution/sandbox/ipc-protocol.d.ts +111 -3
- package/dist/execution/sandbox/job-deadline.d.ts +13 -0
- package/dist/execution/sandbox/step-loop.d.ts +53 -1
- package/dist/execution/sandbox/types.d.ts +20 -1
- package/dist/execution/sandbox/workflow-runner.d.ts +9 -1
- package/dist/execution/tmp-gc.d.ts +17 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +342 -12
- package/dist/server.js +623 -168
- package/dist/workflow-runner.js +2001 -950
- package/dist/ws/orchestrator-client.d.ts +55 -3
- package/package.json +16 -10
- package/sbom.spdx.json +1923 -148
package/dist/workflow-runner.js
CHANGED
|
@@ -1,1060 +1,1735 @@
|
|
|
1
|
-
import { fileURLToPath as __cjs_fileURLToPath } from "node:url";
|
|
2
|
-
import { dirname as __cjs_dirname } from "node:path";
|
|
3
|
-
__cjs_dirname(__cjs_fileURLToPath(import.meta.url));
|
|
4
1
|
import { register } from "node:module";
|
|
5
2
|
import { createInterface } from "node:readline";
|
|
6
3
|
import crypto, { createHash, randomUUID } from "node:crypto";
|
|
7
4
|
import { existsSync } from "node:fs";
|
|
8
|
-
import fsPromises, { access, mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
|
9
|
-
import os, { tmpdir } from "node:os";
|
|
10
|
-
import path, { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import fsPromises, { access, cp, mkdir, mkdtemp, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import os, { homedir, tmpdir } from "node:os";
|
|
7
|
+
import path, { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
11
8
|
import { $ } from "zx";
|
|
12
9
|
import { createLogger, deriveSharedSecret, initZx, normalizeLineEndings, sha256, sha256File, toErrorMessage } from "@kici-dev/shared";
|
|
13
|
-
import { ExecutionJobStatus, ExecutionStepStatus } from "@kici-dev/engine";
|
|
14
|
-
import { buildKiciApi, createStepSecrets, evaluateRules, isDynamicJobFn, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
|
|
15
|
-
import { execFile } from "node:child_process";
|
|
10
|
+
import { CacheOutcome, CacheStepType, ExecutionJobStatus, ExecutionStepStatus, TimeoutReason } from "@kici-dev/engine";
|
|
11
|
+
import { buildKiciApi, createStepSecrets, evaluateRules, isDynamicJobFn, normalizeCacheSpecs, normalizeRequireApproval, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
|
|
16
12
|
import { Readable, Transform } from "node:stream";
|
|
17
13
|
import { pipeline } from "node:stream/promises";
|
|
18
14
|
import { createGunzip } from "node:zlib";
|
|
15
|
+
import { c, x } from "tar";
|
|
16
|
+
import https from "node:https";
|
|
17
|
+
import http from "node:http";
|
|
19
18
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
-
import {
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
21
20
|
import { promisify } from "node:util";
|
|
22
21
|
import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, detectPackageManagerFromManifests } from "@kici-dev/shared/package-manager";
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
var __defProp = Object.defineProperty;
|
|
23
|
+
var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
24
|
+
var __exportAll = (all, no_symbols) => {
|
|
25
|
+
let target = {};
|
|
26
|
+
for (var name in all) __defProp(target, name, {
|
|
27
|
+
get: all[name],
|
|
28
|
+
enumerable: true
|
|
29
|
+
});
|
|
30
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
31
|
+
return target;
|
|
32
|
+
};
|
|
25
33
|
import.meta.url;
|
|
26
34
|
//#endregion
|
|
27
|
-
//#region src/execution/
|
|
35
|
+
//#region src/execution/dep-restore.ts
|
|
28
36
|
/**
|
|
29
|
-
*
|
|
37
|
+
* Dependency restoration from cached tarballs.
|
|
30
38
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
39
|
+
* Downloads a pre-built dependency tarball, verifies SHA-256 integrity,
|
|
40
|
+
* and extracts to .kici/node_modules/ in the work directory.
|
|
33
41
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
42
|
+
* HTTP/HTTPS downloads use a streaming pipeline (response -> hash transform ->
|
|
43
|
+
* gunzip -> tar extract) to avoid buffering entire tarballs in memory.
|
|
44
|
+
* file:// URLs use a buffer-based approach (local, no streaming benefit).
|
|
45
|
+
*
|
|
46
|
+
* Streaming downloads have a 5-minute timeout and up to 2 retries.
|
|
36
47
|
*/
|
|
37
|
-
/** Minimum length for a secret value to be maskable (avoids false positives). */
|
|
38
|
-
const MIN_MASK_LENGTH = 3;
|
|
39
48
|
/**
|
|
40
|
-
*
|
|
49
|
+
* Compute SHA-256 hash of a buffer.
|
|
41
50
|
*/
|
|
42
|
-
function
|
|
43
|
-
return
|
|
51
|
+
function computeHash(data) {
|
|
52
|
+
return sha256(data);
|
|
44
53
|
}
|
|
45
54
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* Usage:
|
|
49
|
-
* ```ts
|
|
50
|
-
* const masker = new LogMasker();
|
|
51
|
-
* masker.registerSecrets({ TOKEN: 'abc123', SHORT: 'ab' });
|
|
52
|
-
* masker.mask('Token is abc123'); // 'Token is ***'
|
|
53
|
-
* // 'ab' is NOT masked (< 3 chars)
|
|
54
|
-
* ```
|
|
55
|
+
* Extract a gzip tarball from a buffer into the target directory.
|
|
56
|
+
* Used for file:// URLs where streaming provides no benefit.
|
|
55
57
|
*/
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
* (prevents partial masking when one secret is a substring of another).
|
|
67
|
-
*/
|
|
68
|
-
registerSecrets(secrets) {
|
|
69
|
-
const seen = /* @__PURE__ */ new Set();
|
|
70
|
-
const values = [];
|
|
71
|
-
for (const value of Object.values(secrets)) if (value.length >= MIN_MASK_LENGTH && !seen.has(value)) {
|
|
72
|
-
seen.add(value);
|
|
73
|
-
values.push(value);
|
|
74
|
-
const b64 = Buffer.from(value).toString("base64");
|
|
75
|
-
if (b64.length >= MIN_MASK_LENGTH && !seen.has(b64)) {
|
|
76
|
-
seen.add(b64);
|
|
77
|
-
values.push(b64);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (values.length === 0) {
|
|
81
|
-
this.pattern = null;
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
values.sort((a, b) => b.length - a.length);
|
|
85
|
-
this.pattern = new RegExp(values.map((v) => escapeRegExp(v)).join("|"), "g");
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Mask all registered secret values in a log line.
|
|
89
|
-
*
|
|
90
|
-
* Returns the line unchanged if no secrets are registered.
|
|
91
|
-
*/
|
|
92
|
-
mask(line) {
|
|
93
|
-
if (!this.pattern) return line;
|
|
94
|
-
this.pattern.lastIndex = 0;
|
|
95
|
-
return line.replace(this.pattern, "***");
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Returns true if any maskable secrets are registered.
|
|
99
|
-
*/
|
|
100
|
-
hasSecrets() {
|
|
101
|
-
return this.pattern !== null;
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
//#endregion
|
|
105
|
-
//#region src/execution/sandbox/secret-merge.ts
|
|
58
|
+
async function extractTarball(data, targetDir) {
|
|
59
|
+
await mkdir(targetDir, { recursive: true });
|
|
60
|
+
const readable = Readable.from(data);
|
|
61
|
+
await new Promise((resolve, reject) => {
|
|
62
|
+
readable.pipe(x({
|
|
63
|
+
cwd: targetDir,
|
|
64
|
+
gzip: true
|
|
65
|
+
})).on("finish", resolve).on("error", reject);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
106
68
|
/**
|
|
107
|
-
*
|
|
69
|
+
* Stream download and extract an HTTP/HTTPS tarball.
|
|
108
70
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
71
|
+
* Computes SHA-256 hash on the fly via a Transform stream.
|
|
72
|
+
* Returns the computed hash of the compressed tarball data.
|
|
111
73
|
*/
|
|
74
|
+
async function streamFetchAndExtract(url, targetDir) {
|
|
75
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS$2) });
|
|
76
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
77
|
+
if (!response.body) throw new Error("No response body");
|
|
78
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
79
|
+
const hash = createHash("sha256");
|
|
80
|
+
const hashTransform = new Transform({ transform(chunk, _encoding, callback) {
|
|
81
|
+
hash.update(chunk);
|
|
82
|
+
callback(null, chunk);
|
|
83
|
+
} });
|
|
84
|
+
await mkdir(targetDir, { recursive: true });
|
|
85
|
+
await pipeline(nodeStream, hashTransform, createGunzip(), x({ cwd: targetDir }));
|
|
86
|
+
return hash.digest("hex");
|
|
87
|
+
}
|
|
112
88
|
/**
|
|
113
|
-
*
|
|
89
|
+
* Extract the dep tarball into a per-attempt scratch dir so retries never race
|
|
90
|
+
* with still-draining I/O from a previous failed attempt.
|
|
114
91
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
92
|
+
* When `pipeline()` rejects on a network error or AbortSignal timeout, the
|
|
93
|
+
* underlying `tar.x` continues flushing pending file writes for an unbounded
|
|
94
|
+
* window after the promise settles — `pipeline` does not block on async
|
|
95
|
+
* filesystem side effects. If the next retry then runs `rm -rf` on the same
|
|
96
|
+
* `node_modules/`, the walk races with those writes and `rmdir` fails with
|
|
97
|
+
* ENOTEMPTY (new files keep appearing under a directory we just emptied).
|
|
118
98
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
for (const contextSecrets of Object.values(namespacedSecrets)) Object.assign(merged, contextSecrets);
|
|
125
|
-
return merged;
|
|
126
|
-
}
|
|
127
|
-
//#endregion
|
|
128
|
-
//#region src/execution/hook-executor.ts
|
|
129
|
-
/** Default hook timeout: 5 minutes */
|
|
130
|
-
const DEFAULT_HOOK_TIMEOUT_MS = 300 * 1e3;
|
|
131
|
-
/**
|
|
132
|
-
* Build outcome metadata from execution state.
|
|
99
|
+
* We sidestep the race entirely by extracting each attempt into a unique
|
|
100
|
+
* scratch dir under `.kici/`. Failed attempts leave orphan scratch dirs whose
|
|
101
|
+
* draining writes are harmless — the next attempt does not touch them. On
|
|
102
|
+
* success `moveScratchIntoRepo` renames the extracted entries into place
|
|
103
|
+
* (atomic on the same filesystem), then best-effort cleans the scratch dir.
|
|
133
104
|
*
|
|
134
|
-
*
|
|
105
|
+
* Scratch dirs land inside the customer's cloned working tree, so the clone
|
|
106
|
+
* phase registers `SCRATCH_DIR_GIT_EXCLUDE_GLOB` in `.git/info/exclude` to
|
|
107
|
+
* keep them out of `git status` for any workflow step that shells out to git.
|
|
108
|
+
* See `excludeScratchFromGit`.
|
|
135
109
|
*/
|
|
136
|
-
function
|
|
110
|
+
async function extractIntoScratch(url, kiciDir, attempt) {
|
|
111
|
+
const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-${attempt}-${Date.now()}`);
|
|
112
|
+
await mkdir(scratchDir, { recursive: true });
|
|
137
113
|
return {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
failedStep: opts.failedStep,
|
|
141
|
-
stepOutputs: opts.stepOutputs,
|
|
142
|
-
duration: Date.now() - opts.startTime
|
|
114
|
+
scratchDir,
|
|
115
|
+
hash: await streamFetchAndExtract(url, scratchDir)
|
|
143
116
|
};
|
|
144
117
|
}
|
|
145
118
|
/**
|
|
146
|
-
*
|
|
119
|
+
* Append `SCRATCH_DIR_GIT_EXCLUDE_GLOB` to `${repoWorkDir}/.git/info/exclude`
|
|
120
|
+
* so any in-flight or orphaned dep-restore scratch dirs are invisible to
|
|
121
|
+
* `git status` / `git add` inside the customer's cloned working tree.
|
|
122
|
+
*
|
|
123
|
+
* Why `.git/info/exclude` and not `.gitignore`:
|
|
124
|
+
* - `.gitignore` lives in the customer's repo and is committed; we MUST NOT
|
|
125
|
+
* modify it. Doing so would surface the rule in their PRs and create a
|
|
126
|
+
* diff customers never asked for.
|
|
127
|
+
* - `.git/info/exclude` is per-clone, on-disk only, and exactly the git
|
|
128
|
+
* mechanism for "ignore these patterns in THIS working tree". Git creates
|
|
129
|
+
* an empty (template-commented) file on `git init` / `git clone`, so it
|
|
130
|
+
* already exists by the time we're called.
|
|
131
|
+
*
|
|
132
|
+
* Why this lives next to `extractIntoScratch`:
|
|
133
|
+
* - The exclude glob is tied 1:1 to the scratch dir naming convention. If
|
|
134
|
+
* the prefix ever changes, the rule must change too. Defining both in the
|
|
135
|
+
* same file means a rename touches one place, not two.
|
|
136
|
+
*
|
|
137
|
+
* Best-effort: if the exclude file is missing (e.g. caller sandbox blocked
|
|
138
|
+
* `git clone` and the dir layout differs) we log and continue — failing the
|
|
139
|
+
* job over a missing git ignore wiring would be worse than the cosmetic
|
|
140
|
+
* issue we're solving.
|
|
141
|
+
*
|
|
142
|
+
* Idempotent: callers may invoke this multiple times (dual-clone path, retry
|
|
143
|
+
* after partial setup). We skip the append if the glob is already present.
|
|
144
|
+
*
|
|
145
|
+
* @param repoWorkDir - The git working tree root (the dir that contains
|
|
146
|
+
* `.git/`). For normal workflows this is the agent's job workDir; for
|
|
147
|
+
* global workflows it is the workflow repo dir (whose `.kici/` carries
|
|
148
|
+
* the scratch dirs).
|
|
147
149
|
*/
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
throw new Error(`Invalid hook input for ${hookType}`);
|
|
150
|
+
async function excludeScratchFromGit(repoWorkDir) {
|
|
151
|
+
const excludePath = join(repoWorkDir, ".git", "info", "exclude");
|
|
152
|
+
try {
|
|
153
|
+
const existing = await fsPromises.readFile(excludePath, "utf-8").catch(() => "");
|
|
154
|
+
if (existing.split("\n").some((line) => line.trim() === SCRATCH_DIR_GIT_EXCLUDE_GLOB)) return;
|
|
155
|
+
const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
156
|
+
await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
logger$4.warn("Failed to register scratch dir glob in .git/info/exclude", {
|
|
159
|
+
excludePath,
|
|
160
|
+
error: err instanceof Error ? err.message : String(err)
|
|
161
|
+
});
|
|
162
|
+
}
|
|
162
163
|
}
|
|
163
164
|
/**
|
|
164
|
-
*
|
|
165
|
+
* Rewrite localhost URLs to use the orchestrator host.
|
|
165
166
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
167
|
+
* The orchestrator rewrites file:// cache URLs to http://localhost:PORT/...
|
|
168
|
+
* but agent containers can't reach localhost. This utility replaces the
|
|
169
|
+
* host with the orchestrator's host derived from KICI_ORCHESTRATOR_URL.
|
|
168
170
|
*/
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
sendIpc({
|
|
174
|
-
type: "step.start",
|
|
175
|
-
stepIndex,
|
|
176
|
-
stepName: normalized.name,
|
|
177
|
-
step_type: `hook:${hookType}`
|
|
178
|
-
});
|
|
179
|
-
const startTime = Date.now();
|
|
180
|
-
const mergedCtx = {
|
|
181
|
-
...stepContext,
|
|
182
|
-
outcome
|
|
183
|
-
};
|
|
184
|
-
const abortController = new AbortController();
|
|
185
|
-
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
|
171
|
+
function resolveOrchestratorUrl(url) {
|
|
172
|
+
if (!url.match(/^https?:\/\/(localhost|127\.0\.0\.1)[:/]/)) return url;
|
|
173
|
+
const orchestratorUrl = process.env.KICI_ORCHESTRATOR_URL;
|
|
174
|
+
if (!orchestratorUrl) return url;
|
|
186
175
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
176
|
+
const orchestratorParsed = new URL(orchestratorUrl.replace(/^ws/, "http"));
|
|
177
|
+
const parsed = new URL(url);
|
|
178
|
+
parsed.hostname = orchestratorParsed.hostname;
|
|
179
|
+
return parsed.toString();
|
|
180
|
+
} catch {
|
|
181
|
+
return url;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Move a fully-extracted scratch tree into the cloned repo. The dep tarball is
|
|
186
|
+
* packed repo-root-relative, so the scratch holds repo-root entries:
|
|
187
|
+
* `.kici/node_modules` for every manager, plus (for pnpm) the root
|
|
188
|
+
* `node_modules/.pnpm` store and in-repo workspace sibling dirs. `.kici/` itself
|
|
189
|
+
* already exists in the work tree (cloned or source-restored), so its children
|
|
190
|
+
* are moved individually; every other top-level entry is moved wholesale.
|
|
191
|
+
*
|
|
192
|
+
* On a cache-hit execution agent the destinations do not pre-exist (source
|
|
193
|
+
* restore excludes node_modules and never carries sibling dirs), so the renames
|
|
194
|
+
* have nothing to race; the defensive `rm` covers re-runs.
|
|
195
|
+
*/
|
|
196
|
+
async function moveScratchIntoRepo(scratchDir, workDir) {
|
|
197
|
+
for (const child of await fsPromises.readdir(scratchDir)) if (child === ".kici") {
|
|
198
|
+
const kiciScratch = join(scratchDir, ".kici");
|
|
199
|
+
for (const sub of await fsPromises.readdir(kiciScratch)) await moveInto(join(kiciScratch, sub), join(workDir, ".kici", sub));
|
|
200
|
+
} else await moveInto(join(scratchDir, child), join(workDir, child));
|
|
201
|
+
}
|
|
202
|
+
/** Move `src` to `dest`, creating the parent and clearing any stale dest. */
|
|
203
|
+
async function moveInto(src, dest) {
|
|
204
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
205
|
+
await fsPromises.rm(dest, {
|
|
206
|
+
recursive: true,
|
|
207
|
+
force: true
|
|
208
|
+
});
|
|
209
|
+
await fsPromises.rename(src, dest);
|
|
210
|
+
}
|
|
211
|
+
/** Best-effort cleanup of a settled scratch dir; logs and continues on failure. */
|
|
212
|
+
async function cleanupScratch(scratchDir) {
|
|
213
|
+
try {
|
|
214
|
+
await fsPromises.rm(scratchDir, {
|
|
215
|
+
recursive: true,
|
|
216
|
+
force: true
|
|
199
217
|
});
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const error = toErrorMessage(e);
|
|
205
|
-
sendIpc({
|
|
206
|
-
type: "step.complete",
|
|
207
|
-
stepIndex,
|
|
208
|
-
status: "failed",
|
|
209
|
-
durationMs,
|
|
210
|
-
error: { message: error },
|
|
211
|
-
step_type: `hook:${hookType}`
|
|
218
|
+
} catch (cleanupErr) {
|
|
219
|
+
logger$4.warn("Scratch dir cleanup failed (orphan left behind)", {
|
|
220
|
+
scratchDir,
|
|
221
|
+
error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
|
|
212
222
|
});
|
|
213
|
-
return {
|
|
214
|
-
success: false,
|
|
215
|
-
error
|
|
216
|
-
};
|
|
217
223
|
}
|
|
218
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Restore dependencies from a cached tarball into the cloned repo.
|
|
227
|
+
*
|
|
228
|
+
* The tarball is packed repo-root-relative (see `dep-packer.ts`): every manager
|
|
229
|
+
* carries `.kici/node_modules`; pnpm additionally carries the root
|
|
230
|
+
* `node_modules/.pnpm` store and the in-repo workspace siblings `.kici` resolves.
|
|
231
|
+
* Restore extracts into a scratch dir, then moves each entry into place — one
|
|
232
|
+
* code path for all managers.
|
|
233
|
+
*
|
|
234
|
+
* For HTTP/HTTPS URLs: a streaming pipeline (response -> hash -> gunzip -> tar)
|
|
235
|
+
* with a 5-minute timeout and up to 2 retries avoids buffering whole tarballs.
|
|
236
|
+
* For file:// URLs: a buffer-based approach (local, no streaming benefit).
|
|
237
|
+
*
|
|
238
|
+
* @param workDir - Root directory of the cloned repository
|
|
239
|
+
* @param depsUrl - URL to the dependency tarball (http://, https://, or file://)
|
|
240
|
+
* @param depsHash - Optional expected SHA-256 hash of the tarball
|
|
241
|
+
*/
|
|
242
|
+
async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
243
|
+
depsUrl = resolveOrchestratorUrl(depsUrl);
|
|
244
|
+
logger$4.info("Downloading dependency tarball", { url: depsUrl });
|
|
245
|
+
const kiciDir = join(workDir, ".kici");
|
|
246
|
+
if (depsUrl.startsWith("file://")) {
|
|
247
|
+
const localPath = fileURLToPath(depsUrl);
|
|
248
|
+
const data = await fsPromises.readFile(localPath);
|
|
249
|
+
if (depsHash) {
|
|
250
|
+
const actualHash = computeHash(data);
|
|
251
|
+
if (actualHash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${actualHash}`);
|
|
252
|
+
}
|
|
253
|
+
const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-file-${Date.now()}`);
|
|
254
|
+
await extractTarball(data, scratchDir);
|
|
255
|
+
await moveScratchIntoRepo(scratchDir, workDir);
|
|
256
|
+
await cleanupScratch(scratchDir);
|
|
257
|
+
const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
|
|
258
|
+
logger$4.info("Dependencies restored from cache (file)", {
|
|
259
|
+
sizeMB,
|
|
260
|
+
targetDir: workDir
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
|
|
265
|
+
let lastError;
|
|
266
|
+
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
267
|
+
if (attempt > 0) logger$4.warn("Retrying dep tarball download", {
|
|
268
|
+
attempt,
|
|
269
|
+
url: depsUrl
|
|
270
|
+
});
|
|
271
|
+
try {
|
|
272
|
+
const { scratchDir, hash } = await extractIntoScratch(depsUrl, kiciDir, attempt);
|
|
273
|
+
if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
|
|
274
|
+
await moveScratchIntoRepo(scratchDir, workDir);
|
|
275
|
+
await cleanupScratch(scratchDir);
|
|
276
|
+
logger$4.info("Dependencies restored from cache (stream)", { targetDir: workDir });
|
|
277
|
+
return;
|
|
278
|
+
} catch (err) {
|
|
279
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
280
|
+
logger$4.warn("Dep tarball download failed", {
|
|
281
|
+
attempt,
|
|
282
|
+
error: lastError.message
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
|
|
287
|
+
}
|
|
288
|
+
var logger$4, DOWNLOAD_TIMEOUT_MS$2, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
|
|
289
|
+
var init_dep_restore = __esmMin((() => {
|
|
290
|
+
logger$4 = createLogger({ prefix: "dep-restore" });
|
|
291
|
+
DOWNLOAD_TIMEOUT_MS$2 = 300 * 1e3;
|
|
292
|
+
SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
|
|
293
|
+
SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
|
|
294
|
+
}));
|
|
219
295
|
//#endregion
|
|
220
|
-
//#region src/execution/
|
|
221
|
-
initZx();
|
|
296
|
+
//#region src/execution/download.ts
|
|
222
297
|
/**
|
|
223
|
-
*
|
|
298
|
+
* Shared HTTP/HTTPS download utility.
|
|
224
299
|
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* @param env - Merged environment variables
|
|
300
|
+
* Extracted from workflow-loader.ts to avoid duplication across
|
|
301
|
+
* dep-restore.ts and workflow-loader.ts.
|
|
228
302
|
*/
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
303
|
+
var download_exports = /* @__PURE__ */ __exportAll({
|
|
304
|
+
downloadUrl: () => downloadUrl,
|
|
305
|
+
uploadToPresignedUrl: () => uploadToPresignedUrl
|
|
306
|
+
});
|
|
307
|
+
/**
|
|
308
|
+
* Download content from an HTTP/HTTPS URL.
|
|
309
|
+
*
|
|
310
|
+
* Includes a 5-minute timeout to prevent the agent from hanging indefinitely
|
|
311
|
+
* on slow or unresponsive endpoints.
|
|
312
|
+
*
|
|
313
|
+
* @param url - The URL to download from
|
|
314
|
+
* @returns The response body as a Buffer
|
|
315
|
+
*/
|
|
316
|
+
function downloadUrl(url) {
|
|
317
|
+
return new Promise((resolve, reject) => {
|
|
318
|
+
(url.startsWith("https:") ? https : http).get(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS$1) }, (res) => {
|
|
319
|
+
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
|
320
|
+
reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} downloading from ${url}`));
|
|
321
|
+
res.resume();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const chunks = [];
|
|
325
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
326
|
+
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
327
|
+
res.on("error", reject);
|
|
328
|
+
}).on("error", reject);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Upload a buffer to a pre-signed S3 URL via HTTP PUT.
|
|
333
|
+
*
|
|
334
|
+
* Used for direct-to-S3 uploads of bundles and dep tarballs. Localhost /
|
|
335
|
+
* 127.0.0.1 URLs are rewritten via `resolveOrchestratorUrl` so the
|
|
336
|
+
* filesystem cache backend's signed URLs work from container agents that
|
|
337
|
+
* can't reach the orchestrator's host loopback directly.
|
|
338
|
+
*
|
|
339
|
+
* @param url - The pre-signed URL to upload to
|
|
340
|
+
* @param data - The buffer to upload
|
|
341
|
+
*/
|
|
342
|
+
function uploadToPresignedUrl(url, data) {
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
const resolved = resolveOrchestratorUrl(url);
|
|
345
|
+
const parsed = new URL(resolved);
|
|
346
|
+
const req = (parsed.protocol === "https:" ? https : http).request({
|
|
347
|
+
hostname: parsed.hostname,
|
|
348
|
+
port: parsed.port,
|
|
349
|
+
path: parsed.pathname + parsed.search,
|
|
350
|
+
method: "PUT",
|
|
351
|
+
headers: { "Content-Length": data.length }
|
|
352
|
+
}, (res) => {
|
|
353
|
+
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
|
354
|
+
reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} uploading to pre-signed URL`));
|
|
355
|
+
res.resume();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
res.resume();
|
|
359
|
+
res.on("end", () => resolve());
|
|
360
|
+
res.on("error", reject);
|
|
361
|
+
});
|
|
362
|
+
req.on("error", reject);
|
|
363
|
+
req.end(data);
|
|
364
|
+
});
|
|
236
365
|
}
|
|
366
|
+
var DOWNLOAD_TIMEOUT_MS$1;
|
|
367
|
+
var init_download = __esmMin((() => {
|
|
368
|
+
init_dep_restore();
|
|
369
|
+
DOWNLOAD_TIMEOUT_MS$1 = 300 * 1e3;
|
|
370
|
+
}));
|
|
237
371
|
//#endregion
|
|
238
|
-
//#region src/execution/
|
|
372
|
+
//#region src/execution/cache/cache-engine.ts
|
|
239
373
|
/**
|
|
240
|
-
*
|
|
374
|
+
* User-facing cache engine (sandbox-side).
|
|
241
375
|
*
|
|
242
|
-
*
|
|
376
|
+
* Packs `CacheSpec.paths` into a gzip tarball (mirrors dep-packer's tar+sha256
|
|
377
|
+
* approach) and restores a tarball with on-the-fly SHA-256 verification
|
|
378
|
+
* (mirrors dep-restore's streaming pipeline). Drives the orchestrator over an
|
|
379
|
+
* injected request-response transport (IPC -> agent WS -> orchestrator).
|
|
380
|
+
*
|
|
381
|
+
* Path safety: each path is either `~`-prefixed (home-relative) or
|
|
382
|
+
* repo-root-relative; absolute paths and `..` escapes are rejected so a
|
|
383
|
+
* workflow cannot exfiltrate or clobber files outside its tree/home.
|
|
384
|
+
*
|
|
385
|
+
* Multi-root layout: a spec may mix repo-relative and home-relative paths.
|
|
386
|
+
* Each entry is staged under an anchor prefix — `__repo__/<rel>` for
|
|
387
|
+
* repo-root-relative entries, `__home__/<rel>` for `~`-prefixed entries — so a
|
|
388
|
+
* single tarball can carry both roots and extract restores each group to the
|
|
389
|
+
* right destination (repo entries under `workDir`, home entries under the
|
|
390
|
+
* homedir). Extraction lands in a scratch dir first, then moves each group
|
|
391
|
+
* into place so a partial restore never leaves half-written paths in the live
|
|
392
|
+
* tree (mirrors dep-restore).
|
|
243
393
|
*/
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
394
|
+
const logger$3 = createLogger({ prefix: "cache-engine" });
|
|
395
|
+
/** Download timeout for a presigned cache GET: 5 minutes. */
|
|
396
|
+
const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
|
|
397
|
+
/** Anchor prefix for repo-root-relative cache entries inside the tar. */
|
|
398
|
+
const REPO_ANCHOR = "__repo__";
|
|
399
|
+
/** Anchor prefix for home-relative (`~`) cache entries inside the tar. */
|
|
400
|
+
const HOME_ANCHOR = "__home__";
|
|
401
|
+
/**
|
|
402
|
+
* Resolve a cache path. `~`-prefixed -> home root; otherwise repo-root-relative.
|
|
403
|
+
* Rejects absolute paths and `..` escapes so a workflow cannot read or clobber
|
|
404
|
+
* files outside its tree / home.
|
|
405
|
+
*/
|
|
406
|
+
function resolveCachePath(workDir, p, roots) {
|
|
407
|
+
const home = roots?.home ?? homedir();
|
|
408
|
+
if (p === "~" || p.startsWith("~/")) {
|
|
409
|
+
const rel = p === "~" ? "" : p.slice(2);
|
|
410
|
+
return rel ? join(home, rel) : home;
|
|
411
|
+
}
|
|
412
|
+
if (isAbsolute(p)) throw new Error(`cache path must be repo-relative or ~-prefixed: ${p}`);
|
|
413
|
+
const resolved = resolve(workDir, p);
|
|
414
|
+
const rel = relative(workDir, resolved);
|
|
415
|
+
if (rel === ".." || rel.startsWith(`..${sep}`)) throw new Error(`cache path escapes the repo root: ${p}`);
|
|
416
|
+
return resolved;
|
|
417
|
+
}
|
|
418
|
+
/** Resolve + anchor every spec path; rejects escapes via resolveCachePath. */
|
|
419
|
+
function anchorEntries(workDir, paths, roots) {
|
|
420
|
+
const home = roots?.home ?? homedir();
|
|
421
|
+
return paths.map((p) => {
|
|
422
|
+
const abs = resolveCachePath(workDir, p, roots);
|
|
423
|
+
const isHome = p === "~" || p.startsWith("~/");
|
|
424
|
+
return {
|
|
425
|
+
abs,
|
|
426
|
+
anchor: isHome ? HOME_ANCHOR : REPO_ANCHOR,
|
|
427
|
+
rel: relative(isHome ? home : workDir, abs)
|
|
428
|
+
};
|
|
249
429
|
});
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Pack the spec's paths into a gzip tarball + its SHA-256.
|
|
433
|
+
*
|
|
434
|
+
* Each path is copied into a staging dir under its anchor prefix
|
|
435
|
+
* (`__repo__/<rel>` or `__home__/<rel>`), the staging dir is tarred (portable
|
|
436
|
+
* mode strips uid/gid/mtime), and the staging dir is removed. The resulting
|
|
437
|
+
* tarball self-describes which root each entry restores to.
|
|
438
|
+
*/
|
|
439
|
+
async function packCachePaths(workDir, paths, roots) {
|
|
440
|
+
const entries = anchorEntries(workDir, paths, roots);
|
|
441
|
+
const staging = await mkdtemp(join(tmpdir(), "kici-cache-pack-"));
|
|
253
442
|
try {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
443
|
+
const topLevel = /* @__PURE__ */ new Set();
|
|
444
|
+
for (const e of entries) {
|
|
445
|
+
const dest = join(staging, e.anchor, e.rel);
|
|
446
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
447
|
+
await cp(e.abs, dest, {
|
|
448
|
+
recursive: true,
|
|
449
|
+
verbatimSymlinks: true
|
|
257
450
|
});
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
451
|
+
topLevel.add(e.anchor);
|
|
452
|
+
}
|
|
453
|
+
const stream = c({
|
|
454
|
+
gzip: true,
|
|
455
|
+
portable: true,
|
|
456
|
+
cwd: staging
|
|
457
|
+
}, [...topLevel]);
|
|
458
|
+
const chunks = [];
|
|
459
|
+
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
|
460
|
+
const tarball = Buffer.concat(chunks);
|
|
461
|
+
const hash = sha256(tarball);
|
|
462
|
+
logger$3.info("packed user cache", {
|
|
463
|
+
sizeBytes: tarball.length,
|
|
464
|
+
hash: hash.slice(0, 12),
|
|
465
|
+
paths
|
|
272
466
|
});
|
|
273
467
|
return {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
status: ExecutionStepStatus.enum.success,
|
|
277
|
-
durationMs,
|
|
278
|
-
...outputsPayload && { outputs: outputsPayload }
|
|
468
|
+
tarball,
|
|
469
|
+
hash
|
|
279
470
|
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const exitCode = extractExitCode(e);
|
|
285
|
-
const signal = extractSignal(e);
|
|
286
|
-
const secretsAccessed = getSecretsAccessLog?.();
|
|
287
|
-
emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
|
|
288
|
-
sendFn({
|
|
289
|
-
type: "step.complete",
|
|
290
|
-
stepIndex,
|
|
291
|
-
status: ExecutionStepStatus.enum.failed,
|
|
292
|
-
durationMs,
|
|
293
|
-
error: {
|
|
294
|
-
message: error.message,
|
|
295
|
-
...exitCode !== void 0 && { exitCode },
|
|
296
|
-
...signal !== void 0 && { signal }
|
|
297
|
-
},
|
|
298
|
-
...secretsAccessed !== void 0 && { secretsAccessed }
|
|
471
|
+
} finally {
|
|
472
|
+
await rm(staging, {
|
|
473
|
+
recursive: true,
|
|
474
|
+
force: true
|
|
299
475
|
});
|
|
300
|
-
return {
|
|
301
|
-
name: step.name,
|
|
302
|
-
stepIndex,
|
|
303
|
-
status: ExecutionStepStatus.enum.failed,
|
|
304
|
-
durationMs,
|
|
305
|
-
error: {
|
|
306
|
-
message: error.message,
|
|
307
|
-
...exitCode !== void 0 && { exitCode },
|
|
308
|
-
...signal !== void 0 && { signal }
|
|
309
|
-
}
|
|
310
|
-
};
|
|
311
476
|
}
|
|
312
477
|
}
|
|
313
|
-
/**
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
function extractExitCode(error) {
|
|
330
|
-
if (error && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number") return error.exitCode;
|
|
331
|
-
}
|
|
332
|
-
function extractSignal(error) {
|
|
333
|
-
if (error && typeof error === "object" && "signal" in error && typeof error.signal === "string") return error.signal;
|
|
478
|
+
/** Move the extracted `__repo__` / `__home__` groups from a scratch dir into place. */
|
|
479
|
+
async function moveAnchoredGroups(scratchDir, workDir, home) {
|
|
480
|
+
for (const anchor of await readdir(scratchDir)) {
|
|
481
|
+
const anchorDir = join(scratchDir, anchor);
|
|
482
|
+
const destRoot = anchor === HOME_ANCHOR ? home : anchor === REPO_ANCHOR ? workDir : null;
|
|
483
|
+
if (!destRoot) continue;
|
|
484
|
+
for (const child of await readdir(anchorDir)) {
|
|
485
|
+
const dest = join(destRoot, child);
|
|
486
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
487
|
+
await rm(dest, {
|
|
488
|
+
recursive: true,
|
|
489
|
+
force: true
|
|
490
|
+
});
|
|
491
|
+
await rename(join(anchorDir, child), dest);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
334
494
|
}
|
|
335
495
|
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
496
|
+
* Stream-download a presigned URL, verify its SHA-256 on the fly (mirrors
|
|
497
|
+
* dep-restore's response -> hash -> gunzip -> tar pipeline), then move the
|
|
498
|
+
* anchored groups into place. Extracts into a scratch dir so a failed download
|
|
499
|
+
* never half-writes the live tree.
|
|
338
500
|
*/
|
|
339
|
-
async function
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
501
|
+
async function downloadAndExtractCache(url, workDir, expectedHash, roots) {
|
|
502
|
+
const home = roots?.home ?? homedir();
|
|
503
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
504
|
+
if (!response.ok || !response.body) throw new Error(`cache download HTTP ${response.status}`);
|
|
505
|
+
const hash = createHash("sha256");
|
|
506
|
+
const hashTransform = new Transform({ transform(chunk, _enc, cb) {
|
|
507
|
+
hash.update(chunk);
|
|
508
|
+
cb(null, chunk);
|
|
509
|
+
} });
|
|
510
|
+
await mkdir(workDir, { recursive: true });
|
|
511
|
+
const scratch = await mkdtemp(join(tmpdir(), "kici-cache-extract-"));
|
|
512
|
+
try {
|
|
513
|
+
await pipeline(Readable.fromWeb(response.body), hashTransform, createGunzip(), x({ cwd: scratch }));
|
|
514
|
+
const digest = hash.digest("hex");
|
|
515
|
+
if (digest !== expectedHash) throw new Error(`Cache tarball checksum mismatch on download: expected ${expectedHash}, got ${digest}`);
|
|
516
|
+
await moveAnchoredGroups(scratch, workDir, home);
|
|
517
|
+
} finally {
|
|
518
|
+
await rm(scratch, {
|
|
519
|
+
recursive: true,
|
|
520
|
+
force: true
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** Build the imperative `ctx.cache` API bound to a workDir + transport. */
|
|
525
|
+
function createCacheApi(workDir, transport, roots) {
|
|
360
526
|
return {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
527
|
+
async restore(spec) {
|
|
528
|
+
const r = await transport.restore(spec.key, spec.restoreKeys);
|
|
529
|
+
if (!r.hit || !r.downloadUrl || !r.tarHash) return { hit: false };
|
|
530
|
+
await downloadAndExtractCache(r.downloadUrl, workDir, r.tarHash, roots);
|
|
531
|
+
logger$3.info("user cache restored", {
|
|
532
|
+
key: spec.key,
|
|
533
|
+
matchedKey: r.matchedKey
|
|
534
|
+
});
|
|
535
|
+
return {
|
|
536
|
+
hit: true,
|
|
537
|
+
matchedKey: r.matchedKey
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
async save(spec) {
|
|
541
|
+
const begin = await transport.beginSave(spec.key);
|
|
542
|
+
if (begin.skip || !begin.uploadUrl) {
|
|
543
|
+
logger$3.info("user cache save skipped (key exists)", { key: spec.key });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const { tarball, hash } = await packCachePaths(workDir, spec.paths, roots);
|
|
547
|
+
const { uploadToPresignedUrl } = await Promise.resolve().then(() => (init_download(), download_exports));
|
|
548
|
+
await uploadToPresignedUrl(begin.uploadUrl, tarball);
|
|
549
|
+
await transport.completeSave(spec.key, hash, tarball.length);
|
|
550
|
+
logger$3.info("user cache saved", {
|
|
551
|
+
key: spec.key,
|
|
552
|
+
sizeBytes: tarball.length
|
|
553
|
+
});
|
|
554
|
+
}
|
|
365
555
|
};
|
|
366
556
|
}
|
|
557
|
+
//#endregion
|
|
558
|
+
//#region src/execution/cache/cache-phase.ts
|
|
367
559
|
/**
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
560
|
+
* Declarative cache phase (sandbox-side).
|
|
561
|
+
*
|
|
562
|
+
* Restores a list of {@link CacheSpec} before the work that depends on them
|
|
563
|
+
* (a job before its steps, or a step before its `run`) and saves them after
|
|
564
|
+
* (on an exact-key miss). Each operation surfaces as a `cache:restore` /
|
|
565
|
+
* `cache:save` pseudo-step — a `step.start` + `step.complete` IPC pair whose
|
|
566
|
+
* `step_type` comes from {@link CacheStepType} — exactly mirroring how hooks
|
|
567
|
+
* render as `hook:*` pseudo-steps. The `step.complete` `data` carries the
|
|
568
|
+
* {@link CacheOutcome} (plus key / matchedKey / bytes) so the agent can feed a
|
|
569
|
+
* `run.event` and the dashboard can render hit/miss/saved inline.
|
|
371
570
|
*/
|
|
372
|
-
async function runObserverHook(args) {
|
|
373
|
-
const { hook, hookType, step, stepIndex, hookStepIndex, failedStep, opts } = args;
|
|
374
|
-
const outcome = buildOutcomeMetadata({
|
|
375
|
-
status: failedStep !== void 0 ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
|
|
376
|
-
stepOutputs: Object.fromEntries(opts.outputsMap),
|
|
377
|
-
startTime: opts.startTime ?? Date.now(),
|
|
378
|
-
...failedStep !== void 0 && { failedStep }
|
|
379
|
-
});
|
|
380
|
-
const hookResult = await executeHook({
|
|
381
|
-
hook,
|
|
382
|
-
stepContext: opts.createStepContext(stepIndex, step.name),
|
|
383
|
-
outcome,
|
|
384
|
-
hookType,
|
|
385
|
-
stepIndex: hookStepIndex,
|
|
386
|
-
sendIpc: opts.sendIpc,
|
|
387
|
-
timeout: 3e5
|
|
388
|
-
});
|
|
389
|
-
if (!hookResult.success) opts.sendIpc({
|
|
390
|
-
type: "log.line",
|
|
391
|
-
stepIndex,
|
|
392
|
-
line: `[kici] ${hookType} hook failed: ${hookResult.error} (continuing -- hooks are observers)`
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
571
|
/**
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
* Wraps the per-step lifecycle in a `try / finally` that calls
|
|
401
|
-
* `opts.disposeStepResources()` so per-step state (the
|
|
402
|
-
* `ctx.secrets.mountFile` tmpdir, any env vars set via `exposeFile`) is
|
|
403
|
-
* removed even when the step throws, times out, or rule-skips.
|
|
572
|
+
* Restore every spec, surfacing each as a `cache:restore` pseudo-step. Returns
|
|
573
|
+
* a map keyed by spec key recording whether the EXACT key hit (so the save
|
|
574
|
+
* phase can skip a redundant save of an entry that already exists).
|
|
404
575
|
*/
|
|
405
|
-
async function
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
shouldBreak: false
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
if (opts.jobHooks?.beforeStep) await runObserverHook({
|
|
415
|
-
hook: opts.jobHooks.beforeStep,
|
|
416
|
-
hookType: "beforeStep",
|
|
417
|
-
step,
|
|
418
|
-
stepIndex,
|
|
419
|
-
hookStepIndex: opts.steps.length + stepIndex * 2,
|
|
420
|
-
opts
|
|
421
|
-
});
|
|
422
|
-
try {
|
|
423
|
-
const result = await executeStepInLoop(step, stepIndex, opts.createStepContext(stepIndex, step.name), step.timeout ?? opts.defaultTimeoutMs, opts.sendIpc, opts.outputsMap, opts.getSecretsAccessLog, opts.getSecretMountRecords);
|
|
424
|
-
if (opts.jobHooks?.afterStep) await runObserverHook({
|
|
425
|
-
hook: opts.jobHooks.afterStep,
|
|
426
|
-
hookType: "afterStep",
|
|
427
|
-
step,
|
|
576
|
+
async function restoreCacheSpecs(specs, deps) {
|
|
577
|
+
const results = /* @__PURE__ */ new Map();
|
|
578
|
+
for (const spec of specs) {
|
|
579
|
+
const stepIndex = deps.nextStepIndex();
|
|
580
|
+
deps.sendIpc({
|
|
581
|
+
type: "step.start",
|
|
428
582
|
stepIndex,
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
opts
|
|
583
|
+
stepName: `cache restore: ${spec.key}`,
|
|
584
|
+
step_type: CacheStepType.enum["cache:restore"]
|
|
432
585
|
});
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
586
|
+
const start = Date.now();
|
|
587
|
+
try {
|
|
588
|
+
const r = await deps.cache.restore(spec);
|
|
589
|
+
results.set(spec.key, {
|
|
590
|
+
hit: r.hit,
|
|
591
|
+
matchedKey: r.matchedKey
|
|
592
|
+
});
|
|
593
|
+
deps.sendIpc({
|
|
594
|
+
type: "step.complete",
|
|
595
|
+
stepIndex,
|
|
596
|
+
status: "success",
|
|
597
|
+
durationMs: Date.now() - start,
|
|
598
|
+
step_type: CacheStepType.enum["cache:restore"],
|
|
599
|
+
data: {
|
|
600
|
+
cacheOutcome: r.hit ? CacheOutcome.enum.hit : CacheOutcome.enum.miss,
|
|
601
|
+
key: spec.key,
|
|
602
|
+
...r.matchedKey !== void 0 && { matchedKey: r.matchedKey }
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
} catch (e) {
|
|
606
|
+
results.set(spec.key, { hit: false });
|
|
607
|
+
deps.sendIpc({
|
|
608
|
+
type: "step.complete",
|
|
609
|
+
stepIndex,
|
|
610
|
+
status: "failed",
|
|
611
|
+
durationMs: Date.now() - start,
|
|
612
|
+
error: { message: toErrorMessage(e) },
|
|
613
|
+
step_type: CacheStepType.enum["cache:restore"],
|
|
614
|
+
data: {
|
|
615
|
+
cacheOutcome: CacheOutcome.enum.error,
|
|
616
|
+
key: spec.key
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
}
|
|
444
620
|
}
|
|
621
|
+
return results;
|
|
445
622
|
}
|
|
446
623
|
/**
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
624
|
+
* Save every spec whose EXACT key did not already hit on restore (immutable +
|
|
625
|
+
* no redundant save), surfacing each as a `cache:save` pseudo-step. A spec
|
|
626
|
+
* whose restore matched a different key via a `restoreKeys` prefix is still
|
|
627
|
+
* saved under its exact key.
|
|
451
628
|
*/
|
|
452
|
-
async function
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
stepContext: opts.createStepContext(hookStepIndex, hookType),
|
|
462
|
-
outcome,
|
|
463
|
-
hookType,
|
|
464
|
-
stepIndex: hookStepIndex,
|
|
465
|
-
sendIpc: opts.sendIpc
|
|
466
|
-
});
|
|
467
|
-
if (hookResult.success) {
|
|
468
|
-
opts.sendIpc({
|
|
469
|
-
type: "log.line",
|
|
470
|
-
stepIndex: -1,
|
|
471
|
-
line: `[kici] ${hookType} hook completed`
|
|
629
|
+
async function saveCacheSpecs(specs, restoreResults, deps) {
|
|
630
|
+
for (const spec of specs) {
|
|
631
|
+
if (restoreResults.get(spec.key)?.matchedKey === spec.key) continue;
|
|
632
|
+
const stepIndex = deps.nextStepIndex();
|
|
633
|
+
deps.sendIpc({
|
|
634
|
+
type: "step.start",
|
|
635
|
+
stepIndex,
|
|
636
|
+
stepName: `cache save: ${spec.key}`,
|
|
637
|
+
step_type: CacheStepType.enum["cache:save"]
|
|
472
638
|
});
|
|
473
|
-
|
|
639
|
+
const start = Date.now();
|
|
640
|
+
try {
|
|
641
|
+
await deps.cache.save(spec);
|
|
642
|
+
deps.sendIpc({
|
|
643
|
+
type: "step.complete",
|
|
644
|
+
stepIndex,
|
|
645
|
+
status: "success",
|
|
646
|
+
durationMs: Date.now() - start,
|
|
647
|
+
step_type: CacheStepType.enum["cache:save"],
|
|
648
|
+
data: {
|
|
649
|
+
cacheOutcome: CacheOutcome.enum.saved,
|
|
650
|
+
key: spec.key
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
} catch (e) {
|
|
654
|
+
deps.sendIpc({
|
|
655
|
+
type: "step.complete",
|
|
656
|
+
stepIndex,
|
|
657
|
+
status: "failed",
|
|
658
|
+
durationMs: Date.now() - start,
|
|
659
|
+
error: { message: toErrorMessage(e) },
|
|
660
|
+
step_type: CacheStepType.enum["cache:save"],
|
|
661
|
+
data: {
|
|
662
|
+
cacheOutcome: CacheOutcome.enum.error,
|
|
663
|
+
key: spec.key
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
}
|
|
474
667
|
}
|
|
475
|
-
opts.sendIpc({
|
|
476
|
-
type: "log.line",
|
|
477
|
-
stepIndex: -1,
|
|
478
|
-
line: `[kici] ${hookType} hook failed: ${hookResult.error}`
|
|
479
|
-
});
|
|
480
|
-
const reasonFragment = `Hook ${hookType} failed: ${hookResult.error}`;
|
|
481
|
-
return {
|
|
482
|
-
failed: state.failed || promoteToFailed,
|
|
483
|
-
failedStepName: state.failedStepName,
|
|
484
|
-
failureReason: state.failureReason ? `${state.failureReason}; ${reasonFragment}` : reasonFragment
|
|
485
|
-
};
|
|
486
668
|
}
|
|
669
|
+
//#endregion
|
|
670
|
+
//#region src/execution/sandbox/log-masker.ts
|
|
487
671
|
/**
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
672
|
+
* Secret value masking for log lines.
|
|
673
|
+
*
|
|
674
|
+
* Replaces all occurrences of registered secret values with '***' in log output.
|
|
675
|
+
* Used by the workflow runner to prevent secret leaks in IPC log messages.
|
|
676
|
+
*
|
|
677
|
+
* Performance: Builds a single combined regex from all secret values, so each
|
|
678
|
+
* log line is scanned in a single pass (not O(secrets * lines)).
|
|
491
679
|
*/
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
...initial.failedStepName && {
|
|
500
|
-
failedStep: initial.failedStepName,
|
|
501
|
-
reason: `Step '${initial.failedStepName}' failed`
|
|
502
|
-
}
|
|
503
|
-
});
|
|
504
|
-
let state = initial;
|
|
505
|
-
let hookStepIndex = opts.steps.length;
|
|
506
|
-
if (initialFinalStatus === ExecutionStepStatus.enum.success && jobHooks?.onSuccess) {
|
|
507
|
-
state = await runCompletionHook({
|
|
508
|
-
hook: jobHooks.onSuccess,
|
|
509
|
-
hookType: "onSuccess",
|
|
510
|
-
hookStepIndex,
|
|
511
|
-
outcome: jobOutcome,
|
|
512
|
-
state,
|
|
513
|
-
promoteToFailed: true,
|
|
514
|
-
opts
|
|
515
|
-
});
|
|
516
|
-
hookStepIndex++;
|
|
517
|
-
} else if (initialFinalStatus === ExecutionStepStatus.enum.failed && jobHooks?.onFailure) {
|
|
518
|
-
state = await runCompletionHook({
|
|
519
|
-
hook: jobHooks.onFailure,
|
|
520
|
-
hookType: "onFailure",
|
|
521
|
-
hookStepIndex,
|
|
522
|
-
outcome: jobOutcome,
|
|
523
|
-
state,
|
|
524
|
-
promoteToFailed: false,
|
|
525
|
-
opts
|
|
526
|
-
});
|
|
527
|
-
hookStepIndex++;
|
|
528
|
-
}
|
|
529
|
-
if (jobHooks?.cleanup) {
|
|
530
|
-
const cleanupOutcome = buildOutcomeMetadata({
|
|
531
|
-
status: state.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
|
|
532
|
-
stepOutputs: Object.fromEntries(outputsMap),
|
|
533
|
-
startTime,
|
|
534
|
-
...state.failedStepName && { failedStep: state.failedStepName },
|
|
535
|
-
...state.failureReason && { reason: state.failureReason }
|
|
536
|
-
});
|
|
537
|
-
state = await runCompletionHook({
|
|
538
|
-
hook: jobHooks.cleanup,
|
|
539
|
-
hookType: "cleanup",
|
|
540
|
-
hookStepIndex,
|
|
541
|
-
outcome: cleanupOutcome,
|
|
542
|
-
state,
|
|
543
|
-
promoteToFailed: true,
|
|
544
|
-
opts
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
return state;
|
|
680
|
+
/** Minimum length for a secret value to be maskable (avoids false positives). */
|
|
681
|
+
const MIN_MASK_LENGTH = 3;
|
|
682
|
+
/**
|
|
683
|
+
* Escape regex special characters in a string.
|
|
684
|
+
*/
|
|
685
|
+
function escapeRegExp(s) {
|
|
686
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
548
687
|
}
|
|
549
688
|
/**
|
|
550
|
-
*
|
|
689
|
+
* Masks secret values in log lines.
|
|
551
690
|
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
691
|
+
* Usage:
|
|
692
|
+
* ```ts
|
|
693
|
+
* const masker = new LogMasker();
|
|
694
|
+
* masker.registerSecrets({ TOKEN: 'abc123', SHORT: 'ab' });
|
|
695
|
+
* masker.mask('Token is abc123'); // 'Token is ***'
|
|
696
|
+
* // 'ab' is NOT masked (< 3 chars)
|
|
697
|
+
* ```
|
|
698
|
+
*/
|
|
699
|
+
var LogMasker = class {
|
|
700
|
+
pattern = null;
|
|
701
|
+
/**
|
|
702
|
+
* Register secret values to be masked in log output.
|
|
703
|
+
*
|
|
704
|
+
* Values shorter than 3 characters are skipped to avoid false positives.
|
|
705
|
+
* Base64-encoded variants of each qualifying secret are also registered,
|
|
706
|
+
* preventing leaks when secrets appear base64-encoded in logs (e.g.,
|
|
707
|
+
* Authorization: Basic headers, base64-encoded config values).
|
|
708
|
+
* Values are sorted by length descending so longer values are matched first
|
|
709
|
+
* (prevents partial masking when one secret is a substring of another).
|
|
710
|
+
*/
|
|
711
|
+
registerSecrets(secrets) {
|
|
712
|
+
const seen = /* @__PURE__ */ new Set();
|
|
713
|
+
const values = [];
|
|
714
|
+
for (const value of Object.values(secrets)) if (value.length >= MIN_MASK_LENGTH && !seen.has(value)) {
|
|
715
|
+
seen.add(value);
|
|
716
|
+
values.push(value);
|
|
717
|
+
const b64 = Buffer.from(value).toString("base64");
|
|
718
|
+
if (b64.length >= MIN_MASK_LENGTH && !seen.has(b64)) {
|
|
719
|
+
seen.add(b64);
|
|
720
|
+
values.push(b64);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (values.length === 0) {
|
|
724
|
+
this.pattern = null;
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
values.sort((a, b) => b.length - a.length);
|
|
728
|
+
this.pattern = new RegExp(values.map((v) => escapeRegExp(v)).join("|"), "g");
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Mask all registered secret values in a log line.
|
|
732
|
+
*
|
|
733
|
+
* Returns the line unchanged if no secrets are registered.
|
|
734
|
+
*/
|
|
735
|
+
mask(line) {
|
|
736
|
+
if (!this.pattern) return line;
|
|
737
|
+
this.pattern.lastIndex = 0;
|
|
738
|
+
return line.replace(this.pattern, "***");
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Returns true if any maskable secrets are registered.
|
|
742
|
+
*/
|
|
743
|
+
hasSecrets() {
|
|
744
|
+
return this.pattern !== null;
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
//#endregion
|
|
748
|
+
//#region src/execution/sandbox/env-delta.ts
|
|
749
|
+
/**
|
|
750
|
+
* Apply an environment delta to `target` (defaults to process.env), honoring the
|
|
751
|
+
* operator-secret guard.
|
|
556
752
|
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
753
|
+
* - env keys present in `operatorSecretKeys` are rejected (never override an
|
|
754
|
+
* operator secret); `onReject` fires once per rejected key.
|
|
755
|
+
* - pathPrepends are applied so the FIRST array entry ends up FIRST on PATH.
|
|
559
756
|
*/
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
const
|
|
563
|
-
const
|
|
564
|
-
for (const [
|
|
565
|
-
if (
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
state.failed = true;
|
|
570
|
-
state.failedStepName = outcome.failedStepName;
|
|
757
|
+
function applyEnvDelta(delta, options) {
|
|
758
|
+
const target = options.target ?? process.env;
|
|
759
|
+
const appliedKeys = [];
|
|
760
|
+
const rejectedKeys = [];
|
|
761
|
+
for (const [key, value] of Object.entries(delta.env)) {
|
|
762
|
+
if (options.operatorSecretKeys.has(key)) {
|
|
763
|
+
rejectedKeys.push(key);
|
|
764
|
+
options.onReject?.(key);
|
|
765
|
+
continue;
|
|
571
766
|
}
|
|
572
|
-
|
|
767
|
+
target[key] = value;
|
|
768
|
+
appliedKeys.push(key);
|
|
769
|
+
}
|
|
770
|
+
const appliedPaths = [];
|
|
771
|
+
if (delta.pathPrepends.length > 0) {
|
|
772
|
+
for (const dir of [...delta.pathPrepends].reverse()) target.PATH = target.PATH ? `${dir}:${target.PATH}` : dir;
|
|
773
|
+
appliedPaths.push(...delta.pathPrepends);
|
|
573
774
|
}
|
|
574
|
-
if (opts.isAborted?.()) return {
|
|
575
|
-
status: "aborted",
|
|
576
|
-
stepResults,
|
|
577
|
-
failureReason: state.failureReason ?? (state.failed ? `Step '${state.failedStepName}' failed` : void 0)
|
|
578
|
-
};
|
|
579
|
-
const finalState = await runJobCompletionHooks(opts, state, opts.outputsMap, startTime);
|
|
580
775
|
return {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
776
|
+
appliedKeys,
|
|
777
|
+
rejectedKeys,
|
|
778
|
+
appliedPaths
|
|
584
779
|
};
|
|
585
780
|
}
|
|
586
781
|
//#endregion
|
|
587
|
-
//#region src/
|
|
782
|
+
//#region src/execution/sandbox/env-file.ts
|
|
588
783
|
/**
|
|
589
|
-
*
|
|
590
|
-
* tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
|
|
784
|
+
* KICI_ENV / KICI_PATH temp-file contract.
|
|
591
785
|
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
786
|
+
* Before each step the agent points the KICI_ENV and KICI_PATH env vars at fresh
|
|
787
|
+
* temp files. A step's shell commands append `KEY=value` lines to $KICI_ENV and
|
|
788
|
+
* one directory per line to $KICI_PATH. After the step the agent parses both
|
|
789
|
+
* files into an EnvDelta and feeds it through applyEnvDelta() -- the same path
|
|
790
|
+
* the JS API (ctx.setEnv / ctx.addPath) uses -- then truncates the files for the
|
|
791
|
+
* next step.
|
|
597
792
|
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
* - `-o IdentitiesOnly=yes` — don't try other keys from ssh-agent / ~/.ssh.
|
|
601
|
-
* - `-o BatchMode=yes` — never prompt for passwords / passphrases.
|
|
602
|
-
* - host-key checking flags based on `hostKeyPolicy`.
|
|
793
|
+
* Format v1: single-line `KEY=value` for env (no embedded newlines); one
|
|
794
|
+
* directory per line for path. Blank lines and lines without `=` are ignored.
|
|
603
795
|
*/
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
"
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
796
|
+
/**
|
|
797
|
+
* Parse `KEY=value` lines into a record. Blank lines, lines without `=`, and
|
|
798
|
+
* lines with an empty key are ignored. The split is on the first `=` only, so a
|
|
799
|
+
* value may contain `=`. The key is trimmed; the value is taken verbatim after
|
|
800
|
+
* the first `=`. Last assignment to a key wins.
|
|
801
|
+
*/
|
|
802
|
+
function parseEnvFileContent(content) {
|
|
803
|
+
const out = {};
|
|
804
|
+
for (const rawLine of content.split("\n")) {
|
|
805
|
+
const line = rawLine.trim();
|
|
806
|
+
if (line.length === 0) continue;
|
|
807
|
+
const eq = line.indexOf("=");
|
|
808
|
+
if (eq <= 0) continue;
|
|
809
|
+
const key = line.slice(0, eq).trim();
|
|
810
|
+
if (key.length === 0) continue;
|
|
811
|
+
out[key] = line.slice(eq + 1);
|
|
812
|
+
}
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
/** Parse one trimmed directory per non-blank line, preserving order. */
|
|
816
|
+
function parsePathFileContent(content) {
|
|
817
|
+
const out = [];
|
|
818
|
+
for (const rawLine of content.split("\n")) {
|
|
819
|
+
const line = rawLine.trim();
|
|
820
|
+
if (line.length === 0) continue;
|
|
821
|
+
out.push(line);
|
|
822
|
+
}
|
|
823
|
+
return out;
|
|
824
|
+
}
|
|
825
|
+
/** Create fresh, empty env + path files inside a private temp dir under `baseDir`. */
|
|
826
|
+
async function createEnvFiles(baseDir) {
|
|
827
|
+
const dir = await mkdtemp(join(baseDir, "kici-env-"));
|
|
828
|
+
const envFile = join(dir, "env");
|
|
829
|
+
const pathFile = join(dir, "path");
|
|
830
|
+
await writeFile(envFile, "");
|
|
831
|
+
await writeFile(pathFile, "");
|
|
624
832
|
return {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
833
|
+
envFile,
|
|
834
|
+
pathFile
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
/** Read + parse both files into an EnvDelta. Missing/empty files yield an empty delta. */
|
|
838
|
+
async function readEnvDelta(files) {
|
|
839
|
+
const [envContent, pathContent] = await Promise.all([readFile(files.envFile, "utf8").catch(() => ""), readFile(files.pathFile, "utf8").catch(() => "")]);
|
|
840
|
+
return {
|
|
841
|
+
env: parseEnvFileContent(envContent),
|
|
842
|
+
pathPrepends: parsePathFileContent(pathContent)
|
|
633
843
|
};
|
|
634
844
|
}
|
|
845
|
+
/** Truncate both files to empty so the next step starts clean. */
|
|
846
|
+
async function truncateEnvFiles(files) {
|
|
847
|
+
await Promise.all([writeFile(files.envFile, ""), writeFile(files.pathFile, "")]);
|
|
848
|
+
}
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region src/execution/sandbox/secret-merge.ts
|
|
635
851
|
/**
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
852
|
+
* Secret merging utilities for the workflow runner.
|
|
853
|
+
*
|
|
854
|
+
* Separated from workflow-runner.ts to allow unit testing without
|
|
855
|
+
* triggering the runner's top-level side effects (process handlers, main()).
|
|
639
856
|
*/
|
|
640
|
-
|
|
641
|
-
|
|
857
|
+
/**
|
|
858
|
+
* Merge orchestrator-level secrets with auto-flattened context keys.
|
|
859
|
+
*
|
|
860
|
+
* Precedence (last wins):
|
|
861
|
+
* 1. Orchestrator-level secrets (lowest)
|
|
862
|
+
* 2. Context-flattened keys in declaration order (each context's keys overlay previous)
|
|
863
|
+
*
|
|
864
|
+
* This means: context-flattened keys override orchestrator-level secrets,
|
|
865
|
+
* and for collisions between contexts, last declared context wins.
|
|
866
|
+
*/
|
|
867
|
+
function buildMergedFlatSecrets(orchestratorSecrets, namespacedSecrets) {
|
|
868
|
+
const merged = { ...orchestratorSecrets };
|
|
869
|
+
for (const contextSecrets of Object.values(namespacedSecrets)) Object.assign(merged, contextSecrets);
|
|
870
|
+
return merged;
|
|
642
871
|
}
|
|
643
872
|
//#endregion
|
|
644
|
-
//#region src/
|
|
873
|
+
//#region src/execution/hook-executor.ts
|
|
874
|
+
/** Default hook timeout: 5 minutes */
|
|
875
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 300 * 1e3;
|
|
645
876
|
/**
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
* tokens or the absolute path of a temporary SSH key file in logs.
|
|
877
|
+
* Build outcome metadata from execution state.
|
|
878
|
+
*
|
|
879
|
+
* Duration is calculated as elapsed time since startTime.
|
|
650
880
|
*/
|
|
651
|
-
function
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
881
|
+
function buildOutcomeMetadata(opts) {
|
|
882
|
+
return {
|
|
883
|
+
status: opts.status,
|
|
884
|
+
reason: opts.reason,
|
|
885
|
+
failedStep: opts.failedStep,
|
|
886
|
+
stepOutputs: opts.stepOutputs,
|
|
887
|
+
duration: Date.now() - opts.startTime
|
|
888
|
+
};
|
|
656
889
|
}
|
|
657
|
-
|
|
658
|
-
|
|
890
|
+
/**
|
|
891
|
+
* Normalize a HookInput (bare function, { run, timeout }, or HookConfig) into a HookConfig.
|
|
892
|
+
*/
|
|
893
|
+
function normalizeHook(hook, hookType) {
|
|
894
|
+
if (typeof hook === "object" && "name" in hook && "type" in hook) return hook;
|
|
895
|
+
if (typeof hook === "function") return {
|
|
896
|
+
name: hookType,
|
|
897
|
+
type: hookType,
|
|
898
|
+
run: hook
|
|
899
|
+
};
|
|
900
|
+
if (typeof hook === "object" && "run" in hook) return {
|
|
901
|
+
name: hookType,
|
|
902
|
+
type: hookType,
|
|
903
|
+
run: hook.run,
|
|
904
|
+
timeout: hook.timeout
|
|
905
|
+
};
|
|
906
|
+
throw new Error(`Invalid hook input for ${hookType}`);
|
|
659
907
|
}
|
|
660
908
|
/**
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
* Token authentication uses git's `-c http.extraHeader` mechanism which keeps
|
|
664
|
-
* the token out of the clone URL (not visible in `git remote -v` or logs).
|
|
665
|
-
*
|
|
666
|
-
* After clone, verifies that HEAD matches the expected SHA to prevent
|
|
667
|
-
* wrong-ref execution.
|
|
909
|
+
* Execute a single hook with timeout enforcement and IPC reporting.
|
|
668
910
|
*
|
|
669
|
-
*
|
|
911
|
+
* Sends step.start and step.complete IPC messages with step_type = 'hook:{hookType}'.
|
|
912
|
+
* The hook runs in the same sandbox context as regular steps.
|
|
670
913
|
*/
|
|
671
|
-
async function
|
|
672
|
-
const {
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
await writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
|
|
689
|
-
envEntries.GIT_CONFIG_GLOBAL = cfgPath;
|
|
690
|
-
needsCustomEnv = true;
|
|
691
|
-
safeDirCleanup = async () => {
|
|
692
|
-
await rm(dir, {
|
|
693
|
-
recursive: true,
|
|
694
|
-
force: true
|
|
695
|
-
}).catch(() => {});
|
|
696
|
-
};
|
|
697
|
-
}
|
|
698
|
-
let sshSetup;
|
|
914
|
+
async function executeHook(opts) {
|
|
915
|
+
const { stepContext, outcome, hookType, stepIndex, sendIpc } = opts;
|
|
916
|
+
const normalized = normalizeHook(opts.hook, hookType);
|
|
917
|
+
const timeoutMs = normalized.timeout ?? opts.timeout ?? DEFAULT_HOOK_TIMEOUT_MS;
|
|
918
|
+
sendIpc({
|
|
919
|
+
type: "step.start",
|
|
920
|
+
stepIndex,
|
|
921
|
+
stepName: normalized.name,
|
|
922
|
+
step_type: `hook:${hookType}`
|
|
923
|
+
});
|
|
924
|
+
const startTime = Date.now();
|
|
925
|
+
const mergedCtx = {
|
|
926
|
+
...stepContext,
|
|
927
|
+
outcome
|
|
928
|
+
};
|
|
929
|
+
const abortController = new AbortController();
|
|
930
|
+
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
|
699
931
|
try {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
args.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
|
|
704
|
-
} else if (auth?.kind === "ssh") {
|
|
705
|
-
sshSetup = await setupSshAuth({
|
|
706
|
-
privateKey: auth.secret,
|
|
707
|
-
hostKeyPolicy: auth.sshHostKeyPolicy,
|
|
708
|
-
knownHosts: auth.sshKnownHostsPem
|
|
709
|
-
});
|
|
710
|
-
envEntries.GIT_SSH_COMMAND = sshSetup.gitSshCommand;
|
|
711
|
-
needsCustomEnv = true;
|
|
712
|
-
}
|
|
713
|
-
const env = needsCustomEnv ? {
|
|
714
|
-
...process.env,
|
|
715
|
-
...envEntries
|
|
716
|
-
} : void 0;
|
|
717
|
-
if (ref) args.push("clone", "--depth", String(depth), "--branch", ref, repoUrl, workDir);
|
|
718
|
-
else args.push("clone", "--depth", String(depth), repoUrl, workDir);
|
|
719
|
-
const { execFileSync } = await import("node:child_process");
|
|
720
|
-
try {
|
|
721
|
-
execFileSync("git", args, {
|
|
722
|
-
stdio: "pipe",
|
|
723
|
-
timeout: 12e4,
|
|
724
|
-
...env && { env }
|
|
725
|
-
});
|
|
726
|
-
} catch (err) {
|
|
727
|
-
throw sanitizeGitError(err);
|
|
728
|
-
}
|
|
729
|
-
if (!sha || sha === "HEAD") return;
|
|
730
|
-
const envOpts = env ? { env } : {};
|
|
731
|
-
if (!execFileSync("git", [
|
|
732
|
-
"-C",
|
|
733
|
-
workDir,
|
|
734
|
-
"rev-parse",
|
|
735
|
-
"HEAD"
|
|
736
|
-
], {
|
|
737
|
-
encoding: "utf-8",
|
|
738
|
-
timeout: 1e4,
|
|
739
|
-
...envOpts
|
|
740
|
-
}).trim().startsWith(sha)) {
|
|
741
|
-
const fetchArgs = [];
|
|
742
|
-
if (auth?.kind === "basic") {
|
|
743
|
-
const user = auth.user ?? "x-access-token";
|
|
744
|
-
const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
|
|
745
|
-
fetchArgs.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
|
|
746
|
-
}
|
|
747
|
-
fetchArgs.push("fetch", "--depth", "50", "origin", sha);
|
|
748
|
-
try {
|
|
749
|
-
execFileSync("git", [
|
|
750
|
-
"-C",
|
|
751
|
-
workDir,
|
|
752
|
-
...fetchArgs
|
|
753
|
-
], {
|
|
754
|
-
stdio: "pipe",
|
|
755
|
-
timeout: 12e4,
|
|
756
|
-
...envOpts
|
|
757
|
-
});
|
|
758
|
-
} catch (err) {
|
|
759
|
-
throw sanitizeGitError(err);
|
|
760
|
-
}
|
|
761
|
-
execFileSync("git", [
|
|
762
|
-
"-C",
|
|
763
|
-
workDir,
|
|
764
|
-
"checkout",
|
|
765
|
-
sha
|
|
766
|
-
], {
|
|
767
|
-
stdio: "pipe",
|
|
768
|
-
timeout: 3e4,
|
|
769
|
-
...envOpts
|
|
932
|
+
await Promise.race([normalized.run(mergedCtx), new Promise((_, reject) => {
|
|
933
|
+
abortController.signal.addEventListener("abort", () => {
|
|
934
|
+
reject(/* @__PURE__ */ new Error(`Hook '${normalized.name}' timed out after ${timeoutMs}ms`));
|
|
770
935
|
});
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
936
|
+
})]);
|
|
937
|
+
clearTimeout(timeoutId);
|
|
938
|
+
sendIpc({
|
|
939
|
+
type: "step.complete",
|
|
940
|
+
stepIndex,
|
|
941
|
+
status: "success",
|
|
942
|
+
durationMs: Date.now() - startTime,
|
|
943
|
+
step_type: `hook:${hookType}`
|
|
944
|
+
});
|
|
945
|
+
return { success: true };
|
|
946
|
+
} catch (e) {
|
|
947
|
+
clearTimeout(timeoutId);
|
|
948
|
+
const durationMs = Date.now() - startTime;
|
|
949
|
+
const error = toErrorMessage(e);
|
|
950
|
+
sendIpc({
|
|
951
|
+
type: "step.complete",
|
|
952
|
+
stepIndex,
|
|
953
|
+
status: "failed",
|
|
954
|
+
durationMs,
|
|
955
|
+
error: { message: error },
|
|
956
|
+
step_type: `hook:${hookType}`
|
|
957
|
+
});
|
|
958
|
+
return {
|
|
959
|
+
success: false,
|
|
960
|
+
error
|
|
961
|
+
};
|
|
786
962
|
}
|
|
787
963
|
}
|
|
788
964
|
//#endregion
|
|
789
|
-
//#region src/execution/
|
|
965
|
+
//#region src/execution/rule-evaluator.ts
|
|
966
|
+
initZx();
|
|
790
967
|
/**
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
* Downloads a pre-built dependency tarball, verifies SHA-256 integrity,
|
|
794
|
-
* and extracts to .kici/node_modules/ in the work directory.
|
|
968
|
+
* Create RuleContext for agent-side rule evaluation.
|
|
795
969
|
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
970
|
+
* @param event - Event payload from the dispatch message
|
|
971
|
+
* @param changedFiles - List of files changed in this event
|
|
972
|
+
* @param env - Merged environment variables
|
|
973
|
+
*/
|
|
974
|
+
function createRuleContext(event, changedFiles = [], env = {}) {
|
|
975
|
+
return {
|
|
976
|
+
event,
|
|
977
|
+
changedFiles,
|
|
978
|
+
env,
|
|
979
|
+
$
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
//#endregion
|
|
983
|
+
//#region src/execution/sandbox/step-loop.ts
|
|
984
|
+
/**
|
|
985
|
+
* Execute a single step with timeout enforcement.
|
|
799
986
|
*
|
|
800
|
-
*
|
|
987
|
+
* Timeout pattern using Promise.race + AbortController, with IPC status reporting.
|
|
988
|
+
*/
|
|
989
|
+
async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, outputsMap, getSecretsAccessLog, getSecretMountRecords, jobDeadlineSignal) {
|
|
990
|
+
sendFn({
|
|
991
|
+
type: "step.start",
|
|
992
|
+
stepIndex,
|
|
993
|
+
stepName: step.name
|
|
994
|
+
});
|
|
995
|
+
const startTime = Date.now();
|
|
996
|
+
const abortController = new AbortController();
|
|
997
|
+
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
|
998
|
+
try {
|
|
999
|
+
const result = await Promise.race([
|
|
1000
|
+
step.run(ctx),
|
|
1001
|
+
new Promise((_, reject) => {
|
|
1002
|
+
abortController.signal.addEventListener("abort", () => {
|
|
1003
|
+
reject(/* @__PURE__ */ new Error(`Step '${step.name}' timed out after ${timeoutMs}ms`));
|
|
1004
|
+
});
|
|
1005
|
+
}),
|
|
1006
|
+
new Promise((_, reject) => {
|
|
1007
|
+
if (!jobDeadlineSignal) return;
|
|
1008
|
+
if (jobDeadlineSignal.aborted) {
|
|
1009
|
+
reject(/* @__PURE__ */ new Error(`Step '${step.name}' aborted: job timeout exceeded`));
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
jobDeadlineSignal.addEventListener("abort", () => {
|
|
1013
|
+
reject(/* @__PURE__ */ new Error(`Step '${step.name}' aborted: job timeout exceeded`));
|
|
1014
|
+
});
|
|
1015
|
+
})
|
|
1016
|
+
]);
|
|
1017
|
+
clearTimeout(timeoutId);
|
|
1018
|
+
const durationMs = Date.now() - startTime;
|
|
1019
|
+
const outputsPayload = result != null ? result : void 0;
|
|
1020
|
+
if (outputsPayload) outputsMap.set(step.name, outputsPayload);
|
|
1021
|
+
const secretsAccessed = getSecretsAccessLog?.();
|
|
1022
|
+
emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
|
|
1023
|
+
sendFn({
|
|
1024
|
+
type: "step.complete",
|
|
1025
|
+
stepIndex,
|
|
1026
|
+
status: ExecutionStepStatus.enum.success,
|
|
1027
|
+
durationMs,
|
|
1028
|
+
...outputsPayload && { outputs: outputsPayload },
|
|
1029
|
+
...secretsAccessed !== void 0 && { secretsAccessed }
|
|
1030
|
+
});
|
|
1031
|
+
return {
|
|
1032
|
+
name: step.name,
|
|
1033
|
+
stepIndex,
|
|
1034
|
+
status: ExecutionStepStatus.enum.success,
|
|
1035
|
+
durationMs,
|
|
1036
|
+
...outputsPayload && { outputs: outputsPayload }
|
|
1037
|
+
};
|
|
1038
|
+
} catch (e) {
|
|
1039
|
+
clearTimeout(timeoutId);
|
|
1040
|
+
const durationMs = Date.now() - startTime;
|
|
1041
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
1042
|
+
const exitCode = extractExitCode(e);
|
|
1043
|
+
const signal = extractSignal(e);
|
|
1044
|
+
const secretsAccessed = getSecretsAccessLog?.();
|
|
1045
|
+
emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
|
|
1046
|
+
sendFn({
|
|
1047
|
+
type: "step.complete",
|
|
1048
|
+
stepIndex,
|
|
1049
|
+
status: ExecutionStepStatus.enum.failed,
|
|
1050
|
+
durationMs,
|
|
1051
|
+
error: {
|
|
1052
|
+
message: error.message,
|
|
1053
|
+
...exitCode !== void 0 && { exitCode },
|
|
1054
|
+
...signal !== void 0 && { signal }
|
|
1055
|
+
},
|
|
1056
|
+
...secretsAccessed !== void 0 && { secretsAccessed }
|
|
1057
|
+
});
|
|
1058
|
+
return {
|
|
1059
|
+
name: step.name,
|
|
1060
|
+
stepIndex,
|
|
1061
|
+
status: ExecutionStepStatus.enum.failed,
|
|
1062
|
+
durationMs,
|
|
1063
|
+
error: {
|
|
1064
|
+
message: error.message,
|
|
1065
|
+
...exitCode !== void 0 && { exitCode },
|
|
1066
|
+
...signal !== void 0 && { signal }
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Emit one `step.secret_mount` IPC event per `mountFile` / `exposeFile` call
|
|
1073
|
+
* the step performed. Called from both the success and failure paths so the
|
|
1074
|
+
* orchestrator's audit trail records every mount regardless of step outcome.
|
|
801
1075
|
*/
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
const
|
|
1076
|
+
function emitSecretMountEvents(records, stepIndex, sendFn) {
|
|
1077
|
+
if (!records || records.length === 0) return;
|
|
1078
|
+
for (const record of records) sendFn({
|
|
1079
|
+
type: "step.secret_mount",
|
|
1080
|
+
stepIndex,
|
|
1081
|
+
sources: record.sources,
|
|
1082
|
+
target: record.target,
|
|
1083
|
+
kind: record.kind,
|
|
1084
|
+
...record.envVar !== void 0 && { envVar: record.envVar }
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
function extractExitCode(error) {
|
|
1088
|
+
if (error && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number") return error.exitCode;
|
|
1089
|
+
}
|
|
1090
|
+
function extractSignal(error) {
|
|
1091
|
+
if (error && typeof error === "object" && "signal" in error && typeof error.signal === "string") return error.signal;
|
|
1092
|
+
}
|
|
805
1093
|
/**
|
|
806
|
-
*
|
|
1094
|
+
* Evaluate step-level rules. Returns a 'skipped' result + emits IPC when a rule
|
|
1095
|
+
* fails; returns null when the step should run normally.
|
|
807
1096
|
*/
|
|
808
|
-
function
|
|
809
|
-
|
|
1097
|
+
async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
|
|
1098
|
+
if (!step.rules || step.rules.length === 0) return null;
|
|
1099
|
+
const ruleCtx = createRuleContext(opts.event, [], opts.env);
|
|
1100
|
+
const ruleResult = await evaluateRules(step.rules, ruleCtx, step.name);
|
|
1101
|
+
if (ruleResult.allPassed) return null;
|
|
1102
|
+
opts.sendIpc({
|
|
1103
|
+
type: "step.start",
|
|
1104
|
+
stepIndex,
|
|
1105
|
+
stepName: step.name
|
|
1106
|
+
});
|
|
1107
|
+
opts.sendIpc({
|
|
1108
|
+
type: "step.complete",
|
|
1109
|
+
stepIndex,
|
|
1110
|
+
status: ExecutionStepStatus.enum.failed,
|
|
1111
|
+
durationMs: 0
|
|
1112
|
+
});
|
|
1113
|
+
opts.sendIpc({
|
|
1114
|
+
type: "log.line",
|
|
1115
|
+
stepIndex,
|
|
1116
|
+
line: `[kici] Step '${step.name}' skipped: rule '${ruleResult.results.find((r) => !r.passed)?.label}' did not pass`
|
|
1117
|
+
});
|
|
1118
|
+
return {
|
|
1119
|
+
name: step.name,
|
|
1120
|
+
stepIndex,
|
|
1121
|
+
status: ExecutionStepStatus.enum.skipped,
|
|
1122
|
+
durationMs: 0
|
|
1123
|
+
};
|
|
810
1124
|
}
|
|
811
1125
|
/**
|
|
812
|
-
*
|
|
813
|
-
*
|
|
1126
|
+
* Manual approval gate for a step. When the step declares `requireApproval`
|
|
1127
|
+
* and the harness wired `awaitStepApproval`, block until the orchestrator
|
|
1128
|
+
* resolves the hold. Returns a failed `StepIterationOutcome` (breaking the
|
|
1129
|
+
* loop) on reject/expired; returns null when approved or when no gate applies.
|
|
814
1130
|
*/
|
|
815
|
-
async function
|
|
816
|
-
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1131
|
+
async function maybeGateStepApproval(step, stepIndex, opts) {
|
|
1132
|
+
if (step.requireApproval === void 0 || !opts.awaitStepApproval) return null;
|
|
1133
|
+
const normalized = normalizeRequireApproval(step.requireApproval);
|
|
1134
|
+
opts.sendIpc({
|
|
1135
|
+
type: "log.line",
|
|
1136
|
+
stepIndex,
|
|
1137
|
+
line: `[kici] Step '${step.name}' awaiting approval...`
|
|
1138
|
+
});
|
|
1139
|
+
const resolution = await opts.awaitStepApproval({
|
|
1140
|
+
stepIndex,
|
|
1141
|
+
stepName: step.name,
|
|
1142
|
+
clauses: normalized.clauses,
|
|
1143
|
+
reason: normalized.reason ?? `Approval required for step '${step.name}'`,
|
|
1144
|
+
timeoutSeconds: normalized.timeoutSeconds
|
|
1145
|
+
});
|
|
1146
|
+
if (resolution.outcome === "approved") {
|
|
1147
|
+
opts.sendIpc({
|
|
1148
|
+
type: "log.line",
|
|
1149
|
+
stepIndex,
|
|
1150
|
+
line: `[kici] Step '${step.name}' approved.`
|
|
1151
|
+
});
|
|
1152
|
+
return null;
|
|
1153
|
+
}
|
|
1154
|
+
const why = resolution.outcome === "expired" ? "approval expired" : `approval rejected${resolution.reason ? `: ${resolution.reason}` : ""}`;
|
|
1155
|
+
opts.sendIpc({
|
|
1156
|
+
type: "step.start",
|
|
1157
|
+
stepIndex,
|
|
1158
|
+
stepName: step.name
|
|
1159
|
+
});
|
|
1160
|
+
opts.sendIpc({
|
|
1161
|
+
type: "step.complete",
|
|
1162
|
+
stepIndex,
|
|
1163
|
+
status: ExecutionStepStatus.enum.failed,
|
|
1164
|
+
durationMs: 0
|
|
1165
|
+
});
|
|
1166
|
+
opts.sendIpc({
|
|
1167
|
+
type: "log.line",
|
|
1168
|
+
stepIndex,
|
|
1169
|
+
line: `[kici] Step '${step.name}' ${why}.`
|
|
1170
|
+
});
|
|
1171
|
+
await opts.disposeStepResources?.();
|
|
1172
|
+
return {
|
|
1173
|
+
result: {
|
|
1174
|
+
name: step.name,
|
|
1175
|
+
stepIndex,
|
|
1176
|
+
status: ExecutionStepStatus.enum.failed,
|
|
1177
|
+
durationMs: 0,
|
|
1178
|
+
error: { message: `Step '${step.name}' ${why}` }
|
|
1179
|
+
},
|
|
1180
|
+
shouldBreak: true,
|
|
1181
|
+
failedStepName: step.name
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Run a single observer hook (beforeStep / afterStep). Failures only emit a
|
|
1186
|
+
* log line — they never change job status. Centralises the per-call boilerplate
|
|
1187
|
+
* so the per-step body can stay flat.
|
|
1188
|
+
*/
|
|
1189
|
+
async function runObserverHook(args) {
|
|
1190
|
+
const { hook, hookType, step, stepIndex, hookStepIndex, failedStep, opts } = args;
|
|
1191
|
+
const outcome = buildOutcomeMetadata({
|
|
1192
|
+
status: failedStep !== void 0 ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
|
|
1193
|
+
stepOutputs: Object.fromEntries(opts.outputsMap),
|
|
1194
|
+
startTime: opts.startTime ?? Date.now(),
|
|
1195
|
+
...failedStep !== void 0 && { failedStep }
|
|
1196
|
+
});
|
|
1197
|
+
const hookResult = await executeHook({
|
|
1198
|
+
hook,
|
|
1199
|
+
stepContext: opts.createStepContext(stepIndex, step.name),
|
|
1200
|
+
outcome,
|
|
1201
|
+
hookType,
|
|
1202
|
+
stepIndex: hookStepIndex,
|
|
1203
|
+
sendIpc: opts.sendIpc,
|
|
1204
|
+
timeout: 3e5
|
|
1205
|
+
});
|
|
1206
|
+
if (!hookResult.success) opts.sendIpc({
|
|
1207
|
+
type: "log.line",
|
|
1208
|
+
stepIndex,
|
|
1209
|
+
line: `[kici] ${hookType} hook failed: ${hookResult.error} (continuing -- hooks are observers)`
|
|
823
1210
|
});
|
|
824
1211
|
}
|
|
825
1212
|
/**
|
|
826
|
-
*
|
|
1213
|
+
* Execute one iteration of the step loop: step rules → beforeStep → execute →
|
|
1214
|
+
* afterStep → failure-handling. Returns a typed outcome the loop uses to
|
|
1215
|
+
* accumulate results, decide whether to break, and remember the failed step.
|
|
827
1216
|
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
1217
|
+
* Wraps the per-step lifecycle in a `try / finally` that calls
|
|
1218
|
+
* `opts.disposeStepResources()` so per-step state (the
|
|
1219
|
+
* `ctx.secrets.mountFile` tmpdir, any env vars set via `exposeFile`) is
|
|
1220
|
+
* removed even when the step throws, times out, or rule-skips.
|
|
830
1221
|
*/
|
|
831
|
-
async function
|
|
832
|
-
const
|
|
833
|
-
if (
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1222
|
+
async function runStepIteration(step, stepIndex, opts) {
|
|
1223
|
+
const skippedResult = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
|
|
1224
|
+
if (skippedResult) {
|
|
1225
|
+
await opts.disposeStepResources?.();
|
|
1226
|
+
return {
|
|
1227
|
+
result: skippedResult,
|
|
1228
|
+
shouldBreak: false
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
const gate = await maybeGateStepApproval(step, stepIndex, opts);
|
|
1232
|
+
if (gate) return gate;
|
|
1233
|
+
if (opts.jobHooks?.beforeStep) await runObserverHook({
|
|
1234
|
+
hook: opts.jobHooks.beforeStep,
|
|
1235
|
+
hookType: "beforeStep",
|
|
1236
|
+
step,
|
|
1237
|
+
stepIndex,
|
|
1238
|
+
hookStepIndex: opts.steps.length + stepIndex * 2,
|
|
1239
|
+
opts
|
|
1240
|
+
});
|
|
1241
|
+
const stepCacheSpecs = opts.cachePhaseDeps ? normalizeCacheSpecs(step.cache) : [];
|
|
1242
|
+
const stepCacheRestore = stepCacheSpecs.length > 0 && opts.cachePhaseDeps ? await restoreCacheSpecs(stepCacheSpecs, opts.cachePhaseDeps) : /* @__PURE__ */ new Map();
|
|
1243
|
+
try {
|
|
1244
|
+
await opts.beforeStepEnvFiles?.();
|
|
1245
|
+
const ctx = opts.createStepContext(stepIndex, step.name);
|
|
1246
|
+
const timeoutMs = step.timeout ?? opts.defaultTimeoutMs;
|
|
1247
|
+
let result;
|
|
1248
|
+
try {
|
|
1249
|
+
result = await executeStepInLoop(step, stepIndex, ctx, timeoutMs, opts.sendIpc, opts.outputsMap, opts.getSecretsAccessLog, opts.getSecretMountRecords, opts.jobDeadlineSignal);
|
|
1250
|
+
} finally {
|
|
1251
|
+
await opts.afterStepApplyEnvFiles?.();
|
|
1252
|
+
}
|
|
1253
|
+
if (stepCacheSpecs.length > 0 && opts.cachePhaseDeps && result.status === ExecutionStepStatus.enum.success) await saveCacheSpecs(stepCacheSpecs, stepCacheRestore, opts.cachePhaseDeps);
|
|
1254
|
+
if (opts.jobHooks?.afterStep) await runObserverHook({
|
|
1255
|
+
hook: opts.jobHooks.afterStep,
|
|
1256
|
+
hookType: "afterStep",
|
|
1257
|
+
step,
|
|
1258
|
+
stepIndex,
|
|
1259
|
+
hookStepIndex: opts.steps.length + stepIndex * 2 + 1,
|
|
1260
|
+
failedStep: result.status === ExecutionStepStatus.enum.failed ? step.name : void 0,
|
|
1261
|
+
opts
|
|
1262
|
+
});
|
|
1263
|
+
if (result.status === ExecutionStepStatus.enum.failed) return {
|
|
1264
|
+
result,
|
|
1265
|
+
shouldBreak: !step.continueOnError,
|
|
1266
|
+
failedStepName: step.name
|
|
1267
|
+
};
|
|
1268
|
+
return {
|
|
1269
|
+
result,
|
|
1270
|
+
shouldBreak: false
|
|
1271
|
+
};
|
|
1272
|
+
} finally {
|
|
1273
|
+
await opts.disposeStepResources?.();
|
|
1274
|
+
}
|
|
844
1275
|
}
|
|
845
1276
|
/**
|
|
846
|
-
*
|
|
847
|
-
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
* dir prefix can't fall out of sync with the git-ignore wiring.
|
|
851
|
-
*/
|
|
852
|
-
const SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
|
|
853
|
-
/**
|
|
854
|
-
* Glob suitable for `.gitignore` / `.git/info/exclude` that matches every
|
|
855
|
-
* scratch dir created by `extractIntoScratch`, anchored to the workflow
|
|
856
|
-
* working tree's `.kici/` subdir.
|
|
857
|
-
*/
|
|
858
|
-
const SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
|
|
859
|
-
/**
|
|
860
|
-
* Extract the dep tarball into a per-attempt scratch dir so retries never race
|
|
861
|
-
* with still-draining I/O from a previous failed attempt.
|
|
862
|
-
*
|
|
863
|
-
* When `pipeline()` rejects on a network error or AbortSignal timeout, the
|
|
864
|
-
* underlying `tar.x` continues flushing pending file writes for an unbounded
|
|
865
|
-
* window after the promise settles — `pipeline` does not block on async
|
|
866
|
-
* filesystem side effects. If the next retry then runs `rm -rf` on the same
|
|
867
|
-
* `node_modules/`, the walk races with those writes and `rmdir` fails with
|
|
868
|
-
* ENOTEMPTY (new files keep appearing under a directory we just emptied).
|
|
869
|
-
*
|
|
870
|
-
* We sidestep the race entirely by extracting each attempt into a unique
|
|
871
|
-
* scratch dir under `.kici/`. Failed attempts leave orphan scratch dirs whose
|
|
872
|
-
* draining writes are harmless — the next attempt does not touch them. On
|
|
873
|
-
* success `moveScratchIntoRepo` renames the extracted entries into place
|
|
874
|
-
* (atomic on the same filesystem), then best-effort cleans the scratch dir.
|
|
875
|
-
*
|
|
876
|
-
* Scratch dirs land inside the customer's cloned working tree, so the clone
|
|
877
|
-
* phase registers `SCRATCH_DIR_GIT_EXCLUDE_GLOB` in `.git/info/exclude` to
|
|
878
|
-
* keep them out of `git status` for any workflow step that shells out to git.
|
|
879
|
-
* See `excludeScratchFromGit`.
|
|
1277
|
+
* Execute one named completion hook (onSuccess / onFailure / cleanup) and
|
|
1278
|
+
* return the updated `CompletionState`. Treated as the single source of truth
|
|
1279
|
+
* for the "promote to failed + concat reason" pattern that the three completion
|
|
1280
|
+
* hooks share.
|
|
880
1281
|
*/
|
|
881
|
-
async function
|
|
882
|
-
const
|
|
883
|
-
|
|
1282
|
+
async function runCompletionHook(args) {
|
|
1283
|
+
const { hook, hookType, hookStepIndex, outcome, state, promoteToFailed, opts } = args;
|
|
1284
|
+
opts.sendIpc({
|
|
1285
|
+
type: "log.line",
|
|
1286
|
+
stepIndex: -1,
|
|
1287
|
+
line: `[kici] Running ${hookType} hook...`
|
|
1288
|
+
});
|
|
1289
|
+
const hookResult = await executeHook({
|
|
1290
|
+
hook,
|
|
1291
|
+
stepContext: opts.createStepContext(hookStepIndex, hookType),
|
|
1292
|
+
outcome,
|
|
1293
|
+
hookType,
|
|
1294
|
+
stepIndex: hookStepIndex,
|
|
1295
|
+
sendIpc: opts.sendIpc
|
|
1296
|
+
});
|
|
1297
|
+
if (hookResult.success) {
|
|
1298
|
+
opts.sendIpc({
|
|
1299
|
+
type: "log.line",
|
|
1300
|
+
stepIndex: -1,
|
|
1301
|
+
line: `[kici] ${hookType} hook completed`
|
|
1302
|
+
});
|
|
1303
|
+
return state;
|
|
1304
|
+
}
|
|
1305
|
+
opts.sendIpc({
|
|
1306
|
+
type: "log.line",
|
|
1307
|
+
stepIndex: -1,
|
|
1308
|
+
line: `[kici] ${hookType} hook failed: ${hookResult.error}`
|
|
1309
|
+
});
|
|
1310
|
+
const reasonFragment = `Hook ${hookType} failed: ${hookResult.error}`;
|
|
884
1311
|
return {
|
|
885
|
-
|
|
886
|
-
|
|
1312
|
+
failed: state.failed || promoteToFailed,
|
|
1313
|
+
failedStepName: state.failedStepName,
|
|
1314
|
+
failureReason: state.failureReason ? `${state.failureReason}; ${reasonFragment}` : reasonFragment
|
|
887
1315
|
};
|
|
888
1316
|
}
|
|
889
1317
|
/**
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1318
|
+
* Run the job-completion hook sequence after the per-step loop ends:
|
|
1319
|
+
* onSuccess (or onFailure), then cleanup (always). The cleanup outcome is
|
|
1320
|
+
* recomputed so it reflects any failures introduced by onSuccess/onFailure.
|
|
1321
|
+
*/
|
|
1322
|
+
async function runJobCompletionHooks(opts, initial, outputsMap, startTime) {
|
|
1323
|
+
const { jobHooks } = opts;
|
|
1324
|
+
const initialFinalStatus = initial.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success;
|
|
1325
|
+
const jobOutcome = buildOutcomeMetadata({
|
|
1326
|
+
status: initialFinalStatus,
|
|
1327
|
+
stepOutputs: Object.fromEntries(outputsMap),
|
|
1328
|
+
startTime,
|
|
1329
|
+
...initial.failedStepName && {
|
|
1330
|
+
failedStep: initial.failedStepName,
|
|
1331
|
+
reason: `Step '${initial.failedStepName}' failed`
|
|
1332
|
+
}
|
|
1333
|
+
});
|
|
1334
|
+
let state = initial;
|
|
1335
|
+
let hookStepIndex = opts.steps.length;
|
|
1336
|
+
if (initialFinalStatus === ExecutionStepStatus.enum.success && jobHooks?.onSuccess) {
|
|
1337
|
+
state = await runCompletionHook({
|
|
1338
|
+
hook: jobHooks.onSuccess,
|
|
1339
|
+
hookType: "onSuccess",
|
|
1340
|
+
hookStepIndex,
|
|
1341
|
+
outcome: jobOutcome,
|
|
1342
|
+
state,
|
|
1343
|
+
promoteToFailed: true,
|
|
1344
|
+
opts
|
|
1345
|
+
});
|
|
1346
|
+
hookStepIndex++;
|
|
1347
|
+
} else if (initialFinalStatus === ExecutionStepStatus.enum.failed && jobHooks?.onFailure) {
|
|
1348
|
+
state = await runCompletionHook({
|
|
1349
|
+
hook: jobHooks.onFailure,
|
|
1350
|
+
hookType: "onFailure",
|
|
1351
|
+
hookStepIndex,
|
|
1352
|
+
outcome: jobOutcome,
|
|
1353
|
+
state,
|
|
1354
|
+
promoteToFailed: false,
|
|
1355
|
+
opts
|
|
1356
|
+
});
|
|
1357
|
+
hookStepIndex++;
|
|
1358
|
+
}
|
|
1359
|
+
if (jobHooks?.cleanup) {
|
|
1360
|
+
const cleanupOutcome = buildOutcomeMetadata({
|
|
1361
|
+
status: state.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
|
|
1362
|
+
stepOutputs: Object.fromEntries(outputsMap),
|
|
1363
|
+
startTime,
|
|
1364
|
+
...state.failedStepName && { failedStep: state.failedStepName },
|
|
1365
|
+
...state.failureReason && { reason: state.failureReason }
|
|
1366
|
+
});
|
|
1367
|
+
state = await runCompletionHook({
|
|
1368
|
+
hook: jobHooks.cleanup,
|
|
1369
|
+
hookType: "cleanup",
|
|
1370
|
+
hookStepIndex,
|
|
1371
|
+
outcome: cleanupOutcome,
|
|
1372
|
+
state,
|
|
1373
|
+
promoteToFailed: true,
|
|
1374
|
+
opts
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
return state;
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Execute the step loop with hook integration and step-level rule evaluation.
|
|
912
1381
|
*
|
|
913
|
-
*
|
|
914
|
-
*
|
|
1382
|
+
* Hook execution order:
|
|
1383
|
+
* - beforeStep -> step -> afterStep (per step)
|
|
1384
|
+
* - onSuccess or onFailure (after all steps)
|
|
1385
|
+
* - cleanup (always, after onSuccess/onFailure)
|
|
915
1386
|
*
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
* global workflows it is the workflow repo dir (whose `.kici/` carries
|
|
919
|
-
* the scratch dirs).
|
|
1387
|
+
* Hooks are observers: beforeStep/afterStep failures do NOT affect step execution.
|
|
1388
|
+
* Only completion hooks (onSuccess/onFailure/cleanup) can change job status to failed.
|
|
920
1389
|
*/
|
|
921
|
-
async function
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
await
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
}
|
|
1390
|
+
async function executeStepLoop(opts) {
|
|
1391
|
+
const startTime = opts.startTime ?? Date.now();
|
|
1392
|
+
const stepResults = [];
|
|
1393
|
+
const state = { failed: false };
|
|
1394
|
+
for (const [i, step] of opts.steps.entries()) {
|
|
1395
|
+
if (opts.isAborted?.()) break;
|
|
1396
|
+
const outcome = await runStepIteration(step, i, opts);
|
|
1397
|
+
stepResults.push(outcome.result);
|
|
1398
|
+
if (outcome.failedStepName) {
|
|
1399
|
+
state.failed = true;
|
|
1400
|
+
state.failedStepName = outcome.failedStepName;
|
|
1401
|
+
}
|
|
1402
|
+
if (outcome.shouldBreak) break;
|
|
933
1403
|
}
|
|
1404
|
+
if (opts.isAborted?.()) return {
|
|
1405
|
+
status: "aborted",
|
|
1406
|
+
stepResults,
|
|
1407
|
+
failureReason: state.failureReason ?? (state.failed ? `Step '${state.failedStepName}' failed` : void 0)
|
|
1408
|
+
};
|
|
1409
|
+
const finalState = await runJobCompletionHooks(opts, state, opts.outputsMap, startTime);
|
|
1410
|
+
return {
|
|
1411
|
+
status: finalState.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
|
|
1412
|
+
stepResults,
|
|
1413
|
+
failureReason: finalState.failureReason
|
|
1414
|
+
};
|
|
934
1415
|
}
|
|
1416
|
+
//#endregion
|
|
1417
|
+
//#region src/execution/env-init/init-phase.ts
|
|
1418
|
+
/** Default init timeout when a spec sets none: 10 minutes. */
|
|
1419
|
+
const DEFAULT_INIT_TIMEOUT_MS = 600 * 1e3;
|
|
935
1420
|
/**
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
*
|
|
939
|
-
* but agent containers can't reach localhost. This utility replaces the
|
|
940
|
-
* host with the orchestrator's host derived from KICI_ORCHESTRATOR_URL.
|
|
1421
|
+
* Marker thrown when an init command exceeds its wall-clock budget. Carries the
|
|
1422
|
+
* distinct P3 timeout reason so the phase result + job.complete report a timeout
|
|
1423
|
+
* rather than a generic failure.
|
|
941
1424
|
*/
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1425
|
+
var InitTimeoutError = class extends Error {
|
|
1426
|
+
reason = TimeoutReason.enum.job_timeout;
|
|
1427
|
+
constructor(timeoutMs) {
|
|
1428
|
+
super(`init command exceeded its timeout of ${timeoutMs}ms`);
|
|
1429
|
+
this.name = "InitTimeoutError";
|
|
1430
|
+
}
|
|
1431
|
+
};
|
|
1432
|
+
/** Run all init specs in order; stop + fail at the first non-zero / timeout. */
|
|
1433
|
+
async function runInitPhase(opts) {
|
|
1434
|
+
if (!opts.specs || opts.specs.length === 0) return { ok: true };
|
|
1435
|
+
for (let i = 0; i < opts.specs.length; i++) {
|
|
1436
|
+
const spec = opts.specs[i];
|
|
1437
|
+
const stepIndex = opts.stepIndexBase + i;
|
|
1438
|
+
const stepType = `init:${i}`;
|
|
1439
|
+
const outcome = await runOneInit(spec, i, stepIndex, stepType, opts);
|
|
1440
|
+
if (!outcome.ok) return {
|
|
1441
|
+
ok: false,
|
|
1442
|
+
failedInitIndex: i,
|
|
1443
|
+
error: outcome.error,
|
|
1444
|
+
...outcome.timedOut && {
|
|
1445
|
+
timedOut: true,
|
|
1446
|
+
reason: outcome.reason
|
|
1447
|
+
}
|
|
1448
|
+
};
|
|
953
1449
|
}
|
|
1450
|
+
return { ok: true };
|
|
954
1451
|
}
|
|
955
1452
|
/**
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
* already exists in the work tree (cloned or source-restored), so its children
|
|
961
|
-
* are moved individually; every other top-level entry is moved wholesale.
|
|
962
|
-
*
|
|
963
|
-
* On a cache-hit execution agent the destinations do not pre-exist (source
|
|
964
|
-
* restore excludes node_modules and never carries sibling dirs), so the renames
|
|
965
|
-
* have nothing to race; the defensive `rm` covers re-runs.
|
|
1453
|
+
* Run a promise against a wall-clock budget. Aborts via an AbortController on
|
|
1454
|
+
* breach (mirroring the step loop) and rejects with an {@link InitTimeoutError}
|
|
1455
|
+
* carrying the distinct P3 timeout reason. Resolves with the command's value
|
|
1456
|
+
* when it finishes first.
|
|
966
1457
|
*/
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1458
|
+
function withInitTimeout(run, timeoutMs) {
|
|
1459
|
+
const ac = new AbortController();
|
|
1460
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
1461
|
+
return Promise.race([run, new Promise((_, reject) => {
|
|
1462
|
+
ac.signal.addEventListener("abort", () => reject(new InitTimeoutError(timeoutMs)));
|
|
1463
|
+
})]).finally(() => clearTimeout(timer));
|
|
972
1464
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1465
|
+
async function runOneInit(spec, index, stepIndex, stepType, opts) {
|
|
1466
|
+
opts.sendIpc({
|
|
1467
|
+
type: "step.start",
|
|
1468
|
+
stepIndex,
|
|
1469
|
+
stepName: stepType,
|
|
1470
|
+
step_type: stepType
|
|
979
1471
|
});
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
/** Best-effort cleanup of a settled scratch dir; logs and continues on failure. */
|
|
983
|
-
async function cleanupScratch(scratchDir) {
|
|
1472
|
+
const start = Date.now();
|
|
1473
|
+
const shell = spec.shell ?? "bash";
|
|
984
1474
|
try {
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1475
|
+
let cacheHit = false;
|
|
1476
|
+
if (spec.cache && opts.cache) cacheHit = (await opts.cache.restore(spec.cache)).hit;
|
|
1477
|
+
await opts.env?.beginCapture();
|
|
1478
|
+
const $ = opts.shellFor(spec, index);
|
|
1479
|
+
const timeoutMs = spec.timeout ?? DEFAULT_INIT_TIMEOUT_MS;
|
|
1480
|
+
await withInitTimeout($`${shell} -c ${spec.run}`, timeoutMs);
|
|
1481
|
+
if (spec.cache && opts.cache && !cacheHit) await opts.cache.save(spec.cache);
|
|
1482
|
+
await opts.env?.applyDelta();
|
|
1483
|
+
opts.sendIpc({
|
|
1484
|
+
type: "step.complete",
|
|
1485
|
+
stepIndex,
|
|
1486
|
+
status: ExecutionStepStatus.enum.success,
|
|
1487
|
+
durationMs: Date.now() - start,
|
|
1488
|
+
step_type: stepType
|
|
988
1489
|
});
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1490
|
+
return { ok: true };
|
|
1491
|
+
} catch (e) {
|
|
1492
|
+
const error = toErrorMessage(e);
|
|
1493
|
+
const timedOut = e instanceof InitTimeoutError;
|
|
1494
|
+
const exitCode = e && typeof e === "object" && "exitCode" in e && typeof e.exitCode === "number" ? e.exitCode : void 0;
|
|
1495
|
+
opts.sendIpc({
|
|
1496
|
+
type: "step.complete",
|
|
1497
|
+
stepIndex,
|
|
1498
|
+
status: ExecutionStepStatus.enum.failed,
|
|
1499
|
+
durationMs: Date.now() - start,
|
|
1500
|
+
error: {
|
|
1501
|
+
message: error,
|
|
1502
|
+
...exitCode !== void 0 && { exitCode }
|
|
1503
|
+
},
|
|
1504
|
+
step_type: stepType
|
|
993
1505
|
});
|
|
1506
|
+
return {
|
|
1507
|
+
ok: false,
|
|
1508
|
+
error,
|
|
1509
|
+
...timedOut && {
|
|
1510
|
+
timedOut: true,
|
|
1511
|
+
reason: e.reason
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
994
1514
|
}
|
|
995
1515
|
}
|
|
1516
|
+
//#endregion
|
|
1517
|
+
//#region src/execution/sandbox/job-deadline.ts
|
|
996
1518
|
/**
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1519
|
+
* Arm a job-level wall-clock deadline. When `timeoutMs` is set and elapses
|
|
1520
|
+
* before clear() is called, invokes `onTimeout` with the distinct
|
|
1521
|
+
* `job_timeout` reason and the configured budget. A no-op when `timeoutMs`
|
|
1522
|
+
* is undefined (no job-level cap configured).
|
|
1523
|
+
*/
|
|
1524
|
+
function armJobDeadline(timeoutMs, onTimeout) {
|
|
1525
|
+
if (timeoutMs === void 0 || timeoutMs <= 0) return { clear: () => {} };
|
|
1526
|
+
const timer = setTimeout(() => {
|
|
1527
|
+
onTimeout(TimeoutReason.enum.job_timeout, timeoutMs);
|
|
1528
|
+
}, timeoutMs);
|
|
1529
|
+
timer.unref?.();
|
|
1530
|
+
return { clear: () => clearTimeout(timer) };
|
|
1531
|
+
}
|
|
1532
|
+
//#endregion
|
|
1533
|
+
//#region src/checkout/ssh-auth.ts
|
|
1534
|
+
/**
|
|
1535
|
+
* Materialize an SSH private key (and optional pinned known_hosts) into a
|
|
1536
|
+
* tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
|
|
1004
1537
|
*
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1538
|
+
* Permissions:
|
|
1539
|
+
* - private key mode 0o600 (required by OpenSSH — refuses to use world-
|
|
1540
|
+
* readable keys).
|
|
1541
|
+
* - known_hosts mode 0o600.
|
|
1542
|
+
* - tempdir mode 0o700.
|
|
1008
1543
|
*
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1544
|
+
* SSH flags composed:
|
|
1545
|
+
* - `-i <keyfile>` — identity file.
|
|
1546
|
+
* - `-o IdentitiesOnly=yes` — don't try other keys from ssh-agent / ~/.ssh.
|
|
1547
|
+
* - `-o BatchMode=yes` — never prompt for passwords / passphrases.
|
|
1548
|
+
* - host-key checking flags based on `hostKeyPolicy`.
|
|
1012
1549
|
*/
|
|
1013
|
-
async function
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1550
|
+
async function setupSshAuth(opts) {
|
|
1551
|
+
if (opts.hostKeyPolicy === "pinned" && !opts.knownHosts) throw new Error("pinned hostKeyPolicy requires knownHosts content");
|
|
1552
|
+
const tempDir = await mkdtemp(join(tmpdir(), "kici-ssh-"));
|
|
1553
|
+
const keyPath = join(tempDir, "id");
|
|
1554
|
+
await writeFile(keyPath, opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`, { mode: 384 });
|
|
1555
|
+
const knownHostsPath = join(tempDir, "known_hosts");
|
|
1556
|
+
await writeFile(knownHostsPath, opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "", { mode: 384 });
|
|
1557
|
+
const parts = [
|
|
1558
|
+
"ssh",
|
|
1559
|
+
"-i",
|
|
1560
|
+
escapeShellArg(keyPath),
|
|
1561
|
+
"-o",
|
|
1562
|
+
"IdentitiesOnly=yes",
|
|
1563
|
+
"-o",
|
|
1564
|
+
"BatchMode=yes",
|
|
1565
|
+
"-o",
|
|
1566
|
+
`UserKnownHostsFile=${escapeShellArg(knownHostsPath)}`
|
|
1567
|
+
];
|
|
1568
|
+
if (opts.hostKeyPolicy === "pinned") parts.push("-o", "StrictHostKeyChecking=yes");
|
|
1569
|
+
else parts.push("-o", "StrictHostKeyChecking=accept-new");
|
|
1570
|
+
return {
|
|
1571
|
+
gitSshCommand: parts.join(" "),
|
|
1572
|
+
tempDir,
|
|
1573
|
+
async cleanup() {
|
|
1574
|
+
await rm(tempDir, {
|
|
1575
|
+
recursive: true,
|
|
1576
|
+
force: true
|
|
1577
|
+
});
|
|
1023
1578
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Quote a path for inclusion in `GIT_SSH_COMMAND`. We use single-quote
|
|
1583
|
+
* wrapping so backslashes and spaces survive git's shell-parse of the
|
|
1584
|
+
* command value.
|
|
1585
|
+
*/
|
|
1586
|
+
function escapeShellArg(value) {
|
|
1587
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
1588
|
+
}
|
|
1589
|
+
//#endregion
|
|
1590
|
+
//#region src/checkout/git-clone.ts
|
|
1591
|
+
/**
|
|
1592
|
+
* Strip auth credentials from git error messages to prevent token leakage.
|
|
1593
|
+
* Node's execFileSync includes the full command line (including -c http.extraHeader
|
|
1594
|
+
* and GIT_SSH_COMMAND flags) in error messages, which would expose Base64-encoded
|
|
1595
|
+
* tokens or the absolute path of a temporary SSH key file in logs.
|
|
1596
|
+
*/
|
|
1597
|
+
function sanitizeGitError(error) {
|
|
1598
|
+
if (!(error instanceof Error)) return new Error(String(error));
|
|
1599
|
+
const sanitized = new Error(redactSensitive(error.message));
|
|
1600
|
+
sanitized.stack = error.stack ? redactSensitive(error.stack) : void 0;
|
|
1601
|
+
return sanitized;
|
|
1602
|
+
}
|
|
1603
|
+
function redactSensitive(input) {
|
|
1604
|
+
return input.replace(/http\.extraHeader=Authorization: Basic \S+/g, "http.extraHeader=Authorization: Basic [REDACTED]").replace(/'[^']*kici-ssh-[^']*'/g, "'[REDACTED_SSH_PATH]'").replace(/\S*kici-ssh-\S+/g, "[REDACTED_SSH_PATH]");
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Shallow-clone a git repository at a specific ref with optional token auth.
|
|
1608
|
+
*
|
|
1609
|
+
* Token authentication uses git's `-c http.extraHeader` mechanism which keeps
|
|
1610
|
+
* the token out of the clone URL (not visible in `git remote -v` or logs).
|
|
1611
|
+
*
|
|
1612
|
+
* After clone, verifies that HEAD matches the expected SHA to prevent
|
|
1613
|
+
* wrong-ref execution.
|
|
1614
|
+
*
|
|
1615
|
+
* @throws Error if clone fails or SHA does not match
|
|
1616
|
+
*/
|
|
1617
|
+
async function gitClone(options) {
|
|
1618
|
+
const { repoUrl, ref, sha, workDir, token, gitAuth, depth = 1 } = options;
|
|
1619
|
+
const auth = gitAuth ? gitAuth : token ? {
|
|
1620
|
+
kind: "basic",
|
|
1621
|
+
user: "x-access-token",
|
|
1622
|
+
secret: token
|
|
1623
|
+
} : void 0;
|
|
1624
|
+
const args = [];
|
|
1625
|
+
const envEntries = {};
|
|
1626
|
+
let needsCustomEnv = false;
|
|
1627
|
+
let safeDirCleanup;
|
|
1628
|
+
if (repoUrl.startsWith("file://")) {
|
|
1629
|
+
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
|
1630
|
+
const { tmpdir } = await import("node:os");
|
|
1631
|
+
const path = await import("node:path");
|
|
1632
|
+
const dir = await mkdtemp(path.join(tmpdir(), "kici-gitcfg-"));
|
|
1633
|
+
const cfgPath = path.join(dir, "config");
|
|
1634
|
+
await writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
|
|
1635
|
+
envEntries.GIT_CONFIG_GLOBAL = cfgPath;
|
|
1636
|
+
needsCustomEnv = true;
|
|
1637
|
+
safeDirCleanup = async () => {
|
|
1638
|
+
await rm(dir, {
|
|
1639
|
+
recursive: true,
|
|
1640
|
+
force: true
|
|
1641
|
+
}).catch(() => {});
|
|
1642
|
+
};
|
|
1034
1643
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
})
|
|
1644
|
+
let sshSetup;
|
|
1645
|
+
try {
|
|
1646
|
+
if (auth?.kind === "basic") {
|
|
1647
|
+
const user = auth.user ?? "x-access-token";
|
|
1648
|
+
const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
|
|
1649
|
+
args.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
|
|
1650
|
+
} else if (auth?.kind === "ssh") {
|
|
1651
|
+
sshSetup = await setupSshAuth({
|
|
1652
|
+
privateKey: auth.secret,
|
|
1653
|
+
hostKeyPolicy: auth.sshHostKeyPolicy,
|
|
1654
|
+
knownHosts: auth.sshKnownHostsPem
|
|
1655
|
+
});
|
|
1656
|
+
envEntries.GIT_SSH_COMMAND = sshSetup.gitSshCommand;
|
|
1657
|
+
needsCustomEnv = true;
|
|
1658
|
+
}
|
|
1659
|
+
const env = needsCustomEnv ? {
|
|
1660
|
+
...process.env,
|
|
1661
|
+
...envEntries
|
|
1662
|
+
} : void 0;
|
|
1663
|
+
if (ref) args.push("clone", "--depth", String(depth), "--branch", ref, repoUrl, workDir);
|
|
1664
|
+
else args.push("clone", "--depth", String(depth), repoUrl, workDir);
|
|
1665
|
+
const { execFileSync } = await import("node:child_process");
|
|
1042
1666
|
try {
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
return;
|
|
1667
|
+
execFileSync("git", args, {
|
|
1668
|
+
stdio: "pipe",
|
|
1669
|
+
timeout: 12e4,
|
|
1670
|
+
...env && { env }
|
|
1671
|
+
});
|
|
1049
1672
|
} catch (err) {
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1673
|
+
throw sanitizeGitError(err);
|
|
1674
|
+
}
|
|
1675
|
+
if (!sha || sha === "HEAD") return;
|
|
1676
|
+
const envOpts = env ? { env } : {};
|
|
1677
|
+
if (!execFileSync("git", [
|
|
1678
|
+
"-C",
|
|
1679
|
+
workDir,
|
|
1680
|
+
"rev-parse",
|
|
1681
|
+
"HEAD"
|
|
1682
|
+
], {
|
|
1683
|
+
encoding: "utf-8",
|
|
1684
|
+
timeout: 1e4,
|
|
1685
|
+
...envOpts
|
|
1686
|
+
}).trim().startsWith(sha)) {
|
|
1687
|
+
const fetchArgs = [];
|
|
1688
|
+
if (auth?.kind === "basic") {
|
|
1689
|
+
const user = auth.user ?? "x-access-token";
|
|
1690
|
+
const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
|
|
1691
|
+
fetchArgs.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
|
|
1692
|
+
}
|
|
1693
|
+
fetchArgs.push("fetch", "--depth", "50", "origin", sha);
|
|
1694
|
+
try {
|
|
1695
|
+
execFileSync("git", [
|
|
1696
|
+
"-C",
|
|
1697
|
+
workDir,
|
|
1698
|
+
...fetchArgs
|
|
1699
|
+
], {
|
|
1700
|
+
stdio: "pipe",
|
|
1701
|
+
timeout: 12e4,
|
|
1702
|
+
...envOpts
|
|
1703
|
+
});
|
|
1704
|
+
} catch (err) {
|
|
1705
|
+
throw sanitizeGitError(err);
|
|
1706
|
+
}
|
|
1707
|
+
execFileSync("git", [
|
|
1708
|
+
"-C",
|
|
1709
|
+
workDir,
|
|
1710
|
+
"checkout",
|
|
1711
|
+
sha
|
|
1712
|
+
], {
|
|
1713
|
+
stdio: "pipe",
|
|
1714
|
+
timeout: 3e4,
|
|
1715
|
+
...envOpts
|
|
1054
1716
|
});
|
|
1717
|
+
const recheckedSha = execFileSync("git", [
|
|
1718
|
+
"-C",
|
|
1719
|
+
workDir,
|
|
1720
|
+
"rev-parse",
|
|
1721
|
+
"HEAD"
|
|
1722
|
+
], {
|
|
1723
|
+
encoding: "utf-8",
|
|
1724
|
+
timeout: 1e4,
|
|
1725
|
+
...envOpts
|
|
1726
|
+
}).trim();
|
|
1727
|
+
if (!recheckedSha.startsWith(sha)) throw new Error(`SHA mismatch: expected ${sha}, got ${recheckedSha}`);
|
|
1055
1728
|
}
|
|
1729
|
+
} finally {
|
|
1730
|
+
if (sshSetup) await sshSetup.cleanup().catch(() => {});
|
|
1731
|
+
if (safeDirCleanup) await safeDirCleanup();
|
|
1056
1732
|
}
|
|
1057
|
-
throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
|
|
1058
1733
|
}
|
|
1059
1734
|
//#endregion
|
|
1060
1735
|
//#region src/execution/npm-resolver.ts
|
|
@@ -1565,8 +2240,8 @@ function logSubprocessStreams(e, tokens) {
|
|
|
1565
2240
|
* no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
|
|
1566
2241
|
* Node's normal ESM lookup against `.kici/node_modules/`.
|
|
1567
2242
|
*/
|
|
1568
|
-
const AGENT_SDK_VERSION = "0.1.
|
|
1569
|
-
const AGENT_SDK_BUNDLE_HASH = "
|
|
2243
|
+
const AGENT_SDK_VERSION = "0.1.16";
|
|
2244
|
+
const AGENT_SDK_BUNDLE_HASH = "cd4b8e0d91efe578c96e7c746f89b44a3df8fcaebaec2185399168ef8eb4ebca";
|
|
1570
2245
|
/**
|
|
1571
2246
|
* Register the `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook so
|
|
1572
2247
|
* subsequent dynamic `import()` calls for `.ts` / `.tsx` files transform on the
|
|
@@ -1727,40 +2402,6 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
|
|
|
1727
2402
|
throw new Error(`Generated job '${jobName}' not found in DynamicJobFn output (workflow '${workflow.name}', index ${dynamicIndex}). Available: ${actualNames.join(", ")}`);
|
|
1728
2403
|
}
|
|
1729
2404
|
//#endregion
|
|
1730
|
-
//#region src/execution/download.ts
|
|
1731
|
-
/**
|
|
1732
|
-
* Shared HTTP/HTTPS download utility.
|
|
1733
|
-
*
|
|
1734
|
-
* Extracted from workflow-loader.ts to avoid duplication across
|
|
1735
|
-
* dep-restore.ts and workflow-loader.ts.
|
|
1736
|
-
*/
|
|
1737
|
-
/** Download timeout: 5 minutes. */
|
|
1738
|
-
const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
|
|
1739
|
-
/**
|
|
1740
|
-
* Download content from an HTTP/HTTPS URL.
|
|
1741
|
-
*
|
|
1742
|
-
* Includes a 5-minute timeout to prevent the agent from hanging indefinitely
|
|
1743
|
-
* on slow or unresponsive endpoints.
|
|
1744
|
-
*
|
|
1745
|
-
* @param url - The URL to download from
|
|
1746
|
-
* @returns The response body as a Buffer
|
|
1747
|
-
*/
|
|
1748
|
-
function downloadUrl(url) {
|
|
1749
|
-
return new Promise((resolve, reject) => {
|
|
1750
|
-
(url.startsWith("https:") ? https : http).get(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }, (res) => {
|
|
1751
|
-
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
|
1752
|
-
reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} downloading from ${url}`));
|
|
1753
|
-
res.resume();
|
|
1754
|
-
return;
|
|
1755
|
-
}
|
|
1756
|
-
const chunks = [];
|
|
1757
|
-
res.on("data", (chunk) => chunks.push(chunk));
|
|
1758
|
-
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1759
|
-
res.on("error", reject);
|
|
1760
|
-
}).on("error", reject);
|
|
1761
|
-
});
|
|
1762
|
-
}
|
|
1763
|
-
//#endregion
|
|
1764
2405
|
//#region src/execution/source-restore.ts
|
|
1765
2406
|
/**
|
|
1766
2407
|
* `.kici/` source tarball restoration for execution agents.
|
|
@@ -1783,6 +2424,8 @@ function downloadUrl(url) {
|
|
|
1783
2424
|
* runs without a preceding build (cache infrastructure unavailable, or a
|
|
1784
2425
|
* build job that failed but left dynamic dispatch in flight).
|
|
1785
2426
|
*/
|
|
2427
|
+
init_download();
|
|
2428
|
+
init_dep_restore();
|
|
1786
2429
|
const logger$1 = createLogger({ prefix: "source-restore" });
|
|
1787
2430
|
async function extractSourceTarball(data, targetDir) {
|
|
1788
2431
|
await mkdir(targetDir, { recursive: true });
|
|
@@ -1824,6 +2467,7 @@ async function restoreSource(workDir, sourceTarUrl) {
|
|
|
1824
2467
|
* Wire format: [12-byte IV][16-byte auth tag][ciphertext]
|
|
1825
2468
|
* Same encryption scheme as packages/compiler/src/remote/encryption.ts.
|
|
1826
2469
|
*/
|
|
2470
|
+
init_download();
|
|
1827
2471
|
const logger = createLogger({ prefix: "overlay-applier" });
|
|
1828
2472
|
const IV_LENGTH = 12;
|
|
1829
2473
|
const AUTH_TAG_LENGTH = 16;
|
|
@@ -1957,6 +2601,7 @@ async function applyOverlay(config) {
|
|
|
1957
2601
|
* This file is compiled alongside the agent by rolldown (existing build), but
|
|
1958
2602
|
* runs as a SEPARATE process spawned by the sandbox backend.
|
|
1959
2603
|
*/
|
|
2604
|
+
init_dep_restore();
|
|
1960
2605
|
process.on("uncaughtException", (err) => {
|
|
1961
2606
|
process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
|
|
1962
2607
|
if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
|
|
@@ -2139,6 +2784,21 @@ let aborted = false;
|
|
|
2139
2784
|
*/
|
|
2140
2785
|
let forceAborted = false;
|
|
2141
2786
|
/**
|
|
2787
|
+
* Set when the job-level wall-clock deadline (the lock job's `timeout`) is
|
|
2788
|
+
* breached. Trips the same abort path cancellation uses, but tags the
|
|
2789
|
+
* job.complete with the distinct TimeoutReason.job_timeout reason.
|
|
2790
|
+
*/
|
|
2791
|
+
let jobTimedOut = false;
|
|
2792
|
+
/** The configured job timeout budget in ms, captured when the deadline fires. */
|
|
2793
|
+
let jobTimedOutMs;
|
|
2794
|
+
/**
|
|
2795
|
+
* Aborted when the job-level deadline fires. Threaded into the step loop so the
|
|
2796
|
+
* IN-FLIGHT step (e.g. a long-running `sleep`) is interrupted immediately on
|
|
2797
|
+
* breach — the between-steps `isAborted()` check alone cannot unwind a single
|
|
2798
|
+
* long step that has no per-step `timeout`.
|
|
2799
|
+
*/
|
|
2800
|
+
const jobDeadlineAbort = new AbortController();
|
|
2801
|
+
/**
|
|
2142
2802
|
* Pending promises for event.emit requests awaiting responses from the agent.
|
|
2143
2803
|
*
|
|
2144
2804
|
* Key: requestId (correlates EventEmitRequest -> EventEmitResponse)
|
|
@@ -2245,6 +2905,190 @@ function waitForApiResponse(requestId) {
|
|
|
2245
2905
|
});
|
|
2246
2906
|
}
|
|
2247
2907
|
/**
|
|
2908
|
+
* Pending promises for cache.response messages from the agent.
|
|
2909
|
+
* Key: requestId (correlates cache.request -> cache.response).
|
|
2910
|
+
*/
|
|
2911
|
+
const pendingCacheResponses = /* @__PURE__ */ new Map();
|
|
2912
|
+
/** Default timeout for a cache request relay (matches the upload-URL request budget). */
|
|
2913
|
+
const CACHE_RESPONSE_TIMEOUT_MS = 3e4;
|
|
2914
|
+
/** Wait for a cache.response from the agent with the given requestId. */
|
|
2915
|
+
function waitForCacheResponse(requestId) {
|
|
2916
|
+
return new Promise((resolve, reject) => {
|
|
2917
|
+
const timer = setTimeout(() => {
|
|
2918
|
+
pendingCacheResponses.delete(requestId);
|
|
2919
|
+
reject(/* @__PURE__ */ new Error(`Cache request timed out after ${CACHE_RESPONSE_TIMEOUT_MS}ms`));
|
|
2920
|
+
}, CACHE_RESPONSE_TIMEOUT_MS);
|
|
2921
|
+
pendingCacheResponses.set(requestId, {
|
|
2922
|
+
resolve: (response) => {
|
|
2923
|
+
clearTimeout(timer);
|
|
2924
|
+
pendingCacheResponses.delete(requestId);
|
|
2925
|
+
resolve(response);
|
|
2926
|
+
},
|
|
2927
|
+
reject: (err) => {
|
|
2928
|
+
clearTimeout(timer);
|
|
2929
|
+
pendingCacheResponses.delete(requestId);
|
|
2930
|
+
reject(err);
|
|
2931
|
+
},
|
|
2932
|
+
timer
|
|
2933
|
+
});
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Pending promises for approval.resolved messages from the agent.
|
|
2938
|
+
* Key: requestId (correlates approval.request -> approval.resolved).
|
|
2939
|
+
*/
|
|
2940
|
+
const pendingApprovalResolutions = /* @__PURE__ */ new Map();
|
|
2941
|
+
/**
|
|
2942
|
+
* Hard ceiling on how long the runner blocks a step waiting for an approval
|
|
2943
|
+
* resolution. The orchestrator enforces the real (org-/SDK-configured) expiry
|
|
2944
|
+
* and sends `expired` when it lapses; this is a safety net well above any sane
|
|
2945
|
+
* approval window so the runner cannot hang forever if the resolution is lost.
|
|
2946
|
+
*/
|
|
2947
|
+
const APPROVAL_RESOLUTION_TIMEOUT_MS = 10080 * 60 * 1e3;
|
|
2948
|
+
/** Wait for an approval.resolved from the agent with the given requestId. */
|
|
2949
|
+
function waitForApprovalResolution(requestId) {
|
|
2950
|
+
return new Promise((resolve) => {
|
|
2951
|
+
const timer = setTimeout(() => {
|
|
2952
|
+
pendingApprovalResolutions.delete(requestId);
|
|
2953
|
+
resolve({
|
|
2954
|
+
type: "approval.resolved",
|
|
2955
|
+
requestId,
|
|
2956
|
+
outcome: "expired"
|
|
2957
|
+
});
|
|
2958
|
+
}, APPROVAL_RESOLUTION_TIMEOUT_MS);
|
|
2959
|
+
pendingApprovalResolutions.set(requestId, {
|
|
2960
|
+
resolve: (response) => {
|
|
2961
|
+
clearTimeout(timer);
|
|
2962
|
+
pendingApprovalResolutions.delete(requestId);
|
|
2963
|
+
resolve(response);
|
|
2964
|
+
},
|
|
2965
|
+
timer
|
|
2966
|
+
});
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
/**
|
|
2970
|
+
* Build the `awaitStepApproval` callback the step loop uses to block on a
|
|
2971
|
+
* `requireApproval` step. Sends an `approval.request` IPC (relayed by the agent
|
|
2972
|
+
* over the WS as a `step.approval-request`) and awaits the matching
|
|
2973
|
+
* `approval.resolved`. A relay error is treated as a fail-closed reject.
|
|
2974
|
+
*/
|
|
2975
|
+
function buildAwaitStepApproval() {
|
|
2976
|
+
return async (req) => {
|
|
2977
|
+
const requestId = randomUUID();
|
|
2978
|
+
sendMessage({
|
|
2979
|
+
type: "approval.request",
|
|
2980
|
+
requestId,
|
|
2981
|
+
stepIndex: req.stepIndex,
|
|
2982
|
+
stepName: req.stepName,
|
|
2983
|
+
clauses: req.clauses,
|
|
2984
|
+
reason: req.reason,
|
|
2985
|
+
...req.timeoutSeconds !== void 0 && { timeoutSeconds: req.timeoutSeconds }
|
|
2986
|
+
});
|
|
2987
|
+
const resolution = await waitForApprovalResolution(requestId);
|
|
2988
|
+
if (resolution.error) return {
|
|
2989
|
+
outcome: "rejected",
|
|
2990
|
+
reason: resolution.error
|
|
2991
|
+
};
|
|
2992
|
+
return {
|
|
2993
|
+
outcome: resolution.outcome ?? "rejected",
|
|
2994
|
+
...resolution.reason !== void 0 && { reason: resolution.reason }
|
|
2995
|
+
};
|
|
2996
|
+
};
|
|
2997
|
+
}
|
|
2998
|
+
/**
|
|
2999
|
+
* Build the {@link CacheTransport} the sandbox-side cache engine uses to reach
|
|
3000
|
+
* the orchestrator. Each method sends a `cache.request` IPC (relayed by the
|
|
3001
|
+
* agent over the WS as a `cache.user.*` message) and awaits the matching
|
|
3002
|
+
* `cache.response`. Mirrors the `event.emit` / `agent.api.request` relays.
|
|
3003
|
+
*/
|
|
3004
|
+
function buildCacheTransport() {
|
|
3005
|
+
return {
|
|
3006
|
+
async restore(key, restoreKeys) {
|
|
3007
|
+
const requestId = randomUUID();
|
|
3008
|
+
sendMessage({
|
|
3009
|
+
type: "cache.request",
|
|
3010
|
+
requestId,
|
|
3011
|
+
op: "restore",
|
|
3012
|
+
key,
|
|
3013
|
+
...restoreKeys && { restoreKeys }
|
|
3014
|
+
});
|
|
3015
|
+
const response = await waitForCacheResponse(requestId);
|
|
3016
|
+
if (response.error) throw new Error(`Cache restore failed: ${response.error}`);
|
|
3017
|
+
return {
|
|
3018
|
+
hit: response.hit ?? false,
|
|
3019
|
+
...response.matchedKey && { matchedKey: response.matchedKey },
|
|
3020
|
+
...response.downloadUrl && { downloadUrl: response.downloadUrl },
|
|
3021
|
+
...response.tarHash && { tarHash: response.tarHash }
|
|
3022
|
+
};
|
|
3023
|
+
},
|
|
3024
|
+
async beginSave(key) {
|
|
3025
|
+
const requestId = randomUUID();
|
|
3026
|
+
sendMessage({
|
|
3027
|
+
type: "cache.request",
|
|
3028
|
+
requestId,
|
|
3029
|
+
op: "beginSave",
|
|
3030
|
+
key
|
|
3031
|
+
});
|
|
3032
|
+
const response = await waitForCacheResponse(requestId);
|
|
3033
|
+
if (response.error) throw new Error(`Cache save failed: ${response.error}`);
|
|
3034
|
+
return {
|
|
3035
|
+
skip: response.skip ?? true,
|
|
3036
|
+
...response.uploadUrl && { uploadUrl: response.uploadUrl }
|
|
3037
|
+
};
|
|
3038
|
+
},
|
|
3039
|
+
async completeSave(key, tarHash, sizeBytes) {
|
|
3040
|
+
const requestId = randomUUID();
|
|
3041
|
+
sendMessage({
|
|
3042
|
+
type: "cache.request",
|
|
3043
|
+
requestId,
|
|
3044
|
+
op: "completeSave",
|
|
3045
|
+
key,
|
|
3046
|
+
tarHash,
|
|
3047
|
+
sizeBytes
|
|
3048
|
+
});
|
|
3049
|
+
const response = await waitForCacheResponse(requestId);
|
|
3050
|
+
if (response.error) throw new Error(`Cache save-complete failed: ${response.error}`);
|
|
3051
|
+
}
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
/**
|
|
3055
|
+
* Build the declarative-cache phase dependencies and run the job-level cache
|
|
3056
|
+
* restore (Phase 9b).
|
|
3057
|
+
*
|
|
3058
|
+
* The deps carry one job-scoped cache API (over the same IPC→WS transport
|
|
3059
|
+
* `ctx.cache` uses) plus a monotonic pseudo-step index allocator that starts
|
|
3060
|
+
* well above every real-step and hook index (hooks use up to `stepCount * 3`).
|
|
3061
|
+
* Cache restores/saves surface as `cache:restore` / `cache:save` pseudo-steps
|
|
3062
|
+
* allocated from this cursor. The returned `jobCacheRestore` map lets the
|
|
3063
|
+
* post-loop save phase skip exact-key hits.
|
|
3064
|
+
*/
|
|
3065
|
+
async function setupJobCache(stepCwd, stepCount, jobCache, sendIpc) {
|
|
3066
|
+
let cacheStepCursor = stepCount * 3 + 100;
|
|
3067
|
+
const cachePhaseDeps = {
|
|
3068
|
+
cache: createCacheApi(stepCwd, buildCacheTransport()),
|
|
3069
|
+
sendIpc,
|
|
3070
|
+
nextStepIndex: () => cacheStepCursor++
|
|
3071
|
+
};
|
|
3072
|
+
const jobCacheSpecs = normalizeCacheSpecs(jobCache);
|
|
3073
|
+
return {
|
|
3074
|
+
cachePhaseDeps,
|
|
3075
|
+
jobCacheSpecs,
|
|
3076
|
+
jobCacheRestore: jobCacheSpecs.length > 0 ? await restoreCacheSpecs(jobCacheSpecs, cachePhaseDeps) : /* @__PURE__ */ new Map()
|
|
3077
|
+
};
|
|
3078
|
+
}
|
|
3079
|
+
/**
|
|
3080
|
+
* Job-level cache save (Phase 9c). Runs after the step loop, only when every
|
|
3081
|
+
* step succeeded and the job was not aborted. Each spec whose exact key did NOT
|
|
3082
|
+
* already hit on restore is saved (immutable: a re-save of an existing exact
|
|
3083
|
+
* key is skipped by the save phase itself). A no-op when the job declared no
|
|
3084
|
+
* cache or did not fully succeed (a failed/aborted job's artifacts may be
|
|
3085
|
+
* partial).
|
|
3086
|
+
*/
|
|
3087
|
+
async function maybeSaveJobCache(specs, restoreResults, deps, succeeded) {
|
|
3088
|
+
if (specs.length === 0 || !succeeded) return;
|
|
3089
|
+
await saveCacheSpecs(specs, restoreResults, deps);
|
|
3090
|
+
}
|
|
3091
|
+
/**
|
|
2248
3092
|
* Dispatch an agent-to-runner message to the appropriate pending handler.
|
|
2249
3093
|
* Shared between fork mode (IPC channel) and stdio mode (stdin readline).
|
|
2250
3094
|
*/
|
|
@@ -2261,6 +3105,12 @@ function dispatchAgentMessage(msg) {
|
|
|
2261
3105
|
const pending = pendingApiResponses.get(msg.requestId);
|
|
2262
3106
|
if (pending) if (msg.error) pending.reject(new Error(msg.error));
|
|
2263
3107
|
else pending.resolve(msg.result);
|
|
3108
|
+
} else if (msg.type === "cache.response") {
|
|
3109
|
+
const pending = pendingCacheResponses.get(msg.requestId);
|
|
3110
|
+
if (pending) pending.resolve(msg);
|
|
3111
|
+
} else if (msg.type === "approval.resolved") {
|
|
3112
|
+
const pending = pendingApprovalResolutions.get(msg.requestId);
|
|
3113
|
+
if (pending) pending.resolve(msg);
|
|
2264
3114
|
}
|
|
2265
3115
|
}
|
|
2266
3116
|
if (isForkMode) process.on("message", (msg) => {
|
|
@@ -2390,16 +3240,29 @@ function createSecretMasker(request) {
|
|
|
2390
3240
|
return masker;
|
|
2391
3241
|
}
|
|
2392
3242
|
/**
|
|
2393
|
-
*
|
|
3243
|
+
* Build a fresh zx `$` shell bound to the sandbox working directory and the
|
|
3244
|
+
* sanitized environment (process.env was set by the parent via env-sanitizer
|
|
3245
|
+
* before spawning this process). This is the single shell-construction code
|
|
3246
|
+
* path shared by step execution (`createSandboxStepContext`) and the per-job
|
|
3247
|
+
* init phase (`runInitPhase`), so init commands run through the identical shell
|
|
3248
|
+
* steps use — same cwd, same env snapshot, same masked log streaming.
|
|
2394
3249
|
*
|
|
2395
|
-
*
|
|
2396
|
-
*
|
|
2397
|
-
*
|
|
3250
|
+
* Intercept zx subprocess output via the log callback: zx does NOT write child
|
|
3251
|
+
* stdout/stderr to process.stdout — it pipes to an internal VoidStream and only
|
|
3252
|
+
* calls $.log() with { kind: 'stdout'|'stderr' }. With verbose=false the default
|
|
3253
|
+
* log function skips stdout entirely, and with quiet=false it writes stderr to
|
|
3254
|
+
* process.stderr. We override the log function to capture both kinds directly
|
|
3255
|
+
* and send them as masked IPC log.line messages tagged with `stepIndex`.
|
|
3256
|
+
*
|
|
3257
|
+
* IMPORTANT: The log function must be passed in the zx$() config, not set on the
|
|
3258
|
+
* returned function. zx$() returns a plain function (not the proxy $), so setting
|
|
3259
|
+
* step$.log would only set it on the function object and NOT propagate to the
|
|
3260
|
+
* AsyncLocalStorage store that zx uses for ProcessPromise snapshots.
|
|
2398
3261
|
*/
|
|
2399
|
-
function
|
|
3262
|
+
function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
|
|
2400
3263
|
let zxLineBuf = "";
|
|
2401
|
-
|
|
2402
|
-
cwd
|
|
3264
|
+
return $({
|
|
3265
|
+
cwd,
|
|
2403
3266
|
env: { ...process.env },
|
|
2404
3267
|
verbose: false,
|
|
2405
3268
|
quiet: false,
|
|
@@ -2418,20 +3281,36 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
2418
3281
|
}
|
|
2419
3282
|
})
|
|
2420
3283
|
});
|
|
3284
|
+
}
|
|
3285
|
+
/**
|
|
3286
|
+
* Create a StepContext natively inside the workflow runner.
|
|
3287
|
+
*
|
|
3288
|
+
* The context is reconstructed from the environment and IPC request fields --
|
|
3289
|
+
* NOT serialized across the process boundary. This means zx $ runs natively
|
|
3290
|
+
* inside this process with full shell access.
|
|
3291
|
+
*/
|
|
3292
|
+
function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets) {
|
|
3293
|
+
const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
|
|
2421
3294
|
const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
|
|
3295
|
+
const rawPayload = rawPayloadFromEvent(request.event);
|
|
2422
3296
|
return {
|
|
2423
3297
|
$: step$,
|
|
2424
3298
|
log,
|
|
2425
3299
|
env: process.env,
|
|
2426
3300
|
setEnv: (key, value) => {
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
}
|
|
2431
|
-
|
|
3301
|
+
applyEnvDelta({
|
|
3302
|
+
env: { [key]: value },
|
|
3303
|
+
pathPrepends: []
|
|
3304
|
+
}, {
|
|
3305
|
+
operatorSecretKeys,
|
|
3306
|
+
onReject: (k) => log.warn(`Cannot override operator secret "${k}" via setEnv — value preserved`)
|
|
3307
|
+
});
|
|
2432
3308
|
},
|
|
2433
3309
|
addPath: (dir) => {
|
|
2434
|
-
|
|
3310
|
+
applyEnvDelta({
|
|
3311
|
+
env: {},
|
|
3312
|
+
pathPrepends: [dir]
|
|
3313
|
+
}, { operatorSecretKeys });
|
|
2435
3314
|
},
|
|
2436
3315
|
inputs: {},
|
|
2437
3316
|
secrets,
|
|
@@ -2442,6 +3321,7 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
2442
3321
|
},
|
|
2443
3322
|
isTestRun: request.isTestRun ?? false,
|
|
2444
3323
|
environment: request.environment,
|
|
3324
|
+
cache: createCacheApi(workDir, buildCacheTransport()),
|
|
2445
3325
|
emit: async (eventName, payload, options) => {
|
|
2446
3326
|
const reqId = randomUUID();
|
|
2447
3327
|
sendMessage({
|
|
@@ -2474,10 +3354,15 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
2474
3354
|
});
|
|
2475
3355
|
return waitForApiResponse(reqId);
|
|
2476
3356
|
}),
|
|
2477
|
-
...
|
|
3357
|
+
...rawPayload && { rawPayload },
|
|
2478
3358
|
...request.provider && { provider: request.provider }
|
|
2479
3359
|
};
|
|
2480
3360
|
}
|
|
3361
|
+
/** Raw provider webhook body for ctx.rawPayload — nested in the envelope. */
|
|
3362
|
+
function rawPayloadFromEvent(event) {
|
|
3363
|
+
if (!event) return void 0;
|
|
3364
|
+
return event.payload ?? void 0;
|
|
3365
|
+
}
|
|
2481
3366
|
/**
|
|
2482
3367
|
* Check if a file exists at the given path.
|
|
2483
3368
|
*/
|
|
@@ -2499,7 +3384,8 @@ function abortAndExit(reason) {
|
|
|
2499
3384
|
sendMessage({
|
|
2500
3385
|
type: "job.complete",
|
|
2501
3386
|
status: ExecutionJobStatus.enum.failed,
|
|
2502
|
-
stepResults: []
|
|
3387
|
+
stepResults: [],
|
|
3388
|
+
...jobTimedOut && { error: `${TimeoutReason.enum.job_timeout}: job exceeded its timeout of ${jobTimedOutMs ?? 0}ms` }
|
|
2503
3389
|
});
|
|
2504
3390
|
process.exit(1);
|
|
2505
3391
|
}
|
|
@@ -3094,6 +3980,121 @@ function collectJobHooks(job) {
|
|
|
3094
3980
|
return jobHooks;
|
|
3095
3981
|
}
|
|
3096
3982
|
/**
|
|
3983
|
+
* Normalize `Job.init` (config | config[] | false | undefined) to an ordered
|
|
3984
|
+
* array of init specs. `false` is an explicit opt-out and `undefined` (no
|
|
3985
|
+
* config) both resolve to an empty list — the init phase is then a no-op.
|
|
3986
|
+
*/
|
|
3987
|
+
function resolveInitSpecs(job) {
|
|
3988
|
+
if (!job || job.init === void 0 || job.init === false) return [];
|
|
3989
|
+
return Array.isArray(job.init) ? [...job.init] : [job.init];
|
|
3990
|
+
}
|
|
3991
|
+
/**
|
|
3992
|
+
* Base stepIndex for the `init:<n>` pseudo-steps. The step loop reserves the
|
|
3993
|
+
* range starting at `steps.length` for hook pseudo-steps (`beforeStep` =
|
|
3994
|
+
* `steps.length + i*2`, `afterStep` = `steps.length + i*2 + 1`, and job-level
|
|
3995
|
+
* onSuccess/onFailure/cleanup from `steps.length` upward — see step-loop.ts),
|
|
3996
|
+
* so init indices must sit ABOVE every possible hook index to avoid collision.
|
|
3997
|
+
* A large fixed offset reserves a dedicated range no realistic step/hook count
|
|
3998
|
+
* can reach; init:<n> then occupies `INIT_STEP_INDEX_BASE + n`.
|
|
3999
|
+
*/
|
|
4000
|
+
const INIT_STEP_INDEX_BASE = 1e6;
|
|
4001
|
+
/**
|
|
4002
|
+
* Read the KICI_ENV/KICI_PATH delta written by a command, apply it through
|
|
4003
|
+
* `applyEnvDelta` (operator-secret override guard + masked reject log), then
|
|
4004
|
+
* truncate the files for the next command. Shared by the per-job init phase and
|
|
4005
|
+
* the per-step after-hook so both honor the identical operator-secret guard.
|
|
4006
|
+
*/
|
|
4007
|
+
async function applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend) {
|
|
4008
|
+
applyEnvDelta(await readEnvDelta(envFiles), {
|
|
4009
|
+
operatorSecretKeys,
|
|
4010
|
+
onReject: (key) => maskedSend({
|
|
4011
|
+
type: "log.line",
|
|
4012
|
+
stepIndex: -1,
|
|
4013
|
+
line: `[kici] Cannot override operator secret "${key}" via $KICI_ENV — value preserved`
|
|
4014
|
+
})
|
|
4015
|
+
});
|
|
4016
|
+
await truncateEnvFiles(envFiles);
|
|
4017
|
+
}
|
|
4018
|
+
/**
|
|
4019
|
+
* Build the step loop's KICI_ENV/KICI_PATH callbacks over the shared `envFiles`.
|
|
4020
|
+
* `beforeStepEnvFiles` points the runner's process.env at the files (each step's
|
|
4021
|
+
* zx $ snapshots process.env at context creation, which happens AFTER this
|
|
4022
|
+
* before-hook, so the shell sees them; the pre-fork env allowlist does not
|
|
4023
|
+
* re-filter runtime-set vars). `afterStepApplyEnvFiles` applies + truncates the
|
|
4024
|
+
* delta, mirroring the init phase's env port.
|
|
4025
|
+
*/
|
|
4026
|
+
function buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend) {
|
|
4027
|
+
return {
|
|
4028
|
+
beforeStepEnvFiles: async () => {
|
|
4029
|
+
process.env.KICI_ENV = envFiles.envFile;
|
|
4030
|
+
process.env.KICI_PATH = envFiles.pathFile;
|
|
4031
|
+
},
|
|
4032
|
+
afterStepApplyEnvFiles: async () => {
|
|
4033
|
+
try {
|
|
4034
|
+
await applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend);
|
|
4035
|
+
} catch (err) {
|
|
4036
|
+
maskedSend({
|
|
4037
|
+
type: "log.line",
|
|
4038
|
+
stepIndex: -1,
|
|
4039
|
+
line: `[kici] Failed to apply $KICI_ENV/$KICI_PATH delta: ${toErrorMessage(err)}`
|
|
4040
|
+
});
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
};
|
|
4044
|
+
}
|
|
4045
|
+
/**
|
|
4046
|
+
* Run the per-job init phase with concrete ports, then fail the job (no steps)
|
|
4047
|
+
* if any init spec failed or timed out.
|
|
4048
|
+
*
|
|
4049
|
+
* Concrete ports supplied to `runInitPhase`:
|
|
4050
|
+
* - shell: a fresh `buildSandboxShell` per init (same zx config steps use), cwd
|
|
4051
|
+
* = the clone root. Each init snapshots `process.env` (which `beginCapture`
|
|
4052
|
+
* has pointed at the shared KICI_ENV/KICI_PATH files), so the command can hand
|
|
4053
|
+
* env + PATH off to later inits and to every step.
|
|
4054
|
+
* - cache: the transport-backed `CacheApi` (`createCacheApi`) — the same engine
|
|
4055
|
+
* `ctx.cache` and the declarative cache phase use. Restore before / save on
|
|
4056
|
+
* key miss after.
|
|
4057
|
+
* - env: the P1 KICI_ENV/KICI_PATH lifecycle over the shared `envFiles`,
|
|
4058
|
+
* mirroring the step loop's `beforeStepEnvFiles` / `afterStepApplyEnvFiles`
|
|
4059
|
+
* (same `operatorSecretKeys` guard + masked reject log).
|
|
4060
|
+
*
|
|
4061
|
+
* On `result.ok === false`: emit `job.complete{failed}` with `stepResults: []`
|
|
4062
|
+
* (no step ran), an actionable error (carrying the distinct P3 timeout reason
|
|
4063
|
+
* when `timedOut`), and `process.exit(1)` — the step loop never executes.
|
|
4064
|
+
*/
|
|
4065
|
+
async function runInitPhaseOrFailJob(args) {
|
|
4066
|
+
const { job, stepCwd, envFiles, operatorSecretKeys, maskedSend } = args;
|
|
4067
|
+
const initSpecs = resolveInitSpecs(job);
|
|
4068
|
+
if (initSpecs.length === 0) return;
|
|
4069
|
+
const initResult = await runInitPhase({
|
|
4070
|
+
specs: initSpecs,
|
|
4071
|
+
shellFor: (_spec, i) => buildSandboxShell(stepCwd, INIT_STEP_INDEX_BASE + i, maskedSend),
|
|
4072
|
+
sendIpc: maskedSend,
|
|
4073
|
+
stepIndexBase: INIT_STEP_INDEX_BASE,
|
|
4074
|
+
cache: createCacheApi(stepCwd, buildCacheTransport()),
|
|
4075
|
+
env: {
|
|
4076
|
+
beginCapture: async () => {
|
|
4077
|
+
process.env.KICI_ENV = envFiles.envFile;
|
|
4078
|
+
process.env.KICI_PATH = envFiles.pathFile;
|
|
4079
|
+
await truncateEnvFiles(envFiles);
|
|
4080
|
+
},
|
|
4081
|
+
applyDelta: async () => {
|
|
4082
|
+
await applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend);
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
4085
|
+
});
|
|
4086
|
+
if (!initResult.ok) {
|
|
4087
|
+
const errorBody = initResult.error ?? "";
|
|
4088
|
+
sendMessage({
|
|
4089
|
+
type: "job.complete",
|
|
4090
|
+
status: ExecutionJobStatus.enum.failed,
|
|
4091
|
+
stepResults: [],
|
|
4092
|
+
error: initResult.reason ? `init[${initResult.failedInitIndex}] ${initResult.reason}: ${errorBody}`.trim() : `init[${initResult.failedInitIndex}] failed: ${errorBody}`.trim()
|
|
4093
|
+
});
|
|
4094
|
+
process.exit(1);
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
/**
|
|
3097
4098
|
* Run the complete job execution lifecycle.
|
|
3098
4099
|
*
|
|
3099
4100
|
* 1. Receive execution request
|
|
@@ -3124,6 +4125,18 @@ async function main() {
|
|
|
3124
4125
|
});
|
|
3125
4126
|
else sendMessage(msg);
|
|
3126
4127
|
};
|
|
4128
|
+
const jobDeadline = armJobDeadline(request.jobTimeoutMs, (reason, timeoutMs) => {
|
|
4129
|
+
jobTimedOut = true;
|
|
4130
|
+
jobTimedOutMs = timeoutMs;
|
|
4131
|
+
aborted = true;
|
|
4132
|
+
forceAborted = true;
|
|
4133
|
+
maskedSend({
|
|
4134
|
+
type: "log.line",
|
|
4135
|
+
stepIndex: -1,
|
|
4136
|
+
line: `[kici] Job exceeded its timeout of ${timeoutMs}ms (${reason}); aborting.`
|
|
4137
|
+
});
|
|
4138
|
+
jobDeadlineAbort.abort();
|
|
4139
|
+
});
|
|
3127
4140
|
await cloneRepoIfRequested(request, workDir, workflowDir, sourceDir, isGlobal);
|
|
3128
4141
|
await applyOverlayIfRequested(request, workflowDir);
|
|
3129
4142
|
if (aborted) abortAndExit("aborted after clone");
|
|
@@ -3153,6 +4166,15 @@ async function main() {
|
|
|
3153
4166
|
flushOutputCapture();
|
|
3154
4167
|
capturePrepareActive = false;
|
|
3155
4168
|
const stepCwd = sourceDir;
|
|
4169
|
+
const envFiles = await createEnvFiles(tmpdir());
|
|
4170
|
+
await runInitPhaseOrFailJob({
|
|
4171
|
+
job,
|
|
4172
|
+
stepCwd,
|
|
4173
|
+
envFiles,
|
|
4174
|
+
operatorSecretKeys,
|
|
4175
|
+
maskedSend
|
|
4176
|
+
});
|
|
4177
|
+
const { cachePhaseDeps, jobCacheSpecs, jobCacheRestore } = await setupJobCache(stepCwd, normalizedSteps.length, job?.cache, maskedSend);
|
|
3156
4178
|
let currentStepSecrets = null;
|
|
3157
4179
|
let currentStepDispose = null;
|
|
3158
4180
|
const createStepCtxWithCapture = (stepIndex, stepName) => {
|
|
@@ -3167,6 +4189,7 @@ async function main() {
|
|
|
3167
4189
|
}
|
|
3168
4190
|
return ctx;
|
|
3169
4191
|
};
|
|
4192
|
+
const stepEnvHooks = buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend);
|
|
3170
4193
|
const jobStartTime = Date.now();
|
|
3171
4194
|
const loopResult = await executeStepLoop({
|
|
3172
4195
|
steps: normalizedSteps,
|
|
@@ -3177,7 +4200,9 @@ async function main() {
|
|
|
3177
4200
|
event: request.event ?? {},
|
|
3178
4201
|
env: process.env,
|
|
3179
4202
|
jobHooks,
|
|
4203
|
+
cachePhaseDeps,
|
|
3180
4204
|
isAborted: () => aborted,
|
|
4205
|
+
jobDeadlineSignal: jobDeadlineAbort.signal,
|
|
3181
4206
|
startTime: jobStartTime,
|
|
3182
4207
|
getSecretsAccessLog: () => {
|
|
3183
4208
|
flushOutputCapture();
|
|
@@ -3192,8 +4217,13 @@ async function main() {
|
|
|
3192
4217
|
currentStepDispose = null;
|
|
3193
4218
|
currentStepSecrets = null;
|
|
3194
4219
|
if (disposeFn) await disposeFn();
|
|
3195
|
-
}
|
|
4220
|
+
},
|
|
4221
|
+
beforeStepEnvFiles: stepEnvHooks.beforeStepEnvFiles,
|
|
4222
|
+
afterStepApplyEnvFiles: stepEnvHooks.afterStepApplyEnvFiles,
|
|
4223
|
+
awaitStepApproval: buildAwaitStepApproval()
|
|
3196
4224
|
});
|
|
4225
|
+
jobDeadline.clear();
|
|
4226
|
+
await maybeSaveJobCache(jobCacheSpecs, jobCacheRestore, cachePhaseDeps, !aborted && loopResult.status === ExecutionStepStatus.enum.success);
|
|
3197
4227
|
let finalStatus = loopResult.status === ExecutionStepStatus.enum.success ? ExecutionJobStatus.enum.success : ExecutionJobStatus.enum.failed;
|
|
3198
4228
|
let cancelFailureReason;
|
|
3199
4229
|
if (aborted) {
|
|
@@ -3216,19 +4246,40 @@ async function main() {
|
|
|
3216
4246
|
finalStatus = cancelResult.finalStatus;
|
|
3217
4247
|
cancelFailureReason = cancelResult.cancelFailureReason;
|
|
3218
4248
|
}
|
|
4249
|
+
if (jobTimedOut) finalStatus = ExecutionJobStatus.enum.failed;
|
|
4250
|
+
emitJobComplete({
|
|
4251
|
+
finalStatus,
|
|
4252
|
+
loopResult,
|
|
4253
|
+
outputsMap,
|
|
4254
|
+
secretOutputs,
|
|
4255
|
+
jobTimedOut,
|
|
4256
|
+
jobTimeoutMs: request.jobTimeoutMs,
|
|
4257
|
+
cancelFailureReason,
|
|
4258
|
+
driftDroppedJobs
|
|
4259
|
+
});
|
|
4260
|
+
process.exit(finalStatus === ExecutionJobStatus.enum.success ? 0 : 1);
|
|
4261
|
+
}
|
|
4262
|
+
/**
|
|
4263
|
+
* Phase 11 — emit the terminal `job.complete` IPC. Aggregates per-step outputs
|
|
4264
|
+
* by step name, includes encrypted secret outputs, and selects the error
|
|
4265
|
+
* message: a `job_timeout` reason when the job-level deadline tripped, else the
|
|
4266
|
+
* step-loop's failure reason (or the cancel-path's compound reason).
|
|
4267
|
+
*/
|
|
4268
|
+
function emitJobComplete(args) {
|
|
3219
4269
|
const aggregatedOutputs = {};
|
|
3220
|
-
for (const [stepName, outputs] of outputsMap) aggregatedOutputs[stepName] = outputs;
|
|
4270
|
+
for (const [stepName, outputs] of args.outputsMap) aggregatedOutputs[stepName] = outputs;
|
|
3221
4271
|
sendMessage({
|
|
3222
4272
|
type: "job.complete",
|
|
3223
|
-
status: finalStatus,
|
|
3224
|
-
stepResults: loopResult.stepResults,
|
|
4273
|
+
status: args.finalStatus,
|
|
4274
|
+
stepResults: args.loopResult.stepResults,
|
|
3225
4275
|
...Object.keys(aggregatedOutputs).length > 0 && { outputs: aggregatedOutputs },
|
|
3226
|
-
...secretOutputs.size > 0 && { secretOutputs: Object.fromEntries(secretOutputs) },
|
|
3227
|
-
...
|
|
3228
|
-
|
|
3229
|
-
|
|
4276
|
+
...args.secretOutputs.size > 0 && { secretOutputs: Object.fromEntries(args.secretOutputs) },
|
|
4277
|
+
...args.jobTimedOut ? { error: `${TimeoutReason.enum.job_timeout}: job exceeded its timeout of ${args.jobTimeoutMs}ms` } : {
|
|
4278
|
+
...args.loopResult.failureReason && { error: args.loopResult.failureReason },
|
|
4279
|
+
...args.cancelFailureReason && { error: args.cancelFailureReason }
|
|
4280
|
+
},
|
|
4281
|
+
...args.driftDroppedJobs.length > 0 && { droppedJobs: args.driftDroppedJobs }
|
|
3230
4282
|
});
|
|
3231
|
-
process.exit(finalStatus === ExecutionJobStatus.enum.success ? 0 : 1);
|
|
3232
4283
|
}
|
|
3233
4284
|
/**
|
|
3234
4285
|
* Find a static Job by name in the workflow.
|
|
@@ -3255,6 +4306,6 @@ main().catch((error) => {
|
|
|
3255
4306
|
setTimeout(() => process.exit(1), 100);
|
|
3256
4307
|
});
|
|
3257
4308
|
//#endregion
|
|
3258
|
-
export {};
|
|
4309
|
+
export { rawPayloadFromEvent, resolveInitSpecs };
|
|
3259
4310
|
|
|
3260
4311
|
//# sourceMappingURL=workflow-runner.js.map
|