@kici-dev/agent 0.1.14 → 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/index.d.ts +1 -0
- package/dist/index.js +342 -12
- package/dist/server.js +399 -149
- package/dist/workflow-runner.js +1874 -950
- package/dist/ws/orchestrator-client.d.ts +18 -0
- package/package.json +14 -10
- package/sbom.spdx.json +60 -60
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";
|
|
15
|
+
import { c, x } from "tar";
|
|
16
|
+
import https from "node:https";
|
|
17
|
+
import http from "node:http";
|
|
19
18
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
20
|
-
import {
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
21
20
|
import { promisify } from "node:util";
|
|
22
21
|
import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, detectPackageManagerFromManifests } from "@kici-dev/shared/package-manager";
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
var __defProp = Object.defineProperty;
|
|
23
|
+
var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
24
|
+
var __exportAll = (all, no_symbols) => {
|
|
25
|
+
let target = {};
|
|
26
|
+
for (var name in all) __defProp(target, name, {
|
|
27
|
+
get: all[name],
|
|
28
|
+
enumerable: true
|
|
29
|
+
});
|
|
30
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
31
|
+
return target;
|
|
32
|
+
};
|
|
25
33
|
import.meta.url;
|
|
26
34
|
//#endregion
|
|
27
|
-
//#region src/execution/
|
|
35
|
+
//#region src/execution/dep-restore.ts
|
|
28
36
|
/**
|
|
29
|
-
*
|
|
37
|
+
* Dependency restoration from cached tarballs.
|
|
30
38
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
39
|
+
* Downloads a pre-built dependency tarball, verifies SHA-256 integrity,
|
|
40
|
+
* and extracts to .kici/node_modules/ in the work directory.
|
|
33
41
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
42
|
+
* HTTP/HTTPS downloads use a streaming pipeline (response -> hash transform ->
|
|
43
|
+
* gunzip -> tar extract) to avoid buffering entire tarballs in memory.
|
|
44
|
+
* file:// URLs use a buffer-based approach (local, no streaming benefit).
|
|
45
|
+
*
|
|
46
|
+
* Streaming downloads have a 5-minute timeout and up to 2 retries.
|
|
36
47
|
*/
|
|
37
|
-
/** Minimum length for a secret value to be maskable (avoids false positives). */
|
|
38
|
-
const MIN_MASK_LENGTH = 3;
|
|
39
48
|
/**
|
|
40
|
-
*
|
|
49
|
+
* Compute SHA-256 hash of a buffer.
|
|
41
50
|
*/
|
|
42
|
-
function
|
|
43
|
-
return
|
|
51
|
+
function computeHash(data) {
|
|
52
|
+
return sha256(data);
|
|
44
53
|
}
|
|
45
54
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* Usage:
|
|
49
|
-
* ```ts
|
|
50
|
-
* const masker = new LogMasker();
|
|
51
|
-
* masker.registerSecrets({ TOKEN: 'abc123', SHORT: 'ab' });
|
|
52
|
-
* masker.mask('Token is abc123'); // 'Token is ***'
|
|
53
|
-
* // 'ab' is NOT masked (< 3 chars)
|
|
54
|
-
* ```
|
|
55
|
+
* Extract a gzip tarball from a buffer into the target directory.
|
|
56
|
+
* Used for file:// URLs where streaming provides no benefit.
|
|
55
57
|
*/
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
* (prevents partial masking when one secret is a substring of another).
|
|
67
|
-
*/
|
|
68
|
-
registerSecrets(secrets) {
|
|
69
|
-
const seen = /* @__PURE__ */ new Set();
|
|
70
|
-
const values = [];
|
|
71
|
-
for (const value of Object.values(secrets)) if (value.length >= MIN_MASK_LENGTH && !seen.has(value)) {
|
|
72
|
-
seen.add(value);
|
|
73
|
-
values.push(value);
|
|
74
|
-
const b64 = Buffer.from(value).toString("base64");
|
|
75
|
-
if (b64.length >= MIN_MASK_LENGTH && !seen.has(b64)) {
|
|
76
|
-
seen.add(b64);
|
|
77
|
-
values.push(b64);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (values.length === 0) {
|
|
81
|
-
this.pattern = null;
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
values.sort((a, b) => b.length - a.length);
|
|
85
|
-
this.pattern = new RegExp(values.map((v) => escapeRegExp(v)).join("|"), "g");
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Mask all registered secret values in a log line.
|
|
89
|
-
*
|
|
90
|
-
* Returns the line unchanged if no secrets are registered.
|
|
91
|
-
*/
|
|
92
|
-
mask(line) {
|
|
93
|
-
if (!this.pattern) return line;
|
|
94
|
-
this.pattern.lastIndex = 0;
|
|
95
|
-
return line.replace(this.pattern, "***");
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Returns true if any maskable secrets are registered.
|
|
99
|
-
*/
|
|
100
|
-
hasSecrets() {
|
|
101
|
-
return this.pattern !== null;
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
//#endregion
|
|
105
|
-
//#region src/execution/sandbox/secret-merge.ts
|
|
58
|
+
async function extractTarball(data, targetDir) {
|
|
59
|
+
await mkdir(targetDir, { recursive: true });
|
|
60
|
+
const readable = Readable.from(data);
|
|
61
|
+
await new Promise((resolve, reject) => {
|
|
62
|
+
readable.pipe(x({
|
|
63
|
+
cwd: targetDir,
|
|
64
|
+
gzip: true
|
|
65
|
+
})).on("finish", resolve).on("error", reject);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
106
68
|
/**
|
|
107
|
-
*
|
|
69
|
+
* Stream download and extract an HTTP/HTTPS tarball.
|
|
108
70
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
71
|
+
* Computes SHA-256 hash on the fly via a Transform stream.
|
|
72
|
+
* Returns the computed hash of the compressed tarball data.
|
|
111
73
|
*/
|
|
74
|
+
async function streamFetchAndExtract(url, targetDir) {
|
|
75
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS$2) });
|
|
76
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
77
|
+
if (!response.body) throw new Error("No response body");
|
|
78
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
79
|
+
const hash = createHash("sha256");
|
|
80
|
+
const hashTransform = new Transform({ transform(chunk, _encoding, callback) {
|
|
81
|
+
hash.update(chunk);
|
|
82
|
+
callback(null, chunk);
|
|
83
|
+
} });
|
|
84
|
+
await mkdir(targetDir, { recursive: true });
|
|
85
|
+
await pipeline(nodeStream, hashTransform, createGunzip(), x({ cwd: targetDir }));
|
|
86
|
+
return hash.digest("hex");
|
|
87
|
+
}
|
|
112
88
|
/**
|
|
113
|
-
*
|
|
89
|
+
* Extract the dep tarball into a per-attempt scratch dir so retries never race
|
|
90
|
+
* with still-draining I/O from a previous failed attempt.
|
|
114
91
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
92
|
+
* When `pipeline()` rejects on a network error or AbortSignal timeout, the
|
|
93
|
+
* underlying `tar.x` continues flushing pending file writes for an unbounded
|
|
94
|
+
* window after the promise settles — `pipeline` does not block on async
|
|
95
|
+
* filesystem side effects. If the next retry then runs `rm -rf` on the same
|
|
96
|
+
* `node_modules/`, the walk races with those writes and `rmdir` fails with
|
|
97
|
+
* ENOTEMPTY (new files keep appearing under a directory we just emptied).
|
|
118
98
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
for (const contextSecrets of Object.values(namespacedSecrets)) Object.assign(merged, contextSecrets);
|
|
125
|
-
return merged;
|
|
126
|
-
}
|
|
127
|
-
//#endregion
|
|
128
|
-
//#region src/execution/hook-executor.ts
|
|
129
|
-
/** Default hook timeout: 5 minutes */
|
|
130
|
-
const DEFAULT_HOOK_TIMEOUT_MS = 300 * 1e3;
|
|
131
|
-
/**
|
|
132
|
-
* Build outcome metadata from execution state.
|
|
99
|
+
* We sidestep the race entirely by extracting each attempt into a unique
|
|
100
|
+
* scratch dir under `.kici/`. Failed attempts leave orphan scratch dirs whose
|
|
101
|
+
* draining writes are harmless — the next attempt does not touch them. On
|
|
102
|
+
* success `moveScratchIntoRepo` renames the extracted entries into place
|
|
103
|
+
* (atomic on the same filesystem), then best-effort cleans the scratch dir.
|
|
133
104
|
*
|
|
134
|
-
*
|
|
105
|
+
* Scratch dirs land inside the customer's cloned working tree, so the clone
|
|
106
|
+
* phase registers `SCRATCH_DIR_GIT_EXCLUDE_GLOB` in `.git/info/exclude` to
|
|
107
|
+
* keep them out of `git status` for any workflow step that shells out to git.
|
|
108
|
+
* See `excludeScratchFromGit`.
|
|
135
109
|
*/
|
|
136
|
-
function
|
|
110
|
+
async function extractIntoScratch(url, kiciDir, attempt) {
|
|
111
|
+
const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-${attempt}-${Date.now()}`);
|
|
112
|
+
await mkdir(scratchDir, { recursive: true });
|
|
137
113
|
return {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
failedStep: opts.failedStep,
|
|
141
|
-
stepOutputs: opts.stepOutputs,
|
|
142
|
-
duration: Date.now() - opts.startTime
|
|
114
|
+
scratchDir,
|
|
115
|
+
hash: await streamFetchAndExtract(url, scratchDir)
|
|
143
116
|
};
|
|
144
117
|
}
|
|
145
118
|
/**
|
|
146
|
-
*
|
|
119
|
+
* Append `SCRATCH_DIR_GIT_EXCLUDE_GLOB` to `${repoWorkDir}/.git/info/exclude`
|
|
120
|
+
* so any in-flight or orphaned dep-restore scratch dirs are invisible to
|
|
121
|
+
* `git status` / `git add` inside the customer's cloned working tree.
|
|
122
|
+
*
|
|
123
|
+
* Why `.git/info/exclude` and not `.gitignore`:
|
|
124
|
+
* - `.gitignore` lives in the customer's repo and is committed; we MUST NOT
|
|
125
|
+
* modify it. Doing so would surface the rule in their PRs and create a
|
|
126
|
+
* diff customers never asked for.
|
|
127
|
+
* - `.git/info/exclude` is per-clone, on-disk only, and exactly the git
|
|
128
|
+
* mechanism for "ignore these patterns in THIS working tree". Git creates
|
|
129
|
+
* an empty (template-commented) file on `git init` / `git clone`, so it
|
|
130
|
+
* already exists by the time we're called.
|
|
131
|
+
*
|
|
132
|
+
* Why this lives next to `extractIntoScratch`:
|
|
133
|
+
* - The exclude glob is tied 1:1 to the scratch dir naming convention. If
|
|
134
|
+
* the prefix ever changes, the rule must change too. Defining both in the
|
|
135
|
+
* same file means a rename touches one place, not two.
|
|
136
|
+
*
|
|
137
|
+
* Best-effort: if the exclude file is missing (e.g. caller sandbox blocked
|
|
138
|
+
* `git clone` and the dir layout differs) we log and continue — failing the
|
|
139
|
+
* job over a missing git ignore wiring would be worse than the cosmetic
|
|
140
|
+
* issue we're solving.
|
|
141
|
+
*
|
|
142
|
+
* Idempotent: callers may invoke this multiple times (dual-clone path, retry
|
|
143
|
+
* after partial setup). We skip the append if the glob is already present.
|
|
144
|
+
*
|
|
145
|
+
* @param repoWorkDir - The git working tree root (the dir that contains
|
|
146
|
+
* `.git/`). For normal workflows this is the agent's job workDir; for
|
|
147
|
+
* global workflows it is the workflow repo dir (whose `.kici/` carries
|
|
148
|
+
* the scratch dirs).
|
|
147
149
|
*/
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
throw new Error(`Invalid hook input for ${hookType}`);
|
|
150
|
+
async function excludeScratchFromGit(repoWorkDir) {
|
|
151
|
+
const excludePath = join(repoWorkDir, ".git", "info", "exclude");
|
|
152
|
+
try {
|
|
153
|
+
const existing = await fsPromises.readFile(excludePath, "utf-8").catch(() => "");
|
|
154
|
+
if (existing.split("\n").some((line) => line.trim() === SCRATCH_DIR_GIT_EXCLUDE_GLOB)) return;
|
|
155
|
+
const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
156
|
+
await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
logger$4.warn("Failed to register scratch dir glob in .git/info/exclude", {
|
|
159
|
+
excludePath,
|
|
160
|
+
error: err instanceof Error ? err.message : String(err)
|
|
161
|
+
});
|
|
162
|
+
}
|
|
162
163
|
}
|
|
163
164
|
/**
|
|
164
|
-
*
|
|
165
|
+
* Rewrite localhost URLs to use the orchestrator host.
|
|
165
166
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
167
|
+
* The orchestrator rewrites file:// cache URLs to http://localhost:PORT/...
|
|
168
|
+
* but agent containers can't reach localhost. This utility replaces the
|
|
169
|
+
* host with the orchestrator's host derived from KICI_ORCHESTRATOR_URL.
|
|
168
170
|
*/
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
sendIpc({
|
|
174
|
-
type: "step.start",
|
|
175
|
-
stepIndex,
|
|
176
|
-
stepName: normalized.name,
|
|
177
|
-
step_type: `hook:${hookType}`
|
|
178
|
-
});
|
|
179
|
-
const startTime = Date.now();
|
|
180
|
-
const mergedCtx = {
|
|
181
|
-
...stepContext,
|
|
182
|
-
outcome
|
|
183
|
-
};
|
|
184
|
-
const abortController = new AbortController();
|
|
185
|
-
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
|
171
|
+
function resolveOrchestratorUrl(url) {
|
|
172
|
+
if (!url.match(/^https?:\/\/(localhost|127\.0\.0\.1)[:/]/)) return url;
|
|
173
|
+
const orchestratorUrl = process.env.KICI_ORCHESTRATOR_URL;
|
|
174
|
+
if (!orchestratorUrl) return url;
|
|
186
175
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
176
|
+
const orchestratorParsed = new URL(orchestratorUrl.replace(/^ws/, "http"));
|
|
177
|
+
const parsed = new URL(url);
|
|
178
|
+
parsed.hostname = orchestratorParsed.hostname;
|
|
179
|
+
return parsed.toString();
|
|
180
|
+
} catch {
|
|
181
|
+
return url;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Move a fully-extracted scratch tree into the cloned repo. The dep tarball is
|
|
186
|
+
* packed repo-root-relative, so the scratch holds repo-root entries:
|
|
187
|
+
* `.kici/node_modules` for every manager, plus (for pnpm) the root
|
|
188
|
+
* `node_modules/.pnpm` store and in-repo workspace sibling dirs. `.kici/` itself
|
|
189
|
+
* already exists in the work tree (cloned or source-restored), so its children
|
|
190
|
+
* are moved individually; every other top-level entry is moved wholesale.
|
|
191
|
+
*
|
|
192
|
+
* On a cache-hit execution agent the destinations do not pre-exist (source
|
|
193
|
+
* restore excludes node_modules and never carries sibling dirs), so the renames
|
|
194
|
+
* have nothing to race; the defensive `rm` covers re-runs.
|
|
195
|
+
*/
|
|
196
|
+
async function moveScratchIntoRepo(scratchDir, workDir) {
|
|
197
|
+
for (const child of await fsPromises.readdir(scratchDir)) if (child === ".kici") {
|
|
198
|
+
const kiciScratch = join(scratchDir, ".kici");
|
|
199
|
+
for (const sub of await fsPromises.readdir(kiciScratch)) await moveInto(join(kiciScratch, sub), join(workDir, ".kici", sub));
|
|
200
|
+
} else await moveInto(join(scratchDir, child), join(workDir, child));
|
|
201
|
+
}
|
|
202
|
+
/** Move `src` to `dest`, creating the parent and clearing any stale dest. */
|
|
203
|
+
async function moveInto(src, dest) {
|
|
204
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
205
|
+
await fsPromises.rm(dest, {
|
|
206
|
+
recursive: true,
|
|
207
|
+
force: true
|
|
208
|
+
});
|
|
209
|
+
await fsPromises.rename(src, dest);
|
|
210
|
+
}
|
|
211
|
+
/** Best-effort cleanup of a settled scratch dir; logs and continues on failure. */
|
|
212
|
+
async function cleanupScratch(scratchDir) {
|
|
213
|
+
try {
|
|
214
|
+
await fsPromises.rm(scratchDir, {
|
|
215
|
+
recursive: true,
|
|
216
|
+
force: true
|
|
199
217
|
});
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const error = toErrorMessage(e);
|
|
205
|
-
sendIpc({
|
|
206
|
-
type: "step.complete",
|
|
207
|
-
stepIndex,
|
|
208
|
-
status: "failed",
|
|
209
|
-
durationMs,
|
|
210
|
-
error: { message: error },
|
|
211
|
-
step_type: `hook:${hookType}`
|
|
218
|
+
} catch (cleanupErr) {
|
|
219
|
+
logger$4.warn("Scratch dir cleanup failed (orphan left behind)", {
|
|
220
|
+
scratchDir,
|
|
221
|
+
error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
|
|
212
222
|
});
|
|
213
|
-
return {
|
|
214
|
-
success: false,
|
|
215
|
-
error
|
|
216
|
-
};
|
|
217
223
|
}
|
|
218
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Restore dependencies from a cached tarball into the cloned repo.
|
|
227
|
+
*
|
|
228
|
+
* The tarball is packed repo-root-relative (see `dep-packer.ts`): every manager
|
|
229
|
+
* carries `.kici/node_modules`; pnpm additionally carries the root
|
|
230
|
+
* `node_modules/.pnpm` store and the in-repo workspace siblings `.kici` resolves.
|
|
231
|
+
* Restore extracts into a scratch dir, then moves each entry into place — one
|
|
232
|
+
* code path for all managers.
|
|
233
|
+
*
|
|
234
|
+
* For HTTP/HTTPS URLs: a streaming pipeline (response -> hash -> gunzip -> tar)
|
|
235
|
+
* with a 5-minute timeout and up to 2 retries avoids buffering whole tarballs.
|
|
236
|
+
* For file:// URLs: a buffer-based approach (local, no streaming benefit).
|
|
237
|
+
*
|
|
238
|
+
* @param workDir - Root directory of the cloned repository
|
|
239
|
+
* @param depsUrl - URL to the dependency tarball (http://, https://, or file://)
|
|
240
|
+
* @param depsHash - Optional expected SHA-256 hash of the tarball
|
|
241
|
+
*/
|
|
242
|
+
async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
243
|
+
depsUrl = resolveOrchestratorUrl(depsUrl);
|
|
244
|
+
logger$4.info("Downloading dependency tarball", { url: depsUrl });
|
|
245
|
+
const kiciDir = join(workDir, ".kici");
|
|
246
|
+
if (depsUrl.startsWith("file://")) {
|
|
247
|
+
const localPath = fileURLToPath(depsUrl);
|
|
248
|
+
const data = await fsPromises.readFile(localPath);
|
|
249
|
+
if (depsHash) {
|
|
250
|
+
const actualHash = computeHash(data);
|
|
251
|
+
if (actualHash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${actualHash}`);
|
|
252
|
+
}
|
|
253
|
+
const scratchDir = join(kiciDir, `${SCRATCH_DIR_BASENAME_PREFIX}${process.pid}-file-${Date.now()}`);
|
|
254
|
+
await extractTarball(data, scratchDir);
|
|
255
|
+
await moveScratchIntoRepo(scratchDir, workDir);
|
|
256
|
+
await cleanupScratch(scratchDir);
|
|
257
|
+
const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
|
|
258
|
+
logger$4.info("Dependencies restored from cache (file)", {
|
|
259
|
+
sizeMB,
|
|
260
|
+
targetDir: workDir
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
|
|
265
|
+
let lastError;
|
|
266
|
+
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
267
|
+
if (attempt > 0) logger$4.warn("Retrying dep tarball download", {
|
|
268
|
+
attempt,
|
|
269
|
+
url: depsUrl
|
|
270
|
+
});
|
|
271
|
+
try {
|
|
272
|
+
const { scratchDir, hash } = await extractIntoScratch(depsUrl, kiciDir, attempt);
|
|
273
|
+
if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
|
|
274
|
+
await moveScratchIntoRepo(scratchDir, workDir);
|
|
275
|
+
await cleanupScratch(scratchDir);
|
|
276
|
+
logger$4.info("Dependencies restored from cache (stream)", { targetDir: workDir });
|
|
277
|
+
return;
|
|
278
|
+
} catch (err) {
|
|
279
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
280
|
+
logger$4.warn("Dep tarball download failed", {
|
|
281
|
+
attempt,
|
|
282
|
+
error: lastError.message
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
|
|
287
|
+
}
|
|
288
|
+
var logger$4, DOWNLOAD_TIMEOUT_MS$2, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
|
|
289
|
+
var init_dep_restore = __esmMin((() => {
|
|
290
|
+
logger$4 = createLogger({ prefix: "dep-restore" });
|
|
291
|
+
DOWNLOAD_TIMEOUT_MS$2 = 300 * 1e3;
|
|
292
|
+
SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
|
|
293
|
+
SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
|
|
294
|
+
}));
|
|
219
295
|
//#endregion
|
|
220
|
-
//#region src/execution/
|
|
221
|
-
initZx();
|
|
296
|
+
//#region src/execution/download.ts
|
|
222
297
|
/**
|
|
223
|
-
*
|
|
298
|
+
* Shared HTTP/HTTPS download utility.
|
|
224
299
|
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* @param env - Merged environment variables
|
|
300
|
+
* Extracted from workflow-loader.ts to avoid duplication across
|
|
301
|
+
* dep-restore.ts and workflow-loader.ts.
|
|
228
302
|
*/
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
303
|
+
var download_exports = /* @__PURE__ */ __exportAll({
|
|
304
|
+
downloadUrl: () => downloadUrl,
|
|
305
|
+
uploadToPresignedUrl: () => uploadToPresignedUrl
|
|
306
|
+
});
|
|
307
|
+
/**
|
|
308
|
+
* Download content from an HTTP/HTTPS URL.
|
|
309
|
+
*
|
|
310
|
+
* Includes a 5-minute timeout to prevent the agent from hanging indefinitely
|
|
311
|
+
* on slow or unresponsive endpoints.
|
|
312
|
+
*
|
|
313
|
+
* @param url - The URL to download from
|
|
314
|
+
* @returns The response body as a Buffer
|
|
315
|
+
*/
|
|
316
|
+
function downloadUrl(url) {
|
|
317
|
+
return new Promise((resolve, reject) => {
|
|
318
|
+
(url.startsWith("https:") ? https : http).get(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS$1) }, (res) => {
|
|
319
|
+
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
|
320
|
+
reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} downloading from ${url}`));
|
|
321
|
+
res.resume();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const chunks = [];
|
|
325
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
326
|
+
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
327
|
+
res.on("error", reject);
|
|
328
|
+
}).on("error", reject);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Upload a buffer to a pre-signed S3 URL via HTTP PUT.
|
|
333
|
+
*
|
|
334
|
+
* Used for direct-to-S3 uploads of bundles and dep tarballs. Localhost /
|
|
335
|
+
* 127.0.0.1 URLs are rewritten via `resolveOrchestratorUrl` so the
|
|
336
|
+
* filesystem cache backend's signed URLs work from container agents that
|
|
337
|
+
* can't reach the orchestrator's host loopback directly.
|
|
338
|
+
*
|
|
339
|
+
* @param url - The pre-signed URL to upload to
|
|
340
|
+
* @param data - The buffer to upload
|
|
341
|
+
*/
|
|
342
|
+
function uploadToPresignedUrl(url, data) {
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
const resolved = resolveOrchestratorUrl(url);
|
|
345
|
+
const parsed = new URL(resolved);
|
|
346
|
+
const req = (parsed.protocol === "https:" ? https : http).request({
|
|
347
|
+
hostname: parsed.hostname,
|
|
348
|
+
port: parsed.port,
|
|
349
|
+
path: parsed.pathname + parsed.search,
|
|
350
|
+
method: "PUT",
|
|
351
|
+
headers: { "Content-Length": data.length }
|
|
352
|
+
}, (res) => {
|
|
353
|
+
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
|
354
|
+
reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} uploading to pre-signed URL`));
|
|
355
|
+
res.resume();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
res.resume();
|
|
359
|
+
res.on("end", () => resolve());
|
|
360
|
+
res.on("error", reject);
|
|
361
|
+
});
|
|
362
|
+
req.on("error", reject);
|
|
363
|
+
req.end(data);
|
|
364
|
+
});
|
|
236
365
|
}
|
|
366
|
+
var DOWNLOAD_TIMEOUT_MS$1;
|
|
367
|
+
var init_download = __esmMin((() => {
|
|
368
|
+
init_dep_restore();
|
|
369
|
+
DOWNLOAD_TIMEOUT_MS$1 = 300 * 1e3;
|
|
370
|
+
}));
|
|
237
371
|
//#endregion
|
|
238
|
-
//#region src/execution/
|
|
372
|
+
//#region src/execution/cache/cache-engine.ts
|
|
239
373
|
/**
|
|
240
|
-
*
|
|
374
|
+
* User-facing cache engine (sandbox-side).
|
|
241
375
|
*
|
|
242
|
-
*
|
|
376
|
+
* Packs `CacheSpec.paths` into a gzip tarball (mirrors dep-packer's tar+sha256
|
|
377
|
+
* approach) and restores a tarball with on-the-fly SHA-256 verification
|
|
378
|
+
* (mirrors dep-restore's streaming pipeline). Drives the orchestrator over an
|
|
379
|
+
* injected request-response transport (IPC -> agent WS -> orchestrator).
|
|
380
|
+
*
|
|
381
|
+
* Path safety: each path is either `~`-prefixed (home-relative) or
|
|
382
|
+
* repo-root-relative; absolute paths and `..` escapes are rejected so a
|
|
383
|
+
* workflow cannot exfiltrate or clobber files outside its tree/home.
|
|
384
|
+
*
|
|
385
|
+
* Multi-root layout: a spec may mix repo-relative and home-relative paths.
|
|
386
|
+
* Each entry is staged under an anchor prefix — `__repo__/<rel>` for
|
|
387
|
+
* repo-root-relative entries, `__home__/<rel>` for `~`-prefixed entries — so a
|
|
388
|
+
* single tarball can carry both roots and extract restores each group to the
|
|
389
|
+
* right destination (repo entries under `workDir`, home entries under the
|
|
390
|
+
* homedir). Extraction lands in a scratch dir first, then moves each group
|
|
391
|
+
* into place so a partial restore never leaves half-written paths in the live
|
|
392
|
+
* tree (mirrors dep-restore).
|
|
243
393
|
*/
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
394
|
+
const logger$3 = createLogger({ prefix: "cache-engine" });
|
|
395
|
+
/** Download timeout for a presigned cache GET: 5 minutes. */
|
|
396
|
+
const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
|
|
397
|
+
/** Anchor prefix for repo-root-relative cache entries inside the tar. */
|
|
398
|
+
const REPO_ANCHOR = "__repo__";
|
|
399
|
+
/** Anchor prefix for home-relative (`~`) cache entries inside the tar. */
|
|
400
|
+
const HOME_ANCHOR = "__home__";
|
|
401
|
+
/**
|
|
402
|
+
* Resolve a cache path. `~`-prefixed -> home root; otherwise repo-root-relative.
|
|
403
|
+
* Rejects absolute paths and `..` escapes so a workflow cannot read or clobber
|
|
404
|
+
* files outside its tree / home.
|
|
405
|
+
*/
|
|
406
|
+
function resolveCachePath(workDir, p, roots) {
|
|
407
|
+
const home = roots?.home ?? homedir();
|
|
408
|
+
if (p === "~" || p.startsWith("~/")) {
|
|
409
|
+
const rel = p === "~" ? "" : p.slice(2);
|
|
410
|
+
return rel ? join(home, rel) : home;
|
|
411
|
+
}
|
|
412
|
+
if (isAbsolute(p)) throw new Error(`cache path must be repo-relative or ~-prefixed: ${p}`);
|
|
413
|
+
const resolved = resolve(workDir, p);
|
|
414
|
+
const rel = relative(workDir, resolved);
|
|
415
|
+
if (rel === ".." || rel.startsWith(`..${sep}`)) throw new Error(`cache path escapes the repo root: ${p}`);
|
|
416
|
+
return resolved;
|
|
417
|
+
}
|
|
418
|
+
/** Resolve + anchor every spec path; rejects escapes via resolveCachePath. */
|
|
419
|
+
function anchorEntries(workDir, paths, roots) {
|
|
420
|
+
const home = roots?.home ?? homedir();
|
|
421
|
+
return paths.map((p) => {
|
|
422
|
+
const abs = resolveCachePath(workDir, p, roots);
|
|
423
|
+
const isHome = p === "~" || p.startsWith("~/");
|
|
424
|
+
return {
|
|
425
|
+
abs,
|
|
426
|
+
anchor: isHome ? HOME_ANCHOR : REPO_ANCHOR,
|
|
427
|
+
rel: relative(isHome ? home : workDir, abs)
|
|
428
|
+
};
|
|
249
429
|
});
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Pack the spec's paths into a gzip tarball + its SHA-256.
|
|
433
|
+
*
|
|
434
|
+
* Each path is copied into a staging dir under its anchor prefix
|
|
435
|
+
* (`__repo__/<rel>` or `__home__/<rel>`), the staging dir is tarred (portable
|
|
436
|
+
* mode strips uid/gid/mtime), and the staging dir is removed. The resulting
|
|
437
|
+
* tarball self-describes which root each entry restores to.
|
|
438
|
+
*/
|
|
439
|
+
async function packCachePaths(workDir, paths, roots) {
|
|
440
|
+
const entries = anchorEntries(workDir, paths, roots);
|
|
441
|
+
const staging = await mkdtemp(join(tmpdir(), "kici-cache-pack-"));
|
|
253
442
|
try {
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
443
|
+
const topLevel = /* @__PURE__ */ new Set();
|
|
444
|
+
for (const e of entries) {
|
|
445
|
+
const dest = join(staging, e.anchor, e.rel);
|
|
446
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
447
|
+
await cp(e.abs, dest, {
|
|
448
|
+
recursive: true,
|
|
449
|
+
verbatimSymlinks: true
|
|
257
450
|
});
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
451
|
+
topLevel.add(e.anchor);
|
|
452
|
+
}
|
|
453
|
+
const stream = c({
|
|
454
|
+
gzip: true,
|
|
455
|
+
portable: true,
|
|
456
|
+
cwd: staging
|
|
457
|
+
}, [...topLevel]);
|
|
458
|
+
const chunks = [];
|
|
459
|
+
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
|
460
|
+
const tarball = Buffer.concat(chunks);
|
|
461
|
+
const hash = sha256(tarball);
|
|
462
|
+
logger$3.info("packed user cache", {
|
|
463
|
+
sizeBytes: tarball.length,
|
|
464
|
+
hash: hash.slice(0, 12),
|
|
465
|
+
paths
|
|
272
466
|
});
|
|
273
467
|
return {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
status: ExecutionStepStatus.enum.success,
|
|
277
|
-
durationMs,
|
|
278
|
-
...outputsPayload && { outputs: outputsPayload }
|
|
468
|
+
tarball,
|
|
469
|
+
hash
|
|
279
470
|
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const exitCode = extractExitCode(e);
|
|
285
|
-
const signal = extractSignal(e);
|
|
286
|
-
const secretsAccessed = getSecretsAccessLog?.();
|
|
287
|
-
emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
|
|
288
|
-
sendFn({
|
|
289
|
-
type: "step.complete",
|
|
290
|
-
stepIndex,
|
|
291
|
-
status: ExecutionStepStatus.enum.failed,
|
|
292
|
-
durationMs,
|
|
293
|
-
error: {
|
|
294
|
-
message: error.message,
|
|
295
|
-
...exitCode !== void 0 && { exitCode },
|
|
296
|
-
...signal !== void 0 && { signal }
|
|
297
|
-
},
|
|
298
|
-
...secretsAccessed !== void 0 && { secretsAccessed }
|
|
471
|
+
} finally {
|
|
472
|
+
await rm(staging, {
|
|
473
|
+
recursive: true,
|
|
474
|
+
force: true
|
|
299
475
|
});
|
|
300
|
-
return {
|
|
301
|
-
name: step.name,
|
|
302
|
-
stepIndex,
|
|
303
|
-
status: ExecutionStepStatus.enum.failed,
|
|
304
|
-
durationMs,
|
|
305
|
-
error: {
|
|
306
|
-
message: error.message,
|
|
307
|
-
...exitCode !== void 0 && { exitCode },
|
|
308
|
-
...signal !== void 0 && { signal }
|
|
309
|
-
}
|
|
310
|
-
};
|
|
311
476
|
}
|
|
312
477
|
}
|
|
313
|
-
/**
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
function extractExitCode(error) {
|
|
330
|
-
if (error && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number") return error.exitCode;
|
|
331
|
-
}
|
|
332
|
-
function extractSignal(error) {
|
|
333
|
-
if (error && typeof error === "object" && "signal" in error && typeof error.signal === "string") return error.signal;
|
|
478
|
+
/** Move the extracted `__repo__` / `__home__` groups from a scratch dir into place. */
|
|
479
|
+
async function moveAnchoredGroups(scratchDir, workDir, home) {
|
|
480
|
+
for (const anchor of await readdir(scratchDir)) {
|
|
481
|
+
const anchorDir = join(scratchDir, anchor);
|
|
482
|
+
const destRoot = anchor === HOME_ANCHOR ? home : anchor === REPO_ANCHOR ? workDir : null;
|
|
483
|
+
if (!destRoot) continue;
|
|
484
|
+
for (const child of await readdir(anchorDir)) {
|
|
485
|
+
const dest = join(destRoot, child);
|
|
486
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
487
|
+
await rm(dest, {
|
|
488
|
+
recursive: true,
|
|
489
|
+
force: true
|
|
490
|
+
});
|
|
491
|
+
await rename(join(anchorDir, child), dest);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
334
494
|
}
|
|
335
495
|
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
496
|
+
* Stream-download a presigned URL, verify its SHA-256 on the fly (mirrors
|
|
497
|
+
* dep-restore's response -> hash -> gunzip -> tar pipeline), then move the
|
|
498
|
+
* anchored groups into place. Extracts into a scratch dir so a failed download
|
|
499
|
+
* never half-writes the live tree.
|
|
338
500
|
*/
|
|
339
|
-
async function
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
501
|
+
async function downloadAndExtractCache(url, workDir, expectedHash, roots) {
|
|
502
|
+
const home = roots?.home ?? homedir();
|
|
503
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
504
|
+
if (!response.ok || !response.body) throw new Error(`cache download HTTP ${response.status}`);
|
|
505
|
+
const hash = createHash("sha256");
|
|
506
|
+
const hashTransform = new Transform({ transform(chunk, _enc, cb) {
|
|
507
|
+
hash.update(chunk);
|
|
508
|
+
cb(null, chunk);
|
|
509
|
+
} });
|
|
510
|
+
await mkdir(workDir, { recursive: true });
|
|
511
|
+
const scratch = await mkdtemp(join(tmpdir(), "kici-cache-extract-"));
|
|
512
|
+
try {
|
|
513
|
+
await pipeline(Readable.fromWeb(response.body), hashTransform, createGunzip(), x({ cwd: scratch }));
|
|
514
|
+
const digest = hash.digest("hex");
|
|
515
|
+
if (digest !== expectedHash) throw new Error(`Cache tarball checksum mismatch on download: expected ${expectedHash}, got ${digest}`);
|
|
516
|
+
await moveAnchoredGroups(scratch, workDir, home);
|
|
517
|
+
} finally {
|
|
518
|
+
await rm(scratch, {
|
|
519
|
+
recursive: true,
|
|
520
|
+
force: true
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** Build the imperative `ctx.cache` API bound to a workDir + transport. */
|
|
525
|
+
function createCacheApi(workDir, transport, roots) {
|
|
360
526
|
return {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
527
|
+
async restore(spec) {
|
|
528
|
+
const r = await transport.restore(spec.key, spec.restoreKeys);
|
|
529
|
+
if (!r.hit || !r.downloadUrl || !r.tarHash) return { hit: false };
|
|
530
|
+
await downloadAndExtractCache(r.downloadUrl, workDir, r.tarHash, roots);
|
|
531
|
+
logger$3.info("user cache restored", {
|
|
532
|
+
key: spec.key,
|
|
533
|
+
matchedKey: r.matchedKey
|
|
534
|
+
});
|
|
535
|
+
return {
|
|
536
|
+
hit: true,
|
|
537
|
+
matchedKey: r.matchedKey
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
async save(spec) {
|
|
541
|
+
const begin = await transport.beginSave(spec.key);
|
|
542
|
+
if (begin.skip || !begin.uploadUrl) {
|
|
543
|
+
logger$3.info("user cache save skipped (key exists)", { key: spec.key });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const { tarball, hash } = await packCachePaths(workDir, spec.paths, roots);
|
|
547
|
+
const { uploadToPresignedUrl } = await Promise.resolve().then(() => (init_download(), download_exports));
|
|
548
|
+
await uploadToPresignedUrl(begin.uploadUrl, tarball);
|
|
549
|
+
await transport.completeSave(spec.key, hash, tarball.length);
|
|
550
|
+
logger$3.info("user cache saved", {
|
|
551
|
+
key: spec.key,
|
|
552
|
+
sizeBytes: tarball.length
|
|
553
|
+
});
|
|
554
|
+
}
|
|
365
555
|
};
|
|
366
556
|
}
|
|
557
|
+
//#endregion
|
|
558
|
+
//#region src/execution/cache/cache-phase.ts
|
|
367
559
|
/**
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
560
|
+
* Declarative cache phase (sandbox-side).
|
|
561
|
+
*
|
|
562
|
+
* Restores a list of {@link CacheSpec} before the work that depends on them
|
|
563
|
+
* (a job before its steps, or a step before its `run`) and saves them after
|
|
564
|
+
* (on an exact-key miss). Each operation surfaces as a `cache:restore` /
|
|
565
|
+
* `cache:save` pseudo-step — a `step.start` + `step.complete` IPC pair whose
|
|
566
|
+
* `step_type` comes from {@link CacheStepType} — exactly mirroring how hooks
|
|
567
|
+
* render as `hook:*` pseudo-steps. The `step.complete` `data` carries the
|
|
568
|
+
* {@link CacheOutcome} (plus key / matchedKey / bytes) so the agent can feed a
|
|
569
|
+
* `run.event` and the dashboard can render hit/miss/saved inline.
|
|
371
570
|
*/
|
|
372
|
-
async function runObserverHook(args) {
|
|
373
|
-
const { hook, hookType, step, stepIndex, hookStepIndex, failedStep, opts } = args;
|
|
374
|
-
const outcome = buildOutcomeMetadata({
|
|
375
|
-
status: failedStep !== void 0 ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
|
|
376
|
-
stepOutputs: Object.fromEntries(opts.outputsMap),
|
|
377
|
-
startTime: opts.startTime ?? Date.now(),
|
|
378
|
-
...failedStep !== void 0 && { failedStep }
|
|
379
|
-
});
|
|
380
|
-
const hookResult = await executeHook({
|
|
381
|
-
hook,
|
|
382
|
-
stepContext: opts.createStepContext(stepIndex, step.name),
|
|
383
|
-
outcome,
|
|
384
|
-
hookType,
|
|
385
|
-
stepIndex: hookStepIndex,
|
|
386
|
-
sendIpc: opts.sendIpc,
|
|
387
|
-
timeout: 3e5
|
|
388
|
-
});
|
|
389
|
-
if (!hookResult.success) opts.sendIpc({
|
|
390
|
-
type: "log.line",
|
|
391
|
-
stepIndex,
|
|
392
|
-
line: `[kici] ${hookType} hook failed: ${hookResult.error} (continuing -- hooks are observers)`
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
571
|
/**
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
* Wraps the per-step lifecycle in a `try / finally` that calls
|
|
401
|
-
* `opts.disposeStepResources()` so per-step state (the
|
|
402
|
-
* `ctx.secrets.mountFile` tmpdir, any env vars set via `exposeFile`) is
|
|
403
|
-
* removed even when the step throws, times out, or rule-skips.
|
|
572
|
+
* Restore every spec, surfacing each as a `cache:restore` pseudo-step. Returns
|
|
573
|
+
* a map keyed by spec key recording whether the EXACT key hit (so the save
|
|
574
|
+
* phase can skip a redundant save of an entry that already exists).
|
|
404
575
|
*/
|
|
405
|
-
async function
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
shouldBreak: false
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
if (opts.jobHooks?.beforeStep) await runObserverHook({
|
|
415
|
-
hook: opts.jobHooks.beforeStep,
|
|
416
|
-
hookType: "beforeStep",
|
|
417
|
-
step,
|
|
418
|
-
stepIndex,
|
|
419
|
-
hookStepIndex: opts.steps.length + stepIndex * 2,
|
|
420
|
-
opts
|
|
421
|
-
});
|
|
422
|
-
try {
|
|
423
|
-
const result = await executeStepInLoop(step, stepIndex, opts.createStepContext(stepIndex, step.name), step.timeout ?? opts.defaultTimeoutMs, opts.sendIpc, opts.outputsMap, opts.getSecretsAccessLog, opts.getSecretMountRecords);
|
|
424
|
-
if (opts.jobHooks?.afterStep) await runObserverHook({
|
|
425
|
-
hook: opts.jobHooks.afterStep,
|
|
426
|
-
hookType: "afterStep",
|
|
427
|
-
step,
|
|
576
|
+
async function restoreCacheSpecs(specs, deps) {
|
|
577
|
+
const results = /* @__PURE__ */ new Map();
|
|
578
|
+
for (const spec of specs) {
|
|
579
|
+
const stepIndex = deps.nextStepIndex();
|
|
580
|
+
deps.sendIpc({
|
|
581
|
+
type: "step.start",
|
|
428
582
|
stepIndex,
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
opts
|
|
583
|
+
stepName: `cache restore: ${spec.key}`,
|
|
584
|
+
step_type: CacheStepType.enum["cache:restore"]
|
|
432
585
|
});
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
586
|
+
const start = Date.now();
|
|
587
|
+
try {
|
|
588
|
+
const r = await deps.cache.restore(spec);
|
|
589
|
+
results.set(spec.key, {
|
|
590
|
+
hit: r.hit,
|
|
591
|
+
matchedKey: r.matchedKey
|
|
592
|
+
});
|
|
593
|
+
deps.sendIpc({
|
|
594
|
+
type: "step.complete",
|
|
595
|
+
stepIndex,
|
|
596
|
+
status: "success",
|
|
597
|
+
durationMs: Date.now() - start,
|
|
598
|
+
step_type: CacheStepType.enum["cache:restore"],
|
|
599
|
+
data: {
|
|
600
|
+
cacheOutcome: r.hit ? CacheOutcome.enum.hit : CacheOutcome.enum.miss,
|
|
601
|
+
key: spec.key,
|
|
602
|
+
...r.matchedKey !== void 0 && { matchedKey: r.matchedKey }
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
} catch (e) {
|
|
606
|
+
results.set(spec.key, { hit: false });
|
|
607
|
+
deps.sendIpc({
|
|
608
|
+
type: "step.complete",
|
|
609
|
+
stepIndex,
|
|
610
|
+
status: "failed",
|
|
611
|
+
durationMs: Date.now() - start,
|
|
612
|
+
error: { message: toErrorMessage(e) },
|
|
613
|
+
step_type: CacheStepType.enum["cache:restore"],
|
|
614
|
+
data: {
|
|
615
|
+
cacheOutcome: CacheOutcome.enum.error,
|
|
616
|
+
key: spec.key
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
}
|
|
444
620
|
}
|
|
621
|
+
return results;
|
|
445
622
|
}
|
|
446
623
|
/**
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
624
|
+
* Save every spec whose EXACT key did not already hit on restore (immutable +
|
|
625
|
+
* no redundant save), surfacing each as a `cache:save` pseudo-step. A spec
|
|
626
|
+
* whose restore matched a different key via a `restoreKeys` prefix is still
|
|
627
|
+
* saved under its exact key.
|
|
451
628
|
*/
|
|
452
|
-
async function
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
stepContext: opts.createStepContext(hookStepIndex, hookType),
|
|
462
|
-
outcome,
|
|
463
|
-
hookType,
|
|
464
|
-
stepIndex: hookStepIndex,
|
|
465
|
-
sendIpc: opts.sendIpc
|
|
466
|
-
});
|
|
467
|
-
if (hookResult.success) {
|
|
468
|
-
opts.sendIpc({
|
|
469
|
-
type: "log.line",
|
|
470
|
-
stepIndex: -1,
|
|
471
|
-
line: `[kici] ${hookType} hook completed`
|
|
629
|
+
async function saveCacheSpecs(specs, restoreResults, deps) {
|
|
630
|
+
for (const spec of specs) {
|
|
631
|
+
if (restoreResults.get(spec.key)?.matchedKey === spec.key) continue;
|
|
632
|
+
const stepIndex = deps.nextStepIndex();
|
|
633
|
+
deps.sendIpc({
|
|
634
|
+
type: "step.start",
|
|
635
|
+
stepIndex,
|
|
636
|
+
stepName: `cache save: ${spec.key}`,
|
|
637
|
+
step_type: CacheStepType.enum["cache:save"]
|
|
472
638
|
});
|
|
473
|
-
|
|
639
|
+
const start = Date.now();
|
|
640
|
+
try {
|
|
641
|
+
await deps.cache.save(spec);
|
|
642
|
+
deps.sendIpc({
|
|
643
|
+
type: "step.complete",
|
|
644
|
+
stepIndex,
|
|
645
|
+
status: "success",
|
|
646
|
+
durationMs: Date.now() - start,
|
|
647
|
+
step_type: CacheStepType.enum["cache:save"],
|
|
648
|
+
data: {
|
|
649
|
+
cacheOutcome: CacheOutcome.enum.saved,
|
|
650
|
+
key: spec.key
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
} catch (e) {
|
|
654
|
+
deps.sendIpc({
|
|
655
|
+
type: "step.complete",
|
|
656
|
+
stepIndex,
|
|
657
|
+
status: "failed",
|
|
658
|
+
durationMs: Date.now() - start,
|
|
659
|
+
error: { message: toErrorMessage(e) },
|
|
660
|
+
step_type: CacheStepType.enum["cache:save"],
|
|
661
|
+
data: {
|
|
662
|
+
cacheOutcome: CacheOutcome.enum.error,
|
|
663
|
+
key: spec.key
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
}
|
|
474
667
|
}
|
|
475
|
-
opts.sendIpc({
|
|
476
|
-
type: "log.line",
|
|
477
|
-
stepIndex: -1,
|
|
478
|
-
line: `[kici] ${hookType} hook failed: ${hookResult.error}`
|
|
479
|
-
});
|
|
480
|
-
const reasonFragment = `Hook ${hookType} failed: ${hookResult.error}`;
|
|
481
|
-
return {
|
|
482
|
-
failed: state.failed || promoteToFailed,
|
|
483
|
-
failedStepName: state.failedStepName,
|
|
484
|
-
failureReason: state.failureReason ? `${state.failureReason}; ${reasonFragment}` : reasonFragment
|
|
485
|
-
};
|
|
486
668
|
}
|
|
669
|
+
//#endregion
|
|
670
|
+
//#region src/execution/sandbox/log-masker.ts
|
|
487
671
|
/**
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
672
|
+
* Secret value masking for log lines.
|
|
673
|
+
*
|
|
674
|
+
* Replaces all occurrences of registered secret values with '***' in log output.
|
|
675
|
+
* Used by the workflow runner to prevent secret leaks in IPC log messages.
|
|
676
|
+
*
|
|
677
|
+
* Performance: Builds a single combined regex from all secret values, so each
|
|
678
|
+
* log line is scanned in a single pass (not O(secrets * lines)).
|
|
491
679
|
*/
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
...initial.failedStepName && {
|
|
500
|
-
failedStep: initial.failedStepName,
|
|
501
|
-
reason: `Step '${initial.failedStepName}' failed`
|
|
502
|
-
}
|
|
503
|
-
});
|
|
504
|
-
let state = initial;
|
|
505
|
-
let hookStepIndex = opts.steps.length;
|
|
506
|
-
if (initialFinalStatus === ExecutionStepStatus.enum.success && jobHooks?.onSuccess) {
|
|
507
|
-
state = await runCompletionHook({
|
|
508
|
-
hook: jobHooks.onSuccess,
|
|
509
|
-
hookType: "onSuccess",
|
|
510
|
-
hookStepIndex,
|
|
511
|
-
outcome: jobOutcome,
|
|
512
|
-
state,
|
|
513
|
-
promoteToFailed: true,
|
|
514
|
-
opts
|
|
515
|
-
});
|
|
516
|
-
hookStepIndex++;
|
|
517
|
-
} else if (initialFinalStatus === ExecutionStepStatus.enum.failed && jobHooks?.onFailure) {
|
|
518
|
-
state = await runCompletionHook({
|
|
519
|
-
hook: jobHooks.onFailure,
|
|
520
|
-
hookType: "onFailure",
|
|
521
|
-
hookStepIndex,
|
|
522
|
-
outcome: jobOutcome,
|
|
523
|
-
state,
|
|
524
|
-
promoteToFailed: false,
|
|
525
|
-
opts
|
|
526
|
-
});
|
|
527
|
-
hookStepIndex++;
|
|
528
|
-
}
|
|
529
|
-
if (jobHooks?.cleanup) {
|
|
530
|
-
const cleanupOutcome = buildOutcomeMetadata({
|
|
531
|
-
status: state.failed ? ExecutionStepStatus.enum.failed : ExecutionStepStatus.enum.success,
|
|
532
|
-
stepOutputs: Object.fromEntries(outputsMap),
|
|
533
|
-
startTime,
|
|
534
|
-
...state.failedStepName && { failedStep: state.failedStepName },
|
|
535
|
-
...state.failureReason && { reason: state.failureReason }
|
|
536
|
-
});
|
|
537
|
-
state = await runCompletionHook({
|
|
538
|
-
hook: jobHooks.cleanup,
|
|
539
|
-
hookType: "cleanup",
|
|
540
|
-
hookStepIndex,
|
|
541
|
-
outcome: cleanupOutcome,
|
|
542
|
-
state,
|
|
543
|
-
promoteToFailed: true,
|
|
544
|
-
opts
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
return state;
|
|
680
|
+
/** Minimum length for a secret value to be maskable (avoids false positives). */
|
|
681
|
+
const MIN_MASK_LENGTH = 3;
|
|
682
|
+
/**
|
|
683
|
+
* Escape regex special characters in a string.
|
|
684
|
+
*/
|
|
685
|
+
function escapeRegExp(s) {
|
|
686
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
548
687
|
}
|
|
549
688
|
/**
|
|
550
|
-
*
|
|
689
|
+
* Masks secret values in log lines.
|
|
551
690
|
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
691
|
+
* Usage:
|
|
692
|
+
* ```ts
|
|
693
|
+
* const masker = new LogMasker();
|
|
694
|
+
* masker.registerSecrets({ TOKEN: 'abc123', SHORT: 'ab' });
|
|
695
|
+
* masker.mask('Token is abc123'); // 'Token is ***'
|
|
696
|
+
* // 'ab' is NOT masked (< 3 chars)
|
|
697
|
+
* ```
|
|
698
|
+
*/
|
|
699
|
+
var LogMasker = class {
|
|
700
|
+
pattern = null;
|
|
701
|
+
/**
|
|
702
|
+
* Register secret values to be masked in log output.
|
|
703
|
+
*
|
|
704
|
+
* Values shorter than 3 characters are skipped to avoid false positives.
|
|
705
|
+
* Base64-encoded variants of each qualifying secret are also registered,
|
|
706
|
+
* preventing leaks when secrets appear base64-encoded in logs (e.g.,
|
|
707
|
+
* Authorization: Basic headers, base64-encoded config values).
|
|
708
|
+
* Values are sorted by length descending so longer values are matched first
|
|
709
|
+
* (prevents partial masking when one secret is a substring of another).
|
|
710
|
+
*/
|
|
711
|
+
registerSecrets(secrets) {
|
|
712
|
+
const seen = /* @__PURE__ */ new Set();
|
|
713
|
+
const values = [];
|
|
714
|
+
for (const value of Object.values(secrets)) if (value.length >= MIN_MASK_LENGTH && !seen.has(value)) {
|
|
715
|
+
seen.add(value);
|
|
716
|
+
values.push(value);
|
|
717
|
+
const b64 = Buffer.from(value).toString("base64");
|
|
718
|
+
if (b64.length >= MIN_MASK_LENGTH && !seen.has(b64)) {
|
|
719
|
+
seen.add(b64);
|
|
720
|
+
values.push(b64);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (values.length === 0) {
|
|
724
|
+
this.pattern = null;
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
values.sort((a, b) => b.length - a.length);
|
|
728
|
+
this.pattern = new RegExp(values.map((v) => escapeRegExp(v)).join("|"), "g");
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Mask all registered secret values in a log line.
|
|
732
|
+
*
|
|
733
|
+
* Returns the line unchanged if no secrets are registered.
|
|
734
|
+
*/
|
|
735
|
+
mask(line) {
|
|
736
|
+
if (!this.pattern) return line;
|
|
737
|
+
this.pattern.lastIndex = 0;
|
|
738
|
+
return line.replace(this.pattern, "***");
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Returns true if any maskable secrets are registered.
|
|
742
|
+
*/
|
|
743
|
+
hasSecrets() {
|
|
744
|
+
return this.pattern !== null;
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
//#endregion
|
|
748
|
+
//#region src/execution/sandbox/env-delta.ts
|
|
749
|
+
/**
|
|
750
|
+
* Apply an environment delta to `target` (defaults to process.env), honoring the
|
|
751
|
+
* operator-secret guard.
|
|
556
752
|
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
753
|
+
* - env keys present in `operatorSecretKeys` are rejected (never override an
|
|
754
|
+
* operator secret); `onReject` fires once per rejected key.
|
|
755
|
+
* - pathPrepends are applied so the FIRST array entry ends up FIRST on PATH.
|
|
559
756
|
*/
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
const
|
|
563
|
-
const
|
|
564
|
-
for (const [
|
|
565
|
-
if (
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
state.failed = true;
|
|
570
|
-
state.failedStepName = outcome.failedStepName;
|
|
757
|
+
function applyEnvDelta(delta, options) {
|
|
758
|
+
const target = options.target ?? process.env;
|
|
759
|
+
const appliedKeys = [];
|
|
760
|
+
const rejectedKeys = [];
|
|
761
|
+
for (const [key, value] of Object.entries(delta.env)) {
|
|
762
|
+
if (options.operatorSecretKeys.has(key)) {
|
|
763
|
+
rejectedKeys.push(key);
|
|
764
|
+
options.onReject?.(key);
|
|
765
|
+
continue;
|
|
571
766
|
}
|
|
572
|
-
|
|
767
|
+
target[key] = value;
|
|
768
|
+
appliedKeys.push(key);
|
|
769
|
+
}
|
|
770
|
+
const appliedPaths = [];
|
|
771
|
+
if (delta.pathPrepends.length > 0) {
|
|
772
|
+
for (const dir of [...delta.pathPrepends].reverse()) target.PATH = target.PATH ? `${dir}:${target.PATH}` : dir;
|
|
773
|
+
appliedPaths.push(...delta.pathPrepends);
|
|
573
774
|
}
|
|
574
|
-
if (opts.isAborted?.()) return {
|
|
575
|
-
status: "aborted",
|
|
576
|
-
stepResults,
|
|
577
|
-
failureReason: state.failureReason ?? (state.failed ? `Step '${state.failedStepName}' failed` : void 0)
|
|
578
|
-
};
|
|
579
|
-
const finalState = await runJobCompletionHooks(opts, state, opts.outputsMap, startTime);
|
|
580
775
|
return {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
776
|
+
appliedKeys,
|
|
777
|
+
rejectedKeys,
|
|
778
|
+
appliedPaths
|
|
584
779
|
};
|
|
585
780
|
}
|
|
586
781
|
//#endregion
|
|
587
|
-
//#region src/
|
|
782
|
+
//#region src/execution/sandbox/env-file.ts
|
|
588
783
|
/**
|
|
589
|
-
*
|
|
590
|
-
* tempdir and build the `GIT_SSH_COMMAND` that `git clone` needs.
|
|
784
|
+
* KICI_ENV / KICI_PATH temp-file contract.
|
|
591
785
|
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
786
|
+
* Before each step the agent points the KICI_ENV and KICI_PATH env vars at fresh
|
|
787
|
+
* temp files. A step's shell commands append `KEY=value` lines to $KICI_ENV and
|
|
788
|
+
* one directory per line to $KICI_PATH. After the step the agent parses both
|
|
789
|
+
* files into an EnvDelta and feeds it through applyEnvDelta() -- the same path
|
|
790
|
+
* the JS API (ctx.setEnv / ctx.addPath) uses -- then truncates the files for the
|
|
791
|
+
* next step.
|
|
597
792
|
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
* - `-o IdentitiesOnly=yes` — don't try other keys from ssh-agent / ~/.ssh.
|
|
601
|
-
* - `-o BatchMode=yes` — never prompt for passwords / passphrases.
|
|
602
|
-
* - host-key checking flags based on `hostKeyPolicy`.
|
|
793
|
+
* Format v1: single-line `KEY=value` for env (no embedded newlines); one
|
|
794
|
+
* directory per line for path. Blank lines and lines without `=` are ignored.
|
|
603
795
|
*/
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
"
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
796
|
+
/**
|
|
797
|
+
* Parse `KEY=value` lines into a record. Blank lines, lines without `=`, and
|
|
798
|
+
* lines with an empty key are ignored. The split is on the first `=` only, so a
|
|
799
|
+
* value may contain `=`. The key is trimmed; the value is taken verbatim after
|
|
800
|
+
* the first `=`. Last assignment to a key wins.
|
|
801
|
+
*/
|
|
802
|
+
function parseEnvFileContent(content) {
|
|
803
|
+
const out = {};
|
|
804
|
+
for (const rawLine of content.split("\n")) {
|
|
805
|
+
const line = rawLine.trim();
|
|
806
|
+
if (line.length === 0) continue;
|
|
807
|
+
const eq = line.indexOf("=");
|
|
808
|
+
if (eq <= 0) continue;
|
|
809
|
+
const key = line.slice(0, eq).trim();
|
|
810
|
+
if (key.length === 0) continue;
|
|
811
|
+
out[key] = line.slice(eq + 1);
|
|
812
|
+
}
|
|
813
|
+
return out;
|
|
814
|
+
}
|
|
815
|
+
/** Parse one trimmed directory per non-blank line, preserving order. */
|
|
816
|
+
function parsePathFileContent(content) {
|
|
817
|
+
const out = [];
|
|
818
|
+
for (const rawLine of content.split("\n")) {
|
|
819
|
+
const line = rawLine.trim();
|
|
820
|
+
if (line.length === 0) continue;
|
|
821
|
+
out.push(line);
|
|
822
|
+
}
|
|
823
|
+
return out;
|
|
824
|
+
}
|
|
825
|
+
/** Create fresh, empty env + path files inside a private temp dir under `baseDir`. */
|
|
826
|
+
async function createEnvFiles(baseDir) {
|
|
827
|
+
const dir = await mkdtemp(join(baseDir, "kici-env-"));
|
|
828
|
+
const envFile = join(dir, "env");
|
|
829
|
+
const pathFile = join(dir, "path");
|
|
830
|
+
await writeFile(envFile, "");
|
|
831
|
+
await writeFile(pathFile, "");
|
|
624
832
|
return {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
833
|
+
envFile,
|
|
834
|
+
pathFile
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
/** Read + parse both files into an EnvDelta. Missing/empty files yield an empty delta. */
|
|
838
|
+
async function readEnvDelta(files) {
|
|
839
|
+
const [envContent, pathContent] = await Promise.all([readFile(files.envFile, "utf8").catch(() => ""), readFile(files.pathFile, "utf8").catch(() => "")]);
|
|
840
|
+
return {
|
|
841
|
+
env: parseEnvFileContent(envContent),
|
|
842
|
+
pathPrepends: parsePathFileContent(pathContent)
|
|
633
843
|
};
|
|
634
844
|
}
|
|
845
|
+
/** Truncate both files to empty so the next step starts clean. */
|
|
846
|
+
async function truncateEnvFiles(files) {
|
|
847
|
+
await Promise.all([writeFile(files.envFile, ""), writeFile(files.pathFile, "")]);
|
|
848
|
+
}
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region src/execution/sandbox/secret-merge.ts
|
|
635
851
|
/**
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
852
|
+
* Secret merging utilities for the workflow runner.
|
|
853
|
+
*
|
|
854
|
+
* Separated from workflow-runner.ts to allow unit testing without
|
|
855
|
+
* triggering the runner's top-level side effects (process handlers, main()).
|
|
639
856
|
*/
|
|
640
|
-
|
|
641
|
-
|
|
857
|
+
/**
|
|
858
|
+
* Merge orchestrator-level secrets with auto-flattened context keys.
|
|
859
|
+
*
|
|
860
|
+
* Precedence (last wins):
|
|
861
|
+
* 1. Orchestrator-level secrets (lowest)
|
|
862
|
+
* 2. Context-flattened keys in declaration order (each context's keys overlay previous)
|
|
863
|
+
*
|
|
864
|
+
* This means: context-flattened keys override orchestrator-level secrets,
|
|
865
|
+
* and for collisions between contexts, last declared context wins.
|
|
866
|
+
*/
|
|
867
|
+
function buildMergedFlatSecrets(orchestratorSecrets, namespacedSecrets) {
|
|
868
|
+
const merged = { ...orchestratorSecrets };
|
|
869
|
+
for (const contextSecrets of Object.values(namespacedSecrets)) Object.assign(merged, contextSecrets);
|
|
870
|
+
return merged;
|
|
642
871
|
}
|
|
643
872
|
//#endregion
|
|
644
|
-
//#region src/
|
|
873
|
+
//#region src/execution/hook-executor.ts
|
|
874
|
+
/** Default hook timeout: 5 minutes */
|
|
875
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 300 * 1e3;
|
|
645
876
|
/**
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
* tokens or the absolute path of a temporary SSH key file in logs.
|
|
877
|
+
* Build outcome metadata from execution state.
|
|
878
|
+
*
|
|
879
|
+
* Duration is calculated as elapsed time since startTime.
|
|
650
880
|
*/
|
|
651
|
-
function
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
881
|
+
function buildOutcomeMetadata(opts) {
|
|
882
|
+
return {
|
|
883
|
+
status: opts.status,
|
|
884
|
+
reason: opts.reason,
|
|
885
|
+
failedStep: opts.failedStep,
|
|
886
|
+
stepOutputs: opts.stepOutputs,
|
|
887
|
+
duration: Date.now() - opts.startTime
|
|
888
|
+
};
|
|
656
889
|
}
|
|
657
|
-
|
|
658
|
-
|
|
890
|
+
/**
|
|
891
|
+
* Normalize a HookInput (bare function, { run, timeout }, or HookConfig) into a HookConfig.
|
|
892
|
+
*/
|
|
893
|
+
function normalizeHook(hook, hookType) {
|
|
894
|
+
if (typeof hook === "object" && "name" in hook && "type" in hook) return hook;
|
|
895
|
+
if (typeof hook === "function") return {
|
|
896
|
+
name: hookType,
|
|
897
|
+
type: hookType,
|
|
898
|
+
run: hook
|
|
899
|
+
};
|
|
900
|
+
if (typeof hook === "object" && "run" in hook) return {
|
|
901
|
+
name: hookType,
|
|
902
|
+
type: hookType,
|
|
903
|
+
run: hook.run,
|
|
904
|
+
timeout: hook.timeout
|
|
905
|
+
};
|
|
906
|
+
throw new Error(`Invalid hook input for ${hookType}`);
|
|
659
907
|
}
|
|
660
908
|
/**
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
* Token authentication uses git's `-c http.extraHeader` mechanism which keeps
|
|
664
|
-
* the token out of the clone URL (not visible in `git remote -v` or logs).
|
|
665
|
-
*
|
|
666
|
-
* After clone, verifies that HEAD matches the expected SHA to prevent
|
|
667
|
-
* wrong-ref execution.
|
|
909
|
+
* Execute a single hook with timeout enforcement and IPC reporting.
|
|
668
910
|
*
|
|
669
|
-
*
|
|
911
|
+
* Sends step.start and step.complete IPC messages with step_type = 'hook:{hookType}'.
|
|
912
|
+
* The hook runs in the same sandbox context as regular steps.
|
|
670
913
|
*/
|
|
671
|
-
async function
|
|
672
|
-
const {
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
await writeFile(cfgPath, "[safe]\n directory = *\n", { mode: 384 });
|
|
689
|
-
envEntries.GIT_CONFIG_GLOBAL = cfgPath;
|
|
690
|
-
needsCustomEnv = true;
|
|
691
|
-
safeDirCleanup = async () => {
|
|
692
|
-
await rm(dir, {
|
|
693
|
-
recursive: true,
|
|
694
|
-
force: true
|
|
695
|
-
}).catch(() => {});
|
|
696
|
-
};
|
|
697
|
-
}
|
|
698
|
-
let sshSetup;
|
|
914
|
+
async function executeHook(opts) {
|
|
915
|
+
const { stepContext, outcome, hookType, stepIndex, sendIpc } = opts;
|
|
916
|
+
const normalized = normalizeHook(opts.hook, hookType);
|
|
917
|
+
const timeoutMs = normalized.timeout ?? opts.timeout ?? DEFAULT_HOOK_TIMEOUT_MS;
|
|
918
|
+
sendIpc({
|
|
919
|
+
type: "step.start",
|
|
920
|
+
stepIndex,
|
|
921
|
+
stepName: normalized.name,
|
|
922
|
+
step_type: `hook:${hookType}`
|
|
923
|
+
});
|
|
924
|
+
const startTime = Date.now();
|
|
925
|
+
const mergedCtx = {
|
|
926
|
+
...stepContext,
|
|
927
|
+
outcome
|
|
928
|
+
};
|
|
929
|
+
const abortController = new AbortController();
|
|
930
|
+
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
|
699
931
|
try {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
args.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
|
|
704
|
-
} else if (auth?.kind === "ssh") {
|
|
705
|
-
sshSetup = await setupSshAuth({
|
|
706
|
-
privateKey: auth.secret,
|
|
707
|
-
hostKeyPolicy: auth.sshHostKeyPolicy,
|
|
708
|
-
knownHosts: auth.sshKnownHostsPem
|
|
709
|
-
});
|
|
710
|
-
envEntries.GIT_SSH_COMMAND = sshSetup.gitSshCommand;
|
|
711
|
-
needsCustomEnv = true;
|
|
712
|
-
}
|
|
713
|
-
const env = needsCustomEnv ? {
|
|
714
|
-
...process.env,
|
|
715
|
-
...envEntries
|
|
716
|
-
} : void 0;
|
|
717
|
-
if (ref) args.push("clone", "--depth", String(depth), "--branch", ref, repoUrl, workDir);
|
|
718
|
-
else args.push("clone", "--depth", String(depth), repoUrl, workDir);
|
|
719
|
-
const { execFileSync } = await import("node:child_process");
|
|
720
|
-
try {
|
|
721
|
-
execFileSync("git", args, {
|
|
722
|
-
stdio: "pipe",
|
|
723
|
-
timeout: 12e4,
|
|
724
|
-
...env && { env }
|
|
725
|
-
});
|
|
726
|
-
} catch (err) {
|
|
727
|
-
throw sanitizeGitError(err);
|
|
728
|
-
}
|
|
729
|
-
if (!sha || sha === "HEAD") return;
|
|
730
|
-
const envOpts = env ? { env } : {};
|
|
731
|
-
if (!execFileSync("git", [
|
|
732
|
-
"-C",
|
|
733
|
-
workDir,
|
|
734
|
-
"rev-parse",
|
|
735
|
-
"HEAD"
|
|
736
|
-
], {
|
|
737
|
-
encoding: "utf-8",
|
|
738
|
-
timeout: 1e4,
|
|
739
|
-
...envOpts
|
|
740
|
-
}).trim().startsWith(sha)) {
|
|
741
|
-
const fetchArgs = [];
|
|
742
|
-
if (auth?.kind === "basic") {
|
|
743
|
-
const user = auth.user ?? "x-access-token";
|
|
744
|
-
const basic = Buffer.from(`${user}:${auth.secret}`).toString("base64");
|
|
745
|
-
fetchArgs.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
|
|
746
|
-
}
|
|
747
|
-
fetchArgs.push("fetch", "--depth", "50", "origin", sha);
|
|
748
|
-
try {
|
|
749
|
-
execFileSync("git", [
|
|
750
|
-
"-C",
|
|
751
|
-
workDir,
|
|
752
|
-
...fetchArgs
|
|
753
|
-
], {
|
|
754
|
-
stdio: "pipe",
|
|
755
|
-
timeout: 12e4,
|
|
756
|
-
...envOpts
|
|
757
|
-
});
|
|
758
|
-
} catch (err) {
|
|
759
|
-
throw sanitizeGitError(err);
|
|
760
|
-
}
|
|
761
|
-
execFileSync("git", [
|
|
762
|
-
"-C",
|
|
763
|
-
workDir,
|
|
764
|
-
"checkout",
|
|
765
|
-
sha
|
|
766
|
-
], {
|
|
767
|
-
stdio: "pipe",
|
|
768
|
-
timeout: 3e4,
|
|
769
|
-
...envOpts
|
|
932
|
+
await Promise.race([normalized.run(mergedCtx), new Promise((_, reject) => {
|
|
933
|
+
abortController.signal.addEventListener("abort", () => {
|
|
934
|
+
reject(/* @__PURE__ */ new Error(`Hook '${normalized.name}' timed out after ${timeoutMs}ms`));
|
|
770
935
|
});
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
936
|
+
})]);
|
|
937
|
+
clearTimeout(timeoutId);
|
|
938
|
+
sendIpc({
|
|
939
|
+
type: "step.complete",
|
|
940
|
+
stepIndex,
|
|
941
|
+
status: "success",
|
|
942
|
+
durationMs: Date.now() - startTime,
|
|
943
|
+
step_type: `hook:${hookType}`
|
|
944
|
+
});
|
|
945
|
+
return { success: true };
|
|
946
|
+
} catch (e) {
|
|
947
|
+
clearTimeout(timeoutId);
|
|
948
|
+
const durationMs = Date.now() - startTime;
|
|
949
|
+
const error = toErrorMessage(e);
|
|
950
|
+
sendIpc({
|
|
951
|
+
type: "step.complete",
|
|
952
|
+
stepIndex,
|
|
953
|
+
status: "failed",
|
|
954
|
+
durationMs,
|
|
955
|
+
error: { message: error },
|
|
956
|
+
step_type: `hook:${hookType}`
|
|
957
|
+
});
|
|
958
|
+
return {
|
|
959
|
+
success: false,
|
|
960
|
+
error
|
|
961
|
+
};
|
|
786
962
|
}
|
|
787
963
|
}
|
|
788
964
|
//#endregion
|
|
789
|
-
//#region src/execution/
|
|
965
|
+
//#region src/execution/rule-evaluator.ts
|
|
966
|
+
initZx();
|
|
790
967
|
/**
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
* Downloads a pre-built dependency tarball, verifies SHA-256 integrity,
|
|
794
|
-
* and extracts to .kici/node_modules/ in the work directory.
|
|
968
|
+
* Create RuleContext for agent-side rule evaluation.
|
|
795
969
|
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
970
|
+
* @param event - Event payload from the dispatch message
|
|
971
|
+
* @param changedFiles - List of files changed in this event
|
|
972
|
+
* @param env - Merged environment variables
|
|
973
|
+
*/
|
|
974
|
+
function createRuleContext(event, changedFiles = [], env = {}) {
|
|
975
|
+
return {
|
|
976
|
+
event,
|
|
977
|
+
changedFiles,
|
|
978
|
+
env,
|
|
979
|
+
$
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
//#endregion
|
|
983
|
+
//#region src/execution/sandbox/step-loop.ts
|
|
984
|
+
/**
|
|
985
|
+
* Execute a single step with timeout enforcement.
|
|
799
986
|
*
|
|
800
|
-
*
|
|
987
|
+
* Timeout pattern using Promise.race + AbortController, with IPC status reporting.
|
|
988
|
+
*/
|
|
989
|
+
async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, outputsMap, getSecretsAccessLog, getSecretMountRecords, jobDeadlineSignal) {
|
|
990
|
+
sendFn({
|
|
991
|
+
type: "step.start",
|
|
992
|
+
stepIndex,
|
|
993
|
+
stepName: step.name
|
|
994
|
+
});
|
|
995
|
+
const startTime = Date.now();
|
|
996
|
+
const abortController = new AbortController();
|
|
997
|
+
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
|
998
|
+
try {
|
|
999
|
+
const result = await Promise.race([
|
|
1000
|
+
step.run(ctx),
|
|
1001
|
+
new Promise((_, reject) => {
|
|
1002
|
+
abortController.signal.addEventListener("abort", () => {
|
|
1003
|
+
reject(/* @__PURE__ */ new Error(`Step '${step.name}' timed out after ${timeoutMs}ms`));
|
|
1004
|
+
});
|
|
1005
|
+
}),
|
|
1006
|
+
new Promise((_, reject) => {
|
|
1007
|
+
if (!jobDeadlineSignal) return;
|
|
1008
|
+
if (jobDeadlineSignal.aborted) {
|
|
1009
|
+
reject(/* @__PURE__ */ new Error(`Step '${step.name}' aborted: job timeout exceeded`));
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
jobDeadlineSignal.addEventListener("abort", () => {
|
|
1013
|
+
reject(/* @__PURE__ */ new Error(`Step '${step.name}' aborted: job timeout exceeded`));
|
|
1014
|
+
});
|
|
1015
|
+
})
|
|
1016
|
+
]);
|
|
1017
|
+
clearTimeout(timeoutId);
|
|
1018
|
+
const durationMs = Date.now() - startTime;
|
|
1019
|
+
const outputsPayload = result != null ? result : void 0;
|
|
1020
|
+
if (outputsPayload) outputsMap.set(step.name, outputsPayload);
|
|
1021
|
+
const secretsAccessed = getSecretsAccessLog?.();
|
|
1022
|
+
emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
|
|
1023
|
+
sendFn({
|
|
1024
|
+
type: "step.complete",
|
|
1025
|
+
stepIndex,
|
|
1026
|
+
status: ExecutionStepStatus.enum.success,
|
|
1027
|
+
durationMs,
|
|
1028
|
+
...outputsPayload && { outputs: outputsPayload },
|
|
1029
|
+
...secretsAccessed !== void 0 && { secretsAccessed }
|
|
1030
|
+
});
|
|
1031
|
+
return {
|
|
1032
|
+
name: step.name,
|
|
1033
|
+
stepIndex,
|
|
1034
|
+
status: ExecutionStepStatus.enum.success,
|
|
1035
|
+
durationMs,
|
|
1036
|
+
...outputsPayload && { outputs: outputsPayload }
|
|
1037
|
+
};
|
|
1038
|
+
} catch (e) {
|
|
1039
|
+
clearTimeout(timeoutId);
|
|
1040
|
+
const durationMs = Date.now() - startTime;
|
|
1041
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
1042
|
+
const exitCode = extractExitCode(e);
|
|
1043
|
+
const signal = extractSignal(e);
|
|
1044
|
+
const secretsAccessed = getSecretsAccessLog?.();
|
|
1045
|
+
emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
|
|
1046
|
+
sendFn({
|
|
1047
|
+
type: "step.complete",
|
|
1048
|
+
stepIndex,
|
|
1049
|
+
status: ExecutionStepStatus.enum.failed,
|
|
1050
|
+
durationMs,
|
|
1051
|
+
error: {
|
|
1052
|
+
message: error.message,
|
|
1053
|
+
...exitCode !== void 0 && { exitCode },
|
|
1054
|
+
...signal !== void 0 && { signal }
|
|
1055
|
+
},
|
|
1056
|
+
...secretsAccessed !== void 0 && { secretsAccessed }
|
|
1057
|
+
});
|
|
1058
|
+
return {
|
|
1059
|
+
name: step.name,
|
|
1060
|
+
stepIndex,
|
|
1061
|
+
status: ExecutionStepStatus.enum.failed,
|
|
1062
|
+
durationMs,
|
|
1063
|
+
error: {
|
|
1064
|
+
message: error.message,
|
|
1065
|
+
...exitCode !== void 0 && { exitCode },
|
|
1066
|
+
...signal !== void 0 && { signal }
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Emit one `step.secret_mount` IPC event per `mountFile` / `exposeFile` call
|
|
1073
|
+
* the step performed. Called from both the success and failure paths so the
|
|
1074
|
+
* orchestrator's audit trail records every mount regardless of step outcome.
|
|
801
1075
|
*/
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
const
|
|
1076
|
+
function emitSecretMountEvents(records, stepIndex, sendFn) {
|
|
1077
|
+
if (!records || records.length === 0) return;
|
|
1078
|
+
for (const record of records) sendFn({
|
|
1079
|
+
type: "step.secret_mount",
|
|
1080
|
+
stepIndex,
|
|
1081
|
+
sources: record.sources,
|
|
1082
|
+
target: record.target,
|
|
1083
|
+
kind: record.kind,
|
|
1084
|
+
...record.envVar !== void 0 && { envVar: record.envVar }
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
function extractExitCode(error) {
|
|
1088
|
+
if (error && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number") return error.exitCode;
|
|
1089
|
+
}
|
|
1090
|
+
function extractSignal(error) {
|
|
1091
|
+
if (error && typeof error === "object" && "signal" in error && typeof error.signal === "string") return error.signal;
|
|
1092
|
+
}
|
|
805
1093
|
/**
|
|
806
|
-
*
|
|
1094
|
+
* Evaluate step-level rules. Returns a 'skipped' result + emits IPC when a rule
|
|
1095
|
+
* fails; returns null when the step should run normally.
|
|
807
1096
|
*/
|
|
808
|
-
function
|
|
809
|
-
|
|
1097
|
+
async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
|
|
1098
|
+
if (!step.rules || step.rules.length === 0) return null;
|
|
1099
|
+
const ruleCtx = createRuleContext(opts.event, [], opts.env);
|
|
1100
|
+
const ruleResult = await evaluateRules(step.rules, ruleCtx, step.name);
|
|
1101
|
+
if (ruleResult.allPassed) return null;
|
|
1102
|
+
opts.sendIpc({
|
|
1103
|
+
type: "step.start",
|
|
1104
|
+
stepIndex,
|
|
1105
|
+
stepName: step.name
|
|
1106
|
+
});
|
|
1107
|
+
opts.sendIpc({
|
|
1108
|
+
type: "step.complete",
|
|
1109
|
+
stepIndex,
|
|
1110
|
+
status: ExecutionStepStatus.enum.failed,
|
|
1111
|
+
durationMs: 0
|
|
1112
|
+
});
|
|
1113
|
+
opts.sendIpc({
|
|
1114
|
+
type: "log.line",
|
|
1115
|
+
stepIndex,
|
|
1116
|
+
line: `[kici] Step '${step.name}' skipped: rule '${ruleResult.results.find((r) => !r.passed)?.label}' did not pass`
|
|
1117
|
+
});
|
|
1118
|
+
return {
|
|
1119
|
+
name: step.name,
|
|
1120
|
+
stepIndex,
|
|
1121
|
+
status: ExecutionStepStatus.enum.skipped,
|
|
1122
|
+
durationMs: 0
|
|
1123
|
+
};
|
|
810
1124
|
}
|
|
811
1125
|
/**
|
|
812
|
-
*
|
|
813
|
-
*
|
|
1126
|
+
* 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
|
|
@@ -1565,8 +2179,8 @@ function logSubprocessStreams(e, tokens) {
|
|
|
1565
2179
|
* no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
|
|
1566
2180
|
* Node's normal ESM lookup against `.kici/node_modules/`.
|
|
1567
2181
|
*/
|
|
1568
|
-
const AGENT_SDK_VERSION = "0.1.
|
|
1569
|
-
const AGENT_SDK_BUNDLE_HASH = "
|
|
2182
|
+
const AGENT_SDK_VERSION = "0.1.15";
|
|
2183
|
+
const AGENT_SDK_BUNDLE_HASH = "cee24b67bbb4a1d2a770e4063dd37a9af33409faeb5ce32f936703b4431316dd";
|
|
1570
2184
|
/**
|
|
1571
2185
|
* Register the `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook so
|
|
1572
2186
|
* subsequent dynamic `import()` calls for `.ts` / `.tsx` files transform on the
|
|
@@ -1727,40 +2341,6 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
|
|
|
1727
2341
|
throw new Error(`Generated job '${jobName}' not found in DynamicJobFn output (workflow '${workflow.name}', index ${dynamicIndex}). Available: ${actualNames.join(", ")}`);
|
|
1728
2342
|
}
|
|
1729
2343
|
//#endregion
|
|
1730
|
-
//#region src/execution/download.ts
|
|
1731
|
-
/**
|
|
1732
|
-
* Shared HTTP/HTTPS download utility.
|
|
1733
|
-
*
|
|
1734
|
-
* Extracted from workflow-loader.ts to avoid duplication across
|
|
1735
|
-
* dep-restore.ts and workflow-loader.ts.
|
|
1736
|
-
*/
|
|
1737
|
-
/** Download timeout: 5 minutes. */
|
|
1738
|
-
const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
|
|
1739
|
-
/**
|
|
1740
|
-
* Download content from an HTTP/HTTPS URL.
|
|
1741
|
-
*
|
|
1742
|
-
* Includes a 5-minute timeout to prevent the agent from hanging indefinitely
|
|
1743
|
-
* on slow or unresponsive endpoints.
|
|
1744
|
-
*
|
|
1745
|
-
* @param url - The URL to download from
|
|
1746
|
-
* @returns The response body as a Buffer
|
|
1747
|
-
*/
|
|
1748
|
-
function downloadUrl(url) {
|
|
1749
|
-
return new Promise((resolve, reject) => {
|
|
1750
|
-
(url.startsWith("https:") ? https : http).get(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }, (res) => {
|
|
1751
|
-
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
|
1752
|
-
reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} downloading from ${url}`));
|
|
1753
|
-
res.resume();
|
|
1754
|
-
return;
|
|
1755
|
-
}
|
|
1756
|
-
const chunks = [];
|
|
1757
|
-
res.on("data", (chunk) => chunks.push(chunk));
|
|
1758
|
-
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1759
|
-
res.on("error", reject);
|
|
1760
|
-
}).on("error", reject);
|
|
1761
|
-
});
|
|
1762
|
-
}
|
|
1763
|
-
//#endregion
|
|
1764
2344
|
//#region src/execution/source-restore.ts
|
|
1765
2345
|
/**
|
|
1766
2346
|
* `.kici/` source tarball restoration for execution agents.
|
|
@@ -1783,6 +2363,8 @@ function downloadUrl(url) {
|
|
|
1783
2363
|
* runs without a preceding build (cache infrastructure unavailable, or a
|
|
1784
2364
|
* build job that failed but left dynamic dispatch in flight).
|
|
1785
2365
|
*/
|
|
2366
|
+
init_download();
|
|
2367
|
+
init_dep_restore();
|
|
1786
2368
|
const logger$1 = createLogger({ prefix: "source-restore" });
|
|
1787
2369
|
async function extractSourceTarball(data, targetDir) {
|
|
1788
2370
|
await mkdir(targetDir, { recursive: true });
|
|
@@ -1824,6 +2406,7 @@ async function restoreSource(workDir, sourceTarUrl) {
|
|
|
1824
2406
|
* Wire format: [12-byte IV][16-byte auth tag][ciphertext]
|
|
1825
2407
|
* Same encryption scheme as packages/compiler/src/remote/encryption.ts.
|
|
1826
2408
|
*/
|
|
2409
|
+
init_download();
|
|
1827
2410
|
const logger = createLogger({ prefix: "overlay-applier" });
|
|
1828
2411
|
const IV_LENGTH = 12;
|
|
1829
2412
|
const AUTH_TAG_LENGTH = 16;
|
|
@@ -1957,6 +2540,7 @@ async function applyOverlay(config) {
|
|
|
1957
2540
|
* This file is compiled alongside the agent by rolldown (existing build), but
|
|
1958
2541
|
* runs as a SEPARATE process spawned by the sandbox backend.
|
|
1959
2542
|
*/
|
|
2543
|
+
init_dep_restore();
|
|
1960
2544
|
process.on("uncaughtException", (err) => {
|
|
1961
2545
|
process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
|
|
1962
2546
|
if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
|
|
@@ -2139,6 +2723,21 @@ let aborted = false;
|
|
|
2139
2723
|
*/
|
|
2140
2724
|
let forceAborted = false;
|
|
2141
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
|
+
/**
|
|
2142
2741
|
* Pending promises for event.emit requests awaiting responses from the agent.
|
|
2143
2742
|
*
|
|
2144
2743
|
* Key: requestId (correlates EventEmitRequest -> EventEmitResponse)
|
|
@@ -2245,6 +2844,128 @@ function waitForApiResponse(requestId) {
|
|
|
2245
2844
|
});
|
|
2246
2845
|
}
|
|
2247
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
|
+
/**
|
|
2248
2969
|
* Dispatch an agent-to-runner message to the appropriate pending handler.
|
|
2249
2970
|
* Shared between fork mode (IPC channel) and stdio mode (stdin readline).
|
|
2250
2971
|
*/
|
|
@@ -2261,6 +2982,9 @@ function dispatchAgentMessage(msg) {
|
|
|
2261
2982
|
const pending = pendingApiResponses.get(msg.requestId);
|
|
2262
2983
|
if (pending) if (msg.error) pending.reject(new Error(msg.error));
|
|
2263
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);
|
|
2264
2988
|
}
|
|
2265
2989
|
}
|
|
2266
2990
|
if (isForkMode) process.on("message", (msg) => {
|
|
@@ -2390,16 +3114,29 @@ function createSecretMasker(request) {
|
|
|
2390
3114
|
return masker;
|
|
2391
3115
|
}
|
|
2392
3116
|
/**
|
|
2393
|
-
*
|
|
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.
|
|
2394
3123
|
*
|
|
2395
|
-
*
|
|
2396
|
-
*
|
|
2397
|
-
*
|
|
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.
|
|
2398
3135
|
*/
|
|
2399
|
-
function
|
|
3136
|
+
function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
|
|
2400
3137
|
let zxLineBuf = "";
|
|
2401
|
-
|
|
2402
|
-
cwd
|
|
3138
|
+
return $({
|
|
3139
|
+
cwd,
|
|
2403
3140
|
env: { ...process.env },
|
|
2404
3141
|
verbose: false,
|
|
2405
3142
|
quiet: false,
|
|
@@ -2418,20 +3155,36 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
2418
3155
|
}
|
|
2419
3156
|
})
|
|
2420
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);
|
|
2421
3168
|
const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
|
|
3169
|
+
const rawPayload = rawPayloadFromEvent(request.event);
|
|
2422
3170
|
return {
|
|
2423
3171
|
$: step$,
|
|
2424
3172
|
log,
|
|
2425
3173
|
env: process.env,
|
|
2426
3174
|
setEnv: (key, value) => {
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
}
|
|
2431
|
-
|
|
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
|
+
});
|
|
2432
3182
|
},
|
|
2433
3183
|
addPath: (dir) => {
|
|
2434
|
-
|
|
3184
|
+
applyEnvDelta({
|
|
3185
|
+
env: {},
|
|
3186
|
+
pathPrepends: [dir]
|
|
3187
|
+
}, { operatorSecretKeys });
|
|
2435
3188
|
},
|
|
2436
3189
|
inputs: {},
|
|
2437
3190
|
secrets,
|
|
@@ -2442,6 +3195,7 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
2442
3195
|
},
|
|
2443
3196
|
isTestRun: request.isTestRun ?? false,
|
|
2444
3197
|
environment: request.environment,
|
|
3198
|
+
cache: createCacheApi(workDir, buildCacheTransport()),
|
|
2445
3199
|
emit: async (eventName, payload, options) => {
|
|
2446
3200
|
const reqId = randomUUID();
|
|
2447
3201
|
sendMessage({
|
|
@@ -2474,10 +3228,15 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
2474
3228
|
});
|
|
2475
3229
|
return waitForApiResponse(reqId);
|
|
2476
3230
|
}),
|
|
2477
|
-
...
|
|
3231
|
+
...rawPayload && { rawPayload },
|
|
2478
3232
|
...request.provider && { provider: request.provider }
|
|
2479
3233
|
};
|
|
2480
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
|
+
}
|
|
2481
3240
|
/**
|
|
2482
3241
|
* Check if a file exists at the given path.
|
|
2483
3242
|
*/
|
|
@@ -2499,7 +3258,8 @@ function abortAndExit(reason) {
|
|
|
2499
3258
|
sendMessage({
|
|
2500
3259
|
type: "job.complete",
|
|
2501
3260
|
status: ExecutionJobStatus.enum.failed,
|
|
2502
|
-
stepResults: []
|
|
3261
|
+
stepResults: [],
|
|
3262
|
+
...jobTimedOut && { error: `${TimeoutReason.enum.job_timeout}: job exceeded its timeout of ${jobTimedOutMs ?? 0}ms` }
|
|
2503
3263
|
});
|
|
2504
3264
|
process.exit(1);
|
|
2505
3265
|
}
|
|
@@ -3094,6 +3854,121 @@ function collectJobHooks(job) {
|
|
|
3094
3854
|
return jobHooks;
|
|
3095
3855
|
}
|
|
3096
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
|
+
/**
|
|
3097
3972
|
* Run the complete job execution lifecycle.
|
|
3098
3973
|
*
|
|
3099
3974
|
* 1. Receive execution request
|
|
@@ -3124,6 +3999,18 @@ async function main() {
|
|
|
3124
3999
|
});
|
|
3125
4000
|
else sendMessage(msg);
|
|
3126
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
|
+
});
|
|
3127
4014
|
await cloneRepoIfRequested(request, workDir, workflowDir, sourceDir, isGlobal);
|
|
3128
4015
|
await applyOverlayIfRequested(request, workflowDir);
|
|
3129
4016
|
if (aborted) abortAndExit("aborted after clone");
|
|
@@ -3153,6 +4040,15 @@ async function main() {
|
|
|
3153
4040
|
flushOutputCapture();
|
|
3154
4041
|
capturePrepareActive = false;
|
|
3155
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);
|
|
3156
4052
|
let currentStepSecrets = null;
|
|
3157
4053
|
let currentStepDispose = null;
|
|
3158
4054
|
const createStepCtxWithCapture = (stepIndex, stepName) => {
|
|
@@ -3167,6 +4063,7 @@ async function main() {
|
|
|
3167
4063
|
}
|
|
3168
4064
|
return ctx;
|
|
3169
4065
|
};
|
|
4066
|
+
const stepEnvHooks = buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend);
|
|
3170
4067
|
const jobStartTime = Date.now();
|
|
3171
4068
|
const loopResult = await executeStepLoop({
|
|
3172
4069
|
steps: normalizedSteps,
|
|
@@ -3177,7 +4074,9 @@ async function main() {
|
|
|
3177
4074
|
event: request.event ?? {},
|
|
3178
4075
|
env: process.env,
|
|
3179
4076
|
jobHooks,
|
|
4077
|
+
cachePhaseDeps,
|
|
3180
4078
|
isAborted: () => aborted,
|
|
4079
|
+
jobDeadlineSignal: jobDeadlineAbort.signal,
|
|
3181
4080
|
startTime: jobStartTime,
|
|
3182
4081
|
getSecretsAccessLog: () => {
|
|
3183
4082
|
flushOutputCapture();
|
|
@@ -3192,8 +4091,12 @@ async function main() {
|
|
|
3192
4091
|
currentStepDispose = null;
|
|
3193
4092
|
currentStepSecrets = null;
|
|
3194
4093
|
if (disposeFn) await disposeFn();
|
|
3195
|
-
}
|
|
4094
|
+
},
|
|
4095
|
+
beforeStepEnvFiles: stepEnvHooks.beforeStepEnvFiles,
|
|
4096
|
+
afterStepApplyEnvFiles: stepEnvHooks.afterStepApplyEnvFiles
|
|
3196
4097
|
});
|
|
4098
|
+
jobDeadline.clear();
|
|
4099
|
+
await maybeSaveJobCache(jobCacheSpecs, jobCacheRestore, cachePhaseDeps, !aborted && loopResult.status === ExecutionStepStatus.enum.success);
|
|
3197
4100
|
let finalStatus = loopResult.status === ExecutionStepStatus.enum.success ? ExecutionJobStatus.enum.success : ExecutionJobStatus.enum.failed;
|
|
3198
4101
|
let cancelFailureReason;
|
|
3199
4102
|
if (aborted) {
|
|
@@ -3216,19 +4119,40 @@ async function main() {
|
|
|
3216
4119
|
finalStatus = cancelResult.finalStatus;
|
|
3217
4120
|
cancelFailureReason = cancelResult.cancelFailureReason;
|
|
3218
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) {
|
|
3219
4142
|
const aggregatedOutputs = {};
|
|
3220
|
-
for (const [stepName, outputs] of outputsMap) aggregatedOutputs[stepName] = outputs;
|
|
4143
|
+
for (const [stepName, outputs] of args.outputsMap) aggregatedOutputs[stepName] = outputs;
|
|
3221
4144
|
sendMessage({
|
|
3222
4145
|
type: "job.complete",
|
|
3223
|
-
status: finalStatus,
|
|
3224
|
-
stepResults: loopResult.stepResults,
|
|
4146
|
+
status: args.finalStatus,
|
|
4147
|
+
stepResults: args.loopResult.stepResults,
|
|
3225
4148
|
...Object.keys(aggregatedOutputs).length > 0 && { outputs: aggregatedOutputs },
|
|
3226
|
-
...secretOutputs.size > 0 && { secretOutputs: Object.fromEntries(secretOutputs) },
|
|
3227
|
-
...
|
|
3228
|
-
|
|
3229
|
-
|
|
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 }
|
|
3230
4155
|
});
|
|
3231
|
-
process.exit(finalStatus === ExecutionJobStatus.enum.success ? 0 : 1);
|
|
3232
4156
|
}
|
|
3233
4157
|
/**
|
|
3234
4158
|
* Find a static Job by name in the workflow.
|
|
@@ -3255,6 +4179,6 @@ main().catch((error) => {
|
|
|
3255
4179
|
setTimeout(() => process.exit(1), 100);
|
|
3256
4180
|
});
|
|
3257
4181
|
//#endregion
|
|
3258
|
-
export {};
|
|
4182
|
+
export { rawPayloadFromEvent, resolveInitSpecs };
|
|
3259
4183
|
|
|
3260
4184
|
//# sourceMappingURL=workflow-runner.js.map
|