@kici-dev/orchestrator 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/dist/__test-helpers__/fake-scaler-state-store.d.ts +24 -0
  2. package/dist/__test-helpers__/mock-db-query.d.ts +130 -0
  3. package/dist/__test-helpers__/mock-db.d.ts +27 -0
  4. package/dist/agent/agent-version.d.ts +34 -0
  5. package/dist/agent/dispatcher.d.ts +151 -13
  6. package/dist/agent/host-roster.d.ts +37 -5
  7. package/dist/agent/registry.d.ts +2 -0
  8. package/dist/app.d.ts +56 -7
  9. package/dist/approvals/apply-decision.d.ts +67 -19
  10. package/dist/approvals/held-run-release-wiring.d.ts +45 -0
  11. package/dist/approvals/independent-wiring.d.ts +55 -0
  12. package/dist/approvals/resume-router.d.ts +9 -0
  13. package/dist/approvals/triggerer-subject.d.ts +37 -0
  14. package/dist/cache/dep-cache.d.ts +54 -10
  15. package/dist/cache/global-eval-round-cache.d.ts +95 -0
  16. package/dist/cache/index.d.ts +3 -0
  17. package/dist/cache/pending-global-evals.d.ts +42 -0
  18. package/dist/cache/pending-inits.d.ts +10 -0
  19. package/dist/cli/api-client.d.ts +18 -0
  20. package/dist/cli/commands/cluster-settings.d.ts +52 -3
  21. package/dist/cli/commands/diagnose.d.ts +6 -0
  22. package/dist/cli/commands/held-run.d.ts +58 -0
  23. package/dist/cli/commands/runs.d.ts +1 -0
  24. package/dist/cli/commands/trust-policy.d.ts +117 -12
  25. package/dist/cli/kici-admin.d.ts +10 -0
  26. package/dist/cli.js +2770 -938
  27. package/dist/cluster/cluster-identity.d.ts +3 -3
  28. package/dist/cluster/cluster-settings-reader.d.ts +59 -1
  29. package/dist/cluster/coordinator.d.ts +44 -0
  30. package/dist/cluster/index.d.ts +4 -0
  31. package/dist/cluster/peer-handler.d.ts +19 -0
  32. package/dist/cluster/peer-registry.d.ts +17 -0
  33. package/dist/cluster/plan-headroom-store.d.ts +26 -0
  34. package/dist/cluster/worker-eviction.d.ts +69 -0
  35. package/dist/cold-store/load-event-log-range.d.ts +10 -3
  36. package/dist/cold-store/load-secret-audit-log-range.d.ts +4 -3
  37. package/dist/cold-store/tables/secret-audit-log.d.ts +4 -3
  38. package/dist/config.d.ts +48 -21
  39. package/dist/content-requirements-cache.d.ts +55 -0
  40. package/dist/contexts/held-runs.d.ts +123 -16
  41. package/dist/contexts/protection/aggregate.d.ts +3 -2
  42. package/dist/contexts/protection/branch-gate.d.ts +15 -0
  43. package/dist/contexts/protection/concurrency-gate.d.ts +8 -2
  44. package/dist/contexts/protection/pipeline.d.ts +18 -0
  45. package/dist/contexts/release-queued-holds.d.ts +35 -0
  46. package/dist/dashboard/attestation-filters.d.ts +8 -0
  47. package/dist/dashboard/handler.d.ts +6 -24
  48. package/dist/db/migrations/109_cluster_settings_cache_knobs.d.ts +4 -0
  49. package/dist/db/migrations/110_cluster_settings_global_eval_knobs.d.ts +4 -0
  50. package/dist/db/migrations/111_cluster_settings_global_eval_wait.d.ts +4 -0
  51. package/dist/db/migrations/112_execution_runs_workflow_repo.d.ts +4 -0
  52. package/dist/db/migrations/113_execution_runs_workflow_repo_index.d.ts +30 -0
  53. package/dist/db/migrations/114_ingest_queue_claim.d.ts +4 -0
  54. package/dist/db/migrations/115_global_workflows_cluster_switch.d.ts +9 -0
  55. package/dist/db/migrations/116_org_plan_headroom.d.ts +4 -0
  56. package/dist/db/migrations/117_invoke_gate_proxy_jobs.d.ts +4 -0
  57. package/dist/db/migrations/118_invoke_gate_scheduling.d.ts +4 -0
  58. package/dist/db/migrations/119_scaler_ha_ownership.d.ts +4 -0
  59. package/dist/db/migrations/120_cluster_settings_scaler_reaper.d.ts +4 -0
  60. package/dist/db/migrations/121_org_settings_allow_untrusted_dockerfile_builds.d.ts +19 -0
  61. package/dist/db/migrations/122_host_roster_scaler_managed.d.ts +25 -0
  62. package/dist/db/migrations/123_workflow_registrations_default_branch.d.ts +24 -0
  63. package/dist/db/migrations/124_cluster_settings_provision_backoff.d.ts +4 -0
  64. package/dist/db/migrations/125_org_trust_directory.d.ts +30 -0
  65. package/dist/db/migrations/126_held_runs_posted_pending_check.d.ts +28 -0
  66. package/dist/db/migrations/127_org_trust_policy_approval_expiry_seconds.d.ts +26 -0
  67. package/dist/db/migrations/128_execution_runs_global_eval_round.d.ts +23 -0
  68. package/dist/db/migrations/129_execution_runs_dispatch_routing_key.d.ts +24 -0
  69. package/dist/db/migrations/130_scaler_provision_outcomes.d.ts +32 -0
  70. package/dist/db/types.d.ts +428 -12
  71. package/dist/entry-helpers.d.ts +28 -0
  72. package/dist/events/event-emitter.d.ts +22 -0
  73. package/dist/events/event-router.d.ts +87 -1
  74. package/dist/events/types.d.ts +2 -23
  75. package/dist/git/credential-broker.d.ts +94 -0
  76. package/dist/git/inherited-secret.d.ts +24 -0
  77. package/dist/git/installation-token.d.ts +38 -0
  78. package/dist/git/job-context.d.ts +13 -0
  79. package/dist/index.js +324 -84
  80. package/dist/metrics/agent-metrics-aggregator.d.ts +4 -4
  81. package/dist/metrics/prometheus.d.ts +170 -3
  82. package/dist/oidc/oidc-mint-registration.d.ts +7 -3
  83. package/dist/orchestrator-core.d.ts +91 -24
  84. package/dist/pipeline/content-filter.d.ts +71 -0
  85. package/dist/pipeline/direct-ingress-deps.d.ts +100 -0
  86. package/dist/pipeline/dispatch-matched-workflow.d.ts +521 -11
  87. package/dist/pipeline/global-eval-round.d.ts +307 -0
  88. package/dist/pipeline/internal-event-pipeline.d.ts +140 -0
  89. package/dist/pipeline/invoke-gate.d.ts +164 -0
  90. package/dist/pipeline/job-contexts.d.ts +16 -17
  91. package/dist/pipeline/needs-scheduler.d.ts +13 -0
  92. package/dist/pipeline/process-webhook.d.ts +126 -31
  93. package/dist/pipeline/processor.d.ts +282 -23
  94. package/dist/pipeline/rerun.d.ts +21 -0
  95. package/dist/pipeline/resume-workflow.d.ts +40 -7
  96. package/dist/pipeline/route-or-dispatch-jobs.d.ts +15 -0
  97. package/dist/pipeline/security-hold-check.d.ts +217 -0
  98. package/dist/pipeline/test-pipeline.d.ts +12 -0
  99. package/dist/pipeline/undispatched-hold-checks.d.ts +23 -0
  100. package/dist/pipeline/webhook-payload-store.d.ts +20 -0
  101. package/dist/policy/dashboard-write-policy-listener.d.ts +62 -0
  102. package/dist/policy/dashboard-write-policy.d.ts +14 -0
  103. package/dist/provenance/backfill-run.d.ts +10 -1
  104. package/dist/provider-registry.d.ts +47 -4
  105. package/dist/providers/github/check-status-poster.d.ts +33 -3
  106. package/dist/providers/github/commit-message.d.ts +20 -0
  107. package/dist/providers/github/file-contents.d.ts +40 -0
  108. package/dist/providers/github/index.d.ts +2 -2
  109. package/dist/providers/github/normalizer.d.ts +3 -2
  110. package/dist/providers/local/normalizer.d.ts +3 -5
  111. package/dist/providers/universal-git/config.d.ts +2 -0
  112. package/dist/providers/universal-git/index.d.ts +11 -2
  113. package/dist/providers/universal-git/normalizer.d.ts +10 -0
  114. package/dist/queue/cleanup.d.ts +7 -1
  115. package/dist/queue/job-queue.d.ts +112 -7
  116. package/dist/queue/terminalize-unroutable.d.ts +13 -0
  117. package/dist/registration/extractor.d.ts +3 -3
  118. package/dist/registration/registration-index.d.ts +7 -0
  119. package/dist/registration/registration-run-match.d.ts +47 -0
  120. package/dist/registration/registration-store.d.ts +22 -0
  121. package/dist/reporting/check-run-reporter.d.ts +146 -2
  122. package/dist/reporting/check-run-summary.d.ts +25 -1
  123. package/dist/reporting/execution-tracker.d.ts +324 -9
  124. package/dist/reporting/log-chunk-sink.d.ts +8 -5
  125. package/dist/reporting/run-aggregator.d.ts +4 -14
  126. package/dist/reporting/step-display-order.d.ts +43 -0
  127. package/dist/routes/admin-held-runs.d.ts +142 -0
  128. package/dist/routes/admin-org-settings.d.ts +5 -0
  129. package/dist/routes/admin-trust-policy.d.ts +34 -6
  130. package/dist/routes/admin.d.ts +15 -0
  131. package/dist/scaler/backend-factory.d.ts +55 -0
  132. package/dist/scaler/bare-metal-backend.d.ts +43 -5
  133. package/dist/scaler/claim-store.d.ts +119 -0
  134. package/dist/scaler/config.d.ts +4 -0
  135. package/dist/scaler/container-backend.d.ts +18 -4
  136. package/dist/scaler/container-routing.d.ts +23 -0
  137. package/dist/scaler/container-spawn.d.ts +28 -0
  138. package/dist/scaler/event-backend.d.ts +124 -0
  139. package/dist/scaler/event-provision-reaper.d.ts +350 -0
  140. package/dist/scaler/failure-tracker.d.ts +1 -1
  141. package/dist/scaler/firecracker-backend.d.ts +33 -6
  142. package/dist/scaler/index.d.ts +11 -1
  143. package/dist/scaler/label-matcher.d.ts +4 -3
  144. package/dist/scaler/manager.d.ts +905 -76
  145. package/dist/scaler/resolve-container-auth.d.ts +45 -0
  146. package/dist/scaler/scaler-events.d.ts +11 -0
  147. package/dist/scaler/scaler-state-store.d.ts +294 -10
  148. package/dist/scaler/types.d.ts +131 -13
  149. package/dist/scaler/warm-pool.d.ts +132 -29
  150. package/dist/security/comment-handler.d.ts +50 -11
  151. package/dist/security/global-workflow-policy.d.ts +52 -12
  152. package/dist/security/identity-link.d.ts +60 -0
  153. package/dist/security/lock-source.d.ts +11 -13
  154. package/dist/security/reduced-privilege-note.d.ts +59 -0
  155. package/dist/security/trust-directory-store.d.ts +233 -0
  156. package/dist/security/trust-policy-gate.d.ts +94 -59
  157. package/dist/security/trust-policy-store.d.ts +3 -0
  158. package/dist/security/trust-resolver.d.ts +14 -98
  159. package/dist/server.d.ts +11 -1
  160. package/dist/server.js +56329 -42234
  161. package/dist/stale-detector/gate-deadline-detector.d.ts +52 -0
  162. package/dist/stale-detector/stale-run-detector.d.ts +61 -3
  163. package/dist/standalone.js +37619 -23570
  164. package/dist/webhook/ingest-accept.d.ts +70 -0
  165. package/dist/webhook/ingest-overflow-buffer.d.ts +35 -4
  166. package/dist/webhook/ingest-overflow-replayer.d.ts +50 -6
  167. package/dist/worker/in-memory-job-queue.d.ts +1 -1
  168. package/dist/ws/agent-handler.d.ts +31 -4
  169. package/dist/ws/dashboard-context-handler.d.ts +28 -4
  170. package/dist/ws/dashboard-global-workflows-handler.d.ts +36 -9
  171. package/dist/ws/execution-status-frame.d.ts +32 -0
  172. package/dist/ws/failure-messages.d.ts +2 -0
  173. package/dist/ws/git-credential-relay.d.ts +39 -0
  174. package/dist/ws/oidc-token-relay.d.ts +6 -11
  175. package/dist/ws/platform-client.d.ts +68 -9
  176. package/dist/ws/test-relay-handlers.d.ts +35 -10
  177. package/installer-image-digests.json +3 -3
  178. package/package.json +24 -19
  179. package/sbom.spdx.json +868 -821
  180. package/dist/approvals/team-membership-lookup.d.ts +0 -13
  181. package/dist/pipeline/inline-eval.d.ts +0 -44
  182. package/dist/providers/github/contributor-resolver.d.ts +0 -30
  183. package/dist/security/contributor-cache.d.ts +0 -83
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Resolve a job's container spec into something a runtime can pull with.
3
+ *
4
+ * The lock carries credential REFERENCES (`<context>:<secret-name>`), never
5
+ * material — so somebody has to turn those into an authconfig, and three call
6
+ * sites need the identical answer: the agent's container sandbox, the container
7
+ * scaler backend, and the bare-metal backend in container mode. One resolver
8
+ * keeps them from drifting into three subtly different readings of the same
9
+ * lock field.
10
+ */
11
+ import type { LockJob } from '@kici-dev/engine';
12
+ import type { ResolvedContainerSpawn } from './types.js';
13
+ export type { ResolvedContainerSpawn };
14
+ /** The `container` field as the lock carries it. */
15
+ type LockContainer = LockJob['container'];
16
+ export interface ResolveContainerSpawnDeps {
17
+ /**
18
+ * Resolve a qualified `<context>:<secret-name>` reference, or `undefined`
19
+ * when no such secret exists.
20
+ */
21
+ resolveSecret: (qualifiedRef: string) => Promise<string | undefined>;
22
+ }
23
+ /**
24
+ * Resolve just the registry credentials for a job's container.
25
+ *
26
+ * Split out of {@link resolveContainerSpawn} because the two consumers diverge
27
+ * for a dockerfile build: there is no image for the SCALER to spawn an agent
28
+ * from, but the AGENT still needs credentials to pull the Dockerfile's own
29
+ * `FROM` base.
30
+ *
31
+ * Returns `undefined` when the job declared no credentials at all.
32
+ */
33
+ export declare function resolveContainerRegistryAuth(container: LockContainer, deps: ResolveContainerSpawnDeps): Promise<NonNullable<ResolvedContainerSpawn['authconfig']> | undefined>;
34
+ /**
35
+ * Turn a lock `container` field into an image plus, when the job declared
36
+ * credentials, an authconfig for the registry that image lives in.
37
+ *
38
+ * Returns `undefined` for a job with no container at all — and for a job that
39
+ * BUILDS its image, because the scaler cannot spawn an agent from an image that
40
+ * does not exist yet. That job is run by an ordinary agent, which nests the
41
+ * container after it has cloned and built. So a caller can treat "no container",
42
+ * "container without auth" and "container built from a dockerfile" distinctly.
43
+ */
44
+ export declare function resolveContainerSpawn(container: LockContainer, deps: ResolveContainerSpawnDeps): Promise<ResolvedContainerSpawn | undefined>;
45
+ //# sourceMappingURL=resolve-container-auth.d.ts.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Orchestrator-side entry point for the event scaler's event vocabulary.
3
+ *
4
+ * The schemas and constants themselves live in `@kici-dev/engine` so the SDK —
5
+ * the only KiCI package a workflow file may import — shares one source of truth
6
+ * with the emitting side. This module re-exports them (plus the reserved
7
+ * prefix) so orchestrator call sites keep a single local import for the whole
8
+ * vocabulary.
9
+ */
10
+ export { KICI_EVENT_NAME_PREFIX, SCALER_EVENT_NAMES, ScaleDownReason, ScalerScaleUpPayload, ScalerScaleDownPayload, } from '@kici-dev/engine';
11
+ //# sourceMappingURL=scaler-events.d.ts.map
@@ -1,9 +1,13 @@
1
- import type { Kysely } from 'kysely';
1
+ import { type Kysely } from 'kysely';
2
2
  import type { Database } from '../db/types.js';
