@kici-dev/orchestrator 0.9.1 → 0.11.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 (101) hide show
  1. package/dist/__test-helpers__/test-db.d.ts +14 -1
  2. package/dist/agent/dispatcher.d.ts +137 -41
  3. package/dist/agent/registry.d.ts +79 -2
  4. package/dist/app.d.ts +2 -0
  5. package/dist/cancel/cancel-held-run.d.ts +68 -0
  6. package/dist/cancel/cancel-route-answer.d.ts +21 -0
  7. package/dist/cancel/cancel-run.d.ts +32 -0
  8. package/dist/cli/api-client.d.ts +23 -1
  9. package/dist/cli/commands/attestations.d.ts +10 -0
  10. package/dist/cli/commands/cold-store-impl.d.ts +3 -3
  11. package/dist/cli/commands/cold-store.d.ts +6 -3
  12. package/dist/cli/commands/context.d.ts +6 -2
  13. package/dist/cli/commands/dashboard-encryption-key.d.ts +18 -0
  14. package/dist/cli/commands/event-dlq.d.ts +2 -2
  15. package/dist/cli/commands/registration.d.ts +8 -4
  16. package/dist/cli/commands/runs.d.ts +7 -0
  17. package/dist/cli/commands/secret.d.ts +1 -0
  18. package/dist/cli/commands/shared/confirm.d.ts +19 -0
  19. package/dist/cli/commands/shared/unbound-context-warning.d.ts +43 -0
  20. package/dist/cli/commands/signing-key.d.ts +22 -1
  21. package/dist/cli.js +1522 -871
  22. package/dist/cluster/cluster-settings-reader.d.ts +1 -1
  23. package/dist/cluster/coordinator.d.ts +2 -0
  24. package/dist/cluster/live-coordinator-peer.d.ts +13 -0
  25. package/dist/config.d.ts +9 -0
  26. package/dist/contexts/context-store.d.ts +3 -2
  27. package/dist/contexts/protection/satisfiability.d.ts +6 -3
  28. package/dist/contexts/variable-store.d.ts +2 -0
  29. package/dist/db/migrations/145_execution_runs_workflow_provenance.d.ts +4 -0
  30. package/dist/db/migrations/146_pending_job_contexts_context_resolution.d.ts +4 -0
  31. package/dist/db/migrations/147_sealed_job_secrets.d.ts +19 -0
  32. package/dist/db/migrations/148_cluster_settings_sealed_secrets_retry_backoff_ms.d.ts +21 -0
  33. package/dist/db/migrations/149_workflow_registrations_dep_cache_key.d.ts +4 -0
  34. package/dist/db/migrations/150_execution_runs_status_epoch.d.ts +18 -0
  35. package/dist/db/migrations/151_workflow_registrations_dep_cache_key_sha.d.ts +4 -0
  36. package/dist/db/repos/signing-keys-repo.d.ts +48 -3
  37. package/dist/db/types.d.ts +81 -1
  38. package/dist/git/clone-token-auth.d.ts +7 -0
  39. package/dist/git/dispatch-git-auth.d.ts +63 -0
  40. package/dist/index.js +26 -0
  41. package/dist/lockfile-validate.d.ts +4 -2
  42. package/dist/metrics/prometheus.d.ts +1 -1
  43. package/dist/oidc/id-token-claims.d.ts +10 -0
  44. package/dist/oidc/oidc-mint-registration.d.ts +2 -0
  45. package/dist/oidc/orchestrator-mint.d.ts +25 -4
  46. package/dist/oidc/reconcile-signing-key.d.ts +31 -6
  47. package/dist/oidc/resolve-signer.d.ts +12 -6
  48. package/dist/pipeline/decorating-secret-resolver.d.ts +5 -3
  49. package/dist/pipeline/dispatch-matched-workflow.d.ts +105 -13
  50. package/dist/pipeline/dispatch-matched-workflow.test-helpers.d.ts +121 -0
  51. package/dist/pipeline/global-dispatch-identity.d.ts +76 -0
  52. package/dist/pipeline/global-dispatch.d.ts +91 -0
  53. package/dist/pipeline/global-eval-round.d.ts +56 -4
  54. package/dist/pipeline/global-round-hold.d.ts +41 -0
  55. package/dist/pipeline/held-context-data.d.ts +143 -0
  56. package/dist/pipeline/held-round-registrations.d.ts +40 -0
  57. package/dist/pipeline/install-gate.d.ts +93 -0
  58. package/dist/pipeline/install-secrets-resolver.d.ts +28 -13
  59. package/dist/pipeline/job-contexts.d.ts +21 -6
  60. package/dist/pipeline/pending-workflow-context.d.ts +15 -3
  61. package/dist/pipeline/process-webhook.d.ts +38 -1
  62. package/dist/pipeline/processor.d.ts +20 -0
  63. package/dist/pipeline/rerun-global.d.ts +28 -0
  64. package/dist/pipeline/rerun-inputs.d.ts +88 -0
  65. package/dist/pipeline/rerun.d.ts +25 -2
  66. package/dist/pipeline/resume-workflow.d.ts +36 -2
  67. package/dist/pipeline/unbound-job-context.d.ts +34 -0
  68. package/dist/pipeline/workflow-lock-at-sha.d.ts +67 -0
  69. package/dist/provenance/backfill-run.d.ts +5 -0
  70. package/dist/providers/github/check-status-poster.d.ts +4 -16
  71. package/dist/queue/job-queue.d.ts +127 -10
  72. package/dist/queue/terminalize-unroutable.d.ts +37 -4
  73. package/dist/registration/registration-index.d.ts +13 -1
  74. package/dist/registration/registration-store.d.ts +10 -1
  75. package/dist/reporting/execution-tracker.d.ts +149 -8
  76. package/dist/reporting/log-chunk-sink.d.ts +1 -1
  77. package/dist/reporting/log-writer.d.ts +14 -0
  78. package/dist/reporting/step-log-path.d.ts +8 -0
  79. package/dist/reporting/step-log-reader.d.ts +21 -3
  80. package/dist/routes/admin-contexts.d.ts +6 -0
  81. package/dist/routes/admin-registrations.d.ts +2 -2
  82. package/dist/routes/admin-signing-keys.d.ts +33 -0
  83. package/dist/scaler/agent-fit.d.ts +159 -0
  84. package/dist/scaler/container-routing.d.ts +16 -16
  85. package/dist/scaler/manager.d.ts +23 -17
  86. package/dist/secrets/audit-logger.d.ts +1 -1
  87. package/dist/secrets/context-secret-resolver.d.ts +1 -1
  88. package/dist/secrets/index.d.ts +1 -1
  89. package/dist/secrets/job-secret-gate.d.ts +25 -3
  90. package/dist/secrets/job-secret-seal.d.ts +36 -0
  91. package/dist/secrets/master-key-rotation.d.ts +7 -3
  92. package/dist/secrets/secret-resolver.d.ts +42 -23
  93. package/dist/server.js +27611 -23219
  94. package/dist/standalone.js +58327 -53119
  95. package/dist/worker/in-memory-job-queue.d.ts +12 -3
  96. package/dist/worker/reroute-dispatch.d.ts +35 -0
  97. package/dist/ws/agent-handler.d.ts +23 -10
  98. package/dist/ws/git-credential-relay.d.ts +16 -3
  99. package/installer-image-digests.json +3 -3
  100. package/package.json +4 -4
  101. package/sbom.spdx.json +53 -53
