@kici-dev/compiler 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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/
@@ -2489,9 +2599,9 @@ const test = job('test', { needs: [lint], ... });
2489
2599
  // 2. Reference by string name
2490
2600
  const test = job('test', { needs: ['lint'], ... });
2491
2601
 
2492
- // 3. Object form with per-edge failure policy
2602
+ // 3. Object form with a per-edge run condition (`when`)
2493
2603
  const cleanup = job('cleanup', {
2494
- needs: [{ name: 'build', ifFailed: 'run' }],
2604
+ needs: [{ name: 'build', when: 'always' }],
2495
2605
  ...
2496
2606
  });
2497
2607
 
@@ -2502,16 +2612,36 @@ const deploy = job('deploy', {
2502
2612
  });
2503
2613
  ```
2504
2614
 
2505
- **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.
2506
2616
 
2507
- | Value | Behavior |
2508
- | ------ | ------------------------------------------------------------------------------------------- |
2509
- | `skip` | (Default) Downstream transitions directly to `skipped`. Failures cascade through the DAG. |
2510
- | `run` | Downstream dispatches anyway. Use for cleanup, notification, or "always-run" teardown jobs. |
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 |
2511
2623
 
2512
- 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).
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`.
2513
2625
 
2514
- **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.
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).
2627
+
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.
2629
+
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).
2515
2645
 
2516
2646
  **DAG validation:** three-layer cycle detection.
2517
2647
 
@@ -2524,7 +2654,10 @@ String and `Job`-reference entries default to `ifFailed: 'skip'`. To override, u
2524
2654
  Create a reference to a dynamic job group, for use inside a static job's `needs` array.
2525
2655
 
2526
2656
  ```typescript
2527
- 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;
2528
2661
  ```
2529
2662
 
2530
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).
@@ -3445,10 +3578,10 @@ export default workflow('discovery-fan-out', { jobs: [discover, reports] });
3445
3578
 
3446
3579
  `ctx.needs` shape:
3447
3580
 
3448
- | Need form | `ctx.needs[...]` value |
3449
- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
3450
- | `'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>`) |
3451
- | `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 |
3452
3585
 
3453
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).
3454
3587
 
@@ -3685,6 +3818,32 @@ A held host occupies a wave slot indefinitely while it waits to reconnect, stall
3685
3818
  roll behind an absent box. `skip` (run only reachable hosts) or `fail` (refuse the roll
3686
3819
  if any expected host is down) keep the window moving.
3687
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
+
3688
3847
  ### Limits (v0)
3689
3848
 
3690
3849
  - Per-host secret scoping is not yet available — all hosts receive the job's resolved
@@ -5649,6 +5808,8 @@ kici run remote [fixture] [options]
5649
5808
  | `--history` | `false` | Show table of recent test runs |
5650
5809
  | `--context <ctx.key=value>` | none | Inject a namespaced context secret, uploaded encrypted (repeatable) |
5651
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 |
5652
5813
  | `--debug` | `false` | Verbose internals |
5653
5814
  | `--kici-dir <path>` | `.kici` | Path to .kici directory |
5654
5815
 
@@ -5687,8 +5848,44 @@ kici run remote push-main --no-wait
5687
5848
 
5688
5849
  # View recent test run history
5689
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
5690
5860
  ```
5691
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
+
5692
5889
  **Exit codes:**
5693
5890
 
5694
5891
  | Code | Meaning |
@@ -5829,18 +6026,21 @@ By default, `kici login` opens your browser for OIDC authentication using PKCE.
5829
6026
 
5830
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`).
5831
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
+
5832
6031
  ```bash
5833
6032
  kici login [options]
5834
6033
  ```
5835
6034
 
5836
6035
  **Options:**
5837
6036
 
5838
- | Option | Default | Description |
5839
- | --------------------------- | ------- | ---------------------------------------------- |
5840
- | `--token <key>` | none | API key for direct authentication (legacy) |
5841
- | `--device` | false | Force device authorization flow (headless/SSH) |
5842
- | `--platform-endpoint <url>` | none | Platform relay URL |
5843
- | `--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 |
5844
6044
 
5845
6045
  **Environment variables:**
5846
6046
 
@@ -5866,7 +6066,8 @@ kici login --device
5866
6066
  kici login --token kici_sk_abc123...
5867
6067
 
5868
6068
  # Log in against a self-hosted Platform
5869
- 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
5870
6071
 
5871
6072
  # Suppress browser opening (print authorize URL to stdout)
5872
6073
  KICI_BROWSER_CMD=none kici login
@@ -7520,12 +7721,14 @@ Source: https://docs.kici.dev/user/approvals/
7520
7721
 
7521
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.
7522
7723
 
7523
- 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:
7524
7725
 
7525
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.
7526
7727
  - **Job** — hold the job before any of its steps run.
7527
7728
  - **Workflow** — hold the whole run before any job is dispatched.