3
3
  import type { ScalerEvent } from './types.js';
4
4
  /**
5
5
  * Snapshot of a spawning-agent record. Mirrors the row shape in
6
6
  * `scaler_spawning_agents`.
7
+ *
8
+ * The ownership and self-describing fields are optional: a row written before a
9
+ * coordinator instance id was known carries none of them, and an absent
10
+ * `ownerInstanceId` means "unknown owner", never "owned by someone else".
7
11
  */
8
12
  export interface SpawningAgentSnapshot {
9
13
  agentId: string;
@@ -13,6 +17,82 @@ export interface SpawningAgentSnapshot {
13
17
  jobId?: string;
14
18
  boundJobId?: string;
15
19
  spawnedAt: Date;
20
+ /** The coordinator instance that spawned the agent. */
21
+ ownerInstanceId?: string;
22
+ /** The coordinator the agent actually reached when it registered. */
23
+ adoptedBy?: string;
24
+ /** When the adopting coordinator claimed the agent. */
25
+ adoptedAt?: Date;
26
+ /** The scaler's mandatory labels, copied onto the row at spawn time. */
27
+ mandatoryLabels?: string[];
28
+ /** The scaler's provisioning targets, copied onto the row at spawn time. */
29
+ provisioningTargets?: string[];
30
+ /** The scaler's roles, copied onto the row at spawn time. */
31
+ roles?: string[];
32
+ /** The scaler backend that spawned the agent. */
33
+ backendType?: string;
34
+ }
35
+ /** A pending provisioning claim, as written by the event scaler's claim store. */
36
+ export interface PendingClaimRow {
37
+ claimHash: string;
38
+ claimPrefix: string;
39
+ agentId: string;
40
+ scalerName: string;
41
+ labels: string[];
42
+ agentTokenTtlMs: number;
43
+ orchestratorUrl: string;
44
+ expiresAt: Date;
45
+ }
46
+ /** What a successful `redeemClaim` returns. */
47
+ export interface RedeemedClaim {
48
+ agentId: string;
49
+ labels: string[];
50
+ agentTokenTtlMs: number;
51
+ orchestratorUrl: string;
52
+ }
53
+ /**
54
+ * The critical section `withScalerCapLock` hands its caller: the scaler's
55
+ * cluster-wide spawn count, plus the one write that changes it.
56
+ */
57
+ export interface ScalerCapSlot {
58
+ /**
59
+ * Live event-scaler spawn rows for this scaler across every coordinator.
60
+ * Read inside the advisory-locked transaction, so no other holder of the
61
+ * lock can change it while the caller decides.
62
+ */
63
+ clusterActiveCount: number;
64
+ /**
65
+ * Claim a slot by writing the spawn row in the same transaction the count
66
+ * was read in. Counting without claiming here bounds nothing: the spawn row
67
+ * is otherwise written well after the check, so every coordinator arriving
68
+ * in that window reads the same pre-spawn count and admits.
69
+ */
70
+ reserve(snapshot: SpawningAgentSnapshot): Promise<void>;
71
+ }
72
+ /** A row the event-provision reaper may need to tear down. */
73
+ export interface ReapCandidate {
74
+ agentId: string;
75
+ scalerName: string;
76
+ provisioningTargets: string[];
77
+ ownerInstanceId?: string;
78
+ adoptedBy?: string;
79
+ /**
80
+ * When the provision was requested. The reaper ages every row on this and
81
+ * never on `adopted_at`: a coordinator that re-adopts its own agent refreshes
82
+ * `adopted_at`, so a restart loop on the adopter would keep resetting the
83
+ * clock on its own stranded provision and the row would never age out.
84
+ */
85
+ spawnedAt: Date;
86
+ /**
87
+ * The run this provision was spawned for, when it was job-bound. Carried so a
88
+ * teardown for a provision that never registered can be attributed back to the
89
+ * job that is still waiting on it — the reaper is leader-gated, so the
90
+ * coordinator that condemns the row is often not the one that spawned it and
91
+ * has no in-memory spawning entry to read the identity from.
92
+ */
93
+ runId?: string;
94
+ /** The queued job this provision was spawned for. Absent for a warm fill. */
95
+ boundJobId?: string;
16
96
  }
17
97
  /**
18
98
  * Snapshot of an agent-job correlation. Mirrors the row shape in
@@ -32,13 +112,18 @@ export interface ReservationSnapshot {
32
112
  scalerName: string;
33
113
  cpus: number;
34
114
  memBytes: number;
115
+ /**
116
+ * The coordinator instance holding the reservation. Absent means "unknown
117
+ * owner", never "held by someone else".
118
+ */
119
+ ownerInstanceId?: string;
35
120
  }
