@kici-dev/agent 0.1.21 → 0.1.22

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.
@@ -8,7 +8,7 @@ import path, { dirname, isAbsolute, join, relative, resolve, sep } from "node:pa
8
8
  import { $ } from "zx";
9
9
  import { createLogger, deriveSharedSecret, initZx, normalizeLineEndings, sha256, sha256File, toErrorMessage } from "@kici-dev/shared";
10
10
  import { CacheOutcome, CacheStepType, CheckMode, CheckStepOutcome, ExecutionJobStatus, ExecutionStepStatus, TimeoutReason } from "@kici-dev/engine";
11
- import { buildKiciApi, createStepSecrets, evaluateRules, isDynamicJobFn, normalizeCacheSpecs, normalizeRequireApproval, provenanceSubjectIsPath, resolveJobOutputs, resolveStepOutputs, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
11
+ import { buildKiciApi, buildNeedsContext, createStepSecrets, evaluateRules, isDynamicJobFn, 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 { sha256File as sha256File$1 } from "@kici-dev/core";
14
14
  import { calculateJwkThumbprint, decodeJwt, exportJWK, generateKeyPair } from "jose";
@@ -1117,6 +1117,13 @@ function createRuleContext(event, changedFiles = [], env = {}) {
1117
1117
  }
1118
1118
  //#endregion
1119
1119
  //#region src/execution/sandbox/step-loop.ts
1120
+ /** Result of a rejected drift gate: the run was declined by a reviewer. */
1121
+ var DriftGateRejectedError = class extends Error {
1122
+ constructor(reason) {
1123
+ super(reason ? `approval rejected: ${reason}` : "approval rejected");
1124
+ this.name = "DriftGateRejectedError";
1125
+ }
1126
+ };
1120
1127
  /**
1121
1128
  * Run one step honoring the run-level {@link CheckMode}, reusing the
1122
1129
  * `runIdempotentStep` primitive for checked steps (never hand-rolled branching).
@@ -1124,10 +1131,13 @@ function createRuleContext(event, changedFiles = [], env = {}) {
1124
1131
  * - Plain step (no `check`): in apply mode, runs as today; in any check mode it
1125
1132
  * is skipped with `no_check` (a side-effecting step can't be safely previewed).
1126
1133
  * - Checked step: adapted into an `IdempotentStep` and driven by the primitive
1127
- * with `dryRun` set in check mode (so `apply`/`run` never fires) and `yes: true`
1128
- * (v0 has no mid-run confirm). On drift the summary is emitted as a log line.
1134
+ * with `dryRun` set in check mode (so `apply`/`run` never fires). On drift the
1135
+ * summary is emitted as a log line. A `approval: { when: 'drift' }` step in
1136
+ * apply mode passes a `confirm` callback that round-trips a payload-bearing
1137
+ * step-approval; on reject the gate throws (fail-stop). Any other apply-mode
1138
+ * step uses `yes: true`.
1129
1139
  */
1130
- async function runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn) {
1140
+ async function runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn, opts) {
1131
1141
  if (!step.check) {
1132
1142
  if (checkMode !== CheckMode.enum.apply) return {
1133
1143
  checkOutcome: CheckStepOutcome.enum.no_check,
@@ -1139,15 +1149,37 @@ async function runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn) {
1139
1149
  outputs: await step.run(ctx)
1140
1150
  };
1141
1151
  }
1142
- const res = await runIdempotentStep({
1152
+ let lastDrift = null;
1153
+ const adapted = {
1143
1154
  name: step.name,
1144
- check: () => step.check(ctx),
1155
+ check: async () => {
1156
+ lastDrift = await step.check(ctx);
1157
+ return lastDrift;
1158
+ },
1145
1159
  summarize: step.summarize,
1146
1160
  apply: (drift) => step.run(ctx, drift),
1147
1161
  whenInSync: step.whenInSync ? () => step.whenInSync(ctx) : void 0
1148
- }, {
1162
+ };
1163
+ const driftGate = step.approval !== void 0 && normalizeApproval(step.approval).when === "drift" && checkMode === CheckMode.enum.apply && opts.awaitStepApprovalWithPayload !== void 0;
1164
+ const res = await runIdempotentStep(adapted, {
1149
1165
  dryRun: checkMode !== CheckMode.enum.apply,
1150
- yes: true,
1166
+ ...driftGate ? { confirm: async () => {
1167
+ const norm = normalizeApproval(step.approval);
1168
+ const summaryMarkdown = step.summarize(lastDrift);
1169
+ const resolution = await opts.awaitStepApprovalWithPayload({
1170
+ stepIndex,
1171
+ stepName: step.name,
1172
+ clauses: norm.clauses,
1173
+ reason: norm.reason ?? `Approval required for drift in '${step.name}'`,
1174
+ ...norm.timeoutSeconds !== void 0 && { timeoutSeconds: norm.timeoutSeconds },
1175
+ payload: {
1176
+ summaryMarkdown,
1177
+ drift: lastDrift
1178
+ }
1179
+ });
1180
+ if (resolution.outcome === "approved") return true;
1181
+ throw new DriftGateRejectedError(resolution.outcome === "expired" ? "approval expired" : resolution.reason);
1182
+ } } : { yes: true },
1151
1183
  log: (line) => sendFn({
1152
1184
  type: "log.line",
1153
1185
  stepIndex,
@@ -1170,7 +1202,7 @@ async function runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn) {
1170
1202
  *
1171
1203
  * Timeout pattern using Promise.race + AbortController, with IPC status reporting.
1172
1204
  */
1173
- async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, outputsMap, getSecretsAccessLog, getSecretMountRecords, jobDeadlineSignal, checkMode = CheckMode.enum.apply) {
1205
+ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, outputsMap, getSecretsAccessLog, getSecretMountRecords, jobDeadlineSignal, checkMode, opts) {
1174
1206
  sendFn({
1175
1207
  type: "step.start",
1176
1208
  stepIndex,
@@ -1181,7 +1213,7 @@ async function executeStepInLoop(step, stepIndex, ctx, timeoutMs, sendFn, output
1181
1213
  const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
1182
1214
  try {
1183
1215
  const phase = await Promise.race([
1184
- runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn),
1216
+ runStepWithCheckMode(step, stepIndex, ctx, checkMode, sendFn, opts),
1185
1217
  new Promise((_, reject) => {
1186
1218
  abortController.signal.addEventListener("abort", () => {
1187
1219
  reject(/* @__PURE__ */ new Error(`Step '${step.name}' timed out after ${timeoutMs}ms`));
@@ -1311,14 +1343,17 @@ async function evaluateStepRulesAndMaybeSkip(step, stepIndex, opts) {
1311
1343
  };
1312
1344
  }
1313
1345
  /**
1314
- * Manual approval gate for a step. When the step declares `requireApproval`
1315
- * and the harness wired `awaitStepApproval`, block until the orchestrator
1316
- * resolves the hold. Returns a failed `StepIterationOutcome` (breaking the
1317
- * loop) on reject/expired; returns null when approved or when no gate applies.
1346
+ * Pre-step manual approval gate. When the step declares `approval` with
1347
+ * `when: 'always'` and the harness wired `awaitStepApproval`, block until the
1348
+ * orchestrator resolves the hold. A `when: 'drift'` gate is NOT handled here —
1349
+ * it fires mid-execution inside `runStepWithCheckMode` once `check()` returns
1350
+ * drift. Returns a failed `StepIterationOutcome` (breaking the loop) on
1351
+ * reject/expired; returns null when approved or when no gate applies.
1318
1352
  */
1319
1353
  async function maybeGateStepApproval(step, stepIndex, opts) {
1320
- if (step.requireApproval === void 0 || !opts.awaitStepApproval) return null;
1321
- const normalized = normalizeRequireApproval(step.requireApproval);
1354
+ if (step.approval === void 0 || !opts.awaitStepApproval) return null;
1355
+ const normalized = normalizeApproval(step.approval);
1356
+ if (normalized.when === "drift") return null;
1322
1357
  opts.sendIpc({
1323
1358
  type: "log.line",
1324
1359
  stepIndex,
@@ -1434,7 +1469,7 @@ async function runStepIteration(step, stepIndex, opts) {
1434
1469
  const timeoutMs = step.timeout ?? opts.defaultTimeoutMs;
1435
1470
  let result;
1436
1471
  try {
1437
- result = await executeStepInLoop(step, stepIndex, ctx, timeoutMs, opts.sendIpc, opts.outputsMap, opts.getSecretsAccessLog, opts.getSecretMountRecords, opts.jobDeadlineSignal, opts.checkMode ?? CheckMode.enum.apply);
1472
+ result = await executeStepInLoop(step, stepIndex, ctx, timeoutMs, opts.sendIpc, opts.outputsMap, opts.getSecretsAccessLog, opts.getSecretMountRecords, opts.jobDeadlineSignal, opts.checkMode ?? CheckMode.enum.apply, opts);
1438
1473
  } finally {
1439
1474
  await opts.afterStepApplyEnvFiles?.();
1440
1475
  }
@@ -3076,8 +3111,8 @@ function logSubprocessStreams(e, tokens) {
3076
3111
  * no Rolldown step at runtime. `@kici-dev/sdk` and host-repo deps resolve via
3077
3112
  * Node's normal ESM lookup against `.kici/node_modules/`.
3078
3113
  */
3079
- const AGENT_SDK_VERSION = "0.1.21";
3080
- const AGENT_SDK_BUNDLE_HASH = "8308089347c304e41b457d3867b17bbff11d6b5cd9706b6823e7abdbd849f33f";
3114
+ const AGENT_SDK_VERSION = "0.1.22";
3115
+ const AGENT_SDK_BUNDLE_HASH = "4b26a42978f77e29db110d47523ec947d3d673c3109fc48141c1a62814bde7a1";
3081
3116
  /**
3082
3117
  * Register the `@kici-dev/core/ts-loader-hook` oxc-transform ESM loader hook so
3083
3118
  * subsequent dynamic `import()` calls for `.ts` / `.tsx` files transform on the
@@ -3439,7 +3474,7 @@ async function applyOverlay(config) {
3439
3474
  */
3440
3475
  init_download();
3441
3476
  init_dep_restore();
3442
- const AGENT_VERSION = "0.1.21";
3477
+ const AGENT_VERSION = "0.1.22";
3443
3478
  process.on("uncaughtException", (err) => {
3444
3479
  process.stderr.write(`[workflow-runner] UNCAUGHT EXCEPTION: ${err.message}\n`);
3445
3480
  if (err.stack) process.stderr.write(`[workflow-runner] Stack: ${err.stack}\n`);
@@ -3832,35 +3867,49 @@ function waitForApprovalResolution(requestId) {
3832
3867
  });
3833
3868
  }
3834
3869
  /**
3835
- * Build the `awaitStepApproval` callback the step loop uses to block on a
3836
- * `requireApproval` step. Sends an `approval.request` IPC (relayed by the agent
3837
- * over the WS as a `step.approval-request`) and awaits the matching
3838
- * `approval.resolved`. A relay error is treated as a fail-closed reject.
3870
+ * Send a step-approval `approval.request` IPC (relayed by the agent over the WS
3871
+ * as a `step.approval-request`), await the matching `approval.resolved`, and map
3872
+ * it onto a `StepApprovalResolution`. A relay error is treated as a fail-closed
3873
+ * reject. Shared by the `when: 'always'` and `when: 'drift'` callbacks; the
3874
+ * latter carries a drift `payload`.
3839
3875
  */
3840
- function buildAwaitStepApproval() {
3841
- return async (req) => {
3842
- const requestId = randomUUID();
3843
- sendMessage({
3844
- type: "approval.request",
3845
- requestId,
3846
- stepIndex: req.stepIndex,
3847
- stepName: req.stepName,
3848
- clauses: req.clauses,
3849
- reason: req.reason,
3850
- ...req.timeoutSeconds !== void 0 && { timeoutSeconds: req.timeoutSeconds }
3851
- });
3852
- const resolution = await waitForApprovalResolution(requestId);
3853
- if (resolution.error) return {
3854
- outcome: "rejected",
3855
- reason: resolution.error
3856
- };
3857
- return {
3858
- outcome: resolution.outcome ?? "rejected",
3859
- ...resolution.reason !== void 0 && { reason: resolution.reason }
3860
- };
3876
+ async function requestStepApproval(req) {
3877
+ const requestId = randomUUID();
3878
+ sendMessage({
3879
+ type: "approval.request",
3880
+ requestId,
3881
+ stepIndex: req.stepIndex,
3882
+ stepName: req.stepName,
3883
+ clauses: req.clauses,
3884
+ reason: req.reason,
3885
+ ...req.timeoutSeconds !== void 0 && { timeoutSeconds: req.timeoutSeconds },
3886
+ ...req.payload !== void 0 && { payload: req.payload }
3887
+ });
3888
+ const resolution = await waitForApprovalResolution(requestId);
3889
+ if (resolution.error) return {
3890
+ outcome: "rejected",
3891
+ reason: resolution.error
3892
+ };
3893
+ return {
3894
+ outcome: resolution.outcome ?? "rejected",
3895
+ ...resolution.reason !== void 0 && { reason: resolution.reason }
3861
3896
  };
3862
3897
  }
3863
3898
  /**
3899
+ * Build the `awaitStepApproval` callback the step loop uses to block on an
3900
+ * `approval` step (`when: 'always'`).
3901
+ */
3902
+ function buildAwaitStepApproval() {
3903
+ return (req) => requestStepApproval(req);
3904
+ }
3905
+ /**
3906
+ * Build the `awaitStepApprovalWithPayload` callback the step loop uses to block
3907
+ * on a `when: 'drift'` step mid-execution, carrying the computed drift payload.
3908
+ */
3909
+ function buildAwaitStepApprovalWithPayload() {
3910
+ return (req) => requestStepApproval(req);
3911
+ }
3912
+ /**
3864
3913
  * Build the {@link CacheTransport} the sandbox-side cache engine uses to reach
3865
3914
  * the orchestrator. Each method sends a `cache.request` IPC (relayed by the
3866
3915
  * agent over the WS as a `cache.user.*` message) and awaits the matching
@@ -4093,6 +4142,75 @@ function createIpcLogger(stepIndex, stepName, sendFn) {
4093
4142
  }
4094
4143
  };
4095
4144
  }
4145
+ /** A lock needs entry maps to a base name (single/matrix/host) or a group name. */
4146
+ function needBaseName(need) {
4147
+ if (typeof need === "string") return {
4148
+ kind: "job",
4149
+ key: need
4150
+ };
4151
+ if (need && typeof need === "object") {
4152
+ if ("group" in need) return {
4153
+ kind: "group",
4154
+ key: need.group
4155
+ };
4156
+ if ("name" in need) return {
4157
+ kind: "job",
4158
+ key: need.name
4159
+ };
4160
+ }
4161
+ return null;
4162
+ }
4163
+ /**
4164
+ * Build `ctx.needs` for a job's steps from the dispatch envelope. Reconstructs
4165
+ * an {@link UpstreamSnapshot} from `upstreamJobOutputs` (flat per single job;
4166
+ * `byMatrix` / `byHost` envelopes per fan-out) + `upstreamJobStatuses` (keyed by
4167
+ * each upstream job/child name), then resolves the job's declared needs into the
4168
+ * `{ result, status }` / ordered-array shape via the shared SDK builder. Returns
4169
+ * undefined when the job declares no needs.
4170
+ */
4171
+ function buildStepNeedsContext(declaredNeeds, upstreamJobOutputs, upstreamJobStatuses) {
4172
+ if (!declaredNeeds || declaredNeeds.length === 0) return void 0;
4173
+ const statuses = upstreamJobStatuses ?? {};
4174
+ const jobs = {};
4175
+ const groups = {};
4176
+ const snapStatuses = {};
4177
+ const resolvedNeeds = [];
4178
+ for (const need of declaredNeeds) {
4179
+ const base = needBaseName(need);
4180
+ if (!base) continue;
4181
+ const childNames = Object.keys(statuses).filter((n) => n.startsWith(`${base.key} (`));
4182
+ if (base.kind === "group" || childNames.length > 0) {
4183
+ groups[base.key] = [...childNames].sort();
4184
+ const envelope = upstreamJobOutputs?.[base.key];
4185
+ const bySuffix = envelopeChildOutputs(envelope);
4186
+ for (const child of childNames) {
4187
+ jobs[child] = bySuffix[child.slice(base.key.length + 2, -1)] ?? {};
4188
+ snapStatuses[child] = statuses[child];
4189
+ }
4190
+ resolvedNeeds.push({ group: base.key });
4191
+ } else {
4192
+ jobs[base.key] = upstreamJobOutputs?.[base.key] ?? {};
4193
+ if (statuses[base.key]) snapStatuses[base.key] = statuses[base.key];
4194
+ resolvedNeeds.push(base.key);
4195
+ }
4196
+ }
4197
+ return buildNeedsContext({
4198
+ jobs,
4199
+ groups,
4200
+ statuses: snapStatuses
4201
+ }, resolvedNeeds);
4202
+ }
4203
+ /**
4204
+ * Extract per-child output records from a fan-out outputs envelope, keyed by the
4205
+ * combination suffix (matrix `byMatrix`) or hostname (`runsOnAll` `byHost`).
4206
+ * Returns an empty map for a non-envelope value.
4207
+ */
4208
+ function envelopeChildOutputs(envelope) {
4209
+ if (!envelope) return {};
4210
+ const byMatrix = envelope.byMatrix;
4211
+ const byHost = envelope.byHost;
4212
+ return byMatrix ?? byHost ?? {};
4213
+ }
4096
4214
  /**
4097
4215
  * Build StepSecrets from the job execution request, wired with the per-step
4098
4216
  * file-mount host (used by `ctx.secrets.mountFile` / `exposeFile`).
@@ -4288,7 +4406,11 @@ function createSandboxStepContext(workDir, stepIndex, stepName, request, maskedS
4288
4406
  ...request.provider && { provider: request.provider },
4289
4407
  ...request.matrixValues && { matrix: request.matrixValues },
4290
4408
  ...request.host && { host: request.host },
4291
- ...request.agent && { agent: request.agent }
4409
+ ...request.agent && { agent: request.agent },
4410
+ ...(() => {
4411
+ const needs = buildStepNeedsContext(request.jobNeeds, request.upstreamJobOutputs, request.upstreamJobStatuses);
4412
+ return needs ? { needs } : {};
4413
+ })()
4292
4414
  };
4293
4415
  }
4294
4416
  /** Raw provider webhook body for ctx.rawPayload — nested in the envelope. */
@@ -5180,7 +5302,8 @@ async function main() {
5180
5302
  },
5181
5303
  beforeStepEnvFiles: stepEnvHooks.beforeStepEnvFiles,
5182
5304
  afterStepApplyEnvFiles: stepEnvHooks.afterStepApplyEnvFiles,
5183
- awaitStepApproval: buildAwaitStepApproval()
5305
+ awaitStepApproval: buildAwaitStepApproval(),
5306
+ awaitStepApprovalWithPayload: buildAwaitStepApprovalWithPayload()
5184
5307
  });
5185
5308
  jobDeadline.clear();
5186
5309
  await maybeSaveJobCache(jobCacheSpecs, jobCacheRestore, cachePhaseDeps, !aborted && loopResult.status === ExecutionStepStatus.enum.success);
@@ -5266,6 +5389,6 @@ main().catch((error) => {
5266
5389
  setTimeout(() => process.exit(1), 100);
5267
5390
  });
5268
5391
  //#endregion
5269
- export { createSandboxStepContext, rawPayloadFromEvent };
5392
+ export { buildStepNeedsContext, createSandboxStepContext, rawPayloadFromEvent };
5270
5393
 
5271
5394
  //# sourceMappingURL=workflow-runner.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kici-dev/agent",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
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",
@@ -64,10 +64,10 @@
64
64
  "yaml": "^2.9.0",
65
65
  "zod": "^4.4.3",
66
66
  "zx": "^8.8.5",
67
- "@kici-dev/core": "0.1.21",
68
- "@kici-dev/engine": "0.1.21",
69
- "@kici-dev/sdk": "0.1.21",
70
- "@kici-dev/shared": "0.1.21"
67
+ "@kici-dev/core": "0.1.22",
68
+ "@kici-dev/engine": "0.1.22",
69
+ "@kici-dev/sdk": "0.1.22",
70
+ "@kici-dev/shared": "0.1.22"
71
71
  },
72
72
  "kici": {
73
73
  "metrics": {