@kici-dev/compiler 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -576,7 +576,7 @@ export default workflow('ci', {
576
576
  });
577
577
  ```
578
578
 
579
- The `test` and `typecheck` jobs both depend on `lint`, so they run in parallel after lint succeeds. KiCI validates the dependency graph at compile time -- cycles and missing references are caught before you commit. At runtime, jobs are gated on upstream completion: a job only dispatches after every entry in its `needs` array reaches a terminal state. If an upstream fails, downstream jobs skip by default (override per-edge with `ifFailed: 'run'`). See [Job dependencies (`needs`)](../sdk/core.md#job-dependencies-needs) in the SDK reference for the full matrix of `needs` forms (string, `Job` ref, `{ name, ifFailed }`, `dynamicGroup()`) and [needs-scheduler](../../architecture/execution/needs-scheduler.md) for the dispatch semantics.
579
+ The `test` and `typecheck` jobs both depend on `lint`, so they run in parallel after lint succeeds. KiCI validates the dependency graph at compile time -- cycles and missing references are caught before you commit. At runtime, jobs are gated on upstream completion: a job only dispatches after every entry in its `needs` array reaches a terminal status that satisfies the edge. If an upstream fails, downstream jobs skip by default (override per-edge with `when: 'always'`). See [Job dependencies (`needs`)](../sdk/core.md#job-dependencies-needs) in the SDK reference for the full matrix of `needs` forms (string, `Job` ref, `{ name, when }`, `dynamicGroup()`) and [needs-scheduler](../../architecture/execution/needs-scheduler.md) for the dispatch semantics.
580
580
 
581
581
  **Single-step jobs don't need a `steps` array.** When a job only does one thing, pass `run` to `job()` instead of wrapping it in `steps: [step(...)]`:
582
582
 
@@ -1109,6 +1109,116 @@ This workflow:
1109
1109
 
1110
1110
  ---
1111
1111
 
1112
+ ## Host restart & wait-for-alive
1113
+
1114
+ Source: https://docs.kici.dev/user/patterns/host-restart/
1115
+
1116
+ When a KiCI agent runs on a host you are provisioning, a workflow can reboot
1117
+ that host and resume work once it comes back — the Ansible `reboot` +
1118
+ `wait_for_connection` pattern, expressed as two jobs pinned to the same host.
1119
+
1120
+ ## The two-job pattern
1121
+
1122
+ Host restart is a **job-boundary** capability: the reboot is the last step of a
1123
+ "restart" job, and the post-restart work lives in a **separate job** pinned to
1124
+ the same host that `needs` the restart job. The orchestrator holds the
1125
+ post-restart job until the host completes a reboot cycle, then dispatches it.
1126
+
1127
+ ```typescript
1128
+ import { workflow, job, step, restartHost, waitForHostAlive } from '@kici-dev/sdk';
1129
+
1130
+ export default workflow('patch-and-verify', {
1131
+ on: [
1132
+ /* ... */
1133
+ ],
1134
+ jobs: [
1135
+ // Restart job: apply updates, then reboot. restartHost() MUST be the last step.
1136
+ job('patch', {
1137
+ runsOn: 'kici:host:box-01',
1138
+ steps: [
1139
+ step('upgrade', async (ctx) => {
1140
+ await ctx.$`apt-get upgrade -y`;
1141
+ }),
1142
+ restartHost(),
1143
+ ],
1144
+ }),
1145
+ // Post-restart job: pinned to the SAME host, needs the restart job.
1146
+ job('verify', {
1147
+ runsOn: 'kici:host:box-01',
1148
+ needs: ['patch'],
1149
+ steps: [
1150
+ waitForHostAlive(() => fetch('http://localhost:8080/health')),
1151
+ step('check-service', async (ctx) => {
1152
+ await ctx.$`systemctl is-active myservice`;
1153
+ }),
1154
+ ],
1155
+ }),
1156
+ ],
1157
+ });
1158
+ ```
1159
+
1160
+ ## `restartHost()`
1161
+
1162
+ `restartHost(opts?)` reboots the host the job runs on. It signals the
1163
+ orchestrator that a reboot is pending (which holds the post-restart job and
1164
+ treats the agent's imminent disconnect as expected, not a failure), reports the
1165
+ step success, and the agent issues the OS reboot once the step completes.
1166
+
1167
+ - **Must be the last step** of its job — the job completes before the box goes
1168
+ down.
1169
+ - `deadlineMs` (optional) overrides how long the orchestrator waits for the host
1170
+ to return after the reboot. The default is the orchestrator's
1171
+ `KICI_HOST_REBOOT_DEADLINE_MS` (15 minutes). If the host does not reconnect by
1172
+ the deadline, the held post-restart job fails with a clear "host did not
1173
+ return after reboot" reason.
1174
+ - The reboot command is chosen per operating system (Linux `systemctl reboot`,
1175
+ macOS `shutdown -r now`, Windows `shutdown /r /t 0`).
1176
+
1177
+ ## `waitForHostAlive(probe, opts?)`
1178
+
1179
+ `waitForHostAlive()` is the optional first step of the post-restart job. The
1180
+ baseline "the host is back" guarantee comes for free — the post-restart job only
1181
+ dispatches after the agent reconnects. `waitForHostAlive(probe)` adds a
1182
+ **service-readiness** gate on top: it polls `probe` until it resolves, for hosts
1183
+ where "agent connected" does not yet mean "services ready".
1184
+
1185
+ - The probe can return anything (an HTTP response, an open port check, a marker
1186
+ file). Any non-null resolution means "ready"; a throw or rejection keeps
1187
+ polling.
1188
+ - `intervalMs` (default 3000) and `timeoutMs` (default 300000) tune the poll. If
1189
+ the probe never succeeds within `timeoutMs`, the step fails with "services did
1190
+ not come up".
1191
+
1192
+ ## Same-host pinning
1193
+
1194
+ The "same host" relationship is the pin: both jobs target the same host via
1195
+ `runsOn` (a `kici:host:<id>` label or another label the host carries), and the
1196
+ post-restart job `needs` the restart job. Durable provisioning hosts MUST set a
1197
+ stable agent id (`KICI_AGENT_ID`) so the host re-registers under the same
1198
+ identity after the reboot — that stable identity is what lets the orchestrator
1199
+ recognise the down-then-up cycle and release the held job.
1200
+
1201
+ ## Failure behavior
1202
+
1203
+ - **Host never returns by the deadline** → the held post-restart job fails.
1204
+ - **Reboot privilege denied** → the restart step fails with a clear privilege
1205
+ error (see the operator note below), and the orchestrator clears the
1206
+ reboot-pending hold.
1207
+ - **`waitForHostAlive` probe never succeeds** → that step fails.
1208
+
1209
+ The orchestrator refuses to reboot the host it runs on, so a co-located agent
1210
+ cannot take down the orchestrator's own box.
1211
+
1212
+ ## Operator prerequisite: reboot privilege
1213
+
1214
+ Rebooting needs host privilege. An agent used for host provisioning must be able
1215
+ to run the OS reboot primitive — run the agent service with reboot privilege, or
1216
+ grant a narrow `shutdown` / `systemctl reboot` permission. Agents used for
1217
+ provisioning generally need broad, near-root host privileges; see the operator
1218
+ agent documentation for the full posture.
1219
+
1220
+ ---
1221
+
1112
1222
  ## Integration patterns
1113
1223
 
1114
1224
  Source: https://docs.kici.dev/user/patterns/integrations/
@@ -2108,6 +2218,28 @@ const build = step('build', {
2108
2218
 
2109
2219
  **StepRunFn type:** `(ctx: StepContext) => Promise<void>`
2110
2220
 
2221
+ **With a check facet (idempotent step):**
2222
+
2223
+ Add a `check` function to describe _desired state_ instead of a fixed action. When
2224
+ `check` is present, `run` becomes the _apply_ function and receives the drift value
2225
+ `check` returned; `summarize` (required) renders that drift for logs and the
2226
+ dashboard; `whenInSync` optionally produces the step's outputs when already in sync.
2227
+
2228
+ ```typescript
2229
+ const configureNginx = step('configure-nginx', {
2230
+ check: async (ctx) => ((await inSync(ctx)) ? null : { want: DESIRED }),
2231
+ summarize: (drift) => `would rewrite nginx.conf (${drift.want.length} bytes)`,
2232
+ run: async (ctx, drift) => {
2233
+ await writeConfig(drift.want);
2234
+ return { reloaded: true };
2235
+ },
2236
+ whenInSync: async () => ({ reloaded: false }),
2237
+ });
2238
+ ```
2239
+
2240
+ A checked step can run in apply mode (converge) or `--check` preview mode (report
2241
+ drift, change nothing). See [Idempotent steps and check mode](../idempotent-steps.md).
2242
+
2111
2243
  ### Per-job resources
2112
2244
 
2113
2245
  `options.resources` declares the CPU and memory the job needs. The orchestrator's auto-scaler uses these numbers to:
@@ -2467,9 +2599,9 @@ const test = job('test', { needs: [lint], ... });
2467
2599
  // 2. Reference by string name
2468
2600
  const test = job('test', { needs: ['lint'], ... });
2469
2601
 
2470
- // 3. Object form with per-edge failure policy
2602
+ // 3. Object form with a per-edge run condition (`when`)
2471
2603
  const cleanup = job('cleanup', {
2472
- needs: [{ name: 'build', ifFailed: 'run' }],
2604
+ needs: [{ name: 'build', when: 'always' }],
2473
2605
  ...
2474
2606
  });
2475
2607
 
@@ -2480,16 +2612,36 @@ const deploy = job('deploy', {
2480
2612
  });
2481
2613
  ```