7528
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
+
7529
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.
7530
7733
 
7531
7734
  ## Quick start
@@ -7540,7 +7743,7 @@ export default workflow('deploy', {
7540
7743
  jobs: [
7541
7744
  job('deploy-production', {
7542
7745
  runsOn: 'default',
7543
- requireApproval: [{ team: 'leads' }],
7746
+ approval: [{ team: 'leads' }],
7544
7747
  steps: [step('deploy', async (ctx) => ctx.$`deploy --prod`)],
7545
7748
  }),
7546
7749
  ],
@@ -7549,28 +7752,28 @@ export default workflow('deploy', {
7549
7752
 
7550
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.
7551
7754
 
7552
- ## The `requireApproval` field
7755
+ ## The `approval` field
7553
7756
 
7554
- `requireApproval` accepts three forms.
7757
+ `approval` accepts three forms.
7555
7758
 
7556
7759
  ### Shorthand: `true`
7557
7760
 
7558
7761
  ```typescript
7559
7762
  job('deploy', {
7560
7763
  runsOn: 'default',
7561
- requireApproval: true,
7764
+ approval: true,
7562
7765
  steps: [
7563
7766
  /* ... */
7564
7767
  ],
7565
7768
  });
7566
7769
  ```
7567
7770
 
7568
- `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.
7569
7772
 
7570
7773
  ### Approver list (AND)
7571
7774
 
7572
7775
  ```typescript
7573
- requireApproval: [{ team: 'leads' }, { user: 'cto' }],
7776
+ approval: [{ team: 'leads' }, { user: 'cto' }],
7574
7777
  ```
7575
7778
 
7576
7779
  A list of approver clauses is an **AND** list: every clause must be satisfied before the element is released.
@@ -7582,27 +7785,29 @@ A single approver may satisfy more than one clause. If `cto` is also a member of
7582
7785
 
7583
7786
  There is no OR or nested logic — clauses are always a flat AND list.
7584
7787
 
7585
- ### Object form: reason and timeout
7788
+ ### Object form: when, reason, and timeout
7586
7789
 
7587
7790
  ```typescript
7588
- requireApproval: {
7791
+ approval: {
7792
+ when: 'always',
7589
7793
  approvers: [{ team: 'security' }, { team: 'leads' }],
7590
7794
  reason: 'Production deploy requires security + leads sign-off',
7591
7795
  timeout: 7200, // seconds
7592
7796
  },
7593
7797
  ```
7594
7798
 
7595
- | Field | Type | Description |
7596
- | ----------- | ------------------ | --------------------------------------------------------------------------------------------------- |
7597
- | `approvers` | `ApproverClause[]` | The AND list of `{ team }` / `{ user }` clauses. An empty list means "any approval-capable member". |
7598
- | `reason` | `string` | A human-readable label shown in the dashboard queue and the held-for-approval status check. |
7599
- | `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. |
7600
7805
 
7601
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).
7602
7807
 
7603
7808
  ## Granularity
7604
7809
 
7605
- 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.
7606
7811
 
7607
7812
  ### Workflow-level
7608
7813
 
@@ -7611,7 +7816,7 @@ A workflow-level gate holds the entire run before any job is dispatched:
7611
7816
  ```typescript
7612
7817
  export default workflow('release', {
7613
7818
  on: [push({ branches: ['main'] })],
7614
- requireApproval: [{ team: 'release-managers' }],
7819
+ approval: [{ team: 'release-managers' }],
7615
7820
  jobs: [buildJob, publishJob],
7616
7821
  });
7617
7822
  ```
@@ -7623,7 +7828,7 @@ A job-level gate holds just that job; other jobs in the run proceed normally:
7623
7828
  ```typescript
7624
7829
  job('publish', {
7625
7830
  runsOn: 'default',
7626
- requireApproval: [{ team: 'leads' }],
7831
+ approval: [{ team: 'leads' }],
7627
7832
  steps: [
7628
7833
  /* ... */
7629
7834
  ],
@@ -7640,7 +7845,7 @@ job('migrate-and-deploy', {
7640
7845
  steps: [
7641
7846
  step('build-plan', async (ctx) => ctx.$`./gen-migration-plan.sh`),
7642
7847
  step('apply-migration', {
7643
- requireApproval: [{ team: 'dba' }],
7848
+ approval: [{ team: 'dba' }],
7644
7849
  run: async (ctx) => ctx.$`./apply-migration.sh`,
7645
7850
  }),
7646
7851
  step('deploy', async (ctx) => ctx.$`deploy --prod`),
@@ -7652,9 +7857,39 @@ Here `build-plan` runs, then the job pauses for a `dba` approval. On approval, `
7652
7857
 
7653
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).
7654
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
+
7655
7890
  ## Mandatory vs. explicit gates
7656
7891
 
7657
- `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.
7658
7893
 
7659
7894
  ## Approving from the CLI
7660
7895
 
@@ -7676,10 +7911,23 @@ kici reject <run-id> --job deploy-production --reason "Wrong release branch"
7676
7911
 
7677
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.
7678
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
+
7679
7926
  You can also approve from the dashboard approval queue. See [Dashboard](dashboard/environments-and-secrets.md#approval-queue).
7680
7927
 
7681
7928
  ## See also
7682
7929
 
7930
+ - [Idempotent steps](idempotent-steps.md) — the check/apply step facet that drift gates build on.
7683
7931
  - [Environments](environments.md) — operator-required reviewers on protected environments.
7684
7932
  - [Approval gates (operator guide)](../operator/approvals.md) — teams, the approval queue, expiry, and self-approval.
7685
7933
  - [Approval gates (architecture)](../architecture/approvals.md) — the unified hold model and the step-level round-trip.
@@ -8312,7 +8560,7 @@ Required reviewers: alice, bob
8312
8560
 
8313
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.
8314
8562
 
8315
- 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.
8316
8564
 
8317
8565
  ### Wait timer
8318
8566
 
@@ -8987,7 +9235,7 @@ const configureNginx = step('configure-nginx', {
8987
9235
  `summarize` is **required** whenever `check` is declared. `run` and `whenInSync`
8988
9236
  both produce the same output type — one output shape per step, whichever path
8989
9237
  runs. Every other step facet (`cache`, `rules`, `continueOnError`, `timeout`,
8990
- `requireApproval`, `onCancel`, `cleanup`, `outputs`) composes unchanged.
9238
+ `approval`, `onCancel`, `cleanup`, `outputs`) composes unchanged.
8991
9239
 
8992
9240
  A plain `step()` without `check` keeps its exact current behavior — the check
8993
9241
  facet is fully optional.
@@ -9886,11 +10134,12 @@ GitHub App "my-org" is live.
9886
10134
 
9887
10135
  **Flags:**
9888
10136
 
9889
- | Flag | Effect |
9890
- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9891
- | `--name <name>` | The App name on GitHub (required). |
9892
- | `--github-org <slug>` | Create the App under a GitHub organization instead of your personal account. |
9893
- | `--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. |
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. |
9894
10143
 
9895
10144
  The manifest flow always creates a **new** App on GitHub. If a source for
9896
10145
  that App id already exists on the orchestrator, the command refuses — use
@@ -10054,6 +10303,53 @@ its own source record and the orchestrator looks up the right one by
10054
10303
  combining the URL's `<orgId>` with the App ID from the
10055
10304
  `X-GitHub-Hook-Installation-Target-ID` header.
10056
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
+
10057
10353
  ## Global workflows
10058
10354
 
10059
10355
  A GitHub App source opts in to org-wide global workflows using the
@@ -14,6 +14,7 @@ The full markdown bundle of every page indexed here is available at https://docs
14
14
 
15
15
  - [Basic workflow patterns](https://docs.kici.dev/user/patterns/basic/): Basic CI, PR-only / push-only filters, multiple triggers, manual-only workflows
16
16
  - [Conditionals & matrix patterns](https://docs.kici.dev/user/patterns/conditionals-matrix/): Conditional execution with rules, matrix builds (static + dynamic), dynamic job generation
17
+ - [Host restart & wait-for-alive](https://docs.kici.dev/user/patterns/host-restart/): Reboot the host a workflow runs on and continue after it comes back
17
18
  - [Integration patterns](https://docs.kici.dev/user/patterns/integrations/): Workflow chaining, generic webhooks, Stripe, self-hosted git forges, plain GitHub repos
18
19
  - [Pattern reference](https://docs.kici.dev/user/patterns/reference/): Step context, examples repository, GitHub check run output — cross-cutting reference for all patterns
19
20
  - [Scheduling & event patterns](https://docs.kici.dev/user/patterns/scheduling-and-events/): Nightly cron, workflow-complete-triggered deploys, custom event chaining
@@ -44,7 +45,7 @@ The full markdown bundle of every page indexed here is available at https://docs
44
45
  ## Workflow features
45
46
 
46
47
  - [Account and sign-in](https://docs.kici.dev/user/account-and-login/): How your KiCI account relates to sign-in methods, and how to change the way you sign in.
47
- - [Approval gates](https://docs.kici.dev/user/approvals/): Pause a workflow for human sign-off at step, job, or workflow granularity with requireApproval
48
+ - [Approval gates](https://docs.kici.dev/user/approvals/): Pause a workflow for human sign-off at step, job, or workflow granularity with approval
48
49
  - [Concurrency groups](https://docs.kici.dev/user/concurrency/): Control parallel execution with auto-cancel and queue modes
49
50
  - [Dashboard](https://docs.kici.dev/user/dashboard/): Web UI for monitoring workflow runs, managing sources, secrets, and organization settings.
50
51
  - [Dynamic values](https://docs.kici.dev/user/dynamic-values/)