@kici-dev/orchestrator 0.1.13 → 0.1.15

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 (65) hide show
  1. package/README.md +13 -1
  2. package/dist/__test-helpers__/mock-db.d.ts +2 -0
  3. package/dist/agent/dispatcher.d.ts +110 -6
  4. package/dist/agent/registry.d.ts +14 -0
  5. package/dist/app.d.ts +11 -0
  6. package/dist/cache/agent-job-failed-error.d.ts +13 -0
  7. package/dist/cache/dispatch-cache-ref-tracker.d.ts +45 -0
  8. package/dist/cache/index.d.ts +2 -0
  9. package/dist/cache/user-cache.d.ts +116 -0
  10. package/dist/cancel/cancel-run.d.ts +56 -0
  11. package/dist/cli/commands/environment.d.ts +1 -0
  12. package/dist/cli/service/index.d.ts +1 -1
  13. package/dist/cli/service/instance/manifest.d.ts +9 -0
  14. package/dist/cli.js +601 -113
  15. package/dist/cluster/peer-registry.d.ts +6 -0
  16. package/dist/config/schema.d.ts +4 -0
  17. package/dist/config.d.ts +13 -0
  18. package/dist/dashboard/handler.d.ts +92 -1
  19. package/dist/db/migrations/025_init_failure.d.ts +16 -0
  20. package/dist/db/migrations/026_event_log_lockfile_corrupt.d.ts +11 -0
  21. package/dist/db/migrations/027_workflow_timeout.d.ts +20 -0
  22. package/dist/db/migrations/028_org_settings_user_cache.d.ts +4 -0
  23. package/dist/db/migrations/029_dispatch_queue_attempts.d.ts +16 -0
  24. package/dist/db/migrations/030_held_runs_env_set_null.d.ts +13 -0
  25. package/dist/db/migrations/031_dispatch_queue_ack_deadline.d.ts +19 -0
  26. package/dist/db/migrations/032_org_settings_dispatch_ack_timeout.d.ts +14 -0
  27. package/dist/db/types.d.ts +52 -2
  28. package/dist/diagnostics/checks/index.d.ts +2 -1
  29. package/dist/diagnostics/checks/scaler.d.ts +13 -0
  30. package/dist/diagnostics/types.d.ts +5 -2
  31. package/dist/environments/environment-store.d.ts +14 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.js +362 -40
  34. package/dist/lockfile-cache.d.ts +1 -1
  35. package/dist/metrics/prometheus.d.ts +8 -0
  36. package/dist/orchestrator-core.d.ts +4 -1
  37. package/dist/pipeline/dispatch-matched-workflow.d.ts +9 -0
  38. package/dist/pipeline/inline-eval.d.ts +17 -2
  39. package/dist/pipeline/process-webhook.d.ts +19 -0
  40. package/dist/pipeline/processor.d.ts +6 -1
  41. package/dist/pipeline/test-pipeline.d.ts +10 -0
  42. package/dist/providers/github/lock-file.d.ts +1 -1
  43. package/dist/providers/internal/lock-file-fetcher.d.ts +3 -2
  44. package/dist/queue/job-queue.d.ts +53 -1
  45. package/dist/reporting/execution-tracker.d.ts +80 -7
  46. package/dist/routes/admin-environments.d.ts +1 -0
  47. package/dist/scaler/bare-metal-backend.d.ts +1 -0
  48. package/dist/scaler/container-backend.d.ts +3 -2
  49. package/dist/scaler/failure-tracker.d.ts +46 -0
  50. package/dist/scaler/firecracker-backend.d.ts +17 -0
  51. package/dist/scaler/manager.d.ts +10 -0
  52. package/dist/scaler/nftables.d.ts +25 -3
  53. package/dist/scaler/types.d.ts +26 -1
  54. package/dist/server.js +4421 -1459
  55. package/dist/stale-detector/workflow-deadline-detector.d.ts +49 -0
  56. package/dist/standalone.js +17003 -14561
  57. package/dist/storage/filesystem.d.ts +12 -3
  58. package/dist/storage/s3.d.ts +17 -4
  59. package/dist/storage/types.d.ts +25 -5
  60. package/dist/worker/in-memory-job-queue.d.ts +40 -7
  61. package/dist/ws/agent-handler.d.ts +13 -0
  62. package/dist/ws/dashboard-env-handler.d.ts +1 -0
  63. package/dist/ws/platform-client.d.ts +10 -1
  64. package/package.json +13 -10
  65. package/sbom.spdx.json +91 -36
@@ -5,11 +5,12 @@
5
5
  * in a sandboxed VM context at dispatch time, eliminating the init-job
6
6
  * round-trip for pure functions.
7
7
  */
8
+ import type { LockJob } from '@kici-dev/engine';
8
9
  /**
9
10
  * Evaluate an inline expression that returns a string.
10
11
  *
11
12
  * @param expression - Serialized arrow function, e.g. '(event) => event.ref.split("/").pop()'
12
- * @param event - Normalized webhook event payload
13
+ * @param event - Normalized event envelope (SimulatedEvent shape; raw provider payload at event.payload)
13
14
  * @returns The string result
14
15
  * @throws TypeError if result is not a string
15
16
  * @throws Error on timeout or sandbox violation
@@ -19,10 +20,24 @@ export declare function evaluateInlineString(expression: string, event: object):
19
20
  * Evaluate an inline expression that returns a Record<string, string>.
20
21
  *
21
22
  * @param expression - Serialized arrow function, e.g. '(event) => ({ NODE_ENV: event.env })'
22
- * @param event - Normalized webhook event payload
23
+ * @param event - Normalized event envelope (SimulatedEvent shape; raw provider payload at event.payload)
23
24
  * @returns The record result
24
25
  * @throws TypeError if result is not a plain object
25
26
  * @throws Error on timeout or sandbox violation
26
27
  */
