@kici-dev/compiler 0.1.27 → 0.2.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 (153) hide show
  1. package/dist/auth/headless-detect.d.ts +14 -5
  2. package/dist/auth/headless-detect.js +50 -9
  3. package/dist/cli.js +120 -11
  4. package/dist/commands/compile.js +28 -4
  5. package/dist/commands/diagnostics.js +14 -2
  6. package/dist/commands/doctor.d.ts +64 -0
  7. package/dist/commands/doctor.js +413 -0
  8. package/dist/commands/endpoints.js +6 -0
  9. package/dist/commands/fixture.js +13 -0
  10. package/dist/commands/hook.js +3 -2
  11. package/dist/commands/index.d.ts +8 -0
  12. package/dist/commands/index.js +5 -1
  13. package/dist/commands/init-host-os.d.ts +34 -0
  14. package/dist/commands/init-host-os.js +44 -0
  15. package/dist/commands/init.d.ts +4 -0
  16. package/dist/commands/init.js +159 -36
  17. package/dist/commands/local.d.ts +28 -2
  18. package/dist/commands/local.js +74 -5
  19. package/dist/commands/login.js +1 -1
  20. package/dist/commands/notifications.d.ts +61 -0
  21. package/dist/commands/notifications.js +274 -0
  22. package/dist/commands/preview.js +8 -1
  23. package/dist/commands/run-routed.js +10 -1
  24. package/dist/commands/run.js +41 -12
  25. package/dist/commands/runs/artifacts/download.d.ts +24 -0
  26. package/dist/commands/runs/artifacts/download.js +154 -0
  27. package/dist/commands/runs/artifacts/list.d.ts +5 -0
  28. package/dist/commands/runs/artifacts/list.js +47 -0
  29. package/dist/commands/runs/cancel.js +2 -2
  30. package/dist/commands/runs/list.js +8 -2
  31. package/dist/commands/verify-attestation.d.ts +3 -1
  32. package/dist/commands/verify-attestation.js +18 -7
  33. package/dist/errors/formatter.d.ts +14 -1
  34. package/dist/errors/formatter.js +13 -3
  35. package/dist/errors/index.d.ts +2 -1
  36. package/dist/errors/index.js +3 -2
  37. package/dist/errors/source-location.d.ts +20 -0
  38. package/dist/errors/source-location.js +45 -0
  39. package/dist/execution/executor.js +25 -5
  40. package/dist/fixtures/defaults/index.js +2 -1
  41. package/dist/llm-context/llms-architecture.txt +48 -19
  42. package/dist/llm-context/llms-cli.txt +2461 -1582
  43. package/dist/llm-context/llms-features.txt +178 -106
  44. package/dist/llm-context/llms-full.txt +6124 -4423
  45. package/dist/llm-context/llms-getting-started.txt +306 -42
  46. package/dist/llm-context/llms-patterns.txt +13 -7
  47. package/dist/llm-context/llms-sdk-runtime.txt +2013 -0
  48. package/dist/llm-context/llms-sdk.txt +591 -2152
  49. package/dist/llm-context/llms.txt +30 -16
  50. package/dist/local-plane/orchestrator-process.d.ts +12 -7
  51. package/dist/local-plane/orchestrator-process.js +28 -14
  52. package/dist/local-plane/os-mismatch-hint.d.ts +16 -0
  53. package/dist/local-plane/os-mismatch-hint.js +34 -0
  54. package/dist/local-plane/plane-liveness.d.ts +68 -0
  55. package/dist/local-plane/plane-liveness.js +145 -0
  56. package/dist/local-plane/plane-manager.d.ts +60 -18
  57. package/dist/local-plane/plane-manager.js +210 -70
  58. package/dist/local-plane/plane-trigger.d.ts +13 -4
  59. package/dist/local-plane/plane-trigger.js +27 -14
  60. package/dist/local-plane/platform-attach.js +1 -5
  61. package/dist/local-plane/port-holder.d.ts +78 -0
  62. package/dist/local-plane/port-holder.js +227 -0
  63. package/dist/local-plane/postgres.d.ts +15 -3
  64. package/dist/local-plane/postgres.js +34 -12
  65. package/dist/local-plane/run-follow.d.ts +20 -0
  66. package/dist/local-plane/run-follow.js +51 -4
  67. package/dist/local-plane/scaler-config.d.ts +28 -14
  68. package/dist/local-plane/scaler-config.js +59 -41
  69. package/dist/local-plane/source-provider.js +4 -10
  70. package/dist/lockfile/generator.d.ts +14 -1
  71. package/dist/lockfile/generator.js +99 -33
  72. package/dist/lockfile/index.d.ts +3 -1
  73. package/dist/lockfile/index.js +3 -2
  74. package/dist/lockfile/purity-diagnostics.d.ts +31 -0
  75. package/dist/lockfile/purity-diagnostics.js +52 -0
  76. package/dist/postinstall.js +2 -1
  77. package/dist/remote/artifact-extract.d.ts +8 -0
  78. package/dist/remote/artifact-extract.js +58 -0
  79. package/dist/remote/config.d.ts +21 -3
  80. package/dist/remote/config.js +24 -5
  81. package/dist/remote/dashboard-client.d.ts +39 -2
  82. package/dist/remote/dashboard-client.js +47 -4
  83. package/dist/remote/fs-case.d.ts +21 -0
  84. package/dist/remote/fs-case.js +45 -0
  85. package/dist/remote/history.d.ts +9 -3
  86. package/dist/remote/history.js +79 -10
  87. package/dist/remote/local-repo-identity.d.ts +1 -1
  88. package/dist/remote/local-repo-identity.js +2 -1
  89. package/dist/remote/notifications-client.d.ts +149 -0
  90. package/dist/remote/notifications-client.js +103 -0
  91. package/dist/remote/oauth.d.ts +13 -0
  92. package/dist/remote/oauth.js +31 -5
  93. package/dist/remote/output/summary.js +76 -14
  94. package/dist/remote/render.js +30 -9
  95. package/dist/remote/uploader.js +5 -4
  96. package/dist/templates/agents-md.d.ts +1 -1
  97. package/dist/templates/agents-md.js +6 -6
  98. package/dist/templates/index.d.ts +1 -1
  99. package/dist/templates/index.js +2 -2
  100. package/dist/templates/package-json.d.ts +15 -0
  101. package/dist/templates/package-json.js +26 -5
  102. package/dist/test-runner/dry-run.d.ts +2 -1
  103. package/dist/test-runner/dry-run.js +18 -1
  104. package/dist/test-runner/event-types.d.ts +2 -0
  105. package/dist/test-runner/event-types.js +3 -0
  106. package/dist/test-runner/job-executor.d.ts +1 -0
  107. package/dist/test-runner/job-executor.js +81 -43
  108. package/dist/test-runner/output-formatter.d.ts +5 -2
  109. package/dist/test-runner/output-formatter.js +6 -4
  110. package/dist/test-runner/payload-builder.d.ts +7 -1
  111. package/dist/test-runner/payload-builder.js +11 -2
  112. package/dist/test-runner/rule-evaluator.d.ts +7 -3
  113. package/dist/test-runner/rule-evaluator.js +13 -12
  114. package/dist/test-runner/step-context.d.ts +23 -5
  115. package/dist/test-runner/step-context.js +28 -118
  116. package/dist/types.d.ts +21 -2
  117. package/dist/types.js +4 -2
  118. package/dist/validation/index.d.ts +2 -0
  119. package/dist/validation/index.js +2 -1
  120. package/dist/validation/typecheck.d.ts +28 -0
  121. package/dist/validation/typecheck.js +110 -0
  122. package/dist/validation/validator.d.ts +9 -4
  123. package/dist/validation/validator.js +38 -44
  124. package/package.json +12 -15
  125. package/sbom.spdx.json +1322 -1964
  126. package/dist/assets/api-TJJVHrjC.json +0 -118
  127. package/dist/assets/descriptor-BTtjzN9L.json +0 -1382
  128. package/dist/assets/package-BpQF9kR8.json +0 -74
  129. package/dist/assets/package-Ceo2h27X.json +0 -89
  130. package/dist/assets/source_context-D0atuL28.json +0 -20
  131. package/dist/assets/type-BFqO8SCZ.json +0 -202
  132. package/dist/commands/cancel.d.ts +0 -22
  133. package/dist/commands/detect-package-manager.d.ts +0 -42
  134. package/dist/commands/held-run-resolve.d.ts +0 -50
  135. package/dist/commands/status.d.ts +0 -34
  136. package/dist/commands/test.d.ts +0 -88
  137. package/dist/errors/codes.d.ts +0 -25
  138. package/dist/local-executor/dag-scheduler.d.ts +0 -44
  139. package/dist/local-executor/index.d.ts +0 -23
  140. package/dist/local-executor/job-runner.d.ts +0 -47
  141. package/dist/local-executor/materializer.d.ts +0 -44
  142. package/dist/local-executor/output-streamer.d.ts +0 -31
  143. package/dist/local-executor/payload-generator.d.ts +0 -16
  144. package/dist/local-executor/picker.d.ts +0 -33
  145. package/dist/local-executor/runs-on-display.d.ts +0 -9
  146. package/dist/local-executor/secret-loader.d.ts +0 -18
  147. package/dist/local-executor/to-event-payload.d.ts +0 -16
  148. package/dist/local-executor/types.d.ts +0 -93
  149. package/dist/local-executor/workflow-lock.d.ts +0 -82
  150. package/dist/package-F7UXSDHW.json +0 -74
  151. package/dist/remote/client.d.ts +0 -210
  152. package/dist/remote/observer.d.ts +0 -81
  153. package/dist/test-runner/summary.d.ts +0 -7
