@kici-dev/orchestrator 0.2.0 → 0.4.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.
@@ -1,7 +1,7 @@
1
1
  import type { Kysely } from 'kysely';
2
2
  import type { Database } from '../db/types.js';
3
3
  /** Numeric columns on cluster_settings readable via {@link ClusterSettingsReader}. */
4
- export type ClusterNumberColumn = 'max_github_payload_bytes' | 'event_log_max_payload_bytes' | 'lock_file_max_bytes' | 'webhook_dedup_ttl_ms' | 'contributor_cache_ttl_ms' | 'event_router_event_ttl_seconds' | 'event_router_max_dispatch_attempts' | 'queue_max_depth' | 'reroute_flap_grace_ms' | 'max_fanout_hosts' | 'event_router_rate_limit_per_workflow_per_minute' | 'cache_max_tarball_bytes' | 'cache_ttl_days' | 'check_run_tracking_ttl_days' | 'concurrency_wait_timeout_ms' | 'agent_token_ttl_ms' | 'ownership_db_check_timeout_ms';
4
+ export type ClusterNumberColumn = 'max_github_payload_bytes' | 'event_log_max_payload_bytes' | 'lock_file_max_bytes' | 'webhook_dedup_ttl_ms' | 'contributor_cache_ttl_ms' | 'event_router_event_ttl_seconds' | 'event_router_max_dispatch_attempts' | 'queue_max_depth' | 'reroute_flap_grace_ms' | 'max_fanout_hosts' | 'event_router_rate_limit_per_workflow_per_minute' | 'cache_max_tarball_bytes' | 'cache_ttl_days' | 'check_run_tracking_ttl_days' | 'concurrency_wait_timeout_ms' | 'agent_token_ttl_ms' | 'ownership_db_check_timeout_ms' | 'unroutable_grace_ms';
5
5
  /** Text columns on cluster_settings readable via {@link ClusterSettingsReader}. */
6
6
  export type ClusterStringColumn = 'dashboard_verified_issuer';
7
7
  /**
@@ -164,6 +164,7 @@ export declare const appConfigSchema: z.ZodObject<{
164
164
  rosterTtlMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
165
165
  queueMaxDepth: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
166
166
  queueTimeoutMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
167
+ unroutableGraceMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
167
168
  queueBackpressureThreshold: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
168
169
  lockfileCacheMax: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
169
170
  lockfileCacheTtlMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
@@ -148,6 +148,8 @@ export interface AppConfig {
148
148
  /** Queue settings */
149
149
  queueMaxDepth: number;
150
150
  queueTimeoutMs: number;
151
+ /** Grace before a continuously-unroutable job is failed; 0 = disabled. */
152
+ unroutableGraceMs: number;
151
153
  /**
152
154
  * Pending-depth threshold for operator backpressure warnings. When the
153
155
  * pending dispatch_queue depth stays at or above this for two consecutive
package/dist/config.d.ts CHANGED
@@ -58,6 +58,7 @@ declare const configSchema: z.ZodObject<{
58
58
  lockfileCacheMaxBytes: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
59
59
  queueMaxDepth: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
60
60
  queueTimeoutMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
61
+ unroutableGraceMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
61
62
  queueBackpressureThreshold: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
62
63
  workerConcurrency: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
63
64
  concurrencyWaitTimeoutMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
@@ -251,6 +252,7 @@ export declare const packagingConfigSchema: z.ZodObject<{
251
252
  lockfileCacheMaxBytes: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
252
253
  queueMaxDepth: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
253
254
  queueTimeoutMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
255
+ unroutableGraceMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
254
256
  queueBackpressureThreshold: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
255
257
  workerConcurrency: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
256
258
  concurrencyWaitTimeoutMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
@@ -477,6 +479,7 @@ export declare const envDef: import("@kici-dev/shared/env").DefineEnvResult<{
477
479
  lockfileCacheMaxBytes: number;
478
480
  queueMaxDepth: number;
479
481
  queueTimeoutMs: number;
482
+ unroutableGraceMs: number;
480
483
  queueBackpressureThreshold: number;
481
484
  workerConcurrency: number;
482
485
  concurrencyWaitTimeoutMs: number;
@@ -0,0 +1,4 @@
1
+ import { type Kysely } from 'kysely';
2
+ export declare function up(db: Kysely<unknown>): Promise<void>;
3
+ export declare function down(db: Kysely<unknown>): Promise<void>;
4
+ //# sourceMappingURL=108_unroutable_fast_fail.d.ts.map
@@ -274,6 +274,13 @@ export interface DispatchQueueTable {
274
274
  * real provisioning cause; cleared on dispatch. NULL when none recorded.
275
275
  */