2482
2614
 
2483
- **Failure policy (`ifFailed`):** controls what happens to a downstream job when an upstream reaches a non-success terminal state (`failed`, `cancelled`, `drift_dropped`).
2615
+ **Run condition (`when`):** controls when a downstream edge is satisfied, based on the upstream's terminal status. `when` is keyword sugar (or a raw status-set) that resolves at compile time to the set of upstream terminal statuses that satisfy the edge. The downstream edge is satisfied when the upstream's terminal status is a member of that set.
2616
+
2617
+ | Keyword | Satisfied when the upstream is… | Use for |
2618
+ | ------------------------ | ------------------------------- | ------------------------------------------- |
2619
+ | `'on-success'` (default) | `success` | normal dependencies |
2620
+ | `'always'` | any terminal status | cleanup / notification / teardown jobs |
2621
+ | `'on-skip'` | `success` or `skipped` | continue when an upstream was narrowed out |
2622
+ | `'on-failure'` | `failed` or `timed_out_stale` | error-handler jobs that run only on failure |
2623
+
2624
+ For full control, pass a raw status-set instead of a keyword: `when: ['skipped', 'failed', 'timed_out_stale']`. The valid members are the terminal job statuses: `success`, `failed`, `cancelled`, `skipped`, `timed_out_stale`, `drift_dropped`.
2484
2625
 
2485
- | Value | Behavior |
2486
- | ------ | ------------------------------------------------------------------------------------------- |
2487
- | `skip` | (Default) Downstream transitions directly to `skipped`. Failures cascade through the DAG. |
2488
- | `run` | Downstream dispatches anyway. Use for cleanup, notification, or "always-run" teardown jobs. |
2626
+ String and `Job`-reference entries default to `when: 'on-success'`. To override, use the object form (`{ name, when }` for static upstreams, `{ group, when }` for dynamic groups -- `dynamicGroup(name, { when: 'always' })` produces the latter).
2489
2627
 
2490
- String and `Job`-reference entries default to `ifFailed: 'skip'`. To override, use the object form (`{ name, ifFailed }` for static upstreams, `{ group, ifFailed }` for dynamic groups -- `dynamicGroup(name, { ifFailed: 'run' })` produces the latter).
2628
+ When an upstream's terminal status is **not** in the edge's set, the downstream transitions directly to `skipped`. Because a skipped job is itself terminal, this propagates transitively: each downstream's `when` set governs whether the skip cascades further.
2491
2629
 
2492
- **Dispatch gate:** `needs` is a hard dispatch gate. A job only dispatches after every upstream in its `needs` array reaches a terminal state (success, or failure with `ifFailed: 'run'`). Root jobs (empty `needs`, no dynamic group refs) dispatch immediately. The scheduler is DB-backed and fully recovers across orchestrator restarts.
2630
+ **Dispatch gate:** `needs` is a hard dispatch gate. A job dispatches only after every upstream in its `needs` array reaches a terminal status that satisfies that edge's `when` set. Root jobs (empty `needs`, no dynamic group refs) dispatch immediately. The scheduler is DB-backed and fully recovers across orchestrator restarts.
2631
+
2632
+ **Reading an upstream's status in a step:** inside a running job, `ctx.needs.<job>.status` exposes each upstream's terminal status (`success`, `failed`, `skipped`, …) and `ctx.needs.<job>.result` its outputs. A group / matrix / `runsOnAll` fan-out upstream is an ordered array of `{ name, result, status }`, one per child. Use this to branch in TypeScript:
2633
+
2634
+ ```typescript
2635
+ job('report', {
2636
+ needs: [{ name: 'probe', when: 'always' }],
2637
+ run: async (ctx) => {
2638
+ if (ctx.needs.probe.status === 'failed') await fileIncident(ctx.needs.probe.result);
2639
+ else await publish(ctx.needs.probe.result);
2640
+ },
2641
+ });
2642
+ ```
2643
+
2644
+ For an arbitrary outcome-based gate that prevents a job from dispatching at all, use a result-aware `dynamicJob` that returns `[]` or `[job]` based on `ctx.needs.<job>.status` — see [Dynamic jobs](../../architecture/execution/dynamic-jobs.md).
2493
2645
 
2494
2646
  **DAG validation:** three-layer cycle detection.
2495
2647
 
@@ -2502,7 +2654,10 @@ String and `Job`-reference entries default to `ifFailed: 'skip'`. To override, u
2502
2654
  Create a reference to a dynamic job group, for use inside a static job's `needs` array.
2503
2655
 
2504
2656
  ```typescript
2505
- function dynamicGroup(name: string, options?: { ifFailed?: 'skip' | 'run' }): DynamicGroupRef;
2657
+ function dynamicGroup(
2658
+ name: string,
2659
+ options?: { when?: 'on-success' | 'always' | 'on-skip' | 'on-failure' | string[] },
2660
+ ): DynamicGroupRef;
2506
2661
  ```
2507
2662
 
2508
2663
  Use when a static downstream must wait for every generated job tagged with a given group name to complete. If the dynamic group produces zero jobs, the downstream dispatches immediately (empty group satisfies all upstreams).
@@ -3423,10 +3578,10 @@ export default workflow('discovery-fan-out', { jobs: [discover, reports] });
3423
3578
 
3424
3579
  `ctx.needs` shape:
3425
3580
 
3426
- | Need form | `ctx.needs[...]` value |
3427
- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
3428
- | `'jobName'` / `{ name, ifFailed }` | `{ result }` — `result` is an `OutputProxy` (`ctx.needs.<job>.result.<step>.<field>`; single-step `run` jobs flatten to `ctx.needs.<job>.result.<field>`) |
3429
- | `dynamicGroup('g')` / `dynamicGroup('g', { ifFailed })` | ordered array of `{ name, result }`, one per group member |
3581
+ | Need form | `ctx.needs[...]` value |
3582
+ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3583
+ | `'jobName'` / `{ name, when }` | `{ result, status }` — `result` is an `OutputProxy` (`ctx.needs.<job>.result.<step>.<field>`; single-step `run` jobs flatten to `ctx.needs.<job>.result.<field>`); `status` is the upstream's terminal status |
3584
+ | `dynamicGroup('g')` / `dynamicGroup('g', { when })` | ordered array of `{ name, result, status }`, one per group member |
3430
3585
 
3431
3586
  `ctx.needs` is deterministic — a snapshot of upstream outputs frozen at first eval and replayed unchanged on re-eval, like `ctx.event`. Use result-aware generation for same-run fan-out from a prior job's result; use [`jobComplete()`](./triggers.md) for cross-workflow reactions to a job finishing. See the architecture deep-dive in [dynamic jobs](../../architecture/execution/dynamic-jobs.md#result-aware-generation).
3432
3587
 