36
121
  /**
37
122
  * DB persistence for `ScalerManager` HA-critical state.
38
123
  *
39
- * Backed by three tables — `scaler_spawning_agents`, `scaler_agent_jobs`,
40
- * `scaler_reservations` — so a Raft leader switch / coord crash no
41
- * longer:
124
+ * Backed by four tables — `scaler_spawning_agents`, `scaler_agent_jobs`,
125
+ * `scaler_reservations`, `scaler_pending_claims` — so a Raft leader switch /
126
+ * coord crash no longer:
42
127
  *
43
128
  * - orphans an agent that is mid-spawn (lost `boundJobId` → eager
44
129
  * dispatch silently downgraded to a generic queue drain),
@@ -59,26 +144,225 @@ export interface ReservationSnapshot {
59
144
  * correlation are observability, not correctness. A coord crash before
60
145
  * `correlateAgentToJob()` runs accepts losing those events (see the
61
146
  * wishlist for the rationale).
147
+ *
148
+ * Ownership columns (`owner_instance_id`, `adopted_by`) let several
149
+ * coordinators behind one shared endpoint divide the same tables between them.
150
+ * A NULL owner means the owner is unknown — it never means "owned by another
151
+ * instance", so no query may read a NULL owner as somebody else's row.
152
+ */
153
+ /**
154
+ * Postgres `lock_not_available`. Raised when the `lock_timeout` the cap
155
+ * transaction sets expires with the advisory lock still held elsewhere —
156
+ * a contended lock on a healthy database, which reads very differently from a
157
+ * database that could not be reached at all.
62
158
  */