276
276
  last_provisioning_error: ColumnType<string | null, string | null | undefined, string | null>;
277
+ /**
278
+ * When this job first read unroutable — no registered agent and no scaler
279
+ * backend matched its selectors. NULL once it reads routable again, so the
280
+ * grace clock only ever measures a CONTINUOUS unroutable window. Persisted
281
+ * rather than in-memory so a restart mid-grace resumes the same clock.
282
+ */
283
+ unroutable_since: ColumnType<Date | null, Date | string | null | undefined, Date | string | null>;
277
284
  /** Times this job was returned to pending for re-dispatch (job.reject / pre-start agent loss). */
278
285
  dispatch_attempts: Generated<number>;
279
286
  /**
@@ -518,6 +525,13 @@ export interface ExecutionJobTable {
518
525
  job_name: string;
519
526
  /** Job status: pending | running | success | failed | cancelled | skipped */
520
527
  status: Generated<string>;
528
+ /**
529
+ * Operator-facing reason this job cannot currently be routed to any agent.
530
+ * Written by the unroutable probe while the job is still queued, so the cause
531
+ * is visible long before the job terminalizes; cleared when a matching agent
532
+ * or scaler backend appears. NULL whenever the job is routable.
533
+ */
534
+ routing_reason: ColumnType<string | null, string | null | undefined, string | null>;
521
535
  /** Matrix values JSON (e.g. {"node": "18"}) */
522
536
  matrix_values: string | null;
523
537
  /** Agent ID that ran this job */
