@kici-dev/agent 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/checkout/clone-job-repos.d.ts +53 -0
- package/dist/checkout/credential-helper-bin.d.ts +28 -0
- package/dist/checkout/credential-helper-host.d.ts +48 -0
- package/dist/checkout/credential-helper.d.ts +44 -0
- package/dist/checkout/git-clone.d.ts +26 -0
- package/dist/checkout/grant-table.d.ts +30 -0
- package/dist/checkout/job-git-credentials.d.ts +49 -0
- package/dist/checkout/write-elevation.d.ts +40 -0
- package/dist/config.d.ts +38 -0
- package/dist/container-ts-loader-hook.js +2047 -1814
- package/dist/execution/between-jobs-controller.d.ts +50 -0
- package/dist/execution/between-jobs-reset.d.ts +25 -0
- package/dist/execution/cleanup-rerun.d.ts +21 -0
- package/dist/execution/download.d.ts +27 -2
- package/dist/execution/dynamic-job-serializer.d.ts +6 -2
- package/dist/execution/generator-context.d.ts +54 -0
- package/dist/execution/global-eval-runner.d.ts +92 -0
- package/dist/execution/global-workflow-env.d.ts +57 -0
- package/dist/execution/image-build/build-engine.d.ts +75 -0
- package/dist/execution/image-build/build-step.d.ts +57 -0
- package/dist/execution/image-build/resolve-build-spec.d.ts +41 -0
- package/dist/execution/image-build/runtime-facts.d.ts +31 -0
- package/dist/execution/init-runner.d.ts +60 -3
- package/dist/execution/job-runner.d.ts +146 -0
- package/dist/execution/sandbox/bare-metal-sandbox.d.ts +25 -1
- package/dist/execution/sandbox/container-sandbox.d.ts +97 -2
- package/dist/execution/sandbox/fork-runner.d.ts +55 -1
- package/dist/execution/sandbox/image-preflight.d.ts +38 -0
- package/dist/execution/sandbox/ipc-protocol.d.ts +96 -2
- package/dist/execution/sandbox/kici-runtime.d.ts +48 -0
- package/dist/execution/sandbox/step-loop.d.ts +17 -1
- package/dist/execution/sandbox/types.d.ts +55 -1
- package/dist/execution/sandbox/workflow-runner.d.ts +75 -4
- package/dist/execution/workflow-loader.d.ts +8 -1
- package/dist/idle-shutdown.d.ts +24 -0
- package/dist/index.js +221 -80
- package/dist/metrics/prometheus.d.ts +36 -10
- package/dist/server.js +3197 -462
- package/dist/workflow-runner-bundle.js +42207 -41104
- package/dist/workflow-runner.js +594 -196
- package/dist/ws/orchestrator-client.d.ts +95 -3
- package/package.json +10 -10
- package/sbom.spdx.json +464 -454
package/dist/workflow-runner.js
CHANGED
|
@@ -8,7 +8,7 @@ import path, { dirname, isAbsolute, join, relative, resolve, sep } from "node:pa
|
|
|
8
8
|
import { $ } from "zx";
|
|
9
9
|
import { createLogger, deriveSharedSecret, initZx, normalizeLineEndings, sha256, sha256File, toErrorMessage } from "@kici-dev/shared";
|
|
10
10
|
import { createTempScope, makeTempDir } from "@kici-dev/core/tmp";
|
|
11
|
-
import { CacheOutcome, CacheStepType, CheckMode, CheckStepOutcome, ExecutionJobStatus, ExecutionStepStatus, LogStream, StepConcurrencyKind, TimeoutReason, artifactInvalidNameError, checkArtifactName } from "@kici-dev/engine";
|
|
11
|
+
import { CacheOutcome, CacheStepType, CheckMode, CheckStepOutcome, ExecutionJobStatus, ExecutionStepStatus, LogStream, StepConcurrencyKind, TimeoutReason, artifactInvalidNameError, checkArtifactName, reservedEventNamePrefix } from "@kici-dev/engine";
|
|
12
12
|
import { execFile, execFileSync } from "node:child_process";
|
|
13
13
|
import { buildKiciApi, buildNeedsContext, createRuleContext, createStepSecrets, evaluateRules, isDynamicJobFn, isEventDefinition, isParallelGroup, normalizeApproval, normalizeCacheSpecs, provenanceSubjectIsPath, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
|
|
14
14
|
import { OIDC_TOKEN_REQUEST_METHOD } from "@kici-dev/engine/protocol/messages/oidc-token-relay";
|
|
@@ -123,9 +123,11 @@ async function setupSshAuth(opts) {
|
|
|
123
123
|
if (opts.hostKeyPolicy === "pinned" && !opts.knownHosts) throw new Error("pinned hostKeyPolicy requires knownHosts content");
|
|
124
124
|
const { path: tempDir, cleanup } = await makeTempDir("ssh");
|
|
125
125
|
const keyPath = join(tempDir, "id");
|
|
126
|
-
|
|
126
|
+
const pem = opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`;
|
|
127
|
+
await writeFile(keyPath, pem, { mode: 384 });
|
|
127
128
|
const knownHostsPath = join(tempDir, "known_hosts");
|
|
128
|
-
|
|
129
|
+
const knownHostsBody = opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "";
|
|
130
|
+
await writeFile(knownHostsPath, knownHostsBody, { mode: 384 });
|
|
129
131
|
const parts = [
|
|
130
132
|
"ssh",
|
|
131
133
|
"-i",
|
|
@@ -462,13 +464,14 @@ async function signStatementDsse(payloadType, statementBytes) {
|
|
|
462
464
|
const kid = await calculateJwkThumbprint(publicJwk, "sha256");
|
|
463
465
|
publicJwk.kid = kid;
|
|
464
466
|
const pae = dssePae(payloadType, statementBytes);
|
|
467
|
+
const sig = new Uint8Array(await crypto.subtle.sign({
|
|
468
|
+
name: "ECDSA",
|
|
469
|
+
hash: "SHA-256"
|
|
470
|
+
}, privateKey, pae));
|
|
465
471
|
return {
|
|
466
472
|
envelope: buildDsseEnvelope(payloadType, statementBytes, [{
|
|
467
473
|
keyid: kid,
|
|
468
|
-
sig
|
|
469
|
-
name: "ECDSA",
|
|
470
|
-
hash: "SHA-256"
|
|
471
|
-
}, privateKey, pae))
|
|
474
|
+
sig
|
|
472
475
|
}]),
|
|
473
476
|
publicJwk
|
|
474
477
|
};
|
|
@@ -495,7 +498,8 @@ async function attestProvenance(deps, input) {
|
|
|
495
498
|
startedOn: now,
|
|
496
499
|
finishedOn: now
|
|
497
500
|
});
|
|
498
|
-
const
|
|
501
|
+
const statementBytes = new TextEncoder().encode(JSON.stringify(statement));
|
|
502
|
+
const { envelope, publicJwk } = await signStatementDsse(IN_TOTO_PAYLOAD_TYPE, statementBytes);
|
|
499
503
|
const bundle = {
|
|
500
504
|
mediaType: KICI_PROVENANCE_BUNDLE_MEDIA_TYPE,
|
|
501
505
|
dsseEnvelope: envelope,
|
|
@@ -681,7 +685,7 @@ async function excludeScratchFromGit(repoWorkDir) {
|
|
|
681
685
|
const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
682
686
|
await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
|
|
683
687
|
} catch (err) {
|
|
684
|
-
logger$
|
|
688
|
+
logger$6.warn("Failed to register scratch dir glob in .git/info/exclude", {
|
|
685
689
|
excludePath,
|
|
686
690
|
error: err instanceof Error ? err.message : String(err)
|
|
687
691
|
});
|
|
@@ -742,7 +746,7 @@ async function cleanupScratch(scratchDir) {
|
|
|
742
746
|
force: true
|
|
743
747
|
});
|
|
744
748
|
} catch (cleanupErr) {
|
|
745
|
-
logger$
|
|
749
|
+
logger$6.warn("Scratch dir cleanup failed (orphan left behind)", {
|
|
746
750
|
scratchDir,
|
|
747
751
|
error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
|
|
748
752
|
});
|
|
@@ -767,7 +771,7 @@ async function cleanupScratch(scratchDir) {
|
|
|
767
771
|
*/
|
|
768
772
|
async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
769
773
|
depsUrl = resolveOrchestratorUrl(depsUrl);
|
|
770
|
-
logger$
|
|
774
|
+
logger$6.info("Downloading dependency tarball", { url: depsUrl });
|
|
771
775
|
const kiciDir = join(workDir, ".kici");
|
|
772
776
|
if (depsUrl.startsWith("file://")) {
|
|
773
777
|
const localPath = fileURLToPath(depsUrl);
|
|
@@ -780,8 +784,8 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
780
784
|
await extractTarball(data, scratchDir);
|
|
781
785
|
await moveScratchIntoRepo(scratchDir, workDir);
|
|
782
786
|
await cleanupScratch(scratchDir);
|
|
783
|
-
const sizeMB = (data.length /
|
|
784
|
-
logger$
|
|
787
|
+
const sizeMB = (data.length / 1048576).toFixed(2);
|
|
788
|
+
logger$6.info("Dependencies restored from cache (file)", {
|
|
785
789
|
sizeMB,
|
|
786
790
|
targetDir: workDir
|
|
787
791
|
});
|
|
@@ -790,7 +794,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
790
794
|
if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
|
|
791
795
|
let lastError;
|
|
792
796
|
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
793
|
-
if (attempt > 0) logger$
|
|
797
|
+
if (attempt > 0) logger$6.warn("Retrying dep tarball download", {
|
|
794
798
|
attempt,
|
|
795
799
|
url: depsUrl
|
|
796
800
|
});
|
|
@@ -799,11 +803,11 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
799
803
|
if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
|
|
800
804
|
await moveScratchIntoRepo(scratchDir, workDir);
|
|
801
805
|
await cleanupScratch(scratchDir);
|
|
802
|
-
logger$
|
|
806
|
+
logger$6.info("Dependencies restored from cache (stream)", { targetDir: workDir });
|
|
803
807
|
return;
|
|
804
808
|
} catch (err) {
|
|
805
809
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
806
|
-
logger$
|
|
810
|
+
logger$6.warn("Dep tarball download failed", {
|
|
807
811
|
attempt,
|
|
808
812
|
error: lastError.message
|
|
809
813
|
});
|
|
@@ -811,10 +815,10 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
811
815
|
}
|
|
812
816
|
throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
|
|
813
817
|
}
|
|
814
|
-
var logger$
|
|
818
|
+
var logger$6, DOWNLOAD_TIMEOUT_MS$2, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
|
|
815
819
|
var init_dep_restore = __esmMin((() => {
|
|
816
|
-
logger$
|
|
817
|
-
DOWNLOAD_TIMEOUT_MS$2 =
|
|
820
|
+
logger$6 = createLogger({ prefix: "dep-restore" });
|
|
821
|
+
DOWNLOAD_TIMEOUT_MS$2 = 3e5;
|
|
818
822
|
SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
|
|
819
823
|
SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
|
|
820
824
|
}));
|
|
@@ -827,10 +831,25 @@ var init_dep_restore = __esmMin((() => {
|
|
|
827
831
|
* dep-restore.ts and workflow-loader.ts.
|
|
828
832
|
*/
|
|
829
833
|
var download_exports = /* @__PURE__ */ __exportAll({
|
|
834
|
+
UPLOAD_MAX_RETRIES: () => 2,
|
|
830
835
|
downloadUrl: () => downloadUrl,
|
|
831
836
|
uploadToPresignedUrl: () => uploadToPresignedUrl
|
|
832
837
|
});
|
|
833
838
|
/**
|
|
839
|
+
* Whether a failed upload attempt is worth repeating.
|
|
840
|
+
*
|
|
841
|
+
* A transport failure (connection refused, reset, DNS) never reached a
|
|
842
|
+
* responder, and 5xx / 429 are the object-storage overload signals AWS
|
|
843
|
+
* documents as retry-with-backoff (S3 answers `SlowDown` with 503). Every other
|
|
844
|
+
* status is a decision the server will repeat: a 403 from an expired or
|
|
845
|
+
* malformed signature, a 400 from a malformed request. Retrying those burns the
|
|
846
|
+
* ceiling without a chance of success and delays the real error.
|
|
847
|
+
*/
|
|
848
|
+
function isRetryableUploadFailure(err) {
|
|
849
|
+
if (!(err instanceof PresignedUploadHttpError)) return true;
|
|
850
|
+
return err.statusCode >= 500 || err.statusCode === 429;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
834
853
|
* Download content from an HTTP/HTTPS URL.
|
|
835
854
|
*
|
|
836
855
|
* Includes a 5-minute timeout to prevent the agent from hanging indefinitely
|
|
@@ -854,21 +873,10 @@ function downloadUrl(url) {
|
|
|
854
873
|
}).on("error", reject);
|
|
855
874
|
});
|
|
856
875
|
}
|
|
857
|
-
/**
|
|
858
|
-
|
|
859
|
-
*
|
|
860
|
-
* Used for direct-to-S3 uploads of bundles and dep tarballs. Localhost /
|
|
861
|
-
* 127.0.0.1 URLs are rewritten via `resolveOrchestratorUrl` so the
|
|
862
|
-
* filesystem cache backend's signed URLs work from container agents that
|
|
863
|
-
* can't reach the orchestrator's host loopback directly.
|
|
864
|
-
*
|
|
865
|
-
* @param url - The pre-signed URL to upload to
|
|
866
|
-
* @param data - The buffer to upload
|
|
867
|
-
*/
|
|
868
|
-
function uploadToPresignedUrl(url, data) {
|
|
876
|
+
/** One PUT of the whole buffer. Rejects with {@link PresignedUploadHttpError} on a non-2xx. */
|
|
877
|
+
function putOnce(resolvedUrl, data, timeoutMs) {
|
|
869
878
|
return new Promise((resolve, reject) => {
|
|
870
|
-
const
|
|
871
|
-
const parsed = new URL(resolved);
|
|
879
|
+
const parsed = new URL(resolvedUrl);
|
|
872
880
|
const req = (parsed.protocol === "https:" ? https : http).request({
|
|
873
881
|
hostname: parsed.hostname,
|
|
874
882
|
port: parsed.port,
|
|
@@ -877,7 +885,7 @@ function uploadToPresignedUrl(url, data) {
|
|
|
877
885
|
headers: { "Content-Length": data.length }
|
|
878
886
|
}, (res) => {
|
|
879
887
|
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
|
880
|
-
reject(
|
|
888
|
+
reject(new PresignedUploadHttpError(res.statusCode));
|
|
881
889
|
res.resume();
|
|
882
890
|
return;
|
|
883
891
|
}
|
|
@@ -885,14 +893,80 @@ function uploadToPresignedUrl(url, data) {
|
|
|
885
893
|
res.on("end", () => resolve());
|
|
886
894
|
res.on("error", reject);
|
|
887
895
|
});
|
|
896
|
+
req.setTimeout(timeoutMs, () => {
|
|
897
|
+
req.destroy(/* @__PURE__ */ new Error(`Pre-signed upload timed out after ${timeoutMs}ms`));
|
|
898
|
+
});
|
|
888
899
|
req.on("error", reject);
|
|
889
900
|
req.end(data);
|
|
890
901
|
});
|
|
891
902
|
}
|
|
892
|
-
|
|
903
|
+
/**
|
|
904
|
+
* Upload a buffer to a pre-signed S3 URL via HTTP PUT, retrying a transient
|
|
905
|
+
* failure.
|
|
906
|
+
*
|
|
907
|
+
* Used for direct-to-S3 uploads of bundles and dep tarballs. Localhost /
|
|
908
|
+
* 127.0.0.1 URLs are rewritten via `resolveOrchestratorUrl` so the
|
|
909
|
+
* filesystem cache backend's signed URLs work from container agents that
|
|
910
|
+
* can't reach the orchestrator's host loopback directly.
|
|
911
|
+
*
|
|
912
|
+
* **Why retrying is safe here.** A pre-signed PUT writes one whole object at a
|
|
913
|
+
* single key: there is no multipart session, no append, and no
|
|
914
|
+
* server-generated identity, so a repeat attempt writes the same bytes to the
|
|
915
|
+
* same key and the last write wins. S3 also only makes an object visible once
|
|
916
|
+
* the body has been received in full, so an attempt that died mid-body left
|
|
917
|
+
* nothing behind. A retry therefore cannot double-write or produce a torn
|
|
918
|
+
* object — which is why every AWS SDK retries PUTs by default.
|
|
919
|
+
*
|
|
920
|
+
* Only a failure that can plausibly differ next time is repeated — see
|
|
921
|
+
* {@link isRetryableUploadFailure}.
|
|
922
|
+
*
|
|
923
|
+
* @param url - The pre-signed URL to upload to
|
|
924
|
+
* @param data - The buffer to upload
|
|
925
|
+
* @param opts.baseDelayMs - Backoff before the first retry (doubles thereafter)
|
|
926
|
+
* @param opts.timeoutMs - Per-attempt socket-inactivity timeout (see
|
|
927
|
+
* {@link UPLOAD_TIMEOUT_MS}); an override exists so a test can drive the
|
|
928
|
+
* stall path without waiting out the production budget.
|
|
929
|
+
*/
|
|
930
|
+
async function uploadToPresignedUrl(url, data, opts) {
|
|
931
|
+
const resolved = resolveOrchestratorUrl(url);
|
|
932
|
+
const baseDelayMs = opts?.baseDelayMs ?? UPLOAD_RETRY_BASE_DELAY_MS;
|
|
933
|
+
const timeoutMs = opts?.timeoutMs ?? UPLOAD_TIMEOUT_MS;
|
|
934
|
+
let lastError;
|
|
935
|
+
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
936
|
+
if (attempt > 0) {
|
|
937
|
+
const delayMs = baseDelayMs * 2 ** (attempt - 1);
|
|
938
|
+
logger$5.warn("Retrying pre-signed upload", {
|
|
939
|
+
attempt,
|
|
940
|
+
delayMs,
|
|
941
|
+
error: lastError?.message
|
|
942
|
+
});
|
|
943
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
944
|
+
}
|
|
945
|
+
try {
|
|
946
|
+
await putOnce(resolved, data, timeoutMs);
|
|
947
|
+
return;
|
|
948
|
+
} catch (err) {
|
|
949
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
950
|
+
if (!isRetryableUploadFailure(lastError)) throw lastError;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
throw new Error(`Pre-signed upload failed after 3 attempts: ${lastError?.message}`);
|
|
954
|
+
}
|
|
955
|
+
var logger$5, DOWNLOAD_TIMEOUT_MS$1, UPLOAD_TIMEOUT_MS, UPLOAD_RETRY_BASE_DELAY_MS, PresignedUploadHttpError;
|
|
893
956
|
var init_download = __esmMin((() => {
|
|
894
957
|
init_dep_restore();
|
|
895
|
-
|
|
958
|
+
logger$5 = createLogger({ prefix: "agent:download" });
|
|
959
|
+
DOWNLOAD_TIMEOUT_MS$1 = 3e5;
|
|
960
|
+
UPLOAD_TIMEOUT_MS = 3e5;
|
|
961
|
+
UPLOAD_RETRY_BASE_DELAY_MS = 500;
|
|
962
|
+
PresignedUploadHttpError = class extends Error {
|
|
963
|
+
statusCode;
|
|
964
|
+
constructor(statusCode) {
|
|
965
|
+
super(`HTTP ${statusCode} uploading to pre-signed URL`);
|
|
966
|
+
this.statusCode = statusCode;
|
|
967
|
+
this.name = "PresignedUploadHttpError";
|
|
968
|
+
}
|
|
969
|
+
};
|
|
896
970
|
}));
|
|
897
971
|
//#endregion
|
|
898
972
|
//#region src/execution/cache/cache-engine.ts
|
|
@@ -916,11 +990,13 @@ init_download();
|
|
|
916
990
|
* right destination (repo entries under `workDir`, home entries under the
|
|
917
991
|
* homedir). Extraction lands in a scratch dir first, then moves each group
|
|
918
992
|
* into place so a partial restore never leaves half-written paths in the live
|
|
919
|
-
* tree (mirrors dep-restore).
|
|
993
|
+
* tree (mirrors dep-restore). The scratch dir honors `KICI_TMPDIR`, which may
|
|
994
|
+
* sit on a different filesystem from the workspace, so the move falls back to
|
|
995
|
+
* copy-then-remove on `EXDEV` rather than assuming a same-filesystem rename.
|
|
920
996
|
*/
|
|
921
997
|
const logger$4 = createLogger({ prefix: "cache-engine" });
|
|
922
998
|
/** Download timeout for a presigned cache GET: 5 minutes. */
|
|
923
|
-
const DOWNLOAD_TIMEOUT_MS =
|
|
999
|
+
const DOWNLOAD_TIMEOUT_MS = 3e5;
|
|
924
1000
|
/**
|
|
925
1001
|
* Resolve a cache path. `~`-prefixed -> home root; otherwise repo-root-relative.
|
|
926
1002
|
* Rejects absolute paths and `..` escapes so a workflow cannot read or clobber
|
|
@@ -995,6 +1071,32 @@ async function packCachePaths(workDir, paths, roots) {
|
|
|
995
1071
|
await cleanup();
|
|
996
1072
|
}
|
|
997
1073
|
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Move a tree from `src` to `dest`. Attempts a rename (same-filesystem, cheap)
|
|
1076
|
+
* and falls back to copy-then-remove only on `EXDEV` — the errno `rename(2)`
|
|
1077
|
+
* reports when the scratch dir (which honors `KICI_TMPDIR`) is on a different
|
|
1078
|
+
* filesystem from the destination workspace. `verbatimSymlinks` keeps a cached
|
|
1079
|
+
* symlink graph intact (mirroring packCachePaths), and `preserveTimestamps`
|
|
1080
|
+
* keeps mtimes stable so the fallback is byte-for-byte equivalent to the
|
|
1081
|
+
* rename. Any non-`EXDEV` error propagates so a real permission or corruption
|
|
1082
|
+
* failure is never silently turned into a copy.
|
|
1083
|
+
*/
|
|
1084
|
+
async function moveOrCopy(src, dest) {
|
|
1085
|
+
try {
|
|
1086
|
+
await rename(src, dest);
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
if (err.code !== "EXDEV") throw err;
|
|
1089
|
+
await cp(src, dest, {
|
|
1090
|
+
recursive: true,
|
|
1091
|
+
verbatimSymlinks: true,
|
|
1092
|
+
preserveTimestamps: true
|
|
1093
|
+
});
|
|
1094
|
+
await rm(src, {
|
|
1095
|
+
recursive: true,
|
|
1096
|
+
force: true
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
998
1100
|
/** Move the extracted `__repo__` / `__home__` groups from a scratch dir into place. */
|
|
999
1101
|
async function moveAnchoredGroups(scratchDir, workDir, home) {
|
|
1000
1102
|
for (const anchor of await readdir(scratchDir)) {
|
|
@@ -1008,7 +1110,7 @@ async function moveAnchoredGroups(scratchDir, workDir, home) {
|
|
|
1008
1110
|
recursive: true,
|
|
1009
1111
|
force: true
|
|
1010
1112
|
});
|
|
1011
|
-
await
|
|
1113
|
+
await moveOrCopy(join(anchorDir, child), dest);
|
|
1012
1114
|
}
|
|
1013
1115
|
}
|
|
1014
1116
|
}
|
|
@@ -1487,7 +1589,7 @@ function buildMergedFlatSecrets(orchestratorSecrets, namespacedSecrets) {
|
|
|
1487
1589
|
//#endregion
|
|
1488
1590
|
//#region src/execution/hook-executor.ts
|
|
1489
1591
|
/** Default hook timeout: 5 minutes */
|
|
1490
|
-
const DEFAULT_HOOK_TIMEOUT_MS =
|
|
1592
|
+
const DEFAULT_HOOK_TIMEOUT_MS = 3e5;
|
|
1491
1593
|
/**
|
|
1492
1594
|
* Build outcome metadata from execution state.
|
|
1493
1595
|
*
|
|
@@ -2011,7 +2113,9 @@ async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
|
|
|
2011
2113
|
changedFilesStatus: ev.changedFilesStatus,
|
|
2012
2114
|
env: opts.env,
|
|
2013
2115
|
dispatchInputs: opts.dispatchInputs ?? {},
|
|
2014
|
-
fanout: opts.fanout
|
|
2116
|
+
fanout: opts.fanout,
|
|
2117
|
+
...opts.sourceRepo && { sourceRepo: opts.sourceRepo },
|
|
2118
|
+
...opts.workflowRepo && { workflowRepo: opts.workflowRepo }
|
|
2015
2119
|
});
|
|
2016
2120
|
const ruleResult = await evaluateRules(step.rules, ruleCtx, step.name);
|
|
2017
2121
|
if (ruleResult.allPassed) return null;
|
|
@@ -2363,6 +2467,7 @@ async function runJobCompletionHooks(opts, initial, outputsMap, startTime) {
|
|
|
2363
2467
|
opts
|
|
2364
2468
|
});
|
|
2365
2469
|
}
|
|
2470
|
+
opts.sendIpc({ type: "completion-hooks-done" });
|
|
2366
2471
|
return state;
|
|
2367
2472
|
}
|
|
2368
2473
|
/**
|
|
@@ -2379,7 +2484,7 @@ async function runJobCompletionHooks(opts, initial, outputsMap, startTime) {
|
|
|
2379
2484
|
async function executeStepLoop(opts) {
|
|
2380
2485
|
const startTime = opts.startTime ?? Date.now();
|
|
2381
2486
|
const stepResults = [];
|
|
2382
|
-
const state = { failed:
|
|
2487
|
+
const state = { failed: opts.forceInitialFailure === true };
|
|
2383
2488
|
const nodes = opts.stepNodes ?? opts.steps.map((step, i) => ({
|
|
2384
2489
|
kind: "sequential",
|
|
2385
2490
|
step,
|
|
@@ -2483,7 +2588,7 @@ var StepTaskRegistry = class {
|
|
|
2483
2588
|
//#endregion
|
|
2484
2589
|
//#region src/execution/env-init/init-phase.ts
|
|
2485
2590
|
/** Default init timeout when a spec sets none: 10 minutes. */
|
|
2486
|
-
const DEFAULT_INIT_TIMEOUT_MS =
|
|
2591
|
+
const DEFAULT_INIT_TIMEOUT_MS = 6e5;
|
|
2487
2592
|
/**
|
|
2488
2593
|
* Marker thrown when an init command exceeds its wall-clock budget. Carries the
|
|
2489
2594
|
* distinct P3 timeout reason so the phase result + job.complete report a timeout
|
|
@@ -2849,6 +2954,37 @@ function armJobDeadline(timeoutMs, onTimeout) {
|
|
|
2849
2954
|
//#endregion
|
|
2850
2955
|
//#region src/checkout/git-clone.ts
|
|
2851
2956
|
/**
|
|
2957
|
+
* Point a clone at the agent's credential helper.
|
|
2958
|
+
*
|
|
2959
|
+
* Only the helper PATH is written — never a secret — which is what makes it
|
|
2960
|
+
* safe to persist in `.git/config`. `useHttpPath` makes git include
|
|
2961
|
+
* `path=owner/repo.git` in every credential query, without which the helper
|
|
2962
|
+
* could not tell one repository from another and a write grant could not be
|
|
2963
|
+
* confined to its own repo.
|
|
2964
|
+
*/
|
|
2965
|
+
function configureCredentialHelper(workDir, helperPath) {
|
|
2966
|
+
execFileSync("git", [
|
|
2967
|
+
"-C",
|
|
2968
|
+
workDir,
|
|
2969
|
+
"config",
|
|
2970
|
+
"credential.helper",
|
|
2971
|
+
helperPath
|
|
2972
|
+
], {
|
|
2973
|
+
stdio: "pipe",
|
|
2974
|
+
timeout: 1e4
|
|
2975
|
+
});
|
|
2976
|
+
execFileSync("git", [
|
|
2977
|
+
"-C",
|
|
2978
|
+
workDir,
|
|
2979
|
+
"config",
|
|
2980
|
+
"credential.useHttpPath",
|
|
2981
|
+
"true"
|
|
2982
|
+
], {
|
|
2983
|
+
stdio: "pipe",
|
|
2984
|
+
timeout: 1e4
|
|
2985
|
+
});
|
|
2986
|
+
}
|
|
2987
|
+
/**
|
|
2852
2988
|
* Strip auth credentials from git error messages to prevent token leakage.
|
|
2853
2989
|
* Node's execFileSync includes the full command line (including -c http.extraHeader
|
|
2854
2990
|
* and GIT_SSH_COMMAND flags) in error messages, which would expose Base64-encoded
|
|
@@ -2875,7 +3011,7 @@ function redactSensitive(input) {
|
|
|
2875
3011
|
* @throws Error if clone fails or SHA does not match
|
|
2876
3012
|
*/
|
|
2877
3013
|
async function gitClone(options) {
|
|
2878
|
-
const { repoUrl, ref, sha, workDir, token, gitAuth, depth = 1 } = options;
|
|
3014
|
+
const { repoUrl, ref, sha, workDir, token, gitAuth, depth = 1, credentialHelperPath, sshCleanupRegistry } = options;
|
|
2879
3015
|
const auth = gitAuth ? gitAuth : token ? {
|
|
2880
3016
|
kind: "basic",
|
|
2881
3017
|
user: "x-access-token",
|
|
@@ -2929,6 +3065,7 @@ async function gitClone(options) {
|
|
|
2929
3065
|
} catch (err) {
|
|
2930
3066
|
throw sanitizeGitError(err);
|
|
2931
3067
|
}
|
|
3068
|
+
if (credentialHelperPath) configureCredentialHelper(workDir, credentialHelperPath);
|
|
2932
3069
|
if (!sha || sha === "HEAD") return;
|
|
2933
3070
|
const envOpts = env ? { env } : {};
|
|
2934
3071
|
if (!execFileSync("git", [
|
|
@@ -2984,11 +3121,90 @@ async function gitClone(options) {
|
|
|
2984
3121
|
if (!recheckedSha.startsWith(sha)) throw new Error(`SHA mismatch: expected ${sha}, got ${recheckedSha}`);
|
|
2985
3122
|
}
|
|
2986
3123
|
} finally {
|
|
2987
|
-
if (sshSetup)
|
|
3124
|
+
if (sshSetup) {
|
|
3125
|
+
if (sshCleanupRegistry) {
|
|
3126
|
+
const setup = sshSetup;
|
|
3127
|
+
sshCleanupRegistry.defer(() => setup.cleanup().catch(() => {}));
|
|
3128
|
+
} else await sshSetup.cleanup().catch(() => {});
|
|
3129
|
+
}
|
|
2988
3130
|
if (safeDirCleanup) await safeDirCleanup();
|
|
2989
3131
|
}
|
|
2990
3132
|
}
|
|
2991
3133
|
//#endregion
|
|
3134
|
+
//#region src/checkout/clone-job-repos.ts
|
|
3135
|
+
/**
|
|
3136
|
+
* Clone a job's repositories — from the host or from inside the sandbox.
|
|
3137
|
+
*
|
|
3138
|
+
* This used to live only inside the workflow runner, which meant the clone
|
|
3139
|
+
* always happened wherever the runner ran: for a container job, inside the
|
|
3140
|
+
* customer's image, which therefore had to ship git. Extracting it lets the
|
|
3141
|
+
* AGENT clone on the host and copy the tree in, so the image needs no git —
|
|
3142
|
+
* and it puts clone-time credentials on the host, where the credential helper
|
|
3143
|
+
* already works, rather than needing a route into a container hardened with
|
|
3144
|
+
* `CapDrop: ALL`.
|
|
3145
|
+
*
|
|
3146
|
+
* Both callers run the SAME code: the runner keeps calling it for bare-metal
|
|
3147
|
+
* and for the legacy container path, and the agent calls it for a host-side
|
|
3148
|
+
* checkout. A second implementation would be two subtly different clones.
|
|
3149
|
+
*/
|
|
3150
|
+
/**
|
|
3151
|
+
* Clone whatever this job needs, or nothing.
|
|
3152
|
+
*
|
|
3153
|
+
* Three modes, unchanged from where this logic used to live: full-repo overlay
|
|
3154
|
+
* (no clone), global dual-clone (workflow repo + source repo), and the ordinary
|
|
3155
|
+
* single-repo clone.
|
|
3156
|
+
*/
|
|
3157
|
+
async function cloneJobRepos(request, dirs, deps) {
|
|
3158
|
+
if (request.checkout === false) return;
|
|
3159
|
+
const helper = request.credentialHelperPath ? { credentialHelperPath: request.credentialHelperPath } : {};
|
|
3160
|
+
if (request.fullRepo) {
|
|
3161
|
+
await mkdir(dirs.workDir, { recursive: true });
|
|
3162
|
+
deps.log("Full-repo mode: skipping git clone (workspace from overlay tarball)");
|
|
3163
|
+
return;
|
|
3164
|
+
}
|
|
3165
|
+
if (deps.isGlobal) {
|
|
3166
|
+
await mkdir(dirs.workflowDir, { recursive: true });
|
|
3167
|
+
await mkdir(dirs.sourceDir, { recursive: true });
|
|
3168
|
+
const workflowAuth = request.workflowAuth ?? request.sourceAuth;
|
|
3169
|
+
const sourceAuth = request.sourceAuth ?? request.workflowAuth;
|
|
3170
|
+
deps.log(`Global workflow: cloning workflow repo ${request.workflowRepoUrl} ref=${request.workflowRef} into ${dirs.workflowDir}`);
|
|
3171
|
+
await gitClone({
|
|
3172
|
+
repoUrl: request.workflowRepoUrl,
|
|
3173
|
+
ref: request.workflowRef ?? "",
|
|
3174
|
+
sha: request.workflowSha ?? "",
|
|
3175
|
+
workDir: dirs.workflowDir,
|
|
3176
|
+
gitAuth: workflowAuth,
|
|
3177
|
+
token: workflowAuth ? void 0 : request.token,
|
|
3178
|
+
...helper
|
|
3179
|
+
});
|
|
3180
|
+
await deps.excludeScratchFromGit(dirs.workflowDir);
|
|
3181
|
+
deps.log(`Global workflow: cloning source repo ${request.repoUrl} ref=${request.ref} into ${dirs.sourceDir}`);
|
|
3182
|
+
await gitClone({
|
|
3183
|
+
repoUrl: request.repoUrl,
|
|
3184
|
+
ref: request.ref,
|
|
3185
|
+
sha: request.sha,
|
|
3186
|
+
workDir: dirs.sourceDir,
|
|
3187
|
+
gitAuth: sourceAuth,
|
|
3188
|
+
token: sourceAuth ? void 0 : request.token,
|
|
3189
|
+
...helper
|
|
3190
|
+
});
|
|
3191
|
+
deps.log("Dual-clone complete");
|
|
3192
|
+
return;
|
|
3193
|
+
}
|
|
3194
|
+
deps.log(`Cloning ${request.repoUrl} ref=${request.ref} into ${dirs.workDir}`);
|
|
3195
|
+
await gitClone({
|
|
3196
|
+
repoUrl: request.repoUrl,
|
|
3197
|
+
ref: request.ref,
|
|
3198
|
+
sha: request.sha,
|
|
3199
|
+
workDir: dirs.workDir,
|
|
3200
|
+
gitAuth: request.sourceAuth,
|
|
3201
|
+
token: request.sourceAuth ? void 0 : request.token,
|
|
3202
|
+
...helper
|
|
3203
|
+
});
|
|
3204
|
+
await deps.excludeScratchFromGit(dirs.workDir);
|
|
3205
|
+
deps.log("Clone complete");
|
|
3206
|
+
}
|
|
3207
|
+
//#endregion
|
|
2992
3208
|
//#region src/execution/npm-resolver.ts
|
|
2993
3209
|
/**
|
|
2994
3210
|
* Resolve the npm CLI path relative to the running Node.js binary.
|
|
@@ -3517,7 +3733,7 @@ const logger$2 = createLogger({ prefix: "dep-installer" });
|
|
|
3517
3733
|
const execFileAsync = promisify(execFile);
|
|
3518
3734
|
/** Install subprocess timeout (10 min) and stdout/stderr buffer (128 MiB). */
|
|
3519
3735
|
const INSTALL_TIMEOUT_MS = 6e5;
|
|
3520
|
-
const INSTALL_MAX_BUFFER =
|
|
3736
|
+
const INSTALL_MAX_BUFFER = 134217728;
|
|
3521
3737
|
/**
|
|
3522
3738
|
* Detect the package manager for the cloned repo from its committed manifests.
|
|
3523
3739
|
* A pnpm workspace's `packageManager` field + `pnpm-lock.yaml` live at the repo
|
|
@@ -3874,6 +4090,34 @@ function logSubprocessStreams(e, tokens) {
|
|
|
3874
4090
|
if (e && typeof e === "object" && "stderr" in e) process.stderr.write(`[dep-installer:trace] stderr: ${redactNpmOutput(String(e.stderr), tokens).slice(0, 500)}\n`);
|
|
3875
4091
|
}
|
|
3876
4092
|
//#endregion
|
|
4093
|
+
//#region src/execution/generator-context.ts
|
|
4094
|
+
/**
|
|
4095
|
+
* Build the context handed to a `DynamicJobFn`.
|
|
4096
|
+
*
|
|
4097
|
+
* Optional members are spread conditionally rather than assigned `undefined`,
|
|
4098
|
+
* so an absent `needs` / repo pair leaves no key behind — a present-but-
|
|
4099
|
+
* undefined key reads as "declared" to a generator and serializes differently
|
|
4100
|
+
* between the two evaluations.
|
|
4101
|
+
*/
|
|
4102
|
+
function buildGeneratorContext(input) {
|
|
4103
|
+
const { workflowName, event, env, repos, needs, $, log, kici } = input;
|
|
4104
|
+
return {
|
|
4105
|
+
$,
|
|
4106
|
+
ctx: {
|
|
4107
|
+
workflow: { name: workflowName },
|
|
4108
|
+
event,
|
|
4109
|
+
...needs && { needs }
|
|
4110
|
+
},
|
|
4111
|
+
log,
|
|
4112
|
+
env,
|
|
4113
|
+
kici,
|
|
4114
|
+
...repos && {
|
|
4115
|
+
sourceRepo: repos.sourceRepo,
|
|
4116
|
+
workflowRepo: repos.workflowRepo
|
|
4117
|
+
}
|
|
4118
|
+
};
|
|
4119
|
+
}
|
|
4120
|
+
//#endregion
|
|
3877
4121
|
//#region src/execution/workflow-loader.ts
|
|
3878
4122
|
/**
|
|
3879
4123
|
* Workflow module loading: transforms `.ts` workflow files on import via the
|
|
@@ -3902,7 +4146,8 @@ function logSubprocessStreams(e, tokens) {
|
|
|
3902
4146
|
*/
|
|
3903
4147
|
async function resolveWorkflowSdkSetters(workflowFilePath) {
|
|
3904
4148
|
try {
|
|
3905
|
-
const
|
|
4149
|
+
const sdkEntry = createRequire(workflowFilePath).resolve("@kici-dev/sdk");
|
|
4150
|
+
const sdk = await import(pathToFileURL(sdkEntry).href);
|
|
3906
4151
|
if (typeof sdk.setStepOutputsMap === "function" && typeof sdk.setStepRefMap === "function" && typeof sdk.setJobOutputsMap === "function") return {
|
|
3907
4152
|
setStepOutputsMap: sdk.setStepOutputsMap,
|
|
3908
4153
|
setStepRefMap: sdk.setStepRefMap,
|
|
@@ -3915,8 +4160,8 @@ async function resolveWorkflowSdkSetters(workflowFilePath) {
|
|
|
3915
4160
|
setJobOutputsMap
|
|
3916
4161
|
};
|
|
3917
4162
|
}
|
|
3918
|
-
const AGENT_SDK_VERSION = "0.
|
|
3919
|
-
const AGENT_SDK_BUNDLE_HASH = "
|
|
4163
|
+
const AGENT_SDK_VERSION = "0.6.0";
|
|
4164
|
+
const AGENT_SDK_BUNDLE_HASH = "22faf0da45de7c243ce87f80fd33ee51b1df52809fde457b16bb821678f65eb3";
|
|
3920
4165
|
/**
|
|
3921
4166
|
* Register the ESM loader hook that transforms `.ts` / `.tsx` files on the fly
|
|
3922
4167
|
* for subsequent dynamic `import()` calls. Idempotent at our level via the
|
|
@@ -4056,7 +4301,7 @@ function extractSteps(workflow, jobName) {
|
|
|
4056
4301
|
* A sibling mismatch logs a warning; a missing target job throws a clear
|
|
4057
4302
|
* determinism error.
|
|
4058
4303
|
*/
|
|
4059
|
-
async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event, env, apiTransport, expectedJobNames, upstreamSnapshot, declaredNeeds) {
|
|
4304
|
+
async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event, env, apiTransport, expectedJobNames, upstreamSnapshot, declaredNeeds, repos) {
|
|
4060
4305
|
const dynamicFn = extractDynamicJobFn(workflow, dynamicIndex);
|
|
4061
4306
|
const { $ } = await import("zx");
|
|
4062
4307
|
const { createLogger } = await import("@kici-dev/shared");
|
|
@@ -4064,17 +4309,16 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
|
|
|
4064
4309
|
const log = createLogger({ prefix: `dynamic-job-fn:${workflow.name}` });
|
|
4065
4310
|
const kici = buildKiciApi(apiTransport ?? (() => Promise.reject(/* @__PURE__ */ new Error("Agent API not available during re-evaluation"))));
|
|
4066
4311
|
const needs = upstreamSnapshot ? buildNeedsContext(upstreamSnapshot, declaredNeeds ?? []) : void 0;
|
|
4067
|
-
const generatedJobs = await dynamicFn({
|
|
4312
|
+
const generatedJobs = await dynamicFn(buildGeneratorContext({
|
|
4313
|
+
workflowName: workflow.name,
|
|
4314
|
+
event,
|
|
4315
|
+
env,
|
|
4316
|
+
...repos && { repos },
|
|
4317
|
+
...needs && { needs },
|
|
4068
4318
|
$,
|
|
4069
|
-
ctx: {
|
|
4070
|
-
workflow: { name: workflow.name },
|
|
4071
|
-
event,
|
|
4072
|
-
...needs && { needs }
|
|
4073
|
-
},
|
|
4074
4319
|
log,
|
|
4075
|
-
env,
|
|
4076
4320
|
kici
|
|
4077
|
-
});
|
|
4321
|
+
}));
|
|
4078
4322
|
const actualNames = generatedJobs.map((j) => j.name);
|
|
4079
4323
|
let droppedJobs = [];
|
|
4080
4324
|
if (expectedJobNames) {
|
|
@@ -4096,6 +4340,63 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
|
|
|
4096
4340
|
throw new Error(`Generated job '${jobName}' not found in DynamicJobFn output (workflow '${workflow.name}', index ${dynamicIndex}). Available: ${actualNames.join(", ")}`);
|
|
4097
4341
|
}
|
|
4098
4342
|
//#endregion
|
|
4343
|
+
//#region src/execution/global-workflow-env.ts
|
|
4344
|
+
/**
|
|
4345
|
+
* Derive an `owner/repo` identifier from a clone URL, stripping the trailing
|
|
4346
|
+
* `.git` and any `http(s)://host/` prefix.
|
|
4347
|
+
*/
|
|
4348
|
+
function repoIdentifierFromUrl(repoUrl) {
|
|
4349
|
+
return repoUrl.replace(/\.git$/, "").replace(/^https?:\/\/[^/]+\//, "");
|
|
4350
|
+
}
|
|
4351
|
+
/** Every env key {@link applyGlobalWorkflowEnv} writes, in one place. */
|
|
4352
|
+
const GLOBAL_WORKFLOW_ENV_KEYS = [
|
|
4353
|
+
"KICI_IS_GLOBAL_WORKFLOW",
|
|
4354
|
+
"KICI_WORKFLOW_REPO_PATH",
|
|
4355
|
+
"KICI_SOURCE_REPO_PATH",
|
|
4356
|
+
"KICI_SOURCE_REPO",
|
|
4357
|
+
"KICI_SOURCE_BRANCH",
|
|
4358
|
+
"KICI_SOURCE_SHA",
|
|
4359
|
+
"KICI_WORKFLOW_REPO"
|
|
4360
|
+
];
|
|
4361
|
+
/**
|
|
4362
|
+
* Inject the seven global-workflow env keys and return a restorer that puts
|
|
4363
|
+
* `process.env` back exactly as it was — each key reset to its prior value, or
|
|
4364
|
+
* deleted if it had none.
|
|
4365
|
+
*
|
|
4366
|
+
* **The restorer is mandatory for any caller in a long-lived process.** The
|
|
4367
|
+
* sandbox may ignore it: it runs one job per forked child, which exits. The
|
|
4368
|
+
* pre-dispatch global eval round may NOT: it runs in the agent process, which
|
|
4369
|
+
* serves many dispatches from one `JobRunner`. Leaving the keys set there is
|
|
4370
|
+
* this module's own hazard running backwards — a later NON-global
|
|
4371
|
+
* `DynamicJobFn` evaluation builds its generator context with
|
|
4372
|
+
* `env: process.env` still carrying `KICI_IS_GLOBAL_WORKFLOW=true` and a
|
|
4373
|
+
* `KICI_SOURCE_REPO_PATH` pointing at a deleted work directory, while that
|
|
4374
|
+
* job's own sandbox re-evaluation sees neither (`buildSanitizedEnv` scrubs the
|
|
4375
|
+
* whole `KICI_*` namespace on the trusted profile, and the default profile is
|
|
4376
|
+
* allowlist-only). That is the same two-worlds determinism failure, injected
|
|
4377
|
+
* into an unrelated job.
|
|
4378
|
+
*
|
|
4379
|
+
* `RepoInfo.ref` / `.sha` are optional, so an evaluation with no checkout
|
|
4380
|
+
* metadata writes an empty string rather than leaving the key unset — matching
|
|
4381
|
+
* how `KICI_WORKFLOW_REPO` already handles a missing identifier. Assigning
|
|
4382
|
+
* `undefined` to a `process.env` key would stringify to `"undefined"`, which is
|
|
4383
|
+
* worse than either.
|
|
4384
|
+
*/
|
|
4385
|
+
function applyGlobalWorkflowEnv(repos) {
|
|
4386
|
+
const prior = GLOBAL_WORKFLOW_ENV_KEYS.map((key) => [key, process.env[key]]);
|
|
4387
|
+
process.env.KICI_IS_GLOBAL_WORKFLOW = "true";
|
|
4388
|
+
process.env.KICI_WORKFLOW_REPO_PATH = repos.workflowRepo.path;
|
|
4389
|
+
process.env.KICI_SOURCE_REPO_PATH = repos.sourceRepo.path;
|
|
4390
|
+
process.env.KICI_SOURCE_REPO = repos.sourceRepo.identifier;
|
|
4391
|
+
process.env.KICI_SOURCE_BRANCH = repos.sourceRepo.ref ?? "";
|
|
4392
|
+
process.env.KICI_SOURCE_SHA = repos.sourceRepo.sha ?? "";
|
|
4393
|
+
process.env.KICI_WORKFLOW_REPO = repos.workflowRepo.identifier;
|
|
4394
|
+
return () => {
|
|
4395
|
+
for (const [key, value] of prior) if (value === void 0) delete process.env[key];
|
|
4396
|
+
else process.env[key] = value;
|
|
4397
|
+
};
|
|
4398
|
+
}
|
|
4399
|
+
//#endregion
|
|
4099
4400
|
//#region src/execution/source-restore.ts
|
|
4100
4401
|
/**
|
|
4101
4402
|
* `.kici/` source tarball restoration for execution agents.
|
|
@@ -4206,7 +4507,8 @@ async function applyOverlay(config) {
|
|
|
4206
4507
|
throw new Error(`Overlay download failed from ${tarballUrl.replace(/\?.*$/, "?[redacted]")}: ${toErrorMessage(err)}`);
|
|
4207
4508
|
}
|
|
4208
4509
|
const cliPubKeyBuf = Buffer.from(cliPublicKey, "base64");
|
|
4209
|
-
const
|
|
4510
|
+
const orchPrivKeyBuf = Buffer.from(orchestratorPrivateKey, "base64");
|
|
4511
|
+
const aesKey = deriveSharedSecret(orchPrivKeyBuf, cliPubKeyBuf);
|
|
4210
4512
|
const decryptedData = decryptBuffer(encryptedData, aesKey);
|
|
4211
4513
|
logger.info("Extracting overlay tarball", { size: decryptedData.length });
|
|
4212
4514
|
const extractDir = path.join(tmpDir, "extracted");
|
|
@@ -4292,7 +4594,7 @@ async function applyOverlay(config) {
|
|
|
4292
4594
|
*/
|
|
4293
4595
|
init_download();
|
|
4294
4596
|
init_dep_restore();
|
|
4295
|
-
const AGENT_VERSION = "0.
|
|
4597
|
+
const AGENT_VERSION = "0.6.0";
|
|
4296
4598
|
process.on("uncaughtException", (err) => {
|
|
4297
4599
|
process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
|
|
4298
4600
|
if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
|
|
@@ -4509,6 +4811,56 @@ const EMIT_RESPONSE_TIMEOUT_MS = 5e3;
|
|
|
4509
4811
|
* (per research doc pitfall 5: event was already persisted by orchestrator,
|
|
4510
4812
|
* so it will still be routed even if the ack is lost).
|
|
4511
4813
|
*/
|
|
4814
|
+
/** In-flight git write-grant requests, keyed by requestId. */
|
|
4815
|
+
const pendingGitGrants = /* @__PURE__ */ new Map();
|
|
4816
|
+
/** How long to wait for the agent to answer a grant request. */
|
|
4817
|
+
const GIT_GRANT_TIMEOUT_MS = 6e4;
|
|
4818
|
+
function waitForGitGrant(requestId) {
|
|
4819
|
+
return new Promise((resolve) => {
|
|
4820
|
+
const timer = setTimeout(() => {
|
|
4821
|
+
pendingGitGrants.delete(requestId);
|
|
4822
|
+
resolve({
|
|
4823
|
+
type: "git.grant.response",
|
|
4824
|
+
requestId,
|
|
4825
|
+
error: "timed out waiting for the agent to answer a git write-grant request"
|
|
4826
|
+
});
|
|
4827
|
+
}, GIT_GRANT_TIMEOUT_MS);
|
|
4828
|
+
pendingGitGrants.set(requestId, (r) => {
|
|
4829
|
+
clearTimeout(timer);
|
|
4830
|
+
pendingGitGrants.delete(requestId);
|
|
4831
|
+
resolve(r);
|
|
4832
|
+
});
|
|
4833
|
+
});
|
|
4834
|
+
}
|
|
4835
|
+
/**
|
|
4836
|
+
* Open a write window for one repository, run `fn`, and close it.
|
|
4837
|
+
*
|
|
4838
|
+
* The revoke rides a `finally`, so a throwing callback still closes the
|
|
4839
|
+
* window — and the agent's TTL is the backstop if this process dies outright.
|
|
4840
|
+
*/
|
|
4841
|
+
async function withRepoWrite(repository, opts, fn, send, wait) {
|
|
4842
|
+
const requestId = randomUUID();
|
|
4843
|
+
send({
|
|
4844
|
+
type: "git.grant.request",
|
|
4845
|
+
requestId,
|
|
4846
|
+
op: "elevate",
|
|
4847
|
+
repository,
|
|
4848
|
+
permissions: opts.permissions ?? { contents: "write" },
|
|
4849
|
+
...opts.credential ? { credentialName: opts.credential } : {}
|
|
4850
|
+
});
|
|
4851
|
+
const granted = await wait(requestId);
|
|
4852
|
+
if (granted.error) throw new Error(`Cannot open a git write window for '${repository}': ${granted.error}`);
|
|
4853
|
+
try {
|
|
4854
|
+
await fn();
|
|
4855
|
+
} finally {
|
|
4856
|
+
send({
|
|
4857
|
+
type: "git.grant.request",
|
|
4858
|
+
requestId: randomUUID(),
|
|
4859
|
+
op: "revoke",
|
|
4860
|
+
grantId: granted.grantId
|
|
4861
|
+
});
|
|
4862
|
+
}
|
|
4863
|
+
}
|
|
4512
4864
|
function waitForEmitResponse(requestId) {
|
|
4513
4865
|
return new Promise((resolve) => {
|
|
4514
4866
|
const timer = setTimeout(() => {
|
|
@@ -4693,7 +5045,7 @@ const pendingApprovalResolutions = /* @__PURE__ */ new Map();
|
|
|
4693
5045
|
* and sends `expired` when it lapses; this is a safety net well above any sane
|
|
4694
5046
|
* approval window so the runner cannot hang forever if the resolution is lost.
|
|
4695
5047
|
*/
|
|
4696
|
-
const APPROVAL_RESOLUTION_TIMEOUT_MS =
|
|
5048
|
+
const APPROVAL_RESOLUTION_TIMEOUT_MS = 6048e5;
|
|
4697
5049
|
/** Wait for an approval.resolved from the agent with the given requestId. */
|
|
4698
5050
|
function waitForApprovalResolution(requestId) {
|
|
4699
5051
|
return new Promise((resolve) => {
|
|
@@ -5019,12 +5371,15 @@ function dispatchAgentMessage(msg) {
|
|
|
5019
5371
|
} else if (msg.type === "event.emit.response") {
|
|
5020
5372
|
const pending = pendingEmitResponses.get(msg.requestId);
|
|
5021
5373
|
if (pending) pending.resolve(msg);
|
|
5022
|
-
} else if (msg.type === "
|
|
5374
|
+
} else if (msg.type === "git.grant.response") pendingGitGrants.get(msg.requestId)?.(msg);
|
|
5375
|
+
else if (msg.type === "concurrency.ack") {
|
|
5023
5376
|
if (pendingConcurrencyAck) pendingConcurrencyAck.resolve(msg);
|
|
5024
5377
|
} else if (msg.type === "agent.api.response") {
|
|
5025
5378
|
const pending = pendingApiResponses.get(msg.requestId);
|
|
5026
|
-
if (pending)
|
|
5027
|
-
|
|
5379
|
+
if (pending) {
|
|
5380
|
+
if (msg.error) pending.reject(new Error(msg.error));
|
|
5381
|
+
else pending.resolve(msg.result);
|
|
5382
|
+
}
|
|
5028
5383
|
} else if (msg.type === "cache.response") {
|
|
5029
5384
|
const pending = pendingCacheResponses.get(msg.requestId);
|
|
5030
5385
|
if (pending) pending.resolve(msg);
|
|
@@ -5120,16 +5475,22 @@ function needBaseName(need) {
|
|
|
5120
5475
|
* `{ result, status }` / ordered-array shape via the shared SDK builder. Returns
|
|
5121
5476
|
* undefined when the job declares no needs.
|
|
5122
5477
|
*/
|
|
5123
|
-
function buildStepNeedsContext(declaredNeeds, upstreamJobOutputs, upstreamJobStatuses) {
|
|
5478
|
+
function buildStepNeedsContext(declaredNeeds, upstreamJobOutputs, upstreamJobStatuses, upstreamInvokeResults) {
|
|
5124
5479
|
if (!declaredNeeds || declaredNeeds.length === 0) return void 0;
|
|
5125
5480
|
const statuses = upstreamJobStatuses ?? {};
|
|
5126
5481
|
const jobs = {};
|
|
5127
5482
|
const groups = {};
|
|
5128
5483
|
const snapStatuses = {};
|
|
5484
|
+
const invokeResults = {};
|
|
5129
5485
|
const resolvedNeeds = [];
|
|
5130
5486
|
for (const need of declaredNeeds) {
|
|
5131
5487
|
const base = needBaseName(need);
|
|
5132
5488
|
if (!base) continue;
|
|
5489
|
+
if (base.kind === "job" && upstreamInvokeResults?.[base.key]) {
|
|
5490
|
+
invokeResults[base.key] = upstreamInvokeResults[base.key];
|
|
5491
|
+
resolvedNeeds.push(base.key);
|
|
5492
|
+
continue;
|
|
5493
|
+
}
|
|
5133
5494
|
const childNames = Object.keys(statuses).filter((n) => n.startsWith(`${base.key} (`));
|
|
5134
5495
|
if (base.kind === "group" || childNames.length > 0) {
|
|
5135
5496
|
groups[base.key] = [...childNames].sort();
|
|
@@ -5146,11 +5507,13 @@ function buildStepNeedsContext(declaredNeeds, upstreamJobOutputs, upstreamJobSta
|
|
|
5146
5507
|
resolvedNeeds.push(base.key);
|
|
5147
5508
|
}
|
|
5148
5509
|
}
|
|
5149
|
-
|
|
5510
|
+
const snapshot = {
|
|
5150
5511
|
jobs,
|
|
5151
5512
|
groups,
|
|
5152
5513
|
statuses: snapStatuses
|
|
5153
|
-
}
|
|
5514
|
+
};
|
|
5515
|
+
if (Object.keys(invokeResults).length > 0) snapshot.invokeResults = invokeResults;
|
|
5516
|
+
return buildNeedsContext(snapshot, resolvedNeeds);
|
|
5154
5517
|
}
|
|
5155
5518
|
/**
|
|
5156
5519
|
* Extract per-child output records from a fan-out outputs envelope, keyed by the
|
|
@@ -5191,7 +5554,8 @@ function buildStepSecrets(request, masker, onMaskerSecretsAdded) {
|
|
|
5191
5554
|
async writeMountedFile(args) {
|
|
5192
5555
|
const dir = await ensureTmpdir();
|
|
5193
5556
|
mountCounter += 1;
|
|
5194
|
-
const
|
|
5557
|
+
const filename = args.name ?? `secret-${mountCounter}`;
|
|
5558
|
+
const filePath = join(dir, filename);
|
|
5195
5559
|
await fsPromises.writeFile(filePath, args.content);
|
|
5196
5560
|
await fsPromises.chmod(filePath, args.mode);
|
|
5197
5561
|
masker.registerSecrets({ [`__mount_${mountCounter}__`]: args.content });
|
|
@@ -5287,6 +5651,19 @@ function resolveEmitEventName(nameOrDefinition) {
|
|
|
5287
5651
|
return isEventDefinition(nameOrDefinition) ? nameOrDefinition.name : nameOrDefinition;
|
|
5288
5652
|
}
|
|
5289
5653
|
/**
|
|
5654
|
+
* Reject a user `ctx.emit` whose event name uses a reserved prefix: `kici.`
|
|
5655
|
+
* (KiCI-internal system events — the event scaler's scale-up / scale-down) or
|
|
5656
|
+
* `__` (the events the orchestrator mints for itself). A workflow step must
|
|
5657
|
+
* forge neither — a `__` name is dispatched as a TRUSTED ref and skips the
|
|
5658
|
+
* event-storm rate limiter, so emitting one would be a privilege escalation,
|
|
5659
|
+
* not merely a naming collision. Throws a clear error at the emit call; the
|
|
5660
|
+
* orchestrator enforces the same reservation authoritatively.
|
|
5661
|
+
*/
|
|
5662
|
+
function assertUserEmittableEventName(eventName) {
|
|
5663
|
+
const reservedPrefix = reservedEventNamePrefix(eventName);
|
|
5664
|
+
if (reservedPrefix) throw new Error(`event name prefix "${reservedPrefix}" is reserved for KiCI internal events and cannot be emitted from a workflow step (got "${eventName}")`);
|
|
5665
|
+
}
|
|
5666
|
+
/**
|
|
5290
5667
|
* Sanitize a raw identifier into a valid temp label: lowercase, every
|
|
5291
5668
|
* non-`[a-z0-9-]` char to `-`, falling back to `'step'` when the result is
|
|
5292
5669
|
* empty. Applied to both the caller-supplied `ctx.mktemp(label)` and the
|
|
@@ -5389,6 +5766,7 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
5389
5766
|
artifacts: createArtifactsApi(workDir, buildArtifactTransport()),
|
|
5390
5767
|
emit: async (nameOrDefinition, payload, options) => {
|
|
5391
5768
|
const eventName = resolveEmitEventName(nameOrDefinition);
|
|
5769
|
+
assertUserEmittableEventName(eventName);
|
|
5392
5770
|
const reqId = randomUUID();
|
|
5393
5771
|
sendMessage({
|
|
5394
5772
|
type: "event.emit",
|
|
@@ -5413,6 +5791,13 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
5413
5791
|
mktemp: (label) => jobTempScope.mktemp(sanitizeTempLabel(label ?? stepName)),
|
|
5414
5792
|
mktempFile: (label, opts) => jobTempScope.mktempFile(sanitizeTempLabel(label ?? stepName), opts),
|
|
5415
5793
|
kici,
|
|
5794
|
+
repo: {
|
|
5795
|
+
identifier: repoIdentifierFromUrl(request.repoUrl),
|
|
5796
|
+
path: workDir,
|
|
5797
|
+
ref: request.ref,
|
|
5798
|
+
sha: request.sha,
|
|
5799
|
+
withWrite: (opts, fn) => withRepoWrite(repoIdentifierFromUrl(request.repoUrl), opts, fn, sendMessage, waitForGitGrant)
|
|
5800
|
+
},
|
|
5416
5801
|
attestProvenance: buildAttestProvenanceFn(request, workDir, (o) => kici.oidc.token(o)),
|
|
5417
5802
|
...rawPayload && { rawPayload },
|
|
5418
5803
|
...request.provider && { provider: request.provider },
|
|
@@ -5425,7 +5810,7 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
|
|
|
5425
5810
|
})(),
|
|
5426
5811
|
dispatchInputs: request.dispatchInputs ?? {},
|
|
5427
5812
|
...(() => {
|
|
5428
|
-
const needs = buildStepNeedsContext(request.jobNeeds, request.upstreamJobOutputs, request.upstreamJobStatuses);
|
|
5813
|
+
const needs = buildStepNeedsContext(request.jobNeeds, request.upstreamJobOutputs, request.upstreamJobStatuses, request.upstreamInvokeResults);
|
|
5429
5814
|
return needs ? { needs } : {};
|
|
5430
5815
|
})()
|
|
5431
5816
|
};
|
|
@@ -5483,80 +5868,22 @@ function abortAndExit(reason) {
|
|
|
5483
5868
|
* progress IPC log lines as before.
|
|
5484
5869
|
*/
|
|
5485
5870
|
async function cloneRepoIfRequested(request, workDir, workflowDir, sourceDir, isGlobal) {
|
|
5486
|
-
|
|
5487
|
-
if (request.fullRepo) {
|
|
5488
|
-
trace("fullRepo mode -- skipping git clone, workspace from overlay");
|
|
5489
|
-
await fsPromises.mkdir(workDir, { recursive: true });
|
|
5490
|
-
sendMessage({
|
|
5491
|
-
type: "log.line",
|
|
5492
|
-
stepIndex: -1,
|
|
5493
|
-
line: "[workflow-runner] Full-repo mode: skipping git clone (workspace from overlay tarball)"
|
|
5494
|
-
});
|
|
5495
|
-
return;
|
|
5496
|
-
}
|
|
5497
|
-
if (isGlobal) {
|
|
5498
|
-
trace(`starting dual-clone (global workflow)`);
|
|
5499
|
-
await fsPromises.mkdir(workflowDir, { recursive: true });
|
|
5500
|
-
await fsPromises.mkdir(sourceDir, { recursive: true });
|
|
5501
|
-
const workflowAuth = request.workflowAuth ?? request.sourceAuth;
|
|
5502
|
-
const sourceAuth = request.sourceAuth ?? request.workflowAuth;
|
|
5503
|
-
sendMessage({
|
|
5504
|
-
type: "log.line",
|
|
5505
|
-
stepIndex: -1,
|
|
5506
|
-
line: `[workflow-runner] Global workflow: cloning workflow repo ${request.workflowRepoUrl} ref=${request.workflowRef} into ${workflowDir}`
|
|
5507
|
-
});
|
|
5508
|
-
await gitClone({
|
|
5509
|
-
repoUrl: request.workflowRepoUrl,
|
|
5510
|
-
ref: request.workflowRef ?? "",
|
|
5511
|
-
sha: request.workflowSha ?? "",
|
|
5512
|
-
workDir: workflowDir,
|
|
5513
|
-
gitAuth: workflowAuth,
|
|
5514
|
-
token: workflowAuth ? void 0 : request.token
|
|
5515
|
-
});
|
|
5516
|
-
trace("workflow repo clone complete");
|
|
5517
|
-
await excludeScratchFromGit(workflowDir);
|
|
5518
|
-
sendMessage({
|
|
5519
|
-
type: "log.line",
|
|
5520
|
-
stepIndex: -1,
|
|
5521
|
-
line: `[workflow-runner] Global workflow: cloning source repo ${request.repoUrl} ref=${request.ref} into ${sourceDir}`
|
|
5522
|
-
});
|
|
5523
|
-
await gitClone({
|
|
5524
|
-
repoUrl: request.repoUrl,
|
|
5525
|
-
ref: request.ref,
|
|
5526
|
-
sha: request.sha,
|
|
5527
|
-
workDir: sourceDir,
|
|
5528
|
-
gitAuth: sourceAuth,
|
|
5529
|
-
token: sourceAuth ? void 0 : request.token
|
|
5530
|
-
});
|
|
5531
|
-
trace("source repo clone complete");
|
|
5532
|
-
sendMessage({
|
|
5533
|
-
type: "log.line",
|
|
5534
|
-
stepIndex: -1,
|
|
5535
|
-
line: "[workflow-runner] Dual-clone complete"
|
|
5536
|
-
});
|
|
5537
|
-
return;
|
|
5538
|
-
}
|
|
5539
|
-
trace("starting git clone");
|
|
5540
|
-
sendMessage({
|
|
5541
|
-
type: "log.line",
|
|
5542
|
-
stepIndex: -1,
|
|
5543
|
-
line: `[workflow-runner] Cloning ${request.repoUrl} ref=${request.ref} into ${workDir}`
|
|
5544
|
-
});
|
|
5545
|
-
await gitClone({
|
|
5546
|
-
repoUrl: request.repoUrl,
|
|
5547
|
-
ref: request.ref,
|
|
5548
|
-
sha: request.sha,
|
|
5871
|
+
await cloneJobRepos(request, {
|
|
5549
5872
|
workDir,
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
}
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5873
|
+
workflowDir,
|
|
5874
|
+
sourceDir
|
|
5875
|
+
}, {
|
|
5876
|
+
isGlobal,
|
|
5877
|
+
log: (line) => {
|
|
5878
|
+
trace(line);
|
|
5879
|
+
sendMessage({
|
|
5880
|
+
type: "log.line",
|
|
5881
|
+
stepIndex: -1,
|
|
5882
|
+
line: `[workflow-runner] ${line}`
|
|
5883
|
+
});
|
|
5884
|
+
},
|
|
5885
|
+
excludeScratchFromGit
|
|
5558
5886
|
});
|
|
5559
|
-
trace("git clone complete");
|
|
5560
5887
|
}
|
|
5561
5888
|
/**
|
|
5562
5889
|
* Phase 1b — Apply the encrypted overlay tarball when present (test runs
|
|
@@ -5740,6 +6067,27 @@ async function loadWorkflowModuleWithCapture(workflowRoot, request, isGlobal, ma
|
|
|
5740
6067
|
}
|
|
5741
6068
|
}
|
|
5742
6069
|
/**
|
|
6070
|
+
* Build the argument the user's `concurrency.group(...)` function receives.
|
|
6071
|
+
*
|
|
6072
|
+
* The only inputs an author can scope a group by. `branch` alone does not
|
|
6073
|
+
* separate one repository from another — an organization-wide workflow runs on
|
|
6074
|
+
* events from many repositories, and their default branches share a name — so
|
|
6075
|
+
* `event.sourceRepo` is what makes a per-source-repository group expressible.
|
|
6076
|
+
* That is why the orchestrator writes the whole normalized envelope into every
|
|
6077
|
+
* global job config: an empty `event` here silently collapses every repository
|
|
6078
|
+
* into one group, and with `cancelInProgress` (the default) one repository's
|
|
6079
|
+
* push then cancels another's in-flight run.
|
|
6080
|
+
*
|
|
6081
|
+
* Boundary cast: the wire `request.event` is untyped JSON that, per the unified
|
|
6082
|
+
* event protocol, always carries the normalized event envelope.
|
|
6083
|
+
*/
|
|
6084
|
+
function buildConcurrencyGroupContext(request) {
|
|
6085
|
+
return {
|
|
6086
|
+
branch: request.branch ?? request.ref,
|
|
6087
|
+
event: request.event ?? {}
|
|
6088
|
+
};
|
|
6089
|
+
}
|
|
6090
|
+
/**
|
|
5743
6091
|
* Phase 4b — Evaluate the user-defined `concurrency.group(...)` function with
|
|
5744
6092
|
* a timeout, report the resulting key to the orchestrator, and act on the
|
|
5745
6093
|
* returned ack. `wait` and `cancel` paths exit the process directly (the run
|
|
@@ -5753,10 +6101,7 @@ async function evaluateConcurrencyGroupIfPresent(workflow, request) {
|
|
|
5753
6101
|
if (!workflow.concurrency?.group) return "proceed";
|
|
5754
6102
|
trace("evaluating concurrency group function");
|
|
5755
6103
|
const concurrencyTimeoutMs = request.concurrencyEvaluationTimeoutMs ?? 3e4;
|
|
5756
|
-
const groupCtx =
|
|
5757
|
-
branch: request.branch ?? request.ref,
|
|
5758
|
-
event: request.event ?? {}
|
|
5759
|
-
};
|
|
6104
|
+
const groupCtx = buildConcurrencyGroupContext(request);
|
|
5760
6105
|
try {
|
|
5761
6106
|
const ac = new AbortController();
|
|
5762
6107
|
const concurrencyTimer = setTimeout(() => ac.abort(), concurrencyTimeoutMs);
|
|
@@ -5867,21 +6212,21 @@ async function evaluateConcurrencyGroupIfPresent(workflow, request) {
|
|
|
5867
6212
|
}
|
|
5868
6213
|
}
|
|
5869
6214
|
/**
|
|
5870
|
-
* Phase 5 — Inject env vars and build the `RepoInfo` pair that
|
|
5871
|
-
* receive when the job is a global workflow.
|
|
6215
|
+
* Phase 5 — Inject env vars and build the `RepoInfo` pair that the generator,
|
|
6216
|
+
* the job rules, and step contexts receive when the job is a global workflow.
|
|
6217
|
+
* No-op for normal jobs.
|
|
6218
|
+
*
|
|
6219
|
+
* Runs at the head of phase 5, before anything that may read the source tree:
|
|
6220
|
+
* the generator's re-evaluation (phase 5) and the job rules (phase 7) both take
|
|
6221
|
+
* the returned pair, and both must see what the pre-dispatch evaluation saw.
|
|
6222
|
+
*
|
|
6223
|
+
* `sourceRepo.path` is this sandbox's own absolute path — the same repo lives at
|
|
6224
|
+
* a different path in the evaluation that produced the job list. Read through
|
|
6225
|
+
* it; never compare it or embed it in a job name.
|
|
5872
6226
|
*/
|
|
5873
6227
|
function setupGlobalWorkflowEnv(request, isGlobal, workflowDir, sourceDir) {
|
|
5874
6228
|
if (!isGlobal) return void 0;
|
|
5875
|
-
|
|
5876
|
-
process.env.KICI_WORKFLOW_REPO_PATH = workflowDir;
|
|
5877
|
-
process.env.KICI_SOURCE_REPO_PATH = sourceDir;
|
|
5878
|
-
const sourceRepoIdentifier = request.repoUrl.replace(/\.git$/, "").replace(/^https?:\/\/[^/]+\//, "");
|
|
5879
|
-
process.env.KICI_SOURCE_REPO = sourceRepoIdentifier;
|
|
5880
|
-
process.env.KICI_SOURCE_BRANCH = request.ref;
|
|
5881
|
-
process.env.KICI_SOURCE_SHA = request.sha;
|
|
5882
|
-
process.env.KICI_WORKFLOW_REPO = request.workflowRepoIdentifier ?? "";
|
|
5883
|
-
trace(`global workflow env vars injected: KICI_WORKFLOW_REPO_PATH=${workflowDir}, KICI_SOURCE_REPO_PATH=${sourceDir}`);
|
|
5884
|
-
return {
|
|
6229
|
+
const repos = {
|
|
5885
6230
|
workflowRepo: {
|
|
5886
6231
|
identifier: request.workflowRepoIdentifier ?? "",
|
|
5887
6232
|
path: workflowDir,
|
|
@@ -5889,12 +6234,15 @@ function setupGlobalWorkflowEnv(request, isGlobal, workflowDir, sourceDir) {
|
|
|
5889
6234
|
sha: request.workflowSha
|
|
5890
6235
|
},
|
|
5891
6236
|
sourceRepo: {
|
|
5892
|
-
identifier:
|
|
6237
|
+
identifier: repoIdentifierFromUrl(request.repoUrl),
|
|
5893
6238
|
path: sourceDir,
|
|
5894
6239
|
ref: request.ref,
|
|
5895
6240
|
sha: request.sha
|
|
5896
6241
|
}
|
|
5897
6242
|
};
|
|
6243
|
+
applyGlobalWorkflowEnv(repos);
|
|
6244
|
+
trace(`global workflow env vars injected: KICI_WORKFLOW_REPO_PATH=${workflowDir}, KICI_SOURCE_REPO_PATH=${sourceDir}`);
|
|
6245
|
+
return repos;
|
|
5898
6246
|
}
|
|
5899
6247
|
/**
|
|
5900
6248
|
* Phase 8 — Cancel-path hook execution. Runs the four cancel hooks
|
|
@@ -5978,6 +6326,7 @@ async function runCancelPathHooks(args) {
|
|
|
5978
6326
|
stepIndex: -1,
|
|
5979
6327
|
line: `[kici] Cancel complete, job status: ${cancelFailureReason ? "failed" : "cancelled"}`
|
|
5980
6328
|
});
|
|
6329
|
+
maskedSend({ type: "completion-hooks-done" });
|
|
5981
6330
|
return {
|
|
5982
6331
|
finalStatus: ExecutionJobStatus.enum.failed,
|
|
5983
6332
|
cancelFailureReason
|
|
@@ -6030,11 +6379,11 @@ function coerceStep(stepOrFn, state) {
|
|
|
6030
6379
|
* Bare-function steps get auto-generated `step-N` names so the IPC reporting
|
|
6031
6380
|
* and the StepRefMap (used by `.result` proxies) line up.
|
|
6032
6381
|
*/
|
|
6033
|
-
async function extractAndNormalizeSteps(workflow, request, apiTransport) {
|
|
6382
|
+
async function extractAndNormalizeSteps(workflow, request, apiTransport, repos) {
|
|
6034
6383
|
let rawSteps;
|
|
6035
6384
|
let driftDroppedJobs = [];
|
|
6036
6385
|
if (request.dynamicSource) {
|
|
6037
|
-
const dynamicResult = await extractStepsFromDynamicJob(workflow, request.dynamicSource.index, request.jobName, request.dynamicSource.event, process.env, apiTransport, request.dynamicSource.expectedJobNames, request.dynamicSource.upstreamSnapshot, request.dynamicSource.declaredNeeds);
|
|
6386
|
+
const dynamicResult = await extractStepsFromDynamicJob(workflow, request.dynamicSource.index, request.jobName, request.dynamicSource.event, process.env, apiTransport, request.dynamicSource.expectedJobNames, request.dynamicSource.upstreamSnapshot, request.dynamicSource.declaredNeeds, repos);
|
|
6038
6387
|
rawSteps = dynamicResult.steps;
|
|
6039
6388
|
driftDroppedJobs = dynamicResult.droppedJobs;
|
|
6040
6389
|
if (driftDroppedJobs.length > 0) trace(`Determinism drift: ${driftDroppedJobs.length} job(s) dropped: ${driftDroppedJobs.join(", ")}`);
|
|
@@ -6180,7 +6529,7 @@ function buildJobRuleCompletion(ruleResult, normalizedSteps) {
|
|
|
6180
6529
|
* success-skip). Returns false when the caller should continue to step
|
|
6181
6530
|
* execution.
|
|
6182
6531
|
*/
|
|
6183
|
-
async function maybeSkipJobOnRules(job, request, normalizedSteps) {
|
|
6532
|
+
async function maybeSkipJobOnRules(job, request, normalizedSteps, repos) {
|
|
6184
6533
|
if (!job?.rules || job.rules.length === 0) return false;
|
|
6185
6534
|
const ev = request.event ?? {};
|
|
6186
6535
|
const ruleCtx = createRuleContext({
|
|
@@ -6189,7 +6538,11 @@ async function maybeSkipJobOnRules(job, request, normalizedSteps) {
|
|
|
6189
6538
|
changedFilesStatus: ev.changedFilesStatus,
|
|
6190
6539
|
env: process.env,
|
|
6191
6540
|
dispatchInputs: request.dispatchInputs ?? {},
|
|
6192
|
-
fanout: deriveFanout(request)
|
|
6541
|
+
fanout: deriveFanout(request),
|
|
6542
|
+
...repos && {
|
|
6543
|
+
sourceRepo: repos.sourceRepo,
|
|
6544
|
+
workflowRepo: repos.workflowRepo
|
|
6545
|
+
}
|
|
6193
6546
|
});
|
|
6194
6547
|
const completion = buildJobRuleCompletion(await evaluateRules(job.rules, ruleCtx, request.jobName), normalizedSteps);
|
|
6195
6548
|
if (!completion) return false;
|
|
@@ -6199,6 +6552,30 @@ async function maybeSkipJobOnRules(job, request, normalizedSteps) {
|
|
|
6199
6552
|
process.exit(0);
|
|
6200
6553
|
}
|
|
6201
6554
|
/**
|
|
6555
|
+
* Build the inputs the step loop turns into every step rule's `RuleContext`.
|
|
6556
|
+
*
|
|
6557
|
+
* Shares its source with `maybeSkipJobOnRules` so a step rule and a job rule
|
|
6558
|
+
* see the same world: the same event, env, dispatch inputs, fan-out position,
|
|
6559
|
+
* and — for a global workflow — the same source / workflow repo pair. A step
|
|
6560
|
+
* rule that received the pair as `undefined` while the job rule beside it
|
|
6561
|
+
* received the real thing would read the same `RuleContext` type two ways.
|
|
6562
|
+
*
|
|
6563
|
+
* The pair is spread conditionally: a present-but-undefined `sourceRepo` reads
|
|
6564
|
+
* as "declared" to a rule that guards on the key rather than the value.
|
|
6565
|
+
*/
|
|
6566
|
+
function buildStepLoopRuleInputs(request, repos) {
|
|
6567
|
+
return {
|
|
6568
|
+
event: request.event ?? {},
|
|
6569
|
+
env: process.env,
|
|
6570
|
+
dispatchInputs: request.dispatchInputs ?? {},
|
|
6571
|
+
fanout: deriveFanout(request),
|
|
6572
|
+
...repos && {
|
|
6573
|
+
sourceRepo: repos.sourceRepo,
|
|
6574
|
+
workflowRepo: repos.workflowRepo
|
|
6575
|
+
}
|
|
6576
|
+
};
|
|
6577
|
+
}
|
|
6578
|
+
/**
|
|
6202
6579
|
* Collect the six job-level hooks (beforeStep / afterStep / onSuccess /
|
|
6203
6580
|
* onFailure / onCancel / cleanup) into a single typed object the step loop
|
|
6204
6581
|
* and cancel-path consume.
|
|
@@ -6360,6 +6737,30 @@ async function runInitPhaseOrFailJob(args) {
|
|
|
6360
6737
|
* 7. Execute steps sequentially with IPC reporting
|
|
6361
6738
|
* 8. Send job.complete and exit
|
|
6362
6739
|
*/
|
|
6740
|
+
/**
|
|
6741
|
+
* Extract + normalize the job's steps, or return an empty set for a cleanup-only
|
|
6742
|
+
* re-run (which runs no steps and skips the generator re-evaluation this would
|
|
6743
|
+
* otherwise trigger). Owns the `apiTransport` closure the generator uses.
|
|
6744
|
+
*/
|
|
6745
|
+
async function extractStepsForRun(workflow, request, globalRepoInfo) {
|
|
6746
|
+
if (request.cleanupOnly) return {
|
|
6747
|
+
normalizedSteps: [],
|
|
6748
|
+
nodes: [],
|
|
6749
|
+
refMap: /* @__PURE__ */ new WeakMap(),
|
|
6750
|
+
driftDroppedJobs: []
|
|
6751
|
+
};
|
|
6752
|
+
const apiTransport = async (method, params) => {
|
|
6753
|
+
const reqId = randomUUID();
|
|
6754
|
+
sendMessage({
|
|
6755
|
+
type: "agent.api.request",
|
|
6756
|
+
requestId: reqId,
|
|
6757
|
+
method,
|
|
6758
|
+
params: params ?? {}
|
|
6759
|
+
});
|
|
6760
|
+
return waitForApiResponse(reqId);
|
|
6761
|
+
};
|
|
6762
|
+
return extractAndNormalizeSteps(workflow, request, apiTransport, globalRepoInfo);
|
|
6763
|
+
}
|
|
6363
6764
|
async function main() {
|
|
6364
6765
|
trace(`main() started, isForkMode=${isForkMode}, pid=${process.pid}`);
|
|
6365
6766
|
sendMessage({ type: "ready" });
|
|
@@ -6367,7 +6768,7 @@ async function main() {
|
|
|
6367
6768
|
const request = (await receiveRequest()).request;
|
|
6368
6769
|
trace(`execute request received: workDir=${request.workDir}, workflow=${request.workflowName}, job=${request.jobName}`);
|
|
6369
6770
|
const workDir = request.workDir;
|
|
6370
|
-
const defaultTimeoutMs = request.defaultStepTimeoutMs ??
|
|
6771
|
+
const defaultTimeoutMs = request.defaultStepTimeoutMs ?? 18e5;
|
|
6371
6772
|
const isGlobal = request.isGlobalWorkflow === true;
|
|
6372
6773
|
const workflowDir = isGlobal ? join(workDir, "workflow") : workDir;
|
|
6373
6774
|
const sourceDir = isGlobal ? join(workDir, "source") : workDir;
|
|
@@ -6391,48 +6792,47 @@ async function main() {
|
|
|
6391
6792
|
});
|
|
6392
6793
|
jobDeadlineAbort.abort();
|
|
6393
6794
|
});
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
|
|
6399
|
-
|
|
6400
|
-
|
|
6795
|
+
if (!request.cleanupOnly) {
|
|
6796
|
+
await cloneRepoIfRequested(request, workDir, workflowDir, sourceDir, isGlobal);
|
|
6797
|
+
await applyOverlayIfRequested(request, workflowDir);
|
|
6798
|
+
await makeOverlayGitUsable(request, workflowDir);
|
|
6799
|
+
if (aborted) abortAndExit("aborted after clone");
|
|
6800
|
+
await installDependenciesIfNeeded(workflowDir, request);
|
|
6801
|
+
if (aborted) abortAndExit("aborted after deps");
|
|
6802
|
+
await restoreSourceTarballIfRequested(workflowDir, request);
|
|
6803
|
+
}
|
|
6401
6804
|
const loaded = await loadWorkflowModuleWithCapture(workflowDir, request, isGlobal, maskedSend);
|
|
6402
6805
|
const module = loaded.module;
|
|
6403
6806
|
const workflow = extractWorkflow(module, request.workflowName);
|
|
6404
|
-
await evaluateConcurrencyGroupIfPresent(workflow, request);
|
|
6405
|
-
const
|
|
6406
|
-
|
|
6407
|
-
sendMessage({
|
|
6408
|
-
type: "agent.api.request",
|
|
6409
|
-
requestId: reqId,
|
|
6410
|
-
method,
|
|
6411
|
-
params: params ?? {}
|
|
6412
|
-
});
|
|
6413
|
-
return waitForApiResponse(reqId);
|
|
6414
|
-
};
|
|
6415
|
-
const { normalizedSteps, nodes, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
|
|
6807
|
+
if (!request.cleanupOnly) await evaluateConcurrencyGroupIfPresent(workflow, request);
|
|
6808
|
+
const globalRepoInfo = setupGlobalWorkflowEnv(request, isGlobal, workflowDir, sourceDir);
|
|
6809
|
+
const { normalizedSteps, nodes, refMap, driftDroppedJobs } = await extractStepsForRun(workflow, request, globalRepoInfo);
|
|
6416
6810
|
const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap, loaded.sdkSetters);
|
|
6417
6811
|
const job = findJob(workflow, request.jobName);
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
|
|
6422
|
-
|
|
6812
|
+
if (!request.cleanupOnly) {
|
|
6813
|
+
const jobHasRules = (job?.rules?.length ?? 0) > 0;
|
|
6814
|
+
const anyStepHasRules = normalizedSteps.some((s) => (s.rules?.length ?? 0) > 0);
|
|
6815
|
+
await resolveChangedFilesForRules(request, sourceDir, jobHasRules || anyStepHasRules);
|
|
6816
|
+
await maybeSkipJobOnRules(job, request, normalizedSteps, globalRepoInfo);
|
|
6817
|
+
if (aborted) abortAndExit("aborted after rules");
|
|
6818
|
+
}
|
|
6423
6819
|
const jobHooks = collectJobHooks(job);
|
|
6424
|
-
|
|
6820
|
+
maskedSend({
|
|
6821
|
+
type: "hooks-declared",
|
|
6822
|
+
declaresCleanup: Boolean(jobHooks.onFailure || jobHooks.cleanup)
|
|
6823
|
+
});
|
|
6425
6824
|
flushOutputCapture();
|
|
6426
6825
|
capturePrepareActive = false;
|
|
6427
6826
|
const stepCwd = sourceDir;
|
|
6428
|
-
await
|
|
6827
|
+
const envFiles = await createEnvFiles(tmpdir());
|
|
6828
|
+
if (!request.cleanupOnly) await runInitPhaseOrFailJob({
|
|
6429
6829
|
job,
|
|
6430
6830
|
stepCwd,
|
|
6431
|
-
envFiles
|
|
6831
|
+
envFiles,
|
|
6432
6832
|
operatorSecretKeys,
|
|
6433
6833
|
maskedSend
|
|
6434
6834
|
});
|
|
6435
|
-
const { cachePhaseDeps, jobCacheSpecs, jobCacheRestore } = await setupJobCache(stepCwd, normalizedSteps.length, job?.cache, maskedSend);
|
|
6835
|
+
const { cachePhaseDeps, jobCacheSpecs, jobCacheRestore } = await setupJobCache(stepCwd, normalizedSteps.length, request.cleanupOnly ? void 0 : job?.cache, maskedSend);
|
|
6436
6836
|
const stepTasks = new StepTaskRegistry();
|
|
6437
6837
|
const stepAbortControllers = /* @__PURE__ */ new Map();
|
|
6438
6838
|
const jobTempScope = createTempScope();
|
|
@@ -6464,14 +6864,12 @@ async function main() {
|
|
|
6464
6864
|
abortStep: (stepIndex) => stepAbortControllers.get(stepIndex)?.abort(),
|
|
6465
6865
|
getStepAbortSignal: (stepIndex) => stepAbortControllers.get(stepIndex)?.signal,
|
|
6466
6866
|
checkMode: request.checkMode,
|
|
6867
|
+
...request.cleanupOnly ? { forceInitialFailure: true } : {},
|
|
6467
6868
|
createStepContext: createStepCtxWithCapture,
|
|
6468
6869
|
sendIpc: maskedSend,
|
|
6469
6870
|
defaultTimeoutMs,
|
|
6470
6871
|
outputsMap,
|
|
6471
|
-
|
|
6472
|
-
env: process.env,
|
|
6473
|
-
dispatchInputs: request.dispatchInputs ?? {},
|
|
6474
|
-
fanout: deriveFanout(request),
|
|
6872
|
+
...buildStepLoopRuleInputs(request, globalRepoInfo),
|
|
6475
6873
|
jobHooks,
|
|
6476
6874
|
cachePhaseDeps,
|
|
6477
6875
|
isAborted: () => aborted,
|
|
@@ -6575,6 +6973,6 @@ main().catch((error) => {
|
|
|
6575
6973
|
setTimeout(() => process.exit(1), 100);
|
|
6576
6974
|
});
|
|
6577
6975
|
//#endregion
|
|
6578
|
-
export { buildJobRuleCompletion, buildSandboxShell, buildStepEnvFileHooks, buildStepNeedsContext, coerceStep, createSandboxStepContext, deriveFanout, drainJobTempScope, rawPayloadFromEvent, resolveChangedFilesForRules, resolveEmitEventName, sanitizeTempLabel };
|
|
6976
|
+
export { assertUserEmittableEventName, buildConcurrencyGroupContext, buildJobRuleCompletion, buildSandboxShell, buildStepEnvFileHooks, buildStepLoopRuleInputs, buildStepNeedsContext, coerceStep, createSandboxStepContext, deriveFanout, drainJobTempScope, rawPayloadFromEvent, resolveChangedFilesForRules, resolveEmitEventName, sanitizeTempLabel, setupGlobalWorkflowEnv, withRepoWrite };
|
|
6579
6977
|
|
|
6580
6978
|
//# sourceMappingURL=workflow-runner.js.map
|