@kici-dev/agent 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/checkout/clone-job-repos.d.ts +53 -0
  2. package/dist/checkout/credential-helper-bin.d.ts +28 -0
  3. package/dist/checkout/credential-helper-host.d.ts +48 -0
  4. package/dist/checkout/credential-helper.d.ts +44 -0
  5. package/dist/checkout/git-clone.d.ts +26 -0
  6. package/dist/checkout/grant-table.d.ts +30 -0
  7. package/dist/checkout/job-git-credentials.d.ts +49 -0
  8. package/dist/checkout/write-elevation.d.ts +40 -0
  9. package/dist/config.d.ts +38 -0
  10. package/dist/container-ts-loader-hook.js +2047 -1814
  11. package/dist/execution/between-jobs-controller.d.ts +50 -0
  12. package/dist/execution/between-jobs-reset.d.ts +25 -0
  13. package/dist/execution/cleanup-rerun.d.ts +21 -0
  14. package/dist/execution/download.d.ts +27 -2
  15. package/dist/execution/dynamic-job-serializer.d.ts +6 -2
  16. package/dist/execution/generator-context.d.ts +54 -0
  17. package/dist/execution/global-eval-runner.d.ts +92 -0
  18. package/dist/execution/global-workflow-env.d.ts +57 -0
  19. package/dist/execution/image-build/build-engine.d.ts +75 -0
  20. package/dist/execution/image-build/build-step.d.ts +57 -0
  21. package/dist/execution/image-build/resolve-build-spec.d.ts +41 -0
  22. package/dist/execution/image-build/runtime-facts.d.ts +31 -0
  23. package/dist/execution/init-runner.d.ts +60 -3
  24. package/dist/execution/job-runner.d.ts +146 -0
  25. package/dist/execution/sandbox/bare-metal-sandbox.d.ts +25 -1
  26. package/dist/execution/sandbox/container-sandbox.d.ts +97 -2
  27. package/dist/execution/sandbox/fork-runner.d.ts +55 -1
  28. package/dist/execution/sandbox/image-preflight.d.ts +38 -0
  29. package/dist/execution/sandbox/ipc-protocol.d.ts +96 -2
  30. package/dist/execution/sandbox/kici-runtime.d.ts +48 -0
  31. package/dist/execution/sandbox/step-loop.d.ts +17 -1
  32. package/dist/execution/sandbox/types.d.ts +55 -1
  33. package/dist/execution/sandbox/workflow-runner.d.ts +75 -4
  34. package/dist/execution/workflow-loader.d.ts +8 -1
  35. package/dist/idle-shutdown.d.ts +24 -0
  36. package/dist/index.js +221 -80
  37. package/dist/metrics/prometheus.d.ts +36 -10
  38. package/dist/server.js +3197 -462
  39. package/dist/workflow-runner-bundle.js +42207 -41104
  40. package/dist/workflow-runner.js +594 -196
  41. package/dist/ws/orchestrator-client.d.ts +95 -3
  42. package/package.json +10 -10
  43. package/sbom.spdx.json +464 -454
@@ -1,5 +1,6 @@
1
- import type { Workflow } from '@kici-dev/sdk';
2
- import { type MatrixValues } from '@kici-dev/engine';
1
+ import type { $ as Shell } from 'zx';
2
+ import type { Workflow, RepoInfo } from '@kici-dev/sdk';
3
+ import { type ChangedFilesStatus, type MatrixValues } from '@kici-dev/engine';
3
4
  /**
4
5
  * Result of evaluating dynamic fields on a job.
5
6
  * Only fields that were flagged as dynamic and successfully resolved are set.
@@ -14,7 +15,56 @@ export interface InitResult {
14
15
  * The orchestrator re-materializes these into N execution jobs at dispatch.
15
16
  */
16
17
  matrixValues?: MatrixValues[];
18
+ /**
19
+ * Verdict of the workflow-level `filter`, set only when the init job was asked
20
+ * to evaluate one (`flags.hasFilter`). `false` means the workflow does not
21
+ * apply and its job must not be dispatched.
22
+ *
23
+ * Optional on purpose: an agent that predates the filter never sets it, so the
24
+ * orchestrator reads absence as "no verdict was reported", never as "suppress".
25
+ */
26
+ filterPassed?: boolean;
27
+ }
28
+ /**
29
+ * Everything a workflow-level `filter` needs beyond the workflow module and the
30
+ * event. Supplied by the caller because none of it is derivable here: the source
31
+ * tree lives wherever the init job materialized it, and the diff is ground truth
32
+ * from that same clone.
33
+ */
34
+ export interface FilterEvalInput {
35
+ /** The repo whose event triggered this evaluation. */
36
+ sourceRepo: RepoInfo;
37
+ /** The repo that registered the workflow — identical to `sourceRepo` here. */
38
+ workflowRepo: RepoInfo;
39
+ changedFiles: string[];
40
+ changedFilesStatus: ChangedFilesStatus;
41
+ env?: Record<string, string | undefined>;
42
+ /** zx shell handed to the filter. Defaults to the ambient `$`. */
43
+ $?: typeof Shell;
17
44
  }