@@ -0,0 +1,2013 @@
1
+ # KiCI SDK reference: runtime and advanced
2
+
3
+ This bundle covers: Runtime and advanced authoring: step runtime context, event payloads, host fan-out, idempotent steps, caching, artifacts, waiting.
4
+
5
+ ## Artifacts
6
+
7
+ Source: https://docs.kici.dev/user/sdk/artifacts/
8
+
9
+ An **artifact** is a named, durable build output — a compiled binary, a bundle, a report — that one job produces and a later job (or a human) consumes. `ctx.artifacts.upload(name, paths)` packs the given files and stores them under a name; `ctx.artifacts.download(name, destDir?)` retrieves them in a later job of the same run. Uploaded artifacts also appear on the run detail page, where anyone with access can download them.
10
+
11
+ ```ts
12
+ import { workflow, job } from '@kici-dev/sdk';
13
+
14
+ const build = job('build', {
15
+ runsOn: 'kici:os:linux',
16
+ run: async (ctx) => {
17
+ await ctx.$`npm run build`;
18
+ // Pack ./dist and upload it as the "app" artifact.
19
+ await ctx.artifacts.upload('app', ['dist']);
20
+ },
21
+ });
22
+
23
+ const publish = job('publish', {
24
+ runsOn: 'kici:os:linux',
25
+ needs: [build],
26
+ run: async (ctx) => {
27
+ // Download what `build` produced, into the working directory.
28
+ await ctx.artifacts.download('app');
29
+ await ctx.$`ls dist && ./scripts/publish.sh`;
30
+ },
31
+ });
32
+
33
+ export default workflow('release', {
34
+ on: [/* triggers */],
35
+ jobs: [build, publish],
36
+ });
37
+ ```
38
+
39
+ ## The API
40
+
41
+ ```ts
42
+ interface ArtifactsApi {
43
+ /** Pack `paths` and upload them as `name`. Returns the packed size + sha256. */
44
+ upload(name: string, paths: string[]): Promise<{ size: number; sha256: string }>;
45
+ /** Download `name` (uploaded by an earlier job of this run) into destDir
46
+ * (default: the step's working directory). Returns the size + sha256. */
47
+ download(name: string, destDir?: string): Promise<{ size: number; sha256: string }>;
48
+ }
49
+ ```
50
+
51
+ - **`name`** is a short token of letters, digits, `.`, `_`, and `-` (up to 128 chars), and cannot be made only of dots. It is how a downstream job addresses the artifact. A name that breaks the rule fails the step with the reason — `invalid artifact name`, plus which part of the rule it broke — rather than being quietly rewritten. The orchestrator enforces the same rule when it receives the upload and reports it with the same sentence, so the error reads the same whichever side caught it.
52
+ - **`paths`** are repo-root-relative or `~`-prefixed, exactly like [cache paths](https://docs.kici.dev/user/sdk/caching/) — the same packing, path-safety, and multi-root anchoring apply. Absolute paths and `..` escapes are rejected.
53
+ - **`destDir`** on download defaults to the step's working directory; pass an explicit directory to extract elsewhere.
54
+
55
+ Both methods verify the content hash end to end: the SHA-256 computed at upload is checked again when the tarball is downloaded, so a corrupted transfer fails loudly.
56
+
57
+ `upload()` returns only once the orchestrator confirms it recorded the artifact. If that commit cannot be completed — the uploaded object never landed, or the orchestrator's storage or database is unreachable long enough for its retries to run out — the step fails with that reason instead of returning successfully. So a green upload step always means a downstream job can download the artifact. The reason names which kind of failure it was, not the orchestrator's internal error text — an internal commit failure is one to take to whoever runs the orchestrator, who can read the details in its logs.
58
+
59
+ A connection blip while that confirmation is in flight does not fail the step. Recording the artifact is idempotent, so the agent re-sends the confirmation once the connection is back and waits for the answer, within the same overall deadline it already had. If the connection never comes back in time, the step still fails — and says the artifact may nevertheless have been recorded, so you know to check the run's artifacts before assuming the upload was lost.
60
+
61
+ ## Immutable per run
62
+
63
+ The **first upload of a name within a run wins.** A second `upload('app', ...)` in the same run — from a retry, a parallel writer, or a copy-paste — fails with a clear error rather than silently overwriting the first. This makes artifacts a deterministic contract between jobs: once `build` has uploaded `app`, every downstream job that downloads `app` gets exactly those bytes.
64
+
65
+ Downloading a name that was never uploaded in the run throws a not-found error.
66
+
67
+ ## Viewing artifacts in the dashboard
68
+
69
+ Every artifact a run uploads is listed on the run detail page under the **Artifacts** tab, with its name, the job that produced it, size, content hash, and creation time. Anyone with read access to the run can download an artifact directly from there — a link straight to the stored object, so the bytes never pass through the control plane.
70
+
71
+ <!-- help:run-artifacts#viewing-artifacts-in-the-dashboard -->
72
+
73
+ The **Artifacts** tab lists every named build artifact this run uploaded with `ctx.artifacts.upload`.
74
+
75
+ Each row shows the artifact name, the job that produced it, its size, content hash, and creation time.
76
+
77
+ Use **Download** to fetch an artifact directly — the link points straight at the stored object. Artifacts expire after the orchestrator's configured retention, after which they no longer appear here.
78
+ <!-- /help:run-artifacts -->
79
+
80
+ ## From the CLI
81
+
82
+ You can also list and fetch a run's artifacts from a terminal:
83
+
84
+ - `kici runs artifacts list <run-id>` — the same rows the dashboard **Artifacts** tab shows (`--json` for machine-readable output).
85
+ - `kici runs artifacts download <run-id> [name]` — download one artifact, or every artifact of the run if you omit the name. Each one extracts into its own `<name>/` directory; `--archive` keeps the raw `.tar.gz` and `-o <dir>` sets the target directory.
86
+
87
+ Like the dashboard download, the bytes stream straight from object storage over a short-lived signed URL and never pass through the control plane — and the CLI verifies the content hash end to end. See the [CLI reference](https://docs.kici.dev/user/cli/runs-and-approvals/#kici-runs-artifacts-list) for the full flag list.
88
+
89
+ ## Artifacts vs cache vs outputs
90
+
91
+ KiCI gives you three ways to move data between jobs. They look similar but solve different problems:
92
+
93
+ | Mechanism | Addressed by | Lifetime | Use it for |
94
+ | ------------- | ------------------------ | ------------------------------------- | -------------------------------------------------------------------- |
95
+ | **Outputs** | job/step name (`needs`) | the run | small JSON values — a version string, a computed flag, a list of ids |
96
+ | **Cache** | a content key you choose | shared across runs, eviction-tolerant | recomputable speedups — a package store, a toolchain, a build cache |
97
+ | **Artifacts** | a name you choose | this run, durable + downloadable | deliverables — a built binary, a bundle, a test report |
98
+
99
+ - Reach for **[outputs](https://docs.kici.dev/user/sdk/core/#job-dependencies-needs)** when the value is small and structured (it rides the `needs` graph as JSON).
100
+ - Reach for **[cache](https://docs.kici.dev/user/sdk/caching/)** when the data is a recomputable speedup keyed by its inputs (a cache miss just recomputes; entries are evicted under quota).
101
+ - Reach for **artifacts** when the data is a _deliverable_ that a later job or a person needs verbatim — something you would be unhappy to see silently recomputed or evicted.
102
+
103
+ ## Limits
104
+
105
+ An orchestrator enforces a few limits, each surfaced as a clear step error when hit:
106
+
107
+ - A **per-artifact size cap** (1 GiB by default).
108
+ - A **per-run count cap** (50 artifacts by default).
109
+ - A **per-org storage quota** (20 GiB by default) across all non-expired artifacts.
110
+ - An **expiry** (30 days by default) after which an artifact is no longer downloadable or listed.
111
+
112
+ Operators can raise or lower the quota and expiry per organization; see the [orchestrator storage layout](https://docs.kici.dev/operator/orchestrator/storage-layout/#artifacts) for the operator-facing knobs.
113
+
114
+ When an upload or download fails for a reason that is **not** one of these limits — the orchestrator has no artifact storage configured, or it could not service the request — the step error says exactly that, instead of reporting a quota rejection or a missing artifact. So an error that names a limit really is a limit you can act on, and an error that names an orchestrator problem is one to take to whoever runs it.
115
+
116
+ ---
117
+
118
+ ## Caching
119
+
120
+ Source: https://docs.kici.dev/user/sdk/caching/
121
+
122
+ KiCI ships a general-purpose cache for any files or directories your workflow produces — compiled artifacts, downloaded toolchains, package manager stores, build outputs. A cache entry is keyed, immutable once written, and shared across runs of the same repository so a later run can restore what an earlier run produced instead of recomputing it.
123
+
124
+ Two surfaces drive the same cache:
125
+
126
+ - **Declarative** — a `cache` field on a job or a step. The runtime restores before the work runs and saves after it succeeds, with no code in your step body.
127
+ - **Imperative** — `ctx.cache.restore(spec)` / `ctx.cache.save(spec)` inside a step body, for fine-grained control over when restore and save happen.
128
+
129
+ The cache is backed by the orchestrator's object storage. Entries are isolated per organization and per ref scope (see [Isolation](https://docs.kici.dev/user/sdk/caching/#isolation)); no other tenant can read your cache, and an untrusted/fork ref can never poison the cache a trusted branch reads.
130
+
131
+ ## CacheSpec
132
+
133
+ Both surfaces take the same shape:
134
+
135
+ ```typescript
136
+ interface CacheSpec {
137
+ /** Exact cache key. First save wins; re-saving an existing key is a no-op. */
138
+ key: string;
139
+ /** Files/directories to cache. Repo-root-relative or `~`-prefixed. */
140
+ paths: string[];
141
+ /** Ordered prefix fallbacks for partial restore; newest matching entry wins. */
142
+ restoreKeys?: string[];
143
+ }
144
+ ```
145
+
146
+ - **`key`** is the exact cache key. It is **immutable** — the first save under a given key wins, and any later save under the same exact key is a no-op (the existing entry is never overwritten). Build keys from inputs that change when the cached content should change, e.g. a hash of your lockfile: `` key: `deps-${await ctx.$`sha256sum pnpm-lock.yaml`}` ``.
147
+ - **`paths`** are the files and directories to archive, repo-root-relative or `~`-prefixed (the agent expands `~` to the workspace home). At least one path is required.
148
+ - **`restoreKeys`** are ordered **prefix** fallbacks tried only when the exact `key` misses on restore. Each prefix is matched against existing entries; the **newest** matching entry wins. This lets a run that changed its lockfile still restore the closest previous cache and rebuild incrementally.
149
+
150
+ ## Declarative cache
151
+
152
+ Add a `cache` field to a job or a step. It accepts one `CacheSpec` or an array of them. The runtime restores every spec before the job/step runs (surfaced as a `cache:restore` pseudo-step) and saves every spec after it completes successfully (surfaced as a `cache:save` pseudo-step):
153
+
154
+ ```typescript
155
+ import { job } from '@kici-dev/sdk';
156
+
157
+ job('build', {
158
+ runsOn: 'linux-x64',
159
+ cache: {
160
+ key: 'mise-tools-v1',
161
+ paths: ['~/.local/share/mise'],
162
+ },
163
+ steps: [
164
+ step('install-tools', async (ctx) => {
165
+ await ctx.$`mise install`;
166
+ }),
167
+ step('build', async (ctx) => {
168
+ await ctx.$`mise exec -- pnpm build`;
169
+ }),
170
+ ],
171
+ });
172
+ ```
173
+
174
+ Step-level cache scopes the restore/save to a single step:
175
+
176
+ ```typescript
177
+ step('deps', {
178
+ cache: { key: `npm-${lockfileHash}`, paths: ['node_modules'], restoreKeys: ['npm-'] },
179
+ run: async (ctx) => {
180
+ await ctx.$`pnpm install --frozen-lockfile`;
181
+ },
182
+ });
183
+ ```
184
+
185
+ On a cache **hit**, the archived paths are restored before the step body runs, so `pnpm install` sees a warm `node_modules`. On a **miss**, the step runs cold and the resulting paths are saved under the exact key for the next run.
186
+
187
+ ## Imperative cache (`ctx.cache`)
188
+
189
+ When you need to decide at runtime whether to restore or save — for example, save only when a build actually changed something — use the imperative API on the step context:
190
+
191
+ ```typescript
192
+ step('build', async (ctx) => {
193
+ const result = await ctx.cache.restore({
194
+ key: `build-${sourceHash}`,
195
+ paths: ['dist'],
196
+ restoreKeys: ['build-'],
197
+ });
198
+
199
+ if (result.hit) {
200
+ ctx.log.info(`restored cache (matched ${result.matchedKey})`);
201
+ }
202
+
203
+ await ctx.$`pnpm build`;
204
+
205
+ await ctx.cache.save({ key: `build-${sourceHash}`, paths: ['dist'] });
206
+ });
207
+ ```
208
+
209
+ `restore(spec)` returns `{ hit, matchedKey? }`:
210
+
211
+ - `hit` is `true` when the exact `key` matched **or** a `restoreKeys` prefix matched.
212
+ - `matchedKey` is the full key that actually matched — the exact key on a direct hit, or the full key of the matched prefix entry on a fallback hit.
213
+
214
+ `save(spec)` archives `spec.paths` under `spec.key`. Like the declarative surface, it is immutable: the first save under an exact key wins, and re-saving the same key is a no-op.
215
+
216
+ ## Restore semantics
217
+
218
+ A restore resolves in this order:
219
+
220
+ 1. **Exact key.** If an entry exists under the exact `key`, it is restored and `matchedKey === key`.
221
+ 2. **restoreKeys prefix fallback.** Each `restoreKeys` prefix is tried in order. Within a prefix, the **newest** matching entry wins; `matchedKey` is that entry's full key.
222
+ 3. **Miss.** If nothing matches, `hit` is `false` and no paths are restored.
223
+
224
+ This mirrors the familiar lockfile-hash pattern: key the entry on the exact lockfile hash, and add a `restoreKeys` prefix so a changed lockfile still restores the most recent prior cache to rebuild from.
225
+
226
+ ## Immutability
227
+
228
+ Cache keys are write-once. The **first** save under an exact key wins; every subsequent save under that same exact key is a no-op and the original bytes are preserved. To publish new content, use a new key (typically by including a content hash in the key). Immutability is what makes a cache hit safe to trust — the bytes behind a given key never change after they are first written.
229
+
230
+ ## Isolation
231
+
232
+ Each cache entry is scoped to your organization and to the ref's trust level:
233
+
234
+ - **Trusted refs** (your repository's own branches, default branch) read and write a **shared** scope visible to the whole org for that repository.
235
+ - **Untrusted / fork refs** read the shared scope as a fallback but write to an **isolated** per-run scope. A fork build can therefore benefit from a warm cache the trusted branch produced, but can never write into the shared scope — so a malicious fork cannot poison the cache a trusted branch later restores.
236
+
237
+ No tenant can read another tenant's cache; the org boundary is enforced in the cache key namespace.
238
+
239
+ ## Eviction
240
+
241
+ Cache storage is bounded per organization. Two mechanisms keep it bounded:
242
+
243
+ - **Quota** — when a save pushes the org over its byte quota (`KICI_USER_CACHE_QUOTA_BYTES`, default 5 GiB), the oldest entries are evicted until the org is back under quota.
244
+ - **TTL** — entries unused for `KICI_USER_CACHE_TTL_MS` (default 7 days) expire. The TTL refreshes on read (touch-on-read), so an actively used cache stays warm.
245
+
246
+ Both knobs are operator-configured on the orchestrator — see [orchestrator storage layout](https://docs.kici.dev/operator/orchestrator/storage-layout/).
247
+
248
+ ## Observability
249
+
250
+ Each cache restore and save surfaces in the run timeline as a `cache:restore` / `cache:save` pseudo-step, reporting the outcome (hit/miss/saved, the matched key, bytes). The same outcomes are recorded as `cache.restore` / `cache.save` run events. See [data flows](https://docs.kici.dev/architecture/data-flows/#user-facing-cache-flow) for the restore/save protocol.
251
+
252
+ ## See also
253
+
254
+ - [Core](https://docs.kici.dev/user/sdk/core/) -- `job()` / `step()` factories the `cache` field attaches to
255
+ - [Runtime](https://docs.kici.dev/user/sdk/runtime/) -- `StepContext`, where `ctx.cache` lives
256
+ - [Orchestrator storage layout](https://docs.kici.dev/operator/orchestrator/storage-layout/) -- cache prefix, quota, TTL, and eviction
257
+ - [Data flows](https://docs.kici.dev/architecture/data-flows/#user-facing-cache-flow) -- restore/save protocol and trust→scope mapping
258
+
259
+ ---
260
+
261
+ ## Event payload reference
262
+
263
+ Source: https://docs.kici.dev/user/sdk/event-payloads/
264
+
265
+ <!-- Generated by scripts/docs-gen-event-payloads.ts — do not edit by hand. Regenerate: pnpm docs:gen:events -->
266
+
267
+ ## The envelope
268
+
269
+ The normalized event envelope is the single event contract in KiCI. Rules receive it as `ctx.event`, and every dynamic function — `environment:`, `env:`, and `concurrencyGroup:` resolvers, generated jobs, and a workflow's `concurrency.group` — receives the same envelope as its argument.
270
+
271
+ Narrow on the `type` field to branch per trigger kind (`if (event.type === 'push')`). The raw provider webhook body is nested at `payload`; the typed variants below describe its shape per event type.
272
+
273
+ These fields are present on every envelope (the `EventBase` shape):
274
+
275
+ | Field | Type | Description |
276
+ | --------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
277
+ | `type` | `string` | Normalized event type discriminant. |
278
+ | `action?` | `string` | Sub-action (e.g. 'opened', 'created', 'submitted'). |
279
+ | `targetBranch?` | `string` | Target branch (push target, PR base, or default branch). |
280
+ | `sourceBranch?` | `string` | Source branch (PR head branch). Only set for PR-like events. |
281
+ | `provider?` | `string` | Provider that originated this event. |
282
+ | `isForkPR?` | `boolean` | Whether this PR comes from a fork. Only set for PR-like events. |
283
+ | `baseBranch?` | `string` | Base branch ref for PR events. |
284
+ | `senderUsername?` | `string` | Sender username from the webhook payload. |
285
+ | `sourceRepo?` | `string` | Repository identifier (e.g. "owner/repo"). |
286
+ | `changedFiles?` | `string[]` | Files changed in this event (for path filtering). |
287
+ | `changedFilesStatus?` | `import('@kici-dev/engine').ChangedFilesStatus` | Availability of `changedFiles` — `fetched` (real diff), `unavailable` (no diff / could not compute), or `skipped` (orchestrator did not fetch; the agent recomputes from its clone). |
288
+ | `payload?` | `Record<string, unknown>` | Raw webhook payload from the provider. May be absent in flattened event forms. |
289
+ | `[key: string]` | `unknown` | Index signature for backward compatibility — untyped fields resolve to unknown. |
290
+
291
+ ## Event types
292
+
293
+ One section per member of the `EventPayload` union. The heading is the `type` literal; the table lists the fields of that event's `payload` property when it declares a typed shape.
294
+
295
+ ### `pull_request`
296
+
297
+ Carried by `PullRequestEventPayload`. The `payload` property has the following shape:
298
+
299
+ | Field | Type | Description |
300
+ | --------------- | ------------------- | ----------- |
301
+ | `action` | `string` | |
302
+ | `number` | `number` | |
303
+ | `pull_request` | `GitHubPullRequest` | |
304
+ | `repository` | `GitHubRepository` | |
305
+ | `sender` | `GitHubUser` | |
306
+ | `[key: string]` | `unknown` | |
307
+
308
+ ### `push`
309
+
310
+ Carried by `PushEventPayload`. The `payload` property has the following shape:
311
+
312
+ | Field | Type | Description |
313
+ | --------------- | ------------------ | ----------- |
314
+ | `ref` | `string` | |
315
+ | `after` | `string` | |
316
+ | `before` | `string` | |
317
+ | `head_commit?` | `GitHubCommit` | |
318
+ | `commits?` | `GitHubCommit[]` | |
319
+ | `repository` | `GitHubRepository` | |
320
+ | `sender?` | `GitHubUser` | |
321
+ | `forced?` | `boolean` | |
322
+ | `[key: string]` | `unknown` | |
323
+
324
+ ### `tag`
325
+
326
+ Carried by `TagEventPayload`. The `payload` property has the following shape:
327
+
328
+ | Field | Type | Description |
329
+ | --------------- | ------------------ | ----------- |
330
+ | `ref` | `string` | |
331
+ | `after` | `string` | |
332
+ | `repository` | `GitHubRepository` | |
333
+ | `sender?` | `GitHubUser` | |
334
+ | `[key: string]` | `unknown` | |
335
+
336
+ ### `comment`
337
+
338
+ Carried by `CommentEventPayload`. The `payload` property has the following shape:
339
+
340
+ | Field | Type | Description |
341
+ | --------------- | ------------------------------------------------------------------------------------ | ----------- |
342
+ | `action` | `string` | |
343
+ | `comment` | `GitHubComment` | |
344
+ | `issue?` | `{ number: number; title?: string; pull_request?: unknown; [key: string]: unknown }` | |
345
+ | `repository` | `GitHubRepository` | |
346
+ | `sender` | `GitHubUser` | |
347
+ | `[key: string]` | `unknown` | |
348
+
349
+ ### `review`
350
+
351
+ Carried by `ReviewEventPayload`. The `payload` property has the following shape:
352
+
353
+ | Field | Type | Description |
354
+ | --------------- | ------------------- | ----------- |
355
+ | `action` | `string` | |
356
+ | `review` | `GitHubReview` | |
357
+ | `pull_request` | `GitHubPullRequest` | |
358
+ | `repository` | `GitHubRepository` | |
359
+ | `sender` | `GitHubUser` | |
360
+ | `[key: string]` | `unknown` | |
361
+
362
+ ### `review_comment`
363
+
364
+ Carried by `ReviewCommentEventPayload`. The `payload` property has the following shape:
365
+
366
+ | Field | Type | Description |
367
+ | --------------- | ------------------- | ----------- |
368
+ | `action` | `string` | |
369
+ | `comment` | `GitHubComment` | |
370
+ | `pull_request` | `GitHubPullRequest` | |
371
+ | `repository` | `GitHubRepository` | |
372
+ | `sender` | `GitHubUser` | |
373
+ | `[key: string]` | `unknown` | |
374
+
375
+ ### `release`
376
+
377
+ Carried by `ReleaseEventPayload`. The `payload` property has the following shape:
378
+
379
+ | Field | Type | Description |
380
+ | --------------- | ------------------ | ----------- |
381
+ | `action` | `string` | |
382
+ | `release` | `GitHubRelease` | |
383
+ | `repository` | `GitHubRepository` | |
384
+ | `sender` | `GitHubUser` | |
385
+ | `[key: string]` | `unknown` | |
386
+
387
+ ### `dispatch`
388
+
389
+ Carried by `DispatchEventPayload`. The `payload` property has the following shape:
390
+
391
+ | Field | Type | Description |
392
+ | ----------------- | ------------------------- | ----------- |
393
+ | `action` | `string` | |
394
+ | `client_payload?` | `Record<string, unknown>` | |
395
+ | `repository` | `GitHubRepository` | |
396
+ | `sender?` | `GitHubUser` | |
397
+ | `[key: string]` | `unknown` | |
398
+
399
+ ### `create`
400
+
401
+ Carried by `CreateEventPayload`. The `payload` property has the following shape:
402
+
403
+ | Field | Type | Description |
404
+ | --------------- | ------------------ | ----------- |
405
+ | `ref` | `string` | |
406
+ | `ref_type` | `string` | |
407
+ | `repository` | `GitHubRepository` | |
408
+ | `sender` | `GitHubUser` | |
409
+ | `[key: string]` | `unknown` | |
410
+
411
+ ### `delete`
412
+
413
+ Carried by `DeleteEventPayload`. The `payload` property has the following shape:
414
+
415
+ | Field | Type | Description |
416
+ | --------------- | ------------------ | ----------- |
417
+ | `ref` | `string` | |
418
+ | `ref_type` | `string` | |
419
+ | `repository` | `GitHubRepository` | |
420
+ | `sender` | `GitHubUser` | |
421
+ | `[key: string]` | `unknown` | |
422
+
423
+ ### `status`
424
+
425
+ Carried by `StatusEventPayload`. The `payload` property has the following shape:
426
+
427
+ | Field | Type | Description |
428
+ | --------------- | ------------------------------------------------- | ----------- |
429
+ | `state` | `string` | |
430
+ | `sha` | `string` | |
431
+ | `context` | `string` | |
432
+ | `description?` | `string` | |
433
+ | `target_url?` | `string` | |
434
+ | `branches?` | `Array<{ name: string; [key: string]: unknown }>` | |
435
+ | `repository` | `GitHubRepository` | |
436
+ | `sender` | `GitHubUser` | |
437
+ | `[key: string]` | `unknown` | |
438
+
439
+ ### `workflow_run`
440
+
441
+ Carried by `WorkflowRunEventPayload`. The `payload` property has the following shape:
442
+
443
+ | Field | Type | Description |
444
+ | --------------- | ------------------------------------------------------------------------------------------------------ | ----------- |
445
+ | `action` | `string` | |
446
+ | `workflow_run` | `{ head_branch: string; name: string; conclusion?: string; status?: string; [key: string]: unknown; }` | |
447
+ | `repository` | `GitHubRepository` | |
448
+ | `sender` | `GitHubUser` | |
449
+ | `[key: string]` | `unknown` | |
450
+
451
+ ### `fork`
452
+
453
+ Carried by `ForkEventPayload`. The `payload` property has the following shape:
454
+
455
+ | Field | Type | Description |
456
+ | --------------- | ----------------------------------------------- | ----------- |
457
+ | `forkee` | `{ full_name: string; [key: string]: unknown }` | |
458
+ | `repository` | `GitHubRepository` | |
459
+ | `sender` | `GitHubUser` | |
460
+ | `[key: string]` | `unknown` | |
461
+
462
+ ### `star`
463
+
464
+ Carried by `StarEventPayload`. The `payload` property has the following shape:
465
+
466
+ | Field | Type | Description |
467
+ | --------------- | ------------------ | ----------- |
468
+ | `action` | `string` | |
469
+ | `repository` | `GitHubRepository` | |
470
+ | `sender` | `GitHubUser` | |
471
+ | `[key: string]` | `unknown` | |
472
+
473
+ ### `watch`
474
+
475
+ Carried by `WatchEventPayload`. The `payload` property has the following shape:
476
+
477
+ | Field | Type | Description |
478
+ | --------------- | ------------------ | ----------- |
479
+ | `action` | `string` | |
480
+ | `repository` | `GitHubRepository` | |
481
+ | `sender` | `GitHubUser` | |
482
+ | `[key: string]` | `unknown` | |
483
+
484
+ ### `webhook`
485
+
486
+ Carried by `WebhookEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
487
+
488
+ ### `kici_event`
489
+
490
+ Carried by `KiciEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
491
+
492
+ ### `workflow_complete`
493
+
494
+ Carried by `WorkflowCompleteEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
495
+
496
+ ### `job_complete`
497
+
498
+ Carried by `JobCompleteEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
499
+
500
+ ### `generic_webhook`
501
+
502
+ Carried by `GenericWebhookEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
503
+
504
+ ### `schedule`
505
+
506
+ Carried by `ScheduleEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
507
+
508
+ ### `lifecycle`
509
+
510
+ Carried by `LifecycleEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
511
+
512
+ ### `rerun`
513
+
514
+ Carried by `RerunEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
515
+
516
+ ### `manual_schedule`
517
+
518
+ Carried by `ManualScheduleEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
519
+
520
+ ### `unknown`
521
+
522
+ Carried by `UnknownEventPayload`. Adds no typed fields beyond the shared envelope; `payload` is the raw provider body (`Record<string, unknown>`).
523
+
524
+ ## Shared GitHub object shapes
525
+
526
+ The typed `payload` shapes above reference these partial GitHub object types. Each lists only the commonly accessed fields; the index signature on every shape resolves any other field to `unknown`.
527
+
528
+ ### `GitHubRepository`
529
+
530
+ | Field | Type | Description |
531
+ | ---------------- | ------------------------------------------- | ----------- |
532
+ | `full_name` | `string` | |
533
+ | `default_branch` | `string` | |
534
+ | `name?` | `string` | |
535
+ | `owner?` | `{ login: string; [key: string]: unknown }` | |
536
+ | `private?` | `boolean` | |
537
+ | `[key: string]` | `unknown` | |
538
+
539
+ ### `GitHubUser`
540
+
541
+ | Field | Type | Description |
542
+ | --------------- | --------- | ----------- |
543
+ | `login` | `string` | |
544
+ | `id?` | `number` | |
545
+ | `[key: string]` | `unknown` | |
546
+
547
+ ### `GitHubPullRequest`
548
+
549
+ | Field | Type | Description |
550
+ | --------------- | ------------------------------------------------------------------------------------------------------------- | ----------- |
551
+ | `number` | `number` | |
552
+ | `draft?` | `boolean` | |
553
+ | `title?` | `string` | |
554
+ | `body?` | `string` | |
555
+ | `state?` | `string` | |
556
+ | `merged?` | `boolean` | |
557
+ | `head` | `{ ref: string; sha: string; repo?: { full_name: string; [key: string]: unknown }; [key: string]: unknown; }` | |
558
+ | `base` | `{ ref: string; repo?: { full_name: string; [key: string]: unknown }; [key: string]: unknown; }` | |
559
+ | `user?` | `GitHubUser` | |
560
+ | `labels?` | `Array<{ name: string; [key: string]: unknown }>` | |
561
+ | `[key: string]` | `unknown` | |
562
+
563
+ ### `GitHubCommit`
564
+
565
+ | Field | Type | Description |
566
+ | --------------- | ------------------------------------------------------------------------------ | ----------- |
567
+ | `id` | `string` | |
568
+ | `message` | `string` | |
569
+ | `author?` | `{ name?: string; email?: string; username?: string; [key: string]: unknown }` | |
570
+ | `timestamp?` | `string` | |
571
+ | `added?` | `string[]` | |
572
+ | `removed?` | `string[]` | |
573
+ | `modified?` | `string[]` | |
574
+ | `[key: string]` | `unknown` | |
575
+
576
+ ### `GitHubComment`
577
+
578
+ | Field | Type | Description |
579
+ | --------------- | ------------ | ----------- |
580
+ | `id` | `number` | |
581
+ | `body` | `string` | |
582
+ | `user` | `GitHubUser` | |
583
+ | `[key: string]` | `unknown` | |
584
+
585
+ ### `GitHubReview`
586
+
587
+ | Field | Type | Description |
588
+ | --------------- | ------------ | ----------- |
589
+ | `id` | `number` | |
590
+ | `state` | `string` | |
591
+ | `body?` | `string` | |
592
+ | `user` | `GitHubUser` | |
593
+ | `[key: string]` | `unknown` | |
594
+
595
+ ### `GitHubRelease`
596
+
597
+ | Field | Type | Description |
598
+ | ------------------- | --------- | ----------- |
599
+ | `id` | `number` | |
600
+ | `tag_name` | `string` | |
601
+ | `name?` | `string` | |
602
+ | `body?` | `string` | |
603
+ | `draft?` | `boolean` | |
604
+ | `prerelease?` | `boolean` | |
605
+ | `target_commitish?` | `string` | |
606
+ | `[key: string]` | `unknown` | |
607
+
608
+ ---
609
+
610
+ ## SDK reference: idempotent
611
+
612
+ Source: https://docs.kici.dev/user/sdk/idempotent/
613
+
614
+ The SDK exposes three idempotency helpers — a generic function `idempotent()`, the step factory `idempotentStep()`, and its check-mode-aware sibling `checkStep()` — for the common case where a workflow step should:
615
+
616
+ 1. **Check** whether the desired state is already in place.
617
+ 2. **Apply** the change only when drift is detected.
618
+ 3. **Surface** the resource (or its identifier) on both branches, so downstream steps don't need to know whether work happened or was skipped.
619
+
620
+ `idempotent()` and `idempotentStep()` wrap the same underlying runner and always apply on drift. Pick `idempotentStep()` when the operation is the whole job of a step; use `idempotent()` from anywhere — inside a multi-action step, a hook, or a bare async function. Pick `checkStep()` when the step should respect the run-level check mode — `kici run --check` previews the drift without applying it.
621
+
622
+ ## `idempotent(options)`
623
+
624
+ Run a single check / apply cycle and return a discriminated result describing the outcome.
625
+
626
+ ### Parameters
627
+
628
+ | Name | Type | Required | Description |
629
+ | ------------ | -------------------------------------- | -------- | ------------------------------------------------------------------------------------------------ |
630
+ | `name` | `string` | No | Label that appears in log lines. Defaults to `'idempotent'`. |
631
+ | `check` | `() => Promise<TDrift \| null>` | Yes | Read-only inspection. Return `null` when the system is already in the desired state. |
632
+ | `apply` | `(drift: TDrift) => Promise<TApplied>` | Yes | Brings the system to the desired state when `check()` returned a non-null drift value. |
633
+ | `whenInSync` | `() => Promise<TInSync>` | No | Runs when `check()` returned `null`. Use it to fetch the already-satisfied resource. |
634
+ | `summarize` | `(drift: TDrift) => string` | No | Human-readable, multi-line summary of what `apply()` would do. Defaults to a JSON dump of drift. |
635
+ | `log` | `(line: string) => void` | No | Sink for status lines. Defaults to `console.log`. |
636
+
637
+ ### Result
638
+
639
+ `idempotent()` resolves to a discriminated `IdempotentResult` union:
640
+
641
+ | Outcome | `drift` | `result` |
642
+ | ----------- | -------- | ------------------------------------------------ |
643
+ | `'skipped'` | `null` | The `whenInSync()` return value, or `undefined`. |
644
+ | `'applied'` | `TDrift` | The `apply()` return value. |
645
+
646
+ Narrow on `result.outcome` before reading `result.result` to get the correct typed shape.
647
+
648
+ ### Example
649
+
650
+ ```typescript
651
+ import { idempotent } from '@kici-dev/sdk';
652
+
653
+ const result = await idempotent({
654
+ name: 'create-dns-record',
655
+ check: async () => {
656
+ const existing = await dns.getRecord('api.example.com');
657
+ return existing ? null : { fqdn: 'api.example.com', target: '203.0.113.10' };
658
+ },
659
+ whenInSync: async () => {
660
+ const existing = await dns.getRecord('api.example.com');
661
+ return { id: existing.id };
662
+ },
663
+ apply: async (drift) => {
664
+ const created = await dns.createRecord(drift.fqdn, drift.target);
665
+ return { id: created.id };
666
+ },
667
+ summarize: (drift) => `Create A record ${drift.fqdn} → ${drift.target}`,
668
+ });
669
+
670
+ // Both branches surface the record id.
671
+ const recordId = result.result.id;
672
+ ```
673
+
674
+ ## `idempotentStep(name, options)`
675
+
676
+ A factory returning an SDK `Step` whose `run` body executes `idempotent(...)` and routes status lines through the step's structured logger.
677
+
678
+ ### Parameters
679
+
680
+ | Name | Type | Required | Description |
681
+ | --------- | ------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------ |
682
+ | `name` | `string` | Yes | Step name. Appears in the run timeline and in log lines. |
683
+ | `options` | `Omit<IdempotentOptions, 'name' \| 'log'>` | Yes | Same shape as `idempotent()` minus `name` (already provided) and `log` (provided by the step context). |
684
+
685
+ ### Result
686
+
687
+ `idempotentStep(...)` returns `Step<IdempotentResult<TDrift, TInSync, TApplied>>`. Other steps can consume the result through the standard step output mechanisms.
688
+
689
+ ### Example
690
+
691
+ ```typescript
692
+ import { idempotentStep, job } from '@kici-dev/sdk';
693
+
694
+ const ensureBucket = idempotentStep('ensure-bucket', {
695
+ check: async () => {
696
+ const exists = await s3.bucketExists('app-cache');
697
+ return exists ? null : { bucket: 'app-cache', region: 'eu-central-1' };
698
+ },
699
+ whenInSync: async () => ({ arn: 'arn:aws:s3:::app-cache' }),
700
+ apply: async (drift) => {
701
+ const created = await s3.createBucket(drift.bucket, drift.region);
702
+ return { arn: created.arn };
703
+ },
704
+ summarize: (drift) => `Create S3 bucket ${drift.bucket} in ${drift.region}`,
705
+ });
706
+
707
+ export const setup = job('setup', {
708
+ runsOn: 'linux',
709
+ steps: [ensureBucket],
710
+ });
711
+ ```
712
+
713
+ ## `checkStep(name, options)`
714
+
715
+ The check-mode-aware sibling of `idempotentStep()`. It takes the **same option shape**, but behaves differently when a run is started in check mode (`kici run --check`):
716
+
717
+ | Factory | Behavior under `kici run --check` |
718
+ | ---------------- | ----------------------------------------- |
719
+ | `idempotentStep` | always applies on drift |
720
+ | `checkStep` | reports drift, applies only in apply mode |
721
+
722
+ Use `checkStep()` for deploy-style steps where you want a dry-run preview of pending changes before committing them, and `idempotentStep()` for steps that must always converge (for example inside a hook). A `checkStep()` desugars to the first-class step check facet (`check` / `summarize` / `run(ctx, drift)` / `whenInSync`), so it participates in run-level check mode automatically: `kici run --check` reports the drift and skips `apply`, `kici run --check --fail-on-drift` exits non-zero when drift is detected, and apply mode applies the change.
723
+
724
+ ### Parameters
725
+
726
+ | Name | Type | Required | Description |
727
+ | ----------------- | ------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------- |
728
+ | `name` | `string` | Yes | Step name. Appears in the run timeline and in log lines. |
729
+ | `check` | `(ctx) => Promise<TDrift \| null>` | Yes | Read-only inspection. Return `null` when the system is already in the desired state. |
730
+ | `apply` | `(ctx, drift: TDrift) => Promise<TApplied>` | Yes | Brings the system to the desired state. Runs only in apply mode (skipped under `kici run --check`). |
731
+ | `summarize` | `(drift: TDrift) => string` | Yes | Human-readable summary of what `apply()` would do; shown in check-mode drift output. |
732
+ | `whenInSync` | `(ctx) => Promise<TInSync>` | No | Runs when `check()` returned `null` (already in sync). |
733
+ | `continueOnError` | `boolean` | No | When true, the job proceeds even if this step fails. |
734
+ | `timeout` | `number` | No | Step-level timeout in milliseconds. |
735
+
736
+ The one signature difference from `idempotentStep`: `apply` and `whenInSync` receive `ctx` as their first argument, so the apply logic has access to `ctx.$`, `ctx.log`, and `ctx.secrets`.
737
+
738
+ ### Result
739
+
740
+ `checkStep(...)` returns `Step<TApplied | TInSync>` — the output is whichever of `apply` / `whenInSync` ran.
741
+
742
+ ### Example
743
+
744
+ ```typescript
745
+ import { checkStep, job } from '@kici-dev/sdk';
746
+
747
+ const ensureDnsRecord = checkStep('ensure-dns-record', {
748
+ check: async (ctx) => {
749
+ const existing = await ctx.$`dig +short api.example.com`;
750
+ return existing.stdout.trim() ? null : { fqdn: 'api.example.com', target: '203.0.113.10' };
751
+ },
752
+ summarize: (drift) => `Create A record ${drift.fqdn} → ${drift.target}`,
753
+ apply: async (ctx, drift) => {
754
+ await ctx.$`dns-cli create ${drift.fqdn} ${drift.target}`;
755
+ return { created: true };
756
+ },
757
+ whenInSync: async () => ({ created: false }),
758
+ });
759
+
760
+ export const deploy = job('deploy', {
761
+ runsOn: 'linux',
762
+ steps: [ensureDnsRecord],
763
+ });
764
+ ```
765
+
766
+ Run `kici run --check` against this workflow to see the drift summary without touching DNS; run it without `--check` to apply.
767
+
768
+ ## Worked example: create-if-missing returning a resource id
769
+
770
+ The typical use case is **resource provisioning that should be safe to re-run**. The helper guarantees the same downstream typed shape whether the resource already existed or was just created:
771
+
772
+ ```typescript
773
+ import { idempotent } from '@kici-dev/sdk';
774
+
775
+ interface BucketDrift {
776
+ bucket: string;
777
+ region: string;
778
+ }
779
+
780
+ interface BucketHandle {
781
+ arn: string;
782
+ }
783
+
784
+ async function ensureBucket(bucket: string, region: string): Promise<BucketHandle> {
785
+ const result = await idempotent<BucketDrift, BucketHandle, BucketHandle>({
786
+ name: `ensure-${bucket}`,
787
+ check: async () => {
788
+ const existing = await s3.describeBucket(bucket);
789
+ return existing ? null : { bucket, region };
790
+ },
791
+ whenInSync: async () => {
792
+ const existing = await s3.describeBucket(bucket);
793
+ return { arn: existing.arn };
794
+ },
795
+ apply: async (drift) => {
796
+ const created = await s3.createBucket(drift.bucket, drift.region);
797
+ return { arn: created.arn };
798
+ },
799
+ summarize: (drift) => `Create S3 bucket ${drift.bucket} in ${drift.region}`,
800
+ });
801
+
802
+ return result.result;
803
+ }
804
+ ```
805
+
806
+ The caller never has to branch on outcome — `result.result` is always a `BucketHandle`. A second invocation against the same bucket logs a single "in sync, skipping" line and returns the same ARN.
807
+
808
+ ## See also
809
+
810
+ - [Core SDK reference](https://docs.kici.dev/user/sdk/core/) — the `step()`, `job()`, and `workflow()` factories that `idempotentStep()` builds on.
811
+ - [Runtime types](https://docs.kici.dev/user/sdk/runtime/) — `StepContext`, `Logger`, and other surface used inside the helpers.
812
+
813
+ ---
814
+
815
+ ## SDK reference: runsOnAll host fan-out
816
+
817
+ Source: https://docs.kici.dev/user/sdk/runs-on-all/
818
+
819
+ ## runsOnAll
820
+
821
+ `runsOnAll` fans a single job out to **every** host in the orchestrator's declared
822
+ roster that matches a label predicate — one pinned execution per host. Use it for
823
+ fleet-wide operations: patch every web tier, smoke-test every node, collect uptime
824
+ from the fleet.
825
+
826
+ `runsOnAll` is mutually exclusive with [`runsOn`](https://docs.kici.dev/user/sdk/core/): a job declares one
827
+ or the other. Where `runsOn` picks a **single** agent that satisfies the labels,
828
+ `runsOnAll` targets **all** matching hosts and runs the job once on each, pinned to
829
+ that specific host.
830
+
831
+ ```typescript
832
+ import { job } from '@kici-dev/sdk';
833
+
834
+ // Run on every host labelled role:web.
835
+ const patch = job('patch', {
836
+ runsOnAll: 'role:web',
837
+ run: async (ctx) => {
838
+ await ctx.$`sudo apt-get update && sudo apt-get upgrade -y`;
839
+ ctx.log.info(`patched ${ctx.host}`);
840
+ },
841
+ });
842
+ ```
843
+
844
+ ### Input forms
845
+
846
+ `runsOnAll` accepts three shapes:
847
+
848
+ - **Bare string** — one required label.
849
+
850
+ ```typescript
851
+ runsOnAll: 'role:web';
852
+ ```
853
+
854
+ - **Array** — every positive entry is required (AND); a `!`-prefixed entry excludes a host.
855
+
856
+ ```typescript
857
+ runsOnAll: ['kici:os:linux', 'role:db', '!kici:host:db-01'];
858
+ ```
859
+
860
+ - **Structured** — explicit OR-of-AND include groups plus excludes.
861
+
862
+ ```typescript
863
+ runsOnAll: {
864
+ include: [{ all: ['kici:os:linux', 'role:db'] }, { all: ['role:replica'] }],
865
+ exclude: ['kici:host:db-01'],
866
+ };
867
+ ```
868
+
869
+ A host matches when it satisfies **any** include group (all labels in that group)
870
+ and carries **none** of the exclude labels.
871
+
872
+ #### Targeting by pattern
873
+
874
+ Every entry in any of these forms — include or exclude — can be an exact string, a
875
+ glob, or a regular expression, exactly like [`runsOn`](https://docs.kici.dev/user/sdk/core/#targeting-by-pattern):
876
+
877
+ - **Plain string → exact match** (`'role:web'`).
878
+ - **String with glob metacharacters (`*`, `?`, `[]`, `{}`) → glob** (`'kici:host:web-*'`).
879
+ - **`RegExp` literal → regular expression** (`/.*-canary$/`).
880
+
881
+ In the array form, a leading `!` routes an entry to the exclude side and is stripped
882
+ **before** the matching mode is decided, so `'!kici:host:box-*'` is an exclude **glob**
883
+ and `'!box-01'` an exclude **exact** match. A regular-expression exclusion uses the
884
+ structured `exclude: [/…/]` form (a `RegExp` cannot carry a `!` prefix):
885
+
886
+ ```typescript
887
+ const fanout = job('deploy', {
888
+ runsOnAll: {
889
+ include: [{ all: ['kici:os:linux', 'kici:host:web-*'] }],
890
+ exclude: [/.*-canary$/],
891
+ },
892
+ run: async (ctx) => {
893
+ /* runs once per matched host */
894
+ },
895
+ });
896
+ ```
897
+
898
+ A custom label that literally contains glob metacharacters is always treated as a glob
899
+ and can no longer be matched exactly. A regular expression you supply is validated for
900
+ catastrophic-backtracking (ReDoS) when you run `kici compile` and rejected if it could
901
+ hang on a crafted input.
902
+
903
+ ### Per-host execution model
904
+
905
+ Each matching host runs the job as its own pinned child, named `<job> (<hostname>)`
906
+ (e.g. `patch (web-01)`). The children fan in for downstream `needs:` exactly like a
907
+ matrix job — a downstream that needs the base job waits for every host child.
908
+
909
+ The job runs once per host with concurrency `unlimited` (all hosts in parallel).
910
+
911
+ ### ctx.host and ctx.agent
912
+
913
+ Inside a `runsOnAll` step, two extra context fields identify the host the child is
914
+ running on:
915
+
916
+ - `ctx.host` — the hostname (string).
917
+ - `ctx.agent` — the resolved agent facts: `{ host, labels, platform?, arch? }`.
918
+
919
+ ```typescript
920
+ run: async (ctx) => {
921
+ ctx.log.info(`running on ${ctx.host} (${ctx.agent?.platform}/${ctx.agent?.arch})`);
922
+ };
923
+ ```
924
+
925
+ Both are `undefined` for jobs that do not use `runsOnAll`.
926
+
927
+ ### ctx.fanout — fan-out position
928
+
929
+ Every fan-out child — a `runsOnAll` host **or** a matrix combination — also
930
+ carries its **position** within the fan-out:
931
+
932
+ ```typescript
933
+ ctx.fanout?: {
934
+ index: number; // 0-based position in the deterministically-ordered fan-out
935
+ total: number; // number of children in this fan-out
936
+ first: boolean; // index === 0
937
+ last: boolean; // index === total - 1
938
+ };
939
+ ```
940
+
941
+ The order is a **documented guarantee**: host fan-out is ordered by agent id,
942
+ matrix fan-out by its combination label. So `ctx.fanout.first` is always the
943
+ same (lowest-agent-id) host across re-runs, and `ctx.fanout.last` the same final
944
+ one. `ctx.fanout` is `undefined` on a job that is not fanned out.
945
+
946
+ ### Run-once steps: onlyOnFirstHost / onlyOnLastHost / onlyOnFanoutIndex
947
+
948
+ For ordered, stateful rollouts you often need a step that runs on exactly **one**
949
+ host — enable a leader before the rest join, run a one-time migration, take a
950
+ single dump. Three rule helpers express this by reading `ctx.fanout`:
951
+
952
+ ```typescript
953
+ import { job, step, onlyOnFirstHost, onlyOnLastHost, onlyOnFanoutIndex } from '@kici-dev/sdk';
954
+
955
+ const rollout = job('rollout', {
956
+ runsOnAll: 'role:db',
957
+ maxParallel: 1, // serial, so "first" runs before the rest
958
+ steps: [
959
+ // Runs only on the first (lowest-agent-id) host — KiCI's run-once primitive.
960
+ step('enable-sync-mode', { rules: [onlyOnFirstHost()] }, async (ctx) => {
961
+ /* configure the leader before standbys join */
962
+ }),
963
+ // Runs on every host.
964
+ step('apply', async (ctx) => {
965
+ /* ... */
966
+ }),
967
+ // Runs only on the last host.
968
+ step('finalize', { rules: [onlyOnLastHost()] }, async (ctx) => {
969
+ /* ... */
970
+ }),
971
+ ],
972
+ });
973
+ ```
974
+
975
+ - A step gated this way is **skipped** (not failed) on non-matching hosts — its
976
+ outputs exist only on the host where it ran.
977
+ - `onlyOnFanoutIndex(n)` targets the host at a specific position.
978
+ - **Non-fan-out safety:** on a job that is not fanned out, `ctx.fanout` is
979
+ `undefined` and these helpers treat the job as a single implicit child at
980
+ index 0 — so `onlyOnFirstHost()` runs normally there (there is one host, which
981
+ is the first). This means you can author a step with `onlyOnFirstHost()` and it
982
+ behaves correctly whether or not the job ends up fanning out.
983
+ - The helpers are host-flavored by name (the dominant use case) but read
984
+ `ctx.fanout`, so they work for matrix fan-out too — `onlyOnFirstHost()` runs on
985
+ the first combination.
986
+
987
+ ### byHost outputs
988
+
989
+ A downstream that `needs:` a `runsOnAll` job receives a **byHost** envelope instead
990
+ of a flat outputs object — keyed by hostname, with a per-host summary:
991
+
992
+ ```typescript
993
+ import { isHostJobOutputs } from '@kici-dev/sdk';
994
+
995
+ const report = job('report', {
996
+ runsOn: 'role:control',
997
+ needs: [patch],
998
+ run: async (ctx) => {
999
+ const outputs = ctx.jobOutputs(patch);
1000
+ if (isHostJobOutputs(outputs)) {
1001
+ ctx.log.info(`succeeded: ${outputs.summary.succeededHosts.join(', ')}`);
1002
+ ctx.log.info(`failed: ${outputs.summary.failedHosts.join(', ')}`);
1003
+ // Per-host outputs, keyed by hostname:
1004
+ const version = outputs.byHost['web-01']?.version;
1005
+ // Array view of one output key across every host:
1006
+ const allVersions = outputs.summary.outputs.version;
1007
+ }
1008
+ },
1009
+ });
1010
+ ```
1011
+
1012
+ Unlike the matrix envelope's last-write-wins `merged`, the host summary never collapses
1013
+ to a single scalar: `summary.outputs[key]` is an array of every host's value, and
1014
+ `succeededHosts` / `failedHosts` record each host's terminal outcome.
1015
+
1016
+ ### onUnreachable: skip | fail | hold
1017
+
1018
+ Resolution is backed by the **declared host roster** (see the operator
1019
+ [host roster](https://docs.kici.dev/operator/orchestrator/host-roster/) doc), not just the live registry.
1020
+ This lets KiCI surface an expected-but-absent host instead of silently fanning out to a
1021
+ partial fleet. The `onUnreachable` policy controls what happens when a **durable**
1022
+ (static) host in the roster is matched but not currently connected:
1023
+
1024
+ - **`hold`** (default) — queue a pinned child for the absent host and wait for it to
1025
+ reconnect. The fan-out is honest: a 5-host fleet with 1 host rebooting reports
1026
+ `4 ran, 1 held`, not a silent 4-of-5 success.
1027
+ - **`skip`** — omit the absent durable host and run only on the reachable hosts.
1028
+ - **`fail`** — fail the run init if any expected durable host is unreachable.
1029
+
1030
+ ```typescript
1031
+ const patch = job('patch', {
1032
+ runsOnAll: 'role:web',
1033
+ onUnreachable: 'skip',
1034
+ run: async (ctx) => {
1035
+ /* ... */
1036
+ },
1037
+ });
1038
+ ```
1039
+
1040
+ Ephemeral (scaled-down) hosts that are no longer connected are **always** skipped,
1041
+ independent of `onUnreachable` — a scaled-down node may never return. A `runsOnAll`
1042
+ that matches zero usable hosts fails the run rather than reporting a silent zero-child
1043
+ success.
1044
+
1045
+ ### includeUninitialized: converge a fresh fleet
1046
+
1047
+ `onUnreachable` governs declared hosts that _had_ an agent and are momentarily absent.
1048
+ A **never-initialized** host — a freshly-provisioned box reachable over SSH but with no
1049
+ agent yet — is a different case: there is nothing to run on. Set
1050
+ `includeUninitialized: true` to widen the fan-out to those hosts and bring them up:
1051
+
1052
+ ```typescript
1053
+ const converge = job('converge', {
1054
+ runsOnAll: 'kici:group:prod',
1055
+ includeUninitialized: true,
1056
+ steps: [partitionDisk, formatLuks, debootstrap, installAgent],
1057
+ });
1058
+ ```
1059
+
1060
+ For each un-agented declared host (one carrying SSH reach metadata), KiCI brings up a
1061
+ temporary init-runner over SSH and runs the **same steps** on it; hosts that already
1062
+ have a live agent run the steps on their own agent. One workflow converges the whole
1063
+ fleet — fresh boxes get built, live boxes run the same phases.
1064
+
1065
+ Because the steps run on already-initialized hosts too, the bootstrap phases **must be
1066
+ idempotent [check-steps](https://docs.kici.dev/user/sdk/core/)**: each step's `check()` reports in-sync on a
1067
+ live box so the partition / format / install steps **skip** there and run only on fresh
1068
+ boxes. This is the safety guard — an OS or disk-format step must never re-run on a host
1069
+ that is already built. Re-running the workflow is a no-op everywhere. See the operator
1070
+ [fresh-box bootstrap](https://docs.kici.dev/operator/orchestrator/host-roster/) doc for the bring-up,
1071
+ capability gating, and lifecycle details.
1072
+
1073
+ `includeUninitialized` is only meaningful alongside `runsOnAll`; it is ignored on a
1074
+ single-agent `runsOn` job.
1075
+
1076
+ ### Rolling rollout: maxParallel + failFast
1077
+
1078
+ By default a `runsOnAll` fan-out dispatches to every matched host at once — fine for
1079
+ collecting state across the fleet, dangerous for a deploy that takes the whole tier
1080
+ down simultaneously. Two job options bound the rollout:
1081
+
1082
+ - **`maxParallel`** — the fan-out width: at most this many hosts run at once. It is a
1083
+ sliding window — each host that finishes (success or failure) releases the next held
1084
+ host. `maxParallel: 1` is a strictly serial, one-host-at-a-time rolling deploy. Must
1085
+ be `>= 1`.
1086
+ - **`failFast`** — when `true`, the first host failure halts the rollout: no further
1087
+ held hosts are started, and the remaining ones are marked skipped. Default `false`
1088
+ (every host runs regardless of sibling outcomes — the same as the unbounded fan-out).
1089
+
1090
+ ```typescript
1091
+ const deploy = job('deploy', {
1092
+ runsOnAll: 'role:web',
1093
+ onUnreachable: 'skip', // see the caveat below
1094
+ maxParallel: 1, // strictly one host at a time
1095
+ failFast: true, // stop the roll on the first failure
1096
+ run: async (ctx) => {
1097
+ /* patch ctx.host */
1098
+ },
1099
+ });
1100
+ ```
1101
+
1102
+ Both options are **fan-out-generic** — they bound a `matrix` fan-out exactly the same
1103
+ way (the children are matrix combinations instead of hosts). They are ignored on a job
1104
+ with neither `matrix` nor `runsOnAll` (there is no fan-out to bound).
1105
+
1106
+ **Caveat — use `onUnreachable: 'skip'` or `'fail'` for rolling deploys, not `'hold'`.**
1107
+ A held host occupies a wave slot indefinitely while it waits to reconnect, stalling the
1108
+ roll behind an absent box. `skip` (run only reachable hosts) or `fail` (refuse the roll
1109
+ if any expected host is down) keep the window moving.
1110
+
1111
+ ### Narrowing the roster at run time with `--target`
1112
+
1113
+ A `runsOnAll` predicate is authored once in the workflow, but you can narrow it for a
1114
+ single run with `kici run --target <selector>` — an Ansible-`--limit`-style runtime
1115
+ filter. The effective host set becomes `runsOnAll ∩ target`: the selector can only
1116
+ _remove_ hosts from the matched roster, never add them. The narrowing is **run-global**
1117
+ (it applies to every `runsOnAll` job) and **`runsOnAll`-only** (single `runsOn`-pinned
1118
+ jobs are untouched). Repeated `--target` values AND-combine — a host must satisfy every
1119
+ selector to survive.
1120
+
1121
+ ```bash
1122
+ # Patch only the role:web subset of whatever role:* hosts the job would match
1123
+ kici run remote deploy --target role:web
1124
+
1125
+ # Intersect two selectors: hosts must be BOTH role:web AND dc:eu
1126
+ kici run remote deploy --target role:web --target dc:eu
1127
+ ```
1128
+
1129
+ When `--target` narrows a `runsOnAll` job to zero hosts, the run **fails** by default
1130
+ (a mistyped selector should be loud, not silently no-op). Pass `--target-allow-empty`
1131
+ to **skip** the zeroed job instead — it records a `skipped` status, and downstream jobs
1132
+ gated with `when: 'on-skip'` (or `when: 'always'`) still run, exactly as for an
1133
+ `onUnreachable: 'skip'` zero-host fan-out. See the [CLI reference](https://docs.kici.dev/user/cli-reference/#host-narrowing-with---target)
1134
+ for the full flag behavior and the [`needs` gating model](https://docs.kici.dev/user/sdk/core/#job-dependencies-needs)
1135
+ for how a skipped upstream propagates.
1136
+
1137
+ ### Limits (v0)
1138
+
1139
+ - Per-host secret scoping is not yet available — all hosts receive the job's resolved
1140
+ secrets.
1141
+
1142
+ ---
1143
+
1144
+ ## SDK reference: runtime
1145
+
1146
+ Source: https://docs.kici.dev/user/sdk/runtime/
1147
+
1148
+ ## Types
1149
+
1150
+ All types are exported from `@kici-dev/sdk` as type-only imports.
1151
+
1152
+ ### Core types
1153
+
1154
+ | Type | Description |
1155
+ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1156
+ | `Workflow` | Workflow definition returned by `workflow()` |
1157
+ | `WorkflowOptions` | Options for `workflow()` factory |
1158
+ | `Job` | Job definition returned by `job()` |
1159
+ | `JobOptions` | Options for `job()` factory |
1160
+ | `Step<TOutputs>` | Step definition returned by `step()` |
1161
+ | `StepOptions<T>` | Options for `step()` factory (full form with outputs) |
1162
+ | `StepRunFn` | Simple step function type: `(ctx) => Promise<void>` |
1163
+ | `BareStepFn` | Bare step function (no options, just `(ctx) => ...`) |
1164
+ | `StepInput` | Union of step input forms accepted by `job()` |
1165
+ | `OutputSchema` | Record of Zod types for step outputs |
1166
+ | `InferOutputs<T>` | Infer output type from output schema |
1167
+ | `ContainerConfig` | Container config for job execution (`image`, `env?`) |
1168
+ | `RunsOn` | Union of `runsOn` forms: `string \| RegExp \| (string \| RegExp)[] \| RunsOnSelector`. A plain string matches exactly, a string with glob metacharacters (`*?[]{}`) is a glob, and a `RegExp` is a regular expression. See [Targeting by pattern](https://docs.kici.dev/user/sdk/core/#targeting-by-pattern). |
1169
+ | `RunsOnSelector` | Object form for `runsOn` with `labels` (required) and `exclude` (optional) properties. Each element accepts the exact / glob / regex forms on both sides. |
1170
+ | `RunsOnPick` | Single-agent selection policy when several agents match a `runsOn` selector: `'deterministic'` (stable hash — same job lands on the same host across re-runs) or `'any'` (spread load). See [runsOnAll](https://docs.kici.dev/user/sdk/runs-on-all/#targeting-by-pattern) for the fan-out forms. |
1171
+ | `Fixture` | Test fixture definition returned by `fixture()` |
1172
+ | `FixtureOptions` | Options for `fixture()` factory |
1173
+ | `Registry` | Private npm registry declaration used in `WorkflowOptions.registries` |
1174
+
1175
+ ### Trigger types
1176
+
1177
+ | Type | Description |
1178
+ | ------------------------------- | --------------------------------------------------------------------- |
1179
+ | `Trigger` | Trigger definition (trigger config + source location) |
1180
+ | `TriggerConfig` | Union of all 23 trigger config types |
1181
+ | `PrTriggerConfig` | PR trigger configuration (from `pr()`) |
1182
+ | `PushTriggerConfig` | Push trigger configuration (from `push()`) |
1183
+ | `TagTriggerConfig` | Tag trigger configuration (from `tag()`) |
1184
+ | `CommentTriggerConfig` | Comment trigger configuration (from `comment()`) |
1185
+ | `ReviewTriggerConfig` | Review trigger configuration (from `review()`) |
1186
+ | `ReviewCommentTriggerConfig` | Review comment trigger configuration (from `reviewComment()`) |
1187
+ | `ReleaseTriggerConfig` | Release trigger configuration (from `release()`) |
1188
+ | `DispatchTriggerConfig` | Repository dispatch trigger configuration (from `dispatch()`) |
1189
+ | `CreateTriggerConfig` | Ref creation trigger configuration (from `create()`) |
1190
+ | `DeleteTriggerConfig` | Ref deletion trigger configuration (from `delete()`) |
1191
+ | `StatusTriggerConfig` | Commit status trigger configuration (from `status()`) |
1192
+ | `WorkflowRunTriggerConfig` | Workflow run trigger configuration (from `workflowRun()`) |
1193
+ | `ForkTriggerConfig` | Fork trigger configuration (from `fork()`) |
1194
+ | `StarTriggerConfig` | Star trigger configuration (from `star()`) |
1195
+ | `WatchTriggerConfig` | Watch trigger configuration (from `watch()`) |
1196
+ | `WebhookTriggerConfig` | Catch-all webhook trigger configuration (from `webhook()`) |
1197
+ | `KiciEventTriggerConfig` | Custom event trigger configuration (from `kiciEvent()`) |
1198
+ | `WorkflowCompleteTriggerConfig` | Workflow completion trigger configuration (from `workflowComplete()`) |
1199
+ | `JobCompleteTriggerConfig` | Job completion trigger configuration (from `jobComplete()`) |
1200
+ | `GenericWebhookTriggerConfig` | Generic webhook trigger configuration (from `genericWebhook()`) |
1201
+ | `ScheduleTriggerConfig` | Schedule trigger configuration (from `schedule()`) |
1202
+ | `LifecycleTriggerConfig` | Lifecycle trigger configuration (from `lifecycle()`) |
1203
+ | `PrConfigInput` | Config object for `pr()` factory |
1204
+ | `PushConfigInput` | Config object for `push()` factory |
1205
+ | `BranchPattern` | `{ type: 'glob', pattern } \| { type: 'regex', pattern, flags? }` |
1206
+ | `PrEvent` | PR event string literal union (17 event types) |
1207
+ | `GenericWebhookConfigInput` | Config object for `genericWebhook()` factory |
1208
+ | `GenericWebhookAuth` | Union of generic webhook auth types (HMAC or API key) |
1209
+ | `GenericWebhookHmacAuth` | HMAC-SHA256 auth configuration for generic webhooks |
1210
+ | `GenericWebhookApiKeyAuth` | API key auth configuration for generic webhooks |
1211
+ | `GenericWebhookAuthMethod` | Auth method string literal (`'hmac-sha256' \| 'api-key'`) |
1212
+
1213
+ ### Rule types
1214
+
1215
+ | Type | Description |
1216
+ | ---------------------- | ----------------------------------------------------------------------- |
1217
+ | `Rule` | Rule definition returned by `rule()` / `skip()` |
1218
+ | `RuleCheckFn` | `(ctx: RuleContext) => Promise<boolean> \| boolean` |
1219
+ | `RuleContext` | Context passed to rule check functions |
1220
+ | `RuleResult` | Result of rule evaluation (label, passed, duration) |
1221
+ | `EventPayload` | Discriminated union over event type (narrow on `type` for autocomplete) |
1222
+ | `RuleEvaluationResult` | Result of `evaluateRules()` (allPassed + results) |
1223
+
1224
+ ### Matrix types
1225
+
1226
+ | Type | Description |
1227
+ | ---------------------- | ------------------------------------------------------------------- |
1228
+ | `Matrix` | Union: `StaticMatrixArray \| StaticMatrixObject \| DynamicMatrixFn` |
1229
+ | `StaticMatrixArray` | `string[]` |
1230
+ | `StaticMatrixObject` | `Record<string, string[]>` |
1231
+ | `DynamicMatrixFn` | `(ctx) => Promise<StaticMatrixArray \| StaticMatrixObject>` |
1232
+ | `DynamicMatrixContext` | Context passed to dynamic matrix functions |
1233
+ | `MatrixValues` | Values exposed to steps (`value?` + named dimensions) |
1234
+ | `MatrixInclude` | `Record<string, string>` -- additional combinations |
1235
+ | `MatrixExclude` | `Record<string, string>` -- removed combinations |
1236
+
1237
+ ### Hook types
1238
+
1239
+ | Type | Description |
1240
+ | ----------------- | --------------------------------------------------------------- |
1241
+ | `HookConfig` | Hook definition returned by hook factories (`onCancel()`, etc.) |
1242
+ | `HookFn` | Hook function type: `(ctx: HookContext) => Promise<void>` |
1243
+ | `HookInput` | Hook input: `HookFn \| { run: HookFn; timeout?: number }` |
1244
+ | `HookContext` | Context passed to hook functions |
1245
+ | `OutcomeMetadata` | Metadata about the outcome that triggered the hook |
1246
+
1247
+ ### Dynamic job types
1248
+
1249
+ | Type | Description |
1250
+ | ------------------- | ---------------------------------- |
1251
+ | `DynamicJobFn` | `(ctx) => Promise<Job[]>` |
1252
+ | `DynamicJobContext` | Context for dynamic job generators |
1253
+ | `JobOrFactory` | `Job \| DynamicJobFn` |
1254
+
1255
+ ### Context types
1256
+
1257
+ | Type | Description |
1258
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------- |
1259
+ | `StepContext<T>` | Context passed to step run functions |
1260
+ | `Logger` | Logger interface (info, warn, error, debug) |
1261
+ | `WorkflowInfo` | Workflow metadata: `{ name: string }` |
1262
+ | `JobInfo` | Job metadata: `{ name: string, runsOn: string }` |
1263
+ | `AgentInfo` | Facts about the pinned agent (`hostname`, `labels`, `platform`, `arch`), set on `runsOnAll` fan-out jobs |
1264
+ | `FanoutPosition` | Position of a child within its fan-out (host or matrix), deterministically ordered |
1265
+ | `MatrixJobOutputs` | Envelope returned by `jobOutputs()` for a matrix upstream: `{ byMatrix, merged }` |
1266
+ | `HostJobOutputs` | Envelope returned by `jobOutputs()` for a `runsOnAll` upstream, keyed per host |
1267
+ | `RepoInfo` | Repository metadata available in step context |
1268
+ | `StepSecrets` | Async accessor interface for step secrets (`get`, `expose`, `has`, `getMeta`, `list`, `mountFile`, `exposeFile`) |
1269
+ | `StepSecretsTyped` | Typed step secrets with known key inference |
1270
+ | `KnownSecretKeys` | String literal union of declared secret context keys |
1271
+ | `SecretNotFoundError` | Thrown when accessing a nonexistent key in secrets |
1272
+
1273
+ ## StepContext
1274
+
1275
+ The context object passed to every step's `run` function:
1276
+
1277
+ ```typescript
1278
+ interface StepContext<TInputs = Record<string, unknown>> {
1279
+ /** zx shell executor for running commands */
1280
+ $: typeof Shell;
1281
+ /** Structured logger */
1282
+ log: Logger;
1283
+ /** Environment variables */
1284
+ env: Record<string, string | undefined>;
1285
+ /** Set an environment variable visible to this step and all subsequent steps */
1286
+ setEnv(key: string, value: string): void;
1287
+ /** Prepend a directory to PATH, visible to this step and all subsequent steps */
1288
+ addPath(dir: string): void;
1289
+ /** Aborted when this step should stop early (job cancelled, job timeout, fail-fast sibling) */
1290
+ signal: AbortSignal;
1291
+ /** Typed inputs from dependency step outputs */
1292
+ inputs: TInputs;
1293
+ /** Validated workflow-dispatch inputs declared via `dispatch({ inputs })` */
1294
+ dispatchInputs: Readonly<Record<string, string | number | boolean | null>>;
1295
+ /** Current workflow metadata */
1296
+ workflow: WorkflowInfo;
1297
+ /** Current job metadata */
1298
+ job: JobInfo;
1299
+ /** Matrix values for current job instance (undefined without matrix) */
1300
+ matrix?: MatrixValues;
1301
+ /** Hostname of the pinned agent — set only on `runsOnAll` host fan-out */
1302
+ host?: string;
1303
+ /** Facts about the pinned agent — set only on `runsOnAll` host fan-out */
1304
+ agent?: AgentInfo;
1305
+ /** Position of this child within its fan-out (host or matrix); undefined otherwise */
1306
+ fanout?: FanoutPosition;
1307
+ /** Raw webhook payload from the git provider */
1308
+ rawPayload?: Record<string, unknown>;
1309
+ /** Which git provider triggered this workflow (e.g. 'github', 'gitlab') */
1310
+ provider?: string;
1311
+ /** Whether this execution was triggered by `kici run remote` (developer-initiated remote run) */
1312
+ isTestRun: boolean;
1313
+ /** Registering repo of a global workflow (undefined for non-global workflows) */
1314
+ workflowRepo?: RepoInfo;
1315
+ /** Repo where the triggering event occurred (undefined for non-global workflows) */
1316
+ sourceRepo?: RepoInfo;
1317
+ /** The resolved context name for this job (undefined for jobs without a context) */
1318
+ context?: string;
1319
+ /** Secrets resolved for this job's context. Never injected into env automatically. */
1320
+ secrets: StepSecretsTyped;
1321
+ /** Emit a custom event that can trigger other workflows — typed or ad-hoc by name */
1322
+ emit<T extends z.ZodTypeAny>(
1323
+ definition: EventDefinition<T>,
1324
+ payload: z.infer<T>,
1325
+ options?: EventEmitOptions,
1326
+ ): Promise<{ deliveryId: string }>;
1327
+ emit(
1328
+ eventName: string,
1329
+ payload?: Record<string, unknown>,
1330
+ options?: EventEmitOptions,
1331
+ ): Promise<{ deliveryId: string }>;
1332
+ /** Resolve outputs from a preceding step by reference */
1333
+ outputsOf<T>(ref: { _tag: 'Step'; name: string } | ((...args: any[]) => any)): T;
1334
+ /** Resolve outputs from a preceding job by reference (fan-out upstreams return an envelope) */
1335
+ jobOutputs<T>(ref: Job<T>): T | MatrixJobOutputs<T> | HostJobOutputs<T>;
1336
+ /** Publish a secret output value from this job (encrypted before leaving the agent) */
1337
+ setSecretOutput(key: string, value: string): void;
1338
+ /** Typed KiCI API — orchestrator queries over WS (e.g. `kici.infrastructure.list()`) */
1339
+ kici: KiciApi;
1340
+ /** Imperative cache API — `cache.restore(spec)` / `cache.save(spec)` */
1341
+ cache: CacheApi;
1342
+ /** Imperative artifacts API — `artifacts.upload(name, paths)` / `artifacts.download(name)` */
1343
+ artifacts: ArtifactsApi;
1344
+ /** Build, sign, and persist a build-provenance attestation for a produced artifact */
1345
+ attestProvenance(opts: AttestProvenanceOptions): Promise<AttestProvenanceResult>;
1346
+ /** Allocate a job-scoped scratch directory, removed automatically when the job ends */
1347
+ mktemp(label?: string): Promise<TempHandle>;
1348
+ /** Allocate a job-scoped scratch file, removed automatically when the job ends */
1349
+ mktempFile(label?: string, opts?: { suffix?: string }): Promise<TempHandle>;
1350
+ /** Upstream needs keyed by job or group name — `needs.<job>.result` / `.status` */
1351
+ needs?: NeedsContext;
1352
+ }
1353
+ ```
1354
+
1355
+ ### Logger
1356
+
1357
+ ```typescript
1358
+ interface Logger {
1359
+ info(message: string, ...args: unknown[]): void;
1360
+ warn(message: string, ...args: unknown[]): void;
1361
+ error(message: string, ...args: unknown[]): void;
1362
+ debug(message: string, ...args: unknown[]): void;
1363
+ }
1364
+ ```
1365
+
1366
+ ### Usage
1367
+
1368
+ ```typescript
1369
+ step('example', async ({ $, log, env, matrix, workflow, job }) => {
1370
+ log.info(`Running in workflow: ${workflow.name}`);
1371
+ log.info(`Job: ${job.name} on ${job.runsOn}`);
1372
+
1373
+ if (matrix) {
1374
+ log.info(`Matrix value: ${matrix.value}`);
1375
+ }
1376
+
1377
+ const token = env.GITHUB_TOKEN;
1378
+ await $`echo "Building..."`;
1379
+ });
1380
+ ```
1381
+
1382
+ ### `rawPayload` and rule-context parity
1383
+
1384
+ `ctx.rawPayload` carries the same data that rule contexts access via `ctx.event.payload` — the unmodified webhook body from the git provider. A rule that branches on `ctx.event.payload.client_payload.foo` and a step body that reads `ctx.rawPayload.client_payload.foo` see the same value. Use it inside steps when the operator's dispatch payload (or any other provider-specific field) needs to drive runtime behavior — e.g. a `--dry-run` toggle or a deploy target — without bouncing the data through an env var.
1385
+
1386
+ **What's captured in the dashboard log viewer.** KiCI captures user output from every place in a workflow that can run TypeScript:
1387
+
1388
+ - **Inside a step body** — the agent merges three streams into the step's log: `ctx.log.*` structured calls, subprocess stdout/stderr from `ctx.$`, and any direct `console.log` / `.error` / `.warn` / `.info` / `.debug` (or other library that writes to `process.stdout` / `process.stderr`).
1389
+ - **Inside hooks** (`beforeStep`, `afterStep`, `onSuccess`, `onFailure`, `onCancel`, `cleanup`) — the same three streams are captured; per-step hooks share the step's log, post-loop hooks get their own dashboard row.
1390
+ - **At workflow module top-level, in rule `check` functions, and in the workflow `concurrency.group` function** — captured to the workflow-level `prepare` log bucket for the job, alongside KiCI's own setup narration.
1391
+ - **Inside a dynamic `context` / `env` / `concurrencyGroup` function** on a static job — captured to the `__init__` job's synthetic step-0 log, which appears in the timeline as "Init: _jobname_".
1392
+ - **Inside a `DynamicJobFn` body and the per-generated-job `context` / `env` / `concurrencyGroup` / `matrix` functions** — captured to the `__dynamic__` job's synthetic step-0 log ("Evaluate: _jobname_" in the timeline). The `$` parameter in that context is a scoped zx shell, so `await $\`...\`` subprocess output is captured too.
1393
+
1394
+ Use whichever style is convenient — you don't have to wrap `console.log` in the provided `log` parameter to make it visible. One limitation applies to in-process contexts only (init, build, dynamic-eval): direct `process.stdout.write` / `printf` is not captured there, because the agent's own logger uses that path and we don't want agent-internal output leaking into your step logs. Use `console.*` or the `log` parameter instead. See [Log streaming](https://docs.kici.dev/architecture/execution/job-execution/#log-streaming) for the full capture surface and limits (default 10 MB per step, backpressure behavior).
1395
+
1396
+ ### setEnv(key, value)
1397
+
1398
+ Export an environment variable to later steps in the same job. This is the canonical way to hand a value computed in one step to the steps that follow — the equivalent of `echo "KEY=VALUE" >> $GITHUB_ENV` in GitHub Actions. The value is visible to the current step and all subsequent steps in the job.
1399
+
1400
+ ```typescript
1401
+ step('setup', async (ctx) => {
1402
+ // Install a tool and record its version
1403
+ await ctx.$`npm install -g some-tool`;
1404
+ const version = (await ctx.$`some-tool --version`).stdout.trim();
1405
+ ctx.setEnv('TOOL_VERSION', version);
1406
+ });
1407
+
1408
+ step('use', async (ctx) => {
1409
+ // TOOL_VERSION is available here
1410
+ ctx.log.info(`Using tool version: ${ctx.env.TOOL_VERSION}`);
1411
+ });
1412
+ ```
1413
+
1414
+ **Behavior:**
1415
+
1416
+ - Last-write-wins -- if multiple steps set the same key, the last value is used
1417
+ - Cannot override operator-injected secrets (the operator value takes precedence)
1418
+ - Changes take effect immediately in the current step and persist for all subsequent steps
1419
+ - Shell commands export the same way by appending to `$KICI_ENV` (see [Exporting env from shell commands](https://docs.kici.dev/user/sdk/runtime/#exporting-env-from-shell-commands-kici_env--kici_path) below)
1420
+
1421
+ ### addPath(dir)
1422
+
1423
+ Prepend a directory to `PATH` for the current step and all subsequent steps in the same job. Useful for tools installed to non-standard locations.
1424
+
1425
+ ```typescript
1426
+ step('install-go', async (ctx) => {
1427
+ await ctx.$`curl -L https://go.dev/dl/go1.22.0.linux-amd64.tar.gz | tar -C /tmp -xz`;
1428
+ ctx.addPath('/tmp/go/bin');
1429
+ });
1430
+
1431
+ step('build', async (ctx) => {
1432
+ // `go` is now on PATH
1433
+ await ctx.$`go build ./...`;
1434
+ });
1435
+ ```
1436
+
1437
+ ### Exporting env from shell commands ($KICI_ENV / $KICI_PATH)
1438
+
1439
+ `setEnv` and `addPath` are the TypeScript form of "export env to later steps". A shell command — including a non-JS toolchain installer — exports env the same way by appending to two files the agent points at before every step:
1440
+
1441
+ - **`$KICI_ENV`** — append `KEY=value` lines. Each becomes an environment variable visible to subsequent steps, exactly like `ctx.setEnv('KEY', 'value')`.
1442
+ - **`$KICI_PATH`** — append one directory per line. Each is prepended to `PATH` for subsequent steps, exactly like `ctx.addPath(dir)`. The first directory appended ends up first on `PATH`.
1443
+
1444
+ ```typescript
1445
+ step('install-tool', async (ctx) => {
1446
+ await ctx.$`./install-mytool.sh`; // installs to /opt/mytool
1447
+ // Export from the shell, no JS round-trip needed:
1448
+ await ctx.$`echo "MYTOOL_HOME=/opt/mytool" >> "$KICI_ENV"`;
1449
+ await ctx.$`echo "/opt/mytool/bin" >> "$KICI_PATH"`;
1450
+ });
1451
+
1452
+ step('build', async (ctx) => {
1453
+ // MYTOOL_HOME is set and /opt/mytool/bin is on PATH here.
1454
+ await ctx.$`mytool build`;
1455
+ });
1456
+ ```
1457
+
1458
+ **Format (v1):**
1459
+
1460
+ - One `KEY=value` per line in `$KICI_ENV`. The split is on the first `=`, so the value may contain `=`. Blank lines and lines without a `=` are ignored.
1461
+ - One directory per line in `$KICI_PATH`. Blank lines are ignored.
1462
+ - Values must be single-line — embedded newlines are not supported in v1.
1463
+
1464
+ **Behavior (shared with `setEnv` / `addPath`):**
1465
+
1466
+ - Applied after the step completes and visible to every later step in the job.
1467
+ - Last-write-wins on a repeated key.
1468
+ - Cannot override an operator-injected secret — a collision is ignored and logged, and the operator value is preserved.
1469
+ - The files are reset before each step, so each step sees only its own appended lines.
1470
+
1471
+ ### setSecretOutput(key, value)
1472
+
1473
+ Publish an encrypted secret output from this job. Downstream jobs that list this job in their `needs` array receive the value merged into `ctx.secrets`.
1474
+
1475
+ ```typescript
1476
+ const generateToken = job('generate-token', {
1477
+ steps: [
1478
+ step('create', async (ctx) => {
1479
+ const token = (await ctx.$`vault write -f auth/token/create`).stdout.trim();
1480
+ ctx.setSecretOutput('DEPLOY_TOKEN', token);
1481
+ }),
1482
+ ],
1483
+ });
1484
+
1485
+ const deploy = job('deploy', {
1486
+ needs: [generateToken],
1487
+ steps: [
1488
+ step('deploy', async (ctx) => {
1489
+ // DEPLOY_TOKEN is available as a secret (decrypted by the orchestrator)
1490
+ const token = await ctx.secrets.get('DEPLOY_TOKEN');
1491
+ await ctx.$`DEPLOY_TOKEN=${token} ./deploy.sh`;
1492
+ }),
1493
+ ],
1494
+ });
1495
+ ```
1496
+
1497
+ **Security model:**
1498
+
1499
+ - The value is encrypted on the agent before leaving the machine (X25519 ECDH + AES-256-GCM)
1500
+ - The orchestrator decrypts and re-encrypts with its own key before storing
1501
+ - The ephemeral key pair is deleted when the run completes (forward secrecy)
1502
+ - Downstream agents never see the plaintext -- they receive it as part of their injected secrets
1503
+
1504
+ **Limits:**
1505
+
1506
+ - Maximum 20 secret outputs per job
1507
+ - Maximum 64 KB per value
1508
+
1509
+ ### ctx.kici.oidc.token({ audience })
1510
+
1511
+ Request a short-lived OIDC ID token for the current job, bound to an `audience`. The token is a signed JWT whose identity claims (`repository`, `ref`, `sha`, `kici_run_id`, `kici_job_id`) are derived by your orchestrator from the run context — a step cannot spoof them. Use it to authenticate the build to an external service that trusts the orchestrator's OIDC issuer (for example, when generating build provenance).
1512
+
1513
+ ```typescript
1514
+ const publish = job('publish', {
1515
+ steps: [
1516
+ step('mint', async (ctx) => {
1517
+ const { token, expiresIn } = await ctx.kici.oidc.token({ audience: 'sigstore' });
1518
+ ctx.log.info(`Got an ID token valid for ${expiresIn}s`);
1519
+ // Hand `token` to a tool that exchanges it with the trusting service.
1520
+ }),
1521
+ ],
1522
+ });
1523
+ ```
1524
+
1525
+ **Behavior:**
1526
+
1527
+ - The token is short-lived (about 10 minutes) and scoped to the current run and job.
1528
+ - The returned token value is automatically masked in step logs.
1529
+ - The step never holds signing credentials — the orchestrator mints and signs the token on the step's behalf from its own run records.
1530
+ - Only available inside a running job step; calling it outside one (a dynamic-job generator, or the workflow module's top level) rejects with a clear error. `kici run --local` runs are supported: the local dev plane mints dev-signed tokens under the clearly-non-production issuer `kici-local`.
1531
+
1532
+ ### ctx.kici.inventory.query(selector?) / .get(agentId)
1533
+
1534
+ Query the **host inventory** — the roster of agents in the caller's orchestrator cluster — from inside a workflow. Each host is a `HostInventoryEntry`:
1535
+
1536
+ ```typescript
1537
+ interface HostInventoryEntry {
1538
+ agentId: string;
1539
+ labels: string[]; // flat-string grouping/tags dimension
1540
+ properties: Record<string, string | number | boolean>; // typed host-vars dimension
1541
+ hostname: string | null;
1542
+ platform: string | null;
1543
+ arch: string | null;
1544
+ lifecycleClass: 'static' | 'ephemeral';
1545
+ status: 'ready' | 'unreachable' | 'stale';
1546
+ lastSeen: string; // ISO timestamp
1547
+ }
1548
+ ```
1549
+
1550
+ Two dimensions describe a host. **Labels** are flat strings used for grouping and targeting (the same labels `runsOn` / `runsOnAll` match). **Properties** are typed host-vars (`string | number | boolean`) — the place for facts like `region`, `cores`, or `gpu`. A host reports its own properties via the agent's `KICI_PROPERTIES` config, and an operator can pre-declare them with `kici-admin host declare --prop key=value`; the two are shallow-merged (agent-reported keys win).
1551
+
1552
+ ```typescript
1553
+ // All hosts:
1554
+ const all = await ctx.kici.inventory.query();
1555
+
1556
+ // Server-side label filter (OR-of-AND include groups, plus exclude):
1557
+ const dbHosts = await ctx.kici.inventory.query({
1558
+ include: [[{ kind: 'exact', value: 'role:db' }]],
1559
+ });
1560
+
1561
+ // Property filtering is client-side — plain JS in the workflow:
1562
+ const euDbHosts = dbHosts.filter((h) => h.properties.region === 'eu');
1563
+
1564
+ // One host by id:
1565
+ const host = await ctx.kici.inventory.get('box-1'); // HostInventoryEntry | null
1566
+ ```
1567
+
1568
+ **The label selector is applied server-side** (reusing the same glob/regex matchers as `runsOnAll`). **Property filtering is client-side** — you filter the returned array in plain JavaScript, so there is no query DSL to learn.
1569
+
1570
+ **Headline use — dynamic-job fan-out.** A dynamic-job generator can query the inventory and return one job per matching host, fanning a workflow out across a fleet:
1571
+
1572
+ ```typescript
1573
+ const migrate = job('migrate', async (ctx) => {
1574
+ const hosts = await ctx.kici.inventory.query({
1575
+ include: [[{ kind: 'exact', value: 'role:db' }]],
1576
+ });
1577
+ return hosts
1578
+ .filter((h) => h.properties.region === 'eu')
1579
+ .map((h) =>
1580
+ job(`migrate-${h.agentId}`, {
1581
+ runsOn: [h.agentId],
1582
+ run: async (c) => {
1583
+ await c.$`./migrate.sh`;
1584
+ },
1585
+ }),
1586
+ );
1587
+ });
1588
+ ```
1589
+
1590
+ A `runsOn` of a single host's `agentId` (as in `runsOn: [h.agentId]` above) **pins the job to that host**: the orchestrator routes it to that agent only, and queues it with the pin if the host is momentarily offline — the same host-pin path `runsOnAll` uses. A `runsOn` with multiple labels or a glob/regex pattern stays ordinary label routing.
1591
+
1592
+ `ctx.kici.inventory` is available to **both** steps and dynamic-job generators (unlike `ctx.kici.oidc.token`, which is job-bound — the inventory is cluster-scoped, not job-bound).
1593
+
1594
+ **Determinism caveat.** The inventory is **live**: it can change between when a dynamic-job generator first runs (at dispatch) and when it re-evaluates (at agent time). Generating jobs from `inventory.query()` therefore inherits the same non-determinism contract as `infrastructure.list()` — KiCI warns when the re-evaluated job set drifts (a sibling job name changed) and hard-errors when a targeted job vanishes. Prefer stable inputs where you can, and treat a fanned-out job set as a snapshot of the roster at generation time.
1595
+
1596
+ ### ctx.attestProvenance({ subject })
1597
+
1598
+ Build, sign, and persist a build-provenance attestation for an artifact your step produced. KiCI assembles an in-toto SLSA v1.0 provenance statement whose build identity (`repository`, `ref`, `sha`, run/job ids) comes from your orchestrator — not from the step — so it cannot be spoofed, signs it, and stores a verifiable bundle that the dashboard surfaces and the `kici verify-attestation` CLI checks.
1599
+
1600
+ The artifact is **caller-supplied**: give it either a precomputed digest or a path (relative to the step working directory) that KiCI digests with SHA-256. For a container image, pass the manifest digest your build tool emitted.
1601
+
1602
+ ```typescript
1603
+ const publish = job('publish', {
1604
+ steps: [
1605
+ step('build', async (ctx) => {
1606
+ await ctx.$`npm pack`;
1607
+ }),
1608
+ step('attest', async (ctx) => {
1609
+ // Digest a file KiCI hashes for you:
1610
+ const result = await ctx.attestProvenance({
1611
+ subject: { name: 'my-pkg-1.2.3.tgz', path: 'my-pkg-1.2.3.tgz' },
1612
+ });
1613
+ ctx.log.info(`Attestation stored at ${result.storageKey}`);
1614
+
1615
+ // Or supply a precomputed digest (e.g. a container manifest digest):
1616
+ await ctx.attestProvenance({
1617
+ subject: { name: 'ghcr.io/acme/app', digest: { sha256: '<manifest-digest>' } },
1618
+ });
1619
+ }),
1620
+ ],
1621
+ });
1622
+ ```
1623
+
1624
+ **Behavior:**
1625
+
1626
+ - The attestation is a signed [DSSE](https://github.com/secure-systems-lab/dsse) envelope over an [in-toto](https://in-toto.io) statement carrying the [SLSA v1.0](https://slsa.dev/spec/v1.0/provenance) provenance predicate.
1627
+ - It is signed with an ephemeral key bound to an orchestrator-minted identity token, so it is **offline-verifiable** against the orchestrator's published signing keys — no online lookup needed at verify time.
1628
+ - The bundle is persisted to object storage and recorded so the dashboard can show it and `kici verify-attestation` can retrieve it.
1629
+ - The returned `{ storageKey, subjectDigest, bundleMediaType }` identifies the stored bundle.
1630
+ - Only available inside a running job step; calling it outside one (a dynamic-job generator, or the workflow module's top level) rejects with a clear error. `kici run --local` runs are supported: the offline local dev plane signs with a dev identity under the clearly-non-production issuer `kici-local`, and those bundles verify against a trust root exported with `kici local trust-root`.
1631
+
1632
+ See the [build provenance guide](https://docs.kici.dev/user/provenance/) for the end-to-end attest →
1633
+ verify → view journey, including how to verify a bundle with `kici verify-attestation`.
1634
+
1635
+ ## Secrets
1636
+
1637
+ Workflows access secrets through `ctx.secrets` on `StepContext`. Use `await ctx.secrets.get('KEY')` to retrieve a value (rejects with `SecretNotFoundError` if the key is missing, fail-fast on typos), `ctx.secrets.has('KEY')` for a synchronous existence check, and `await ctx.secrets.expose('KEY')` when you need the value as a `process.env` entry for a child process.
1638
+
1639
+ ### Declaring the secret context
1640
+
1641
+ Each job picks its secret context via the `context` option on `job()`. The orchestrator resolves the context's scoped-secret store at dispatch time, evaluates access rules, and sends the decrypted secrets to the agent:
1642
+
1643
+ ```typescript
1644
+ const deploy = job('deploy', {
1645
+ runsOn: 'linux',
1646
+ context: 'production',
1647
+ steps: [/* ... */],
1648
+ });
1649
+
1650
+ export default workflow('deploy', {
1651
+ on: push({ branches: 'main' }),
1652
+ jobs: [deploy],
1653
+ });
1654
+ ```
1655
+
1656
+ `context` accepts either a static string or an async function `(event) => string | Promise<string>` for dynamic resolution at trigger-evaluation time. The resolved context's secrets are flattened into `ctx.secrets`.
1657
+
1658
+ ### Accessing secrets (ctx.secrets)
1659
+
1660
+ `ctx.secrets` provides flat access to the secrets resolved for the job's context.
1661
+
1662
+ ```typescript
1663
+ step('deploy', async ({ secrets }) => {
1664
+ // get() rejects with SecretNotFoundError if DEPLOY_TOKEN is not found
1665
+ const token = await secrets.get('DEPLOY_TOKEN');
1666
+
1667
+ // Safe check before access (no throw, synchronous)
1668
+ if (secrets.has('OPTIONAL_KEY')) {
1669
+ const optional = await secrets.get('OPTIONAL_KEY');
1670
+ }
1671
+ });
1672
+ ```
1673
+
1674
+ **Throw behavior:** `get()` rejects with `SecretNotFoundError` and the message lists all available keys. This catches typos immediately rather than producing silent `undefined` values.
1675
+
1676
+ ### Complete example
1677
+
1678
+ ```typescript
1679
+ import { workflow, job, step, push } from '@kici-dev/sdk';
1680
+
1681
+ const deploy = job('deploy', {
1682
+ runsOn: 'linux',
1683
+ context: 'production',
1684
+ steps: [
1685
+ step('deploy', async (ctx) => {
1686
+ const token = await ctx.secrets.get('DEPLOY_TOKEN');
1687
+
1688
+ // Safe check before access
1689
+ if (ctx.secrets.has('OPTIONAL_NOTIFICATION_URL')) {
1690
+ const url = await ctx.secrets.get('OPTIONAL_NOTIFICATION_URL');
1691
+ ctx.log.info('Sending notification...');
1692
+ }
1693
+
1694
+ // Pass to subprocess explicitly (secrets are NOT auto-injected as env vars)
1695
+ await ctx.$`DEPLOY_TOKEN=${token} ./scripts/deploy.sh`;
1696
+ }),
1697
+ ],
1698
+ });
1699
+
1700
+ export default workflow('deploy-production', {
1701
+ on: push({ branches: 'main' }),
1702
+ jobs: [deploy],
1703
+ });
1704
+ ```
1705
+
1706
+ ### Security notes
1707
+
1708
+ - Secrets are **not** automatically injected as environment variables. You must explicitly pass them to subprocesses.
1709
+ - All secret values are automatically **masked** in log output. If a step logs a string containing a secret value, the value is replaced with `***`.
1710
+ - Secrets flow from the orchestrator to the agent via the authenticated WebSocket channel. The Platform tier never handles secret material.
1711
+
1712
+ ### Enumerating available keys (ctx.secrets.list)
1713
+
1714
+ `ctx.secrets.list()` returns every secret key available to the step, sorted alphabetically. Synchronous, never throws, names only — call `getMeta(key)` to inspect backend / scope per key. Useful when the set of provisioned keys isn't known at workflow-author time, for example to pick up every `AGE_KEY_*` the operator has seeded:
1715
+
1716
+ ```typescript
1717
+ step('discover', async (ctx) => {
1718
+ const ageKeys = ctx.secrets.list().filter((k) => k.startsWith('AGE_KEY_'));
1719
+ ctx.log.info(`Found ${ageKeys.length} age keys`);
1720
+ });
1721
+ ```
1722
+
1723
+ ### File-mounted secrets (ctx.secrets.mountFile / exposeFile)
1724
+
1725
+ Tools that require a file path on disk (sops `SOPS_AGE_KEY_FILE`, kubectl `KUBECONFIG`, gcloud `GOOGLE_APPLICATION_CREDENTIALS`) get a typed step-side API: `ctx.secrets.mountFile(opts)` writes the concatenation of one or more existing secrets to a per-step tmpfile and returns the path; `ctx.secrets.exposeFile(envVar, opts)` additionally sets `process.env[envVar] = path`. Files are removed and env vars are unset automatically when the step completes (success, failure, or timeout) — no manual cleanup. See [Mounting secrets as files](https://docs.kici.dev/user/secrets/#mounting-secrets-as-files) for the full options table, lifecycle details, and the canonical sops example.
1726
+
1727
+ ### Local test mode secrets
1728
+
1729
+ When running `kici preview`, you can provide secrets locally without an orchestrator.
1730
+
1731
+ #### .kici/.secrets file
1732
+
1733
+ Create a `.kici/.secrets` file in your project (auto-gitignored by `kici init`):
1734
+
1735
+ ```ini
1736
+ # Flat secrets (before any section)
1737
+ DEPLOY_TOKEN=my-deploy-token
1738
+ API_KEY=my-api-key
1739
+
1740
+ # Context-scoped secrets
1741
+ [production]
1742
+ DB_PASSWORD=prod-secret
1743
+ API_KEY=prod-key
1744
+
1745
+ [npm-publish]
1746
+ NPM_TOKEN=npm-abc123
1747
+ ```
1748
+
1749
+ Lines before any `[section]` header are flat secrets. Lines within a section become context-scoped secrets. Comments start with `#`. Values are everything after the first `=` (so values can contain `=` characters).
1750
+
1751
+ #### CLI flags
1752
+
1753
+ Override or supplement file-based secrets with CLI flags:
1754
+
1755
+ ```bash
1756
+ # Inject flat secrets (repeatable)
1757
+ kici preview push --secret DEPLOY_TOKEN=my-token --secret API_KEY=my-key
1758
+
1759
+ # Inject context-scoped secrets (repeatable)
1760
+ kici preview push --context production.DB_PASSWORD=prod-secret --context npm-publish.NPM_TOKEN=abc123
1761
+ ```
1762
+
1763
+ **Precedence:** CLI flags override `.kici/.secrets` file values. Context secrets are auto-flattened into `ctx.secrets` using the same merge logic as production (last context wins).
1764
+
1765
+ ## Fixtures
1766
+
1767
+ Test fixtures define event replicas for `kici run remote`. They simulate trigger events without requiring real webhooks.
1768
+
1769
+ ### fixture(id, options)
1770
+
1771
+ ```typescript
1772
+ function fixture(
1773
+ id: string,
1774
+ options: FixtureOptions | (() => FixtureOptions | Promise<FixtureOptions>),
1775
+ ): Fixture;
1776
+ ```
1777
+
1778
+ **Parameters:**
1779
+
1780
+ - `id` — unique fixture name (no whitespace). Used in `kici run remote <id>`.
1781
+ - `options` — a `FixtureOptions` object, or an async factory function returning one.
1782
+
1783
+ ```typescript
1784
+ import { fixture, push } from '@kici-dev/sdk';
1785
+
1786
+ export const pushMain = fixture('push-main', {
1787
+ event: push({ branches: ['main'] }),
1788
+ });
1789
+ ```
1790
+
1791
+ ### FixtureOptions
1792
+
1793
+ | Property | Type | Description |
1794
+ | -------------- | ------------------------ | ---------------------------------------------------------- |
1795
+ | `event` | `TriggerConfig` | The trigger event to simulate (required) |
1796
+ | `branch` | `string` | Override branch name (defaults to git-detected) |
1797
+ | `sha` | `string` | Override commit SHA (defaults to HEAD) |
1798
+ | `repo` | `string` | Override repository (defaults to git-detected) |
1799
+ | `pr` | `number` | For PR events, override PR number |
1800
+ | `secrets` | `Record<string, string>` | Secret context mappings: `{ localName: 'remote-context' }` |
1801
+ | `workflowName` | `string` | Bypass trigger matching and run this workflow directly |
1802
+
1803
+ Options can also be provided as an async factory function for dynamic fixture generation.
1804
+
1805
+ ---
1806
+
1807
+ ## SDK reference: temp directories
1808
+
1809
+ Source: https://docs.kici.dev/user/sdk/temp-directories/
1810
+
1811
+ Steps often need a throwaway working directory — somewhere to unpack an archive, stage a build, or write an intermediate file. `ctx.mktemp()` and `ctx.mktempFile()` allocate that scratch space and clean it up for you when the job ends, so you never leak temp trees on the agent.
1812
+
1813
+ ## ctx.mktemp(label?)
1814
+
1815
+ Allocate a scratch **directory** for the current job. Returns a handle:
1816
+
1817
+ ```typescript
1818
+ interface TempHandle {
1819
+ /** Absolute path to the allocated directory (or file, for mktempFile). */
1820
+ readonly path: string;
1821
+ /** Remove the allocation. Idempotent — safe to call more than once. */
1822
+ cleanup(): Promise<void>;
1823
+ /** Enables `await using` — disposes on scope exit. */
1824
+ [Symbol.asyncDispose](): Promise<void>;
1825
+ }
1826
+ ```
1827
+
1828
+ ```typescript
1829
+ step('build', async (ctx) => {
1830
+ const scratch = await ctx.mktemp();
1831
+ await ctx.$`git clone --depth 1 https://example.com/repo.git ${scratch.path}`;
1832
+ await ctx.$`tar -czf out.tgz -C ${scratch.path} .`;
1833
+ });
1834
+ ```
1835
+
1836
+ The `label` argument is optional. When omitted it defaults to a sanitized step id, so the directory name carries a hint about which step created it. Pass an explicit label to make it obvious in the temp root:
1837
+
1838
+ ```typescript
1839
+ const cache = await ctx.mktemp('npm-cache');
1840
+ // path looks like /tmp/kici-npm-cache-a1b2c3
1841
+ ```
1842
+
1843
+ A label must be lowercase alphanumeric with hyphens (`a-z`, `0-9`, `-`).
1844
+
1845
+ ## ctx.mktempFile(label?, { suffix? })
1846
+
1847
+ Allocate a scratch **file** instead of a directory. Same handle shape; `path` points at an empty file you can write to. Pass `suffix` to give the file an extension:
1848
+
1849
+ ```typescript
1850
+ step('render', async (ctx) => {
1851
+ const config = await ctx.mktempFile('render-config', { suffix: '.json' });
1852
+ await ctx.$`echo ${JSON.stringify({ mode: 'prod' })} > ${config.path}`;
1853
+ await ctx.$`my-tool --config ${config.path}`;
1854
+ // path looks like /tmp/kici-render-config-a1b2c3/render-config.json
1855
+ });
1856
+ ```
1857
+
1858
+ ## Automatic cleanup
1859
+
1860
+ Every allocation from `ctx.mktemp()` / `ctx.mktempFile()` is tied to the job. When the job ends — on **success, failure, or cancellation** — its scratch dirs and files are removed automatically. You do not have to clean up in a `finally` block or worry about a failing step leaving debris behind.
1861
+
1862
+ ## Manual cleanup
1863
+
1864
+ The returned `cleanup()` lets you release a large allocation early, before the job finishes — useful when a later step no longer needs a multi-gigabyte checkout:
1865
+
1866
+ ```typescript
1867
+ step('extract', async (ctx) => {
1868
+ const work = await ctx.mktemp('extract');
1869
+ await ctx.$`tar -xzf big-archive.tgz -C ${work.path}`;
1870
+ await ctx.$`./process.sh ${work.path}`;
1871
+ await work.cleanup(); // free the space now; don't wait for job end
1872
+ });
1873
+ ```
1874
+
1875
+ `cleanup()` is **idempotent** — calling it a second time (or letting the automatic job-end cleanup run after you already called it) is a no-op, never an error.
1876
+
1877
+ ## The `await using` form
1878
+
1879
+ Because a handle is an async disposable, you can bind its lifetime to the enclosing scope with `await using`. The directory is removed as soon as the block exits, whether it returns normally or throws:
1880
+
1881
+ ```typescript
1882
+ step('sign', async (ctx) => {
1883
+ await using keydir = await ctx.mktemp('gpg-home');
1884
+ await ctx.$`gpg --homedir ${keydir.path} --import key.asc`;
1885
+ await ctx.$`gpg --homedir ${keydir.path} --detach-sign artifact.tar`;
1886
+ // keydir is disposed here, at the end of the block
1887
+ });
1888
+ ```
1889
+
1890
+ This is the tidiest form when a scratch dir is only needed for a bounded section of a step and you want it gone the moment you are done with it — you get the same guaranteed cleanup as a `try/finally` without the boilerplate.
1891
+
1892
+ ## See also
1893
+
1894
+ - [SDK reference: runtime](https://docs.kici.dev/user/sdk/runtime/) — the full `StepContext` surface (`$`, `log`, `env`, `secrets`, and more).
1895
+ - [Artifacts](https://docs.kici.dev/user/sdk/artifacts/) — for durable, named build deliverables that outlive the job, use `ctx.artifacts` instead of a temp dir.
1896
+
1897
+ ---
1898
+
1899
+ ## SDK reference: waitFor
1900
+
1901
+ Source: https://docs.kici.dev/user/sdk/wait-for/
1902
+
1903
+ The SDK exposes two wait-for helpers — a generic function `waitFor()` and a step factory `waitForStep()` — for the common case where a workflow step should:
1904
+
1905
+ 1. **Poll** a condition on a fixed interval.
1906
+ 2. **Proceed** as soon as the condition is met, optionally running a success action.
1907
+ 3. **Fail or recover** gracefully when the deadline is exceeded, with an optional timeout action.
1908
+
1909
+ Both helpers wrap the same polling loop, so they share semantics and return shape. Pick `waitForStep()` when the wait is the whole job of a step; use `waitFor()` from anywhere — inside a multi-action step, a hook, or a bare async function.
1910
+
1911
+ ## `waitFor(options)`
1912
+
1913
+ Poll `check()` on a fixed interval until it returns a non-null value or the deadline is exceeded. Resolves to a discriminated result describing which outcome occurred.
1914
+
1915
+ ### Parameters
1916
+
1917
+ | Name | Type | Required | Description |
1918
+ | ---------------- | ---------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
1919
+ | `name` | `string` | No | Label that appears in log lines and in the timeout error. Defaults to `'waitFor'`. |
1920
+ | `check` | `() => Promise<TValue \| null>` | Yes | Polled inspection. Return the resolved value when the condition is met, or `null` to keep polling. |
1921
+ | `intervalMs` | `number` | No | Time between successive `check()` invocations. Defaults to `2000` milliseconds. |
1922
+ | `timeoutMs` | `number` | No | Total time budget for the wait. Defaults to `60000` milliseconds. |
1923
+ | `initialDelayMs` | `number` | No | Time to wait before the first `check()` invocation. Defaults to `0`. |
1924
+ | `onSuccess` | `(value: TValue) => Promise<TSuccess>` | No | Runs once after `check()` returns a non-null value. Its return value is surfaced as `result` on success. |
1925
+ | `onTimeout` | `(info: { elapsedMs: number; attempts: number }) => Promise<TTimeout>` | No | Runs when the deadline is exceeded. Its return value is surfaced as `result` on the `'timed-out'` outcome. |
1926
+ | `swallowErrors` | `boolean` | No | When `true` (default), errors thrown by `check()` are logged and polling continues. |
1927
+ | `log` | `(line: string) => void` | No | Sink for status lines. Defaults to `console.log`. |
1928
+
1929
+ ### Result
1930
+
1931
+ `waitFor()` resolves to a discriminated `WaitForResult` union:
1932
+
1933
+ | Outcome | Branch fields |
1934
+ | ------------- | ----------------------------------------------------------------------------------------------------- |
1935
+ | `'succeeded'` | `value: TValue`, `elapsedMs`, `attempts`, `result: TSuccess` (the `onSuccess` return or `undefined`). |
1936
+ | `'timed-out'` | `elapsedMs`, `attempts`, `result: TTimeout` (the `onTimeout` return). |
1937
+
1938
+ Narrow on `result.outcome` before reading the branch-specific fields.
1939
+
1940
+ When `onTimeout` is **not** supplied, the helper throws a `WaitForTimeoutError` instead of returning a `'timed-out'` result. The error exposes `stepName`, `elapsedMs`, and `attempts` as instance fields so a catch block can branch on them.
1941
+
1942
+ ### Cancellation and the deadline check
1943
+
1944
+ The loop inspects the deadline at the top of each iteration. A `check()` that takes longer than `intervalMs` is not aborted mid-flight; the helper has no `AbortSignal` plumbing. The step's own `timeout` field is the hard kill if the step needs to be interrupted unconditionally.
1945
+
1946
+ ### Example
1947
+
1948
+ ```typescript
1949
+ import { waitFor } from '@kici-dev/sdk';
1950
+
1951
+ const result = await waitFor({
1952
+ name: 'await-build-artifact',
1953
+ check: async () => {
1954
+ const artifact = await registry.findArtifact('myapp', 'v1.2.3');
1955
+ return artifact ?? null;
1956
+ },
1957
+ onSuccess: async (artifact) => ({ digest: artifact.digest }),
1958
+ intervalMs: 5000,
1959
+ timeoutMs: 5 * 60 * 1000,
1960
+ });
1961
+
1962
+ if (result.outcome === 'succeeded') {
1963
+ console.log(`Artifact ready: ${result.result.digest} (${result.attempts} polls)`);
1964
+ } else {
1965
+ console.log(`Gave up after ${result.elapsedMs} ms`);
1966
+ }
1967
+ ```
1968
+
1969
+ ## `waitForStep(name, options)`
1970
+
1971
+ A factory returning an SDK `Step` whose `run` body executes `waitFor(...)` and routes status lines through the step's structured logger.
1972
+
1973
+ ### Parameters
1974
+
1975
+ | Name | Type | Required | Description |
1976
+ | --------- | --------------------------------------- | -------- | --------------------------------------------------------------------------------------------------- |
1977
+ | `name` | `string` | Yes | Step name. Appears in the run timeline and in log lines. |
1978
+ | `options` | `Omit<WaitForOptions, 'name' \| 'log'>` | Yes | Same shape as `waitFor()` minus `name` (already provided) and `log` (provided by the step context). |
1979
+
1980
+ ### Result
1981
+
1982
+ `waitForStep(...)` returns `Step<WaitForResult<TValue, TSuccess, TTimeout>>`. Other steps can consume the result through the standard step output mechanisms.
1983
+
1984
+ ### Example
1985
+
1986
+ ```typescript
1987
+ import { waitForStep, job } from '@kici-dev/sdk';
1988
+
1989
+ const awaitMarker = waitForStep('await-marker', {
1990
+ check: async () => {
1991
+ const stat = await tryStatMarker('/tmp/build-ready');
1992
+ return stat ? { path: '/tmp/build-ready' } : null;
1993
+ },
1994
+ intervalMs: 1000,
1995
+ timeoutMs: 60_000,
1996
+ onTimeout: async ({ attempts }) => ({ aborted: true, attempts }),
1997
+ });
1998
+
1999
+ export const release = job('release', {
2000
+ runsOn: 'linux',
2001
+ steps: [awaitMarker],
2002
+ });
2003
+ ```
2004
+
2005
+ If `check()` throws while polling, the error is logged and polling continues — the default `swallowErrors: true` matches the "poll until healthy" pattern. Pass `swallowErrors: false` to fail fast on the first error instead.
2006
+
2007
+ ## See also
2008
+
2009
+ - [Core SDK reference](https://docs.kici.dev/user/sdk/core/) — the `step()`, `job()`, and `workflow()` factories that `waitForStep()` builds on.
2010
+ - [Idempotent helpers](https://docs.kici.dev/user/sdk/idempotent/) — `idempotent()` and `idempotentStep()` for check / apply patterns.
2011
+ - [Runtime types](https://docs.kici.dev/user/sdk/runtime/) — `StepContext`, `Logger`, and other surface used inside the helpers.
2012
+
2013
+ ---