@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.
Files changed (43) hide show
  1. package/dist/checkout/clone-job-repos.d.ts +53 -0
  2. package/dist/checkout/credential-helper-bin.d.ts +28 -0
  3. package/dist/checkout/credential-helper-host.d.ts +48 -0
  4. package/dist/checkout/credential-helper.d.ts +44 -0
  5. package/dist/checkout/git-clone.d.ts +26 -0
  6. package/dist/checkout/grant-table.d.ts +30 -0
  7. package/dist/checkout/job-git-credentials.d.ts +49 -0
  8. package/dist/checkout/write-elevation.d.ts +40 -0
  9. package/dist/config.d.ts +38 -0
  10. package/dist/container-ts-loader-hook.js +2047 -1814
  11. package/dist/execution/between-jobs-controller.d.ts +50 -0
  12. package/dist/execution/between-jobs-reset.d.ts +25 -0
  13. package/dist/execution/cleanup-rerun.d.ts +21 -0
  14. package/dist/execution/download.d.ts +27 -2
  15. package/dist/execution/dynamic-job-serializer.d.ts +6 -2
  16. package/dist/execution/generator-context.d.ts +54 -0
  17. package/dist/execution/global-eval-runner.d.ts +92 -0
  18. package/dist/execution/global-workflow-env.d.ts +57 -0
  19. package/dist/execution/image-build/build-engine.d.ts +75 -0
  20. package/dist/execution/image-build/build-step.d.ts +57 -0
  21. package/dist/execution/image-build/resolve-build-spec.d.ts +41 -0
  22. package/dist/execution/image-build/runtime-facts.d.ts +31 -0
  23. package/dist/execution/init-runner.d.ts +60 -3
  24. package/dist/execution/job-runner.d.ts +146 -0
  25. package/dist/execution/sandbox/bare-metal-sandbox.d.ts +25 -1
  26. package/dist/execution/sandbox/container-sandbox.d.ts +97 -2
  27. package/dist/execution/sandbox/fork-runner.d.ts +55 -1
  28. package/dist/execution/sandbox/image-preflight.d.ts +38 -0
  29. package/dist/execution/sandbox/ipc-protocol.d.ts +96 -2
  30. package/dist/execution/sandbox/kici-runtime.d.ts +48 -0
  31. package/dist/execution/sandbox/step-loop.d.ts +17 -1
  32. package/dist/execution/sandbox/types.d.ts +55 -1
  33. package/dist/execution/sandbox/workflow-runner.d.ts +75 -4
  34. package/dist/execution/workflow-loader.d.ts +8 -1
  35. package/dist/idle-shutdown.d.ts +24 -0
  36. package/dist/index.js +221 -80
  37. package/dist/metrics/prometheus.d.ts +36 -10
  38. package/dist/server.js +3197 -462
  39. package/dist/workflow-runner-bundle.js +42207 -41104
  40. package/dist/workflow-runner.js +594 -196
  41. package/dist/ws/orchestrator-client.d.ts +95 -3
  42. package/package.json +10 -10
  43. package/sbom.spdx.json +464 -454