45
+ /**
46
+ * Run a workflow's `filter` and report whether the workflow applies.
47
+ *
48
+ * Shared by both agent-side evaluation sites for a same-repo workflow: the init
49
+ * job that gates each static job's dispatch, and the dynamic-eval job that gates
50
+ * whether a generator runs at all. Both must reach the same verdict from the same
51
+ * inputs, so neither builds the context itself.
52
+ *
53
+ * The context is built through `createFilterContext` rather than as an object
54
+ * literal: the factory installs `changedFiles` as a throwing getter, so a filter
55
+ * that reads the diff on an event that has none fails loudly instead of seeing an
56
+ * empty list. A `false` verdict dispatches none of the workflow's own jobs, so a
57
+ * silently-empty diff would suppress it on a mistake. On this same-repo path the
58
+ * verdict is at least recoverable — the run row exists, carrying the `__init__*`
59
+ * jobs, and this evaluation's own step log records the verdict; it is the
60
+ * organization-wide path, which runs elsewhere, that leaves nothing behind.
61
+ *
62
+ * A throwing filter propagates: the evaluating job fails, which surfaces as a
63
+ * failed run. "Could not decide" is never treated as "do not run" — that would
64
+ * be a false green, the same reasoning `buildJobRuleCompletion` applies to a rule
65
+ * whose `check()` threw.
66
+ */
67
+ export declare function evaluateWorkflowFilter(workflow: Workflow, event: Record<string, unknown>, input: FilterEvalInput | undefined, timeoutMs: number): Promise<boolean>;
18
68
  /**
19
69
  * Evaluate dynamic fields (context, env, concurrencyGroup) on a job.
20
70
  *
@@ -26,16 +76,23 @@ export interface InitResult {
26
76
  * -: If a dynamic function returns undefined/null, the field is left undefined.
27
77
  * -: Each dynamic function call is wrapped in a timeout (default 60s).
28
78
  *
79
+ * A workflow-level `filter` is evaluated FIRST when `flags.hasFilter` is set. A
80
+ * `false` verdict returns immediately: no job of that workflow will be
81
+ * dispatched, so evaluating this one's dynamic fields would run customer code
82
+ * whose result nothing can consume.
83
+ *
29
84
  * @param workflow - The extracted Workflow object
30
85
  * @param jobName - Name of the job whose dynamic fields to evaluate
31
86
  * @param event - Normalized event envelope — same shape every dynamic-function call site receives.
32
87
  * @param flags - Which fields are dynamic and need evaluation
33
88
  * @param timeoutMs - Timeout per dynamic function call (default 60_000ms)
89
+ * @param filterInput - Source tree + diff the workflow's `filter` reads. Required when `flags.hasFilter`.
34
90
  */
35
91
  export declare function evaluateDynamicFields(workflow: Workflow, jobName: string, event: Record<string, unknown>, flags: {
36
92
  dynamicContext: boolean;
37
93
  dynamicEnv: boolean;
38
94
  dynamicConcurrencyGroup: boolean;
39
95
  dynamicMatrix?: boolean;
40
- }, timeoutMs?: number): Promise<InitResult>;
96
+ hasFilter?: boolean;
97
+ }, timeoutMs?: number, filterInput?: FilterEvalInput): Promise<InitResult>;
41
98
  //# sourceMappingURL=init-runner.d.ts.map
@@ -1,7 +1,70 @@
1
1
  import type { AgentToOrchestratorMessage, JobDispatch } from '@kici-dev/engine';
2
+ import type { LogStream } from '@kici-dev/engine';
2
3
  import type { AppConfig } from '../config.js';
4
+ import { type FilterEvalInput } from './init-runner.js';
3
5
  import { buildNeedsContext } from '@kici-dev/sdk';
