@kici-dev/agent 0.1.13 → 0.1.15
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/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 +21 -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 +68 -3
- package/dist/execution/sandbox/job-deadline.d.ts +13 -0
- package/dist/execution/sandbox/step-loop.d.ts +29 -0
- package/dist/execution/sandbox/types.d.ts +11 -1
- package/dist/execution/sandbox/workflow-runner.d.ts +9 -1
- package/dist/execution/tmp-gc.d.ts +17 -0
- package/dist/execution/workflow-loader.d.ts +2 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +345 -14
- package/dist/server.js +406 -153
- package/dist/workflow-runner.js +1885 -958
- package/dist/ws/orchestrator-client.d.ts +18 -0
- package/package.json +14 -9
- package/sbom.spdx.json +108 -48
package/dist/workflow-runner.js
CHANGED
|
@@ -1,1060 +1,1674 @@
|
|
|
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, 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";
|
|
19
|
-
import {
|
|
20
|
-
import { x } from "tar";
|
|
21
|
-
import { promisify } from "node:util";
|
|
22
|
-
import { PackageManager, detectPackageManagerFromManifests } from "@kici-dev/shared/package-manager";
|
|
15
|
+
import { c, x } from "tar";
|
|
23
16
|
import https from "node:https";
|
|
24
17
|
import http from "node:http";
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, detectPackageManagerFromManifests } from "@kici-dev/shared/package-manager";
|
|
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
|
+
* Run a single observer hook (beforeStep / afterStep). Failures only emit a
|
|
1127
|
+
* log line — they never change job status. Centralises the per-call boilerplate
|
|
1128
|
+
* so the per-step body can stay flat.
|
|
814
1129
|
*/
|
|
815
|
-
async function
|
|
816
|
-
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1130
|
+
async function runObserverHook(args) {
|
|
1131
|
+
const { hook, hookType, step, stepIndex, hookStepIndex, failedStep, opts } = args;
|
|
1132
|
+
const outcome = buildOutcomeMetadata({
|
|
1133
|
+
status: failedStep !== void 0 ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
|
|
1134
|
+
stepOutputs: Object.fromEntries(opts.outputsMap),
|
|
1135
|
+
startTime: opts.startTime ?? Date.now(),
|
|
1136
|
+
...failedStep !== void 0 && { failedStep }
|
|
1137
|
+
});
|
|
1138
|
+
const hookResult = await executeHook({
|
|
1139
|
+
hook,
|
|
1140
|
+
stepContext: opts.createStepContext(stepIndex, step.name),
|
|
1141
|
+
outcome,
|
|
1142
|
+
hookType,
|
|
1143
|
+
stepIndex: hookStepIndex,
|
|
1144
|
+
sendIpc: opts.sendIpc,
|
|
1145
|
+
timeout: 3e5
|
|
1146
|
+
});
|
|
1147
|
+
if (!hookResult.success) opts.sendIpc({
|
|
1148
|
+
type: "log.line",
|
|
1149
|
+
stepIndex,
|
|
1150
|
+
line: `[kici] ${hookType} hook failed: ${hookResult.error} (continuing -- hooks are observers)`
|
|
823
1151
|
});
|
|
824
1152
|
}
|
|
825
1153
|
/**
|
|
826
|
-
*
|
|
1154
|
+
* Execute one iteration of the step loop: step rules → beforeStep → execute →
|
|
1155
|
+
* afterStep → failure-handling. Returns a typed outcome the loop uses to
|
|
1156
|
+
* accumulate results, decide whether to break, and remember the failed step.
|
|
827
1157
|
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
1158
|
+
* Wraps the per-step lifecycle in a `try / finally` that calls
|
|
1159
|
+
* `opts.disposeStepResources()` so per-step state (the
|
|
1160
|
+
* `ctx.secrets.mountFile` tmpdir, any env vars set via `exposeFile`) is
|
|
1161
|
+
* removed even when the step throws, times out, or rule-skips.
|
|
830
1162
|
*/
|
|
831
|
-
async function
|
|
832
|
-
const
|
|
833
|
-
if (
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1163
|
+
async function runStepIteration(step, stepIndex, opts) {
|
|
1164
|
+
const skippedResult = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
|
|
1165
|
+
if (skippedResult) {
|
|
1166
|
+
await opts.disposeStepResources?.();
|
|
1167
|
+
return {
|
|
1168
|
+
result: skippedResult,
|
|
1169
|
+
shouldBreak: false
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
if (opts.jobHooks?.beforeStep) await runObserverHook({
|
|
1173
|
+
hook: opts.jobHooks.beforeStep,
|
|
1174
|
+
hookType: "beforeStep",
|
|
1175
|
+
step,
|
|
1176
|
+
stepIndex,
|
|
1177
|
+
hookStepIndex: opts.steps.length + stepIndex * 2,
|
|
1178
|
+
opts
|
|
1179
|
+
});
|
|
1180
|
+
const stepCacheSpecs = opts.cachePhaseDeps ? normalizeCacheSpecs(step.cache) : [];
|
|
1181
|
+
const stepCacheRestore = stepCacheSpecs.length > 0 && opts.cachePhaseDeps ? await restoreCacheSpecs(stepCacheSpecs, opts.cachePhaseDeps) : /* @__PURE__ */ new Map();
|
|
1182
|
+
try {
|
|
1183
|
+
await opts.beforeStepEnvFiles?.();
|
|
1184
|
+
const ctx = opts.createStepContext(stepIndex, step.name);
|
|
1185
|
+
const timeoutMs = step.timeout ?? opts.defaultTimeoutMs;
|
|
1186
|
+
let result;
|
|
1187
|
+
try {
|
|
1188
|
+
result = await executeStepInLoop(step, stepIndex, ctx, timeoutMs, opts.sendIpc, opts.outputsMap, opts.getSecretsAccessLog, opts.getSecretMountRecords, opts.jobDeadlineSignal);
|
|
1189
|
+
} finally {
|
|
1190
|
+
await opts.afterStepApplyEnvFiles?.();
|
|
1191
|
+
}
|
|
1192
|
+
if (stepCacheSpecs.length > 0 && opts.cachePhaseDeps && result.status === ExecutionStepStatus.enum.success) await saveCacheSpecs(stepCacheSpecs, stepCacheRestore, opts.cachePhaseDeps);
|
|
1193
|
+
if (opts.jobHooks?.afterStep) await runObserverHook({
|
|
1194
|
+
hook: opts.jobHooks.afterStep,
|
|
1195
|
+
hookType: "afterStep",
|
|
1196
|
+
step,
|
|
1197
|
+
stepIndex,
|
|
1198
|
+
hookStepIndex: opts.steps.length + stepIndex * 2 + 1,
|
|
1199
|
+
failedStep: result.status === ExecutionStepStatus.enum.failed ? step.name : void 0,
|
|
1200
|
+
opts
|
|
1201
|
+
});
|
|
1202
|
+
if (result.status === ExecutionStepStatus.enum.failed) return {
|
|
1203
|
+
result,
|
|
1204
|
+
shouldBreak: !step.continueOnError,
|
|
1205
|
+
failedStepName: step.name
|
|
1206
|
+
};
|
|
1207
|
+
return {
|
|
1208
|
+
result,
|
|
1209
|
+
shouldBreak: false
|
|
1210
|
+
};
|
|
1211
|
+
} finally {
|
|
1212
|
+
await opts.disposeStepResources?.();
|
|
1213
|
+
}
|
|
844
1214
|
}
|
|
845
1215
|
/**
|
|
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`.
|
|
1216
|
+
* Execute one named completion hook (onSuccess / onFailure / cleanup) and
|
|
1217
|
+
* return the updated `CompletionState`. Treated as the single source of truth
|
|
1218
|
+
* for the "promote to failed + concat reason" pattern that the three completion
|
|
1219
|
+
* hooks share.
|
|
880
1220
|
*/
|
|
881
|
-
async function
|
|
882
|
-
const
|
|
883
|
-
|
|
1221
|
+
async function runCompletionHook(args) {
|
|
1222
|
+
const { hook, hookType, hookStepIndex, outcome, state, promoteToFailed, opts } = args;
|
|
1223
|
+
opts.sendIpc({
|
|
1224
|
+
type: "log.line",
|
|
1225
|
+
stepIndex: -1,
|
|
1226
|
+
line: `[kici] Running ${hookType} hook...`
|
|
1227
|
+
});
|
|
1228
|
+
const hookResult = await executeHook({
|
|
1229
|
+
hook,
|
|
1230
|
+
stepContext: opts.createStepContext(hookStepIndex, hookType),
|
|
1231
|
+
outcome,
|
|
1232
|
+
hookType,
|
|
1233
|
+
stepIndex: hookStepIndex,
|
|
1234
|
+
sendIpc: opts.sendIpc
|
|
1235
|
+
});
|
|
1236
|
+
if (hookResult.success) {
|
|
1237
|
+
opts.sendIpc({
|
|
1238
|
+
type: "log.line",
|
|
1239
|
+
stepIndex: -1,
|
|
1240
|
+
line: `[kici] ${hookType} hook completed`
|
|
1241
|
+
});
|
|
1242
|
+
return state;
|
|
1243
|
+
}
|
|
1244
|
+
opts.sendIpc({
|
|
1245
|
+
type: "log.line",
|
|
1246
|
+
stepIndex: -1,
|
|
1247
|
+
line: `[kici] ${hookType} hook failed: ${hookResult.error}`
|
|
1248
|
+
});
|
|
1249
|
+
const reasonFragment = `Hook ${hookType} failed: ${hookResult.error}`;
|
|
884
1250
|
return {
|
|
885
|
-
|
|
886
|
-
|
|
1251
|
+
failed: state.failed || promoteToFailed,
|
|
1252
|
+
failedStepName: state.failedStepName,
|
|
1253
|
+
failureReason: state.failureReason ? `${state.failureReason}; ${reasonFragment}` : reasonFragment
|
|
887
1254
|
};
|
|
888
1255
|
}
|
|
889
1256
|
/**
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
* Why `.git/info/exclude` and not `.gitignore`:
|
|
895
|
-
* - `.gitignore` lives in the customer's repo and is committed; we MUST NOT
|
|
896
|
-
* modify it. Doing so would surface the rule in their PRs and create a
|
|
897
|
-
* diff customers never asked for.
|
|
898
|
-
* - `.git/info/exclude` is per-clone, on-disk only, and exactly the git
|
|
899
|
-
* mechanism for "ignore these patterns in THIS working tree". Git creates
|
|
900
|
-
* an empty (template-commented) file on `git init` / `git clone`, so it
|
|
901
|
-
* already exists by the time we're called.
|
|
902
|
-
*
|
|
903
|
-
* Why this lives next to `extractIntoScratch`:
|
|
904
|
-
* - The exclude glob is tied 1:1 to the scratch dir naming convention. If
|
|
905
|
-
* the prefix ever changes, the rule must change too. Defining both in the
|
|
906
|
-
* same file means a rename touches one place, not two.
|
|
907
|
-
*
|
|
908
|
-
* Best-effort: if the exclude file is missing (e.g. caller sandbox blocked
|
|
909
|
-
* `git clone` and the dir layout differs) we log and continue — failing the
|
|
910
|
-
* job over a missing git ignore wiring would be worse than the cosmetic
|
|
911
|
-
* issue we're solving.
|
|
912
|
-
*
|
|
913
|
-
* Idempotent: callers may invoke this multiple times (dual-clone path, retry
|
|
914
|
-
* after partial setup). We skip the append if the glob is already present.
|
|
915
|
-
*
|
|
916
|
-
* @param repoWorkDir - The git working tree root (the dir that contains
|
|
917
|
-
* `.git/`). For normal workflows this is the agent's job workDir; for
|
|
918
|
-
* global workflows it is the workflow repo dir (whose `.kici/` carries
|
|
919
|
-
* the scratch dirs).
|
|
1257
|
+
* Run the job-completion hook sequence after the per-step loop ends:
|
|
1258
|
+
* onSuccess (or onFailure), then cleanup (always). The cleanup outcome is
|
|
1259
|
+
* recomputed so it reflects any failures introduced by onSuccess/onFailure.
|
|
920
1260
|
*/
|
|
921
|
-
async function
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1261
|
+
async function runJobCompletionHooks(opts, initial, outputsMap, startTime) {
|
|
1262
|
+
const { jobHooks } = opts;
|
|
1263
|
+
const initialFinalStatus = initial.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success;
|
|
1264
|
+
const jobOutcome = buildOutcomeMetadata({
|
|
1265
|
+
status: initialFinalStatus,
|
|
1266
|
+
stepOutputs: Object.fromEntries(outputsMap),
|
|
1267
|
+
startTime,
|
|
1268
|
+
...initial.failedStepName && {
|
|
1269
|
+
failedStep: initial.failedStepName,
|
|
1270
|
+
reason: `Step '${initial.failedStepName}' failed`
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
let state = initial;
|
|
1274
|
+
let hookStepIndex = opts.steps.length;
|
|
1275
|
+
if (initialFinalStatus === ExecutionStepStatus.enum.success && jobHooks?.onSuccess) {
|
|
1276
|
+
state = await runCompletionHook({
|
|
1277
|
+
hook: jobHooks.onSuccess,
|
|
1278
|
+
hookType: "onSuccess",
|
|
1279
|
+
hookStepIndex,
|
|
1280
|
+
outcome: jobOutcome,
|
|
1281
|
+
state,
|
|
1282
|
+
promoteToFailed: true,
|
|
1283
|
+
opts
|
|
1284
|
+
});
|
|
1285
|
+
hookStepIndex++;
|
|
1286
|
+
} else if (initialFinalStatus === ExecutionStepStatus.enum.failed && jobHooks?.onFailure) {
|
|
1287
|
+
state = await runCompletionHook({
|
|
1288
|
+
hook: jobHooks.onFailure,
|
|
1289
|
+
hookType: "onFailure",
|
|
1290
|
+
hookStepIndex,
|
|
1291
|
+
outcome: jobOutcome,
|
|
1292
|
+
state,
|
|
1293
|
+
promoteToFailed: false,
|
|
1294
|
+
opts
|
|
1295
|
+
});
|
|
1296
|
+
hookStepIndex++;
|
|
1297
|
+
}
|
|
1298
|
+
if (jobHooks?.cleanup) {
|
|
1299
|
+
const cleanupOutcome = buildOutcomeMetadata({
|
|
1300
|
+
status: state.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
|
|
1301
|
+
stepOutputs: Object.fromEntries(outputsMap),
|
|
1302
|
+
startTime,
|
|
1303
|
+
...state.failedStepName && { failedStep: state.failedStepName },
|
|
1304
|
+
...state.failureReason && { reason: state.failureReason }
|
|
1305
|
+
});
|
|
1306
|
+
state = await runCompletionHook({
|
|
1307
|
+
hook: jobHooks.cleanup,
|
|
1308
|
+
hookType: "cleanup",
|
|
1309
|
+
hookStepIndex,
|
|
1310
|
+
outcome: cleanupOutcome,
|
|
1311
|
+
state,
|
|
1312
|
+
promoteToFailed: true,
|
|
1313
|
+
opts
|
|
932
1314
|
});
|
|
933
1315
|
}
|
|
1316
|
+
return state;
|
|
934
1317
|
}
|
|
935
1318
|
/**
|
|
936
|
-
*
|
|
1319
|
+
* Execute the step loop with hook integration and step-level rule evaluation.
|
|
937
1320
|
*
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
1321
|
+
* Hook execution order:
|
|
1322
|
+
* - beforeStep -> step -> afterStep (per step)
|
|
1323
|
+
* - onSuccess or onFailure (after all steps)
|
|
1324
|
+
* - cleanup (always, after onSuccess/onFailure)
|
|
1325
|
+
*
|
|
1326
|
+
* Hooks are observers: beforeStep/afterStep failures do NOT affect step execution.
|
|
1327
|
+
* Only completion hooks (onSuccess/onFailure/cleanup) can change job status to failed.
|
|
941
1328
|
*/
|
|
942
|
-
function
|
|
943
|
-
|
|
944
|
-
const
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
const
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1329
|
+
async function executeStepLoop(opts) {
|
|
1330
|
+
const startTime = opts.startTime ?? Date.now();
|
|
1331
|
+
const stepResults = [];
|
|
1332
|
+
const state = { failed: false };
|
|
1333
|
+
for (const [i, step] of opts.steps.entries()) {
|
|
1334
|
+
if (opts.isAborted?.()) break;
|
|
1335
|
+
const outcome = await runStepIteration(step, i, opts);
|
|
1336
|
+
stepResults.push(outcome.result);
|
|
1337
|
+
if (outcome.failedStepName) {
|
|
1338
|
+
state.failed = true;
|
|
1339
|
+
state.failedStepName = outcome.failedStepName;
|
|
1340
|
+
}
|
|
1341
|
+
if (outcome.shouldBreak) break;
|
|
953
1342
|
}
|
|
1343
|
+
if (opts.isAborted?.()) return {
|
|
1344
|
+
status: "aborted",
|
|
1345
|
+
stepResults,
|
|
1346
|
+
failureReason: state.failureReason ?? (state.failed ? `Step '${state.failedStepName}' failed` : void 0)
|
|
1347
|
+
};
|
|
1348
|
+
const finalState = await runJobCompletionHooks(opts, state, opts.outputsMap, startTime);
|
|
1349
|
+
return {
|
|
1350
|
+
status: finalState.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
|
|
1351
|
+
stepResults,
|
|
1352
|
+
failureReason: finalState.failureReason
|
|
1353
|
+
};
|
|
954
1354
|
}
|
|
1355
|
+
//#endregion
|
|
1356
|
+
//#region src/execution/env-init/init-phase.ts
|
|
1357
|
+
/** Default init timeout when a spec sets none: 10 minutes. */
|
|
1358
|
+
const DEFAULT_INIT_TIMEOUT_MS = 600 * 1e3;
|
|
955
1359
|
/**
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
* `node_modules/.pnpm` store and in-repo workspace sibling dirs. `.kici/` itself
|
|
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.
|
|
1360
|
+
* Marker thrown when an init command exceeds its wall-clock budget. Carries the
|
|
1361
|
+
* distinct P3 timeout reason so the phase result + job.complete report a timeout
|
|
1362
|
+
* rather than a generic failure.
|
|
966
1363
|
*/
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1364
|
+
var InitTimeoutError = class extends Error {
|
|
1365
|
+
reason = TimeoutReason.enum.job_timeout;
|
|
1366
|
+
constructor(timeoutMs) {
|
|
1367
|
+
super(`init command exceeded its timeout of ${timeoutMs}ms`);
|
|
1368
|
+
this.name = "InitTimeoutError";
|
|
1369
|
+
}
|
|
1370
|
+
};
|
|
1371
|
+
/** Run all init specs in order; stop + fail at the first non-zero / timeout. */
|
|
1372
|
+
async function runInitPhase(opts) {
|
|
1373
|
+
if (!opts.specs || opts.specs.length === 0) return { ok: true };
|
|
1374
|
+
for (let i = 0; i < opts.specs.length; i++) {
|
|
1375
|
+
const spec = opts.specs[i];
|
|
1376
|
+
const stepIndex = opts.stepIndexBase + i;
|
|
1377
|
+
const stepType = `init:${i}`;
|
|
1378
|
+
const outcome = await runOneInit(spec, i, stepIndex, stepType, opts);
|
|
1379
|
+
if (!outcome.ok) return {
|
|
1380
|
+
ok: false,
|
|
1381
|
+
failedInitIndex: i,
|
|
1382
|
+
error: outcome.error,
|
|
1383
|
+
...outcome.timedOut && {
|
|
1384
|
+
timedOut: true,
|
|
1385
|
+
reason: outcome.reason
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
return { ok: true };
|
|
972
1390
|
}
|
|
973
|
-
/**
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1391
|
+
/**
|
|
1392
|
+
* Run a promise against a wall-clock budget. Aborts via an AbortController on
|
|
1393
|
+
* breach (mirroring the step loop) and rejects with an {@link InitTimeoutError}
|
|
1394
|
+
* carrying the distinct P3 timeout reason. Resolves with the command's value
|
|
1395
|
+
* when it finishes first.
|
|
1396
|
+
*/
|
|
1397
|
+
function withInitTimeout(run, timeoutMs) {
|
|
1398
|
+
const ac = new AbortController();
|
|
1399
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
1400
|
+
return Promise.race([run, new Promise((_, reject) => {
|
|
1401
|
+
ac.signal.addEventListener("abort", () => reject(new InitTimeoutError(timeoutMs)));
|
|
1402
|
+
})]).finally(() => clearTimeout(timer));
|
|
981
1403
|
}
|
|
982
|
-
|
|
983
|
-
|
|
1404
|
+
async function runOneInit(spec, index, stepIndex, stepType, opts) {
|
|
1405
|
+
opts.sendIpc({
|
|
1406
|
+
type: "step.start",
|
|
1407
|
+
stepIndex,
|
|
1408
|
+
stepName: stepType,
|
|
1409
|
+
step_type: stepType
|
|
1410
|
+
});
|
|
1411
|
+
const start = Date.now();
|
|
1412
|
+
const shell = spec.shell ?? "bash";
|
|
984
1413
|
try {
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1414
|
+
let cacheHit = false;
|
|
1415
|
+
if (spec.cache && opts.cache) cacheHit = (await opts.cache.restore(spec.cache)).hit;
|
|
1416
|
+
await opts.env?.beginCapture();
|
|
1417
|
+
const $ = opts.shellFor(spec, index);
|
|
1418
|
+
const timeoutMs = spec.timeout ?? DEFAULT_INIT_TIMEOUT_MS;
|
|
1419
|
+
await withInitTimeout($`${shell} -c ${spec.run}`, timeoutMs);
|
|
1420
|
+
if (spec.cache && opts.cache && !cacheHit) await opts.cache.save(spec.cache);
|
|
1421
|
+
await opts.env?.applyDelta();
|
|
1422
|
+
opts.sendIpc({
|
|
1423
|
+
type: "step.complete",
|
|
1424
|
+
stepIndex,
|
|
1425
|
+
status: ExecutionStepStatus.enum.success,
|
|
1426
|
+
durationMs: Date.now() - start,
|
|
1427
|
+
step_type: stepType
|
|
988
1428
|
});
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1429
|
+
return { ok: true };
|
|
1430
|
+
} catch (e) {
|
|
1431
|
+
const error = toErrorMessage(e);
|
|
1432
|
+
const timedOut = e instanceof InitTimeoutError;
|
|
1433
|
+
const exitCode = e && typeof e === "object" && "exitCode" in e && typeof e.exitCode === "number" ? e.exitCode : void 0;
|
|
1434
|
+
opts.sendIpc({
|
|
1435
|
+
type: "step.complete",
|
|
1436
|
+
stepIndex,
|
|
1437
|
+
status: ExecutionStepStatus.enum.failed,
|
|
1438
|
+
durationMs: Date.now() - start,
|
|
1439
|
+
error: {
|
|
1440
|
+
message: error,
|
|
1441
|
+
...exitCode !== void 0 && { exitCode }
|
|
1442
|
+
},
|
|
1443
|
+
step_type: stepType
|
|
993
1444
|
});
|
|
1445
|
+
return {
|
|
1446
|
+
ok: false,
|
|
1447
|
+
error,
|
|
1448
|
+
...timedOut && {
|
|
1449
|
+
timedOut: true,
|
|
1450
|
+
reason: e.reason
|
|
1451
|
+
}
|
|
1452
|
+
};
|
|
994
1453
|
}
|
|
995
1454
|
}
|
|
1455
|
+
//#endregion
|
|
1456
|
+
//#region src/execution/sandbox/job-deadline.ts
|
|
996
1457
|
/**
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1458
|
+
* Arm a job-level wall-clock deadline. When `timeoutMs` is set and elapses
|
|
1459
|
+
* before clear() is called, invokes `onTimeout` with the distinct
|
|
1460
|
+
* `job_timeout` reason and the configured budget. A no-op when `timeoutMs`
|
|
1461
|
+
* is undefined (no job-level cap configured).
|
|
1462
|
+
*/
|
|
1463
|
+
function armJobDeadline(timeoutMs, onTimeout) {
|
|
1464
|
+
if (timeoutMs === void 0 || timeoutMs <= 0) return { clear: () => {} };
|
|
1465
|
+
const timer = setTimeout(() => {
|
|
1466
|
+
onTimeout(TimeoutReason.enum.job_timeout, timeoutMs);
|
|
1467
|
+
}, timeoutMs);
|
|
1468
|
+
timer.unref?.();
|
|
1469
|
+
return { clear: () => clearTimeout(timer) };
|
|
1470
|
+
}
|
|
1471
|
+
//#endregion
|
|
1472
|
+
//#region src/checkout/ssh-auth.ts
|
|
1473
|
+
/**
|
|
1474
|
+
* Materialize an SSH private key (and optional pinned known_hosts) into a
|
|
1475
|
+
* tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
|
|
1004
1476
|
*
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1477
|
+
* Permissions:
|
|
1478
|
+
* - private key mode 0o600 (required by OpenSSH — refuses to use world-
|
|
1479
|
+
* readable keys).
|
|
1480
|
+
* - known_hosts mode 0o600.
|
|
1481
|
+
* - tempdir mode 0o700.
|
|
1008
1482
|
*
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1483
|
+
* SSH flags composed:
|
|
1484
|
+
* - `-i <keyfile>` — identity file.
|
|
1485
|
+
* - `-o IdentitiesOnly=yes` — don't try other keys from ssh-agent / ~/.ssh.
|
|
1486
|
+
* - `-o BatchMode=yes` — never prompt for passwords / passphrases.
|
|
1487
|
+
* - host-key checking flags based on `hostKeyPolicy`.
|
|
1012
1488
|
*/
|
|
1013
|
-
async function
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1489
|
+
async function setupSshAuth(opts) {
|
|
1490
|
+
if (opts.hostKeyPolicy === "pinned" && !opts.knownHosts) throw new Error("pinned hostKeyPolicy requires knownHosts content");
|
|
1491
|
+
const tempDir = await mkdtemp(join(tmpdir(), "kici-ssh-"));
|
|
1492
|
+
const keyPath = join(tempDir, "id");
|
|
1493
|
+
await writeFile(keyPath, opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`, { mode: 384 });
|
|
1494
|
+
const knownHostsPath = join(tempDir, "known_hosts");
|
|
1495
|
+
await writeFile(knownHostsPath, opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "", { mode: 384 });
|
|
1496
|
+
const parts = [
|
|
1497
|
+
"ssh",
|
|
1498
|
+
"-i",
|
|
1499
|
+
escapeShellArg(keyPath),
|
|
1500
|
+
"-o",
|
|
1501
|
+
"IdentitiesOnly=yes",
|
|
1502
|
+
"-o",
|
|
1503
|
+
"BatchMode=yes",
|
|
1504
|
+
"-o",
|
|
1505
|
+
`UserKnownHostsFile=${escapeShellArg(knownHostsPath)}`
|
|
1506
|
+
];
|
|
1507
|
+
if (opts.hostKeyPolicy === "pinned") parts.push("-o", "StrictHostKeyChecking=yes");
|
|
1508
|
+
else parts.push("-o", "StrictHostKeyChecking=accept-new");
|
|
1509
|
+
return {
|
|
1510
|
+
gitSshCommand: parts.join(" "),
|
|
1511
|
+
tempDir,
|
|
1512
|
+
async cleanup() {
|
|
1513
|
+
await rm(tempDir, {
|
|
1514
|
+
recursive: true,
|
|
1515
|
+
force: true
|
|
1516
|
+
});
|
|
1023
1517
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* Quote a path for inclusion in `GIT_SSH_COMMAND`. We use single-quote
|
|
1522
|
+
* wrapping so backslashes and spaces survive git's shell-parse of the
|
|
1523
|
+
* command value.
|
|
1524
|
+
*/
|
|
1525
|
+
function escapeShellArg(value) {
|
|
1526
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
1527
|
+
}
|
|
1528
|
+
//#endregion
|
|
1529
|
+
//#region src/checkout/git-clone.ts
|
|
1530
|
+
/**
|
|
1531
|
+
* Strip auth credentials from git error messages to prevent token leakage.
|
|
1532
|
+
* Node's execFileSync includes the full command line (including -c http.extraHeader
|
|
1533
|
+
* and GIT_SSH_COMMAND flags) in error messages, which would expose Base64-encoded
|
|
1534
|
+
* tokens or the absolute path of a temporary SSH key file in logs.
|
|
1535
|
+
*/
|
|
1536
|
+
function sanitizeGitError(error) {
|
|
1537
|
+
if (!(error instanceof Error)) return new Error(String(error));
|
|
1538
|
+
const sanitized = new Error(redactSensitive(error.message));
|
|
1539
|
+
sanitized.stack = error.stack ? redactSensitive(error.stack) : void 0;
|
|
1540
|
+
return sanitized;
|
|
1541
|
+
}
|
|
1542
|
+
function redactSensitive(input) {
|
|
1543
|
+
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]");
|
|
1544
|
+
}
|
|
1545
|
+
/**
|
|
1546
|
+
* Shallow-clone a git repository at a specific ref with optional token auth.
|
|
1547
|
+
*
|
|
1548
|
+
* Token authentication uses git's `-c http.extraHeader` mechanism which keeps
|
|
1549
|
+
* the token out of the clone URL (not visible in `git remote -v` or logs).
|
|
1550
|
+
*
|
|
1551
|
+
* After clone, verifies that HEAD matches the expected SHA to prevent
|
|
1552
|
+
* wrong-ref execution.
|
|
1553
|
+
*
|
|
1554
|
+
* @throws Error if clone fails or SHA does not match
|
|
1555
|
+
*/
|
|
1556
|
+
async function gitClone(options) {
|
|
1557
|
+
const { repoUrl, ref, sha, workDir, token, gitAuth, depth = 1 } = options;
|
|
1558
|
+
const auth = gitAuth ? gitAuth : token ? {
|
|
1559
|
+
kind: "basic",
|
|
1560
|
+
user: "x-access-token",
|
|
1561
|
+
secret: token
|
|
1562
|
+
} : void 0;
|
|
1563
|
+
const args = [];
|
|
1564
|
+
const envEntries = {};
|
|
1565
|
+
let needsCustomEnv = false;
|
|
1566
|
+
let safeDirCleanup;
|
|
1567
|
+
if (repoUrl.startsWith("file://")) {
|
|
1568
|
+
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
|
1569
|
+
const { tmpdir } = await import("node:os");
|
|
1570
|
+
const path = await import("node:path");
|
|
1571
|
+
const dir = await mkdtemp(path.join(tmpdir(), "kici-gitcfg-"));
|
|
1572
|
+
const cfgPath = path.join(dir, "config");
|
|
1573
|
+
await writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
|
|
1574
|
+
envEntries.GIT_CONFIG_GLOBAL = cfgPath;
|
|
1575
|
+
needsCustomEnv = true;
|
|
1576
|
+
safeDirCleanup = async () => {
|
|
1577
|
+
await rm(dir, {
|
|
1578
|
+
recursive: true,
|
|
1579
|
+
force: true
|
|
1580
|
+
}).catch(() => {});
|
|
1581
|
+
};
|
|
1034
1582
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
})
|
|
1583
|
+
let sshSetup;
|
|
1584
|
+
try {
|
|
1585
|
+
if (auth?.kind === "basic") {
|
|
1586
|
+
const user = auth.user ?? "x-access-token";
|
|
1587
|
+
const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
|
|
1588
|
+
args.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
|
|
1589
|
+
} else if (auth?.kind === "ssh") {
|
|
1590
|
+
sshSetup = await setupSshAuth({
|
|
1591
|
+
privateKey: auth.secret,
|
|
1592
|
+
hostKeyPolicy: auth.sshHostKeyPolicy,
|
|
1593
|
+
knownHosts: auth.sshKnownHostsPem
|
|
1594
|
+
});
|
|
1595
|
+
envEntries.GIT_SSH_COMMAND = sshSetup.gitSshCommand;
|
|
1596
|
+
needsCustomEnv = true;
|
|
1597
|
+
}
|
|
1598
|
+
const env = needsCustomEnv ? {
|
|
1599
|
+
...process.env,
|
|
1600
|
+
...envEntries
|
|
1601
|
+
} : void 0;
|
|
1602
|
+
if (ref) args.push("clone", "--depth", String(depth), "--branch", ref, repoUrl, workDir);
|
|
1603
|
+
else args.push("clone", "--depth", String(depth), repoUrl, workDir);
|
|
1604
|
+
const { execFileSync } = await import("node:child_process");
|
|
1042
1605
|
try {
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
return;
|
|
1606
|
+
execFileSync("git", args, {
|
|
1607
|
+
stdio: "pipe",
|
|
1608
|
+
timeout: 12e4,
|
|
1609
|
+
...env && { env }
|
|
1610
|
+
});
|
|
1049
1611
|
} catch (err) {
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1612
|
+
throw sanitizeGitError(err);
|
|
1613
|
+
}
|
|
1614
|
+
if (!sha || sha === "HEAD") return;
|
|
1615
|
+
const envOpts = env ? { env } : {};
|
|
1616
|
+
if (!execFileSync("git", [
|
|
1617
|
+
"-C",
|
|
1618
|
+
workDir,
|
|
1619
|
+
"rev-parse",
|
|
1620
|
+
"HEAD"
|
|
1621
|
+
], {
|
|
1622
|
+
encoding: "utf-8",
|
|
1623
|
+
timeout: 1e4,
|
|
1624
|
+
...envOpts
|
|
1625
|
+
}).trim().startsWith(sha)) {
|
|
1626
|
+
const fetchArgs = [];
|
|
1627
|
+
if (auth?.kind === "basic") {
|
|
1628
|
+
const user = auth.user ?? "x-access-token";
|
|
1629
|
+
const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
|
|
1630
|
+
fetchArgs.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
|
|
1631
|
+
}
|
|
1632
|
+
fetchArgs.push("fetch", "--depth", "50", "origin", sha);
|
|
1633
|
+
try {
|
|
1634
|
+
execFileSync("git", [
|
|
1635
|
+
"-C",
|
|
1636
|
+
workDir,
|
|
1637
|
+
...fetchArgs
|
|
1638
|
+
], {
|
|
1639
|
+
stdio: "pipe",
|
|
1640
|
+
timeout: 12e4,
|
|
1641
|
+
...envOpts
|
|
1642
|
+
});
|
|
1643
|
+
} catch (err) {
|
|
1644
|
+
throw sanitizeGitError(err);
|
|
1645
|
+
}
|
|
1646
|
+
execFileSync("git", [
|
|
1647
|
+
"-C",
|
|
1648
|
+
workDir,
|
|
1649
|
+
"checkout",
|
|
1650
|
+
sha
|
|
1651
|
+
], {
|
|
1652
|
+
stdio: "pipe",
|
|
1653
|
+
timeout: 3e4,
|
|
1654
|
+
...envOpts
|
|
1054
1655
|
});
|
|
1656
|
+
const recheckedSha = execFileSync("git", [
|
|
1657
|
+
"-C",
|
|
1658
|
+
workDir,
|
|
1659
|
+
"rev-parse",
|
|
1660
|
+
"HEAD"
|
|
1661
|
+
], {
|
|
1662
|
+
encoding: "utf-8",
|
|
1663
|
+
timeout: 1e4,
|
|
1664
|
+
...envOpts
|
|
1665
|
+
}).trim();
|
|
1666
|
+
if (!recheckedSha.startsWith(sha)) throw new Error(`SHA mismatch: expected ${sha}, got ${recheckedSha}`);
|
|
1055
1667
|
}
|
|
1668
|
+
} finally {
|
|
1669
|
+
if (sshSetup) await sshSetup.cleanup().catch(() => {});
|
|
1670
|
+
if (safeDirCleanup) await safeDirCleanup();
|
|
1056
1671
|
}
|
|
1057
|
-
throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
|
|
1058
1672
|
}
|
|
1059
1673
|
//#endregion
|
|
1060
1674
|
//#region src/execution/npm-resolver.ts
|
|
@@ -1483,7 +2097,8 @@ async function runPnpmInstall(args) {
|
|
|
1483
2097
|
`--config.store-dir=${storeDir}`,
|
|
1484
2098
|
"--config.package-import-method=copy",
|
|
1485
2099
|
"--config.confirm-modules-purge=false",
|
|
1486
|
-
"--config.side-effects-cache=false"
|
|
2100
|
+
"--config.side-effects-cache=false",
|
|
2101
|
+
PNPM_IGNORE_BUILD_GATE_ARG
|
|
1487
2102
|
];
|
|
1488
2103
|
if (args.hasPrivateRegistry) argv.push("--ignore-scripts");
|
|
1489
2104
|
try {
|
|
@@ -1558,23 +2173,25 @@ function logSubprocessStreams(e, tokens) {
|
|
|
1558
2173
|
//#region src/execution/workflow-loader.ts
|
|
1559
2174
|
/**
|
|
1560
2175
|
* Workflow module loading: transforms `.ts` workflow files on import via the
|
|
1561
|
-
*
|
|
2176
|
+
* `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook. Customer
|
|
2177
|
+
* workflow code is imported
|
|
1562
2178
|
* directly from the cloned / extracted source tree — no intermediate bundle,
|
|
1563
2179
|
* no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
|
|
1564
2180
|
* Node's normal ESM lookup against `.kici/node_modules/`.
|
|
1565
2181
|
*/
|
|
1566
|
-
const AGENT_SDK_VERSION = "0.1.
|
|
1567
|
-
const AGENT_SDK_BUNDLE_HASH = "
|
|
2182
|
+
const AGENT_SDK_VERSION = "0.1.15";
|
|
2183
|
+
const AGENT_SDK_BUNDLE_HASH = "cee24b67bbb4a1d2a770e4063dd37a9af33409faeb5ce32f936703b4431316dd";
|
|
1568
2184
|
/**
|
|
1569
|
-
* Register the
|
|
1570
|
-
* `import()` calls for `.ts` / `.tsx` files transform on the
|
|
1571
|
-
* at our level via the `hookRegistered` flag; Node also
|
|
1572
|
-
* `register()` calls by stacking layers, but we avoid the
|
|
2185
|
+
* Register the `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook so
|
|
2186
|
+
* subsequent dynamic `import()` calls for `.ts` / `.tsx` files transform on the
|
|
2187
|
+
* fly. Idempotent at our level via the `hookRegistered` flag; Node also
|
|
2188
|
+
* tolerates repeated `register()` calls by stacking layers, but we avoid the
|
|
2189
|
+
* noise.
|
|
1573
2190
|
*/
|
|
1574
2191
|
let hookRegistered = false;
|
|
1575
2192
|
function ensureLoaderHookRegistered() {
|
|
1576
2193
|
if (hookRegistered) return;
|
|
1577
|
-
register("@kici-dev/
|
|
2194
|
+
register("@kici-dev/core/ts-loader-hook", import.meta.url);
|
|
1578
2195
|
hookRegistered = true;
|
|
1579
2196
|
}
|
|
1580
2197
|
/**
|
|
@@ -1724,40 +2341,6 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
|
|
|
1724
2341
|
throw new Error(`Generated job '${jobName}' not found in DynamicJobFn output (workflow '${workflow.name}', index ${dynamicIndex}). Available: ${actualNames.join(", ")}`);
|
|
1725
2342
|
}
|
|
1726
2343
|
//#endregion
|
|
1727
|
-
//#region src/execution/download.ts
|
|
1728
|
-
/**
|
|
1729
|
-
* Shared HTTP/HTTPS download utility.
|
|
1730
|
-
*
|
|
1731
|
-
* Extracted from workflow-loader.ts to avoid duplication across
|
|
1732
|
-
* dep-restore.ts and workflow-loader.ts.
|
|
1733
|
-
*/
|
|
1734
|
-
/** Download timeout: 5 minutes. */
|
|
1735
|
-
const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
|
|
1736
|
-
/**
|
|
1737
|
-
* Download content from an HTTP/HTTPS URL.
|
|
1738
|
-
*
|
|
1739
|
-
* Includes a 5-minute timeout to prevent the agent from hanging indefinitely
|
|
1740
|
-
* on slow or unresponsive endpoints.
|
|
1741
|
-
*
|
|
1742
|
-
* @param url - The URL to download from
|
|
1743
|
-
* @returns The response body as a Buffer
|
|
1744
|
-
*/
|
|
1745
|
-
function downloadUrl(url) {
|
|
1746
|
-
return new Promise((resolve, reject) => {
|
|
1747
|
-
(url.startsWith("https:") ? https : http).get(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }, (res) => {
|
|
1748
|
-
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
|
1749
|
-
reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} downloading from ${url}`));
|
|
1750
|
-
res.resume();
|
|
1751
|
-
return;
|
|
1752
|
-
}
|
|
1753
|
-
const chunks = [];
|
|
1754
|
-
res.on("data", (chunk) => chunks.push(chunk));
|
|
1755
|
-
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1756
|
-
res.on("error", reject);
|
|
1757
|
-
}).on("error", reject);
|
|
1758
|
-
});
|
|
1759
|
-
}
|
|
1760
|
-
//#endregion
|
|
1761
2344
|
//#region src/execution/source-restore.ts
|
|
1762
2345
|
/**
|
|
1763
2346
|
* `.kici/` source tarball restoration for execution agents.
|
|
@@ -1780,6 +2363,8 @@ function downloadUrl(url) {
|
|
|
1780
2363
|
* runs without a preceding build (cache infrastructure unavailable, or a
|
|
1781
2364
|
* build job that failed but left dynamic dispatch in flight).
|
|
1782
2365
|
*/
|
|
2366
|
+
init_download();
|
|
2367
|
+
init_dep_restore();
|
|
1783
2368
|
const logger$1 = createLogger({ prefix: "source-restore" });
|
|
1784
2369
|
async function extractSourceTarball(data, targetDir) {
|
|
1785
2370
|
await mkdir(targetDir, { recursive: true });
|
|
@@ -1821,6 +2406,7 @@ async function restoreSource(workDir, sourceTarUrl) {
|
|
|
1821
2406
|
* Wire format: [12-byte IV][16-byte auth tag][ciphertext]
|
|
1822
2407
|
* Same encryption scheme as packages/compiler/src/remote/encryption.ts.
|
|
1823
2408
|
*/
|
|
2409
|
+
init_download();
|
|
1824
2410
|
const logger = createLogger({ prefix: "overlay-applier" });
|
|
1825
2411
|
const IV_LENGTH = 12;
|
|
1826
2412
|
const AUTH_TAG_LENGTH = 16;
|
|
@@ -1954,6 +2540,7 @@ async function applyOverlay(config) {
|
|
|
1954
2540
|
* This file is compiled alongside the agent by rolldown (existing build), but
|
|
1955
2541
|
* runs as a SEPARATE process spawned by the sandbox backend.
|
|
1956
2542
|
*/
|
|
2543
|
+
init_dep_restore();
|
|
1957
2544
|
process.on("uncaughtException", (err) => {
|
|
1958
2545
|
process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
|
|
1959
2546
|
if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
|
|
@@ -2136,6 +2723,21 @@ let aborted = false;
|
|
|
2136
2723
|
*/
|
|
2137
2724
|
let forceAborted = false;
|
|
2138
2725
|
/**
|
|
2726
|
+
* Set when the job-level wall-clock deadline (the lock job's `timeout`) is
|
|
2727
|
+
* breached. Trips the same abort path cancellation uses, but tags the
|
|
2728
|
+
* job.complete with the distinct TimeoutReason.job_timeout reason.
|
|
2729
|
+
*/
|
|
2730
|
+
let jobTimedOut = false;
|
|
2731
|
+
/** The configured job timeout budget in ms, captured when the deadline fires. */
|
|
2732
|
+
let jobTimedOutMs;
|
|
2733
|
+
/**
|
|
2734
|
+
* Aborted when the job-level deadline fires. Threaded into the step loop so the
|
|
2735
|
+
* IN-FLIGHT step (e.g. a long-running `sleep`) is interrupted immediately on
|
|
2736
|
+
* breach — the between-steps `isAborted()` check alone cannot unwind a single
|
|
2737
|
+
* long step that has no per-step `timeout`.
|
|
2738
|
+
*/
|
|
2739
|
+
const jobDeadlineAbort = new AbortController();
|
|
2740
|
+
/**
|
|
2139
2741
|
* Pending promises for event.emit requests awaiting responses from the agent.
|
|
2140
2742
|
*
|
|
2141
2743
|
* Key: requestId (correlates EventEmitRequest -> EventEmitResponse)
|
|
@@ -2242,6 +2844,128 @@ function waitForApiResponse(requestId) {
|
|
|
2242
2844
|
});
|
|
2243
2845
|
}
|
|
2244
2846
|
/**
|
|
2847
|
+
* Pending promises for cache.response messages from the agent.
|
|
2848
|
+
* Key: requestId (correlates cache.request -> cache.response).
|
|
2849
|
+
*/
|
|
2850
|
+
const pendingCacheResponses = /* @__PURE__ */ new Map();
|
|
2851
|
+
/** Default timeout for a cache request relay (matches the upload-URL request budget). */
|
|
2852
|
+
const CACHE_RESPONSE_TIMEOUT_MS = 3e4;
|
|
2853
|
+
/** Wait for a cache.response from the agent with the given requestId. */
|
|
2854
|
+
function waitForCacheResponse(requestId) {
|
|
2855
|
+
return new Promise((resolve, reject) => {
|
|
2856
|
+
const timer = setTimeout(() => {
|
|
2857
|
+
pendingCacheResponses.delete(requestId);
|
|
2858
|
+
reject(/* @__PURE__ */ new Error(`Cache request timed out after ${CACHE_RESPONSE_TIMEOUT_MS}ms`));
|
|
2859
|
+
}, CACHE_RESPONSE_TIMEOUT_MS);
|
|
2860
|
+
pendingCacheResponses.set(requestId, {
|
|
2861
|
+
resolve: (response) => {
|
|
2862
|
+
clearTimeout(timer);
|
|
2863
|
+
pendingCacheResponses.delete(requestId);
|
|
2864
|
+
resolve(response);
|
|
2865
|
+
},
|
|
2866
|
+
reject: (err) => {
|
|
2867
|
+
clearTimeout(timer);
|
|
2868
|
+
pendingCacheResponses.delete(requestId);
|
|
2869
|
+
reject(err);
|
|
2870
|
+
},
|
|
2871
|
+
timer
|
|
2872
|
+
});
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
/**
|
|
2876
|
+
* Build the {@link CacheTransport} the sandbox-side cache engine uses to reach
|
|
2877
|
+
* the orchestrator. Each method sends a `cache.request` IPC (relayed by the
|
|
2878
|
+
* agent over the WS as a `cache.user.*` message) and awaits the matching
|
|
2879
|
+
* `cache.response`. Mirrors the `event.emit` / `agent.api.request` relays.
|
|
2880
|
+
*/
|
|
2881
|
+
function buildCacheTransport() {
|
|
2882
|
+
return {
|
|
2883
|
+
async restore(key, restoreKeys) {
|
|
2884
|
+
const requestId = randomUUID();
|
|
2885
|
+
sendMessage({
|
|
2886
|
+
type: "cache.request",
|
|
2887
|
+
requestId,
|
|
2888
|
+
op: "restore",
|
|
2889
|
+
key,
|
|
2890
|
+
...restoreKeys && { restoreKeys }
|
|
2891
|
+
});
|
|
2892
|
+
const response = await waitForCacheResponse(requestId);
|
|
2893
|
+
if (response.error) throw new Error(`Cache restore failed: ${response.error}`);
|
|
2894
|
+
return {
|
|
2895
|
+
hit: response.hit ?? false,
|
|
2896
|
+
...response.matchedKey && { matchedKey: response.matchedKey },
|
|
2897
|
+
...response.downloadUrl && { downloadUrl: response.downloadUrl },
|
|
2898
|
+
...response.tarHash && { tarHash: response.tarHash }
|
|
2899
|
+
};
|
|
2900
|
+
},
|
|
2901
|
+
async beginSave(key) {
|
|
2902
|
+
const requestId = randomUUID();
|
|
2903
|
+
sendMessage({
|
|
2904
|
+
type: "cache.request",
|
|
2905
|
+
requestId,
|
|
2906
|
+
op: "beginSave",
|
|
2907
|
+
key
|
|
2908
|
+
});
|
|
2909
|
+
const response = await waitForCacheResponse(requestId);
|
|
2910
|
+
if (response.error) throw new Error(`Cache save failed: ${response.error}`);
|
|
2911
|
+
return {
|
|
2912
|
+
skip: response.skip ?? true,
|
|
2913
|
+
...response.uploadUrl && { uploadUrl: response.uploadUrl }
|
|
2914
|
+
};
|
|
2915
|
+
},
|
|
2916
|
+
async completeSave(key, tarHash, sizeBytes) {
|
|
2917
|
+
const requestId = randomUUID();
|
|
2918
|
+
sendMessage({
|
|
2919
|
+
type: "cache.request",
|
|
2920
|
+
requestId,
|
|
2921
|
+
op: "completeSave",
|
|
2922
|
+
key,
|
|
2923
|
+
tarHash,
|
|
2924
|
+
sizeBytes
|
|
2925
|
+
});
|
|
2926
|
+
const response = await waitForCacheResponse(requestId);
|
|
2927
|
+
if (response.error) throw new Error(`Cache save-complete failed: ${response.error}`);
|
|
2928
|
+
}
|
|
2929
|
+
};
|
|
2930
|
+
}
|
|
2931
|
+
/**
|
|
2932
|
+
* Build the declarative-cache phase dependencies and run the job-level cache
|
|
2933
|
+
* restore (Phase 9b).
|
|
2934
|
+
*
|
|
2935
|
+
* The deps carry one job-scoped cache API (over the same IPC→WS transport
|
|
2936
|
+
* `ctx.cache` uses) plus a monotonic pseudo-step index allocator that starts
|
|
2937
|
+
* well above every real-step and hook index (hooks use up to `stepCount * 3`).
|
|
2938
|
+
* Cache restores/saves surface as `cache:restore` / `cache:save` pseudo-steps
|
|
2939
|
+
* allocated from this cursor. The returned `jobCacheRestore` map lets the
|
|
2940
|
+
* post-loop save phase skip exact-key hits.
|
|
2941
|
+
*/
|
|
2942
|
+
async function setupJobCache(stepCwd, stepCount, jobCache, sendIpc) {
|
|
2943
|
+
let cacheStepCursor = stepCount * 3 + 100;
|
|
2944
|
+
const cachePhaseDeps = {
|
|
2945
|
+
cache: createCacheApi(stepCwd, buildCacheTransport()),
|
|
2946
|
+
sendIpc,
|
|
2947
|
+
nextStepIndex: () => cacheStepCursor++
|
|
2948
|
+
};
|
|
2949
|
+
const jobCacheSpecs = normalizeCacheSpecs(jobCache);
|
|
2950
|
+
return {
|
|
2951
|
+
cachePhaseDeps,
|
|
2952
|
+
jobCacheSpecs,
|
|
2953
|
+
jobCacheRestore: jobCacheSpecs.length > 0 ? await restoreCacheSpecs(jobCacheSpecs, cachePhaseDeps) : /* @__PURE__ */ new Map()
|
|
2954
|
+
};
|
|
2955
|
+
}
|
|
2956
|
+
/**
|
|
2957
|
+
* Job-level cache save (Phase 9c). Runs after the step loop, only when every
|
|
2958
|
+
* step succeeded and the job was not aborted. Each spec whose exact key did NOT
|
|
2959
|
+
* already hit on restore is saved (immutable: a re-save of an existing exact
|
|
2960
|
+
* key is skipped by the save phase itself). A no-op when the job declared no
|
|
2961
|
+
* cache or did not fully succeed (a failed/aborted job's artifacts may be
|
|
2962
|
+
* partial).
|
|
2963
|
+
*/
|
|
2964
|
+
async function maybeSaveJobCache(specs, restoreResults, deps, succeeded) {
|
|
2965
|
+
if (specs.length === 0 || !succeeded) return;
|
|
2966
|
+
await saveCacheSpecs(specs, restoreResults, deps);
|
|
2967
|
+
}
|
|
2968
|
+
/**
|
|
2245
2969
|
* Dispatch an agent-to-runner message to the appropriate pending handler.
|
|
2246
2970
|
* Shared between fork mode (IPC channel) and stdio mode (stdin readline).
|
|
2247
2971
|
*/
|
|
@@ -2258,6 +2982,9 @@ function dispatchAgentMessage(msg) {
|
|
|
2258
2982
|
const pending = pendingApiResponses.get(msg.requestId);
|
|
2259
2983
|
if (pending) if (msg.error) pending.reject(new Error(msg.error));
|
|
2260
2984
|
else pending.resolve(msg.result);
|
|
2985
|
+
} else if (msg.type === "cache.response") {
|
|
2986
|
+
const pending = pendingCacheResponses.get(msg.requestId);
|
|
2987
|
+
if (pending) pending.resolve(msg);
|
|
2261
2988
|
}
|
|
2262
2989
|
}
|
|
2263
2990
|
if (isForkMode) process.on("message", (msg) => {
|
|
@@ -2387,16 +3114,29 @@ function createSecretMasker(request) {
|
|
|
2387
3114
|
return masker;
|
|
2388
3115
|
}
|
|
2389
3116
|
/**
|
|
2390
|
-
*
|
|
3117
|
+
* Build a fresh zx `$` shell bound to the sandbox working directory and the
|
|
3118
|
+
* sanitized environment (process.env was set by the parent via env-sanitizer
|
|
3119
|
+
* before spawning this process). This is the single shell-construction code
|
|
3120
|
+
* path shared by step execution (`createSandboxStepContext`) and the per-job
|
|
3121
|
+
* init phase (`runInitPhase`), so init commands run through the identical shell
|
|
3122
|
+
* steps use — same cwd, same env snapshot, same masked log streaming.
|
|
2391
3123
|
*
|
|
2392
|
-
*
|
|
2393
|
-
*
|
|
2394
|
-
*
|
|
3124
|
+
* Intercept zx subprocess output via the log callback: zx does NOT write child
|
|
3125
|
+
* stdout/stderr to process.stdout — it pipes to an internal VoidStream and only
|
|
3126
|
+
* calls $.log() with { kind: 'stdout'|'stderr' }. With verbose=false the default
|
|
3127
|
+
* log function skips stdout entirely, and with quiet=false it writes stderr to
|
|
3128
|
+
* process.stderr. We override the log function to capture both kinds directly
|
|
3129
|
+
* and send them as masked IPC log.line messages tagged with `stepIndex`.
|
|
3130
|
+
*
|
|
3131
|
+
* IMPORTANT: The log function must be passed in the zx$() config, not set on the
|
|
3132
|
+
* returned function. zx$() returns a plain function (not the proxy $), so setting
|
|
3133
|
+
* step$.log would only set it on the function object and NOT propagate to the
|
|
3134
|
+
* AsyncLocalStorage store that zx uses for ProcessPromise snapshots.
|
|
2395
3135
|
*/
|
|
2396
|
-
function
|
|
3136
|
+
function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
|
|
2397
3137
|
let zxLineBuf = "";
|
|
2398
|
-
|
|
2399
|
-
cwd
|
|
3138
|
+
return $({
|
|
3139
|
+
cwd,
|
|
2400
3140
|
env: { ...process.env },
|
|
2401
3141
|
verbose: false,
|
|
2402
3142
|
quiet: false,
|
|
@@ -2415,20 +3155,36 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
2415
3155
|
}
|
|
2416
3156
|
})
|
|
2417
3157
|
});
|
|
3158
|
+
}
|
|
3159
|
+
/**
|
|
3160
|
+
* Create a StepContext natively inside the workflow runner.
|
|
3161
|
+
*
|
|
3162
|
+
* The context is reconstructed from the environment and IPC request fields --
|
|
3163
|
+
* NOT serialized across the process boundary. This means zx $ runs natively
|
|
3164
|
+
* inside this process with full shell access.
|
|
3165
|
+
*/
|
|
3166
|
+
function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets) {
|
|
3167
|
+
const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
|
|
2418
3168
|
const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
|
|
3169
|
+
const rawPayload = rawPayloadFromEvent(request.event);
|
|
2419
3170
|
return {
|
|
2420
3171
|
$: step$,
|
|
2421
3172
|
log,
|
|
2422
3173
|
env: process.env,
|
|
2423
3174
|
setEnv: (key, value) => {
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
}
|
|
2428
|
-
|
|
3175
|
+
applyEnvDelta({
|
|
3176
|
+
env: { [key]: value },
|
|
3177
|
+
pathPrepends: []
|
|
3178
|
+
}, {
|
|
3179
|
+
operatorSecretKeys,
|
|
3180
|
+
onReject: (k) => log.warn(`Cannot override operator secret "${k}" via setEnv — value preserved`)
|
|
3181
|
+
});
|
|
2429
3182
|
},
|
|
2430
3183
|
addPath: (dir) => {
|
|
2431
|
-
|
|
3184
|
+
applyEnvDelta({
|
|
3185
|
+
env: {},
|
|
3186
|
+
pathPrepends: [dir]
|
|
3187
|
+
}, { operatorSecretKeys });
|
|
2432
3188
|
},
|
|
2433
3189
|
inputs: {},
|
|
2434
3190
|
secrets,
|
|
@@ -2439,6 +3195,7 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
2439
3195
|
},
|
|
2440
3196
|
isTestRun: request.isTestRun ?? false,
|
|
2441
3197
|
environment: request.environment,
|
|
3198
|
+
cache: createCacheApi(workDir, buildCacheTransport()),
|
|
2442
3199
|
emit: async (eventName, payload, options) => {
|
|
2443
3200
|
const reqId = randomUUID();
|
|
2444
3201
|
sendMessage({
|
|
@@ -2471,10 +3228,15 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
2471
3228
|
});
|
|
2472
3229
|
return waitForApiResponse(reqId);
|
|
2473
3230
|
}),
|
|
2474
|
-
...
|
|
3231
|
+
...rawPayload && { rawPayload },
|
|
2475
3232
|
...request.provider && { provider: request.provider }
|
|
2476
3233
|
};
|
|
2477
3234
|
}
|
|
3235
|
+
/** Raw provider webhook body for ctx.rawPayload — nested in the envelope. */
|
|
3236
|
+
function rawPayloadFromEvent(event) {
|
|
3237
|
+
if (!event) return void 0;
|
|
3238
|
+
return event.payload ?? void 0;
|
|
3239
|
+
}
|
|
2478
3240
|
/**
|
|
2479
3241
|
* Check if a file exists at the given path.
|
|
2480
3242
|
*/
|
|
@@ -2496,7 +3258,8 @@ function abortAndExit(reason) {
|
|
|
2496
3258
|
sendMessage({
|
|
2497
3259
|
type: "job.complete",
|
|
2498
3260
|
status: ExecutionJobStatus.enum.failed,
|
|
2499
|
-
stepResults: []
|
|
3261
|
+
stepResults: [],
|
|
3262
|
+
...jobTimedOut && { error: `${TimeoutReason.enum.job_timeout}: job exceeded its timeout of ${jobTimedOutMs ?? 0}ms` }
|
|
2500
3263
|
});
|
|
2501
3264
|
process.exit(1);
|
|
2502
3265
|
}
|
|
@@ -3091,6 +3854,121 @@ function collectJobHooks(job) {
|
|
|
3091
3854
|
return jobHooks;
|
|
3092
3855
|
}
|
|
3093
3856
|
/**
|
|
3857
|
+
* Normalize `Job.init` (config | config[] | false | undefined) to an ordered
|
|
3858
|
+
* array of init specs. `false` is an explicit opt-out and `undefined` (no
|
|
3859
|
+
* config) both resolve to an empty list — the init phase is then a no-op.
|
|
3860
|
+
*/
|
|
3861
|
+
function resolveInitSpecs(job) {
|
|
3862
|
+
if (!job || job.init === void 0 || job.init === false) return [];
|
|
3863
|
+
return Array.isArray(job.init) ? [...job.init] : [job.init];
|
|
3864
|
+
}
|
|
3865
|
+
/**
|
|
3866
|
+
* Base stepIndex for the `init:<n>` pseudo-steps. The step loop reserves the
|
|
3867
|
+
* range starting at `steps.length` for hook pseudo-steps (`beforeStep` =
|
|
3868
|
+
* `steps.length + i*2`, `afterStep` = `steps.length + i*2 + 1`, and job-level
|
|
3869
|
+
* onSuccess/onFailure/cleanup from `steps.length` upward — see step-loop.ts),
|
|
3870
|
+
* so init indices must sit ABOVE every possible hook index to avoid collision.
|
|
3871
|
+
* A large fixed offset reserves a dedicated range no realistic step/hook count
|
|
3872
|
+
* can reach; init:<n> then occupies `INIT_STEP_INDEX_BASE + n`.
|
|
3873
|
+
*/
|
|
3874
|
+
const INIT_STEP_INDEX_BASE = 1e6;
|
|
3875
|
+
/**
|
|
3876
|
+
* Read the KICI_ENV/KICI_PATH delta written by a command, apply it through
|
|
3877
|
+
* `applyEnvDelta` (operator-secret override guard + masked reject log), then
|
|
3878
|
+
* truncate the files for the next command. Shared by the per-job init phase and
|
|
3879
|
+
* the per-step after-hook so both honor the identical operator-secret guard.
|
|
3880
|
+
*/
|
|
3881
|
+
async function applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend) {
|
|
3882
|
+
applyEnvDelta(await readEnvDelta(envFiles), {
|
|
3883
|
+
operatorSecretKeys,
|
|
3884
|
+
onReject: (key) => maskedSend({
|
|
3885
|
+
type: "log.line",
|
|
3886
|
+
stepIndex: -1,
|
|
3887
|
+
line: `[kici] Cannot override operator secret "${key}" via $KICI_ENV — value preserved`
|
|
3888
|
+
})
|
|
3889
|
+
});
|
|
3890
|
+
await truncateEnvFiles(envFiles);
|
|
3891
|
+
}
|
|
3892
|
+
/**
|
|
3893
|
+
* Build the step loop's KICI_ENV/KICI_PATH callbacks over the shared `envFiles`.
|
|
3894
|
+
* `beforeStepEnvFiles` points the runner's process.env at the files (each step's
|
|
3895
|
+
* zx $ snapshots process.env at context creation, which happens AFTER this
|
|
3896
|
+
* before-hook, so the shell sees them; the pre-fork env allowlist does not
|
|
3897
|
+
* re-filter runtime-set vars). `afterStepApplyEnvFiles` applies + truncates the
|
|
3898
|
+
* delta, mirroring the init phase's env port.
|
|
3899
|
+
*/
|
|
3900
|
+
function buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend) {
|
|
3901
|
+
return {
|
|
3902
|
+
beforeStepEnvFiles: async () => {
|
|
3903
|
+
process.env.KICI_ENV = envFiles.envFile;
|
|
3904
|
+
process.env.KICI_PATH = envFiles.pathFile;
|
|
3905
|
+
},
|
|
3906
|
+
afterStepApplyEnvFiles: async () => {
|
|
3907
|
+
try {
|
|
3908
|
+
await applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend);
|
|
3909
|
+
} catch (err) {
|
|
3910
|
+
maskedSend({
|
|
3911
|
+
type: "log.line",
|
|
3912
|
+
stepIndex: -1,
|
|
3913
|
+
line: `[kici] Failed to apply $KICI_ENV/$KICI_PATH delta: ${toErrorMessage(err)}`
|
|
3914
|
+
});
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
};
|
|
3918
|
+
}
|
|
3919
|
+
/**
|
|
3920
|
+
* Run the per-job init phase with concrete ports, then fail the job (no steps)
|
|
3921
|
+
* if any init spec failed or timed out.
|
|
3922
|
+
*
|
|
3923
|
+
* Concrete ports supplied to `runInitPhase`:
|
|
3924
|
+
* - shell: a fresh `buildSandboxShell` per init (same zx config steps use), cwd
|
|
3925
|
+
* = the clone root. Each init snapshots `process.env` (which `beginCapture`
|
|
3926
|
+
* has pointed at the shared KICI_ENV/KICI_PATH files), so the command can hand
|
|
3927
|
+
* env + PATH off to later inits and to every step.
|
|
3928
|
+
* - cache: the transport-backed `CacheApi` (`createCacheApi`) — the same engine
|
|
3929
|
+
* `ctx.cache` and the declarative cache phase use. Restore before / save on
|
|
3930
|
+
* key miss after.
|
|
3931
|
+
* - env: the P1 KICI_ENV/KICI_PATH lifecycle over the shared `envFiles`,
|
|
3932
|
+
* mirroring the step loop's `beforeStepEnvFiles` / `afterStepApplyEnvFiles`
|
|
3933
|
+
* (same `operatorSecretKeys` guard + masked reject log).
|
|
3934
|
+
*
|
|
3935
|
+
* On `result.ok === false`: emit `job.complete{failed}` with `stepResults: []`
|
|
3936
|
+
* (no step ran), an actionable error (carrying the distinct P3 timeout reason
|
|
3937
|
+
* when `timedOut`), and `process.exit(1)` — the step loop never executes.
|
|
3938
|
+
*/
|
|
3939
|
+
async function runInitPhaseOrFailJob(args) {
|
|
3940
|
+
const { job, stepCwd, envFiles, operatorSecretKeys, maskedSend } = args;
|
|
3941
|
+
const initSpecs = resolveInitSpecs(job);
|
|
3942
|
+
if (initSpecs.length === 0) return;
|
|
3943
|
+
const initResult = await runInitPhase({
|
|
3944
|
+
specs: initSpecs,
|
|
3945
|
+
shellFor: (_spec, i) => buildSandboxShell(stepCwd, INIT_STEP_INDEX_BASE + i, maskedSend),
|
|
3946
|
+
sendIpc: maskedSend,
|
|
3947
|
+
stepIndexBase: INIT_STEP_INDEX_BASE,
|
|
3948
|
+
cache: createCacheApi(stepCwd, buildCacheTransport()),
|
|
3949
|
+
env: {
|
|
3950
|
+
beginCapture: async () => {
|
|
3951
|
+
process.env.KICI_ENV = envFiles.envFile;
|
|
3952
|
+
process.env.KICI_PATH = envFiles.pathFile;
|
|
3953
|
+
await truncateEnvFiles(envFiles);
|
|
3954
|
+
},
|
|
3955
|
+
applyDelta: async () => {
|
|
3956
|
+
await applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend);
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
});
|
|
3960
|
+
if (!initResult.ok) {
|
|
3961
|
+
const errorBody = initResult.error ?? "";
|
|
3962
|
+
sendMessage({
|
|
3963
|
+
type: "job.complete",
|
|
3964
|
+
status: ExecutionJobStatus.enum.failed,
|
|
3965
|
+
stepResults: [],
|
|
3966
|
+
error: initResult.reason ? `init[${initResult.failedInitIndex}] ${initResult.reason}: ${errorBody}`.trim() : `init[${initResult.failedInitIndex}] failed: ${errorBody}`.trim()
|
|
3967
|
+
});
|
|
3968
|
+
process.exit(1);
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
/**
|
|
3094
3972
|
* Run the complete job execution lifecycle.
|
|
3095
3973
|
*
|
|
3096
3974
|
* 1. Receive execution request
|
|
@@ -3121,6 +3999,18 @@ async function main() {
|
|
|
3121
3999
|
});
|
|
3122
4000
|
else sendMessage(msg);
|
|
3123
4001
|
};
|
|
4002
|
+
const jobDeadline = armJobDeadline(request.jobTimeoutMs, (reason, timeoutMs) => {
|
|
4003
|
+
jobTimedOut = true;
|
|
4004
|
+
jobTimedOutMs = timeoutMs;
|
|
4005
|
+
aborted = true;
|
|
4006
|
+
forceAborted = true;
|
|
4007
|
+
maskedSend({
|
|
4008
|
+
type: "log.line",
|
|
4009
|
+
stepIndex: -1,
|
|
4010
|
+
line: `[kici] Job exceeded its timeout of ${timeoutMs}ms (${reason}); aborting.`
|
|
4011
|
+
});
|
|
4012
|
+
jobDeadlineAbort.abort();
|
|
4013
|
+
});
|
|
3124
4014
|
await cloneRepoIfRequested(request, workDir, workflowDir, sourceDir, isGlobal);
|
|
3125
4015
|
await applyOverlayIfRequested(request, workflowDir);
|
|
3126
4016
|
if (aborted) abortAndExit("aborted after clone");
|
|
@@ -3150,6 +4040,15 @@ async function main() {
|
|
|
3150
4040
|
flushOutputCapture();
|
|
3151
4041
|
capturePrepareActive = false;
|
|
3152
4042
|
const stepCwd = sourceDir;
|
|
4043
|
+
const envFiles = await createEnvFiles(tmpdir());
|
|
4044
|
+
await runInitPhaseOrFailJob({
|
|
4045
|
+
job,
|
|
4046
|
+
stepCwd,
|
|
4047
|
+
envFiles,
|
|
4048
|
+
operatorSecretKeys,
|
|
4049
|
+
maskedSend
|
|
4050
|
+
});
|
|
4051
|
+
const { cachePhaseDeps, jobCacheSpecs, jobCacheRestore } = await setupJobCache(stepCwd, normalizedSteps.length, job?.cache, maskedSend);
|
|
3153
4052
|
let currentStepSecrets = null;
|
|
3154
4053
|
let currentStepDispose = null;
|
|
3155
4054
|
const createStepCtxWithCapture = (stepIndex, stepName) => {
|
|
@@ -3164,6 +4063,7 @@ async function main() {
|
|
|
3164
4063
|
}
|
|
3165
4064
|
return ctx;
|
|
3166
4065
|
};
|
|
4066
|
+
const stepEnvHooks = buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend);
|
|
3167
4067
|
const jobStartTime = Date.now();
|
|
3168
4068
|
const loopResult = await executeStepLoop({
|
|
3169
4069
|
steps: normalizedSteps,
|
|
@@ -3174,7 +4074,9 @@ async function main() {
|
|
|
3174
4074
|
event: request.event ?? {},
|
|
3175
4075
|
env: process.env,
|
|
3176
4076
|
jobHooks,
|
|
4077
|
+
cachePhaseDeps,
|
|
3177
4078
|
isAborted: () => aborted,
|
|
4079
|
+
jobDeadlineSignal: jobDeadlineAbort.signal,
|
|
3178
4080
|
startTime: jobStartTime,
|
|
3179
4081
|
getSecretsAccessLog: () => {
|
|
3180
4082
|
flushOutputCapture();
|
|
@@ -3189,8 +4091,12 @@ async function main() {
|
|
|
3189
4091
|
currentStepDispose = null;
|
|
3190
4092
|
currentStepSecrets = null;
|
|
3191
4093
|
if (disposeFn) await disposeFn();
|
|
3192
|
-
}
|
|
4094
|
+
},
|
|
4095
|
+
beforeStepEnvFiles: stepEnvHooks.beforeStepEnvFiles,
|
|
4096
|
+
afterStepApplyEnvFiles: stepEnvHooks.afterStepApplyEnvFiles
|
|
3193
4097
|
});
|
|
4098
|
+
jobDeadline.clear();
|
|
4099
|
+
await maybeSaveJobCache(jobCacheSpecs, jobCacheRestore, cachePhaseDeps, !aborted && loopResult.status === ExecutionStepStatus.enum.success);
|
|
3194
4100
|
let finalStatus = loopResult.status === ExecutionStepStatus.enum.success ? ExecutionJobStatus.enum.success : ExecutionJobStatus.enum.failed;
|
|
3195
4101
|
let cancelFailureReason;
|
|
3196
4102
|
if (aborted) {
|
|
@@ -3213,19 +4119,40 @@ async function main() {
|
|
|
3213
4119
|
finalStatus = cancelResult.finalStatus;
|
|
3214
4120
|
cancelFailureReason = cancelResult.cancelFailureReason;
|
|
3215
4121
|
}
|
|
4122
|
+
if (jobTimedOut) finalStatus = ExecutionJobStatus.enum.failed;
|
|
4123
|
+
emitJobComplete({
|
|
4124
|
+
finalStatus,
|
|
4125
|
+
loopResult,
|
|
4126
|
+
outputsMap,
|
|
4127
|
+
secretOutputs,
|
|
4128
|
+
jobTimedOut,
|
|
4129
|
+
jobTimeoutMs: request.jobTimeoutMs,
|
|
4130
|
+
cancelFailureReason,
|
|
4131
|
+
driftDroppedJobs
|
|
4132
|
+
});
|
|
4133
|
+
process.exit(finalStatus === ExecutionJobStatus.enum.success ? 0 : 1);
|
|
4134
|
+
}
|
|
4135
|
+
/**
|
|
4136
|
+
* Phase 11 — emit the terminal `job.complete` IPC. Aggregates per-step outputs
|
|
4137
|
+
* by step name, includes encrypted secret outputs, and selects the error
|
|
4138
|
+
* message: a `job_timeout` reason when the job-level deadline tripped, else the
|
|
4139
|
+
* step-loop's failure reason (or the cancel-path's compound reason).
|
|
4140
|
+
*/
|
|
4141
|
+
function emitJobComplete(args) {
|
|
3216
4142
|
const aggregatedOutputs = {};
|
|
3217
|
-
for (const [stepName, outputs] of outputsMap) aggregatedOutputs[stepName] = outputs;
|
|
4143
|
+
for (const [stepName, outputs] of args.outputsMap) aggregatedOutputs[stepName] = outputs;
|
|
3218
4144
|
sendMessage({
|
|
3219
4145
|
type: "job.complete",
|
|
3220
|
-
status: finalStatus,
|
|
3221
|
-
stepResults: loopResult.stepResults,
|
|
4146
|
+
status: args.finalStatus,
|
|
4147
|
+
stepResults: args.loopResult.stepResults,
|
|
3222
4148
|
...Object.keys(aggregatedOutputs).length > 0 && { outputs: aggregatedOutputs },
|
|
3223
|
-
...secretOutputs.size > 0 && { secretOutputs: Object.fromEntries(secretOutputs) },
|
|
3224
|
-
...
|
|
3225
|
-
|
|
3226
|
-
|
|
4149
|
+
...args.secretOutputs.size > 0 && { secretOutputs: Object.fromEntries(args.secretOutputs) },
|
|
4150
|
+
...args.jobTimedOut ? { error: `${TimeoutReason.enum.job_timeout}: job exceeded its timeout of ${args.jobTimeoutMs}ms` } : {
|
|
4151
|
+
...args.loopResult.failureReason && { error: args.loopResult.failureReason },
|
|
4152
|
+
...args.cancelFailureReason && { error: args.cancelFailureReason }
|
|
4153
|
+
},
|
|
4154
|
+
...args.driftDroppedJobs.length > 0 && { droppedJobs: args.driftDroppedJobs }
|
|
3227
4155
|
});
|
|
3228
|
-
process.exit(finalStatus === ExecutionJobStatus.enum.success ? 0 : 1);
|
|
3229
4156
|
}
|
|
3230
4157
|
/**
|
|
3231
4158
|
* Find a static Job by name in the workflow.
|
|
@@ -3252,6 +4179,6 @@ main().catch((error) => {
|
|
|
3252
4179
|
setTimeout(() => process.exit(1), 100);
|
|
3253
4180
|
});
|
|
3254
4181
|
//#endregion
|
|
3255
|
-
export {};
|
|
4182
|
+
export { rawPayloadFromEvent, resolveInitSpecs };
|
|
3256
4183
|
|
|
3257
4184
|
//# sourceMappingURL=workflow-runner.js.map
|