27
28
  export declare function evaluateInlineRecord(expression: string, event: object): Record<string, string>;
29
+ /**
30
+ * Evaluate a lock job's inline (pure-function) dynamic fields against the
31
+ * normalized event envelope (SimulatedEvent shape: { type, action,
32
+ * targetBranch, sourceBranch, payload, … }). The raw provider webhook body is
33
+ * nested at `event.payload` — the same shape rules and step contexts see.
34
+ *
35
+ * Throws a job-attributed Error when any expression throws; inline evaluation
36
+ * failures are immediate dispatch failures (no init-job fallback).
37
+ */
38
+ export declare function evaluateInlineFields(lockJob: LockJob, event: object): {
39
+ inlineEnvironmentName: string | undefined;
40
+ inlineEnv: Record<string, string> | undefined;
41
+ inlineConcurrencyGroup: string | undefined;
42
+ };
28
43
  //# sourceMappingURL=inline-eval.d.ts.map
@@ -16,8 +16,26 @@
16
16
  * Internal helpers are pure phase functions returning typed results; the only
17
17
  * top-level export is `processWebhook`, callable from server.ts / app.ts.
18
18
  */
19
+ import type { SimulatedEvent } from '@kici-dev/engine';
19
20
  import type { WebhookInfo } from '../webhook/handler.js';
21
+ import type { ProviderBundle } from '../provider-registry.js';
22
+ import type { TrustResolution } from '../security/trust-resolver.js';
20
23
  import { type ProcessingDeps } from './processor.js';
24
+ interface TrustOutcome {
25
+ trustResolution: TrustResolution | undefined;
26
+ /** Default 'base' for PR events; trust resolution may override to 'head'. */
27
+ lockFileSource: 'head' | 'base';
28
+ }
29
+ export declare function resolveTrustForPR(args: {
30
+ info: WebhookInfo;
31
+ deps: ProcessingDeps;
32
+ bundle: ProviderBundle;
33
+ event: SimulatedEvent;
34
+ payload: Record<string, unknown>;
35
+ resolvedOrgId: string;
36
+ repoIdentifier: string;
37
+ credentials: Record<string, unknown>;
38
+ }): Promise<TrustOutcome>;
21
39
  /**
22
40
  * Process a webhook through the complete pipeline.
23
41
  *
@@ -35,4 +53,5 @@ import { type ProcessingDeps } from './processor.js';
35
53
  * 10. Forward Platform trace + record event log
36
54
  */
37
55
  export declare function processWebhook(info: WebhookInfo, deps: ProcessingDeps): Promise<void>;
56
+ export {};
38
57
  //# sourceMappingURL=process-webhook.d.ts.map
@@ -34,6 +34,7 @@ import type { LogStorage } from '../reporting/log-storage.js';
34
34
  import type { SecretResolver } from '../secrets/secret-resolver.js';
35
35
  import type { ContributorCache } from '../security/contributor-cache.js';
36
36
  import type { LockFile as FullLockFile, LockWorkflow, SimulatedEvent, WebhookNormalizer } from '@kici-dev/engine';
37
+ import { LockFileParseError } from '@kici-dev/engine';
37
38
  import type { EventRouter } from '../events/event-router.js';
38
39
  import type { RegistrationStore } from '../registration/registration-store.js';
39
40
  import type { RegistrationIndex } from '../registration/registration-index.js';