6
+ import type { $ as Shell } from 'zx';
4
7
  import type { CacheRequestIpc, CacheResponseIpc, ProvenanceRequestIpc, ProvenanceResponseIpc, ArtifactRequestIpc, ArtifactResponseIpc, StepApprovalRequestIpc, StepApprovalResolvedIpc } from './sandbox/index.js';
8
+ import { BetweenJobsController } from './between-jobs-controller.js';
9
+ /**
10
+ * Materialize the source tree a non-global workflow's `filter` reads through
11
+ * `ctx.sourceRepo.path`.
12
+ *
13
+ * An init or dynamic-eval job normally restores only `.kici/` from the cached
14
+ * source tarball — enough to import the workflow module, but a directory with no
15
+ * repo in it. A filter that reads a file or shells out against that path would
16
+ * get a confidently wrong answer, and `changedFiles` could not be computed at
17
+ * all, so a filter-bearing job clones the source repo into a sibling directory.
18
+ *
19
+ * When no tarball was attached the job already cloned the whole repo into
20
+ * `workDir`, and that clone is reused rather than duplicated — including the
21
+ * local working-tree case, where there is no repo url and `workDir` IS the tree.
22
+ *
23
+ * A tarball with no repo url is the one combination that cannot be honoured:
24
+ * `workDir` holds `.kici/` alone and there is nothing to clone from. Returning it
25
+ * would hand the filter a directory in which every path test answers "absent" —
26
+ * the exact silent lie this function exists to prevent — so it throws instead.
27
+ */
28
+ export declare function ensureFilterSourceDir(dispatch: JobDispatch, workDir: string): Promise<string>;
29
+ /**
30
+ * Build the context a non-global workflow's `filter` is evaluated against.
31
+ *
32
+ * `sourceRepo` and `workflowRepo` are the same repo — that is what "non-global"
33
+ * means — so both carry the same identifier, path, ref, and sha. The zx shell is
34
+ * rooted at the source tree and streams into the evaluating step's log, matching
35
+ * what the global eval round hands its own filters.
36
+ *
37
+ * They are two distinct objects all the same. Being the same repo is a fact
38
+ * about their VALUES, not a licence to hand the author one object under two
39
+ * names: a filter that mutated `ctx.sourceRepo` would silently see
40
+ * `ctx.workflowRepo` change with it, which happens on no other path.
41
+ */
42
+ export declare function buildInitFilterInput(dispatch: JobDispatch, event: Record<string, unknown>, workDir: string, emit: (line: string, stream: LogStream) => void): Promise<FilterEvalInput>;
43
+ /**
44
+ * Build the per-invocation zx `$` a global eval round hands to filters and
45
+ * generators, so a `await $\`…\`` inside one is visible in the eval step's log.
46
+ *
47
+ * **`env` is the LIVE `process.env` reference, never a spread.** A spread is a
48
+ * snapshot taken when the shell is built, which is before the round applies the
49
+ * seven `KICI_*` keys — so a filter that shells out (`$\`printenv
50
+ * KICI_SOURCE_REPO_PATH\``, or any subprocess inheriting env) would see nothing
51
+ * here while the sandbox re-evaluation's ambient `$` resolves `process.env`
52
+ * after `setupGlobalWorkflowEnv` has run and does see them. That is the same
53
+ * two-worlds determinism failure the cwd choice below exists to prevent, one
54
+ * layer down. Passing the live reference reproduces the ambient `$`'s own
55
+ * behaviour, which is what the sandbox uses.
56
+ *
57
+ * `verbose: true` + `makeStreamingZxLog` honors a per-call `quiet: true`, so a
58
+ * decrypted secret never leaks into the log.
59
+ *
60
+ * `emit` is a callback rather than the `LogStreamer` itself so the caller can
61
+ * route it through its own closed-guard: `LogStreamer.destroy()` sets no closed
62
+ * flag and `addLine` buffers unconditionally, so a subprocess line arriving
63
+ * after the step was reported would otherwise emit a `log.chunk` for a terminal
64
+ * step. That is the likeliest path for it — an orphaned candidate is usually
65
+ * orphaned *because* it is waiting on a subprocess.
66
+ */
67
+ export declare function buildEvalShell(cwd: string, emit: (line: string, stream: LogStream) => void): Promise<typeof Shell>;
5
68
  /**
6
69
  * Dependencies injected into JobRunner.
7
70
  */