@@ -2,9 +2,16 @@
2
2
  * Teardown helper for tests that create their own PostgreSQL database.
3
3
  */
4
4
  import type pg from 'pg';
5
+ /** Backends still hold the database after they were terminated and waited on. */
6
+ export declare class TestDbBackendsRemainError extends Error {
7
+ readonly dbName: string;
8
+ readonly remaining: number;
9
+ constructor(dbName: string, remaining: number);
10
+ }
5
11
  /**
6
12
  * Terminate whatever backends still hold `dbName`, after first waiting for
7
- * the connections the test itself closed to finish closing.
13
+ * the connections the test itself closed to finish closing, then wait for the
14
+ * terminated backends to exit.
8
15
  *
9
16
  * `pool.end()` — and Kysely's `destroy()`, which calls it — resolves as soon
10
17
  * as every client has SENT its Terminate message, not when its socket has
@@ -14,8 +21,14 @@ import type pg from 'pg';
14
21
  * test file after every test in it passed. So this polls `pg_stat_activity`
15
22
  * until the database has no backend left (bounded by `timeoutMs`), and
16
23
  * terminates only what is still there — a connection the test never closed.
24
+ *
25
+ * A terminated backend stays in `pg_stat_activity` until it has exited, and
26
+ * `DROP DATABASE` fails while it is listed. So after the terminate this polls
27
+ * again until the count is zero, bounded by `exitTimeoutMs`, and throws
28
+ * {@link TestDbBackendsRemainError} if the bound passes.
17
29
  */
18
30
  export declare function terminateTestDbBackends(admin: pg.Pool, dbName: string, opts?: {
19
31
  timeoutMs?: number;
32
+ exitTimeoutMs?: number;
20
33
  }): Promise<void>;
21
34
  //# sourceMappingURL=test-db.d.ts.map
@@ -2,6 +2,7 @@ import type { AgentRegistry } from './registry.js';
2
2
  import { type JobQueue, type QueuedJob, type QueuedJobInput } from '../queue/job-queue.js';
3
3
  import type { ScaleResult, ScalerRedispatchTrigger } from '../scaler/types.js';
4
4
  import type { ResolvedContainerSpawn } from '../scaler/types.js';
5
+ import { type ScalerAgentView } from '../scaler/agent-fit.js';
5
6
  import type { ResourceRequest } from '@kici-dev/engine';
6
7
  /**
7
8
  * Why a freed agent's queue drain claimed nothing.
@@ -63,6 +64,10 @@ export interface HostRosterRebootStore {
63
64
  * released and onDispatch is NOT called). The reroute caller should treat the
64
65
  * job as already handled. Only reachable on a reroute re-dispatch — first-
65
66
  * dispatch paths use a fresh UUID that can never collide.
67
+ *
68
+ * `queued` also answers a direct dispatch refused because the job's run has
69
+ * stopped: the queue row is recorded `expired` and the job cancelled, the
70
+ * state a cancel leaves a queued job of its run in.
66
71
  */
67
72
  type DispatchResult = {
68
73
  status: 'dispatched';
@@ -89,6 +94,16 @@ export declare function containerSpawnFor(jobConfig: Record<string, unknown> | u
89
94
  serveraddress: string;
90
95
  } | undefined;
91
96
  } | undefined;