@@ -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
- await writeFile(keyPath, opts.privateKey.endsWith("\n") ? opts.privateKey : `${opts.privateKey}\n`, { mode: 384 });
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
- await writeFile(knownHostsPath, opts.hostKeyPolicy === "pinned" ? opts.knownHosts : "", { mode: 384 });
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: new Uint8Array(await crypto.subtle.sign({
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 { envelope, publicJwk } = await signStatementDsse(IN_TOTO_PAYLOAD_TYPE, new TextEncoder().encode(JSON.stringify(statement)));
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$5.warn("Failed to register scratch dir glob in .git/info/exclude", {
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$5.warn("Scratch dir cleanup failed (orphan left behind)", {
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$5.info("Downloading dependency tarball", { url: depsUrl });
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 / (1024 * 1024)).toFixed(2);
784
- logger$5.info("Dependencies restored from cache (file)", {
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$5.warn("Retrying dep tarball download", {
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$5.info("Dependencies restored from cache (stream)", { targetDir: workDir });
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$5.warn("Dep tarball download failed", {
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$5, DOWNLOAD_TIMEOUT_MS$2, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
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$5 = createLogger({ prefix: "dep-restore" });
817
- DOWNLOAD_TIMEOUT_MS$2 = 300 * 1e3;
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
- * Upload a buffer to a pre-signed S3 URL via HTTP PUT.
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 resolved = resolveOrchestratorUrl(url);
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(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} uploading to pre-signed URL`));
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
- var DOWNLOAD_TIMEOUT_MS$1;
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
- DOWNLOAD_TIMEOUT_MS$1 = 300 * 1e3;
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 = 300 * 1e3;
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 rename(join(anchorDir, child), dest);
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 = 300 * 1e3;
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: false };
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 = 600 * 1e3;
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) await sshSetup.cleanup().catch(() => {});
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 = 128 * 1024 * 1024;
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 sdk = await import(pathToFileURL(createRequire(workflowFilePath).resolve("@kici-dev/sdk")).href);
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.4.0";
3919
- const AGENT_SDK_BUNDLE_HASH = "fcd0a836aa1cb92debd93e0254450614d123b3748511aa4148d956fd72a7c29f";
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 aesKey = deriveSharedSecret(Buffer.from(orchestratorPrivateKey, "base64"), cliPubKeyBuf);
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.4.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 = 10080 * 60 * 1e3;
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 === "concurrency.ack") {
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) if (msg.error) pending.reject(new Error(msg.error));
5027
- else pending.resolve(msg.result);
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
- return buildNeedsContext({
5510
+ const snapshot = {
5150
5511
  jobs,
5151
5512
  groups,
5152
5513
  statuses: snapStatuses
5153
- }, resolvedNeeds);
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 filePath = join(dir, args.name ?? `secret-${mountCounter}`);
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
- if (request.checkout === false) return;
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
- gitAuth: request.sourceAuth,
5551
- token: request.sourceAuth ? void 0 : request.token
5552
- });
5553
- await excludeScratchFromGit(workDir);
5554
- sendMessage({
5555
- type: "log.line",
5556
- stepIndex: -1,
5557
- line: "[workflow-runner] Clone complete"
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 step contexts
5871
- * receive when the job is a global workflow. No-op for normal jobs.
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
- process.env.KICI_IS_GLOBAL_WORKFLOW = "true";
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: sourceRepoIdentifier,
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 ?? 1800 * 1e3;
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
- await cloneRepoIfRequested(request, workDir, workflowDir, sourceDir, isGlobal);
6395
- await applyOverlayIfRequested(request, workflowDir);
6396
- await makeOverlayGitUsable(request, workflowDir);
6397
- if (aborted) abortAndExit("aborted after clone");
6398
- await installDependenciesIfNeeded(workflowDir, request);
6399
- if (aborted) abortAndExit("aborted after deps");
6400
- await restoreSourceTarballIfRequested(workflowDir, request);
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 apiTransport = async (method, params) => {
6406
- const reqId = randomUUID();
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
- const jobHasRules = (job?.rules?.length ?? 0) > 0;
6419
- const anyStepHasRules = normalizedSteps.some((s) => (s.rules?.length ?? 0) > 0);
6420
- await resolveChangedFilesForRules(request, sourceDir, jobHasRules || anyStepHasRules);
6421
- await maybeSkipJobOnRules(job, request, normalizedSteps);
6422
- if (aborted) abortAndExit("aborted after rules");
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
- const globalRepoInfo = setupGlobalWorkflowEnv(request, isGlobal, workflowDir, sourceDir);
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 runInitPhaseOrFailJob({
6827
+ const envFiles = await createEnvFiles(tmpdir());
6828
+ if (!request.cleanupOnly) await runInitPhaseOrFailJob({
6429
6829
  job,
6430
6830
  stepCwd,
6431
- envFiles: await createEnvFiles(tmpdir()),
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
- event: request.event ?? {},
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