@@ -10,10 +73,17 @@ export interface JobRunnerDeps {
10
73
  send: (msg: AgentToOrchestratorMessage) => void;
11
74
  /** Agent config */
12
75
  config: AppConfig;
76
+ /**
77
+ * Supervisor-owned between-jobs phase, run after every job to reap the
78
+ * process tree, re-run declared cleanup out-of-band, delete the workdir, and
79
+ * run the operator reset command. Optional so unit harnesses can omit it.
80
+ */
81
+ betweenJobsController?: BetweenJobsController;
13
82
  /** Request a pre-signed S3 upload URL from the orchestrator via WS request-response. */
14
83
  requestUploadUrl: (jobId: string, cacheType: 'source' | 'deps', key: {
15
84
  contentHash?: string;
16
85
  lockfileHash?: string;
86
+ depsHash?: string;
17
87
  platform: string;
18
88
  arch: string;
19
89
  }) => Promise<string>;
@@ -182,6 +252,8 @@ export declare class JobRunner {
182
252
  private readonly _sendRunEvent;
183
253
  private readonly _sendConcurrencyReport;
184
254
  private readonly _sendApiRequest?;
255
+ /** Live git credentials per job, so the sandbox can reach the grant table. */
256
+ private readonly jobGitCredentials;
185
257
  private readonly _requestUserCache?;
186
258
  private readonly _relayProvenance?;
187
259
  private readonly _requestUserArtifact?;
@@ -190,6 +262,15 @@ export declare class JobRunner {
190
262
  readonly activeJobs: Map<string, ActiveJob>;
191
263
  /** Active sandbox for the current job (used for abort). */
192
264
  private activeSandbox;
265
+ /** Supervisor-owned between-jobs phase (reap / cleanup re-run / reset). */
266
+ private readonly betweenJobsController?;
267
+ /**
268
+ * Facts about the just-finished standard job, captured before sandbox
269
+ * teardown so the between-jobs controller (run in `execute`'s `.finally`) can
270
+ * reach them. Null for special job types (init / build / dynamic), which run
271
+ * no sandbox — the controller then only deletes the workdir and resets.
272
+ */
273
+ private betweenJobsFacts;
193
274
  constructor(deps: JobRunnerDeps);
194
275
  /**
195
276
  * Execute a dispatched job through its full lifecycle.
@@ -198,6 +279,23 @@ export declare class JobRunner {
198
279
  * reports status, and cleans up.
199
280
  */
200
281
  execute(dispatch: JobDispatch): Promise<void>;
282
+ /**
283
+ * Stand up per-job git credentials, or `undefined` when the agent has no
284
+ * orchestrator relay (unit harnesses, offline local runs).
285
+ *
286
+ * A failure here must not fail the job: without a helper, git falls back to
287
+ * its own mechanisms exactly as it did before this existed. The job simply
288
+ * cannot push.
289
+ */
290
+ private startGitCredentials;
291
+ /**
292
+ * Run the supervisor-owned between-jobs phase after a job finishes. Delegates
293
+ * to the injected `BetweenJobsController` (out-of-band cleanup → reap →
294
+ * workdir delete → operator reset) with facts captured before sandbox
295
+ * teardown. When no controller is wired (unit harnesses) it just deletes the
296
+ * workdir, preserving the historical behavior. Never throws.
297
+ */
298
+ private runBetweenJobsPhase;
201
299
  /**
202
300
  * Cancel a running job by signaling its abort controller
203
301
  * and aborting the active sandbox.
@@ -276,6 +374,18 @@ export declare class JobRunner {
276
374
  * uncommitted changes).
277
375
  */
278
376
  private cloneAndApplyOverlay;
377
+ /**
378
+ * Materialize the init job's workflow source into `workDir`. A test run ships
379
+ * its full working tree as an encrypted overlay tarball (`fullRepo`) rather
380
+ * than a git repo, so skip the clone and let the overlay populate the
381
+ * workspace — the same handling the normal execution-job path uses. Otherwise
382
+ * restore from the cached tarball if present, else clone. In every case, apply
383
+ * an attached overlay tarball afterward (test runs with uncommitted changes;
384
+ * for a fullRepo run this is what actually populates the workspace, so the
385
+ * init job resolves a dynamic context against the real source tree instead of
386
+ * an empty directory).
387
+ */
388
+ private materializeInitJobSource;
279
389
  /**
280
390
  * Phase 2 of build: install dependencies locally if needed for the build,
281
391
  * and (when the orchestrator has flagged the dep cache as stale) pack
@@ -288,6 +398,30 @@ export declare class JobRunner {
288
398
  * it to the source cache.
289
399
  */
290
400
  private packAndUploadSource;
401
+ /**
402
+ * Clone both repos for a global eval round and materialize the workflow
403
+ * repo's dependencies, mirroring the sandbox's own dual-clone: the workflow
404
+ * repo under `<workDir>/workflow`, the source repo under `<workDir>/source`.
405
+ *
406
+ * `.kici/` lives in the WORKFLOW repo for a global workflow, so deps and the
407
+ * scratch-dir git exclude both apply to that checkout, never the source one.
408
+ */
409
+ private checkoutForGlobalEvalRound;
410
+ /**
411
+ * Handle a pre-run global eval round.
412
+ *
413
+ * The round runs once per (event × workflow repo) BEFORE any run row exists:
414
+ * it checks out the workflow repo and the source repo, then runs each
415
+ * candidate global workflow's `filter` and — for a survivor — its
416
+ * `DynamicJobFn`s, so the orchestrator learns which workflows apply to this
417
+ * source repo and which jobs each one generates.
418
+ *
419
+ * A candidate that fails is reported indeterminate inside the result, not as
420
+ * a job failure: the round carries several unrelated org-wide workflows, and
421
+ * one broken filter must not suppress the rest. The job itself fails only
422
+ * when the checkout or the round machinery breaks.
423
+ */
424
+ private handleGlobalEvalRound;
291
425
  /**
292
426
  * Handle an init-only job.
293
427
  *
@@ -309,6 +443,18 @@ export declare class JobRunner {
309
443
  * and sends the result back to the orchestrator.
310
444
  */
311
445
  private handleDynamicJobFn;
446
+ /**
447
+ * Build the job's container image when it declared a Dockerfile, and return
448
+ * the tag the sandbox must run.
449
+ *
450
+ * Returns `undefined` for every other job — one with no container, or one
451
+ * naming a finalized image — which is the common case and costs nothing.
452
+ *
453
+ * Extracted from `setupSandboxForExecution` so that function stays inside the
454
+ * 200-line ceiling, and so the build's streamer lifecycle is visibly bounded
455
+ * by one `finally`.
456
+ */
457
+ private buildJobImageIfDeclared;
312
458
  /**
313
459
  * Create the appropriate sandbox backend based on execution mode.
314
460
  */
@@ -31,6 +31,11 @@ interface BareMetalSandboxOptions {
31
31
  sandboxNetwork?: 'isolated' | 'host';
32
32
  /** Pre-sanitized environment variables (system allowlist + user env). */
33
33
  env: Record<string, string>;
34
+ /**
35
+ * When true (and bwrap is off), spawn the runner in its own process group so
36
+ * the between-jobs phase can reap a backgrounded daemon. Default true.
37
+ */
38
+ orphanCleanup?: boolean;
34
39
  }
35
40
  /**
36
41
  * Bare-metal execution sandbox implementation.
@@ -43,8 +48,10 @@ export declare class BareMetalSandbox implements ExecutionSandbox {
43
48
  private readonly useBwrap;
44
49
  private readonly sandboxNetwork;
45
50
  private readonly env;
51
+ private readonly orphanCleanup;
46
52
  private runner;
47
53
  private workDir;
54
+ private lastOptions;
48
55
  constructor(options: BareMetalSandboxOptions);
49
56
  /**
50
57
  * Validate that the runner path exists and bwrap is available (if needed).
@@ -61,9 +68,26 @@ export declare class BareMetalSandbox implements ExecutionSandbox {
61
68
  */
62
69
  abort(): Promise<void>;
63
70
  /**
64
- * Clean up the child process if still running.
71
+ * Clean up the child process if still running. The handle reference is kept
72
+ * (not nulled) so the between-jobs phase can still read `completionHooksRan` /
73
+ * `declaresCleanup` and reap the process group after teardown — the group
74
+ * survives the single child's death. The next `executeJob` overwrites it.
65
75
  */
66
76
  teardown(): Promise<void>;
77
+ /** Whether the runner signalled its completion hooks ran. */
78
+ get completionHooksRan(): boolean;
79
+ /** Whether the job declared an onFailure / cleanup hook. */
80
+ get declaresCleanup(): boolean;
81
+ /** Reap the finished job's process group. */
82
+ reap(): Promise<number>;
83
+ /**
84
+ * Re-run the finished job's declared cleanup / onFailure hooks against the
85
+ * preserved workdir, in a fresh cleanup-only child. Reuses the last job's
86
+ * dispatch + orchestrator relays but suppresses step/log callbacks (the
87
+ * original job already reported and its log streamers are gone). Rejects when
88
+ * the cleanup-only child fails so the caller can time it out.
89
+ */
90
+ runCleanupOnly(workDir: string, signal: AbortSignal): Promise<void>;
67
91
  }
68
92
  export {};
69
93
  //# sourceMappingURL=bare-metal-sandbox.d.ts.map
@@ -12,8 +12,11 @@
12
12
  * - Agent-internal credentials (KICI_*, KICI_DATABASE_URL, etc.) NEVER enter the container
13
13
  * - IPC uses demuxed Docker stream with JSON-line parsing on stdout
14
14
  *
15
- * The container image MUST have Node.js installed (a kici/runner base image
16
- * is deferred -- for now this is a documented requirement).
15
+ * The container image does NOT need Node.js or git. KiCI provisions its own
16
+ * runtime a pinned, official glibc-2.17 Node plus the runner bundle, mounted
17
+ * read-only at /opt/kici — and launches the runner with THAT node. The image
18
+ * needs only a glibc and a shell, which the preflight asserts before the
19
+ * container is created.
17
20
  */
18
21
  import Docker from 'dockerode';
19
22
  import type { ExecutionSandbox, SandboxSetupOptions, JobExecutionOptions, JobExecutionResult } from './types.js';
@@ -27,6 +30,51 @@ interface ContainerSandboxOptions {
27
30
  runnerPath: string;
28
31
  /** Mount target inside container (default: /opt/kici/workflow-runner.js). */
29
32
  runnerMountPath?: string;
33
+ /**
34
+ * Pre-provisioned KiCI Node tree (the directory whose `bin/node` is the
35
+ * injected runtime), bind-mounted read-only at `/opt/kici/node`. A HOST
36
+ * path, or the name of a volume already holding the tree.
37
+ *
38
+ * When supplied, the runner launches on THAT node and the image needs no
39
+ * Node of its own. Wins over `runtimeImage` — a caller that already has the
40
+ * tree should not pay a materialization to get the same one.
41
+ */
42
+ runtimeNodePath?: string;
43
+ /**
44
+ * KiCI agent image carrying `/opt/kici`, from which the Node tree is
45
+ * materialized into a named volume during setup.
46
+ *
47
+ * This is how an agent nesting a job container gets a runtime: a bind mount
48
+ * needs a HOST path, and the agent may itself be containerized, so it cannot
49
+ * assume `/opt/kici` exists on the host filesystem. Copying the tree out of
50
+ * the image into a volume once, then mounting that volume, works either way.
51
+ *
52
+ * Absent (and no `runtimeNodePath`) means no injection: the runner falls
53
+ * back to the image's own `node`, which is the historical contract and still
54
+ * correct for an image that ships one. Both modes are correct; the fallback
55
+ * is not a workaround for a missing mount but the mode a caller that has no
56
+ * runtime to inject is in.
57
+ */
58
+ runtimeImage?: string;
59
+ /**
60
+ * Tag this sandbox's image was BUILT under, when the job declared a
61
+ * Dockerfile rather than naming an image.
62
+ *
63
+ * Present only for a built image, and removed at teardown — a tag per run
64
+ * would otherwise accumulate on the host forever. Removing the tag leaves the
65
+ * layer cache untouched, which is what makes the next build fast, so this
66
+ * costs nothing but the name.
67
+ */
68
+ buildTag?: string;
69
+ /**
70
+ * Registry credentials for pulling `image`, already resolved by the
71
+ * orchestrator. Absent means an anonymous pull.
72
+ */
73
+ registryAuth?: {
74
+ username: string;
75
+ password: string;
76
+ serveraddress: string;
77
+ };
30
78
  /**
31
79
  * Path to the pure-JS container loader-hook bundle on the HOST (bind-mounted
32
80
  * read-only). Defaults to `container-ts-loader-hook.js` next to `runnerPath`.
@@ -51,6 +99,17 @@ export declare class ContainerSandbox implements ExecutionSandbox {
51
99
  private readonly docker;
52
100
  private readonly image;
53
101
  private readonly runnerPath;
102
+ private readonly runtimeNodePath;
103
+ private readonly runtimeImage;
104
+ private readonly buildTag;
105
+ /**
106
+ * The runtime actually injected into this job's container — the configured
107
+ * path, or the volume materialized during setup. Resolved once in setup()
108
+ * because materialization needs the container runtime, and read by both the
109
+ * bind list and the runner launch.
110
+ */
111
+ private resolvedRuntimeNode;
112
+ private readonly registryAuth;
54
113
  private readonly runnerMountPath;
55
114
  /** Host path to the pure-JS container loader-hook bundle (bind-mounted :ro). */
56
115
  private readonly hookHostPath;
@@ -81,7 +140,35 @@ export declare class ContainerSandbox implements ExecutionSandbox {
81
140
  * private images pre-pulled with registry auth stay working) skip the pull.
82
141
  */
83
142
  private ensureImagePresent;
143
+ /**
144
+ * Resolve the Node tree to inject into the job container, materializing it
145
+ * when only an image was configured.
146
+ *
147
+ * A configured path wins: a caller that already provisioned the tree should
148
+ * not pay a copy to arrive at the same one. Returning `undefined` is a real
149
+ * outcome, not a failure — an agent with no runtime source runs the job on
150
+ * the image's own `node`, which is what a `node:*` image was always doing.
151
+ *
152
+ * A materialization that FAILS is not softened into that outcome. Continuing
153
+ * would start the job against an image the operator never claimed ships Node,
154
+ * and the resulting "node: not found" says nothing about the runtime that was
155
+ * supposed to be there.
156
+ */
157
+ private resolveRuntimeNode;
84
158
  setup(options: SandboxSetupOptions): Promise<void>;
159
+ /**
160
+ * Populate the container's `/workspace` volume from the host working tree.
161
+ *
162
+ * `/workspace` is a container-owned anonymous volume rather than a host bind,
163
+ * which is what dissolves the host-uid vs container-uid conflict once
164
+ * `CapDrop: ['ALL']` removes CAP_DAC_OVERRIDE — so the tree is streamed in as
165
+ * a tar rather than mounted.
166
+ *
167
+ * A failure here is fatal on purpose. Swallowing it would start the job
168
+ * against an EMPTY workspace, which surfaces as a baffling "file not found"
169
+ * in whichever step happens to touch the repo first.
170
+ */
171
+ private copyWorkspaceIn;
85
172
  /**
86
173
  * Build the container's read-only bind list.
87
174
  *
@@ -133,6 +220,14 @@ export declare class ContainerSandbox implements ExecutionSandbox {
133
220
  */
134
221
  private buildExecutionResult;
135
222
  abort(): Promise<void>;
223
+ /**
224
+ * Drop the tag a `container.dockerfile` build produced.
225
+ *
226
+ * Best-effort: teardown must not fail a job that already finished. The LAYER
227
+ * cache — the thing that makes the next build fast — is not a tag and is
228
+ * untouched by this.
229
+ */
230
+ private reclaimBuiltImage;
136
231
  teardown(): Promise<void>;
137
232
  /**
138
233
  * Send the execute request to the workflow runner via the exec's stdin.
@@ -9,6 +9,21 @@ import { type ChildProcess } from 'node:child_process';
9
9
  import type { JobDispatch } from '@kici-dev/engine';
10
10
  import type { JobExecutionOptions, JobExecutionResult } from './types.js';
11
11
  import type { JobExecutionRequest } from './ipc-protocol.js';
12
+ /**
13
+ * Best-effort SIGKILL/SIGTERM of an entire process group led by `pid`.
14
+ * `process.kill(-pid, signal)` targets the group whose leader is `pid` (the
15
+ * child was spawned `detached`, so its pid is its group id). Returns 1 when the
16
+ * group was signalled, 0 when it was already gone (ESRCH) or the pid is invalid.
17
+ * We cannot cheaply count members, so the caller treats a non-zero return as
18
+ * "reap attempted".
19
+ */
20
+ export declare function killProcessGroup(pid: number | undefined, signal: NodeJS.Signals): number;
21
+ /**
22
+ * SIGTERM the job's process group, wait a short grace, then SIGKILL. No-op
23
+ * (returns 0) when the child was not spawned detached or has no pid. Returns the
24
+ * count of reap attempts that signalled a live group (0, 1, or 2).
25
+ */
26
+ export declare function reapGroup(pid: number | undefined, detached: boolean, killFn?: (p: number | undefined, s: NodeJS.Signals) => number, sleepMs?: number): Promise<number>;
12
27
  /** Options for creating a fork-based runner. */
13
28
  interface ForkRunnerOptions {
14
29
  /** Absolute path to the compiled workflow-runner.js. */
@@ -39,6 +54,18 @@ interface ForkRunnerOptions {
39
54
  * Defaults to 30_000 (30 seconds).
40
55
  */
41
56
  maxGracePeriodMs?: number;
57
+ /**
58
+ * Spawn the runner child `detached` so it leads its own process group. Set by
59
+ * the bare-metal backend when orphan cleanup is on and bwrap is off, so the
60
+ * between-jobs phase can reap a backgrounded daemon that reparented to init.
61
+ * Ignored under bwrap (its PID namespace already contains the tree).
62
+ */
63
+ detachProcessGroup?: boolean;
64
+ /**
65
+ * Between-jobs out-of-band cleanup re-run: the runner reuses the preserved
66
+ * workdir and runs only the job's declared cleanup / onFailure hooks.
67
+ */
68
+ cleanupOnly?: boolean;
42
69
  }
43
70
  /** State of a running fork-based child process. */
44
71
  export interface ForkRunnerHandle {
@@ -59,13 +86,40 @@ export interface ForkRunnerHandle {
59
86
  * Capped by the agent's maxGracePeriodMs.
60
87
  */
61
88
  cancel: (force: boolean, gracePeriodMs?: number) => void;
89
+ /**
90
+ * Whether this runner leads its own detached process group (bare-metal,
91
+ * non-bwrap, orphan cleanup on). Drives whether `reap()` does anything.
92
+ */
93
+ detached: boolean;
94
+ /**
95
+ * True once the runner emitted `completion-hooks-done` (its onSuccess /
96
+ * onFailure / cleanup hooks ran). Stays false when the child exits before
97
+ * signalling, which is the between-jobs phase's cue to re-run declared
98
+ * cleanup out-of-band.
99
+ */
100
+ completionHooksRan: boolean;
101
+ /**
102
+ * True once the runner reported (via `hooks-declared`) that the job declares
103
+ * an `onFailure` / `cleanup` hook. Recorded early so it survives a later hard
104
+ * kill; gates whether the out-of-band cleanup re-run is attempted.
105
+ */
106
+ declaresCleanup: boolean;
107
+ /**
108
+ * SIGTERM → grace → SIGKILL the runner's process group, reaping any daemon it
109
+ * backgrounded. No-op (resolves 0) when the runner is not detached; returns
110
+ * the number of reap attempts that signalled a live group.
111
+ */
112
+ reap: () => Promise<number>;
62
113
  }
63
114
  /**
64
115
  * Build a JobExecutionRequest from a JobDispatch.
65
116
  *
66
117
  * Maps orchestrator dispatch fields to the subset needed by the workflow runner.
67
118
  */
68
- export declare function buildRequest(dispatch: JobDispatch, workDir: string): JobExecutionRequest;
119
+ export declare function buildRequest(dispatch: JobDispatch, workDir: string, extra?: {
120
+ cleanupOnly?: boolean;
121
+ credentialHelperPath?: string;
122
+ }): JobExecutionRequest;
69
123
  /**
70
124
  * Build bubblewrap (bwrap) arguments for namespace isolation.
71
125
  *
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Fail a container job on an unusable image BEFORE the job starts.
3
+ *
4
+ * KiCI injects its own runtime (a pinned official glibc-2.17 Node) into the
5
+ * customer's image, so the image needs only a glibc and a shell. When it has
6
+ * neither, the container runtime's own error is close to useless — a musl image
7
+ * reports
8
+ *
9
+ * exec container process (missing dynamic library?) `/opt/kici/node/bin/node`:
10
+ * No such file or directory
11
+ *
12
+ * which names a file that plainly exists and says nothing about musl. The
13
+ * preflight turns that into a sentence an author can act on, and it fires
14
+ * before any step runs rather than partway through a job.
15
+ *
16
+ * glibc-only is the deliberate scope of this version; a musl runtime variant is
17
+ * a documented follow-up.
18
+ */
19
+ import type Docker from 'dockerode';
20
+ /** What an image's rootfs says about whether we can run our runtime in it. */
21
+ export type ImageLibc = 'glibc' | 'musl' | 'static' | 'no-shell';
22
+ /** Every path the preflight stats in the image. */
23
+ export declare const PROBE_PATHS: readonly string[];
24
+ /**
25
+ * Classify an image from the subset of {@link PROBE_PATHS} that exist in it.
26
+ *
27
+ * Pure, so the decision table is testable without a container runtime.
28
+ */
29
+ export declare function classifyImageLibc(presentPaths: readonly string[]): ImageLibc;
30
+ /**
31
+ * Throw unless `image` can host the injected runtime.
32
+ *
33
+ * Stats the probe paths through a created-but-never-started container, so a
34
+ * shell-less or musl image is diagnosed without executing anything in it —
35
+ * running a probe command would fail for the very reason we are testing for.
36
+ */
37
+ export declare function assertImageRunnable(docker: Docker, image: string): Promise<void>;
38
+ //# sourceMappingURL=image-preflight.d.ts.map