97
+ /**
98
+ * What an `onDispatch` callback returns when it refuses to put a job on the
99
+ * wire. The dispatcher fails the job permanently with `refused` as the reason
100
+ * instead of arming an ack deadline for a message that was never sent.
101
+ */
102
+ export interface DispatchRefusal {
103
+ refused: string;
104
+ }
105
+ /** The callback that sends a dispatched job to its agent. */
106
+ export type OnDispatch = (agentId: string, job: QueuedJob) => void | DispatchRefusal | Promise<void | DispatchRefusal>;
92
107
  export declare class Dispatcher {
93
108
  private readonly registry;
94
109
  private readonly queue;
@@ -96,9 +111,7 @@ export declare class Dispatcher {
96
111
  private readonly onDispatch;
97
112
  private readonly onNoMatchingAgent?;
98
113
  /** See the constructor dep of the same name. */
99
- private readonly canPrespawnedAgentServe?;
100
- /** See the constructor dep of the same name. */
101
- private readonly isPrespawnedAgent?;
114
+ private readonly scalerAgentView?;
102
115
  /**
103
116
  * Single-flight guard for `retryPendingScaleRequests`. The capacity-freed
104
117
  * hook and the leader-gated sweep can both fire concurrently; this prevents
@@ -189,11 +202,13 @@ export declare class Dispatcher {
189
202
  /** Coordinator drain predicate; when true, new jobs queue Pending instead of
190
203
  * dispatching. Defaults to never-draining. */
191
204
  private readonly isDraining;
205
+ /** Whether another coordinator is live. Defaults to none (a single node). */
206
+ private readonly hasPeerCoordinators;
192
207
  constructor(deps: {
193
208
  registry: AgentRegistry;
194
209
  queue: JobQueue;
195
210
  metrics: DispatchMetrics;
196
- onDispatch: (agentId: string, job: QueuedJob) => void | Promise<void>;
211
+ onDispatch: OnDispatch;
197
212
  /** Optional hook called when no agent matches the job's labels.
198
213
  * When set, receives the per-job `resources` so the scaler can apply
199
214
  * per-scaler / per-orchestrator / per-machine caps before spawning. */
@@ -205,26 +220,14 @@ export declare class Dispatcher {
205
220
  */
206
221
  containerSpawn?: ResolvedContainerSpawn) => Promise<ScaleResult>;
207
222
  /**
208
- * Optional scaler predicate: may this pre-spawned (warm) agent serve this
209
- * job? A warm agent is generic — started before the job existed, at the
210
- * pool's shape and running the pool's image, both of which are fixed when
211
- * it starts. When the predicate returns false the agent is skipped and the
212
- * job falls through to `onNoMatchingAgent`, which spawns one that fits.
213
- * Absent (no scaler) means every agent is eligible.
214
- */
215
- canPrespawnedAgentServe?: (agentId: string, job: {
216
- resources?: ResourceRequest;
217
- hasOwnContainerImage: boolean;
218
- }) => boolean;
219
- /**
220
- * Whether this scaler pre-spawned the agent, i.e. whether
221
- * `canPrespawnedAgentServe` can ever answer false for it. The queue drain
222
- * (agent asks for work, rather than job looks for an agent) uses it to
223
- * decide whether the suitability predicate is worth carrying into the
224
- * claim: for every ordinary agent it is not, and the drain keeps its
225
- * single-statement fast path.
223
+ * What the scaler knows about an agent it started
224
+ * (`ScalerManager.agentView`), or undefined for one it did not. It feeds
225
+ * `canAgentRunJob`: the job the agent was started for, whether it runs in
226
+ * that job's image, and a warm agent's fixed shape. Absent when no scaler
227
+ * is configured; the predicate then judges every agent on its own
228
+ * registration.
226
229
  */
227
- isPrespawnedAgent?: (agentId: string) => boolean;
230
+ scalerAgentView?: (agentId: string) => ScalerAgentView | undefined;
228
231
  /** Max reconnection delay from agent config (default 60s). Used to derive grace period. */
229
232
  maxReconnectDelayMs?: number;
230
233
  /** Callback fired when a job is permanently failed before/outside agent execution. */
@@ -247,6 +250,13 @@ export declare class Dispatcher {
247
250
  * Pending and onAgentAvailable() claims nothing — jobs already on agents
248
251
  * finish. Defaults to never-draining. */
249
252
  isDraining?: () => boolean;
253
+ /**
254
+ * Whether another coordinator is live: connected, or disconnected for less
255
+ * than the peer flap grace. A job whose sealed secrets this coordinator
256
+ * cannot open is put back for such a peer; with none, no coordinator can
257
+ * open it, so it fails at once. Defaults to none.
258
+ */
259
+ hasPeerCoordinators?: () => boolean | Promise<boolean>;
250
260
  });
251
261
  /**
252
262
  * Dispatch a job to a matching agent, or queue it if none available.
@@ -273,9 +283,20 @@ export declare class Dispatcher {
273
283
  * overlapping hook+sweep cannot double-offer the same jobs. Bounded by
274
284
  * `maxJobs` so one free event cannot storm the scaler.
275
285
  *
286
+ * A job whose sealed secrets this coordinator cannot open is never scaled
287
+ * for: it is settled instead ({@link settleUnopenablePending}).
288
+ *
276
289
  * @returns the number of jobs that entered `spawning`.
277
290
  */
278
291
  retryPendingScaleRequests(maxJobs?: number, trigger?: ScalerRedispatchTrigger): Promise<number>;
292
+ /**
293
+ * Release the slot claimed for a direct dispatch the queue refused because
294
+ * the job's run has stopped. Nothing is sent to the agent. The queue already
295
+ * recorded the row `expired` and cancelled the job, so the job reads as a
296
+ * queued job of the run that the cancel expired, and the caller tracks it
297
+ * under `queued` exactly as it would that one.
298
+ */
299
+ private refuseStoppedRunDispatch;
279
300
  /**
280
301
  * Dispatch a host-fanout pinned child. The job targets exactly
281
302
  * `job.pinnedAgentId`: if that agent is locally connected, satisfies the
@@ -295,11 +316,16 @@ export declare class Dispatcher {
295
316
  * still pending and the agent's labels still satisfy it), marks it
296
317
  * dispatched, and sends it to the agent.
297
318
  *
298
- * Returns true if dispatched, false if the bound job was already claimed
299
- * elsewhere, expired, or the agent isn't registered. The caller falls
300
- * back to the generic onAgentAvailable() drain in either case.
319
+ * Returns true only when the job was sent to the agent. False when nothing
320
+ * was claimed (the bound job was already claimed elsewhere, expired, or the
321
+ * agent isn't registered, is full or the coordinator is draining), and also
322
+ * when the claimed job was not sent: its dispatch was refused and it failed,
323
+ * or this coordinator could not open its sealed secrets. The caller falls
324
+ * back to the generic onAgentAvailable() drain in every false case.
301
325
  */
302
326
  dispatchBoundJob(agentId: string, jobId: string): Promise<boolean>;
327
+ /** {@link dispatchBoundJob}, reporting whether the job was claimed apart from whether it was sent. */
328
+ private claimBoundJob;
303
329
  /**
304
330
  * Try draining the queue for a specific agent.
305
331
  * Called when an agent registers or completes a job.
@@ -314,19 +340,24 @@ export declare class Dispatcher {
314
340
  */
315
341
  private filterRebootPending;
316
342
  /**
317
- * Drop pre-spawned (warm) agents that cannot serve this job. Sibling of
343
+ * Drop the agents that cannot run this job (`canAgentRunJob`). Sibling of
318
344
  * {@link filterRebootPending}: both remove candidates `findAvailable` matched
319
345
  * on labels but that are unusable for a reason labels cannot express.
320
346
  *
321
- * A warm agent's cpu, memory and container image are all set when it starts
322
- * and cannot change afterwards, so a job asking for something else has to get
323
- * an agent of its own. Dropping the candidate here is what sends it down the
324
- * ordinary `onNoMatchingAgent` scale path. No-op when no scaler is wired.
347
+ * The reasons: an agent started inside another job's image, a container job
348
+ * on an agent that reports no container runtime, a warm agent of another
349
+ * shape. Dropping the candidate here is what sends the job down the ordinary
350
+ * `onNoMatchingAgent` scale path.
351
+ *
352
+ * `jobId` is the job's queue id when it has one: it is how the agent started
353
+ * for the job recognizes it.
325
354
  */
326
- private filterUnsuitablePrespawned;
355
+ private filterUnservableAgents;
356
+ /** The registration facts, and the scaler's record, `canAgentRunJob` judges an agent on. */
357
+ private fitAgentFor;
327
358
  /**
328
- * The fit question for one job: the shape it declares, and whether it brings
329
- * its own container image.
359
+ * The fit question for one job: its queue id, the shape it declares, and
360
+ * what its `container:` asks of the agent's host.
330
361
  *
331
362
  * The shape comes from `jobConfig.resources` when the typed `resources`
332
363
  * mirror is absent, because the mirror is optional and several dispatch paths
@@ -336,16 +367,16 @@ export declare class Dispatcher {
336
367
  * declares nothing, which admits it onto a pre-spawned agent of some other
337
368
  * size — the exact mismatch this gate exists to refuse.
338
369
  */
339
- private prespawnedFitFor;
370
+ private agentFitFor;
340
371
  /**
341
- * The queue-drain half of {@link filterUnsuitablePrespawned}: a per-job
372
+ * The queue-drain half of {@link filterUnservableAgents}: a per-job
342
373
  * predicate for one agent, or undefined when this agent needs no check.
343
374
  *
344
- * Undefined is the common answer — an ordinary agent is not pre-spawned, so
345
- * the predicate could only ever say yes, and returning one would cost the
346
- * drain its single-statement fast path for nothing.
375
+ * Undefined is the common answer for an agent that runs any job: the
376
+ * predicate could only ever say yes, and returning one would cost the drain
377
+ * its single-statement fast path for nothing.
347
378
  */
348
- private prespawnedFitFilterFor;
379
+ private agentFitFilterFor;
349
380
  /**
350
381
  * Release a reboot-pending host on its real reconnect (down-then-up). Clears
351
382
  * the persisted flag so the very next `onAgentAvailable` drain dispatches the
@@ -361,8 +392,69 @@ export declare class Dispatcher {
361
392
  * Returns the reason nothing was dispatched, or null when a job was.
362
393
  */
363
394
  private drainForAgent;
395
+ /** {@link deliver} inside the job's own request context, restored for a queue-drained job. */
396
+ private deliverInRequestContext;
397
+ /**
398
+ * Hand a tracked job to `onDispatch`. Returns false when the job was not sent:
399
+ * either the callback refused it, and the job is failed permanently since the
400
+ * refusal is a fact about the job and another agent would refuse it too; or
401
+ * this coordinator cannot open its sealed secrets, and the job is put back so
402
+ * a coordinator holding the key can take it, or failed when none can
403
+ * ({@link releaseUnopenableDispatch}).
404
+ */
405
+ private deliver;
406
+ /**
407
+ * Handle a claimed job whose sealed secrets this coordinator cannot open: it
408
+ * was sealed with a master key this coordinator does not hold.
409
+ *
410
+ * With another coordinator live this is not a permanent refusal: during a
411
+ * rolling master-key rotation that peer may already hold the new key, and if
412
+ * it does, its own drains can take the job. The row returns to pending,
413
+ * spending one dispatch attempt and recording the reason as the job's
414
+ * provisioning error (what an expiry reports), and this coordinator stops
415
+ * offering the job for the sealed-secrets back-off. Once the back-off lapses
416
+ * its drains and periodic re-drives offer the job again like any other, so a
417
+ * job no coordinator can open is claimed again and fails with the reason once
418
+ * {@link MAX_DISPATCH_ATTEMPTS} is reached. With no other coordinator live
419
+ * nobody can open it, so it fails at once.
420
+ *
421
+ * `agentId` is the agent the job was claimed for, or undefined when a re-drive
422
+ * claimed it for no agent ({@link settleUnopenablePending}).
423
+ */
424
+ private releaseUnopenableDispatch;
425
+ /**
426
+ * Settle a pending job whose sealed secrets this coordinator cannot open,
427
+ * found by a re-drive that would otherwise scale for it.
428
+ *
429
+ * Scaling for the job is wrong twice over. The registry credentials of a
430
+ * private image are sealed with the job's other secrets, so a spawn of that
431
+ * image pulls without them and fails before any agent registers: the job is
432
+ * never claimed, never spends an attempt, and every re-drive spawns again.
433
+ * And any agent a spawn does start is one this coordinator would hand the job
434
+ * back from. So the job is claimed by id for no agent and gets the decision a
435
+ * drain that claimed it would give it ({@link releaseUnopenableDispatch}). A
436
+ * claim lost to another coordinator, or refused inside the back-off, leaves
437
+ * the job alone.
438
+ */
439
+ private settleUnopenablePending;
440
+ /**
441
+ * Undo the dispatch accounting of a refused job and fail it with the refusal
442
+ * reason. `agentId` is undefined for a job claimed for no agent.
443
+ */
444
+ private failRefusedDispatch;
445
+ /** Free the slot and tracking a claim took on its agent; a claim for no agent took none. */
446
+ private releaseClaimAccounting;
364
447
  /** Record that a job began executing on its agent. */
365
448
  markJobStarted(jobId: string): void;
449
+ /**
450
+ * The agent accepted `jobId` (a `job.ack`, or the `running` status that stands
451
+ * in for a lost one). Drop a busy hold that already ran out: a job accepted
452
+ * while it sits on the entry was dispatched after it expired, into a free
453
+ * slot, so the agent's job count is not stuck and its next teardown-window
454
+ * rejection must not spend an attempt. A hold still in force is kept — see
455
+ * {@link AgentRegistry.clearExpiredBusyHold}.
456
+ */
457
+ private noteJobAccepted;
366
458
  /**
367
459
  * Arm the ack deadline for a just-sent dispatch (timer + persisted row).
368
460
  *
@@ -400,7 +492,8 @@ export declare class Dispatcher {
400
492
  /**
401
493
  * Requeue a dispatched job for re-delivery, or fail it permanently when
402
494
  * its attempt budget is exhausted. Returns the outcome so disconnect
403
- * triage can surface failed job IDs to the caller.
495
+ * triage can surface failed job IDs to the caller. `countAttempt: false`
496
+ * requeues without spending the budget (a busy rejection).
404
497
  */
405
498
  private requeueOrFail;
406
499
  /**
@@ -443,6 +536,9 @@ export declare class Dispatcher {
443
536
  * It never consults the scaler (that is `retryPendingScaleRequests`) and never
444
537
  * spawns: it only places jobs onto agents already connected here.
445
538
  *
539
+ * Returns the number of jobs sent. A job it claims but does not send (its
540
+ * dispatch refused, or its sealed secrets unopenable here) is not counted.
541
+ *
446
542
  * A failure propagates to the caller rather than being swallowed here — the
447
543
  * per-coord interval wrapper is the single error-log site, matching the
448
544
  * sibling recovery/ack sweeps that share its cadence. The `finally` only
@@ -9,7 +9,28 @@
9
9
  * No persistence needed -- agents re-register on reconnect.
10
10
  */
11
11
  import { type CanonicalLabel, type LabelMatcher } from '@kici-dev/engine';
12
- import type { WsLike } from '@kici-dev/engine';
12
+ import type { AgentCapabilities, WsLike } from '@kici-dev/engine';
13
+ /**
14
+ * The longest a busy rejection holds an agent out of routing when no evidence
15
+ * of a free slot lifts the hold first: an `agent.status` capacity report, or a
16
+ * job on the agent reaching a final status.
17
+ */
18
+ export declare const BUSY_HOLD_MAX_MS = 30000;
19
+ /** What {@link AgentRegistry.markBusyHeld} found on the agent. */
20
+ export declare enum BusyHoldOutcome {
21
+ /** No hold was on the agent: it last reported an empty slot, or never rejected. */
22
+ Held = "held",
23
+ /** A hold that had run out was still on the agent: it never reported an empty slot. */
24
+ ReheldAfterExpiry = "reheld-after-expiry",
25
+ /** The agent is not in the registry. */
26
+ NotRegistered = "not-registered"
27
+ }
28
+ /**
29
+ * Whether an agent can take one more job now: a free slot, and no busy hold
30
+ * in force ({@link AgentRegistry.markBusyHeld}). Draining is checked by the
31
+ * callers that route around it.
32
+ */
33
+ export declare function agentHasFreeSlot(entry: AgentEntry, now?: number): boolean;
13
34
  export type { WsLike } from '@kici-dev/engine';
14
35
  /**
15
36
  * A single tracked agent connection.
@@ -68,6 +89,11 @@ export interface AgentEntry {
68
89
  * WebSocket close that removes the entry — there is nothing to outlive.
69
90
  */
70
91
  draining: boolean;
92
+ /**
93
+ * Epoch ms until which the agent is held out of routing after it rejected a
94
+ * dispatch as busy. Unset when no hold is in force. See {@link AgentRegistry.markBusyHeld}.
95
+ */
96
+ busyHeldUntil?: number;
71
97
  /** Number of jobs currently executing on this agent. */
72
98
  activeJobs: number;
73
99
  /** Maximum concurrent jobs this agent can handle (default 1). */
@@ -82,6 +108,11 @@ export interface AgentEntry {
82
108
  registeredAt: number;
83
109
  /** Self-reported agent version (e.g. "0.0.1"). Null if not reported. */
84
110
  version: string | null;
111
+ /**
112
+ * Optional behaviours the agent advertised on `agent.register`. Null for an
113
+ * agent that advertised none, which is read as supporting none.
114
+ */
115
+ capabilities: AgentCapabilities | null;
85
116
  /**
86
117
  * The `agent_tokens.id` row used to authenticate this connection, or
87
118
  * `null` when auth mode is `none`. Indexed by `tokenIdIndex` so a
@@ -149,6 +180,8 @@ interface AgentMetadata {
149
180
  * any operator-declared properties). Undefined ⇒ the agent reported none.
150
181
  */
151
182
  properties?: Record<string, string | number | boolean>;
183
+ /** Optional behaviours the agent advertised on `agent.register`. */
184
+ capabilities?: AgentCapabilities;
152
185
  }
153
186
  /**
154
187
  * Subset of `HostRosterStore` the registry reconciles into. Optional — when no
@@ -324,7 +357,13 @@ export declare class AgentRegistry {
324
357
  * an off-gate gated agent as "matching but busy" and skip the peer
325
358
  * reroute path even though the local agent can never accept the job.
326
359
  */
327
- hasMatchingAgent(requiredLabels: string[], requiredPatterns?: LabelMatcher[], excludeLabels?: string[], excludePatterns?: LabelMatcher[]): boolean;
360
+ hasMatchingAgent(requiredLabels: string[], requiredPatterns?: LabelMatcher[], excludeLabels?: string[], excludePatterns?: LabelMatcher[],
361
+ /**
362
+ * An extra test a label-matching agent must pass: whether it can run the
363
+ * job at all, which its labels cannot express. An agent that matches but
364
+ * never could is not a reason to keep the job here rather than reroute it.
365
+ */
366
+ canRun?: (entry: AgentEntry) => boolean): boolean;
328
367
  /**
329
368
  * Internal helper: intersect the per-label candidate sets to find agentIds
330
369
  * that satisfy `requiredLabels`. Returns `null` when any required label
@@ -387,6 +426,44 @@ export declare class AgentRegistry {
387
426
  * @returns the agent's active-job count, or `undefined` when it is not registered
388
427
  */
389
428
  markDraining(agentId: string): number | undefined;
429
+ /**
430
+ * Hold an agent out of routing after it rejected a dispatch as busy.
431
+ *
432
+ * A busy rejection means the agent still holds the slot of the job it is
433
+ * tearing down, while the registry already counts that slot free (the
434
+ * job reported completion first). Without the hold, the requeue re-picks the
435
+ * same agent at once and each rejection repeats within milliseconds. The hold
436
+ * lifts on evidence of a free slot ({@link clearBusyHeld}): the agent reports
437
+ * an empty slot on `agent.status`, or a job tracked to it reaches a final
438
+ * status. An expired hold is also dropped when the agent accepts a job
439
+ * ({@link clearExpiredBusyHold}). Otherwise the hold ends after
440
+ * {@link BUSY_HOLD_MAX_MS}, so a lost status report cannot strand the agent.
441
+ *
442
+ * An expired hold keeps its `busyHeldUntil` until that evidence arrives. So a
443
+ * hold still on the entry here means the agent was re-picked because its last
444
+ * hold ran out, and has neither reported a free slot nor accepted or finished
445
+ * a job since — an agent whose job count is stuck. The caller spends an
446
+ * attempt for that, which keeps such an agent from holding a job pending
447
+ * forever.
448
+ */
449
+ markBusyHeld(agentId: string, now?: number): BusyHoldOutcome;
450
+ /**
451
+ * Lift a {@link markBusyHeld} hold: the agent reported an empty slot, or a
452
+ * job tracked to it reached a final status.
453
+ */
454
+ clearBusyHeld(agentId: string): void;
455
+ /**
456
+ * Drop a {@link markBusyHeld} hold that has already run out: the agent
457
+ * accepted a job since.
458
+ *
459
+ * Every dispatch path skips an agent whose hold is in force, so a job the
460
+ * agent accepts while an expired hold is on its entry was dispatched after
461
+ * the hold ran out, into a slot that was free. That refutes the stuck job
462
+ * count the leftover hold stands for. A hold still in force is kept: the
463
+ * accepted job occupies the slot, so the acceptance says nothing about a
464
+ * free slot now.
465
+ */
466
+ clearExpiredBusyHold(agentId: string, now?: number): void;
390
467
  /** Undo {@link markDraining} — a destroy that was rejected leaves the agent alive. */
391
468
  clearDraining(agentId: string): void;
392
469
  get(agentId: string): AgentEntry | undefined;
package/dist/app.d.ts CHANGED
@@ -208,6 +208,8 @@ export interface AppDependencies {
208
208
  issuer: string;
209
209
  resolveSigner: () => Promise<import('./oidc/signer.js').Signer | null>;
210
210
  repo: import('./db/repos/signing-keys-repo.js').OrchestratorSigningKeyRepo;
211
+ /** Whether this node is the Raft leader; named when a mint defers for want of a key. */
212
+ isLeader?: () => boolean;
211
213
  };
212
214
  /**
213
215
  * Dashboard-encryption (X25519) key custody. Present whenever KICI_SECRET_KEY
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Cancel a run that sits `held` behind an approval request.
3
+ *
4
+ * A held run has no job and no dispatch yet: what it has is a pending
5
+ * `held_runs` row, the pending `KiCI Security` check a security hold posted,
6
+ * and the stored dispatch context an approve would replay. Cancelling it has to
7
+ * settle all three, or a later approve still finds its hold pending, replays
8
+ * the stored dispatch, and closes the check as approved for a run that was
9
+ * cancelled. So a cancel withdraws the approval request through the path a
10
+ * reject takes: each pending hold of the run is flipped `rejected` with the
11
+ * cancel reason, then handed to the workflow-hold rejection
12
+ * (`rejectWorkflow`), which cancels the run, completes the checks the held
13
+ * dispatch posted, settles the security check, and drops the stored context.
14
+ */
15
+ import type { Kysely } from 'kysely';
16
+ import type { Database, HeldRun } from '../db/types.js';
17
+ import type { ExecutionTracker } from '../reporting/execution-tracker.js';
18
+ /** How a cancel of a held run's approval requests ended. */
19
+ export declare enum HeldRunWithdrawal {
20
+ /** The cancel rejected at least one pending hold itself. */
21
+ Withdrawn = "withdrawn",
22
+ /** An approve released the run first, so it is resuming. */
23
+ Approved = "approved",
24
+ /** A reject decided the hold first, and that rejection ends the run. */
25
+ Rejected = "rejected",
26
+ /** The hold expired first, and that expiry ends the run. */
27
+ Expired = "expired"
28
+ }
29
+ /** A decision on a held run that reached its holds before a cancel did. */
30
+ export type HeldRunDecidedFirst = Exclude<HeldRunWithdrawal, HeldRunWithdrawal.Withdrawn>;
31
+ /** Options for a workflow-hold rejection reached through a cancel. */
32
+ export interface RejectHeldWorkflowOptions {
33
+ /**
34
+ * False when the run is not `held`: the rejection then settles the checks
35
+ * and drops the stored context but leaves the run row to the job
36
+ * cancellation that follows. Defaults to true.
37
+ */
38
+ runHeld?: boolean;
39
+ }
40
+ /** Options for {@link cancelHeldRunWithReason}. */
41
+ export interface CancelHeldRunOptions {
42
+ /**
43
+ * False when the caller ends the run itself — a cancel of a run that is not
44
+ * `held`, whose jobs are cancelled next. The holds are still rejected and
45
+ * the checks settled, but no held-run terminal write is attempted: it would
46
+ * match no row and only report a missing held run. Defaults to true.
47
+ */
48
+ terminalWrite?: boolean;
49
+ }
50
+ export interface CancelHeldRunDeps {
51
+ db: Kysely<Database>;
52
+ executionTracker: ExecutionTracker;
53
+ /**
54
+ * The workflow-hold rejection (`rejectWorkflow` bound to live processing
55
+ * deps). Undefined where no provider wiring exists: the run is then cancelled
56
+ * and its holds rejected, but no provider check is settled.
57
+ */
58
+ rejectHeldWorkflow?: ((hold: HeldRun, reason: string, opts?: RejectHeldWorkflowOptions) => Promise<boolean>) | undefined;
59
+ }
60
+ /**
61
+ * Withdraw every pending approval request of a held run and cancel the run.
62
+ * Returns {@link HeldRunWithdrawal.Withdrawn} when this call withdrew the run.
63
+ * Otherwise a decision reached every hold first — an approve, a reject or an
64
+ * expiry, which the result names — the call writes nothing, and the caller
65
+ * refuses the cancel.
66
+ */
67
+ export declare function cancelHeldRunWithReason(deps: CancelHeldRunDeps, runId: string, reason: string, opts?: CancelHeldRunOptions): Promise<HeldRunWithdrawal>;
68
+ //# sourceMappingURL=cancel-held-run.d.ts.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The admin cancel route's answer for a cancel that did not find the run
3
+ * already terminal. Kept apart from the route so the mapping is testable
4
+ * without assembling the whole app.
5
+ */
6
+ import { ExecutionRunStatus } from '@kici-dev/engine';
7
+ import { type CancelRunResult } from './cancel-run.js';
8
+ export interface CancelRouteAnswer {
9
+ httpStatus: 200 | 409;
10
+ body: {
11
+ status: ExecutionRunStatus;
12
+ cancelledJobs: number;
13
+ } | {
14
+ error: string;
15
+ status: ExecutionRunStatus;
16
+ };
17
+ /** Note recorded on the access-log row, when the answer is not a plain success. */
18
+ accessNote?: string;
19
+ }
20
+ export declare function cancelRouteAnswer(result: CancelRunResult): CancelRouteAnswer;
21
+ //# sourceMappingURL=cancel-route-answer.d.ts.map
@@ -20,6 +20,8 @@ import type { Database } from '../db/types.js';
20
20
  import type { JobQueue } from '../queue/job-queue.js';
21
21
  import type { AgentRegistry } from '../agent/registry.js';
22
22
  import type { ExecutionTracker } from '../reporting/execution-tracker.js';
23
+ import type { HeldRun } from '../db/types.js';
24
+ import { type HeldRunDecidedFirst, type RejectHeldWorkflowOptions } from './cancel-held-run.js';
23
25
  export interface CancelRunDeps {
24
26
  db: Kysely<Database>;
25
27
  jobQueue: JobQueue;
@@ -38,6 +40,13 @@ export interface CancelRunDeps {
38
40
  cancelJobOnPeer?: (peerId: string, runId: string, jobId: string, reason: string) => boolean;
39
41
  /** How stale a `cluster_instances` heartbeat may be and still read as live. */
40
42
  ownershipGraceMs?: number;
43
+ /**
44
+ * Reject a held run's workflow-scoped hold: `rejectWorkflow` bound to the live
45
+ * processing deps. A cancel of a `held` run goes through it so the approval
46
+ * request is withdrawn with the run. Undefined where no provider wiring
47
+ * exists; the held run is then still cancelled and its holds rejected.
48
+ */
49
+ rejectHeldWorkflow?: (hold: HeldRun, reason: string, opts?: RejectHeldWorkflowOptions) => Promise<boolean>;
41
50
  }
42
51
  export interface CancelRunOptions {
43
52
  /** Force immediate SIGKILL on the agent (skip graceful hooks). */
@@ -64,6 +73,29 @@ export interface CancelRunResult {
64
73
  * no-op: no agent was notified and no row was written.
65
74
  */
66
75
  alreadyTerminal: boolean;
76
+ /**
77
+ * Set when the run was `held` and a decision reached its holds first — an
78
+ * approve, a reject or an expiry — so the cancel wrote nothing. Entry points
79
+ * refuse the cancel with {@link heldRunCancelRefusal}.
80
+ */
81
+ decidedBeforeCancel?: HeldRunDecidedFirst;
82
+ }
83
+ /** Why a cancel of a held run whose hold an approve already released is refused. */
84
+ export declare const RUN_RESUMING_AFTER_APPROVAL_MESSAGE = "Run is resuming after approval; cancel it again once it is running";
85
+ /** Why a cancel of a held run whose hold a reject already decided is refused. */
86
+ export declare const RUN_REJECTED_BEFORE_CANCEL_MESSAGE = "Run was rejected before the cancel reached it; the rejection ends it";
87
+ /** Why a cancel of a held run whose hold already expired is refused. */
88
+ export declare const RUN_EXPIRED_BEFORE_CANCEL_MESSAGE = "Run approval request expired before the cancel reached it; the expiry ends it";
89
+ /** The message a refused cancel of a held run answers with, naming the decision that won. */
90
+ export declare function heldRunCancelRefusal(decision: HeldRunDecidedFirst): string;
91
+ /**
92
+ * Thrown by an entry point that refuses a cancel because a decision reached
93
+ * the held run first. An expected race, not a failure: callers answer it as a
94
+ * refusal rather than logging an error.
95
+ */
96
+ export declare class HeldRunCancelRefusedError extends Error {
97
+ readonly decision: HeldRunDecidedFirst;
98
+ constructor(decision: HeldRunDecidedFirst);
67
99
  }
68
100
  /**
69
101
  * Cancel a whole run, recording `reason` on its cancelled jobs and (if not
@@ -158,6 +158,25 @@ export declare class AdminApiClient {
158
158
  }>;
159
159
  setSecret(orgId: string, scope: string, key: string, value: string): Promise<void>;
160
160
  deleteSecret(orgId: string, scope: string, key: string): Promise<void>;
161
+ /**
162
+ * Create an empty secret scope. A `<backend>:` qualifier selects the
163
+ * backend; an unqualified scope targets the PG backend. Creating a scope
164
+ * that already exists succeeds and changes nothing.
165
+ */
166
+ createScope(orgId: string, scope: string): Promise<{
167
+ created: boolean;
168
+ }>;
169
+ /**
170
+ * Rename a secret scope inside its backend. The route refuses a move between
171
+ * backends and a rename onto a scope that already exists.
172
+ */
173
+ renameScope(orgId: string, oldScope: string, newScope: string): Promise<{
174
+ renamed: boolean;
175
+ }>;
176
+ /** Delete a secret scope and every secret in it. */
177
+ deleteScope(orgId: string, scope: string): Promise<{
178
+ deleted: boolean;
179
+ }>;
161
180
  /**
162
181
  * Create (or upsert) a deployment context. `allowLocalExecution` lets
163
182
  * CLI-initiated / local runs resolve secrets through this context.
@@ -204,6 +223,9 @@ export declare class AdminApiClient {
204
223
  skippedEphemeralKeys: number;
205
224
  reEncryptedSecretOutputs: number;
206
225
  skippedSecretOutputs: number;
226
+ /** Absent from an orchestrator that predates sealed job secrets. */
227
+ reEncryptedJobSecrets?: number;
228
+ skippedJobSecrets?: number;
207
229
  }>;
208
230
  createGenericSource(data: {
209
231
  orgId: string;
@@ -270,7 +292,7 @@ export declare class AdminApiClient {
270
292
  to?: string;
271
293
  limit?: number;
272
294
  offset?: number;
273
- /** Phase D opt-in for cold-store read-through. */
295
+ /** Opt-in cold-store read-through. */
274
296
  includeArchived?: boolean;
275
297
  }): Promise<any[]>;
276
298
  createToken(data: {