@@ -158,7 +159,7 @@ export declare function resolveLockFileWithFallback(args: {
158
159
  deliveryId: string;
159
160
  }): Promise<{
160
161
  lockFile: FullLockFile | null;
161
- resolvedVia: 'inbound' | 'fallback' | 'miss';
162
+ resolvedVia: 'inbound' | 'fallback' | 'miss' | 'corrupt';
162
163
  fallbackRoutingKey?: string;
163
164
  /** The winning provider bundle when resolvedVia='fallback'. Used by the dispatch
164
165
  * site to swap repoUrlBuilder and cloneTokenProvider (Layer 4 cross-provider fix). */
@@ -166,6 +167,10 @@ export declare function resolveLockFileWithFallback(args: {
166
167
  /** The winning registration's providerContext when resolvedVia='fallback'.
167
168
  * Carries installationId etc. for clone token issuance. */
168
169
  fallbackCredentials?: Record<string, unknown>;
170
+ /** Set when resolvedVia='corrupt': the parse error seen while attempting to
171
+ * resolve a lock file. A valid fallback always wins over a corrupt inbound,
172
+ * so this is only surfaced when NOTHING resolved. */
173
+ corruptError?: LockFileParseError;
169
174
  }>;
170
175
  /**
171
176
  * Resolve the customer/org ID for a routing key.
@@ -25,6 +25,8 @@ import type { BuildCoordinator } from '../cache/index.js';
25
25
  import type { DepCache } from '../cache/index.js';
26
26
  import type { PendingBuildTracker } from '../cache/index.js';
27
27
  import type { SecretResolver } from '../secrets/secret-resolver.js';
28
+ import type { EnvironmentStore } from '../environments/environment-store.js';
29
+ import type { VariableStore } from '../environments/variable-store.js';
28
30
  import type { LogStorage } from '../reporting/log-storage.js';
29
31
  import type { Kysely } from 'kysely';
30
32
  import type { Database } from '../db/types.js';
@@ -49,6 +51,10 @@ export interface TestTriggerInput {
49
51
  uploadId?: string;
50
52
  /** Fixture secret context mappings (optional). */
51
53
  secrets?: Record<string, string>;
54
+ /** Base64 X25519+AES-GCM blob of the developer's local secrets ({flat, contexts}). */
55
+ encryptedSecrets?: string;
56
+ /** Base64 ephemeral CLI public key that encrypted `encryptedSecrets`. */
57
+ encryptedSecretsKey?: string;
52
58
  /** Direct workflow run -- bypass triggers (optional). */
53
59
  workflowName?: string;
54
60
  /** Resolved overlay metadata for tarball download + decryption. */
@@ -97,6 +103,10 @@ export interface TestPipelineDeps {
97
103
  logStorage?: LogStorage;
98
104
  /** Database connection for environment protection checks. Optional. */
99
105
  db?: Kysely<Database>;
106
+ /** Environment store for resolving environment ids in test dispatch parity. Optional. */
107
+ environmentStore?: EnvironmentStore;
108
+ /** Variable store for resolving environment variables in test dispatch parity. Optional. */
109
+ variableStore?: VariableStore;
100
110
  }
101
111
  /**
102
112
  * Process a test trigger through the existing pipeline.
@@ -7,7 +7,7 @@
7
7
  * NOTE: This is the raw fetcher without caching. The orchestrator wraps
8
8
  * this with an LRU cache (LockFileCache) for production use.
9
9
  */
10
- import type { LockFileFetcher, LockFile } from '@kici-dev/engine';
10
+ import { type LockFileFetcher, type LockFile } from '@kici-dev/engine';
11
11
  import { type GitHubAppConfig } from './auth.js';
12
12
  /**
13
13
  * GitHub-specific implementation of LockFileFetcher.
@@ -25,10 +25,11 @@ export declare class InternalLockFileFetcher implements LockFileFetcher {
25
25
  * Fetch the lock file from the local filesystem.
26
26
  *
27
27
  * @param repoIdentifier - Either a file:// URL or a relative path under repoBasePath
28
- * @param _ref - Git ref (ignored for filesystem access -- always reads current state)
28
+ * @param ref - Git ref (ignored for filesystem access -- always reads current state)
29
29
  * @param _credentials - Not used (file:// access needs no auth)
30
30
  * @returns Parsed LockFile, or null if not found
31
+ * @throws LockFileParseError when the file is present but unparseable/invalid
31
32
  */
32
- fetchLockFile(repoIdentifier: string, _ref: string, _credentials: unknown): Promise<LockFile | null>;
33
+ fetchLockFile(repoIdentifier: string, ref: string, _credentials: unknown): Promise<LockFile | null>;
33
34
  }
34
35
  //# sourceMappingURL=lock-file-fetcher.d.ts.map
@@ -43,6 +43,13 @@ export declare enum DispatchQueueStatus {
43
43
  Expired = "expired",
44
44
  Recovering = "recovering"
45
45
  }
46
+ /**
47
+ * Maximum delivery attempts for a single dispatch_queue job. A job whose
48
+ * `dispatch_attempts` reaches this value is failed permanently instead of
49
+ * being requeued again. Bounds requeue loops from repeated job.reject /
50
+ * pre-start agent loss; `expires_at` is the time-based backstop.
51
+ */
52
+ export declare const MAX_DISPATCH_ATTEMPTS = 5;
46
53
  /**
47
54
  * Input for enqueuing a job. Callers provide these fields;
48
55
  * the queue generates id, status, created_at, and expires_at.
@@ -307,6 +314,34 @@ export declare class JobQueue {
307
314
  * the leader-gated sweep (`sweepExpiredRecoveries()`).
308
315
  */
309
316
  markRecovering(jobId: string, deadline?: Date, agentId?: string): Promise<void>;
317
+ /**
318
+ * Stamp the dispatch-acknowledgment deadline for a dispatched job.
319
+ * Only touches rows still in 'dispatched' for safety.
320
+ */
321
+ setAckDeadline(jobId: string, deadline: Date, agentId: string): Promise<void>;
322
+ /** Clear the ack deadline (agent answered, or the job left 'dispatched'). */
323
+ clearAckDeadline(jobId: string): Promise<void>;
324
+ /**
325
+ * List dispatched rows still awaiting an ack (non-null deadline). Used at
326
+ * coord boot (`Dispatcher.recoverState()`) to re-arm in-memory timers.
327
+ */
328
+ getDispatchedAwaitingAck(): Promise<Array<{
329
+ id: string;
330
+ runId: string;
331
+ agentId: string | null;
332
+ deadline: Date;
333
+ }>>;
334
+ /**
335
+ * List every dispatched row whose ack deadline is in the past. The caller
336
+ * (leader-gated `Dispatcher.sweepExpiredAckDeadlines`) requeues each via
337
+ * the atomic `requeue()` (WHERE status='dispatched'), so racing coords
338
+ * cannot double-requeue.
339
+ */
340
+ listExpiredAckDeadlines(now: Date): Promise<Array<{
341
+ id: string;
342
+ runId: string;
343
+ agentId: string | null;
344
+ }>>;
310
345
  /**
311
346
  * List every job currently in `recovering` state with its persisted
312
347
  * recovery deadline. Used at coord boot (`Dispatcher.recoverState()`)
@@ -325,7 +360,7 @@ export declare class JobQueue {
325
360
  /**
326
361
  * Sweep every `recovering` row whose `recovery_deadline` is in the
327
362
  * past, marking them `failed`. Returns the rows that flipped so the
328
- * caller can fire the per-job `onRecoveryTimeout` hook in process.
363
+ * caller can fire the per-job `onJobFailedPermanently` hook in process.
329
364
  *
330
365
  * Intended for the leader-gated `Dispatcher.sweepExpiredRecoveries`
331
366
  * tick — running on N coords would still be correct (the WHERE
@@ -343,6 +378,23 @@ export declare class JobQueue {
343
378
  * @returns true if the update affected a row (job was still recovering).
344
379
  */
345
380
  markFailedIfRecovering(jobId: string, _reason: string): Promise<boolean>;
381
+ /**
382
+ * Return a dispatched job to the pending queue for re-dispatch, bumping
383
+ * its attempt counter. Used when an agent explicitly rejects a dispatch
384
+ * (job.reject) and when a scaler-managed agent disconnects before the
385
+ * job started. Only flips rows still in 'dispatched' — a job that was
386
+ * concurrently completed / failed / cancelled is left untouched.
387
+ *
388
+ * @returns the post-increment dispatch_attempts, or null when the row
389
+ * was not in 'dispatched' state (nothing requeued).
390
+ */
391
+ requeue(jobId: string): Promise<number | null>;
392
+ /**
393
+ * Get the full QueuedJob row by ID regardless of status. Used by the
394
+ * dispatcher's redispatch path, which needs runsOnLabels / excludeLabels /
395
+ * resources to pick an agent or consult the scaler for a requeued job.
396
+ */
397
+ getFullJobById(jobId: string): Promise<QueuedJob | null>;
346
398
  /**
347
399
  * Mark a job as dispatched only if it is still in 'recovering' state.
348
400
  * Used when an agent reconnects and claims a recovering job.
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { type Kysely } from 'kysely';
15
15
  import type { Database } from '../db/types.js';
16
- import { ExecutionRunStatus, ScalerEventType } from '@kici-dev/engine';
16
+ import { ExecutionRunStatus, type InitFailure, ScalerEventType } from '@kici-dev/engine';
17
17
  import type { ObserverRegistry } from '../ws/observer-registry.js';
18
18
  import type { LogStorage } from './log-storage.js';
19
19
  import type { JobQueue } from '../queue/job-queue.js';
@@ -92,7 +92,13 @@ export interface ExecutionTrackerDeps {
92
92
  * Set on terminal run states only. Powers the operator-side
93
93
  * `kici_org_log_bytes` capacity-planning gauge on the Platform.
94
94
  */
95
- logBytes?: number) => void;
95
+ logBytes?: number,
96
+ /**
97
+ * Structured init-failure signal. Set when the run never executed a step
98
+ * because of an init-phase failure. Forwarded to Platform's execution.status
99
+ * forward and persisted in execution_runs.init_failure on both sides.
100
+ */
101
+ initFailure?: InitFailure) => void;
96
102
  /** Optional callback to forward job status changes to Platform.
97
103
  * Fires on every job state transition (pending->running, running->success, etc.).
98
104
  * Used to populate Platform's execution_jobs projection table. */