159
+ export declare const PG_LOCK_NOT_AVAILABLE = "55P03";
63
160
  export declare class ScalerStateStore {
64
161
  private readonly db;
65
- constructor(db: Kysely<Database>);
162
+ private readonly lockWaitMs?;
163
+ /**
164
+ * `lockWaitMs` bounds how long `withScalerCapLock` waits to acquire the
165
+ * advisory lock. It is the orchestrator's existing `dbPoolAcquireTimeoutMs`
166
+ * — the same answer to "how long may a caller block waiting to get hold of a
167
+ * database resource before we fail" — not a timeout of its own. Omitted, the
168
+ * wait is unbounded except by `statement_timeout`, which is the behaviour a
169
+ * store built without it has always had.
170
+ */
171
+ constructor(db: Kysely<Database>, lockWaitMs?: number | undefined);
66
172
  upsertSpawningAgent(snapshot: SpawningAgentSnapshot): Promise<void>;
173
+ /**
174
+ * Run a cap check plus its slot claim for one scaler under a cluster-wide
175
+ * lock.
176
+ *
177
+ * `runWithReservationLock` in the manager is an in-process promise chain:
178
+ * correct within an instance, blind across them. For an event scaler that
179
+ * means N coordinators each provision up to `maxAgents` cloud instances, so
180
+ * the number an operator writes is not the number they are billed for. The
181
+ * advisory lock is transaction-scoped, so it releases on commit or rollback
182
+ * with no unlock path to leak.
183
+ *
184
+ * The count and the claim share the transaction on purpose — see
185
+ * `ScalerCapSlot.reserve`. `fn` therefore runs inside an open transaction:
186
+ * keep it short, and never await anything outside the database in it.
187
+ *
188
+ * READ COMMITTED is pinned rather than inherited. The lock is taken first
189
+ * and the count read second, so each statement needs its own snapshot for
190
+ * the second holder to see the first one's committed row. Under REPEATABLE
191
+ * READ the snapshot is taken at the FIRST statement — before the lock is
192
+ * granted — so both coordinators would read the same pre-claim count and
193
+ * both admit, reintroducing the exact defect this exists to close. Pinning
194
+ * it means a server-side `default_transaction_isolation` change cannot do
195
+ * that silently.
196
+ *
197
+ * A caller that cannot get the lock within `lockWaitMs` fails rather than
198
+ * queuing: the manager holds its process-wide reservation lock across this
199
+ * call, so an unbounded wait here stalls every other backend's scale
200
+ * request too.
201
+ */
202
+ withScalerCapLock<T>(scalerName: string, fn: (slot: ScalerCapSlot) => Promise<T> | T): Promise<T>;
67
203
  deleteSpawningAgent(agentId: string): Promise<void>;
204
+ /**
205
+ * Delete a spawning-agent row only while no instance has adopted it.
206
+ *
207
+ * The spawning instance is never told that a peer adopted its agent, so its
208
+ * in-memory entry survives adoption and its stale-entry prune fires five
209
+ * minutes later. An unconditional delete there would drop the row whose
210
+ * `adopted_by` is the only durable record of a live provision — and an
211
+ * adopted event agent legitimately runs for hours, so the reaper would find
212
+ * nothing to tear down if the adopting instance then died. The
213
+ * `adopted_by IS NULL` predicate keeps the prune to rows nobody claimed.
214
+ */
215
+ deleteUnadoptedSpawningAgent(agentId: string): Promise<void>;
216
+ /**
217
+ * The instance that adopted a provision, or null while nobody has.
218
+ *
219
+ * The counterpart read to `deleteUnadoptedSpawningAgent`'s predicate, for the
220
+ * caller that must not act rather than must not delete: the spawning instance
221
+ * is never told that a peer adopted its agent, so a stale in-memory entry is
222
+ * no evidence the provision failed.
223
+ *
224
+ * Reads the LIVE spawn row first and the durable outcome second, because the
225
+ * spawn row is deleted on teardown and its absence is ambiguous — it is both
226
+ * "never adopted" and "adopted, then torn down". The outcome row survives the
227
+ * delete and answers the second case positively.
228
+ *
229
+ * A provision with neither row still answers null, and the prune treats that
230
+ * as "no evidence" and reports — which is what a single coordinator wants and
231
+ * what keeps every coordinator backing off on a real provisioning outage. See
232
+ * `ScalerManager.reportPrunedProvisionFailure`.
233
+ */
234
+ provisionAdopter(agentId: string): Promise<string | null>;
235
+ /**
236
+ * Every spawning-agent row in the table, whoever owns it. Not for recovery —
237
+ * hydrating a peer's in-flight spawns as our own lets the spawn-timeout
238
+ * reaper destroy agents that peer is still waiting on. Recovery uses
239
+ * `listSpawningAgentsForOwner`.
240
+ */
68
241
  listSpawningAgents(): Promise<SpawningAgentSnapshot[]>;
69
242
  /**
70
- * Delete every spawning-agent row whose `spawned_at` is older than the
71
- * given cutoff. Used by the leader-gated GC sweep so a coord that
72
- * crashed mid-spawn doesn't leave the row blocking the spawn-timeout
73
- * detection forever. Returns the row count GC'd.
243
+ * Claim ownership of a spawning agent that registered on this instance.
244
+ * Conditional on the row being unclaimed **or already claimed by this same
245
+ * instance**, so exactly one instance adopts even if the agent flaps between
246
+ * instances behind a load balancer, while a re-registration on the adopter
247
+ * itself still resolves. Without the second arm a restart is a silent leak:
248
+ * recovery rehydrates the row, the stale-spawn prune drops the in-memory
249
+ * entry but spares the row, and the still-live agent's next registration
250
+ * finds neither — it reads as a static agent, so no `scale-down` is ever
251
+ * emitted and the reaper spares it because the agent is registered and its
252
+ * adopter is alive. Gated on `backend_type = 'event'`: a local-backend
253
+ * agent's compute is pinned to another host, so adopting its bookkeeping
254
+ * would be a lie.
255
+ *
256
+ * The adoption is also recorded in `scaler_provision_outcomes`, in the SAME
257
+ * transaction as the stamp. The spawn row is deleted on teardown, so its
258
+ * `adopted_by` is not a durable answer to "was this provision ever adopted?"
259
+ * — and the stale-spawn prune needs exactly that answer to tell a dead
260
+ * external provision from a healthy one it was not told about. Writing
261
+ * it here rather than at the two call sites is deliberate: this method is the
262
+ * single writer of `adopted_by`, so no adoption path can miss the record.
263
+ * Splitting the two writes would let a crash leave an adopted row whose
264
+ * adoption the prune cannot see, which is the very ambiguity being removed.
265
+ */
266
+ adoptSpawningAgent(agentId: string, instanceId: string): Promise<SpawningAgentSnapshot | null>;
267
+ /**
268
+ * Every spawning-agent row this instance owns. Scopes recovery so an instance
269
+ * rehydrates only its own bookkeeping. A row with a NULL `owner_instance_id`
270
+ * has an unknown owner and is deliberately not returned to anybody.
271
+ */
272
+ listSpawningAgentsForOwner(instanceId: string): Promise<SpawningAgentSnapshot[]>;
273
+ /**
274
+ * Every event row that is either past its spawn deadline without ever being
275
+ * adopted, or adopted by some instance. The reaper decides which to tear down;
276
+ * this only narrows the scan. Deliberately NOT a blanket `spawned_at < cutoff`
277
+ * delete: an adopted event agent legitimately runs for hours.
74
278
  */
75
- sweepStaleSpawningAgents(olderThan: Date): Promise<number>;
279
+ listReapCandidates(spawnCutoff: Date): Promise<ReapCandidate[]>;
76
280
  upsertAgentJob(snapshot: AgentJobCorrelationSnapshot): Promise<void>;
77
281
  deleteAgentJob(agentId: string): Promise<void>;
78
282
  listAgentJobs(): Promise<AgentJobCorrelationSnapshot[]>;
79
283
  upsertReservation(snapshot: ReservationSnapshot): Promise<void>;
80
284
  deleteReservation(agentId: string): Promise<void>;
285
+ /**
286
+ * Every reservation row in the table, whoever holds it. Not for recovery —
287
+ * counting a peer's reservations against our own caps double-books the
288
+ * cluster. Recovery uses `listReservationsForOwner`.
289
+ */
81
290
  listReservations(): Promise<ReservationSnapshot[]>;
291
+ /**
292
+ * Every reservation this instance holds. Scopes recovery so an instance
293
+ * rehydrates only its own usage. A row with a NULL `owner_instance_id` has an
294
+ * unknown owner and is deliberately not returned to anybody.
295
+ */
296
+ listReservationsForOwner(instanceId: string): Promise<ReservationSnapshot[]>;
297
+ registerClaim(row: PendingClaimRow): Promise<void>;
298
+ /**
299
+ * Consume a claim and return its spec, or null when the code is unknown,
300
+ * already consumed, or expired. The UPDATE is the consumption: it commits
301
+ * before any mint is attempted, so two concurrent claims of one code — on the
302
+ * same instance or on different ones — can never both mint.
303
+ */
304
+ redeemClaim(claimHash: string): Promise<RedeemedClaim | null>;
305
+ /**
306
+ * Why a redeem failed. Called ONLY on the failure path, so the happy path
307
+ * stays one round trip.
308
+ */
309
+ describeClaim(claimHash: string): Promise<{
310
+ consumed: boolean;
311
+ expired: boolean;
312
+ } | null>;
313
+ invalidateClaimsForAgent(agentId: string): Promise<void>;
314
+ /**
315
+ * Delete every claim whose `expires_at` passed before `cutoff`. Returns how
316
+ * many rows went.
317
+ *
318
+ * Nothing else removes a claim on a timer: `redeemClaim` only marks
319
+ * `consumed_at`, and `invalidateClaimsForAgent` runs on a teardown path a
320
+ * stranded provision never reaches. So a claim outlives its agent whenever a
321
+ * registration unwinds after the claim was written, or whenever the
322
+ * coordinator that would have torn the agent down crashed first — and the row
323
+ * then sits in the table forever. Purging on `expires_at` covers both,
324
+ * consumed or not, because an expired claim can never be redeemed again.
325
+ *
326
+ * The caller subtracts a retention grace from now, so `describeClaim` can
327
+ * still tell a late redeemer "expired" rather than "unknown code" for a while
328
+ * after the deadline. Backed by `idx_scaler_pending_claims_expires_at`.
329
+ */
330
+ purgeExpiredClaims(cutoff: Date): Promise<number>;
331
+ /**
332
+ * Record the reaper's teardown verdict for one provision.
333
+ *
334
+ * Deliberately additive: it writes only the `condemned_*` columns. A
335
+ * `heartbeat-timeout` condemns a provision that WAS adopted, so clearing the
336
+ * adoption here would put the stale-spawn prune straight back to reporting a
337
+ * healthy provision as a failed one — the exact misattribution this table
338
+ * exists to remove.
339
+ *
340
+ * Called only once the teardown was actually delivered. An emit that reached
341
+ * nobody leaves the spawn row in place for the reaper to retry, so there is
342
+ * no verdict yet to record.
343
+ *
344
+ * The `condemned_*` half is the forensic side of the record and has no
345
+ * production reader: `provisionAdopter` answers from `adopted_by` alone, so
346
+ * the prune's verdict does not depend on it. It is written because the row
347
+ * is the only surviving account of what became of a provision once the spawn
348
+ * row is gone — which is what an operator investigating a torn-down
349
+ * provision, and the E2E that pins this behaviour, read it for.
350
+ */
351
+ recordProvisionCondemned(agentId: string, scalerName: string, reason: string): Promise<void>;
352
+ /**
353
+ * Delete outcome rows that can no longer be asked about; returns rows deleted.
354
+ *
355
+ * Two predicates, and the second is the load-bearing one. The cutoff alone is
356
+ * not safe: recovery rehydrates an in-memory spawning entry from
357
+ * `scaler_spawning_agents`, and a rehydrated entry is immediately stale, so
358
+ * it asks about its provision on the very next prune however old the
359
+ * provision is. While the spawn row exists the outcome must stay. Once the
360
+ * spawn row is gone, nothing can rehydrate an entry for it, and the caller's
361
+ * retention floor covers the one prune window still in flight.
362
+ *
363
+ * Backed by `idx_scaler_provision_outcomes_updated_at`.
364
+ */
365
+ purgeProvisionOutcomes(cutoff: Date): Promise<number>;
82
366
  }
