@kici-dev/agent 0.1.23 → 0.1.25

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.
@@ -7,12 +7,13 @@ import os, { homedir, tmpdir } from "node:os";
7
7
  import path, { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import { $ } from "zx";
9
9
  import { createLogger, deriveSharedSecret, initZx, normalizeLineEndings, sha256, sha256File, toErrorMessage } from "@kici-dev/shared";
10
- import { CacheOutcome, CacheStepType, CheckMode, CheckStepOutcome, ExecutionJobStatus, ExecutionStepStatus, TimeoutReason } from "@kici-dev/engine";
11
- import { buildKiciApi, buildNeedsContext, createStepSecrets, evaluateRules, isDynamicJobFn, normalizeApproval, normalizeCacheSpecs, provenanceSubjectIsPath, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
10
+ import { CacheOutcome, CacheStepType, CheckMode, CheckStepOutcome, ExecutionJobStatus, ExecutionStepStatus, StepConcurrencyKind, TimeoutReason } from "@kici-dev/engine";
11
+ import { buildKiciApi, buildNeedsContext, createStepSecrets, evaluateRules, isDynamicJobFn, isParallelGroup, normalizeApproval, normalizeCacheSpecs, provenanceSubjectIsPath, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
12
12
  import { OIDC_TOKEN_REQUEST_METHOD } from "@kici-dev/engine/protocol/messages/oidc-token-relay";
13
13
  import { computeBackoffDelay, sha256File as sha256File$1 } from "@kici-dev/core";
14
14
  import { calculateJwkThumbprint, decodeJwt, exportJWK, generateKeyPair } from "jose";
15
15
  import { IN_TOTO_PAYLOAD_TYPE, KICI_PROVENANCE_AUDIENCE, KICI_PROVENANCE_BUNDLE_MEDIA_TYPE } from "@kici-dev/engine/provenance/bundle";
16
+ import { computeStatementHash } from "@kici-dev/engine/provenance/statement-hash";
16
17
  import { IN_TOTO_STATEMENT_TYPE, KICI_WORKFLOW_BUILD_TYPE, SLSA_PROVENANCE_PREDICATE_TYPE } from "@kici-dev/engine/provenance/schema";
17
18
  import { buildDsseEnvelope, dssePae } from "@kici-dev/engine/provenance/dsse";
18
19
  import https from "node:https";
@@ -23,12 +24,20 @@ import { createGunzip } from "node:zlib";
23
24
  import { fileURLToPath, pathToFileURL } from "node:url";
24
25
  import { c, x } from "tar";
25
26
  import { runIdempotentStep } from "@kici-dev/core/idempotency";
27
+ import { AsyncLocalStorage } from "node:async_hooks";
26
28
  import { execFile } from "node:child_process";
27
29
  import { promisify } from "node:util";
28
30
  import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, YarnFlavor, detectPackageManagerFromManifests, detectYarnFlavor } from "@kici-dev/shared/package-manager";
29
31
  import { parse, stringify } from "yaml";
30
32
  var __defProp = Object.defineProperty;
31
- var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
33
+ var __esmMin = (fn, res, err) => () => {
34
+ if (err) throw err[0];
35
+ try {
36
+ return fn && (res = fn(fn = 0)), res;
37
+ } catch (e) {
38
+ throw err = [e], e;
39
+ }
40
+ };
32
41
  var __exportAll = (all, no_symbols) => {
33
42
  let target = {};
34
43
  for (var name in all) __defProp(target, name, {
@@ -38,7 +47,6 @@ var __exportAll = (all, no_symbols) => {
38
47
  if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
39
48
  return target;
40
49
  };
41
- import.meta.url;
42
50
  //#endregion
43
51
  //#region src/provenance/statement-builder.ts
44
52
  /**
@@ -47,6 +55,53 @@ import.meta.url;
47
55
  * entirely from the JWT claims (Platform-minted, unforgeable), so the
48
56
  * statement's identity equals the token's identity by construction.
49
57
  */
58
+ /**
59
+ * Build a frozen SLSA v1.0 provenance statement from agent-local job context,
60
+ * for a deferred attestation (no minted identity token yet). Marks
61
+ * `attestationOrigin: 'deferred'` in the internal parameters. The caller
62
+ * DSSE-signs the returned statement immediately and computes its statement hash
63
+ * — the binding the later OIDC mint commits to (truth-contract property 2).
64
+ */
65
+ function buildLocalProvenanceStatement(input) {
66
+ const c = input.context;
67
+ return {
68
+ _type: IN_TOTO_STATEMENT_TYPE,
69
+ subject: [{
70
+ name: input.subject.name,
71
+ digest: input.subject.digest
72
+ }],
73
+ predicateType: SLSA_PROVENANCE_PREDICATE_TYPE,
74
+ predicate: {
75
+ buildDefinition: {
76
+ buildType: KICI_WORKFLOW_BUILD_TYPE,
77
+ externalParameters: { workflow: {
78
+ repository: c.repository,
79
+ ref: c.ref,
80
+ path: c.workflowRef
81
+ } },
82
+ internalParameters: {
83
+ ...c.sha ? { commit: c.sha } : {},
84
+ runId: c.runId,
85
+ jobId: c.jobId,
86
+ ...c.orgId ? { orgId: c.orgId } : {},
87
+ ...c.sourceOrigin ? { sourceOrigin: c.sourceOrigin } : {},
88
+ attestationOrigin: "deferred"
89
+ }
90
+ },
91
+ runDetails: {
92
+ builder: {
93
+ id: `${c.issuer}/orchestrator/unknown`,
94
+ version: input.builderVersions
95
+ },
96
+ metadata: {
97
+ invocationId: c.runId,
98
+ startedOn: input.startedOn,
99
+ finishedOn: input.finishedOn
100
+ }
101
+ }
102
+ }
103
+ };
104
+ }
50
105
  /** Build a KiCI SLSA v1.0 provenance statement (validates against the P1.1 schema). */
51
106
  function buildProvenanceStatement(input) {
52
107
  const c = input.tokenClaims;
@@ -60,15 +115,20 @@ function buildProvenanceStatement(input) {
60
115
  predicate: {
61
116
  buildDefinition: {
62
117
  buildType: KICI_WORKFLOW_BUILD_TYPE,
63
- externalParameters: { workflow: {
64
- repository: c.repository ?? "",
65
- ref: c.ref ?? "",
66
- path: c.workflow_ref ?? ""
67
- } },
118
+ externalParameters: {
119
+ workflow: {
120
+ repository: c.repository ?? "",
121
+ ref: c.ref ?? "",
122
+ path: c.workflow_ref ?? ""
123
+ },
124
+ ...c.provider ? { provider: c.provider } : {}
125
+ },
68
126
  internalParameters: {
69
127
  ...c.sha ? { commit: c.sha } : {},
70
128
  runId: c.kici_run_id,
71
- jobId: c.kici_job_id
129
+ jobId: c.kici_job_id,
130
+ ...c.org_id ? { orgId: c.org_id } : {},
131
+ ...c.source_origin ? { sourceOrigin: c.source_origin } : {}
72
132
  }
73
133
  },
74
134
  runDetails: {
@@ -126,7 +186,9 @@ async function signStatementDsse(payloadType, statementBytes) {
126
186
  async function attestProvenance(deps, input) {
127
187
  const audience = input.audience ?? KICI_PROVENANCE_AUDIENCE;
128
188
  const subjectDigest = subjectDigestString(input.subject);
129
- const { token } = await deps.getIdToken({ audience });
189
+ const tokenResult = await deps.getIdToken({ audience });
190
+ if ("deferred" in tokenResult) return deferAttestation(deps, input, subjectDigest, audience);
191
+ const { token } = tokenResult;
130
192
  const claims = decodeJwt(token);
131
193
  const now = (deps.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
132
194
  const statement = buildProvenanceStatement({
@@ -152,6 +214,39 @@ async function attestProvenance(deps, input) {
152
214
  };
153
215
  }
154
216
  /**
217
+ * Freeze + DSSE-sign the provenance statement from agent-local job facts and
218
+ * report it for later minting. The step does NOT throw — the job completes
219
+ * green and the attestation surfaces as `deferred`.
220
+ */
221
+ async function deferAttestation(deps, input, subjectDigest, audience) {
222
+ if (!deps.reportDeferred || !deps.localContext) throw new Error("provenance mint deferred but no reportDeferred/localContext wired to capture it");
223
+ const now = (deps.now ?? (() => (/* @__PURE__ */ new Date()).toISOString()))();
224
+ const statement = buildLocalProvenanceStatement({
225
+ context: deps.localContext,
226
+ subject: input.subject,
227
+ builderVersions: deps.builderVersions,
228
+ startedOn: now,
229
+ finishedOn: now
230
+ });
231
+ const statementBytes = new TextEncoder().encode(JSON.stringify(statement));
232
+ const { envelope, publicJwk } = await signStatementDsse(IN_TOTO_PAYLOAD_TYPE, statementBytes);
233
+ const statementHash = await computeStatementHash(statementBytes);
234
+ await deps.reportDeferred({
235
+ subjectName: input.subject.name,
236
+ subjectDigest,
237
+ audience,
238
+ mediaType: KICI_PROVENANCE_BUNDLE_MEDIA_TYPE,
239
+ statementHash,
240
+ dsseEnvelope: envelope,
241
+ publicKey: publicJwk
242
+ });
243
+ return {
244
+ deferred: true,
245
+ statementHash,
246
+ subjectDigest
247
+ };
248
+ }
249
+ /**
155
250
  * Pick the primary digest (`sha256` preferred) as the storage-key discriminator.
156
251
  * Throws when the subject carries no digest: an empty digest set would otherwise
157
252
  * yield an `undefined` storage-key segment (`provenance/<run>/<job>/undefined.kici.json`)
@@ -689,29 +784,34 @@ function createCacheApi(workDir, transport, roots) {
689
784
  }
690
785
  };
691
786
  }
692
- //#endregion
693
- //#region src/execution/cache/cache-phase.ts
694
- /**
695
- * Declarative cache phase (sandbox-side).
696
- *
697
- * Restores a list of {@link CacheSpec} before the work that depends on them
698
- * (a job before its steps, or a step before its `run`) and saves them after
699
- * (on an exact-key miss). Each operation surfaces as a `cache:restore` /
700
- * `cache:save` pseudo-step a `step.start` + `step.complete` IPC pair whose
701
- * `step_type` comes from {@link CacheStepType} — exactly mirroring how hooks
702
- * render as `hook:*` pseudo-steps. The `step.complete` `data` carries the
703
- * {@link CacheOutcome} (plus key / matchedKey / bytes) so the agent can feed a
704
- * `run.event` and the dashboard can render hit/miss/saved inline.
705
- */
787
+ /** Block size reserved per owner for cache pseudo-step indices. */
788
+ const CACHE_INDEX_BLOCK = 1e3;
789
+ /**
790
+ * Build the cache pseudo-step index allocator. Each owner (a real step index, or
791
+ * {@link JOB_CACHE_OWNER}) gets its own disjoint block of {@link CACHE_INDEX_BLOCK}
792
+ * indices, all above every real-step and hook index (`stepCount * 3 + 100`). A
793
+ * step's two-or-more cache pseudo-steps are a pure function of its own owner
794
+ * index, so concurrent children never collide. Under sequential execution the
795
+ * emitted indices stay above all real/hook indices exactly as before.
796
+ */
797
+ function createCacheStepIndexAllocator(stepCount) {
798
+ const cacheBase = stepCount * 3 + 100;
799
+ const counters = /* @__PURE__ */ new Map();
800
+ return (ownerStepIndex) => {
801
+ const n = counters.get(ownerStepIndex) ?? 0;
802
+ counters.set(ownerStepIndex, n + 1);
803
+ return cacheBase + (ownerStepIndex + 1) * CACHE_INDEX_BLOCK + n;
804
+ };
805
+ }
706
806
  /**
707
807
  * Restore every spec, surfacing each as a `cache:restore` pseudo-step. Returns
708
808
  * a map keyed by spec key recording whether the EXACT key hit (so the save
709
809
  * phase can skip a redundant save of an entry that already exists).
710
810
  */
711
- async function restoreCacheSpecs(specs, deps) {
811
+ async function restoreCacheSpecs(specs, deps, ownerStepIndex) {
712
812
  const results = /* @__PURE__ */ new Map();
713
813
  for (const spec of specs) {
714
- const stepIndex = deps.nextStepIndex();
814
+ const stepIndex = deps.nextStepIndex(ownerStepIndex);
715
815
  deps.sendIpc({
716
816
  type: "step.start",
717
817
  stepIndex,
@@ -761,10 +861,10 @@ async function restoreCacheSpecs(specs, deps) {
761
861
  * whose restore matched a different key via a `restoreKeys` prefix is still
762
862
  * saved under its exact key.
763
863
  */
764
- async function saveCacheSpecs(specs, restoreResults, deps) {
864
+ async function saveCacheSpecs(specs, restoreResults, deps, ownerStepIndex) {
765
865
  for (const spec of specs) {
766
866
  if (restoreResults.get(spec.key)?.matchedKey === spec.key) continue;
767
- const stepIndex = deps.nextStepIndex();
867
+ const stepIndex = deps.nextStepIndex(ownerStepIndex);
768
868
  deps.sendIpc({
769
869
  type: "step.start",
770
870
  stepIndex,
@@ -1120,7 +1220,146 @@ function createRuleContext(event, changedFiles = [], env = {}, dispatchInputs =
1120
1220
  };
1121
1221
  }
1122
1222
  //#endregion
1223
+ //#region src/execution/sandbox/parallel-scheduler.ts
1224
+ /**
1225
+ * Concurrency-aware scheduler for `parallel()` step groups.
1226
+ *
1227
+ * A parallel group's children each run as their own observable step (own logs,
1228
+ * status, timing, retry, cache, hooks — all task-scoped by the Phase 0 per-task
1229
+ * isolation) through the same `runStepIteration` machinery the sequential loop
1230
+ * uses. Children launch behind a `maxParallel` window (queued children report
1231
+ * `pending`); the group joins at a barrier. On the first non-`continueOnError`
1232
+ * child failure when `failFast`, every in-flight sibling's per-task abort
1233
+ * controller is fired so its step race rejects and it is reported `cancelled`
1234
+ * (which is NOT a failure).
1235
+ */
1236
+ /**
1237
+ * Wrap `sendIpc` so a child's own `step.start` / `step.complete` messages carry
1238
+ * the parallel-child concurrency role + the group id. Cache/secret pseudo-step
1239
+ * messages (different stepIndex) pass through untouched.
1240
+ */
1241
+ function stampChildSend(sendIpc, childStepIndex, groupId) {
1242
+ return (msg) => {
1243
+ if ((msg.type === "step.start" || msg.type === "step.complete") && msg.stepIndex === childStepIndex) {
1244
+ sendIpc({
1245
+ ...msg,
1246
+ concurrencyKind: StepConcurrencyKind.enum["parallel-child"],
1247
+ groupId
1248
+ });
1249
+ return;
1250
+ }
1251
+ sendIpc(msg);
1252
+ };
1253
+ }
1254
+ /** Announce a child queued behind the `maxParallel` window as `pending`. */
1255
+ function emitPending(opts, child, groupId) {
1256
+ opts.sendIpc({
1257
+ type: "step.start",
1258
+ stepIndex: child.stepIndex,
1259
+ stepName: child.step.name,
1260
+ state: "pending",
1261
+ concurrencyKind: StepConcurrencyKind.enum["parallel-child"],
1262
+ groupId
1263
+ });
1264
+ }
1265
+ /** Mark a child that never launched (fail-fast already tripped) as `cancelled`. */
1266
+ function emitCancelledSkip(opts, child, groupId) {
1267
+ opts.sendIpc({
1268
+ type: "step.start",
1269
+ stepIndex: child.stepIndex,
1270
+ stepName: child.step.name,
1271
+ concurrencyKind: StepConcurrencyKind.enum["parallel-child"],
1272
+ groupId
1273
+ });
1274
+ opts.sendIpc({
1275
+ type: "step.complete",
1276
+ stepIndex: child.stepIndex,
1277
+ status: "cancelled",
1278
+ durationMs: 0,
1279
+ concurrencyKind: StepConcurrencyKind.enum["parallel-child"],
1280
+ groupId
1281
+ });
1282
+ return {
1283
+ name: child.step.name,
1284
+ stepIndex: child.stepIndex,
1285
+ status: "cancelled",
1286
+ durationMs: 0
1287
+ };
1288
+ }
1289
+ /**
1290
+ * Run a parallel group: launch children with a bounded-concurrency window, join
1291
+ * at a barrier, and fail-fast-cancel in-flight siblings on the first hard
1292
+ * failure.
1293
+ */
1294
+ async function runParallelGroup(node, opts) {
1295
+ const { children, failFast, groupId } = node;
1296
+ const limit = node.maxParallel && node.maxParallel > 0 ? node.maxParallel : children.length;
1297
+ const results = new Array(children.length);
1298
+ const inFlight = /* @__PURE__ */ new Set();
1299
+ let failed = false;
1300
+ let failedStepName;
1301
+ for (let i = limit; i < children.length; i++) emitPending(opts, children[i], groupId);
1302
+ let cursor = 0;
1303
+ const worker = async () => {
1304
+ for (;;) {
1305
+ const idx = cursor++;
1306
+ if (idx >= children.length) return;
1307
+ const child = children[idx];
1308
+ if (failFast && failed) {
1309
+ results[idx] = emitCancelledSkip(opts, child, groupId);
1310
+ continue;
1311
+ }
1312
+ const childOpts = {
1313
+ ...opts,
1314
+ sendIpc: stampChildSend(opts.sendIpc, child.stepIndex, groupId)
1315
+ };
1316
+ inFlight.add(child.stepIndex);
1317
+ try {
1318
+ const outcome = await runStepIteration(child.step, child.stepIndex, childOpts);
1319
+ results[idx] = outcome.result;
1320
+ if (outcome.shouldBreak) {
1321
+ if (!failed) {
1322
+ failed = true;
1323
+ failedStepName = outcome.failedStepName ?? child.step.name;
1324
+ }
1325
+ if (failFast) {
1326
+ for (const sibling of inFlight) if (sibling !== child.stepIndex) opts.abortStep?.(sibling);
1327
+ }
1328
+ }
1329
+ } finally {
1330
+ inFlight.delete(child.stepIndex);
1331
+ }
1332
+ }
1333
+ };
1334
+ await Promise.all(Array.from({ length: Math.min(limit, children.length) }, () => worker()));
1335
+ return {
1336
+ failed,
1337
+ failedStepName,
1338
+ results
1339
+ };
1340
+ }
1341
+ //#endregion
1123
1342
  //#region src/execution/sandbox/step-loop.ts
1343
+ /**
1344
+ * Thrown inside the step race when a step's own per-task abort controller fires
1345
+ * (parallel fail-fast cancels an in-flight sibling). Distinguished from a
1346
+ * timeout/job-deadline reject so the loop reports the step as `cancelled`
1347
+ * (which is NOT a failure) rather than `failed`.
1348
+ */
1349
+ var StepCancelledError = class StepCancelledError extends Error {
1350
+ name = "StepCancelledError";
1351
+ constructor(stepName) {
1352
+ super(`Step '${stepName}' was cancelled by parallel fail-fast`);
1353
+ Object.setPrototypeOf(this, StepCancelledError.prototype);
1354
+ }
1355
+ };
1356
+ /**
1357
+ * Resolve the capture-scope wrapper from options, falling back to a direct call
1358
+ * when no capture wiring is present (unit harnesses).
1359
+ */
1360
+ function captureWrap(opts) {
1361
+ return opts.runWithStepCapture ?? ((_stepIndex, fn) => fn());
1362
+ }
1124
1363
  /** Result of a rejected drift gate: the run was declined by a reviewer. */
1125
1364
  var DriftGateRejectedError = class extends Error {
1126
1365
  constructor(reason) {
@@ -1215,6 +1454,7 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1215
1454
  const startTime = Date.now();
1216
1455
  const abortController = new AbortController();
1217
1456
  const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
1457
+ const stepAbortSignal = opts.getStepAbortSignal?.(stepIndex);
1218
1458
  try {
1219
1459
  const phase = await Promise.race([
1220
1460
  runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn, opts),
@@ -1232,14 +1472,22 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1232
1472
  jobDeadlineSignal.addEventListener("abort", () => {
1233
1473
  reject(/* @__PURE__ */ new Error(`Step '${step.name}' aborted: job timeout exceeded`));
1234
1474
  });
1475
+ }),
1476
+ new Promise((_, reject) => {
1477
+ if (!stepAbortSignal) return;
1478
+ if (stepAbortSignal.aborted) {
1479
+ reject(new StepCancelledError(step.name));
1480
+ return;
1481
+ }
1482
+ stepAbortSignal.addEventListener("abort", () => reject(new StepCancelledError(step.name)));
1235
1483
  })
1236
1484
  ]);
1237
1485
  clearTimeout(timeoutId);
1238
1486
  const durationMs = Date.now() - startTime;
1239
1487
  const outputsPayload = phase.outputs != null ? phase.outputs : void 0;
1240
1488
  if (outputsPayload) outputsMap.set(step.name, outputsPayload);
1241
- const secretsAccessed = getSecretsAccessLog?.();
1242
- emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
1489
+ const secretsAccessed = getSecretsAccessLog?.(stepIndex);
1490
+ emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
1243
1491
  const stepStatus = phase.status === "skipped" ? ExecutionStepStatus.enum.skipped : ExecutionStepStatus.enum.success;
1244
1492
  sendFn({
1245
1493
  type: "step.complete",
@@ -1263,10 +1511,27 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1263
1511
  clearTimeout(timeoutId);
1264
1512
  const durationMs = Date.now() - startTime;
1265
1513
  const error = e instanceof Error ? e : new Error(String(e));
1514
+ if (e instanceof StepCancelledError) {
1515
+ const secretsAccessed = getSecretsAccessLog?.(stepIndex);
1516
+ emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
1517
+ sendFn({
1518
+ type: "step.complete",
1519
+ stepIndex,
1520
+ status: ExecutionStepStatus.enum.cancelled,
1521
+ durationMs,
1522
+ ...secretsAccessed !== void 0 && { secretsAccessed }
1523
+ });
1524
+ return {
1525
+ name: step.name,
1526
+ stepIndex,
1527
+ status: ExecutionStepStatus.enum.cancelled,
1528
+ durationMs
1529
+ };
1530
+ }
1266
1531
  const exitCode = extractExitCode(e);
1267
1532
  const signal = extractSignal(e);
1268
- const secretsAccessed = getSecretsAccessLog?.();
1269
- emitSecretMountEvents(getSecretMountRecords?.(), stepIndex, sendFn);
1533
+ const secretsAccessed = getSecretsAccessLog?.(stepIndex);
1534
+ emitSecretMountEvents(getSecretMountRecords?.(stepIndex), stepIndex, sendFn);
1270
1535
  sendFn({
1271
1536
  type: "step.complete",
1272
1537
  stepIndex,
@@ -1395,7 +1660,7 @@ async function maybeGateStepApproval(step, stepIndex, opts) {
1395
1660
  stepIndex,
1396
1661
  line: `[kici] Step '${step.name}' ${why}.`
1397
1662
  });
1398
- await opts.disposeStepResources?.();
1663
+ await opts.disposeStepResources?.(stepIndex);
1399
1664
  return {
1400
1665
  result: {
1401
1666
  name: step.name,
@@ -1421,15 +1686,16 @@ async function runObserverHook(args) {
1421
1686
  startTime: opts.startTime ?? Date.now(),
1422
1687
  ...failedStep !== void 0 && { failedStep }
1423
1688
  });
1424
- const hookResult = await executeHook({
1689
+ const ctx = opts.createStepContext(stepIndex, step.name);
1690
+ const hookResult = await captureWrap(opts)(hookStepIndex, () => executeHook({
1425
1691
  hook,
1426
- stepContext: opts.createStepContext(stepIndex, step.name),
1692
+ stepContext: ctx,
1427
1693
  outcome,
1428
1694
  hookType,
1429
1695
  stepIndex: hookStepIndex,
1430
1696
  sendIpc: opts.sendIpc,
1431
1697
  timeout: 3e5
1432
- });
1698
+ }));
1433
1699
  if (!hookResult.success) opts.sendIpc({
1434
1700
  type: "log.line",
1435
1701
  stepIndex,
@@ -1482,7 +1748,7 @@ async function runStepWithRetry(step, stepIndex, ctx, timeoutMs, opts) {
1482
1748
  async function runStepIteration(step, stepIndex, opts) {
1483
1749
  const skippedResult = await evaluateStepRulesAndMaybeSkip(step, stepIndex, opts);
1484
1750
  if (skippedResult) {
1485
- await opts.disposeStepResources?.();
1751
+ await opts.disposeStepResources?.(stepIndex);
1486
1752
  return {
1487
1753
  result: skippedResult,
1488
1754
  shouldBreak: false
@@ -1499,18 +1765,18 @@ async function runStepIteration(step, stepIndex, opts) {
1499
1765
  opts
1500
1766
  });
1501
1767
  const stepCacheSpecs = opts.cachePhaseDeps ? normalizeCacheSpecs(step.cache) : [];
1502
- const stepCacheRestore = stepCacheSpecs.length > 0 && opts.cachePhaseDeps ? await restoreCacheSpecs(stepCacheSpecs, opts.cachePhaseDeps) : /* @__PURE__ */ new Map();
1768
+ const stepCacheRestore = stepCacheSpecs.length > 0 && opts.cachePhaseDeps ? await restoreCacheSpecs(stepCacheSpecs, opts.cachePhaseDeps, stepIndex) : /* @__PURE__ */ new Map();
1503
1769
  try {
1504
- await opts.beforeStepEnvFiles?.();
1770
+ await opts.beforeStepEnvFiles?.(stepIndex);
1505
1771
  const ctx = opts.createStepContext(stepIndex, step.name);
1506
1772
  const timeoutMs = step.timeout ?? opts.defaultTimeoutMs;
1507
1773
  let result;
1508
1774
  try {
1509
- result = await runStepWithRetry(step, stepIndex, ctx, timeoutMs, opts);
1775
+ result = await captureWrap(opts)(stepIndex, () => runStepWithRetry(step, stepIndex, ctx, timeoutMs, opts));
1510
1776
  } finally {
1511
- await opts.afterStepApplyEnvFiles?.();
1777
+ await opts.afterStepApplyEnvFiles?.(stepIndex);
1512
1778
  }
1513
- if (stepCacheSpecs.length > 0 && opts.cachePhaseDeps && result.status === ExecutionStepStatus.enum.success) await saveCacheSpecs(stepCacheSpecs, stepCacheRestore, opts.cachePhaseDeps);
1779
+ if (stepCacheSpecs.length > 0 && opts.cachePhaseDeps && result.status === ExecutionStepStatus.enum.success) await saveCacheSpecs(stepCacheSpecs, stepCacheRestore, opts.cachePhaseDeps, stepIndex);
1514
1780
  if (opts.jobHooks?.afterStep) await runObserverHook({
1515
1781
  hook: opts.jobHooks.afterStep,
1516
1782
  hookType: "afterStep",
@@ -1530,7 +1796,7 @@ async function runStepIteration(step, stepIndex, opts) {
1530
1796
  shouldBreak: false
1531
1797
  };
1532
1798
  } finally {
1533
- await opts.disposeStepResources?.();
1799
+ await opts.disposeStepResources?.(stepIndex);
1534
1800
  }
1535
1801
  }
1536
1802
  /**
@@ -1546,14 +1812,15 @@ async function runCompletionHook(args) {
1546
1812
  stepIndex: -1,
1547
1813
  line: `[kici] Running ${hookType} hook...`
1548
1814
  });
1549
- const hookResult = await executeHook({
1815
+ const ctx = opts.createStepContext(hookStepIndex, hookType);
1816
+ const hookResult = await captureWrap(opts)(hookStepIndex, () => executeHook({
1550
1817
  hook,
1551
- stepContext: opts.createStepContext(hookStepIndex, hookType),
1818
+ stepContext: ctx,
1552
1819
  outcome,
1553
1820
  hookType,
1554
1821
  stepIndex: hookStepIndex,
1555
1822
  sendIpc: opts.sendIpc
1556
- });
1823
+ }));
1557
1824
  if (hookResult.success) {
1558
1825
  opts.sendIpc({
1559
1826
  type: "log.line",
@@ -1651,9 +1918,24 @@ async function executeStepLoop(opts) {
1651
1918
  const startTime = opts.startTime ?? Date.now();
1652
1919
  const stepResults = [];
1653
1920
  const state = { failed: false };
1654
- for (const [i, step] of opts.steps.entries()) {
1921
+ const nodes = opts.stepNodes ?? opts.steps.map((step, i) => ({
1922
+ kind: "sequential",
1923
+ step,
1924
+ stepIndex: i
1925
+ }));
1926
+ for (const node of nodes) {
1655
1927
  if (opts.isAborted?.()) break;
1656
- const outcome = await runStepIteration(step, i, opts);
1928
+ if (node.kind === "parallel") {
1929
+ const groupOutcome = await runParallelGroup(node, opts);
1930
+ stepResults.push(...groupOutcome.results);
1931
+ if (groupOutcome.failed) {
1932
+ state.failed = true;
1933
+ state.failedStepName = groupOutcome.failedStepName;
1934
+ break;
1935
+ }
1936
+ continue;
1937
+ }
1938
+ const outcome = await runStepIteration(node.step, node.stepIndex, opts);
1657
1939
  stepResults.push(outcome.result);
1658
1940
  if (outcome.failedStepName) {
1659
1941
  state.failed = true;
@@ -1674,6 +1956,69 @@ async function executeStepLoop(opts) {
1674
1956
  };
1675
1957
  }
1676
1958
  //#endregion
1959
+ //#region src/execution/sandbox/capture-context.ts
1960
+ /**
1961
+ * Attributes captured console output to the step whose run is currently on the
1962
+ * async call stack.
1963
+ *
1964
+ * The monkey-patched `process.stdout/stderr.write` reads
1965
+ * {@link currentCaptureStepIndex} to decide which `step-N.log` a console line
1966
+ * belongs to. Keying this on the async execution context (rather than a single
1967
+ * module global) makes attribution correct when more than one step body runs
1968
+ * concurrently: each step's `run` executes inside its own
1969
+ * {@link runInStepCapture} scope, so its writes resolve to its own index even
1970
+ * while a sibling step is mid-flight. Under sequential execution exactly one
1971
+ * scope is active at a time, identical to the former global.
1972
+ */
1973
+ const stepCaptureStore = new AsyncLocalStorage();
1974
+ /**
1975
+ * Run `fn` with `stepIndex` as the active console-capture attribution for the
1976
+ * duration of its async execution (including everything it awaits).
1977
+ */
1978
+ function runInStepCapture(stepIndex, fn) {
1979
+ return stepCaptureStore.run(stepIndex, fn);
1980
+ }
1981
+ /**
1982
+ * The step index whose run is currently on the async stack, or `-1` when no
1983
+ * capture scope is active (workflow-level / between-steps output).
1984
+ */
1985
+ function currentCaptureStepIndex() {
1986
+ return stepCaptureStore.getStore() ?? -1;
1987
+ }
1988
+ //#endregion
1989
+ //#region src/execution/sandbox/step-task-registry.ts
1990
+ /**
1991
+ * Per-task replacement for the runner's former `currentStepSecrets` /
1992
+ * `currentStepDispose` single-slots.
1993
+ *
1994
+ * The runner used to remember only the *most recent* step's secrets handle and
1995
+ * dispose closure; the access-log / mount-record / dispose reader callbacks read
1996
+ * that single slot. Under sequential execution that is correct (one step at a
1997
+ * time), but two concurrently-running steps would clobber each other's
1998
+ * secrets-audit trail. Keying every slot by the step's index keeps each step's
1999
+ * audit trail and teardown isolated — sequential behavior is identical, Phase 1
2000
+ * concurrency is correct.
2001
+ */
2002
+ var StepTaskRegistry = class {
2003
+ #slots = /* @__PURE__ */ new Map();
2004
+ set(stepIndex, slot) {
2005
+ this.#slots.set(stepIndex, slot);
2006
+ }
2007
+ getAccessLog(stepIndex) {
2008
+ return this.#slots.get(stepIndex)?.secrets.getAccessLog() ?? [];
2009
+ }
2010
+ getMountRecords(stepIndex) {
2011
+ const slot = this.#slots.get(stepIndex);
2012
+ return slot ? [...slot.secrets.getMountRecords()] : [];
2013
+ }
2014
+ async dispose(stepIndex) {
2015
+ const slot = this.#slots.get(stepIndex);
2016
+ if (!slot) return;
2017
+ this.#slots.delete(stepIndex);
2018
+ await slot.dispose();
2019
+ }
2020
+ };
2021
+ //#endregion
1677
2022
  //#region src/execution/env-init/init-phase.ts
1678
2023
  /** Default init timeout when a spec sets none: 10 minutes. */
1679
2024
  const DEFAULT_INIT_TIMEOUT_MS = 600 * 1e3;
@@ -3148,8 +3493,8 @@ function logSubprocessStreams(e, tokens) {
3148
3493
  * no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
3149
3494
  * Node's normal ESM lookup against `.kici/node_modules/`.
3150
3495
  */
3151
- const AGENT_SDK_VERSION = "0.1.23";
3152
- const AGENT_SDK_BUNDLE_HASH = "3a83610d2d122b9f9b0f924b225a47050d0b35f00a8f3256a3e15f3ff3bf7ddc";
3496
+ const AGENT_SDK_VERSION = "0.1.25";
3497
+ const AGENT_SDK_BUNDLE_HASH = "e06bd1acf349ba365bdb357f3d865e0cbb4ad4604a0270b707a681a4f440954e";
3153
3498
  /**
3154
3499
  * Register the `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook so
3155
3500
  * subsequent dynamic `import()` calls for `.ts` / `.tsx` files transform on the
@@ -3511,7 +3856,7 @@ async function applyOverlay(config) {
3511
3856
  */
3512
3857
  init_download();
3513
3858
  init_dep_restore();
3514
- const AGENT_VERSION = "0.1.23";
3859
+ const AGENT_VERSION = "0.1.25";
3515
3860
  process.on("uncaughtException", (err) => {
3516
3861
  process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
3517
3862
  if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
@@ -3534,12 +3879,6 @@ const isForkMode = typeof process.send === "function";
3534
3879
  const origStdoutWrite = process.stdout.write.bind(process.stdout);
3535
3880
  const origStderrWrite = process.stderr.write.bind(process.stderr);
3536
3881
  /**
3537
- * Current step index for console.log/console.error capture.
3538
- * When >= 0, process.stdout/stderr writes are intercepted and sent as
3539
- * log.line IPC messages for the given step. Set to -1 outside step execution.
3540
- */
3541
- let captureStepIndex = -1;
3542
- /**
3543
3882
  * Workflow-level capture flag for the pre-step `prepare` phase
3544
3883
  * (module load, concurrency-group evaluation, rule evaluation).
3545
3884
  *
@@ -3550,17 +3889,18 @@ let captureStepIndex = -1;
3550
3889
  * rule check functions lands in the job's workflow-level log file
3551
3890
  * (`executions/{runId}/job-{name}/step--1.log`) alongside runner narration.
3552
3891
  *
3553
- * Mutually exclusive with `captureStepIndex >= 0` the step loop resets
3554
- * this flag to false before setting `captureStepIndex` to a real step index.
3892
+ * Mutually exclusive with a step capture scope: the step loop resets this flag
3893
+ * to false before any step runs inside its {@link runInStepCapture} scope (the
3894
+ * async-context source of {@link currentCaptureStepIndex}).
3555
3895
  */
3556
3896
  let capturePrepareActive = false;
3557
3897
  /** The maskedSend function used by the output capture. Set during main(). */
3558
3898
  let captureSendFn = null;
3559
3899
  function captureIsActive() {
3560
- return (captureStepIndex >= 0 || capturePrepareActive) && captureSendFn !== null;
3900
+ return (currentCaptureStepIndex() >= 0 || capturePrepareActive) && captureSendFn !== null;
3561
3901
  }
3562
3902
  function captureTargetIndex() {
3563
- return captureStepIndex >= 0 ? captureStepIndex : -1;
3903
+ return currentCaptureStepIndex();
3564
3904
  }
3565
3905
  /**
3566
3906
  * Install monkey-patches on process.stdout.write and process.stderr.write
@@ -3709,6 +4049,14 @@ let jobTimedOutMs;
3709
4049
  */
3710
4050
  const jobDeadlineAbort = new AbortController();
3711
4051
  /**
4052
+ * Aborted when the job is being torn down by cancellation (abort IPC / SIGTERM),
4053
+ * as distinct from the wall-clock deadline (`jobDeadlineAbort`). Each step's
4054
+ * `ctx.signal` composes this with the deadline signal and its own per-step
4055
+ * controller, so a cancelled job cooperatively unwinds in-flight step bodies
4056
+ * that opted into `ctx.signal`. Inert for the rest of the runner today.
4057
+ */
4058
+ const jobCancelAbort = new AbortController();
4059
+ /**
3712
4060
  * Pending promises for event.emit requests awaiting responses from the agent.
3713
4061
  *
3714
4062
  * Key: requestId (correlates EventEmitRequest -> EventEmitResponse)
@@ -4015,9 +4363,20 @@ async function relayProvenanceIpc(request) {
4015
4363
  return response;
4016
4364
  }
4017
4365
  /**
4366
+ * Extract an `owner/repo` identifier from a git clone URL for a deferred
4367
+ * statement's `externalParameters.workflow.repository` (the live path reads this
4368
+ * from the minted token claim; the deferred path has no token yet).
4369
+ */
4370
+ function extractRepoIdentifier(repoUrl) {
4371
+ const match = repoUrl.match(/(?:github|gitlab|bitbucket)\.\w+\/([^/]+\/[^/.]+)/);
4372
+ return match ? match[1] : "unknown/unknown";
4373
+ }
4374
+ /**
4018
4375
  * Build the `ctx.attestProvenance` step helper. Resolves a `path` subject to a
4019
4376
  * SHA-256 digest, threads the identity token via the supplied OIDC getter, and
4020
- * persists the bundle over the IPC -> WS provenance-upload relay.
4377
+ * persists the bundle over the IPC -> WS provenance-upload relay. On a transient
4378
+ * mint failure the statement is frozen + reported for later minting (deferred);
4379
+ * the step still completes.
4021
4380
  */
4022
4381
  function buildAttestProvenanceFn(request, workDir, getIdToken) {
4023
4382
  return async (opts) => {
@@ -4034,6 +4393,27 @@ function buildAttestProvenanceFn(request, workDir, getIdToken) {
4034
4393
  "kici-agent": AGENT_VERSION,
4035
4394
  "kici-orchestrator": "unknown"
4036
4395
  },
4396
+ localContext: {
4397
+ repository: extractRepoIdentifier(request.repoUrl),
4398
+ ref: request.ref,
4399
+ sha: request.sha || null,
4400
+ workflowRef: request.workflowRef ?? request.workflowName,
4401
+ runId: request.runId,
4402
+ jobId: request.jobId,
4403
+ issuer: request.provenanceIssuer ?? ""
4404
+ },
4405
+ reportDeferred: async (report) => {
4406
+ await relayProvenanceIpc({
4407
+ op: "defer",
4408
+ subjectDigest: report.subjectDigest,
4409
+ subjectName: report.subjectName,
4410
+ mediaType: report.mediaType,
4411
+ audience: report.audience,
4412
+ statementHash: report.statementHash,
4413
+ dsseEnvelope: report.dsseEnvelope,
4414
+ publicKey: report.publicKey
4415
+ });
4416
+ },
4037
4417
  persist: async (bundle, subjectDigest) => {
4038
4418
  const urlResponse = await relayProvenanceIpc({
4039
4419
  op: "requestUploadUrl",
@@ -4053,6 +4433,11 @@ function buildAttestProvenanceFn(request, workDir, getIdToken) {
4053
4433
  subject,
4054
4434
  ...opts.audience !== void 0 && { audience: opts.audience }
4055
4435
  });
4436
+ if ("deferred" in result) return {
4437
+ deferred: true,
4438
+ subjectDigest: result.subjectDigest,
4439
+ statementHash: result.statementHash
4440
+ };
4056
4441
  return {
4057
4442
  storageKey: result.storageKey,
4058
4443
  subjectDigest: result.subjectDigest,
@@ -4072,17 +4457,16 @@ function buildAttestProvenanceFn(request, workDir, getIdToken) {
4072
4457
  * post-loop save phase skip exact-key hits.
4073
4458
  */
4074
4459
  async function setupJobCache(stepCwd, stepCount, jobCache, sendIpc) {
4075
- let cacheStepCursor = stepCount * 3 + 100;
4076
4460
  const cachePhaseDeps = {
4077
4461
  cache: createCacheApi(stepCwd, buildCacheTransport()),
4078
4462
  sendIpc,
4079
- nextStepIndex: () => cacheStepCursor++
4463
+ nextStepIndex: createCacheStepIndexAllocator(stepCount)
4080
4464
  };
4081
4465
  const jobCacheSpecs = normalizeCacheSpecs(jobCache);
4082
4466
  return {
4083
4467
  cachePhaseDeps,
4084
4468
  jobCacheSpecs,
4085
- jobCacheRestore: jobCacheSpecs.length > 0 ? await restoreCacheSpecs(jobCacheSpecs, cachePhaseDeps) : /* @__PURE__ */ new Map()
4469
+ jobCacheRestore: jobCacheSpecs.length > 0 ? await restoreCacheSpecs(jobCacheSpecs, cachePhaseDeps, -1) : /* @__PURE__ */ new Map()
4086
4470
  };
4087
4471
  }
4088
4472
  /**
@@ -4095,7 +4479,7 @@ async function setupJobCache(stepCwd, stepCount, jobCache, sendIpc) {
4095
4479
  */
4096
4480
  async function maybeSaveJobCache(specs, restoreResults, deps, succeeded) {
4097
4481
  if (specs.length === 0 || !succeeded) return;
4098
- await saveCacheSpecs(specs, restoreResults, deps);
4482
+ await saveCacheSpecs(specs, restoreResults, deps, -1);
4099
4483
  }
4100
4484
  /**
4101
4485
  * Dispatch an agent-to-runner message to the appropriate pending handler.
@@ -4104,6 +4488,7 @@ async function maybeSaveJobCache(specs, restoreResults, deps, succeeded) {
4104
4488
  function dispatchAgentMessage(msg) {
4105
4489
  if (msg.type === "abort") {
4106
4490
  aborted = true;
4491
+ jobCancelAbort.abort();
4107
4492
  if (msg.force) forceAborted = true;
4108
4493
  } else if (msg.type === "event.emit.response") {
4109
4494
  const pending = pendingEmitResponses.get(msg.requestId);
@@ -4136,6 +4521,7 @@ else getStdinRl().on("line", (line) => {
4136
4521
  });
4137
4522
  process.on("SIGTERM", () => {
4138
4523
  aborted = true;
4524
+ jobCancelAbort.abort();
4139
4525
  });
4140
4526
  /**
4141
4527
  * Create a Logger that sends log lines via IPC.
@@ -4370,7 +4756,7 @@ function buildSandboxShell(cwd, stepIndex, maskedSendFn) {
4370
4756
  * NOT serialized across the process boundary. This means zx $ runs natively
4371
4757
  * inside this process with full shell access.
4372
4758
  */
4373
- function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets, masker) {
4759
+ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedSendFn, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, secrets, masker, signal) {
4374
4760
  const step$ = buildSandboxShell(workDir, stepIndex, maskedSendFn);
4375
4761
  const log = createIpcLogger(stepIndex, stepName, maskedSendFn);
4376
4762
  const rawPayload = rawPayloadFromEvent(request.event);
@@ -4389,6 +4775,7 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
4389
4775
  return {
4390
4776
  $: step$,
4391
4777
  log,
4778
+ signal,
4392
4779
  env: process.env,
4393
4780
  setEnv: (key, value) => {
4394
4781
  applyEnvDelta({
@@ -4962,7 +5349,7 @@ async function runCancelPathHooks(args) {
4962
5349
  const ctx = createStepCtxWithCapture(hookStepIndex, label);
4963
5350
  let hookResult;
4964
5351
  try {
4965
- hookResult = await executeHook({
5352
+ hookResult = await runInStepCapture(hookStepIndex, () => executeHook({
4966
5353
  hook,
4967
5354
  stepContext: ctx,
4968
5355
  outcome: failedStep ? {
@@ -4972,9 +5359,9 @@ async function runCancelPathHooks(args) {
4972
5359
  hookType,
4973
5360
  stepIndex: hookStepIndex,
4974
5361
  sendIpc: maskedSend
4975
- });
5362
+ }));
4976
5363
  } finally {
4977
- await disposeStepResources();
5364
+ await disposeStepResources(hookStepIndex);
4978
5365
  }
4979
5366
  hookStepIndex++;
4980
5367
  if (!hookResult.success) {
@@ -5025,29 +5412,66 @@ async function extractAndNormalizeSteps(workflow, request, apiTransport) {
5025
5412
  } else rawSteps = extractSteps(workflow, request.jobName);
5026
5413
  const refMap = /* @__PURE__ */ new WeakMap();
5027
5414
  let stepCounter = 0;
5028
- return {
5029
- normalizedSteps: rawSteps.map((stepOrFn) => {
5030
- if (typeof stepOrFn === "function") {
5031
- stepCounter++;
5032
- const name = `step-${stepCounter}`;
5033
- refMap.set(stepOrFn, name);
5034
- return {
5035
- _tag: "Step",
5036
- name,
5037
- run: stepOrFn,
5038
- outputs: void 0
5039
- };
5040
- }
5041
- const s = stepOrFn;
5042
- if (!s.name) {
5043
- stepCounter++;
5415
+ const coerce = (stepOrFn) => {
5416
+ if (typeof stepOrFn === "function") {
5417
+ stepCounter++;
5418
+ const name = `step-${stepCounter}`;
5419
+ refMap.set(stepOrFn, name);
5420
+ return {
5421
+ _tag: "Step",
5422
+ name,
5423
+ run: stepOrFn,
5424
+ outputs: void 0
5425
+ };
5426
+ }
5427
+ const s = stepOrFn;
5428
+ if (!s.name) {
5429
+ stepCounter++;
5430
+ return {
5431
+ ...s,
5432
+ name: `step-${stepCounter}`
5433
+ };
5434
+ }
5435
+ return s;
5436
+ };
5437
+ const normalizedSteps = [];
5438
+ const nodes = [];
5439
+ let flatIndex = 0;
5440
+ let groupOrdinal = 0;
5441
+ for (const entry of rawSteps) {
5442
+ if (isParallelGroup(entry)) {
5443
+ const groupId = `g${groupOrdinal++}`;
5444
+ const children = entry.steps.map((child) => {
5445
+ const step = coerce(child);
5446
+ const stepIndex = flatIndex++;
5447
+ normalizedSteps.push(step);
5044
5448
  return {
5045
- ...s,
5046
- name: `step-${stepCounter}`
5449
+ step,
5450
+ stepIndex
5047
5451
  };
5048
- }
5049
- return s;
5050
- }),
5452
+ });
5453
+ nodes.push({
5454
+ kind: "parallel",
5455
+ groupId,
5456
+ name: entry.name ?? groupId,
5457
+ failFast: entry.failFast,
5458
+ ...entry.maxParallel !== void 0 && { maxParallel: entry.maxParallel },
5459
+ children
5460
+ });
5461
+ continue;
5462
+ }
5463
+ const step = coerce(entry);
5464
+ const stepIndex = flatIndex++;
5465
+ normalizedSteps.push(step);
5466
+ nodes.push({
5467
+ kind: "sequential",
5468
+ step,
5469
+ stepIndex
5470
+ });
5471
+ }
5472
+ return {
5473
+ normalizedSteps,
5474
+ nodes,
5051
5475
  refMap,
5052
5476
  driftDroppedJobs
5053
5477
  };
@@ -5144,22 +5568,40 @@ async function applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend) {
5144
5568
  await truncateEnvFiles(envFiles);
5145
5569
  }
5146
5570
  /**
5147
- * Build the step loop's KICI_ENV/KICI_PATH callbacks over the shared `envFiles`.
5148
- * `beforeStepEnvFiles` points the runner's process.env at the files (each step's
5149
- * zx $ snapshots process.env at context creation, which happens AFTER this
5571
+ * Build the step loop's KICI_ENV/KICI_PATH callbacks with a per-step delta-file
5572
+ * pair (keyed by step index). `beforeStepEnvFiles(stepIndex)` lazily creates the
5573
+ * step's pair and points the runner's process.env at it (each step's zx $
5574
+ * snapshots process.env at context creation, which happens AFTER this
5150
5575
  * before-hook, so the shell sees them; the pre-fork env allowlist does not
5151
- * re-filter runtime-set vars). `afterStepApplyEnvFiles` applies + truncates the
5152
- * delta, mirroring the init phase's env port.
5153
- */
5154
- function buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend) {
5576
+ * re-filter runtime-set vars). `afterStepApplyEnvFiles(stepIndex)` applies that
5577
+ * step's delta and releases the pair.
5578
+ *
5579
+ * Env-isolation contract: under sequential execution this is identical to a
5580
+ * single shared pair truncated between steps — each step still sees only its own
5581
+ * delta. The pair is now per-step so two concurrently-running steps cannot
5582
+ * corrupt each other's delta file. `process.env.KICI_ENV` / `process.env.KICI_PATH`
5583
+ * remain process-global, so Phase 1 forbids `setEnv` / `addPath` / `$KICI_ENV`
5584
+ * writes inside `parallel()` children (compile-time validation); Phase 0 only
5585
+ * makes the file pair per-task.
5586
+ */
5587
+ function buildStepEnvFileHooks(operatorSecretKeys, maskedSend) {
5588
+ const perStep = /* @__PURE__ */ new Map();
5155
5589
  return {
5156
- beforeStepEnvFiles: async () => {
5157
- process.env.KICI_ENV = envFiles.envFile;
5158
- process.env.KICI_PATH = envFiles.pathFile;
5590
+ beforeStepEnvFiles: async (stepIndex) => {
5591
+ let files = perStep.get(stepIndex);
5592
+ if (!files) {
5593
+ files = await createEnvFiles(tmpdir());
5594
+ perStep.set(stepIndex, files);
5595
+ }
5596
+ process.env.KICI_ENV = files.envFile;
5597
+ process.env.KICI_PATH = files.pathFile;
5159
5598
  },
5160
- afterStepApplyEnvFiles: async () => {
5599
+ afterStepApplyEnvFiles: async (stepIndex) => {
5600
+ const files = perStep.get(stepIndex);
5601
+ if (!files) return;
5602
+ perStep.delete(stepIndex);
5161
5603
  try {
5162
- await applyEnvFilesDelta(envFiles, operatorSecretKeys, maskedSend);
5604
+ await applyEnvFilesDelta(files, operatorSecretKeys, maskedSend);
5163
5605
  } catch (err) {
5164
5606
  maskedSend({
5165
5607
  type: "log.line",
@@ -5294,7 +5736,7 @@ async function main() {
5294
5736
  });
5295
5737
  return waitForApiResponse(reqId);
5296
5738
  };
5297
- const { normalizedSteps, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
5739
+ const { normalizedSteps, nodes, refMap, driftDroppedJobs } = await extractAndNormalizeSteps(workflow, request, apiTransport);
5298
5740
  const { operatorSecretKeys, outputsMap, secretOutputs, jobOutputsMap } = buildOutputInfrastructure(request, refMap);
5299
5741
  const job = findJob(workflow, request.jobName);
5300
5742
  await maybeSkipJobOnRules(job, request, normalizedSteps);
@@ -5304,33 +5746,43 @@ async function main() {
5304
5746
  flushOutputCapture();
5305
5747
  capturePrepareActive = false;
5306
5748
  const stepCwd = sourceDir;
5307
- const envFiles = await createEnvFiles(tmpdir());
5308
5749
  await runInitPhaseOrFailJob({
5309
5750
  job,
5310
5751
  stepCwd,
5311
- envFiles,
5752
+ envFiles: await createEnvFiles(tmpdir()),
5312
5753
  operatorSecretKeys,
5313
5754
  maskedSend
5314
5755
  });
5315
5756
  const { cachePhaseDeps, jobCacheSpecs, jobCacheRestore } = await setupJobCache(stepCwd, normalizedSteps.length, job?.cache, maskedSend);
5316
- let currentStepSecrets = null;
5317
- let currentStepDispose = null;
5757
+ const stepTasks = new StepTaskRegistry();
5758
+ const stepAbortControllers = /* @__PURE__ */ new Map();
5318
5759
  const createStepCtxWithCapture = (stepIndex, stepName) => {
5319
- captureStepIndex = stepIndex;
5760
+ const stepAbort = new AbortController();
5761
+ stepAbortControllers.set(stepIndex, stepAbort);
5762
+ const signal = AbortSignal.any([
5763
+ stepAbort.signal,
5764
+ jobCancelAbort.signal,
5765
+ jobDeadlineAbort.signal
5766
+ ]);
5320
5767
  const handle = buildStepSecrets(request, masker, () => {});
5321
- currentStepSecrets = handle.secrets;
5322
- currentStepDispose = handle.dispose;
5323
- const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets, masker);
5768
+ stepTasks.set(stepIndex, {
5769
+ secrets: handle.secrets,
5770
+ dispose: handle.dispose
5771
+ });
5772
+ const ctx = createSandboxStepContext(stepCwd, stepIndex, stepName, request, maskedSend, outputsMap, refMap, operatorSecretKeys, secretOutputs, jobOutputsMap, handle.secrets, masker, signal);
5324
5773
  if (globalRepoInfo) {
5325
5774
  ctx.workflowRepo = globalRepoInfo.workflowRepo;
5326
5775
  ctx.sourceRepo = globalRepoInfo.sourceRepo;
5327
5776
  }
5328
5777
  return ctx;
5329
5778
  };
5330
- const stepEnvHooks = buildStepEnvFileHooks(envFiles, operatorSecretKeys, maskedSend);
5779
+ const stepEnvHooks = buildStepEnvFileHooks(operatorSecretKeys, maskedSend);
5331
5780
  const jobStartTime = Date.now();
5332
5781
  const loopResult = await executeStepLoop({
5333
5782
  steps: normalizedSteps,
5783
+ stepNodes: nodes,
5784
+ abortStep: (stepIndex) => stepAbortControllers.get(stepIndex)?.abort(),
5785
+ getStepAbortSignal: (stepIndex) => stepAbortControllers.get(stepIndex)?.signal,
5334
5786
  checkMode: request.checkMode,
5335
5787
  createStepContext: createStepCtxWithCapture,
5336
5788
  sendIpc: maskedSend,
@@ -5345,19 +5797,15 @@ async function main() {
5345
5797
  isAborted: () => aborted,
5346
5798
  jobDeadlineSignal: jobDeadlineAbort.signal,
5347
5799
  startTime: jobStartTime,
5348
- getSecretsAccessLog: () => {
5800
+ runWithStepCapture: runInStepCapture,
5801
+ getSecretsAccessLog: (stepIndex) => {
5349
5802
  flushOutputCapture();
5350
- captureStepIndex = -1;
5351
- return currentStepSecrets?.getAccessLog() ?? [];
5352
- },
5353
- getSecretMountRecords: () => {
5354
- return currentStepSecrets ? [...currentStepSecrets.getMountRecords()] : [];
5803
+ return stepTasks.getAccessLog(stepIndex);
5355
5804
  },
5356
- disposeStepResources: async () => {
5357
- const disposeFn = currentStepDispose;
5358
- currentStepDispose = null;
5359
- currentStepSecrets = null;
5360
- if (disposeFn) await disposeFn();
5805
+ getSecretMountRecords: (stepIndex) => stepTasks.getMountRecords(stepIndex),
5806
+ disposeStepResources: (stepIndex) => {
5807
+ stepAbortControllers.delete(stepIndex);
5808
+ return stepTasks.dispose(stepIndex);
5361
5809
  },
5362
5810
  beforeStepEnvFiles: stepEnvHooks.beforeStepEnvFiles,
5363
5811
  afterStepApplyEnvFiles: stepEnvHooks.afterStepApplyEnvFiles,
@@ -5377,11 +5825,9 @@ async function main() {
5377
5825
  jobStartTime,
5378
5826
  outputsMap,
5379
5827
  createStepCtxWithCapture,
5380
- disposeStepResources: async () => {
5381
- const disposeFn = currentStepDispose;
5382
- currentStepDispose = null;
5383
- currentStepSecrets = null;
5384
- if (disposeFn) await disposeFn();
5828
+ disposeStepResources: (stepIndex) => {
5829
+ stepAbortControllers.delete(stepIndex);
5830
+ return stepTasks.dispose(stepIndex);
5385
5831
  },
5386
5832
  maskedSend
5387
5833
  });
@@ -5448,6 +5894,6 @@ main().catch((error) => {
5448
5894
  setTimeout(() => process.exit(1), 100);
5449
5895
  });
5450
5896
  //#endregion
5451
- export { buildStepNeedsContext, createSandboxStepContext, deriveFanout, rawPayloadFromEvent };
5897
+ export { buildStepEnvFileHooks, buildStepNeedsContext, createSandboxStepContext, deriveFanout, rawPayloadFromEvent };
5452
5898
 
5453
5899
  //# sourceMappingURL=workflow-runner.js.map