@@ -1702,6 +1716,12 @@ export interface ClusterSettingsTable {
1702
1716
  * NULL ⇒ the orchestrator's configured default.
1703
1717
  */
1704
1718
  check_run_tracking_ttl_days: ColumnType<number | null, number | null | undefined, number | null>;
1719
+ /**
1720
+ * Grace window (ms) a job may stay continuously unroutable before it is
1721
+ * terminalized as `unroutable`. NULL = the orchestrator's configured default;
1722
+ * 0 disables fast-fail, leaving `queue_timeout_ms` as the only backstop.
1723
+ */
1724
+ unroutable_grace_ms: ColumnType<number | null, number | null | undefined, number | null>;
1705
1725
  concurrency_wait_timeout_ms: ColumnType<string | null, number | null | undefined, number | null>;
1706
1726
  agent_token_ttl_ms: ColumnType<string | null, number | null | undefined, number | null>;
1707
1727
  /**
package/dist/index.js CHANGED
@@ -2031,6 +2031,46 @@ function createTrustPolicyStoreFromUrl(databaseUrl, opts) {
2031
2031
  * All methods are thin wrappers around fetch() that handle JSON serialization,
2032
2032
  * error formatting, and URL construction.
2033
2033
  */
2034
+ /**
2035
+ * `fetch` rejects with a bare `TypeError: fetch failed` when it cannot reach the
2036
+ * host — it names neither the address dialled nor the knob that sets it. Printed
2037
+ * through the CLI's `Error: ${message}` handler that becomes `Error: fetch
2038
+ * failed`, which reads like a fault in the subcommand rather than a misaddressed
2039
+ * client, and sends the operator debugging the wrong thing.
2040
+ *
2041
+ * The trap is sharpened by two details of this CLI. The base URL comes from
2042
+ * `KICI_ADMIN_URL`, not the `KICI_ORCHESTRATOR_URL` an operator is likelier to
2043
+ * have exported; and subcommands accepting `--database-url` fall back to direct
2044
+ * DB access, so on a host with `KICI_DATABASE_URL` set they keep working while
2045
+ * the HTTP-only ones fail — making it look like specific subcommands are broken.
2046
+ *
2047
+ * So: name the address, name the variable, and name the near-miss.
2048
+ */
2049
+ /**
2050
+ * Best-effort one-line detail from a rejected `fetch`. Returns '' when nothing
2051
+ * useful is available, so the caller can omit the parenthetical rather than
2052
+ * print an empty one.
2053
+ */
2054
+ function firstCauseMessage(err) {
2055
+ if (!(err instanceof Error)) return "";
2056
+ const cause = err.cause;
2057
+ if (!(cause instanceof Error)) return "";
2058
+ if (cause.message) return cause.message;
2059
+ const nested = cause.errors;
2060
+ if (Array.isArray(nested)) {
2061
+ for (const e of nested) if (e instanceof Error && e.message) return e.message;
2062
+ }
2063
+ return "";
2064
+ }
2065
+ async function fetchAdminApi(url, init, baseUrl) {
2066
+ try {
2067
+ return await fetch(url, init);
2068
+ } catch (err) {
2069
+ const detail = firstCauseMessage(err);
2070
+ const cause = detail ? ` (${detail})` : "";
2071
+ throw new Error(`cannot reach the orchestrator admin API at ${baseUrl}${cause}. Set KICI_ADMIN_URL to the orchestrator's HTTP address, or pass --base-url where the subcommand accepts it. Note KICI_ORCHESTRATOR_URL is NOT read by this CLI; if other subcommands appear to work, they are using the --database-url / KICI_DATABASE_URL direct-DB path rather than HTTP.`, { cause: err });
2072
+ }
2073
+ }
2034
2074
  var AdminApiClient = class {
2035
2075
  baseUrl;
2036
2076
  token;
@@ -2042,16 +2082,14 @@ var AdminApiClient = class {
2042
2082
  * Make an authenticated HTTP request to the admin API.
2043
2083
  */
2044
2084
  async request(method, path, body) {
2045
- const url = `${this.baseUrl}${path}`;
2046
- const headers = {
2047
- Authorization: `Bearer ${this.token}`,
2048
- "Content-Type": "application/json"
2049
- };
2050
- const res = await fetch(url, {
2085
+ const res = await fetchAdminApi(`${this.baseUrl}${path}`, {
2051
2086
  method,
2052
- headers,
2087
+ headers: {
2088
+ Authorization: `Bearer ${this.token}`,
2089
+ "Content-Type": "application/json"
2090
+ },
2053
2091
  body: body !== void 0 ? JSON.stringify(body) : void 0
2054
- });
2092
+ }, this.baseUrl);
2055
2093
  if (!res.ok) {
2056
2094
  const text = await res.text();
2057
2095
  let errorBody;
@@ -2099,11 +2137,10 @@ var AdminApiClient = class {
2099
2137
  * Public GET request returning raw response text.
2100
2138
  */
2101
2139
  async getText(path) {
2102
- const url = `${this.baseUrl}${path}`;
2103
- const res = await fetch(url, {
2140
+ const res = await fetchAdminApi(`${this.baseUrl}${path}`, {
2104
2141
  method: "GET",
2105
2142
  headers: { Authorization: `Bearer ${this.token}` }
2106
- });
2143
+ }, this.baseUrl);
2107
2144
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
2108
2145
  return res.text();
2109
2146
  }
@@ -2116,14 +2153,14 @@ var AdminApiClient = class {
2116
2153
  * response is an octet-stream, so it is read as bytes rather than parsed JSON.
2117
2154
  */
2118
2155
  async downloadFleetBundle(body, outPath) {
2119
- const res = await fetch(`${this.baseUrl}/admin/fleet-bundle`, {
2156
+ const res = await fetchAdminApi(`${this.baseUrl}/admin/fleet-bundle`, {
2120
2157
  method: "POST",
2121
2158
  headers: {
2122
2159
  Authorization: `Bearer ${this.token}`,
2123
2160
  "Content-Type": "application/json"
2124
2161
  },
2125
2162
  body: JSON.stringify(body)
2126
- });
2163
+ }, this.baseUrl);
2127
2164
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
2128
2165
  const buf = Buffer.from(await res.arrayBuffer());
2129
2166
  fs.writeFileSync(outPath, buf);
@@ -404,6 +404,7 @@ export declare const installSecretsTokenResolutionDurationSeconds: import("@open
404
404
  * tick's `pruneTerminalDispatchRows`).
405
405
  */
406
406
  export declare const dispatchQueueRowsPrunedTotal: import("@opentelemetry/api").Counter<import("@opentelemetry/api").Attributes>;
407
+ export declare const unroutableFastFailedTotal: import("@opentelemetry/api").Counter<import("@opentelemetry/api").Attributes>;
407
408
  /**
408
409
  * Step-log objects deleted by the retention sweep (the cleanup tick's
409
410
  * `pruneExpiredLogs`, S3 backend only).
@@ -29,6 +29,11 @@ export interface OrchestratorScheduledJobRegistrations {
29
29
  intervalMs: number;
30
30
  handler: () => Promise<void>;
31
31
  };
32
+ /** Short-tick probe that fast-fails jobs nothing in the fleet can route. */
33
+ unroutableProbe?: {
34
+ intervalMs: number;
35
+ handler: () => Promise<void>;
36
+ };
32
37
  orphanSecretCleanup?: {
33
38
  intervalMs: number;
34
39
  handler: () => Promise<void>;
@@ -1,5 +1,4 @@
1
- import { type Kysely } from 'kysely';
2
- import { type LabelMatcher } from '@kici-dev/engine';
1
+ import type { Kysely } from 'kysely';
3
2
  import type { Database } from '../db/types.js';
4
3
  import type { ExecutionTracker } from '../reporting/execution-tracker.js';
5
4
  import type { CheckRunReporter } from '../reporting/check-run-reporter.js';
@@ -7,6 +6,7 @@ import type { CheckRunTrackingStore } from '../reporting/check-run-tracking-stor
7
6
  import type { ClusterSettingsReader } from '../cluster/cluster-settings-reader.js';
8
7
  import type { LogStorage } from '../reporting/log-storage.js';
9
8
  import { JobQueue } from './job-queue.js';
9
+ import { type CanRouteLabels } from './terminalize-unroutable.js';
10
10
  /**
11
11
  * Optional cleanup dependencies + retention knobs. Present in platform/hybrid
12
12
  * mode (the only wiring site, `orchestrator-core`, always supplies them); the
@@ -55,16 +55,18 @@ export interface CleanupExtras {
55
55
  * registered agent yet still routes perfectly well, so asking only the agent
56
56
  * registry would report every failed spawn as a bad `runsOn`.
57
57
  *
58
- * Asked at expiry rather than at dispatch so a label satisfied moments later
59
- * by an autoscaled agent is not mislabelled unroutable. Absent every expiry
60
- * stays `timed_out_stale`, the behaviour before the split existed.
58
+ * Asked both on the unroutable probe's short tick (where a grace window
59
+ * absorbs a label satisfied moments later by an autoscaled agent) and again
60
+ * at expiry, which stays the backstop when fast-fail is disabled. Absent →
61
+ * every expiry stays `timed_out_stale`, the behaviour before the split
62
+ * existed.
61
63
  *
62
64
  * The predicate is allowed to answer "routable" conservatively (the scaler
63
65
  * half matches exact labels only, so a pattern-only `runsOn` reads routable on
64
66
  * a scaler-configured orchestrator). That costs precision on the status, never
65
67
  * safety: the job is terminal either way and the run fails either way.
66
68
  */
67
- canRouteLabels?: (requiredLabels: string[], requiredPatterns: LabelMatcher[], excludeLabels: string[], excludePatterns: LabelMatcher[]) => boolean;
69
+ canRouteLabels?: CanRouteLabels;
68
70
  }
69
71
  /**
70
72
  * Run a single cleanup pass: remove expired dedup_cache entries and
@@ -21,6 +21,14 @@ export interface ExpiredJobInfo {
21
21
  excludeLabels: string[];
22
22
  excludePatterns: LabelMatcher[];
23
23
  }
24
+ /**
25
+ * A pending job as the unroutable probe sees it: the same routing facts the
26
+ * expiry sweep reads, plus the persisted grace clock.
27
+ */
28
+ export interface UnroutableCandidate extends ExpiredJobInfo {
29
+ /** When this job first read unroutable; null while it reads routable. */
30
+ unroutableSince: Date | null;
31
+ }
24
32
  /**
25
33
  * Point-in-time breakdown of dispatch_queue depth used for Prometheus gauges
26
34
  * and operator-facing depth warnings.
@@ -580,6 +588,45 @@ export declare class JobQueue {
580
588
  * the re-drive re-runs the normal scale path, which reserves capacity itself.
581
589
  */
582
590
  listPending(limit: number): Promise<QueuedJob[]>;
591
+ /**
592
+ * Pending, non-expired jobs with the facts the unroutable probe needs:
593
+ * routing selectors, any recorded provisioning error, and the grace clock.
594
+ *
595
+ * Read-only — no claim, no `FOR UPDATE`; the probe never dispatches. Reuses
596
+ * the shared pending query so it inherits the same FIFO ordering and
597
+ * not-yet-expired filter the rest of the queue uses.
598
+ */
599
+ listUnroutableCandidates(limit: number): Promise<UnroutableCandidate[]>;
600
+ /**
601
+ * Stamp the grace clock the first time a job reads unroutable.
602
+ *
603
+ * The `unroutable_since IS NULL` guard is load-bearing, not defensive: the
604
+ * cleanup/probe ticks are NOT leader-gated, so without it two coordinators
605
+ * would each re-stamp `now` on every tick, pushing the deadline outward
606
+ * forever and preventing the grace from ever elapsing.
607
+ */
608
+ markUnroutableSince(id: string, at: Date): Promise<void>;
609
+ /** Clear the grace clock after the job reads routable again. */
610
+ clearUnroutableState(id: string): Promise<void>;
611
+ /**
612
+ * Claim a still-pending row for fast-fail, moving it out of the queue.
613
+ *
614
+ * Mirrors {@link markExpired}: the queue row has to leave `Pending` in the
615
+ * same breath the job is terminalized, and for the same two reasons.
616
+ * A row left pending is still dispatchable, so an agent connecting later
617
+ * would pick up a job whose `execution_jobs` row already reads terminal; and
618
+ * the probe re-lists it on every tick, re-running the whole terminalize path
619
+ * (and re-counting the fast-fail metric) until the queue timeout finally
620
+ * expires it.
621
+ *
622
+ * The `status = Pending` guard is also the concurrency arbiter — probe ticks
623
+ * are NOT leader-gated, so exactly one coordinator's UPDATE hits a row and
624
+ * the losers get `false` and move on.
625
+ *
626
+ * @returns true when this call claimed the row, false when it was already
627
+ * dispatched, cancelled, expired, or claimed by another coordinator.
628
+ */
629
+ claimUnroutable(id: string): Promise<boolean>;
583
630
  /**
584
631
  * Convert a DB row to a QueuedJob object.
585
632
  * Handles both auto-parsed JSONB arrays (from pg driver) and JSON strings (from tests).
@@ -29,6 +29,7 @@ export declare const OrchestratorScheduledJobName: z.ZodEnum<{
29
29
  "cold-store-purge": "cold-store-purge";
30
30
  "orphan-secret-cleanup": "orphan-secret-cleanup";
31
31
  "token-cleanup": "token-cleanup";
32
+ "unroutable-probe": "unroutable-probe";
32
33
  }>;
33
34
  export type OrchestratorScheduledJobName = z.infer<typeof OrchestratorScheduledJobName>;
34
35
  /** Access-log action for a manually triggered off-cadence tick. */
@@ -0,0 +1,53 @@
1
+ import { type Kysely } from 'kysely';
2
+ import { ExecutionJobStatus, type LabelMatcher } from '@kici-dev/engine';
3
+ import type { Database } from '../db/types.js';
4
+ import type { ExecutionTracker } from '../reporting/execution-tracker.js';
5
+ import type { CheckRunReporter } from '../reporting/check-run-reporter.js';
6
+ import type { ExpiredJobInfo } from './job-queue.js';
7
+ /**
8
+ * Whether ANYTHING could ever run a job with these selectors — a registered
9
+ * agent (regardless of capacity) or a scaler backend able to spawn one.
10
+ *
11
+ * The predicate is allowed to answer "routable" conservatively: the scaler half
12
+ * matches exact labels only, so a pattern-only `runsOn` reads routable on a
13
+ * scaler-configured orchestrator. That costs precision on the status and on
14
+ * how quickly the job settles, never safety — a job that reads routable simply
15
+ * falls through to the queue-timeout backstop.
16
+ */
17
+ export type CanRouteLabels = (requiredLabels: string[], requiredPatterns: LabelMatcher[], excludeLabels: string[], excludePatterns: LabelMatcher[]) => boolean;
18
+ /** The routing facts a verdict is computed from. */
19
+ export type JobRoutingFacts = Pick<ExpiredJobInfo, 'lastProvisioningError' | 'runsOnLabels' | 'runsOnPatterns' | 'excludeLabels' | 'excludePatterns'>;
20
+ /** Everything {@link terminalizeUnroutableJob} needs to settle a job. */
21
+ export interface TerminalizeDeps {
22
+ db: Kysely<Database>;
23
+ executionTracker: ExecutionTracker;
24
+ checkRunReporter?: Pick<CheckRunReporter, 'updateJobStatus'>;
25
+ canRouteLabels?: CanRouteLabels;
26
+ }
27
+ export declare function unroutableMessage(job: JobRoutingFacts): string;
28
+ /**
29
+ * Split the two reasons a queued job never ran: nothing in the fleet matched
30
+ * its `runsOn` (`unroutable` — a label/fleet problem an operator has to fix)
31
+ * versus something matched but never produced a usable agent
32
+ * (`timed_out_stale` — a capacity or provisioning problem).
33
+ *
34
+ * Shared by the unroutable probe (which asks on a short tick, gated by a grace
35
+ * window) and the queue-expiry sweep (which asks once at expiry, as the
36
+ * backstop). One verdict, two moments in time — a second copy of this logic is
37
+ * exactly the drift this module exists to prevent.
38
+ */
39
+ export declare function classifyUnroutable(job: JobRoutingFacts, canRouteLabels?: CanRouteLabels): {
40
+ status: ExecutionJobStatus;
41
+ errorMessage: string;
42
+ unroutable: boolean;
43
+ };
44
+ /**
45
+ * Settle one never-dispatched job: write the terminal status locally, surface
46
+ * the reason at run level, forward to Platform, and resolve its check run.
47
+ *
48
+ * @returns the run id when this call actually terminalized the job (so the
49
+ * caller can complete the run), or null when another coordinator got there
50
+ * first or the job was no longer pending.
51
+ */
52
+ export declare function terminalizeUnroutableJob(deps: TerminalizeDeps, job: ExpiredJobInfo): Promise<string | null>;
53
+ //# sourceMappingURL=terminalize-unroutable.d.ts.map
@@ -0,0 +1,52 @@
1
+ import { type Kysely } from 'kysely';
2
+ import type { Database } from '../db/types.js';
3
+ import type { JobQueue, UnroutableCandidate } from './job-queue.js';
4
+ import { type CanRouteLabels } from './terminalize-unroutable.js';
5
+ /**
6
+ * The shipped default grace, mirrored from `config.ts`. Used only to derive a
7
+ * sane tick cadence when the env default is 0 (fast-fail disabled at startup
8
+ * but re-enableable live via the `unroutable_grace_ms` cluster setting).
9
+ */
10
+ export declare const DEFAULT_UNROUTABLE_GRACE_MS = 120000;
11
+ /**
12
+ * Probe cadence, derived from the grace rather than configured separately.
13
+ *
14
+ * The tick is an implementation detail of the grace window — it only has to be
15
+ * fine-grained enough that the grace elapses on time — not independent policy,
16
+ * so it deliberately does not earn its own cluster knob.
17
+ */
18
+ export declare function probeTickIntervalMs(graceMs: number): number;
19
+ /** Writes the operator-facing routing reason onto a still-queued job. */
20
+ export type SetRoutingReason = (runId: string, jobName: string, reason: string | null) => Promise<void>;
21
+ export interface UnroutableProbeDeps {
22
+ queue: Pick<JobQueue, 'listUnroutableCandidates' | 'markUnroutableSince' | 'clearUnroutableState' | 'claimUnroutable'>;
23
+ /** Live read, so an operator changing the knob takes effect without a restart. */
24
+ getGraceMs: () => Promise<number>;
25
+ canRouteLabels: CanRouteLabels;
26
+ setRoutingReason: SetRoutingReason;
27
+ terminalize: (job: UnroutableCandidate) => Promise<void>;
28
+ onFastFailed?: () => void;
29
+ /** Max rows examined per tick. */
30
+ batchLimit?: number;
31
+ }
32
+ /**
33
+ * Write the routing reason onto a job that is still waiting to be routed.
34
+ *
35
+ * The status guard matters: the reason is a statement about a job that has not
36
+ * been picked up, so it must never annotate one that already started or
37
+ * finished between the probe's read and this write.
38
+ */
39
+ export declare function makeRoutingReasonWriter(db: Kysely<Database>): SetRoutingReason;
40
+ /**
41
+ * Build the per-tick handler for the unroutable probe.
42
+ *
43
+ * The probe consults the SAME routability predicate the queue-expiry sweep uses
44
+ * — one verdict, two moments in time — but asks on a short tick instead of once
45
+ * an hour. On the first unroutable verdict it records the reason (so the cause
46
+ * is visible while the job still waits) and starts a grace clock; only once the
47
+ * job has been CONTINUOUSLY unroutable for the whole grace does it terminalize.
48
+ * A job that reads routable again has both cleared, so a scaler reload or an
49
+ * agent reconnect can never cost it its place in the queue.
50
+ */
51
+ export declare function createUnroutableProbeHandler(deps: UnroutableProbeDeps): () => Promise<void>;
52
+ //# sourceMappingURL=unroutable-probe.d.ts.map
@@ -13,7 +13,7 @@
13
13
  * is warm-only in v1.)
14
14
  */
15
15
  import type { Kysely } from 'kysely';
16
- import type { ExecutionJobStatus, InitFailure } from '@kici-dev/engine';
16
+ import { ExecutionJobStatus, type InitFailure } from '@kici-dev/engine';
17
17
  import type { Database } from '../db/types.js';
18
18
  /** A run-detail step row as queried from execution_steps (warm + cold paths). */
19
19
  export interface RunDetailStepRow {
@@ -63,6 +63,7 @@ export interface RunDetailJobRow {
63
63
  duration_ms: number | null;
64
64
  agent_id: string | null;
65
65
  error_message: string | null;
66
+ routing_reason: string | null;
66
67
  runs_on_labels: unknown;
67
68
  contexts: unknown;
68
69
  skipped_contexts: unknown;
@@ -79,6 +80,21 @@ export interface RunDetailJobLookups {
79
80
  runOn: ExecutionJobStatus[];
80
81
  }>>;
81
82
  }
83
+ /**
84
+ * The routing reason as a reader should see it.
85
+ *
86
+ * `routing_reason` is only meaningful while the job is still waiting to be
87
+ * routed, and more than twenty call sites move a job out of `pending`/`queued`
88
+ * — dispatch, cancel, the run-level failure cascades, the stale detector.
89
+ * Requiring every one of them to also null the column would be fragile in the
90
+ * one direction that matters: the site somebody forgets renders a finished job
91
+ * as "waiting for an agent".
92
+ *
93
+ * So the guard lives here, at the read boundary both projections share, where
94
+ * it cannot be forgotten by a writer that does not know this column exists.
95
+ * The stale bytes may linger in the row; they are never shown.
96
+ */
97
+ export declare function visibleRoutingReason(status: string, routingReason: string | null): string | null;
82
98
  /** Map queried job + step rows into the dashboard run-detail job DTO shape. */
83
99
  export declare function buildRunDetailJobs(jobs: RunDetailJobRow[], lookups: RunDetailJobLookups): {
84
100
  jobId: string;
@@ -94,6 +110,7 @@ export declare function buildRunDetailJobs(jobs: RunDetailJobRow[], lookups: Run
94
110
  agentId: string | null;
95
111
  orchestratorId: null;
96
112
  errorMessage: string | null;
113
+ routingReason: string | null;
97
114
  runsOnLabels: string[] | null;
98
115
  contexts: string[] | null;
99
116
  skippedContexts: string[] | null;
@@ -60,12 +60,30 @@ export declare class PgSecretStore implements SecretStore {
60
60
  * Get all secrets for a scope as decrypted key-value pairs.
61
61
  */
62
62
  getSecrets(orgId: string, scope: string): Promise<Record<string, string>>;
63
+ /** Check if a scope is internal/operational (always allowed regardless of toggle). */
64
+ private isInternalScope;
63
65
  /**
64
66
  * Set (create or update) a secret in a scope.
65
67
  * Encrypts the value with AAD = "orgId:scope:key".
68
+ *
69
+ * Rejects a key outside `[A-Za-z0-9._-]` before doing anything else. The AAD
70
+ * is a plain concatenation, so a `:` in the key would let two distinct
71
+ * locations render one AAD (scope 'b' + key 'c:d' equals scope 'b:c' + key
72
+ * 'd') and a ciphertext written at one would authenticate at the other. The
73
+ * check sits ahead of the customerSecretsEnabled gate so internal scopes get
74
+ * no exemption — the binding has to hold for every writer.
75
+ *
76
+ * Callers pass a bare scope path, so the AAD's middle field is colon-free
77
+ * too: the admin route and the dashboard handler both run the scope
78
+ * validator immediately before calling in, and `source-store` builds its
79
+ * scope from a uuid. That precondition is what makes the whole triple
80
+ * recoverable, and it is the caller's to keep — this method does not
81
+ * re-check it.
82
+ *
83
+ * Write-path only: getSecrets, listKeys, deleteSecret, deleteScope,
84
+ * renameScope, getAllSecrets and createScope stay unvalidated, which is what
85
+ * keeps a key stored before this rule readable and deletable.
66
86
  */
67
- /** Check if a scope is internal/operational (always allowed regardless of toggle). */
68
- private isInternalScope;
69
87
  setSecret(orgId: string, scope: string, key: string, value: string): Promise<void>;
70
88
  /**
71
89
  * Delete a secret from a scope.