83
367
  /**
84
368
  * Aggregate event surface for "the scaler manager fully replayed its
@@ -4,8 +4,10 @@
4
4
  * Provides the foundational types that all scaler backends, the ScalerManager,
5
5
  * and the configuration layer depend on.
6
6
  */
7
+ import { ImagePullPolicy } from '@kici-dev/shared/container-runtime';
7
8
  import { z } from 'zod';
8
9
  import { ScalerEventType } from '@kici-dev/engine';
10
+ import type { ScaleDownReason } from './scaler-events.js';
9
11
  import type { ResourceRequest, ResourceSpec, ScalerBackendType, ScalerPlatform } from '@kici-dev/engine';
10
12
  export type { ResourceRequest, ResourceSpec } from '@kici-dev/engine';
11
13
  export { ScalerEventType } from '@kici-dev/engine';
@@ -58,11 +60,43 @@ export interface EffectiveLimits {
58
60
  * select the exact container a trigger produced instead of guessing among
59
61
  * concurrent kici-managed containers. Absent for unbound spawns (warm pool).
60
62
  */
63
+ /**
64
+ * A job's container spec with its registry credentials already resolved.
65
+ *
66
+ * The lock carries credential REFERENCES; this is what a runtime can actually
67
+ * pull with. Produced by `resolveContainerSpawn`, consumed by the container
68
+ * sandbox and by both spawning backends.
69
+ */
70
+ export interface ResolvedContainerSpawn {
71
+ image: string;
72
+ authconfig?: {
73
+ username: string;
74
+ password: string;
75
+ serveraddress: string;
76
+ };
77
+ env?: Record<string, string>;
78
+ }
61
79
  export interface SpawnContext {
62
80
  /** Execution job id the spawn is bound to. */
63
81
  boundJobId?: string;
64
82
  /** Execution run id the bound job belongs to. */
65
83
  runId?: string;
84
+ /**
85
+ * The job's own container image plus resolved registry credentials, when the
86
+ * job declared one. Present means "spawn THIS image with the KiCI runtime
87
+ * injected" rather than the pool's fixed agent image.
88
+ */
89
+ container?: ResolvedContainerSpawn;
90
+ /**
91
+ * Plain platform-taint tokens (`windows`, `macos`, `arm64`) for the pool this
92
+ * spawn belongs to, derived by `ScalerManager` from the same resolved
93
+ * platform its taint gate uses. A backend forwards them to
94
+ * `scalerAgentLabels()` so the agent registers carrying the very tokens the
95
+ * gate demands — without them a tainted pool spawns agents no job can be
96
+ * dispatched to. A backend must never derive them itself: the manager is the
97
+ * single source, and computing them twice is the defect this field closes.
98
+ */
99
+ platformTaints?: readonly string[];
66
100
  }
67
101
  /**
68
102
  * Network policy controlling RFC1918 and internet access for agents in this label set.
@@ -82,13 +116,12 @@ export interface NetworkPolicy {
82
116
  * - `Always`: re-pull on every spawn. Set this on a label set that tracks a
83
117
  * moving tag (e.g. `:latest`) or otherwise needs a fresh image each spawn.
84
118
  * - `Never`: never pull; fail if the image is absent.
119
+ *
120
+ * Defined alongside `pullImageIfMissing` in `@kici-dev/shared` — the agent
121
+ * pulls job images through the same helper — and re-exported here so the scaler
122
+ * config schema and every operator-facing value stay exactly where they were.
85
123
  */
86
- export declare const ImagePullPolicy: z.ZodEnum<{
87
- Always: "Always";
88
- IfNotPresent: "IfNotPresent";
89
- Never: "Never";
90
- }>;
91
- export type ImagePullPolicy = z.infer<typeof ImagePullPolicy>;
124
+ export { ImagePullPolicy };
92
125
  /**
93
126
  * Configuration for a single label-set mapping within a scaler backend.
94
127
  * Maps an exact set of labels to the agent provisioning details.
@@ -196,6 +229,22 @@ export type ValidationResult = {
196
229
  valid: false;
197
230
  errors: string[];
198
231
  };
232
+ /**
233
+ * Optional context passed to `ScalerBackend.destroy`. The event backend surfaces
234
+ * `reason` on its scale-down event; local backends ignore it.
235
+ */
236
+ export interface ScalerDestroyContext {
237
+ /** Why the teardown was requested (idle, job-complete, drain, …). */
238
+ reason?: ScaleDownReason;
239
+ /**
240
+ * Where an event backend must deliver the teardown, overriding its live
241
+ * config. Set from the spawn record when the agent was adopted from another
242
+ * coordinator, so a teardown addresses the targets the provision was spawned
243
+ * with even if `provisioningTargets` has been edited since. Ignored by the
244
+ * local backends, which deliver nothing.
245
+ */
246
+ targets?: string[];
247
+ }
199
248
  /**
200
249
  * Common interface for all scaler backends.
201
250
  * Each backend manages a specific pool of agents for specific label sets.
@@ -209,8 +258,11 @@ export interface ScalerBackend {
209
258
  readonly logsSource?: string;
210
259
  /** Label sets this backend can provision */
211
260
  readonly labelSets: LabelSetConfig[];
212
- /** Per-backend maximum agents */
213
- readonly maxAgents: number;
261
+ /**
262
+ * Per-backend maximum agents. Updated by `reload` so a config change to
263
+ * `maxAgents` applies without an orchestrator restart.
264
+ */
265
+ maxAgents: number;
214
266
  /**
215
267
  * Whether this backend spawns its agents on the orchestrator's own host.
216
268
  * True for bare-metal and Firecracker (local processes / local VMs) and for
@@ -248,9 +300,46 @@ export interface ScalerBackend {
248
300
  signal?: AbortSignal): Promise<ManagedAgent>;
249
301
  /**
250
302
  * Destroy a specific managed agent.
251
- * Docker: docker rm -f; Bare-metal: SIGTERM -> SIGKILL
303
+ * Docker: docker rm -f; Bare-metal: SIGTERM -> SIGKILL.
304
+ *
305
+ * @param context - Optional teardown context. The event backend carries
306
+ * `reason` onto its `kici.scaler.scale-down` event so a teardown workflow
307
+ * (and the timeline) can distinguish an idle reap from a job-complete
308
+ * teardown or a spawn timeout. Local backends accept and ignore it.
252
309
  */
253
- destroy(managedId: string): Promise<void>;
310
+ destroy(managedId: string, context?: ScalerDestroyContext): Promise<void>;
311
+ /**
312
+ * Reclaim the HOST-LOCAL compute of a managed agent this backend no longer
313
+ * tracks in memory, from whatever durable host state survived the loss.
314
+ *
315
+ * `destroy` is keyed off the in-memory agent map, so an orchestrator restart
316
+ * makes it a silent no-op while the VM or host process keeps running. This
317
+ * hook is the restart-surviving half: it reads the backend's own on-host
318
+ * artifacts for `managedId` and reclaims them.
319
+ *
320
+ * Two properties every implementation MUST hold:
321
+ *
322
+ * - **Host-local evidence only.** Reclaim nothing unless an artifact for
323
+ * exactly this `managedId` exists on THIS host — that artifact is the proof
324
+ * this backend spawned it. A coordinator must never be able to reach across
325
+ * and reap a peer's compute.
326
+ * - **Caller supplies the orphan verdict.** The hook force-reclaims a *live*
327
+ * instance, which is precisely what the liveness-driven orphan sweeps
328
+ * refuse to do on their own. Only call it where the agent is known to be
329
+ * unowned.
330
+ *
331
+ * Optional: a backend whose compute is not host-local — the event backend's
332
+ * customer cloud instance — cannot implement it at all. An implementation may
333
+ * also cover only part of its own backend, when the rest keeps nothing durable
334
+ * to read. Bare metal is that case: a container-mode agent carries
335
+ * `kici-agent-id` / `kici-scaler-name` labels on the host and is reclaimed,
336
+ * while a plain-process agent records its PID in the in-memory entry alone, so
337
+ * a restart loses it and the hook reports nothing to reclaim.
338
+ *
339
+ * @returns `true` when host-local state for `managedId` was found and
340
+ * reclaimed, `false` when there was nothing here to reclaim.
341
+ */
342
+ reapUnowned?(managedId: string): Promise<boolean>;
254
343
  /**
255
344
  * Get the LogCapture for a managed agent (optional -- container and bare-metal backends
256
345
  * support stdout capture. Firecracker handles log forwarding internally via file tailing
@@ -273,10 +362,23 @@ export interface ScalerBackend {
273
362
  */
274
363
  ensureHostReady?(): Promise<void>;
275
364
  /**
276
- * Reload configuration (called on SIGHUP).
277
- * Returns validation errors if new config is invalid.
365
+ * Reload configuration (called on config reload / SIGHUP).
366
+ *
367
+ * `opts.maxAgents`, when present, replaces the population cap. `opts.entry`
368
+ * is the whole new config entry, for a backend that reads more than its
369
+ * label sets off it. On an invalid result NOTHING is applied.
370
+ */
371
+ reload(labelSets: LabelSetConfig[], opts?: {
372
+ maxAgents?: number;
373
+ entry?: ScalerEntry;
374
+ }): ValidationResult;
375
+ /**
376
+ * The config entry this backend is currently serving, for backends that hold
377
+ * one. Read by the reload rollback so a rejected reload can restore it
378
+ * alongside `labelSets` and `maxAgents`. Undefined for backends that keep no
379
+ * entry (container, bare-metal, Firecracker all read only their label sets).
278
380
  */
279
- reload(labelSets: LabelSetConfig[]): ValidationResult;
381
+ readonly currentEntry?: ScalerEntry;
280
382
  }
281
383
  /**
282
384
  * Parsed and validated scaler configuration from YAML.
@@ -438,6 +540,22 @@ export interface ScalerEntry {
438
540
  * binaries. Default false.
439
541
  */
440
542
  requireSudo?: boolean;
543
+ /**
544
+ * Workflow refs (e.g. `org/infra`) the reserved scale-up / scale-down events
545
+ * are delivered to. The customer's provisioning / teardown workflows
546
+ * subscribe with `kiciEvent()`. Required for a `type: event` scaler.
547
+ */
548
+ provisioningTargets?: string[];
549
+ /**
550
+ * Seconds a pending provisioning claim code stays valid before it expires.
551
+ * @default 300
552
+ */
553
+ claimTtlSeconds?: number;
554
+ /**
555
+ * Seconds the ephemeral agent token minted for a claimed provision stays
556
+ * valid. @default 600
557
+ */
558
+ agentTokenTtlSeconds?: number;
441
559
  }
442
560
  /**
443
561
  * Warm pool configuration for pre-provisioned idle agents.