@kici-dev/agent 0.1.22 → 0.1.24

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/server.js CHANGED
@@ -25,7 +25,7 @@ import { format, promisify } from "node:util";
25
25
  import { gcStaleTmpDirs } from "@kici-dev/core/tmp-gc";
26
26
  import fs$1, { access, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, unlink, writeFile } from "node:fs/promises";
27
27
  import Docker from "dockerode";
28
- import { buildKiciApi, buildNeedsContext, isDynamicGroupRef, isDynamicJobFn, isStaticArray, isStaticObject } from "@kici-dev/sdk";
28
+ import { buildKiciApi, buildNeedsContext, isDynamicGroupRef, isDynamicJobFn, isParallelGroup, isStaticArray, isStaticObject } from "@kici-dev/sdk";
29
29
  import { c, x } from "tar";
30
30
  import https from "node:https";
31
31
  import http from "node:http";
@@ -1310,14 +1310,14 @@ var init_console_capture = __esmMin((() => {
1310
1310
  init_console_capture();
1311
1311
  function safe(name, fallback = "unknown") {
1312
1312
  switch (name) {
1313
- case "version": return "0.1.22";
1314
- case "buildCommit": return "5afd16303";
1315
- case "sdkVersion": return "0.1.22";
1316
- case "sdkBundleHash": return "4b26a42978f77e29db110d47523ec947d3d673c3109fc48141c1a62814bde7a1";
1317
- case "sharedVersion": return "0.1.22";
1318
- case "sharedBundleHash": return "991385c024392c395d3eb8a68946ef8ed3fcba96f2652f21c3164a54eafa1b1b";
1319
- case "engineVersion": return "0.1.22";
1320
- case "engineBundleHash": return "ff74a3afb2b1db2870c03d47543642f64d04b660bebdcde6681f76d874f26757";
1313
+ case "version": return "0.1.24";
1314
+ case "buildCommit": return "73592f67f";
1315
+ case "sdkVersion": return "0.1.24";
1316
+ case "sdkBundleHash": return "031712cf8cd02f483365113aad7e946da982110d544b177eb05edcc0d4421253";
1317
+ case "sharedVersion": return "0.1.24";
1318
+ case "sharedBundleHash": return "b977224129c767c4851458a795fa470264b2fc51255baf06cf14640e29e2f44c";
1319
+ case "engineVersion": return "0.1.24";
1320
+ case "engineBundleHash": return "734acca885cd70eed07a1a9426b08c04c07a9bf99484c18100d3d797b3eb8f39";
1321
1321
  default: return fallback;
1322
1322
  }
1323
1323
  }
@@ -2080,8 +2080,8 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
2080
2080
  }
2081
2081
  var AGENT_SDK_VERSION, AGENT_SDK_BUNDLE_HASH, hookRegistered;
2082
2082
  var init_workflow_loader = __esmMin((() => {
2083
- AGENT_SDK_VERSION = "0.1.22";
2084
- AGENT_SDK_BUNDLE_HASH = "4b26a42978f77e29db110d47523ec947d3d673c3109fc48141c1a62814bde7a1";
2083
+ AGENT_SDK_VERSION = "0.1.24";
2084
+ AGENT_SDK_BUNDLE_HASH = "031712cf8cd02f483365113aad7e946da982110d544b177eb05edcc0d4421253";
2085
2085
  hookRegistered = false;
2086
2086
  }));
2087
2087
  //#endregion
@@ -2548,9 +2548,16 @@ async function evaluateDynamicFields(workflow, jobName, event, flags, timeoutMs
2548
2548
  if (job.include || job.exclude) combos = applyIncludeExclude(combos, job.include, job.exclude);
2549
2549
  result.matrixValues = combos;
2550
2550
  }
2551
- if (flags.dynamicEnvironment && typeof job.environment === "function") {
2552
- const value = await withTimeout(() => job.environment(event), timeoutMs, `dynamicEnvironment for job '${jobName}'`);
2553
- if (value !== void 0 && value !== null) result.environmentName = value;
2551
+ if (flags.dynamicEnvironment) {
2552
+ const envRefs = job.environments ?? (job.environment !== void 0 ? [job.environment] : void 0);
2553
+ if (envRefs && envRefs.length > 0) {
2554
+ const names = [];
2555
+ for (const ref of envRefs) if (typeof ref === "function") {
2556
+ const value = await withTimeout(() => ref(event), timeoutMs, `dynamicEnvironment for job '${jobName}'`);
2557
+ if (value !== void 0 && value !== null) names.push(value);
2558
+ } else if (typeof ref === "string") names.push(ref);
2559
+ if (names.length > 0) result.environmentNames = names;
2560
+ }
2554
2561
  }
2555
2562
  if (flags.dynamicEnv && typeof job.env === "function") {
2556
2563
  const value = await withTimeout(() => job.env(event), timeoutMs, `dynamicEnv for job '${jobName}'`);
@@ -2566,6 +2573,274 @@ var init_init_runner = __esmMin((() => {
2566
2573
  init_timeout_util();
2567
2574
  }));
2568
2575
  //#endregion
2576
+ //#region src/bootstrap/ssh-exec.ts
2577
+ /**
2578
+ * Agent-side SSH helper for bootstrap bring-up.
2579
+ *
2580
+ * Lifts the discipline from `infra/prod/hw/ssh.sh` + the deploy-prod
2581
+ * `runOnBox` helper into the agent: the bring-up private key is piped into an
2582
+ * **ephemeral ssh-agent** (`ssh-add -` reads it from stdin) and the agent is
2583
+ * torn down in a `finally`, so the key never lands on disk and never enters a
2584
+ * long-lived process environment. The agent only holds the key for the
2585
+ * lifetime of the one `ssh` / `scp` invocation.
2586
+ *
2587
+ * `sshExec` runs a remote command; `sshPush` ships local bytes to a remote
2588
+ * path (`ssh 'cat > path'`). Both accept a resolved private key string
2589
+ * (supplied by the caller — the orchestrator resolves the scoped secret and
2590
+ * hands the key down). `sshExec` supports `{ stdin, port, hostKeyMode }` so it
2591
+ * can also drive a pre-boot dropbear / initramfs prompt (a forced-command
2592
+ * endpoint such as `cryptroot-unlock` on port 2222, which accepts the unlock
2593
+ * input on stdin and uses a transient host key distinct from the OS sshd key).
2594
+ */
2595
+ /** Common `-o` flags every bootstrap SSH connection carries. */
2596
+ function baseSshOptions(hostKeyMode) {
2597
+ return [
2598
+ "-o",
2599
+ `StrictHostKeyChecking=${HOST_KEY_FLAG[hostKeyMode]}`,
2600
+ "-o",
2601
+ "ConnectTimeout=10",
2602
+ "-o",
2603
+ "BatchMode=yes"
2604
+ ];
2605
+ }
2606
+ /** Resolve the `user@address` target + port from reach metadata. */
2607
+ function resolveTarget(reach, portOverride) {
2608
+ if (!reach.address) throw new Error(`host ${reach.agentId} has no SSH reach address declared`);
2609
+ const user = reach.sshUser ?? SSH_USER_DEFAULT;
2610
+ const port = portOverride ?? reach.sshPort ?? SSH_PORT_DEFAULT;
2611
+ return {
2612
+ dest: `${user}@${reach.address}`,
2613
+ port
2614
+ };
2615
+ }
2616
+ /**
2617
+ * Run a command on the target over SSH using an ephemeral, in-memory key.
2618
+ *
2619
+ * The key is loaded into a per-call ssh-agent (never written to disk) and the
2620
+ * agent is killed in `finally`. The remote command's exit code / stdout /
2621
+ * stderr are returned verbatim — a non-zero exit is reported, not swallowed
2622
+ * (a pre-boot unlock legitimately drops the session, so the caller decides
2623
+ * what a "success" looks like).
2624
+ */
2625
+ async function sshExec(reach, privateKey, command, opts = {}, deps = {}) {
2626
+ const spawnFn = deps.spawnFn ?? defaultSpawn;
2627
+ const hostKeyMode = opts.hostKeyMode ?? "accept-new";
2628
+ const { dest, port } = resolveTarget(reach, opts.port);
2629
+ return withEphemeralAgent(privateKey, spawnFn, async (env) => {
2630
+ return spawnFn("ssh", [
2631
+ ...baseSshOptions(hostKeyMode),
2632
+ "-p",
2633
+ String(port),
2634
+ dest,
2635
+ command
2636
+ ], {
2637
+ env,
2638
+ stdin: opts.stdin
2639
+ });
2640
+ });
2641
+ }
2642
+ /**
2643
+ * Ship local bytes to a remote path over SSH (`ssh 'cat > path'`), using the
2644
+ * same ephemeral-key discipline. Throws on a non-zero exit (a push must
2645
+ * succeed end-to-end, unlike a pre-boot unlock).
2646
+ */
2647
+ async function sshPush(reach, privateKey, localBytes, remotePath, opts = {}, deps = {}) {
2648
+ const spawnFn = deps.spawnFn ?? defaultSpawn;
2649
+ const hostKeyMode = opts.hostKeyMode ?? "accept-new";
2650
+ const { dest, port } = resolveTarget(reach, opts.port);
2651
+ const result = await withEphemeralAgent(privateKey, spawnFn, async (env) => {
2652
+ return spawnFn("ssh", [
2653
+ ...baseSshOptions(hostKeyMode),
2654
+ "-p",
2655
+ String(port),
2656
+ dest,
2657
+ `cat > '${remotePath.replace(/'/g, `'\\''`)}'`
2658
+ ], {
2659
+ env,
2660
+ stdin: localBytes
2661
+ });
2662
+ });
2663
+ if (result.exitCode !== 0) throw new Error(`sshPush(${reach.agentId}:${remotePath}): exit ${result.exitCode}${result.stderr ? `\n${result.stderr}` : ""}`);
2664
+ }
2665
+ /**
2666
+ * Start a per-call ephemeral ssh-agent, load the key via stdin (never a file),
2667
+ * run `body` with `SSH_AUTH_SOCK` in env, and kill the agent in `finally`.
2668
+ */
2669
+ async function withEphemeralAgent(privateKey, spawnFn, body) {
2670
+ const baseEnv = { ...process.env };
2671
+ const start = await spawnFn("ssh-agent", ["-s"], { env: baseEnv });
2672
+ if (start.exitCode !== 0) throw new Error(`ssh-agent start failed: exit ${start.exitCode}\n${start.stderr}`);
2673
+ const sock = parseAgentSocket(start.stdout);
2674
+ const pid = parseAgentPid(start.stdout);
2675
+ const agentEnv = {
2676
+ ...baseEnv,
2677
+ SSH_AUTH_SOCK: sock,
2678
+ ...pid ? { SSH_AGENT_PID: pid } : {},
2679
+ SSH_ASKPASS: "/bin/false",
2680
+ DISPLAY: ""
2681
+ };
2682
+ try {
2683
+ const add = await spawnFn("ssh-add", ["-"], {
2684
+ env: agentEnv,
2685
+ stdin: privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`
2686
+ });
2687
+ if (add.exitCode !== 0) throw new Error(`ssh-add failed: exit ${add.exitCode}\n${add.stderr}`);
2688
+ return await body(agentEnv);
2689
+ } finally {
2690
+ await spawnFn("ssh-agent", ["-k"], { env: agentEnv }).catch(() => {});
2691
+ }
2692
+ }
2693
+ /** Extract `SSH_AUTH_SOCK=<path>;` from `ssh-agent -s` output. */
2694
+ function parseAgentSocket(out) {
2695
+ const m = out.match(/SSH_AUTH_SOCK=([^;\n]+)/);
2696
+ if (!m) throw new Error("ssh-agent -s did not emit SSH_AUTH_SOCK");
2697
+ return m[1];
2698
+ }
2699
+ /** Extract `SSH_AGENT_PID=<n>;` from `ssh-agent -s` output (best-effort). */
2700
+ function parseAgentPid(out) {
2701
+ return out.match(/SSH_AGENT_PID=([^;\n]+)/)?.[1];
2702
+ }
2703
+ var defaultSpawn, SSH_USER_DEFAULT, SSH_PORT_DEFAULT, HOST_KEY_FLAG;
2704
+ var init_ssh_exec = __esmMin((() => {
2705
+ defaultSpawn = (command, args, opts) => new Promise((resolve, reject) => {
2706
+ const child = spawn(command, args, {
2707
+ env: opts.env,
2708
+ stdio: [
2709
+ opts.stdin !== void 0 ? "pipe" : "ignore",
2710
+ "pipe",
2711
+ "pipe"
2712
+ ]
2713
+ });
2714
+ let stdout = "";
2715
+ let stderr = "";
2716
+ child.stdout?.on("data", (b) => {
2717
+ stdout += b.toString();
2718
+ });
2719
+ child.stderr?.on("data", (b) => {
2720
+ stderr += b.toString();
2721
+ });
2722
+ child.on("error", reject);
2723
+ child.on("close", (code) => resolve({
2724
+ exitCode: code ?? -1,
2725
+ stdout,
2726
+ stderr
2727
+ }));
2728
+ if (opts.stdin !== void 0) child.stdin?.end(opts.stdin);
2729
+ });
2730
+ SSH_USER_DEFAULT = "root";
2731
+ SSH_PORT_DEFAULT = 22;
2732
+ HOST_KEY_FLAG = {
2733
+ "accept-new": "accept-new",
2734
+ strict: "yes"
2735
+ };
2736
+ }));
2737
+ //#endregion
2738
+ //#region src/bootstrap/ensure-init-runner.ts
2739
+ /**
2740
+ * Build the launcher script that starts the init-runner on the target with its
2741
+ * bootstrap env. Detached (`setsid … &`) so the SSH session can return while
2742
+ * the agent keeps running and dials the orchestrator.
2743
+ */
2744
+ function buildLauncher(material, agentCommand) {
2745
+ return [
2746
+ "#!/usr/bin/env bash",
2747
+ "set -euo pipefail",
2748
+ `setsid env ${[
2749
+ `KICI_AGENT_TOKEN=${shQuote(material.bootstrapToken)}`,
2750
+ `KICI_AGENT_ID=${shQuote(material.targetAgentId)}`,
2751
+ `KICI_ORCHESTRATOR_URL=${shQuote(material.orchestratorUrl)}`,
2752
+ `KICI_LABELS=${shQuote(material.labels.join(","))}`,
2753
+ "KICI_EXECUTION_MODE=bare-metal",
2754
+ "KICI_PORT=0"
2755
+ ].join(" \\\n ")} \\`,
2756
+ ` ${agentCommand} >/tmp/kici-init-runner.log 2>&1 &`,
2757
+ "echo \"init-runner started pid=$!\""
2758
+ ].join("\n");
2759
+ }
2760
+ /** Single-quote a value for safe embedding in the launcher's env assignment. */
2761
+ function shQuote(v) {
2762
+ return `'${v.replace(/'/g, `'\\''`)}'`;
2763
+ }
2764
+ /**
2765
+ * Bring up a temporary init-runner on `targetAgentId`. Returns `{ broughtUp }`:
2766
+ * false when the target already had a live agent (the orchestrator no-op'd),
2767
+ * true when this call dropped + started the init-runner.
2768
+ */
2769
+ async function ensureInitRunner(transport, targetAgentId, deps = {}) {
2770
+ const material = await transport("kici.ensureInitRunner", { targetAgentId });
2771
+ if (!material.broughtUp) return { broughtUp: false };
2772
+ const { reach, privateKey, bootstrapToken, orchestratorUrl, labels } = material;
2773
+ if (!reach || !privateKey || !bootstrapToken || !orchestratorUrl || !labels) throw new Error(`orchestrator returned incomplete bring-up material for ${targetAgentId}`);
2774
+ const agentCommand = deps.agentCommand ?? DEFAULT_AGENT_COMMAND;
2775
+ await sshPush(reach, privateKey, buildLauncher({
2776
+ bootstrapToken,
2777
+ targetAgentId,
2778
+ orchestratorUrl,
2779
+ labels
2780
+ }, agentCommand), LAUNCHER_REMOTE_PATH, {}, deps);
2781
+ const run = await sshExec(reach, privateKey, `chmod 0700 ${LAUNCHER_REMOTE_PATH} && ${LAUNCHER_REMOTE_PATH}`, {}, deps);
2782
+ if (run.exitCode !== 0) throw new Error(`init-runner launch on ${targetAgentId} failed: exit ${run.exitCode}${run.stderr ? `\n${run.stderr}` : ""}`);
2783
+ return { broughtUp: true };
2784
+ }
2785
+ var DEFAULT_AGENT_COMMAND, LAUNCHER_REMOTE_PATH;
2786
+ var init_ensure_init_runner = __esmMin((() => {
2787
+ init_ssh_exec();
2788
+ DEFAULT_AGENT_COMMAND = "kici-agent";
2789
+ LAUNCHER_REMOTE_PATH = "/tmp/kici-init-runner.sh";
2790
+ }));
2791
+ //#endregion
2792
+ //#region src/bootstrap/pre-boot-send.ts
2793
+ /**
2794
+ * Ship a pre-boot input to the target's dropbear/initramfs SSH channel. The
2795
+ * input plaintext is resolved server-side and never logged. Resolves once the
2796
+ * send completes (the SSH session legitimately drops as the box boots).
2797
+ */
2798
+ async function preBootSend(transport, targetAgentId, opts, deps = {}) {
2799
+ const material = await transport("kici.preBootSend", {
2800
+ targetAgentId,
2801
+ inputSecret: opts.inputSecret,
2802
+ ...opts.port !== void 0 ? { port: opts.port } : {},
2803
+ ...opts.command !== void 0 ? { command: opts.command } : {}
2804
+ });
2805
+ await sshExec(material.reach, material.privateKey, material.command, {
2806
+ stdin: material.input,
2807
+ port: material.port,
2808
+ hostKeyMode: "accept-new"
2809
+ }, deps);
2810
+ }
2811
+ var init_pre_boot_send = __esmMin((() => {
2812
+ init_ssh_exec();
2813
+ }));
2814
+ //#endregion
2815
+ //#region src/bootstrap/api-intercept.ts
2816
+ /**
2817
+ * Wrap the orchestrator API transport so the two bootstrap methods are handled
2818
+ * in-process (SSH transport here; privileged resolve relayed to the
2819
+ * orchestrator). `relay` is the raw orchestrator transport (the WS
2820
+ * `sendApiRequest`).
2821
+ */
2822
+ function withBootstrapInterception(relay, deps = {}) {
2823
+ return async (method, params = {}) => {
2824
+ if (method === ENSURE_INIT_RUNNER) return ensureInitRunner(relay, String(params.targetAgentId ?? ""), deps);
2825
+ if (method === PRE_BOOT_SEND) {
2826
+ await preBootSend(relay, String(params.targetAgentId ?? ""), {
2827
+ inputSecret: String(params.inputSecret ?? ""),
2828
+ ...typeof params.port === "number" ? { port: params.port } : {},
2829
+ ...typeof params.command === "string" ? { command: params.command } : {}
2830
+ }, deps);
2831
+ return;
2832
+ }
2833
+ return relay(method, params);
2834
+ };
2835
+ }
2836
+ var ENSURE_INIT_RUNNER, PRE_BOOT_SEND;
2837
+ var init_api_intercept = __esmMin((() => {
2838
+ init_ensure_init_runner();
2839
+ init_pre_boot_send();
2840
+ ENSURE_INIT_RUNNER = "kici.ensureInitRunner";
2841
+ PRE_BOOT_SEND = "kici.preBootSend";
2842
+ }));
2843
+ //#endregion
2569
2844
  //#region src/execution/dynamic-job-serializer.ts
2570
2845
  /**
2571
2846
  * Convert an array of SDK Job objects into LockJob format for the orchestrator.
@@ -2589,11 +2864,22 @@ async function serializeJobsToLock(jobs, ctx, staticNames, allowedGroups) {
2589
2864
  }
2590
2865
  async function serializeJob(job, generatedNames, ctx, staticNames, allowedGroups) {
2591
2866
  const { include: runsOn, exclude: excludeLabels } = normalizeRunsOnToMatchers(job.runsOn, `generated job '${job.name}' runsOn`);
2592
- let resolvedEnvironment;
2593
- if (typeof job.environment === "function") {
2594
- const value = await withTimeout(() => job.environment(ctx.event), DYNAMIC_FIELD_TIMEOUT_MS, `dynamic environment for generated job '${job.name}'`);
2595
- if (value !== void 0 && value !== null) resolvedEnvironment = value;
2596
- } else if (typeof job.environment === "string") resolvedEnvironment = job.environment;
2867
+ const envRefs = job.environments ?? (job.environment !== void 0 ? [job.environment] : void 0);
2868
+ let resolvedEnvironments;
2869
+ if (envRefs !== void 0 && envRefs.length > 0) {
2870
+ const resolved = [];
2871
+ for (const ref of envRefs) if (typeof ref === "function") {
2872
+ const value = await withTimeout(() => ref(ctx.event), DYNAMIC_FIELD_TIMEOUT_MS, `dynamic environment for generated job '${job.name}'`);
2873
+ if (value !== void 0 && value !== null) resolved.push({
2874
+ value,
2875
+ dynamic: false
2876
+ });
2877
+ } else if (typeof ref === "string") resolved.push({
2878
+ value: ref,
2879
+ dynamic: false
2880
+ });
2881
+ if (resolved.length > 0) resolvedEnvironments = resolved;
2882
+ }
2597
2883
  let resolvedEnv;
2598
2884
  if (typeof job.env === "function") {
2599
2885
  const value = await withTimeout(() => job.env(ctx.event), DYNAMIC_FIELD_TIMEOUT_MS, `dynamic env for generated job '${job.name}'`);
@@ -2619,7 +2905,7 @@ async function serializeJob(job, generatedNames, ctx, staticNames, allowedGroups
2619
2905
  ...job.include ? { include: job.include } : {},
2620
2906
  ...job.exclude ? { exclude: job.exclude } : {},
2621
2907
  ...job.description ? { description: job.description } : {},
2622
- ...resolvedEnvironment !== void 0 ? { environment: resolvedEnvironment } : {},
2908
+ ...resolvedEnvironments !== void 0 ? { environments: resolvedEnvironments } : {},
2623
2909
  ...resolvedEnv !== void 0 ? { env: resolvedEnv } : {},
2624
2910
  ...resolvedConcurrencyGroup !== void 0 ? { concurrencyGroup: resolvedConcurrencyGroup } : {}
2625
2911
  };
@@ -2677,20 +2963,41 @@ function resolveNeeds(needs, generatedNames, staticNames, allowedGroups) {
2677
2963
  * are loaded from the workflow bundle at execution time.
2678
2964
  */
2679
2965
  function serializeSteps(steps) {
2680
- return steps.map((stepOrFn, index) => {
2681
- if (typeof stepOrFn === "function") return {
2682
- name: `step-${index}`,
2683
- hasOutputs: false
2684
- };
2685
- const step = stepOrFn;
2686
- return {
2687
- name: step.name || `step-${index}`,
2688
- hasOutputs: !!step.outputs,
2689
- ...step.continueOnError ? { continueOnError: true } : {},
2690
- ...step.timeout ? { timeout: step.timeout } : {}
2691
- };
2966
+ let flatIndex = 0;
2967
+ return steps.map((entry) => {
2968
+ if (isParallelGroup(entry)) {
2969
+ const children = entry.steps.map((child) => serializeSequentialStep(child, flatIndex++));
2970
+ return {
2971
+ kind: "parallel",
2972
+ name: entry.name ?? `parallel-${children[0]?.name ?? "group"}`,
2973
+ failFast: entry.failFast,
2974
+ ...entry.maxParallel !== void 0 ? { maxParallel: entry.maxParallel } : {},
2975
+ children
2976
+ };
2977
+ }
2978
+ return serializeSequentialStep(entry, flatIndex++);
2692
2979
  });
2693
2980
  }
2981
+ /** Serialize one sequential step (or bare function) to a flat `LockStep`. */
2982
+ function serializeSequentialStep(stepOrFn, index) {
2983
+ if (typeof stepOrFn === "function") return {
2984
+ name: `step-${index}`,
2985
+ hasOutputs: false
2986
+ };
2987
+ const step = stepOrFn;
2988
+ return {
2989
+ name: step.name || `step-${index}`,
2990
+ hasOutputs: !!step.outputs,
2991
+ ...step.continueOnError ? { continueOnError: true } : {},
2992
+ ...step.timeout ? { timeout: step.timeout } : {},
2993
+ ...step.retry ? { retry: {
2994
+ maxAttempts: step.retry.maxAttempts,
2995
+ delayMs: step.retry.delayMs,
2996
+ backoff: step.retry.backoff,
2997
+ maxDelayMs: step.retry.maxDelayMs
2998
+ } } : {}
2999
+ };
3000
+ }
2694
3001
  /**
2695
3002
  * Serialize matrix configuration. Static array/object matrices are embedded as-is;
2696
3003
  * dynamic matrix functions are invoked against the eval context (mirroring the
@@ -4263,6 +4570,9 @@ function buildRequest(dispatch, workDir) {
4263
4570
  matrixValues: jobConfig.matrixValues,
4264
4571
  host: jobConfig.host,
4265
4572
  agent: jobConfig.agent,
4573
+ dispatchInputs: jobConfig.dispatchInputs,
4574
+ fanoutIndex: jobConfig.fanoutIndex,
4575
+ fanoutTotal: jobConfig.fanoutTotal,
4266
4576
  secrets: dispatch.secrets,
4267
4577
  namespacedSecrets: dispatch.namespacedSecrets,
4268
4578
  sourceFile: jobConfig.source?.file,
@@ -4606,10 +4916,17 @@ function relayChildIpcMessage(msg, dispatch, ctx) {
4606
4916
  case "log.line":
4607
4917
  ctx.execOptions.onLogLine(msg.stepIndex, msg.line);
4608
4918
  return;
4609
- case "step.start":
4919
+ case "step.start": {
4610
4920
  ctx.stepNames.set(msg.stepIndex, msg.stepName);
4611
- ctx.execOptions.onStepStatus(msg.stepIndex, msg.stepName, ExecutionStepStatus.enum.running);
4921
+ const startState = msg.state === "pending" ? ExecutionStepStatus.enum.pending : ExecutionStepStatus.enum.running;
4922
+ const startData = {
4923
+ ...msg.concurrencyKind && { concurrencyKind: msg.concurrencyKind },
4924
+ ...msg.groupId && { groupId: msg.groupId }
4925
+ };
4926
+ if (Object.keys(startData).length > 0) ctx.execOptions.onStepStatus(msg.stepIndex, msg.stepName, startState, startData);
4927
+ else ctx.execOptions.onStepStatus(msg.stepIndex, msg.stepName, startState);
4612
4928
  return;
4929
+ }
4613
4930
  case "step.complete":
4614
4931
  ctx.execOptions.onStepStatus(msg.stepIndex, ctx.stepNames.get(msg.stepIndex) ?? "", msg.status, {
4615
4932
  durationMs: msg.durationMs,
@@ -4619,6 +4936,8 @@ function relayChildIpcMessage(msg, dispatch, ctx) {
4619
4936
  ...msg.checkOutcome !== void 0 && { checkOutcome: msg.checkOutcome },
4620
4937
  ...msg.driftSummary !== void 0 && { driftSummary: msg.driftSummary },
4621
4938
  ...msg.drift !== void 0 && { drift: msg.drift },
4939
+ ...msg.concurrencyKind && { concurrencyKind: msg.concurrencyKind },
4940
+ ...msg.groupId && { groupId: msg.groupId },
4622
4941
  ...msg.data && msg.data
4623
4942
  });
4624
4943
  return;
@@ -5332,10 +5651,17 @@ var init_container_sandbox = __esmMin((() => {
5332
5651
  case "ready":
5333
5652
  this.sendExecuteRequest(stream, options);
5334
5653
  return false;
5335
- case "step.start":
5654
+ case "step.start": {
5336
5655
  stepNames.set(msg.stepIndex, msg.stepName);
5337
- options.onStepStatus(msg.stepIndex, msg.stepName, ExecutionStepStatus.enum.running);
5656
+ const startState = msg.state === "pending" ? ExecutionStepStatus.enum.pending : ExecutionStepStatus.enum.running;
5657
+ const startData = {
5658
+ ...msg.concurrencyKind && { concurrencyKind: msg.concurrencyKind },
5659
+ ...msg.groupId && { groupId: msg.groupId }
5660
+ };
5661
+ if (Object.keys(startData).length > 0) options.onStepStatus(msg.stepIndex, msg.stepName, startState, startData);
5662
+ else options.onStepStatus(msg.stepIndex, msg.stepName, startState);
5338
5663
  return false;
5664
+ }
5339
5665
  case "step.complete": {
5340
5666
  const name = stepNames.get(msg.stepIndex) ?? `step-${msg.stepIndex}`;
5341
5667
  options.onStepStatus(msg.stepIndex, name, msg.status, {
@@ -5346,6 +5672,8 @@ var init_container_sandbox = __esmMin((() => {
5346
5672
  ...msg.checkOutcome !== void 0 && { checkOutcome: msg.checkOutcome },
5347
5673
  ...msg.driftSummary !== void 0 && { driftSummary: msg.driftSummary },
5348
5674
  ...msg.drift !== void 0 && { drift: msg.drift },
5675
+ ...msg.concurrencyKind && { concurrencyKind: msg.concurrencyKind },
5676
+ ...msg.groupId && { groupId: msg.groupId },
5349
5677
  ...msg.data && msg.data
5350
5678
  });
5351
5679
  stepResults.push({
@@ -5543,6 +5871,8 @@ var init_job_runner = __esmMin((() => {
5543
5871
  init_source_packer();
5544
5872
  init_source_restore();
5545
5873
  init_init_runner();
5874
+ init_api_intercept();
5875
+ init_ensure_init_runner();
5546
5876
  init_timeout_util();
5547
5877
  init_dynamic_job_serializer();
5548
5878
  init_log_streamer();
@@ -5655,6 +5985,10 @@ var init_job_runner = __esmMin((() => {
5655
5985
  await this.handleDynamicJobFn(dispatch, workDir, abortController);
5656
5986
  return true;
5657
5987
  }
5988
+ if (jobConfig.bringupOnly === true) {
5989
+ await this.handleBringupJob(dispatch);
5990
+ return true;
5991
+ }
5658
5992
  if (jobConfig.buildOnly === true) {
5659
5993
  if (jobConfig.fullRepo) {
5660
5994
  logger$2.warn("Build job received for fullRepo run -- skipping (should not happen)", {
@@ -5827,7 +6161,7 @@ var init_job_runner = __esmMin((() => {
5827
6161
  reason: ack.reason
5828
6162
  };
5829
6163
  },
5830
- onApiRequest: this._sendApiRequest ? async (method, params) => this._sendApiRequest(method, params) : void 0,
6164
+ onApiRequest: this._sendApiRequest ? withBootstrapInterception(async (method, params) => this._sendApiRequest(method, params)) : void 0,
5831
6165
  onCacheRequest: this._requestUserCache ? async (request) => this._requestUserCache(jobId, request) : void 0,
5832
6166
  onProvenanceRequest: this._relayProvenance ? async (request) => this._relayProvenance(jobId, request) : void 0,
5833
6167
  onApprovalRequest: this._sendStepApproval ? async (request) => this._sendStepApproval(dispatch.runId, dispatch.jobId, request) : void 0,
@@ -5889,6 +6223,53 @@ var init_job_runner = __esmMin((() => {
5889
6223
  }, result.secretOutputs);
5890
6224
  }
5891
6225
  /**
6226
+ * Handle a bring-up job: bring up a temporary init-runner on a declared-but-
6227
+ * un-agented host over SSH (fresh-box bootstrap convergence). The orchestrator
6228
+ * dispatches this synthetic `__bringup__` job to an agent holding the
6229
+ * `kici:capability:ssh-transport` capability; here we run the agent-side
6230
+ * `ensureInitRunner` helper (the privileged resolve is relayed to the
6231
+ * orchestrator, the SSH transport happens in this agent process — never
6232
+ * reaching workflow code). No clone, no sandbox — the init-runner then
6233
+ * connects under the target's agent id and the orchestrator's pinned-hold
6234
+ * drains the target's bootstrap steps onto it.
6235
+ */
6236
+ async handleBringupJob(dispatch) {
6237
+ const { runId, jobId, jobConfig } = dispatch;
6238
+ const targetAgentId = String(jobConfig.bringupTarget ?? "");
6239
+ logger$2.info("Starting bring-up job", {
6240
+ jobId,
6241
+ runId,
6242
+ targetAgentId
6243
+ });
6244
+ this.sendJobStatus(dispatch, ExecutionJobStatus.enum.running);
6245
+ const streamer = this.createStepStreamer(dispatch, 0);
6246
+ this.sendStepStatus(dispatch, 0, "bring-up", ExecutionStepStatus.enum.running);
6247
+ try {
6248
+ if (!targetAgentId) throw new Error("bring-up job missing bringupTarget");
6249
+ if (!this._sendApiRequest) throw new Error("bring-up job requires an orchestrator API transport");
6250
+ streamer.addLine(`Bringing up init-runner on ${targetAgentId}…`);
6251
+ const result = await ensureInitRunner(async (method, params) => this._sendApiRequest(method, params), targetAgentId);
6252
+ streamer.addLine(result.broughtUp ? `Init-runner brought up on ${targetAgentId}.` : `${targetAgentId} already has a live agent — no bring-up needed.`);
6253
+ await streamer.flush();
6254
+ this.sendStepStatus(dispatch, 0, "bring-up", ExecutionStepStatus.enum.success, void 0, streamer.getTotalBytes());
6255
+ streamer.destroy();
6256
+ this.sendJobStatus(dispatch, ExecutionJobStatus.enum.success);
6257
+ } catch (err) {
6258
+ const message = err instanceof Error ? err.message : String(err);
6259
+ streamer.addLine(`Bring-up failed: ${message}`);
6260
+ await streamer.flush();
6261
+ this.sendStepStatus(dispatch, 0, "bring-up", ExecutionStepStatus.enum.failed, void 0, streamer.getTotalBytes());
6262
+ streamer.destroy();
6263
+ this.sendJobStatus(dispatch, ExecutionJobStatus.enum.failed, { error: message });
6264
+ logger$2.warn("Bring-up job failed", {
6265
+ jobId,
6266
+ runId,
6267
+ targetAgentId,
6268
+ error: message
6269
+ });
6270
+ }
6271
+ }
6272
+ /**
5892
6273
  * Handle a build-only job.
5893
6274
  *
5894
6275
  * Build jobs install dependencies, pack them into a tarball,
@@ -6164,7 +6545,7 @@ var init_job_runner = __esmMin((() => {
6164
6545
  });
6165
6546
  logger$2.info("Init job completed successfully", {
6166
6547
  jobId,
6167
- hasEnvironment: initResult.environmentName !== void 0,
6548
+ hasEnvironment: initResult.environmentNames !== void 0,
6168
6549
  hasEnv: initResult.env !== void 0,
6169
6550
  hasConcurrencyGroup: initResult.concurrencyGroup !== void 0
6170
6551
  });
@@ -6281,7 +6662,7 @@ var init_job_runner = __esmMin((() => {
6281
6662
  error: (msg, ..._args) => evalLog(`ERROR: ${msg}`),
6282
6663
  debug: (msg, ..._args) => evalLog(`DEBUG: ${msg}`)
6283
6664
  };
6284
- const kici = buildKiciApi(this._sendApiRequest ? (method, params) => this._sendApiRequest(method, params ?? {}) : () => Promise.reject(/* @__PURE__ */ new Error("Agent API not available")));
6665
+ const kici = buildKiciApi(this._sendApiRequest ? withBootstrapInterception((method, params) => this._sendApiRequest(method, params ?? {})) : () => Promise.reject(/* @__PURE__ */ new Error("Agent API not available")));
6285
6666
  const lockJobs = await runCaptured(evalSink, async () => {
6286
6667
  const { module } = await loadWorkflowSource(workDir, config.source.file, config.contentHash, config.resolvedHashFiles);
6287
6668
  evalLog("Workflow loaded");
@@ -6471,7 +6852,9 @@ var init_job_runner = __esmMin((() => {
6471
6852
  */
6472
6853
  sendStepStatus(dispatch, stepIndex, stepName, state, data, logBytesStreamed) {
6473
6854
  const secretsAccessed = data?.secretsAccessed;
6474
- const { secretsAccessed: _, ...restData } = data ?? {};
6855
+ const concurrencyKind = data?.concurrencyKind;
6856
+ const groupId = data?.groupId;
6857
+ const { secretsAccessed: _s, concurrencyKind: _c, groupId: _g, ...restData } = data ?? {};
6475
6858
  const hasRestData = Object.keys(restData).length > 0;
6476
6859
  this.sendDirect({
6477
6860
  type: "step.status",
@@ -6484,6 +6867,8 @@ var init_job_runner = __esmMin((() => {
6484
6867
  timestamp: Date.now(),
6485
6868
  ...hasRestData && { data: restData },
6486
6869
  ...secretsAccessed !== void 0 && { secretsAccessed },
6870
+ ...concurrencyKind !== void 0 && { concurrencyKind },
6871
+ ...groupId !== void 0 && { groupId },
6487
6872
  ...logBytesStreamed !== void 0 && { logBytesStreamed }
6488
6873
  });
6489
6874
  }
@@ -6511,14 +6896,14 @@ var init_job_runner = __esmMin((() => {
6511
6896
  */
6512
6897
  init_console_capture();
6513
6898
  init_npm_resolver();
6514
- const AGENT_VERSION = "0.1.22";
6515
- const BUILD_COMMIT = "5afd16303";
6516
- const SDK_VERSION = "0.1.22";
6517
- const SDK_BUNDLE_HASH = "4b26a42978f77e29db110d47523ec947d3d673c3109fc48141c1a62814bde7a1";
6518
- const SHARED_VERSION = "0.1.22";
6519
- const SHARED_BUNDLE_HASH = "991385c024392c395d3eb8a68946ef8ed3fcba96f2652f21c3164a54eafa1b1b";
6520
- const ENGINE_VERSION = "0.1.22";
6521
- const ENGINE_BUNDLE_HASH = "ff74a3afb2b1db2870c03d47543642f64d04b660bebdcde6681f76d874f26757";
6899
+ const AGENT_VERSION = "0.1.24";
6900
+ const BUILD_COMMIT = "73592f67f";
6901
+ const SDK_VERSION = "0.1.24";
6902
+ const SDK_BUNDLE_HASH = "031712cf8cd02f483365113aad7e946da982110d544b177eb05edcc0d4421253";
6903
+ const SHARED_VERSION = "0.1.24";
6904
+ const SHARED_BUNDLE_HASH = "b977224129c767c4851458a795fa470264b2fc51255baf06cf14640e29e2f44c";
6905
+ const ENGINE_VERSION = "0.1.24";
6906
+ const ENGINE_BUNDLE_HASH = "734acca885cd70eed07a1a9426b08c04c07a9bf99484c18100d3d797b3eb8f39";
6522
6907
  initTelemetry({
6523
6908
  serviceName: "kici-agent",
6524
6909
  otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT