@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.
- package/dist/cli/api-client.d.ts +22 -0
- package/dist/cli.js +615 -469
- package/dist/cluster/cluster-settings-reader.d.ts +1 -1
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/types.d.ts +2 -0
- package/dist/config.d.ts +3 -0
- package/dist/db/migrations/108_unroutable_fast_fail.d.ts +4 -0
- package/dist/db/types.d.ts +20 -0
- package/dist/index.js +50 -13
- package/dist/metrics/prometheus.d.ts +1 -0
- package/dist/queue/bootstrap.d.ts +5 -0
- package/dist/queue/cleanup.d.ts +8 -6
- package/dist/queue/job-queue.d.ts +47 -0
- package/dist/queue/scheduled-job.d.ts +1 -0
- package/dist/queue/terminalize-unroutable.d.ts +53 -0
- package/dist/queue/unroutable-probe.d.ts +52 -0
- package/dist/reporting/run-aggregator.d.ts +18 -1
- package/dist/secrets/pg-secret-store.d.ts +20 -2
- package/dist/server.js +1044 -717
- package/dist/standalone.js +1127 -804
- package/installer-image-digests.json +3 -3
- package/package.json +4 -4
- package/sbom.spdx.json +52 -52
|
@@ -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
|
/**
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -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>>;
|
package/dist/config/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/db/types.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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>;
|
package/dist/queue/cleanup.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
59
|
-
*
|
|
60
|
-
* stays
|
|
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?:
|
|
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
|
|
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.
|