@kici-dev/agent 0.4.0 → 0.5.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.
@@ -681,7 +681,7 @@ async function excludeScratchFromGit(repoWorkDir) {
681
681
  const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
682
682
  await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
683
683
  } catch (err) {
684
- logger$5.warn("Failed to register scratch dir glob in .git/info/exclude", {
684
+ logger$6.warn("Failed to register scratch dir glob in .git/info/exclude", {
685
685
  excludePath,
686
686
  error: err instanceof Error ? err.message : String(err)
687
687
  });
@@ -742,7 +742,7 @@ async function cleanupScratch(scratchDir) {
742
742
  force: true
743
743
  });
744
744
  } catch (cleanupErr) {
745
- logger$5.warn("Scratch dir cleanup failed (orphan left behind)", {
745
+ logger$6.warn("Scratch dir cleanup failed (orphan left behind)", {
746
746
  scratchDir,
747
747
  error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
748
748
  });
@@ -767,7 +767,7 @@ async function cleanupScratch(scratchDir) {
767
767
  */
768
768
  async function restoreDeps(workDir, depsUrl, depsHash) {
769
769
  depsUrl = resolveOrchestratorUrl(depsUrl);
770
- logger$5.info("Downloading dependency tarball", { url: depsUrl });
770
+ logger$6.info("Downloading dependency tarball", { url: depsUrl });
771
771
  const kiciDir = join(workDir, ".kici");
772
772
  if (depsUrl.startsWith("file://")) {
773
773
  const localPath = fileURLToPath(depsUrl);
@@ -781,7 +781,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
781
781
  await moveScratchIntoRepo(scratchDir, workDir);
782
782
  await cleanupScratch(scratchDir);
783
783
  const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
784
- logger$5.info("Dependencies restored from cache (file)", {
784
+ logger$6.info("Dependencies restored from cache (file)", {
785
785
  sizeMB,
786
786
  targetDir: workDir
787
787
  });
@@ -790,7 +790,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
790
790
  if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
791
791
  let lastError;
792
792
  for (let attempt = 0; attempt <= 2; attempt++) {
793
- if (attempt > 0) logger$5.warn("Retrying dep tarball download", {
793
+ if (attempt > 0) logger$6.warn("Retrying dep tarball download", {
794
794
  attempt,
795
795
  url: depsUrl
796
796
  });
@@ -799,11 +799,11 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
799
799
  if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
800
800
  await moveScratchIntoRepo(scratchDir, workDir);
801
801
  await cleanupScratch(scratchDir);
802
- logger$5.info("Dependencies restored from cache (stream)", { targetDir: workDir });
802
+ logger$6.info("Dependencies restored from cache (stream)", { targetDir: workDir });
803
803
  return;
804
804
  } catch (err) {
805
805
  lastError = err instanceof Error ? err : new Error(String(err));
806
- logger$5.warn("Dep tarball download failed", {
806
+ logger$6.warn("Dep tarball download failed", {
807
807
  attempt,
808
808
  error: lastError.message
809
809
  });
@@ -811,9 +811,9 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
811
811
  }
812
812
  throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
813
813
  }
814
- var logger$5, DOWNLOAD_TIMEOUT_MS$2, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
814
+ var logger$6, DOWNLOAD_TIMEOUT_MS$2, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
815
815
  var init_dep_restore = __esmMin((() => {
816
- logger$5 = createLogger({ prefix: "dep-restore" });
816
+ logger$6 = createLogger({ prefix: "dep-restore" });
817
817
  DOWNLOAD_TIMEOUT_MS$2 = 300 * 1e3;
818
818
  SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
819
819
  SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
@@ -827,10 +827,25 @@ var init_dep_restore = __esmMin((() => {
827
827
  * dep-restore.ts and workflow-loader.ts.
828
828
  */
829
829
  var download_exports = /* @__PURE__ */ __exportAll({
830
+ UPLOAD_MAX_RETRIES: () => 2,
830
831
  downloadUrl: () => downloadUrl,
831
832
  uploadToPresignedUrl: () => uploadToPresignedUrl
832
833
  });
833
834
  /**
835
+ * Whether a failed upload attempt is worth repeating.
836
+ *
837
+ * A transport failure (connection refused, reset, DNS) never reached a
838
+ * responder, and 5xx / 429 are the object-storage overload signals AWS
839
+ * documents as retry-with-backoff (S3 answers `SlowDown` with 503). Every other
840
+ * status is a decision the server will repeat: a 403 from an expired or
841
+ * malformed signature, a 400 from a malformed request. Retrying those burns the
842
+ * ceiling without a chance of success and delays the real error.
843
+ */
844
+ function isRetryableUploadFailure(err) {
845
+ if (!(err instanceof PresignedUploadHttpError)) return true;
846
+ return err.statusCode >= 500 || err.statusCode === 429;
847
+ }
848
+ /**
834
849
  * Download content from an HTTP/HTTPS URL.
835
850
  *
836
851
  * Includes a 5-minute timeout to prevent the agent from hanging indefinitely
@@ -854,21 +869,10 @@ function downloadUrl(url) {
854
869
  }).on("error", reject);
855
870
  });
856
871
  }
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) {
872
+ /** One PUT of the whole buffer. Rejects with {@link PresignedUploadHttpError} on a non-2xx. */
873
+ function putOnce(resolvedUrl, data, timeoutMs) {
869
874
  return new Promise((resolve, reject) => {
870
- const resolved = resolveOrchestratorUrl(url);
871
- const parsed = new URL(resolved);
875
+ const parsed = new URL(resolvedUrl);
872
876
  const req = (parsed.protocol === "https:" ? https : http).request({
873
877
  hostname: parsed.hostname,
874
878
  port: parsed.port,
@@ -877,7 +881,7 @@ function uploadToPresignedUrl(url, data) {
877
881
  headers: { "Content-Length": data.length }
878
882
  }, (res) => {
879
883
  if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
880
- reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} uploading to pre-signed URL`));
884
+ reject(new PresignedUploadHttpError(res.statusCode));
881
885
  res.resume();
882
886
  return;
883
887
  }
@@ -885,14 +889,80 @@ function uploadToPresignedUrl(url, data) {
885
889
  res.on("end", () => resolve());
886
890
  res.on("error", reject);
887
891
  });
892
+ req.setTimeout(timeoutMs, () => {
893
+ req.destroy(/* @__PURE__ */ new Error(`Pre-signed upload timed out after ${timeoutMs}ms`));
894
+ });
888
895
  req.on("error", reject);
889
896
  req.end(data);
890
897
  });
891
898
  }
892
- var DOWNLOAD_TIMEOUT_MS$1;
899
+ /**
900
+ * Upload a buffer to a pre-signed S3 URL via HTTP PUT, retrying a transient
901
+ * failure.
902
+ *
903
+ * Used for direct-to-S3 uploads of bundles and dep tarballs. Localhost /
904
+ * 127.0.0.1 URLs are rewritten via `resolveOrchestratorUrl` so the
905
+ * filesystem cache backend's signed URLs work from container agents that
906
+ * can't reach the orchestrator's host loopback directly.
907
+ *
908
+ * **Why retrying is safe here.** A pre-signed PUT writes one whole object at a
909
+ * single key: there is no multipart session, no append, and no
910
+ * server-generated identity, so a repeat attempt writes the same bytes to the
911
+ * same key and the last write wins. S3 also only makes an object visible once
912
+ * the body has been received in full, so an attempt that died mid-body left
913
+ * nothing behind. A retry therefore cannot double-write or produce a torn
914
+ * object — which is why every AWS SDK retries PUTs by default.
915
+ *
916
+ * Only a failure that can plausibly differ next time is repeated — see
917
+ * {@link isRetryableUploadFailure}.
918
+ *
919
+ * @param url - The pre-signed URL to upload to
920
+ * @param data - The buffer to upload
921
+ * @param opts.baseDelayMs - Backoff before the first retry (doubles thereafter)
922
+ * @param opts.timeoutMs - Per-attempt socket-inactivity timeout (see
923
+ * {@link UPLOAD_TIMEOUT_MS}); an override exists so a test can drive the
924
+ * stall path without waiting out the production budget.
925
+ */
926
+ async function uploadToPresignedUrl(url, data, opts) {
927
+ const resolved = resolveOrchestratorUrl(url);
928
+ const baseDelayMs = opts?.baseDelayMs ?? UPLOAD_RETRY_BASE_DELAY_MS;
929
+ const timeoutMs = opts?.timeoutMs ?? UPLOAD_TIMEOUT_MS;
930
+ let lastError;
931
+ for (let attempt = 0; attempt <= 2; attempt++) {
932
+ if (attempt > 0) {
933
+ const delayMs = baseDelayMs * 2 ** (attempt - 1);
934
+ logger$5.warn("Retrying pre-signed upload", {
935
+ attempt,
936
+ delayMs,
937
+ error: lastError?.message
938
+ });
939
+ await new Promise((r) => setTimeout(r, delayMs));
940
+ }
941
+ try {
942
+ await putOnce(resolved, data, timeoutMs);
943
+ return;
944
+ } catch (err) {
945
+ lastError = err instanceof Error ? err : new Error(String(err));
946
+ if (!isRetryableUploadFailure(lastError)) throw lastError;
947
+ }
948
+ }
949
+ throw new Error(`Pre-signed upload failed after 3 attempts: ${lastError?.message}`);
950
+ }
951
+ var logger$5, DOWNLOAD_TIMEOUT_MS$1, UPLOAD_TIMEOUT_MS, UPLOAD_RETRY_BASE_DELAY_MS, PresignedUploadHttpError;
893
952
  var init_download = __esmMin((() => {
894
953
  init_dep_restore();
954
+ logger$5 = createLogger({ prefix: "agent:download" });
895
955
  DOWNLOAD_TIMEOUT_MS$1 = 300 * 1e3;
956
+ UPLOAD_TIMEOUT_MS = 300 * 1e3;
957
+ UPLOAD_RETRY_BASE_DELAY_MS = 500;
958
+ PresignedUploadHttpError = class extends Error {
959
+ statusCode;
960
+ constructor(statusCode) {
961
+ super(`HTTP ${statusCode} uploading to pre-signed URL`);
962
+ this.statusCode = statusCode;
963
+ this.name = "PresignedUploadHttpError";
964
+ }
965
+ };
896
966
  }));
897
967
  //#endregion
898
968
  //#region src/execution/cache/cache-engine.ts
@@ -2011,7 +2081,9 @@ async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
2011
2081
  changedFilesStatus: ev.changedFilesStatus,
2012
2082
  env: opts.env,
2013
2083
  dispatchInputs: opts.dispatchInputs ?? {},
2014
- fanout: opts.fanout
2084
+ fanout: opts.fanout,
2085
+ ...opts.sourceRepo && { sourceRepo: opts.sourceRepo },
2086
+ ...opts.workflowRepo && { workflowRepo: opts.workflowRepo }
2015
2087
  });
2016
2088
  const ruleResult = await evaluateRules(step.rules, ruleCtx, step.name);
2017
2089
  if (ruleResult.allPassed) return null;
@@ -3874,6 +3946,34 @@ function logSubprocessStreams(e, tokens) {
3874
3946
  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
3947
  }
3876
3948
  //#endregion
3949
+ //#region src/execution/generator-context.ts
3950
+ /**
3951
+ * Build the context handed to a `DynamicJobFn`.
3952
+ *
3953
+ * Optional members are spread conditionally rather than assigned `undefined`,
3954
+ * so an absent `needs` / repo pair leaves no key behind — a present-but-
3955
+ * undefined key reads as "declared" to a generator and serializes differently
3956
+ * between the two evaluations.
3957
+ */
3958
+ function buildGeneratorContext(input) {
3959
+ const { workflowName, event, env, repos, needs, $, log, kici } = input;
3960
+ return {
3961
+ $,
3962
+ ctx: {
3963
+ workflow: { name: workflowName },
3964
+ event,
3965
+ ...needs && { needs }
3966
+ },
3967
+ log,
3968
+ env,
3969
+ kici,
3970
+ ...repos && {
3971
+ sourceRepo: repos.sourceRepo,
3972
+ workflowRepo: repos.workflowRepo
3973
+ }
3974
+ };
3975
+ }
3976
+ //#endregion
3877
3977
  //#region src/execution/workflow-loader.ts
3878
3978
  /**
3879
3979
  * Workflow module loading: transforms `.ts` workflow files on import via the
@@ -3915,8 +4015,8 @@ async function resolveWorkflowSdkSetters(workflowFilePath) {
3915
4015
  setJobOutputsMap
3916
4016
  };
3917
4017
  }
3918
- const AGENT_SDK_VERSION = "0.4.0";
3919
- const AGENT_SDK_BUNDLE_HASH = "fcd0a836aa1cb92debd93e0254450614d123b3748511aa4148d956fd72a7c29f";
4018
+ const AGENT_SDK_VERSION = "0.5.0";
4019
+ const AGENT_SDK_BUNDLE_HASH = "5b85e7cffa4a08e39329ff80448a2744af8e5b5f9840ec3801fad45ed11a3de9";
3920
4020
  /**
3921
4021
  * Register the ESM loader hook that transforms `.ts` / `.tsx` files on the fly
3922
4022
  * for subsequent dynamic `import()` calls. Idempotent at our level via the
@@ -4056,7 +4156,7 @@ function extractSteps(workflow, jobName) {
4056
4156
  * A sibling mismatch logs a warning; a missing target job throws a clear
4057
4157
  * determinism error.
4058
4158
  */
4059
- async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event, env, apiTransport, expectedJobNames, upstreamSnapshot, declaredNeeds) {
4159
+ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event, env, apiTransport, expectedJobNames, upstreamSnapshot, declaredNeeds, repos) {
4060
4160
  const dynamicFn = extractDynamicJobFn(workflow, dynamicIndex);
4061
4161
  const { $ } = await import("zx");
4062
4162
  const { createLogger } = await import("@kici-dev/shared");
@@ -4064,17 +4164,16 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
4064
4164
  const log = createLogger({ prefix: `dynamic-job-fn:${workflow.name}` });
4065
4165
  const kici = buildKiciApi(apiTransport ?? (() => Promise.reject(/* @__PURE__ */ new Error("Agent API not available during re-evaluation"))));
4066
4166
  const needs = upstreamSnapshot ? buildNeedsContext(upstreamSnapshot, declaredNeeds ?? []) : void 0;
4067
- const generatedJobs = await dynamicFn({
4167
+ const generatedJobs = await dynamicFn(buildGeneratorContext({
4168
+ workflowName: workflow.name,
4169
+ event,
4170
+ env,
4171
+ ...repos && { repos },
4172
+ ...needs && { needs },
4068
4173
  $,
4069
- ctx: {
4070
- workflow: { name: workflow.name },
4071
- event,
4072
- ...needs && { needs }
4073
- },
4074
4174
  log,
4075
- env,
4076
4175
  kici
4077
- });
4176
+ }));
4078
4177
  const actualNames = generatedJobs.map((j) => j.name);
4079
4178
  let droppedJobs = [];
4080
4179
  if (expectedJobNames) {
@@ -4096,6 +4195,63 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
4096
4195
  throw new Error(`Generated job '${jobName}' not found in DynamicJobFn output (workflow '${workflow.name}', index ${dynamicIndex}). Available: ${actualNames.join(", ")}`);
4097
4196
  }
4098
4197
  //#endregion
4198
+ //#region src/execution/global-workflow-env.ts
4199
+ /**
4200
+ * Derive an `owner/repo` identifier from a clone URL, stripping the trailing
4201
+ * `.git` and any `http(s)://host/` prefix.
4202
+ */
4203
+ function repoIdentifierFromUrl(repoUrl) {
4204
+ return repoUrl.replace(/\.git$/, "").replace(/^https?:\/\/[^/]+\//, "");
4205
+ }
4206
+ /** Every env key {@link applyGlobalWorkflowEnv} writes, in one place. */
4207
+ const GLOBAL_WORKFLOW_ENV_KEYS = [
4208
+ "KICI_IS_GLOBAL_WORKFLOW",
4209
+ "KICI_WORKFLOW_REPO_PATH",
4210
+ "KICI_SOURCE_REPO_PATH",
4211
+ "KICI_SOURCE_REPO",
4212
+ "KICI_SOURCE_BRANCH",
4213
+ "KICI_SOURCE_SHA",
4214
+ "KICI_WORKFLOW_REPO"
4215
+ ];
4216
+ /**
4217
+ * Inject the seven global-workflow env keys and return a restorer that puts
4218
+ * `process.env` back exactly as it was — each key reset to its prior value, or
4219
+ * deleted if it had none.
4220
+ *
4221
+ * **The restorer is mandatory for any caller in a long-lived process.** The
4222
+ * sandbox may ignore it: it runs one job per forked child, which exits. The
4223
+ * pre-dispatch global eval round may NOT: it runs in the agent process, which
4224
+ * serves many dispatches from one `JobRunner`. Leaving the keys set there is
4225
+ * this module's own hazard running backwards — a later NON-global
4226
+ * `DynamicJobFn` evaluation builds its generator context with
4227
+ * `env: process.env` still carrying `KICI_IS_GLOBAL_WORKFLOW=true` and a
4228
+ * `KICI_SOURCE_REPO_PATH` pointing at a deleted work directory, while that
4229
+ * job's own sandbox re-evaluation sees neither (`buildSanitizedEnv` scrubs the
4230
+ * whole `KICI_*` namespace on the trusted profile, and the default profile is
4231
+ * allowlist-only). That is the same two-worlds determinism failure, injected
4232
+ * into an unrelated job.
4233
+ *
4234
+ * `RepoInfo.ref` / `.sha` are optional, so an evaluation with no checkout
4235
+ * metadata writes an empty string rather than leaving the key unset — matching
4236
+ * how `KICI_WORKFLOW_REPO` already handles a missing identifier. Assigning
4237
+ * `undefined` to a `process.env` key would stringify to `"undefined"`, which is
4238
+ * worse than either.
4239
+ */
4240
+ function applyGlobalWorkflowEnv(repos) {
4241
+ const prior = GLOBAL_WORKFLOW_ENV_KEYS.map((key) => [key, process.env[key]]);
4242
+ process.env.KICI_IS_GLOBAL_WORKFLOW = "true";
4243
+ process.env.KICI_WORKFLOW_REPO_PATH = repos.workflowRepo.path;
4244
+ process.env.KICI_SOURCE_REPO_PATH = repos.sourceRepo.path;
4245
+ process.env.KICI_SOURCE_REPO = repos.sourceRepo.identifier;
4246
+ process.env.KICI_SOURCE_BRANCH = repos.sourceRepo.ref ?? "";
4247
+ process.env.KICI_SOURCE_SHA = repos.sourceRepo.sha ?? "";
4248
+ process.env.KICI_WORKFLOW_REPO = repos.workflowRepo.identifier;
4249
+ return () => {
4250
+ for (const [key, value] of prior) if (value === void 0) delete process.env[key];
4251
+ else process.env[key] = value;
4252
+ };
4253
+ }
4254
+ //#endregion
4099
4255
  //#region src/execution/source-restore.ts
4100
4256
  /**
4101
4257
  * `.kici/` source tarball restoration for execution agents.
@@ -4292,7 +4448,7 @@ async function applyOverlay(config) {
4292
4448
  */
4293
4449
  init_download();
4294
4450
  init_dep_restore();
4295
- const AGENT_VERSION = "0.4.0";
4451
+ const AGENT_VERSION = "0.5.0";
4296
4452
  process.on("uncaughtException", (err) => {
4297
4453
  process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
4298
4454
  if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
@@ -5740,6 +5896,27 @@ async function loadWorkflowModuleWithCapture(workflowRoot, request, isGlobal, ma
5740
5896
  }
5741
5897
  }
5742
5898
  /**
5899
+ * Build the argument the user's `concurrency.group(...)` function receives.
5900
+ *
5901
+ * The only inputs an author can scope a group by. `branch` alone does not
5902
+ * separate one repository from another — an organization-wide workflow runs on
5903
+ * events from many repositories, and their default branches share a name — so
5904
+ * `event.sourceRepo` is what makes a per-source-repository group expressible.
5905
+ * That is why the orchestrator writes the whole normalized envelope into every
5906
+ * global job config: an empty `event` here silently collapses every repository
5907
+ * into one group, and with `cancelInProgress` (the default) one repository's
5908
+ * push then cancels another's in-flight run.
5909
+ *
5910
+ * Boundary cast: the wire `request.event` is untyped JSON that, per the unified
5911
+ * event protocol, always carries the normalized event envelope.
5912
+ */
5913
+ function buildConcurrencyGroupContext(request) {
5914
+ return {
5915
+ branch: request.branch ?? request.ref,
5916
+ event: request.event ?? {}
5917
+ };
5918
+ }
5919
+ /**
5743
5920
  * Phase 4b — Evaluate the user-defined `concurrency.group(...)` function with
5744
5921
  * a timeout, report the resulting key to the orchestrator, and act on the
5745
5922
  * returned ack. `wait` and `cancel` paths exit the process directly (the run
@@ -5753,10 +5930,7 @@ async function evaluateConcurrencyGroupIfPresent(workflow, request) {
5753
5930
  if (!workflow.concurrency?.group) return "proceed";
5754
5931
  trace("evaluating concurrency group function");
5755
5932
  const concurrencyTimeoutMs = request.concurrencyEvaluationTimeoutMs ?? 3e4;
5756
- const groupCtx = {
5757
- branch: request.branch ?? request.ref,
5758
- event: request.event ?? {}
5759
- };
5933
+ const groupCtx = buildConcurrencyGroupContext(request);
5760
5934
  try {
5761
5935
  const ac = new AbortController();
5762
5936
  const concurrencyTimer = setTimeout(() => ac.abort(), concurrencyTimeoutMs);
@@ -5867,21 +6041,21 @@ async function evaluateConcurrencyGroupIfPresent(workflow, request) {
5867
6041
  }
5868
6042
  }
5869
6043
  /**
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.
6044
+ * Phase 5 — Inject env vars and build the `RepoInfo` pair that the generator,
6045
+ * the job rules, and step contexts receive when the job is a global workflow.
6046
+ * No-op for normal jobs.
6047
+ *
6048
+ * Runs at the head of phase 5, before anything that may read the source tree:
6049
+ * the generator's re-evaluation (phase 5) and the job rules (phase 7) both take
6050
+ * the returned pair, and both must see what the pre-dispatch evaluation saw.
6051
+ *
6052
+ * `sourceRepo.path` is this sandbox's own absolute path — the same repo lives at
6053
+ * a different path in the evaluation that produced the job list. Read through
6054
+ * it; never compare it or embed it in a job name.
5872
6055
  */
5873
6056
  function setupGlobalWorkflowEnv(request, isGlobal, workflowDir, sourceDir) {
5874
6057
  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 {
6058
+ const repos = {
5885
6059
  workflowRepo: {
5886
6060
  identifier: request.workflowRepoIdentifier ?? "",
5887
6061
  path: workflowDir,
@@ -5889,12 +6063,15 @@ function setupGlobalWorkflowEnv(request, isGlobal, workflowDir, sourceDir) {
5889
6063
  sha: request.workflowSha
5890
6064
  },
5891
6065
  sourceRepo: {
5892
- identifier: sourceRepoIdentifier,
6066
+ identifier: repoIdentifierFromUrl(request.repoUrl),
5893
6067
  path: sourceDir,
5894
6068
  ref: request.ref,
5895
6069
  sha: request.sha
5896
6070
  }
5897
6071
  };
6072
+ applyGlobalWorkflowEnv(repos);
6073
+ trace(`global workflow env vars injected: KICI_WORKFLOW_REPO_PATH=${workflowDir}, KICI_SOURCE_REPO_PATH=${sourceDir}`);
6074
+ return repos;
5898
6075
  }
5899
6076
  /**
5900
6077
  * Phase 8 — Cancel-path hook execution. Runs the four cancel hooks
@@ -6030,11 +6207,11 @@ function coerceStep(stepOrFn, state) {
6030
6207
  * Bare-function steps get auto-generated `step-N` names so the IPC reporting
6031
6208
  * and the StepRefMap (used by `.result` proxies) line up.
6032
6209
  */
6033
- async function extractAndNormalizeSteps(workflow, request, apiTransport) {
6210
+ async function extractAndNormalizeSteps(workflow, request, apiTransport, repos) {
6034
6211
  let rawSteps;
6035
6212
  let driftDroppedJobs = [];
6036
6213
  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);
6214
+ 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
6215
  rawSteps = dynamicResult.steps;
6039
6216
  driftDroppedJobs = dynamicResult.droppedJobs;
6040
6217
  if (driftDroppedJobs.length > 0) trace(`Determinism drift: ${driftDroppedJobs.length} job(s) dropped: ${driftDroppedJobs.join(", ")}`);
@@ -6180,7 +6357,7 @@ function buildJobRuleCompletion(ruleResult, normalizedSteps) {
6180
6357
  * success-skip). Returns false when the caller should continue to step
6181
6358
  * execution.
6182
6359
  */
6183
- async function maybeSkipJobOnRules(job, request, normalizedSteps) {
6360
+ async function maybeSkipJobOnRules(job, request, normalizedSteps, repos) {
6184
6361
  if (!job?.rules || job.rules.length === 0) return false;
6185
6362
  const ev = request.event ?? {};
6186
6363
  const ruleCtx = createRuleContext({
@@ -6189,7 +6366,11 @@ async function maybeSkipJobOnRules(job, request, normalizedSteps) {
6189
6366
  changedFilesStatus: ev.changedFilesStatus,
6190
6367
  env: process.env,
6191
6368
  dispatchInputs: request.dispatchInputs ?? {},
6192
- fanout: deriveFanout(request)
6369
+ fanout: deriveFanout(request),
6370
+ ...repos && {
6371
+ sourceRepo: repos.sourceRepo,
6372
+ workflowRepo: repos.workflowRepo
6373
+ }
6193
6374
  });
6194
6375
  const completion = buildJobRuleCompletion(await evaluateRules(job.rules, ruleCtx, request.jobName), normalizedSteps);
6195
6376
  if (!completion) return false;
@@ -6199,6 +6380,30 @@ async function maybeSkipJobOnRules(job, request, normalizedSteps) {
6199
6380
  process.exit(0);
6200
6381
  }
6201
6382
  /**
6383
+ * Build the inputs the step loop turns into every step rule's `RuleContext`.
6384
+ *
6385
+ * Shares its source with `maybeSkipJobOnRules` so a step rule and a job rule
6386
+ * see the same world: the same event, env, dispatch inputs, fan-out position,
6387
+ * and — for a global workflow — the same source / workflow repo pair. A step
6388
+ * rule that received the pair as `undefined` while the job rule beside it
6389
+ * received the real thing would read the same `RuleContext` type two ways.
6390
+ *
6391
+ * The pair is spread conditionally: a present-but-undefined `sourceRepo` reads
6392
+ * as "declared" to a rule that guards on the key rather than the value.
6393
+ */
6394
+ function buildStepLoopRuleInputs(request, repos) {
6395
+ return {
6396
+ event: request.event ?? {},
6397
+ env: process.env,
6398
+ dispatchInputs: request.dispatchInputs ?? {},
6399
+ fanout: deriveFanout(request),
6400
+ ...repos && {
6401
+ sourceRepo: repos.sourceRepo,
6402
+ workflowRepo: repos.workflowRepo
6403
+ }
6404
+ };
6405
+ }
6406
+ /**
6202
6407
  * Collect the six job-level hooks (beforeStep / afterStep / onSuccess /
6203
6408
  * onFailure / onCancel / cleanup) into a single typed object the step loop
6204
6409
  * and cancel-path consume.
@@ -6402,6 +6607,7 @@ async function main() {
6402
6607
  const module = loaded.module;
6403
6608
  const workflow = extractWorkflow(module, request.workflowName);
6404
6609
  await evaluateConcurrencyGroupIfPresent(workflow, request);
6610
+ const globalRepoInfo = setupGlobalWorkflowEnv(request, isGlobal, workflowDir, sourceDir);
6405
6611
  const apiTransport = async (method, params) => {
6406
6612
  const reqId = randomUUID();
6407
6613
  sendMessage({
@@ -6412,16 +6618,15 @@ async function main() {
6412
6618
  });
6413
6619
  return waitForApiResponse(reqId);
6414
6620
  };
6415
- const { normalizedSteps, nodes, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
6621
+ const { normalizedSteps, nodes, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport, globalRepoInfo);
6416
6622
  const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap, loaded.sdkSetters);
6417
6623
  const job = findJob(workflow, request.jobName);
6418
6624
  const jobHasRules = (job?.rules?.length ?? 0) > 0;
6419
6625
  const anyStepHasRules = normalizedSteps.some((s) => (s.rules?.length ?? 0) > 0);
6420
6626
  await resolveChangedFilesForRules(request, sourceDir, jobHasRules || anyStepHasRules);
6421
- await maybeSkipJobOnRules(job, request, normalizedSteps);
6627
+ await maybeSkipJobOnRules(job, request, normalizedSteps, globalRepoInfo);
6422
6628
  if (aborted) abortAndExit("aborted after rules");
6423
6629
  const jobHooks = collectJobHooks(job);
6424
- const globalRepoInfo = setupGlobalWorkflowEnv(request, isGlobal, workflowDir, sourceDir);
6425
6630
  flushOutputCapture();
6426
6631
  capturePrepareActive = false;
6427
6632
  const stepCwd = sourceDir;
@@ -6468,10 +6673,7 @@ async function main() {
6468
6673
  sendIpc: maskedSend,
6469
6674
  defaultTimeoutMs,
6470
6675
  outputsMap,
6471
- event: request.event ?? {},
6472
- env: process.env,
6473
- dispatchInputs: request.dispatchInputs ?? {},
6474
- fanout: deriveFanout(request),
6676
+ ...buildStepLoopRuleInputs(request, globalRepoInfo),
6475
6677
  jobHooks,
6476
6678
  cachePhaseDeps,
6477
6679
  isAborted: () => aborted,
@@ -6575,6 +6777,6 @@ main().catch((error) => {
6575
6777
  setTimeout(() => process.exit(1), 100);
6576
6778
  });
6577
6779
  //#endregion
6578
- export { buildJobRuleCompletion, buildSandboxShell, buildStepEnvFileHooks, buildStepNeedsContext, coerceStep, createSandboxStepContext, deriveFanout, drainJobTempScope, rawPayloadFromEvent, resolveChangedFilesForRules, resolveEmitEventName, sanitizeTempLabel };
6780
+ export { buildConcurrencyGroupContext, buildJobRuleCompletion, buildSandboxShell, buildStepEnvFileHooks, buildStepLoopRuleInputs, buildStepNeedsContext, coerceStep, createSandboxStepContext, deriveFanout, drainJobTempScope, rawPayloadFromEvent, resolveChangedFilesForRules, resolveEmitEventName, sanitizeTempLabel, setupGlobalWorkflowEnv };
6579
6781
 
6580
6782
  //# sourceMappingURL=workflow-runner.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kici-dev/agent",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Customer-deployable agent for the KiCI CI/CD stack. Connects to an orchestrator, clones the workflow repo, executes steps, and streams logs back.",
5
5
  "keywords": [
6
6
  "ci",
@@ -66,10 +66,10 @@
66
66
  "yaml": "^2.9.0",
67
67
  "zod": "^4.4.3",
68
68
  "zx": "^8.8.5",
69
- "@kici-dev/core": "0.4.0",
70
- "@kici-dev/engine": "0.4.0",
71
- "@kici-dev/sdk": "0.4.0",
72
- "@kici-dev/shared": "0.4.0"
69
+ "@kici-dev/core": "0.5.0",
70
+ "@kici-dev/engine": "0.5.0",
71
+ "@kici-dev/sdk": "0.5.0",
72
+ "@kici-dev/shared": "0.5.0"
73
73
  },
74
74
  "kici": {
75
75
  "metrics": {