@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.
- package/dist/execution/download.d.ts +27 -2
- package/dist/execution/generator-context.d.ts +54 -0
- package/dist/execution/global-eval-runner.d.ts +92 -0
- package/dist/execution/global-workflow-env.d.ts +57 -0
- package/dist/execution/init-runner.d.ts +60 -3
- package/dist/execution/job-runner.d.ts +98 -0
- package/dist/execution/sandbox/step-loop.d.ts +10 -1
- package/dist/execution/sandbox/workflow-runner.d.ts +52 -1
- package/dist/execution/workflow-loader.d.ts +8 -1
- package/dist/index.js +89 -19
- package/dist/metrics/prometheus.d.ts +10 -10
- package/dist/server.js +1155 -158
- package/dist/workflow-runner-bundle.js +5052 -4591
- package/dist/workflow-runner.js +269 -67
- package/package.json +5 -5
- package/sbom.spdx.json +69 -64
package/dist/workflow-runner.js
CHANGED
|
@@ -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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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.
|
|
3919
|
-
const AGENT_SDK_BUNDLE_HASH = "
|
|
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.
|
|
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
|
|
5871
|
-
* receive when the job is a global workflow.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
70
|
-
"@kici-dev/engine": "0.
|
|
71
|
-
"@kici-dev/sdk": "0.
|
|
72
|
-
"@kici-dev/shared": "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": {
|