@@ -3663,6 +3818,32 @@ A held host occupies a wave slot indefinitely while it waits to reconnect, stall
3663
3818
  roll behind an absent box. `skip` (run only reachable hosts) or `fail` (refuse the roll
3664
3819
  if any expected host is down) keep the window moving.
3665
3820
 
3821
+ ### Narrowing the roster at run time with `--target`
3822
+
3823
+ A `runsOnAll` predicate is authored once in the workflow, but you can narrow it for a
3824
+ single run with `kici run --target <selector>` — an Ansible-`--limit`-style runtime
3825
+ filter. The effective host set becomes `runsOnAll ∩ target`: the selector can only
3826
+ _remove_ hosts from the matched roster, never add them. The narrowing is **run-global**
3827
+ (it applies to every `runsOnAll` job) and **`runsOnAll`-only** (single `runsOn`-pinned
3828
+ jobs are untouched). Repeated `--target` values AND-combine — a host must satisfy every
3829
+ selector to survive.
3830
+
3831
+ ```bash
3832
+ # Patch only the role:web subset of whatever role:* hosts the job would match
3833
+ kici run remote deploy --target role:web
3834
+
3835
+ # Intersect two selectors: hosts must be BOTH role:web AND dc:eu
3836
+ kici run remote deploy --target role:web --target dc:eu
3837
+ ```
3838
+
3839
+ When `--target` narrows a `runsOnAll` job to zero hosts, the run **fails** by default
3840
+ (a mistyped selector should be loud, not silently no-op). Pass `--target-allow-empty`
3841
+ to **skip** the zeroed job instead — it records a `skipped` status, and downstream jobs
3842
+ gated with `when: 'on-skip'` (or `when: 'always'`) still run, exactly as for an
3843
+ `onUnreachable: 'skip'` zero-host fan-out. See the [CLI reference](/user/cli-reference/#host-narrowing-with---target)
3844
+ for the full flag behavior and the [`needs` gating model](./core.md#job-dependencies-needs)
3845
+ for how a skipped upstream propagates.
3846
+
3666
3847
  ### Limits (v0)
3667
3848
 
3668
3849
  - Per-host secret scoping is not yet available — all hosts receive the job's resolved
@@ -4021,6 +4202,70 @@ const publish = job('publish', {
4021
4202
  - The step never holds platform credentials — the request is relayed through the orchestrator, which mints the token on the step's behalf.
4022
4203
  - Only available inside a running job step; calling it outside one (for example, during local execution) rejects with a clear error.
4023
4204
 
4205
+ ### ctx.kici.inventory.query(selector?) / .get(agentId)
4206
+
4207
+ Query the **host inventory** — the roster of agents in the caller's orchestrator cluster — from inside a workflow. Each host is a `HostInventoryEntry`:
4208
+
4209
+ ```typescript
4210
+ interface HostInventoryEntry {
4211
+ agentId: string;
4212
+ labels: string[]; // flat-string grouping/tags dimension
4213
+ properties: Record<string, string | number | boolean>; // typed host-vars dimension
4214
+ hostname: string | null;
4215
+ platform: string | null;
4216
+ arch: string | null;
4217
+ lifecycleClass: 'static' | 'ephemeral';
4218
+ status: 'ready' | 'unreachable' | 'stale';
4219
+ lastSeen: string; // ISO timestamp
4220
+ }
4221
+ ```
4222
+
4223
+ 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).
4224
+
4225
+ ```typescript
4226
+ // All hosts:
4227
+ const all = await ctx.kici.inventory.query();
4228
+
4229
+ // Server-side label filter (OR-of-AND include groups, plus exclude):
4230
+ const dbHosts = await ctx.kici.inventory.query({
4231
+ include: [[{ kind: 'exact', value: 'role:db' }]],
4232
+ });
4233
+
4234
+ // Property filtering is client-side — plain JS in the workflow:
4235
+ const euDbHosts = dbHosts.filter((h) => h.properties.region === 'eu');
4236
+
4237
+ // One host by id:
4238
+ const host = await ctx.kici.inventory.get('box-1'); // HostInventoryEntry | null
4239
+ ```
4240
+
4241
+ **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.
4242
+
4243
+ **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:
4244
+
4245
+ ```typescript
4246
+ const migrate = job('migrate', async (ctx) => {
4247
+ const hosts = await ctx.kici.inventory.query({
4248
+ include: [[{ kind: 'exact', value: 'role:db' }]],
4249
+ });
4250
+ return hosts
4251
+ .filter((h) => h.properties.region === 'eu')
4252
+ .map((h) =>
4253
+ job(`migrate-${h.agentId}`, {
4254
+ runsOn: [h.agentId],
4255
+ run: async (c) => {
4256
+ await c.$`./migrate.sh`;
4257
+ },
4258
+ }),
4259
+ );
4260
+ });
4261
+ ```
4262
+
4263
+ 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.
4264
+
4265
+ `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).
4266
+
4267
+ **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.
4268
+
4024
4269
  ### ctx.attestProvenance({ subject })
4025
4270
 
4026
4271
  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 the platform — 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.
@@ -5563,6 +5808,8 @@ kici run remote [fixture] [options]
5563
5808
  | `--history` | `false` | Show table of recent test runs |
5564
5809
  | `--context <ctx.key=value>` | none | Inject a namespaced context secret, uploaded encrypted (repeatable) |
5565
5810
  | `--env <KEY=VALUE>` | none | Provide a per-run secret, uploaded encrypted (repeatable) — see [testing guide](testing-guide.md) |
5811
+ | `--target <selector>` | none | Narrow `runsOnAll` jobs to hosts matching this label selector (repeatable, AND-combined) |
5812
+ | `--target-allow-empty` | `false` | A `--target` that narrows a `runsOnAll` job to zero hosts skips it instead of failing |
5566
5813
  | `--debug` | `false` | Verbose internals |
5567
5814
  | `--kici-dir <path>` | `.kici` | Path to .kici directory |
5568
5815
 
@@ -5582,7 +5829,7 @@ kici run remote push-main --org xyz789ghi012
5582
5829
  kici run remote push-main --orchestrator us-east
5583
5830
 
5584
5831
  # Run all push-related fixtures
5585
- kici run remote push-*
5832
+ kici run remote 'push-*'
5586
5833
 
5587
5834
  # Run everything
5588
5835
  kici run remote --all
@@ -5601,8 +5848,44 @@ kici run remote push-main --no-wait
5601
5848
 
5602
5849
  # View recent test run history
5603
5850
  kici run remote --history
5851
+
5852
+ # Narrow runsOnAll jobs to a subset of the host roster
5853
+ kici run remote deploy --target role:web
5854
+
5855
+ # AND-combine repeated --target values (hosts must match every selector)
5856
+ kici run remote deploy --target role:web --target dc:eu
5857
+
5858
+ # Skip a runsOnAll job instead of failing it when the target matches no host
5859
+ kici run remote deploy --target role:gpu --target-allow-empty
5604
5860
  ```
5605
5861
 
5862
+ #### Host narrowing with `--target`
5863
+
5864
+ `--target <selector>` is a runtime narrowing for `runsOnAll` jobs, analogous to
5865
+ Ansible's `--limit`. A `runsOnAll` job normally fans out to **every** roster host
5866
+ matching its predicate, one pinned execution per host. `--target` intersects that
5867
+ matched roster with a label selector, so the effective host set is
5868
+ `runsOnAll ∩ target`:
5869
+
5870
+ - **Narrow-only.** `--target` can only _remove_ hosts from the matched set, never
5871
+ add them. The widening dimension (OR across host groups) lives in the workflow's
5872
+ `runsOnAll`; `--target` only subtracts.
5873
+ - **Run-global, `runsOnAll`-only.** A single `--target` applies to every
5874
+ `runsOnAll` job in the run. Jobs pinned to a single host with `runsOn` are
5875
+ untouched.
5876
+ - **Repeatable and AND-combined.** Each `--target` value is its own selector; a
5877
+ host must satisfy **all** of them to survive the narrowing. Use a single value
5878
+ for an OR-style match within one selector and repeated values for AND.
5879
+ - **Selector syntax** matches `runsOn`: an exact label (`role:web`), a glob
5880
+ (`role:*`), or a regex (`/^box-0[1-3]$/`).
5881
+
5882
+ When `--target` narrows a `runsOnAll` job to zero hosts, the default is to **fail**
5883
+ the run (fail-loud — a typo in the selector shouldn't silently skip work). Pass
5884
+ `--target-allow-empty` to **skip** the zeroed job instead; the job records a
5885
+ `skipped` status, and any downstream job that needs it with `when: 'on-skip'` (or
5886
+ `when: 'always'`) still runs. See [Job dependencies](./sdk/core.md#job-dependencies-needs)
5887
+ for the `when` gating model.
5888
+
5606
5889
  **Exit codes:**
5607
5890
 
5608
5891
  | Code | Meaning |
@@ -5743,18 +6026,21 @@ By default, `kici login` opens your browser for OIDC authentication using PKCE.
5743
6026
 
5744
6027
  After OAuth, the CLI exchanges the OIDC token for a personal access token (PAT) stored in the config directory (`~/.kici/config` by default, overridable with `KICI_CONFIG_DIR`).
5745
6028
 
6029
+ `kici login` targets the hosted KiCI Platform by default. To authenticate against another environment (a self-hosted Platform, for example), pass `--platform-endpoint` / `--oidc-issuer` or set `KICI_PLATFORM_URL` / `KICI_OIDC_ISSUER`. Login persists the platform endpoint and OIDC issuer it authenticated against alongside the PAT, so a saved PAT always matches its endpoint. Because the config describes one environment at a time, **switching the endpoint resets the active organization and default clusters** — re-run `kici org use <name>` after switching environments.
6030
+
5746
6031
  ```bash
5747
6032
  kici login [options]
5748
6033
  ```
5749
6034
 
5750
6035
  **Options:**
5751
6036
 
5752
- | Option | Default | Description |
5753
- | --------------------------- | ------- | ---------------------------------------------- |
5754
- | `--token <key>` | none | API key for direct authentication (legacy) |
5755
- | `--device` | false | Force device authorization flow (headless/SSH) |
5756
- | `--platform-endpoint <url>` | none | Platform relay URL |
5757
- | `--routing-key <key>` | none | Routing key for webhook source identification |
6037
+ | Option | Default | Description |
6038
+ | --------------------------- | ------- | --------------------------------------------------- |
6039
+ | `--token <key>` | none | API key for direct authentication (legacy) |
6040
+ | `--device` | false | Force device authorization flow (headless/SSH) |
6041
+ | `--platform-endpoint <url>` | none | Platform relay URL |
6042
+ | `--oidc-issuer <url>` | none | OIDC issuer URL (selects a non-default environment) |
6043
+ | `--routing-key <key>` | none | Routing key for webhook source identification |
5758
6044
 
5759
6045
  **Environment variables:**
5760
6046
 
@@ -5780,7 +6066,8 @@ kici login --device
5780
6066
  kici login --token kici_sk_abc123...
5781
6067
 
5782
6068
  # Log in against a self-hosted Platform
5783
- kici login --platform-endpoint https://platform.example.com
6069
+ kici login --platform-endpoint https://platform.example.com \
6070
+ --oidc-issuer https://auth.example.com/realms/kici-internal
5784
6071
 
5785
6072
  # Suppress browser opening (print authorize URL to stdout)
5786
6073
  KICI_BROWSER_CMD=none kici login
@@ -6813,7 +7100,7 @@ The lock file (`kici.lock.json`) is a JSON file with the following top-level fie
6813
7100
 
6814
7101
  | Field | Description |
6815
7102
  | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
6816
- | `schemaVersion` | Lock file schema version (currently 17). Incremented on breaking format changes. |
7103
+ | `schemaVersion` | Lock file schema version (currently 20). Incremented on breaking format changes. |
6817
7104
  | `source` | Reference to the source file and export (e.g., `{ file: “.kici/workflows/ci.ts”, export: “#default” }`). |
6818
7105
  | `contentHash` | SHA-256 of the serialized lock file content (excluding itself). Changes when any workflow, trigger, or job changes. |
6819
7106
  | `lockfileHash` | SHA-256 of the detected package manager's lockfile, used as the dependency cache key. The lockfile is `.kici/package-lock.json` for npm, or the repo-root `pnpm-lock.yaml` / `yarn.lock` for a pnpm/yarn workspace; the hash input is prefixed with the manager name so a manager change is a guaranteed cache miss. Omitted when no lockfile exists. |
@@ -6988,12 +7275,14 @@ kici run remote
6988
7275
  kici run remote push-main
6989
7276
 
6990
7277
  # Run all fixtures matching a glob
6991
- kici run remote push-*
7278
+ kici run remote 'push-*'
6992
7279
 
6993
7280
  # Run everything
6994
7281
  kici run remote --all
6995
7282
  ```
6996
7283
 
7284
+ The single quotes keep your shell from expanding `push-*` against local files, so the pattern reaches KiCI intact for its own fixture-glob matching.
7285
+
6997
7286
  ## Fixture reference
6998
7287
 
6999
7288
  ### Event types
@@ -7089,7 +7378,7 @@ kici run remote
7089
7378
  kici run remote push-main
7090
7379
 
7091
7380
  # Glob matching -- run all push-related fixtures
7092
- kici run remote push-*
7381
+ kici run remote 'push-*'
7093
7382
 
7094
7383
  # Run all fixtures sequentially
7095
7384
  kici run remote --all
@@ -7432,12 +7721,14 @@ Source: https://docs.kici.dev/user/approvals/
7432
7721
 
7433
7722
  An **approval gate** pauses execution until an authorized person approves it. Execution resumes from exactly where it paused; a rejection (or an expired hold) fails the run.
7434
7723
 
7435
- You declare a gate in your workflow with `requireApproval`. It is available at three levels of granularity:
7724
+ You declare a gate in your workflow with `approval`. It is available at three levels of granularity:
7436
7725
 
7437
7726
  - **Step** — pause mid-job, before a specific step runs. The agent holds the live workspace (with all prior-step state intact) for the duration of the wait.
7438
7727
  - **Job** — hold the job before any of its steps run.
7439
7728
  - **Workflow** — hold the whole run before any job is dispatched.
7440
7729
 
7730
+ A step-level gate can also fire **only when a check/apply step finds drift** — Terraform's plan→apply, per step. See [Drift gates](#drift-gates-whendrift) below.
7731
+
7441
7732
  Approvers are named as **teams** and **users**. A team is an operator-defined group of org members; your workflow code may name a team but can never change its membership, which is what makes a team clause a real gate rather than a suggestion. See [Approval gates (operator guide)](../operator/approvals.md) for how operators define teams, the approval queue, and expiry; see [the architecture overview](../architecture/approvals.md) for how a hold is evaluated and resumed.
7442
7733
 
7443
7734
  ## Quick start
@@ -7452,7 +7743,7 @@ export default workflow('deploy', {
7452
7743
  jobs: [
7453
7744
  job('deploy-production', {
7454
7745
  runsOn: 'default',
7455
- requireApproval: [{ team: 'leads' }],
7746
+ approval: [{ team: 'leads' }],
7456
7747
  steps: [step('deploy', async (ctx) => ctx.$`deploy --prod`)],
7457
7748
  }),
7458
7749
  ],
@@ -7461,28 +7752,28 @@ export default workflow('deploy', {
7461
7752
 
7462
7753
  When the run reaches this job, it is held instead of dispatched. The held run appears in the dashboard approval queue and can be released from there or with the [`kici approve`](#approving-from-the-cli) command. Once a member of `leads` approves, the job dispatches normally.
7463
7754
 
7464
- ## The `requireApproval` field
7755
+ ## The `approval` field
7465
7756
 
7466
- `requireApproval` accepts three forms.
7757
+ `approval` accepts three forms.
7467
7758
 
7468
7759
  ### Shorthand: `true`
7469
7760
 
7470
7761
  ```typescript
7471
7762
  job('deploy', {
7472
7763
  runsOn: 'default',
7473
- requireApproval: true,
7764
+ approval: true,
7474
7765
  steps: [
7475
7766
  /* ... */
7476
7767
  ],
7477
7768
  });
7478
7769
  ```
7479
7770
 
7480
- `requireApproval: true` holds the element until **any** org member who can act on approvals signs off — anyone with the `environments:write` or `ci_trust:write` permission. Use it when you want a manual gate without restricting who may release it.
7771
+ `approval: true` holds the element until **any** org member who can act on approvals signs off — anyone with the `environments:write` or `ci_trust:write` permission. Use it when you want a manual gate without restricting who may release it.
7481
7772
 
7482
7773
  ### Approver list (AND)
7483
7774
 
7484
7775
  ```typescript
7485
- requireApproval: [{ team: 'leads' }, { user: 'cto' }],
7776
+ approval: [{ team: 'leads' }, { user: 'cto' }],
7486
7777
  ```
7487
7778
 
7488
7779
  A list of approver clauses is an **AND** list: every clause must be satisfied before the element is released.
@@ -7494,27 +7785,29 @@ A single approver may satisfy more than one clause. If `cto` is also a member of
7494
7785
 
7495
7786
  There is no OR or nested logic — clauses are always a flat AND list.
7496
7787
 
7497
- ### Object form: reason and timeout
7788
+ ### Object form: when, reason, and timeout
7498
7789
 
7499
7790
  ```typescript
7500
- requireApproval: {
7791
+ approval: {
7792
+ when: 'always',
7501
7793
  approvers: [{ team: 'security' }, { team: 'leads' }],
7502
7794
  reason: 'Production deploy requires security + leads sign-off',
7503
7795
  timeout: 7200, // seconds
7504
7796
  },
7505
7797
  ```
7506
7798
 
7507
- | Field | Type | Description |
7508
- | ----------- | ------------------ | --------------------------------------------------------------------------------------------------- |
7509
- | `approvers` | `ApproverClause[]` | The AND list of `{ team }` / `{ user }` clauses. An empty list means "any approval-capable member". |
7510
- | `reason` | `string` | A human-readable label shown in the dashboard queue and the held-for-approval status check. |
7511
- | `timeout` | `number` | Per-gate expiry in **seconds**, overriding the org default. On expiry the element is rejected. |
7799
+ | Field | Type | Description |
7800
+ | ----------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7801
+ | `when` | `'always' \| 'drift'` | When the gate fires. `'always'` (default) gates before the element; `'drift'` gates a check/apply step only when it finds drift. See [Drift gates](#drift-gates-whendrift). |
7802
+ | `approvers` | `ApproverClause[]` | The AND list of `{ team }` / `{ user }` clauses. An empty list means "any approval-capable member". |
7803
+ | `reason` | `string` | A human-readable label shown in the dashboard queue and the held-for-approval status check. |
7804
+ | `timeout` | `number` | Per-gate expiry in **seconds**, overriding the org default. On expiry the element is rejected. |
7512
7805
 
7513
7806
  When `timeout` is omitted, the gate uses the org's default approval expiry (set by the operator). On expiry, the held element is rejected and the run fails — see [expiry](../operator/approvals.md#expiry).
7514
7807
 
7515
7808
  ## Granularity
7516
7809
 
7517
- The same `requireApproval` field is accepted on a workflow, a job, and a step.
7810
+ The same `approval` field is accepted on a workflow, a job, and a step.
7518
7811
 
7519
7812
  ### Workflow-level
7520
7813
 
@@ -7523,7 +7816,7 @@ A workflow-level gate holds the entire run before any job is dispatched:
7523
7816
  ```typescript
7524
7817
  export default workflow('release', {
7525
7818
  on: [push({ branches: ['main'] })],
7526
- requireApproval: [{ team: 'release-managers' }],
7819
+ approval: [{ team: 'release-managers' }],
7527
7820
  jobs: [buildJob, publishJob],
7528
7821
  });
7529
7822
  ```
@@ -7535,7 +7828,7 @@ A job-level gate holds just that job; other jobs in the run proceed normally:
7535
7828
  ```typescript
7536
7829
  job('publish', {
7537
7830
  runsOn: 'default',
7538
- requireApproval: [{ team: 'leads' }],
7831
+ approval: [{ team: 'leads' }],
7539
7832
  steps: [
7540
7833
  /* ... */
7541
7834
  ],
@@ -7552,7 +7845,7 @@ job('migrate-and-deploy', {
7552
7845
  steps: [
7553
7846
  step('build-plan', async (ctx) => ctx.$`./gen-migration-plan.sh`),
7554
7847
  step('apply-migration', {
7555
- requireApproval: [{ team: 'dba' }],
7848
+ approval: [{ team: 'dba' }],
7556
7849
  run: async (ctx) => ctx.$`./apply-migration.sh`,
7557
7850
  }),
7558
7851
  step('deploy', async (ctx) => ctx.$`deploy --prod`),
@@ -7564,9 +7857,39 @@ Here `build-plan` runs, then the job pauses for a `dba` approval. On approval, `
7564
7857
 
7565
7858
  Because a step-level hold keeps an agent and its workspace occupied for the whole human wait, prefer job- or workflow-level gates when you do not need prior-step state, and keep step-level timeouts short. See the [operator note on agent occupancy](../operator/approvals.md#agent-occupancy-during-step-level-holds).
7566
7859
 
7860
+ ## Drift gates (`when: 'drift'`)
7861
+
7862
+ A `when: 'drift'` gate is **step-scope only** and requires a [check/apply step](idempotent-steps.md). Instead of pausing unconditionally, it fires **between the step's `check` and `run`, only when `check` finds drift in apply mode** — exactly Terraform's plan→apply, scoped to one step. When the step is already in sync (no drift), nothing pauses and the step skips.
7863
+
7864
+ When the gate fires, the held run carries the **computed drift** as a payload: the rendering your `summarize(drift)` produced (the per-file diff, the commands that would run), plus the structured drift. The dashboard approval queue and the [CLI](#approving-from-the-cli) show the actual diff the operator is approving — not a static reason string.
7865
+
7866
+ ```typescript
7867
+ job('patch-prod', {
7868
+ runsOn: 'default',
7869
+ steps: [
7870
+ step('apply-nginx-config', {
7871
+ check: async (ctx) => ((await inSync(ctx)) ? null : { want: DESIRED_CONF }),
7872
+ summarize: (drift) => `would rewrite nginx.conf (${drift.want.length} bytes)`,
7873
+ run: async (ctx, drift) => {
7874
+ await writeConfig(drift.want);
7875
+ },
7876
+ approval: { when: 'drift', approvers: [{ team: 'ops' }], reason: 'prod patch' },
7877
+ }),
7878
+ ],
7879
+ });
7880
+ ```
7881
+
7882
+ Behavior:
7883
+
7884
+ - The gate fires **only in apply mode**. In `--check` mode nothing applies, so nothing gates — drift is just reported.
7885
+ - **Approve** → the step's `run(ctx, drift)` applies the change.
7886
+ - **Reject** → fail-stop: the step fails, the job fails, and the `needs:` skip-cascade aborts everything downstream.
7887
+
7888
+ A `when: 'drift'` gate on a step without a `check` facet, or at job/workflow scope, is a compile error.
7889
+
7567
7890
  ## Mandatory vs. explicit gates
7568
7891
 
7569
- `requireApproval` is the **explicit** gate — a deliberate "pause for a human here" written by the workflow author. It composes with the **mandatory** gate an operator can attach to a protected environment via required reviewers (see [Environments](environments.md#required-reviewers)). When both apply to the same job, all clauses from both sources must be satisfied before the job is released. The two funnel into one held-element mechanism, so the dashboard queue and `kici approve` work the same way regardless of which source held the element.
7892
+ `approval` is the **explicit** gate — a deliberate "pause for a human here" written by the workflow author. It composes with the **mandatory** gate an operator can attach to a protected environment via required reviewers (see [Environments](environments.md#required-reviewers)). When both apply to the same job, all clauses from both sources must be satisfied before the job is released. The two funnel into one held-element mechanism, so the dashboard queue and `kici approve` work the same way regardless of which source held the element.
7570
7893
 
7571
7894
  ## Approving from the CLI
7572
7895
 
@@ -7588,10 +7911,23 @@ kici reject <run-id> --job deploy-production --reason "Wrong release branch"
7588
7911
 
7589
7912
  You must be eligible for at least one unsatisfied clause — being a member of a named team or being a named user. The orchestrator verifies eligibility against the operator-defined teams, so naming a team in your workflow can never let an ineligible person release the gate. The command reports whether the element was released, how many clauses remain, or that it was rejected. See [`kici approve`](cli-reference.md#kici-approve) for the full command reference.
7590
7913
 
7914
+ ### Inline approval and `--approve-all` in `kici run remote`
7915
+
7916
+ When you trigger a run with `kici run remote` and it pauses on a gate, the CLI prints the gate (and, for a drift gate, the computed drift) and — in an interactive terminal — prompts you to approve or reject right there, without leaving the watch. In a non-interactive shell it prints how to approve out of band and keeps watching.
7917
+
7918
+ To auto-approve **every gate of the run you just dispatched**, pass `--approve-all` (alias `--yes`):
7919
+
7920
+ ```bash
7921
+ kici run remote deploy-prod --approve-all
7922
+ ```
7923
+
7924
+ `--approve-all` is **run-scoped** — it only auto-approves holds belonging to the run this invocation dispatched; there is no fleet-wide or account-wide auto-approve. Eligibility is still enforced per hold: if you are not eligible for a gate, that gate still blocks. Each auto-approved gate prints its payload before resolving and is recorded distinctly in the audit trail (`held_run.auto_approve`).
7925
+
7591
7926
  You can also approve from the dashboard approval queue. See [Dashboard](dashboard/environments-and-secrets.md#approval-queue).
7592
7927
 
7593
7928
  ## See also
7594
7929
 
7930
+ - [Idempotent steps](idempotent-steps.md) — the check/apply step facet that drift gates build on.
7595
7931
  - [Environments](environments.md) — operator-required reviewers on protected environments.
7596
7932
  - [Approval gates (operator guide)](../operator/approvals.md) — teams, the approval queue, expiry, and self-approval.
7597
7933
  - [Approval gates (architecture)](../architecture/approvals.md) — the unified hold model and the step-level round-trip.
@@ -8224,7 +8560,7 @@ Required reviewers: alice, bob
8224
8560
 
8225
8561
  When reviewers are required, the job enters a "held" state. Reviewers can approve or reject via the dashboard, the [`kici approve`](cli-reference.md#kici-approve) command, or the API. Held runs expire after a configurable timeout.
8226
8562
 
8227
- This operator-set rule is the **mandatory** form of an approval gate. Workflow authors can also declare gates in code with `requireApproval` at step, job, or workflow level — see [Approval gates](approvals.md). Both forms use the same held-element mechanism and the same queue.
8563
+ This operator-set rule is the **mandatory** form of an approval gate. Workflow authors can also declare gates in code with `approval` at step, job, or workflow level — see [Approval gates](approvals.md). Both forms use the same held-element mechanism and the same queue.
8228
8564
 
8229
8565
  ### Wait timer
8230
8566
 
@@ -8833,6 +9169,135 @@ Non-push triggers work too — `pr()`, `tag()`, `comment()`, `release()`, `workf
8833
9169
 
8834
9170
  ---
8835
9171
 
9172
+ ## Idempotent steps and check mode
9173
+
9174
+ Source: https://docs.kici.dev/user/idempotent-steps/
9175
+
9176
+ An **idempotent step** describes _desired state_ rather than a fixed sequence of
9177
+ commands. You give the step a `check` function that inspects the world and a
9178
+ `run` function that converges it. KiCI then executes the workflow in one of two
9179
+ modes:
9180
+
9181
+ - **Apply mode** (the default): for each step, `check()` runs first; on drift the
9182
+ step applies the change; when already in sync the step is skipped.
9183
+ - **Check mode** (`--check`): for each step, `check()` runs and KiCI reports what
9184
+ _would_ change — **without changing anything**. This is the same model as a
9185
+ dry-run plan: you see the drift before any side effect happens.
9186
+
9187
+ This turns a workflow into convergent configuration management: re-running an
9188
+ apply is safe (in-sync steps do nothing), and a check-mode run is a read-only
9189
+ preview you can gate a build on.
9190
+
9191
+ ## Authoring a checked step
9192
+
9193
+ Add a `check` facet to the existing `step()` factory. When `check` is present,
9194
+ `run` becomes the _apply_ function and receives the drift value `check`
9195
+ returned:
9196
+
9197
+ ```typescript
9198
+ import { step, z } from '@kici-dev/sdk';
9199
+
9200
+ const configureNginx = step('configure-nginx', {
9201
+ // optional schema for the drift value — gives the dashboard a typed shape
9202
+ drift: z.object({ want: z.string() }),
9203
+
9204
+ // read-only inspection; return null when already in the desired state
9205
+ check: async (ctx) => {
9206
+ const current = await ctx.$`nginx -T`;
9207
+ return current.stdout.includes(DESIRED) ? null : { want: DESIRED };
9208
+ },
9209
+
9210
+ // human-readable preview line — REQUIRED when check is set. It is the drift's
9211
+ // serializable face: it streams to the logs and persists for the dashboard.
9212
+ summarize: (drift) => `would rewrite nginx.conf (${drift.want.length} bytes)`,
9213
+
9214
+ // apply — runs only when check returned drift (apply mode); receives that drift
9215
+ run: async (ctx, drift) => {
9216
+ await writeConfig(drift.want);
9217
+ return { reloaded: true };
9218
+ },
9219
+
9220
+ // optional — runs when check returned null, to produce the step's outputs
9221
+ whenInSync: async () => ({ reloaded: false }),
9222
+ });
9223
+ ```
9224
+
9225
+ ### The facet fields
9226
+
9227
+ | Field | Required | Purpose |
9228
+ | ------------ | ---------------- | ---------------------------------------------------------------------- |
9229
+ | `check` | to opt in | Read-only inspection. Return a drift value, or `null` when in sync. |
9230
+ | `summarize` | when `check` set | Human-readable, serializable preview of the drift. Streams + persists. |
9231
+ | `run` | always | Apply function. With `check`, it receives the drift as its second arg. |
9232
+ | `whenInSync` | optional | Produces the step's outputs when `check` returned `null`. |
9233
+ | `drift` | optional | Schema that validates / shapes the drift value. |
9234
+
9235
+ `summarize` is **required** whenever `check` is declared. `run` and `whenInSync`
9236
+ both produce the same output type — one output shape per step, whichever path
9237
+ runs. Every other step facet (`cache`, `rules`, `continueOnError`, `timeout`,
9238
+ `approval`, `onCancel`, `cleanup`, `outputs`) composes unchanged.
9239
+
9240
+ A plain `step()` without `check` keeps its exact current behavior — the check
9241
+ facet is fully optional.
9242
+
9243
+ ## Run modes
9244
+
9245
+ A run carries one of three modes:
9246
+
9247
+ | Mode | CLI flags | Behavior |
9248
+ | --------------------- | ------------------------- | ------------------------------------------------------------------------------------------ |
9249
+ | `apply` | (default, no flags) | Converge: drift ⇒ apply ⇒ **applied**; null ⇒ **in sync** (skipped). |
9250
+ | `check` | `--check` | Preview only: drift ⇒ **would change**; null ⇒ **in sync**. Never applies. Always exits 0. |
9251
+ | `check-fail-on-drift` | `--check --fail-on-drift` | Same as check, but the run **fails** if any step reports drift. |
9252
+
9253
+ Per-step outcomes:
9254
+
9255
+ - **applied** — drift was found and the step applied the change (apply mode).
9256
+ - **in sync** — `check` returned `null`; nothing to do.
9257
+ - **would change** — drift was found in check mode; the change was previewed, not applied.
9258
+ - **no check** — a plain step (no `check`) reached under check mode. A
9259
+ side-effecting step can't be safely previewed, so it is skipped.
9260
+
9261
+ In check mode KiCI never invokes a checked step's `run` (apply) — the preview is
9262
+ guaranteed side-effect-free.
9263
+
9264
+ ## Running in check mode
9265
+
9266
+ `--check` and `--fail-on-drift` work on both local and remote runs:
9267
+
9268
+ ```bash
9269
+ # Apply (default): converge the workflow.
9270
+ kici run local push
9271
+ kici run remote my-fixture
9272
+
9273
+ # Check: report drift, change nothing. Always exits 0.
9274
+ kici run local push --check
9275
+ kici run remote my-fixture --check
9276
+
9277
+ # Check + fail on drift: exit non-zero (2) locally, or fail the run remotely,
9278
+ # when any step reports drift. Use this as a CI gate ("fail the build if prod
9279
+ # has drifted").
9280
+ kici run local push --check --fail-on-drift
9281
+ ```
9282
+
9283
+ `--fail-on-drift` only modifies check mode — passing it without `--check` is an
9284
+ error.
9285
+
9286
+ ## Where outcomes show up
9287
+
9288
+ A check-mode run is labeled in the dashboard with a **CHECK MODE — preview**
9289
+ badge on the run header. Each step shows its outcome chip — applied / in sync /
9290
+ would change / no check — and, when drift was detected, the `summarize` line
9291
+ describing what would change. The rendering is read-only.
9292
+
9293
+ ## See also
9294
+
9295
+ - [Idempotent SDK helpers](./sdk/idempotent.md) — the `idempotent()` / `idempotentStep()` convenience wrappers, which always apply on drift inside a single step (no run-level check mode).
9296
+ - [Core SDK reference](./sdk/core.md) — the `step()`, `job()`, and `workflow()` factories the check facet extends.
9297
+ - [Lock file and drift](./lock-file-and-drift.md) — how the lock file carries step capability flags.
9298
+
9299
+ ---
9300
+
8836
9301
  ## Private npm registries
8837
9302
 
8838
9303
  Source: https://docs.kici.dev/user/private-registries/
@@ -9618,7 +10083,83 @@ forge side looks like:
9618
10083
  Use the App when you can; the `github-repo` preset is a fallback for
9619
10084
  repos where you can't install an App.
9620
10085
 
9621
- ## Create the GitHub App on GitHub's side
10086
+ ## One-click setup (recommended)
10087
+
10088
+ `kici-admin source add github --manifest` creates **and** configures the
10089
+ GitHub App for you via GitHub's App Manifest flow. KiCI builds a manifest
10090
+ with the exact permissions, events, webhook URL, and webhook secret baked
10091
+ in, so you never pick permissions, paste a URL, generate a secret, or
10092
+ download a `.pem` by hand — the App is correct by construction.
10093
+
10094
+ ```bash
10095
+ kici-admin --url http://<orchestrator-host>:4000 --token $KICI_BOOTSTRAP_ADMIN_TOKEN \
10096
+ source add github --manifest --name my-org --github-org my-org
10097
+ ```
10098
+
10099
+ `--github-org <slug>` creates the App under a GitHub **organization** (the
10100
+ `<slug>` is the org's `github.com/<slug>` URL slug, not its display name) — the
10101
+ recommended default, since org-owned Apps can be installed across the org. Drop
10102
+ the flag only when you deliberately want a personal-account App, which can be
10103
+ installed solely on repos you own. You need permission to create Apps in that
10104
+ org (be an org owner, or have the org allow member App creation).
10105
+
10106
+ What happens:
10107
+
10108
+ 1. The CLI resolves your org's webhook URL and opens GitHub with a
10109
+ pre-filled App manifest. You click **"Create GitHub App"** once — the
10110
+ only manual step.
10111
+ 2. GitHub redirects back to a localhost callback; the CLI exchanges the
10112
+ returned setup code for the App's id, private key, and webhook secret.
10113
+ **The private key is exchanged and stored only on your orchestrator
10114
+ host — it never transits the KiCI Platform.**
10115
+ 3. The CLI stores the credentials encrypted under `KICI_SECRET_KEY` and
10116
+ registers the routing key `github:<appId>`, reusing the same storage
10117
+ path as the manual flow.
10118
+ 4. It opens the App's install page so you can pick repos, then verifies
10119
+ end-to-end: it waits for the installation, mints an installation token,
10120
+ and confirms repo access before declaring success.
10121
+
10122
+ ```
10123
+ $ kici-admin source add github --manifest --name my-org --github-org my-org
10124
+ → Opening GitHub to create your App…
10125
+ → ✓ App created (id 12345), credentials captured
10126
+ → ✓ Stored on orchestrator (encrypted), registered as github:12345
10127
+ → Install the App on your repos: https://github.com/apps/my-org/installations/new
10128
+ → ✓ Installation detected (account my-org)
10129
+ → ✓ Credentials verified (3 repositories reachable)
10130
+
10131
+ GitHub App "my-org" is live.
10132
+ Webhook: https://<platform-host>/webhook/<orgId>/github
10133
+ ```
10134
+
10135
+ **Flags:**
10136
+
10137
+ | Flag | Effect |
10138
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
10139
+ | `--name <name>` | The App name you _request_ on GitHub (required). GitHub assigns the final name + slug; the stored, displayed name always comes from GitHub (see [Display name and slug](#display-name-and-slug)). |
10140
+ | `--github-org <slug>` | Create the App under a GitHub organization instead of your personal account. |
10141
+ | `--webhook-url <url>` | **Advanced / self-hosted.** Bake this `https://` URL into the App's webhook verbatim and skip the platform-mode webhook-URL resolution (so it works even where the auto-resolved KiCI Platform URL is unavailable). See [Self-hosted webhook URL](#self-hosted-webhook-url-override). |
10142
+ | `--no-browser` | Headless mode: the CLI prints a `kici.dev` URL to open, then reads the setup code you paste back. The page is pure client-side — it only displays the short-lived code, which is useless once the CLI exchanges it. |
10143
+
10144
+ The manifest flow always creates a **new** App on GitHub. If a source for
10145
+ that App id already exists on the orchestrator, the command refuses — use
10146
+ `source update` to rotate an existing App's credentials.
10147
+
10148
+ If any step after App creation fails (e.g. storage), the CLI prints the
10149
+ captured App id and writes the private key to a `0600` file, then tells
10150
+ you how to finish with the manual `source add github` command — so a
10151
+ created App is never orphaned.
10152
+
10153
+ Independent-mode orchestrators have no GitHub-App ingress (it is
10154
+ Platform-relayed), so the manifest flow is unavailable there; use a
10155
+ generic webhook source instead.
10156
+
10157
+ ## Manual setup (fallback)
10158
+
10159
+ When you'd rather create the App by hand — or your environment can't run
10160
+ the manifest flow — follow these steps.
10161
+
10162
+ ### Create the GitHub App on GitHub's side
9622
10163
 
9623
10164
  1. **Decide the App scope.** User-owned Apps can only be installed on
9624
10165
  repos you own; organization-owned Apps can be installed anywhere in
@@ -9694,7 +10235,7 @@ repos where you can't install an App.
9694
10235
  KiCI runs. Re-install to add repos later — this is live and
9695
10236
  revocable without redeploying the App.
9696
10237
 
9697
- ## Register the App with the orchestrator
10238
+ ### Register the App with the orchestrator
9698
10239
 
9699
10240
  With the App ID, private key `.pem`, and webhook secret in hand:
9700
10241
 
@@ -9762,6 +10303,53 @@ its own source record and the orchestrator looks up the right one by
9762
10303
  combining the URL's `<orgId>` with the App ID from the
9763
10304
  `X-GitHub-Hook-Installation-Target-ID` header.
9764
10305
 
10306
+ ## Display name and slug
10307
+
10308
+ For a GitHub App source, **GitHub is the source of truth for the displayed
10309
+ name**. `--name` is only the name you _request_ when the App is created;
10310
+ GitHub assigns the final display name and a URL-safe **slug**
10311
+ (`my-org` → `my-org-1` if the name was taken). KiCI captures both at creation
10312
+ and shows them in the dashboard **Sources** tab — the display name prominently,
10313
+ with the slug as dimmed secondary text.
10314
+
10315
+ If you later rename the App in GitHub's UI, KiCI keeps the displayed name in
10316
+ sync two ways:
10317
+
10318
+ - **Automatically**, on a daily schedule. The orchestrator re-reads each GitHub
10319
+ source's name + slug from GitHub and updates the dashboard if they changed.
10320
+ The interval is configurable via `KICI_GITHUB_APP_NAME_REFRESH_INTERVAL_MS`
10321
+ (default 24h).
10322
+ - **On demand**, with `source refresh`:
10323
+
10324
+ ```bash
10325
+ kici-admin source refresh github:<appId> # one source
10326
+ kici-admin source refresh --all # every GitHub source
10327
+ ```
10328
+
10329
+ It prints `old → new` for any name or slug that changed, and is a no-op when
10330
+ GitHub already matches what KiCI has stored. Non-GitHub routing keys are
10331
+ rejected — name/slug sync applies only to GitHub App sources.
10332
+
10333
+ ## Self-hosted webhook URL override
10334
+
10335
+ By default the manifest flow bakes the KiCI Platform webhook endpoint
10336
+ (`https://<platform-host>/webhook/<orgId>/github`) into the App. If you run
10337
+ your own ingress and want GitHub to deliver events to it instead, pass
10338
+ `--webhook-url` when creating the App:
10339
+
10340
+ ```bash
10341
+ kici-admin source add github --manifest --name my-org \
10342
+ --webhook-url https://hooks.my-infra.example/github
10343
+ ```
10344
+
10345
+ The supplied URL must be an absolute `https://` URL; it is written into the
10346
+ App's webhook configuration **verbatim**. This is the operator asserting "I own
10347
+ webhook delivery": KiCI adds **no** ingress at this URL and does **not** receive
10348
+ events there — your own infrastructure is responsible for accepting GitHub's
10349
+ deliveries and routing them onward. Supplying the flag also decouples App
10350
+ creation from platform-mode URL resolution, so it works even in a configuration
10351
+ where the auto-resolved KiCI Platform URL is unavailable.
10352
+
9765
10353
  ## Global workflows
9766
10354
 
9767
10355
  A GitHub App source opts in to org-wide global workflows using the
@@ -10273,7 +10861,7 @@ Source: https://docs.kici.dev/architecture/data-flows/
10273
10861
 
10274
10862
  This document describes the key data flows through the KiCI architecture: webhook delivery, job execution, developer-initiated remote runs, dependency caching, re-run and cancel, trace ID propagation, internal event routing, and generic webhook ingestion.
10275
10863
 
10276
- > **Lock file schema version:** The lock file uses schema version 17, which widens per-job init to typed presets (`mise` / `{ mise }`) and `auto` detection on top of v16's normalized approval config, v15's per-job init config, v14's declarative cache specs, v11's `LockInlineValue` for pure function inline evaluation, v10's simplified negative patterns (! prefix in repos/paths arrays), v9's global workflow repos matching, and v8's runsOn polymorphic type support.
10864
+ > **Lock file schema version:** The lock file uses schema version 21, which adds the `CheckMode` / `CheckStepOutcome` enums for check-mode step execution on top of v20's `LabelMatcher` (exact/regex) selectors for `runsOn`/`runsOnAll`/`excludeLabels`, v19's `maxParallel`/`failFast` fan-out concurrency, v18's `runsOnAll` host fan-out predicate and `onUnreachable` policy, v17's typed init presets (`mise` / `{ mise }`) and `auto` detection, v16's normalized approval config, v15's per-job init config, v14's declarative cache specs, v11's `LockInlineValue` for pure function inline evaluation, v10's simplified negative patterns (! prefix in repos/paths arrays), v9's global workflow repos matching, and v8's runsOn polymorphic type support.
10277
10865
 
10278
10866
  ## Webhook delivery flow
10279
10867
 
@@ -10479,7 +11067,7 @@ Dep cache misses alone do **not** trigger a build job. Deps are platform-specifi
10479
11067
 
10480
11068
  ### Cross-source / no-contentHash workflows
10481
11069
 
10482
- - **Lock files without `contentHash`** (schema v1) skip the source cache entirely; agents compile from source. Regenerate lock files with `kici compile` to enable caching. The current lock file schema version is 17.
11070
+ - **Lock files without `contentHash`** (schema v1) skip the source cache entirely; agents compile from source. Regenerate lock files with `kici compile` to enable caching. The current lock file schema version is 21.
10483
11071
  - **Cross-source / global-workflow dispatch** (a workflow registered against source A fired by a webhook on source B) bypasses both caches. The registration's lock file entry still carries `contentHash`, but the cross-source path always clone-and-installs — the eval temp dir doesn't ship `@kici-dev/sdk`. The execution agent still verifies `contentHash` against the cloned source for drift detection.
10484
11072
 
10485
11073
  ### Build deduplication
@@ -10518,7 +11106,7 @@ Both source and dep caches use `S3CacheStorage` as the sole backend. The `CacheS
10518
11106
 
10519
11107
  Cache keys reflect that source tarballs and deps have different platform characteristics:
10520
11108
 
10521
- - **Source:** `source/{contentHash}.tar.gz` — platform-agnostic. Raw TypeScript source is identical regardless of CPU architecture, so one entry is shared across all platforms. `contentHash` is the per-workflow hash from the lock file (`SHA-256(schemaVersion + ":" + rawSource [+ "\0" + assetDigest])`).
11109
+ - **Source:** `source/{contentHash}.tar.gz` — platform-agnostic. Raw TypeScript source is identical regardless of CPU architecture, so one entry is shared across all platforms. `contentHash` is the per-workflow hash from the lock file (`SHA-256(COMPILE_SCHEMA_VERSION + ":" + rawSource [+ "\0" + assetDigest])`, where `COMPILE_SCHEMA_VERSION = 5` and line endings are normalized to LF so the hash agrees across platforms).
10522
11110
  - **Deps:** `deps/{platform}-{arch}/{lockfileHash}.tar.gz` (e.g., `deps/linux-arm64/def456.tar.gz`) — platform-specific. Native dependencies in `node_modules` differ across architectures, so each platform/arch combination gets its own cache entry.
10523
11111
 
10524
11112
  The orchestrator derives the target platform/arch for dep cache lookups by probing `AgentRegistry.findAvailable()` with the workflow's first job's `runsOn` labels to find a representative matching agent, then using that agent's platform and arch. Falls back to `linux/x64` if no matching agents are registered.
@@ -11163,7 +11751,9 @@ This model also enables fully self-hosted deployment: all three tiers can run on
11163
11751
 
11164
11752
  ### Platform
11165
11753
 
11166
- The Platform tier is a thin webhook router. It verifies inbound webhook signatures, relays the payload to the correct orchestrator over WebSocket, and writes a delivery row to its event log. It does not process, store, or execute any customer code. Multi-tenant SaaS specifics (Stripe-driven billing, dashboard surface, identity provider integration) are documented in the internal docs.
11754
+ The Platform is KiCI's hosted, multi-tenant control plane. It provides the hosted dashboard (run listing, run detail, live log streaming, settings), identity and authentication (OIDC, personal access tokens, API keys, JWTs), multi-tenant organization / team / role-based access management, billing, and webhook ingestion -- verifying inbound signatures (HMAC-SHA256, timing-safe) and relaying payloads to the correct orchestrator over WebSocket. It aggregates execution telemetry and status forwarded by orchestrators, registers sources, and matchmakes peers for clustering.
11755
+
11756
+ The Platform never processes, stores, or executes customer code, and never sees customer secrets. It routes webhook payloads and aggregates execution status; the code itself only ever lives on the customer's orchestrator and agent tiers. In the execution path the Platform is deliberately thin -- it does not run jobs -- but functionally it is a full platform, not merely a relay. The hosted Platform is EU-sovereign.
11167
11757
 
11168
11758
  ### Orchestrator (`@kici-dev/orchestrator`)
11169
11759
 
@@ -11198,7 +11788,7 @@ The agent is the execution worker. It runs on customer infrastructure and has fu
11198
11788
 
11199
11789
  Shared business logic used by all three tiers. Single source of truth for cross-tier concerns. Has no internal `@kici-dev/*` dependencies -- only a handful of third-party libraries.
11200
11790
 
11201
- - Protocol message schemas (Zod-based, direction-specific unions including dashboard REST-over-WS, browser live streaming, test run lifecycle, observer channel, log pull, run events, peer-to-peer, cluster join, and source registration)
11791
+ - Protocol message schemas (Zod-based, direction-specific unions including dashboard REST-over-WS, browser live streaming, the test-relay control plane, log pull, run events, peer-to-peer, cluster join, and source registration)
11202
11792
  - Provider interfaces (WebhookNormalizer, LockFileFetcher, ChangedFilesFetcher, CloneTokenProvider, RepoUrlBuilder, ContributorResolver, CheckStatusPoster)
11203
11793
  - Trigger matching engine (branch, path, event evaluation)
11204
11794
  - Execution state machine (11 states, 16 events, pure functions)