@@ -101,7 +107,12 @@ export interface ExecutionTrackerDeps {
101
107
  * Total raw log bytes accumulated across the job (sum of per-step totals).
102
108
  * Set on terminal job states only.
103
109
  */
104
- logBytes?: number) => void;
110
+ logBytes?: number,
111
+ /**
112
+ * Structured init-failure signal. Set for synthetic rejected-* / init-failed-*
113
+ * jobs that never started. Persisted in execution_jobs.init_failure.
114
+ */
115
+ initFailure?: InitFailure) => void;
105
116
  /**
106
117
  * Optional callback to emit run.event messages to Platform.
107
118
  * Fires at orchestrator lifecycle points (dispatch, agent assignment, job start/complete).
@@ -142,6 +153,22 @@ export declare class ExecutionTracker {
142
153
  private readonly orgId?;
143
154
  private readonly jobQueue?;
144
155
  private readonly runs;
156
+ /**
157
+ * Per-run async-mutex chain. `onJobStatus` and `addJobsToRun` mutate the same
158
+ * `run.jobs` Map / `execution_jobs` row; without serialization a job-status
159
+ * reply that lands mid synthetic→real swap (`addJobsToRun`) clobbers the swap
160
+ * and the run hangs in `running` forever (see `withRunLock`). The map value is
161
+ * the tail of the chain for that runId; entries are GC'd when the last holder
162
+ * releases.
163
+ */
164
+ private readonly runLockTails;
165
+ /**
166
+ * Tracks which runIds the current async context already holds in
167
+ * `withRunLock`, so reentrant calls (e.g. onJobStatus → scheduler hook →
168
+ * dispatchReadyJob → addJobsToRun, all the same runId) bypass re-acquisition
169
+ * instead of deadlocking. Propagates across awaits via AsyncLocalStorage.
170
+ */
171
+ private readonly heldRunLocks;
145
172
  /** Tracks which runs are test runs for observer broadcasting. */
146
173
  private readonly testRunIds;
147
174
  /**
@@ -192,7 +219,9 @@ export declare class ExecutionTracker {
192
219
  concurrency?: {
193
220
  cancelInProgress?: boolean;
194
221
  max?: number;
195
- }): Promise<void>;
222
+ },
223
+ /** Workflow-level wall-clock timeout in ms from the lock file. Sets the run deadline. */
224
+ workflowTimeoutMs?: number): Promise<void>;
196
225
  /**
197
226
  * Add additional jobs to an already-started execution run.
198
227
  * Used when build jobs are tracked early and regular jobs are dispatched later.
@@ -208,6 +237,22 @@ export declare class ExecutionTracker {
208
237
  * which peer owns the downstream dispatch.
209
238
  */
210
239
  findSyntheticJobId(runId: string, jobName: string): Promise<string | undefined>;
240
+ /**
241
+ * Run `fn` while holding a per-run lock, serializing the run-mutating methods
242
+ * (`onJobStatus`, `addJobsToRun`) so a status reply cannot interleave with the
243
+ * synthetic→real job swap and wedge the run in `running`.
244
+ *
245
+ * The lock is **reentrant**: three paths re-enter a locked method within the
246
+ * same async context for the same runId — onJobStatus → scheduler hook →
247
+ * dispatchReadyJob → addJobsToRun; onJobStatus → enforceSchedulerInvariant →
248
+ * onJobStatus; runSchedulerHook → onJobStatus (skip). A non-reentrant mutex
249
+ * would deadlock on these, so a context that already holds the runId's lock
250
+ * (tracked via `heldRunLocks`) runs `fn` inline. A genuinely concurrent caller
251
+ * for the same runId (a separate WS message) is a different async context and
252
+ * correctly waits. All reentrant paths are same-runId, so there is no
253
+ * cross-run lock-ordering deadlock.
254
+ */
255
+ private withRunLock;
211
256
  addJobsToRun(runId: string, jobs: Array<{
212
257
  jobId: string;
213
258
  jobName: string;
@@ -216,6 +261,7 @@ export declare class ExecutionTracker {
216
261
  }>, dispatchedContexts?: string[],
217
262
  /** Synthetic job ID to replace (e.g. needs-pending-deploy-{uuid}). */
218
263
  replaceSyntheticId?: string): Promise<void>;
264
+ private addJobsToRunImpl;
219
265
  /**
220
266
  * Mark a run as a test run for observer broadcasting.
221
267
  * Called by the test pipeline after creating the execution run.
@@ -233,6 +279,7 @@ export declare class ExecutionTracker {
233
279
  * fires the onExecutionComplete callback.
234
280
  */
235
281
  onJobStatus(runId: string, jobId: string, state: string, timestamp: number, agentId?: string, data?: Record<string, unknown>): Promise<void>;
282
+ private onJobStatusImpl;
236
283
  /**
237
284
  * Phase 1a: recover run state from the DB when in-memory tracking is empty.
238
285
  * Returns the rehydrated RunState or null if the run is unknown to the DB
@@ -313,7 +360,7 @@ export declare class ExecutionTracker {
313
360
  * onExecutionStarted but the build subsequently fails. Without this,
314
361
  * the execution_runs row would stay in a non-terminal state.
315
362
  */
316
- onBuildFailed(runId: string): Promise<void>;
363
+ onBuildFailed(runId: string, initFailure?: InitFailure): Promise<void>;
317
364
  /**
318
365
  * Create a failed execution run when the build timed out before onExecutionStarted
319
366
  * had a chance to insert the row (buildJobTrackedEarly was false).
@@ -321,7 +368,33 @@ export declare class ExecutionTracker {
321
368
  * Inserts a minimal execution_runs row with status='failed' directly so the E2E
322
369
  * test (and dashboard) can observe the failure instead of a missing run.
323
370
  */
324
- onBuildFailedBeforeTracking(runId: string, workflowName: string, provider: string, repoIdentifier: string, ref: string, sha: string, deliveryId: string | null, providerContext: Record<string, unknown>, routingKey: string, triggerEvent?: string, commitMessage?: string, failureReason?: string): Promise<void>;
371
+ onBuildFailedBeforeTracking(runId: string, workflowName: string, provider: string, repoIdentifier: string, ref: string, sha: string, deliveryId: string | null, providerContext: Record<string, unknown>, routingKey: string, triggerEvent?: string, commitMessage?: string, failureReason?: string, initFailure?: InitFailure): Promise<void>;
372
+ /**
373
+ * Insert a `failed` execution_runs row directly for an init failure that
374
+ * occurred BEFORE onExecutionStarted ran (so no in-memory state exists
375
+ * and no jobs were dispatched). Also writes the structured init_failure
376
+ * signal and fires onExecutionStatusChange so Platform's projection picks
377
+ * it up via the normal forward path. Idempotent: if a row already exists
378
+ * for this runId, the insert is a no-op (ON CONFLICT DO NOTHING).
379
+ *
380
+ * Closes the silent pre-run-failure gap — without this helper, secret /
381
+ * install-secret / all-jobs-rejected early-exits in dispatch-matched-workflow
382
+ * leave no trace on the dashboard.
383
+ */
384
+ recordInitFailureRun(args: {
385
+ runId: string;
386
+ workflowName: string;
387
+ provider: string;
388
+ repoIdentifier: string;
389
+ ref: string;
390
+ sha: string;
391
+ deliveryId: string | null;
392
+ providerContext: Record<string, unknown>;
393
+ routingKey: string;
394
+ initFailure: InitFailure;
395
+ triggerEvent?: string;
396
+ commitMessage?: string;
397
+ }): Promise<void>;
325
398
  /**
326
399
  * Mark a run as failed immediately with a reason message.
327
400
  *
@@ -330,7 +403,7 @@ export declare class ExecutionTracker {
330
403
  * Instead of leaving the run in 'running' for OrphanRecovery to catch after 5 min,
331
404
  * this fails it right away.
332
405
  */
333
- failRun(runId: string, reason: string): Promise<void>;
406
+ failRun(runId: string, reason: string, initFailure?: InitFailure): Promise<void>;
334
407
  /**
335
408
  * Update step status within a job.
336
409
  *
@@ -6,6 +6,7 @@
6
6
  * PATCH /api/v1/admin/environments/:name/policy — update policy fields
7
7
  * GET /api/v1/admin/environments?orgId=<id> — list environments
8
8
  * GET /api/v1/admin/environments/:name?orgId=<id> — show env + vars + bindings
9
+ * DELETE /api/v1/admin/environments/:name?orgId=<id> — delete env (cascades bindings/variables/overrides; pending held runs block with 409)
9
10
  * POST /api/v1/admin/environments/templates — create/update a template
10
11
  * GET /api/v1/admin/environments/:name/variables?orgId=<id> — list org-level variables
11
12
  * PUT /api/v1/admin/environments/:name/variables/:key?orgId=<id> — upsert variable
@@ -39,6 +39,7 @@ export interface BareMetalScalerBackendOptions {
39
39
  }
40
40
  export declare class BareMetalScalerBackend implements ScalerBackend {
41
41
  readonly type: "bare-metal";
42
+ readonly spawnsOnLocalHost = true;
42
43
  readonly logsSource = "bare-metal";
43
44
  readonly maxAgents: number;
44
45
  private _labelSets;
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { type ToolRequirement } from '@kici-dev/shared';
10
10
  import type { AgentTokenStore } from '../agent/token-store.js';
11
- import type { ScalerBackend, ManagedAgent, LabelSetConfig, LogCapture, ResourceRequest, EffectiveLimits, ScalerEventCallback, ValidationResult, ScalerEntry } from './types.js';
11
+ import type { ScalerBackend, ManagedAgent, LabelSetConfig, LogCapture, ResourceRequest, EffectiveLimits, SpawnContext, ScalerEventCallback, ValidationResult, ScalerEntry } from './types.js';
12
12
  /**
13
13
  * Result of runtime detection.
14
14
  */
@@ -49,6 +49,7 @@ export interface ContainerScalerBackendOptions {
49
49
  }
50
50
  export declare class ContainerScalerBackend implements ScalerBackend {
51
51
  readonly type: "container";
52
+ readonly spawnsOnLocalHost: boolean;
52
53
  readonly maxAgents: number;
53
54
  private _labelSets;
54
55
  private readonly name;
@@ -112,7 +113,7 @@ export declare class ContainerScalerBackend implements ScalerBackend {
112
113
  get logsSource(): string;
113
114
  get labelSets(): LabelSetConfig[];
114
115
  getActiveCount(): number;
115
- spawn(labelSet: string[], agentId: string, orchestratorUrl: string, onEvent?: ScalerEventCallback, effectiveLimits?: EffectiveLimits): Promise<ManagedAgent>;
116
+ spawn(labelSet: string[], agentId: string, orchestratorUrl: string, onEvent?: ScalerEventCallback, effectiveLimits?: EffectiveLimits, spawnContext?: SpawnContext): Promise<ManagedAgent>;
116
117
  getScalerContext(agentId: string): Record<string, unknown> | undefined;
117
118
  destroy(managedId: string): Promise<void>;
118
119
  /**
@@ -0,0 +1,46 @@
1
+ /**
2
+ * In-process bounded record of recent scaler spawn failures.
3
+ *
4
+ * The scaler manager records every `scaler.failed` event here at the same site
5
+ * it increments the fleet-wide Prometheus counter. The diagnose scaler check
6
+ * reads recent failures grouped per backend instance to produce its rows. This
7
+ * is memory-only and bounded — the recent-failures window resets on restart,
8
+ * which is acceptable for an on-demand operator view over a short window.
9
+ */
10
+ /** A single recorded scaler spawn failure. */
11
+ export interface ScalerFailureRecord {
12
+ /** Scaler instance name (the configured scaler `name`); the diagnose row key. */
13
+ backendName: string;
14
+ /** Backend type: 'container' | 'bare-metal' | 'firecracker' | 'unknown'. */
15
+ backendType: string;
16
+ /** True when the failed spawn was bound to a queued job (a run was affected). */
17
+ bound: boolean;
18
+ /** Captured error string from the scaler event detail. */
19
+ detail: string;
20
+ /** Event timestamp in epoch milliseconds. */
21
+ timestampMs: number;
22
+ }
23
+ /** Per-backend summary of recent failures within a window. */
24
+ export interface BackendFailureSummary {
25
+ backendType: string;
26
+ boundCount: number;
27
+ unboundCount: number;
28
+ /** Detail of the most recent failure in the window. */
29
+ lastError: string;
30
+ /** Timestamp of the most recent failure in the window. */
31
+ lastAtMs: number;
32
+ }
33
+ export declare class ScalerFailureTracker {
34
+ private readonly records;
35
+ private readonly maxEntries;
36
+ constructor(maxEntries?: number);
37
+ /** Record a failure, evicting the oldest entry when over capacity. */
38
+ record(rec: ScalerFailureRecord): void;
39
+ /**
40
+ * Group failures newer than `nowMs - windowMs` by backend instance name.
41
+ * `nowMs` is injected so callers control the clock (and tests are
42
+ * deterministic).
43
+ */
44
+ recentByBackend(windowMs: number, nowMs: number): Map<string, BackendFailureSummary>;
45
+ }
46
+ //# sourceMappingURL=failure-tracker.d.ts.map
@@ -82,6 +82,7 @@ export interface FirecrackerScalerBackendOptions {
82
82
  }
83
83
  export declare class FirecrackerScalerBackend implements ScalerBackend {
84
84
  readonly type: "firecracker";
85
+ readonly spawnsOnLocalHost = true;
85
86
  readonly maxAgents: number;
86
87
  readonly logsSource = "firecracker-serial";
87
88
  /** AbortControllers for file tailing per managed VM (keyed by agent ID) */
@@ -211,6 +212,22 @@ export declare class FirecrackerScalerBackend implements ScalerBackend {
211
212
  * Promisified execFile wrapper with 30s timeout.
212
213
  */
213
214
  private execAsync;
215
+ /**
216
+ * Remove a VM's jailer chroot directory.
217
+ *
218
+ * On rootless nodes (requireSudo), the jailer chowns the chroot contents to
219
+ * the jailer uid/gid, leaving the inner directories owned by that uid with
220
+ * mode 0755 — so the orchestrator process (a different, non-root uid) has no
221
+ * write permission on them and a plain `rm` fails with EACCES. destroy() and
222
+ * both cleanupOrphans passes treat removal as best-effort and swallow that
223
+ * failure, so each spawn would otherwise leak a multi-GiB chroot until the
224
+ * data disk fills and the orchestrator crash-loops on ENOSPC (it can no
225
+ * longer write its own files to start, so the in-process orphan sweep never
226
+ * runs to recover). Reclaim ownership via the same sudo-wrapped `chown` path
227
+ * used for `ip` before the `rm` runs as ourselves. On root nodes (requireSudo
228
+ * false) the chown is skipped and `rm` works directly.
229
+ */
230
+ private removeChrootDir;
214
231
  /**
215
232
  * Get the API socket path for an agent's Firecracker VM.
216
233
  */
@@ -7,6 +7,7 @@
7
7
  * and manages the agent lifecycle from spawn to destroy.
8
8
  */
9
9
  import type { ResourceRequest } from '@kici-dev/engine';
10
+ import type { BackendFailureSummary } from './failure-tracker.js';
10
11
  import type { ScalerBackend, ScalerConfig, ScaleResult, ScalerEvent, ResourceCap, ValidationResult } from './types.js';
11
12
  import type { ScalerStateStore, ScalerStateRecovery } from './scaler-state-store.js';
12
13
  /**
@@ -58,6 +59,8 @@ export interface ScalerStatus {
58
59
  type: string;
59
60
  activeCount: number;
60
61
  maxAgents: number;
62
+ /** Whether this backend spawns its agents on the orchestrator's own host. */
63
+ spawnsOnLocalHost: boolean;
61
64
  /** Label sets this backend can provision (each entry is a string[] of labels) */
62
65
  labelSets: string[][];
63
66
  /** Sum of `requests` reserved by this scaler's active agents. */
@@ -80,6 +83,8 @@ export interface ScalerStatus {
80
83
  export declare class ScalerManager {
81
84
  private readonly backends;
82
85
  private readonly backendRoles;
86
+ /** Recent scaler spawn failures, surfaced by `kici-admin diagnose`. */
87
+ private readonly failureTracker;
83
88
  private globalMaxAgents;
84
89
  /** Per-scaler resource caps (`{ maxCpu, maxMemoryBytes }`), keyed by scaler name. */
85
90
  private readonly resourceCaps;
@@ -320,6 +325,11 @@ export declare class ScalerManager {
320
325
  /**
321
326
  * Return status summary for metrics and health endpoints.
322
327
  */
328
+ /**
329
+ * Recent scaler spawn failures grouped per backend instance, for the
330
+ * diagnose scaler check. `nowMs` is injected by the caller.
331
+ */
332
+ recentSpawnFailures(windowMs: number, nowMs: number): Map<string, BackendFailureSummary>;
323
333
  getStatus(): ScalerStatus;
324
334
  /**
325
335
  * Get the backend name managing a specific agent.
@@ -41,13 +41,35 @@ export declare function validateNftablesAvailability(opts?: NftOptions): Promise
41
41
  * Idempotent -- safe to call multiple times.
42
42
  */
43
43
  export declare function ensureKiciTable(opts?: NftOptions): Promise<void>;
44
+ /** A single nft rule operation: the verb decides chain placement. */
45
+ export interface NftRuleOp {
46
+ /** 'insert' prepends to the chain head; 'add' appends to the tail. */
47
+ verb: 'insert' | 'add';
48
+ /** Tokens after `nft <verb> rule ip kici forward`. */
49
+ tokens: string[];
50
+ }
51
+ /**
52
+ * Build the per-identifier isolation rule operations.
53
+ *
54
+ * Placement matters because nftables is first-match-wins and the `kici`
55
+ * forward chain is shared: it accumulates rules for every live agent plus
56
+ * any host-level baseline (e.g. wildcard `iifname "kici-*"` RFC1918 drops
57
+ * installed at host bootstrap). Accepts therefore go in via `insert`
58
+ * (chain head) so they beat any pre-existing drop that also matches the
59
+ * traffic; an appended accept after a wildcard drop is dead code. Drops go
60
+ * in via `add` (tail) — they only need to beat the chain's accept policy.
61
+ *
62
+ * Effective per-identifier order: gateway + allowlist accepts (head),
63
+ * RFC1918 / metadata / denyAll drops (tail).
64
+ */
65
+ export declare function buildIsolationRuleOps(matchClause: string[], gatewayIp: string, networkPolicy?: NetworkPolicy): NftRuleOp[];
44
66
  /**
45
67
  * Add network isolation rules for a specific network interface.
46
68
  * Used by Firecracker and container backends.
47
69
  *
48
- * Rule order (top to bottom):
49
- * 1. Accept gateway traffic (MUST be first -- inserted, not appended)
50
- * 2. Accept allowlisted CIDRs (if any)
70
+ * Rule placement (see buildIsolationRuleOps):
71
+ * 1. Accept gateway traffic (inserted at chain head)
72
+ * 2. Accept allowlisted CIDRs (inserted at chain head)
51
73
  * 3. Drop RFC1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
52
74
  * 4. Drop cloud metadata (169.254.0.0/16)
53
75
  * 5. Drop all remaining traffic (only if denyAll is true)
@@ -50,6 +50,20 @@ export interface EffectiveLimits {
50
50
  cpus?: number;
51
51
  memBytes?: number;
52
52
  }
53
+ /**
54
+ * Identity of the work a spawn was provisioned for, passed to
55
+ * `ScalerBackend.spawn()`. Backends surface it on the provisioned resource
56
+ * (container labels today) so an operator inspecting the backend — e.g.
57
+ * `podman ps` — can tell which job/run each agent serves, and so tests can
58
+ * select the exact container a trigger produced instead of guessing among
59
+ * concurrent kici-managed containers. Absent for unbound spawns (warm pool).
60
+ */
61
+ export interface SpawnContext {
62
+ /** Execution job id the spawn is bound to. */
63
+ boundJobId?: string;
64
+ /** Execution run id the bound job belongs to. */
65
+ runId?: string;
66
+ }
53
67
  /**
54
68
  * Network policy controlling RFC1918 and internet access for agents in this label set.
55
69
  */
@@ -165,6 +179,14 @@ export interface ScalerBackend {
165
179
  readonly labelSets: LabelSetConfig[];
166
180
  /** Per-backend maximum agents */
167
181
  readonly maxAgents: number;
182
+ /**
183
+ * Whether this backend spawns its agents on the orchestrator's own host.
184
+ * True for bare-metal and Firecracker (local processes / local VMs) and for
185
+ * container backends using a local runtime socket; false when the backend
186
+ * provisions elsewhere (remote container runtime, future cloud backends).
187
+ * Drives the static spawning-host display on the diagnostics page.
188
+ */
189
+ readonly spawnsOnLocalHost: boolean;
168
190
  /** Current count of agents managed by this backend (including spawning) */
169
191
  getActiveCount(): number;
170
192
  /**
@@ -177,10 +199,13 @@ export interface ScalerBackend {
177
199
  * for this spawn. Computed by ScalerManager from the job/label-set/scaler
178
200
  * default chain. When omitted (or both fields zero), the backend falls
179
201
  * back to its label-set / default limits the same way it always has.
202
+ * @param spawnContext - Identity of the bound job/run this spawn serves
203
+ * (omitted for unbound spawns, e.g. warm pool). Backends surface it on
204
+ * the provisioned resource for operator inspection.
180
205
  * @returns The managed agent tracking object
181
206
  * @throws If the label set is not supported by this backend
182
207
  */
183
- spawn(labelSet: string[], agentId: string, orchestratorUrl: string, onEvent?: ScalerEventCallback, effectiveLimits?: EffectiveLimits): Promise<ManagedAgent>;
208
+ spawn(labelSet: string[], agentId: string, orchestratorUrl: string, onEvent?: ScalerEventCallback, effectiveLimits?: EffectiveLimits, spawnContext?: SpawnContext): Promise<ManagedAgent>;
184
209
  /**
185
210
  * Destroy a specific managed agent.
186
211
  * Docker: docker rm -f; Bare-metal: SIGTERM -> SIGKILL