@sema-agent/settings-schema 8.1.0 → 8.3.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/fleet.d.ts CHANGED
@@ -18,6 +18,42 @@
18
18
  * the skeleton does not move).
19
19
  */
20
20
  import { z } from "zod";
21
+ /**
22
+ * The center's `/api/fleet/*` endpoints that are addressed from outside the center, relative to its origin. ONE spelling: whoever
23
+ * calls one (the engine, the executor) and whoever serves or routes it (the center, the deployment's Service / Ingress) imports it
24
+ * here — nobody writes the string. Membership rule: an endpoint is here iff this package types its body, or it belongs to the
25
+ * liveness group ({@link FLEET_LIVE_PATHS}); a center-internal route is not.
26
+ * - `instances` — `POST` = the heartbeat ({@link InstanceAnnouncement}; its 2xx reply carries the center's
27
+ * {@link CenterCapabilities} under {@link HEARTBEAT_REPLY_CAPABILITIES_KEY}), `DELETE` = the goodbye ({@link InstanceDeregistration}).
28
+ * - `usage` — `POST` a {@link UsageReport}.
29
+ * - `quotaLeases` — the quota lease ({@link QuotaLease}).
30
+ * - `servingLease` — `POST` a {@link ServingLeaseRequest}, answered with a {@link ServingLeaseResponse}.
31
+ * - `hostPlanner` — `GET` one host's planner record, for the executor (its body is the center's and the executor's contract, not
32
+ * this package's; it is here because the deployment routes it as a member of the liveness group).
33
+ * Notation: a value is a path TEMPLATE — a parameter is a whole segment written `{name}` (`hostPlanner` ends in `/{host}`, filled
34
+ * with the URL-encoded host id); the other values have none and are the path itself. A prefix router (an Ingress rule) takes the
35
+ * template up to its first `{`.
36
+ */
37
+ export declare const FLEET_PATHS: {
38
+ readonly instances: "/api/fleet/instances";
39
+ readonly usage: "/api/fleet/usage";
40
+ readonly quotaLeases: "/api/fleet/leases";
41
+ readonly servingLease: "/api/fleet/live/serving-lease";
42
+ readonly hostPlanner: "/api/fleet/live/host-planner/{host}";
43
+ };
44
+ /**
45
+ * The LIVENESS group — the fleet endpoints a center answers without its readiness gate and without its config-governance gates
46
+ * (they read no config domain and take service credentials only), so that a center whose management face is refused never turns
47
+ * into a fleet that gets no answers. A deployment routes exactly these past the readiness gate (on Kubernetes: a second Service
48
+ * with `publishNotReadyAddresses`, and the Ingress rules for them point at it). Membership is THIS list, not a path prefix — the
49
+ * heartbeat predates the `/api/fleet/live/` prefix; every {@link FLEET_PATHS} entry under that prefix is a member. The
50
+ * quota-lease and usage endpoints are not.
51
+ *
52
+ * The same two constants ship as JSON for tooling that cannot import TypeScript (a Helm chart, a smoke script): the subpath
53
+ * `@sema-agent/settings-schema/fleet-paths.json` is `{ "FLEET_PATHS": …, "FLEET_LIVE_PATHS": … }`, written from these values at
54
+ * build time — never edited by hand.
55
+ */
56
+ export declare const FLEET_LIVE_PATHS: readonly ["/api/fleet/instances", "/api/fleet/live/serving-lease", "/api/fleet/live/host-planner/{host}"];
21
57
  /** Lifecycle of a draining instance. `running`→`draining` on the stop signal; `drained` once in-flight hits
22
58
  * zero (clean) or the grace deadline forces the issue. Exit always follows `drained`. */
23
59
  export declare const DRAIN_PHASES: readonly ["running", "draining", "drained"];
@@ -45,6 +81,29 @@ export type DrainVerdict = "wait" | "exit_clean" | "exit_forced";
45
81
  /** The drain state machine's single decision point: keep waiting, exit clean (drained), or force the exit
46
82
  * (grace exhausted with legs still in flight — durable legs resume cross-instance via resumeFromRunId). */
47
83
  export declare function drainVerdict(s: DrainSnapshot): DrainVerdict;
84
+ export declare function isCanonicalGeneration(value: unknown): value is string;
85
+ /** Numeric order of two canonical generations: `-1` when `a` is older than `b`, `0` when equal, `1` when newer — `"9"` is
86
+ * older than `"10"`; any number of digits. Throws a `TypeError` on an input that is not canonical (a comparison that quietly
87
+ * returned some number would be a fence decision made on garbage); judge untrusted input with {@link isCanonicalGeneration}
88
+ * first — the wire shapes of this file already do. */
89
+ export declare function compareGeneration(a: string, b: string): -1 | 0 | 1;
90
+ /**
91
+ * The serving lease as the ENGINE holds it — a CLOSED set of four words, owned here (the engine's vocabulary, word for word;
92
+ * a reader that switches on it exhaustively is told by the compiler when a word is added):
93
+ * - `none` — the lease is not in effect: the center has not announced {@link CenterCapabilities}`.servingLease` (an older or
94
+ * degraded center). The engine behaves exactly as an engine without a lease — no renewals, no fence.
95
+ * - `granted` — the engine holds a lease; `leaseUntil` says until when (epoch ms, the engine's own computation from the
96
+ * granted `ttlMs`, see {@link ServingLeaseResponse}).
97
+ * - `expired` — the lease is in effect but no grant is valid right now (the center is unreachable, answered `unknown`, or has
98
+ * not granted one yet): the engine keeps serving until its own outage allowance runs out. Not a denial.
99
+ * - `denied` — the center denied this instance on positive evidence (the `denied` verdict): sticky; the engine is draining and
100
+ * stops at its deadline.
101
+ * Only a fleet member says it: an engine without a generation has no lease and carries no `leaseState` at all (there is no
102
+ * fifth word for "not a member" — absence is that).
103
+ */
104
+ export declare const LEASE_STATES: readonly ["none", "granted", "expired", "denied"];
105
+ export declare const LeaseState: z.ZodEnum<["none", "granted", "expired", "denied"]>;
106
+ export type LeaseState = z.infer<typeof LeaseState>;
48
107
  /**
49
108
  * "This instance must restart to apply config" — the engine's OWN observation (S-741), defined ONCE for both exits that
50
109
  * carry it: the fleet heartbeat ({@link InstanceAnnouncement}`.restart`, typed) and GET /health (`restart`, read through
@@ -234,7 +293,10 @@ export declare const STALE_HEARTBEAT_MULTIPLIER = 2;
234
293
  /** Register/heartbeat upsert (worker → center, D2: worker-active). ONE shape for both — registration is the
235
294
  * first heartbeat; every heartbeat re-announces the full row (idempotent upsert, no drift between a
236
295
  * "register" and a "renew" schema). `draining:true` is the instance SELF-removing from the routable set the
237
- * moment the stop signal lands — faster than any observer. */
296
+ * moment the stop signal lands — faster than any observer.
297
+ * Scope: the body carries NO `scope` — this shape has no such key, and one sent anyway is stripped. The row lands in the scope
298
+ * the CREDENTIAL names: under a per-worker token, the scope the token binds (a `?scope=` query parameter, when given, must equal
299
+ * it); under the shared token, the `?scope=` query parameter, absent ⇒ `"global"`. */
238
300
  export declare const InstanceAnnouncement: z.ZodObject<{
239
301
  instanceId: z.ZodString;
240
302
  /** worker (deployment) this instance belongs to — the dispatch key */
@@ -292,6 +354,20 @@ export declare const InstanceAnnouncement: z.ZodObject<{
292
354
  entries: Record<string, string>;
293
355
  plane?: string | undefined;
294
356
  }>>>;
357
+ /** Fleet membership and the lease reading, flat on the heartbeat body under the names and values {@link EngineSignal} gives
358
+ * them (one mint point on the engine; this shape reuses that shape's schemas, not copies):
359
+ * - `generation` — the canonical decimal generation ({@link isCanonicalGeneration}); present ⇔ the instance is a fleet member.
360
+ * - `leaseState` — {@link LeaseState}; present ⇔ fleet member.
361
+ * - `leaseUntil` — epoch ms until which the held lease runs; present ⇔ `leaseState` is `granted`.
362
+ * The presence law between the three is the engine's; this shape does not judge it across keys. Every heartbeat re-announces
363
+ * the full row, so a later heartbeat without a key means the engine no longer says it (the stored row is replaced, never merged).
364
+ * Failure direction = meaning direction: these are evidence a center keeps, never routing input — a value that breaks its schema
365
+ * drops that ONE key to ABSENT (`.catch`) and the heartbeat is still accepted, so a lease word a newer engine adds can never make
366
+ * an older center refuse every heartbeat of that engine. A dropped key reads exactly like an unsaid one; the key may then be
367
+ * present with the value `undefined`. */
368
+ generation: z.ZodCatch<z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>>;
369
+ leaseState: z.ZodCatch<z.ZodOptional<z.ZodEnum<["none", "granted", "expired", "denied"]>>>;
370
+ leaseUntil: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
295
371
  }, "strip", z.ZodTypeAny, {
296
372
  draining: boolean;
297
373
  version: string;
@@ -310,6 +386,9 @@ export declare const InstanceAnnouncement: z.ZodObject<{
310
386
  entries: Record<string, string>;
311
387
  plane?: string | undefined;
312
388
  } | undefined;
389
+ generation?: string | undefined;
390
+ leaseState?: "none" | "granted" | "expired" | "denied" | undefined;
391
+ leaseUntil?: number | undefined;
313
392
  }, {
314
393
  version: string;
315
394
  instanceId: string;
@@ -325,6 +404,9 @@ export declare const InstanceAnnouncement: z.ZodObject<{
325
404
  } | undefined;
326
405
  modelRefusals?: unknown;
327
406
  heartbeatMs?: number | undefined;
407
+ generation?: unknown;
408
+ leaseState?: unknown;
409
+ leaseUntil?: unknown;
328
410
  }>;
329
411
  export type InstanceAnnouncement = z.infer<typeof InstanceAnnouncement>;
330
412
  /** Explicit goodbye (stop signal handler) — removes the row immediately instead of waiting out staleness. */
@@ -401,6 +483,20 @@ export declare const InstanceRegistration: z.ZodObject<{
401
483
  entries: Record<string, string>;
402
484
  plane?: string | undefined;
403
485
  }>>>;
486
+ /** Fleet membership and the lease reading, flat on the heartbeat body under the names and values {@link EngineSignal} gives
487
+ * them (one mint point on the engine; this shape reuses that shape's schemas, not copies):
488
+ * - `generation` — the canonical decimal generation ({@link isCanonicalGeneration}); present ⇔ the instance is a fleet member.
489
+ * - `leaseState` — {@link LeaseState}; present ⇔ fleet member.
490
+ * - `leaseUntil` — epoch ms until which the held lease runs; present ⇔ `leaseState` is `granted`.
491
+ * The presence law between the three is the engine's; this shape does not judge it across keys. Every heartbeat re-announces
492
+ * the full row, so a later heartbeat without a key means the engine no longer says it (the stored row is replaced, never merged).
493
+ * Failure direction = meaning direction: these are evidence a center keeps, never routing input — a value that breaks its schema
494
+ * drops that ONE key to ABSENT (`.catch`) and the heartbeat is still accepted, so a lease word a newer engine adds can never make
495
+ * an older center refuse every heartbeat of that engine. A dropped key reads exactly like an unsaid one; the key may then be
496
+ * present with the value `undefined`. */
497
+ generation: z.ZodCatch<z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>>;
498
+ leaseState: z.ZodCatch<z.ZodOptional<z.ZodEnum<["none", "granted", "expired", "denied"]>>>;
499
+ leaseUntil: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
404
500
  } & {
405
501
  registeredMs: z.ZodNumber;
406
502
  lastSeenMs: z.ZodNumber;
@@ -425,6 +521,9 @@ export declare const InstanceRegistration: z.ZodObject<{
425
521
  entries: Record<string, string>;
426
522
  plane?: string | undefined;
427
523
  } | undefined;
524
+ generation?: string | undefined;
525
+ leaseState?: "none" | "granted" | "expired" | "denied" | undefined;
526
+ leaseUntil?: number | undefined;
428
527
  observedReady?: boolean | undefined;
429
528
  }, {
430
529
  version: string;
@@ -443,6 +542,9 @@ export declare const InstanceRegistration: z.ZodObject<{
443
542
  } | undefined;
444
543
  modelRefusals?: unknown;
445
544
  heartbeatMs?: number | undefined;
545
+ generation?: unknown;
546
+ leaseState?: unknown;
547
+ leaseUntil?: unknown;
446
548
  observedReady?: boolean | undefined;
447
549
  }>;
448
550
  export type InstanceRegistration = z.infer<typeof InstanceRegistration>;
@@ -475,18 +577,24 @@ export declare const EngineDraining: z.ZodObject<{
475
577
  deadlineAt: number;
476
578
  }>;
477
579
  export type EngineDraining = z.infer<typeof EngineDraining>;
478
- export declare function isCanonicalGeneration(value: unknown): value is string;
479
- /** Numeric order of two canonical generations: `-1` when `a` is older than `b`, `0` when equal, `1` when newer — `"9"` is
480
- * older than `"10"`; any number of digits. Throws a `TypeError` on an input that is not canonical (a comparison that quietly
481
- * returned some number would be a fence decision made on garbage); judge untrusted input with {@link isCanonicalGeneration}
482
- * first — the wire shapes of this file already do. */
483
- export declare function compareGeneration(a: string, b: string): -1 | 0 | 1;
484
580
  /**
485
581
  * Everything the ENGINE itself mints about its own state, as one object — the closed form of the engine-owned subset of the
486
582
  * /health self-description ({@link InstanceHealth} is that self-description: an open record, `.passthrough()`, which also
487
- * carries what the host adds — build, pid, port, store posture). One mint point; the heartbeat and `/health` carry the same
488
- * keys with the same values, flat (`ready`, `draining`, `restart`, … sit directly on the body, no wrapper key), and
489
- * {@link readEngineSignal} is the one reader of either.
583
+ * carries what the host adds — build, pid, port, store posture). One mint point on the engine; {@link readEngineSignal} reads
584
+ * this shape off a body whose keys sit flat on it (no wrapper key).
585
+ *
586
+ * Where it rides today: on NEITHER exit whole. The heartbeat ({@link InstanceAnnouncement}) and `/health` ({@link InstanceHealth})
587
+ * still speak the 8.0 dialect for the keys they share with this shape, in three ways:
588
+ * 1. `draining` is a BOOLEAN there, not the {@link EngineDraining} object. The heartbeat always carries it (`false` included);
589
+ * `/health` carries `draining: true` only while draining.
590
+ * 2. `/health` carries `ready` only while NOT ready (`ready: false`); a ready engine's `/health` body has no `ready` key.
591
+ * 3. The engine writes `configTargetVersion` / `configAppliedVersion` as numbers; this shape types them as strings.
592
+ * So `readEngineSignal` over a heartbeat body is always `undefined` (point 1), and over a `/health` body it is `undefined` whenever
593
+ * any of the three applies. What does ride both exits flat, under these names and with these values: `restart`, `instanceId`
594
+ * and the fleet-membership keys `generation` / `leaseState` / `leaseUntil` — on the heartbeat, read them off the parsed
595
+ * {@link InstanceAnnouncement} (which types all five). The next major retires the boolean pair (`draining` / `drainingSince`),
596
+ * has both exits carry this shape whole and moves {@link isReadyForTraffic} / {@link isRoutable} onto it; until then this is
597
+ * the target form, and `readEngineSignal` is the reader for a body that already speaks it.
490
598
  *
491
599
  * - `ready` — always said (never "absent while ready"): the engine can take new work right now.
492
600
  * - `draining` — present ⇔ draining ({@link EngineDraining}). NOTE the dialect: {@link InstanceHealth}`.draining` and
@@ -499,8 +607,10 @@ export declare function compareGeneration(a: string, b: string): -1 | 0 | 1;
499
607
  * - `instanceId` — the engine's OWN process identity, minted by the engine every time the process starts (never read from
500
608
  * env: replicas of one Deployment share one env, and a restarted container reuses its env, so env cannot tell two
501
609
  * processes apart). It is present on a standalone engine too — only `generation` marks fleet membership.
610
+ * - `leaseState` — the serving lease as the engine holds it ({@link LeaseState}: `none` / `granted` / `expired` / `denied`);
611
+ * present ⇔ fleet member (the engine says it on every beat once it has a generation).
502
612
  * - `leaseUntil` — epoch ms until which the engine holds its serving lease, as the engine computed it from the last
503
- * `ttlMs` it was granted ({@link ServingLeaseResponse}); absent when it holds none.
613
+ * `ttlMs` it was granted ({@link ServingLeaseResponse}); present ⇔ `leaseState` is `granted`.
504
614
  * - `configTargetVersion` / `configAppliedVersion` — the config-center version the engine was told to converge on, and the
505
615
  * one it has applied (opaque strings; equal ⇔ converged).
506
616
  *
@@ -543,6 +653,7 @@ export declare const EngineSignal: z.ZodObject<{
543
653
  }>>;
544
654
  generation: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
545
655
  instanceId: z.ZodOptional<z.ZodString>;
656
+ leaseState: z.ZodOptional<z.ZodEnum<["none", "granted", "expired", "denied"]>>;
546
657
  leaseUntil: z.ZodOptional<z.ZodNumber>;
547
658
  configTargetVersion: z.ZodOptional<z.ZodString>;
548
659
  configAppliedVersion: z.ZodOptional<z.ZodString>;
@@ -561,6 +672,7 @@ export declare const EngineSignal: z.ZodObject<{
561
672
  } | undefined;
562
673
  instanceId?: string | undefined;
563
674
  generation?: string | undefined;
675
+ leaseState?: "none" | "granted" | "expired" | "denied" | undefined;
564
676
  leaseUntil?: number | undefined;
565
677
  configTargetVersion?: string | undefined;
566
678
  configAppliedVersion?: string | undefined;
@@ -579,16 +691,17 @@ export declare const EngineSignal: z.ZodObject<{
579
691
  } | undefined;
580
692
  instanceId?: string | undefined;
581
693
  generation?: string | undefined;
694
+ leaseState?: "none" | "granted" | "expired" | "denied" | undefined;
582
695
  leaseUntil?: number | undefined;
583
696
  configTargetVersion?: string | undefined;
584
697
  configAppliedVersion?: string | undefined;
585
698
  }>;
586
699
  export type EngineSignal = z.infer<typeof EngineSignal>;
587
700
  /**
588
- * The reader of {@link EngineSignal}, for either exit (a /health body or a heartbeat body — the keys are flat on both): the
589
- * same guarded body reader as {@link readRestartSignal} and {@link readModelRefusals}, so the same odd input gets the same
590
- * answer from all three. Shape mismatch, a missing `ready`, a non-object body, even a throwing accessor ⇒ `undefined` —
591
- * never throws. See {@link EngineSignal} for what `undefined` means (not "ready").
701
+ * The reader of {@link EngineSignal}: the shape's keys, flat on the given body, judged by the same guarded body reader as
702
+ * {@link readRestartSignal} and {@link readModelRefusals}, so the same odd input gets the same answer from all three. Shape
703
+ * mismatch, a missing `ready`, a non-object body, even a throwing accessor ⇒ `undefined` — never throws. See {@link EngineSignal}
704
+ * for what `undefined` means (not "ready") and for why today's heartbeat and `/health` bodies read as `undefined` here.
592
705
  */
593
706
  export declare function readEngineSignal(health: unknown): EngineSignal | undefined;
594
707
  /**
@@ -599,6 +712,18 @@ export declare function readEngineSignal(health: unknown): EngineSignal | undefi
599
712
  * ({@link EngineSignal}`.instanceId`), and env cannot carry it (see there).
600
713
  */
601
714
  export declare const FLEET_GENERATION_ENV = "FLEET_GENERATION";
715
+ /**
716
+ * The env name a fleet member's tenant scope travels under — the config renderer sets it to the scope this member's per-worker
717
+ * token binds (the same value, taken from the same place the token is issued for), and the engine reads it as the scope of its
718
+ * OWN identity: the scope it compares a {@link DrainRequest}'s `scope` against, sends as {@link ServingLeaseRequest}`.scope`, and
719
+ * names on its heartbeat ({@link InstanceAnnouncement}'s `?scope=`). Both sides import THIS constant; neither spells the name.
720
+ * One fact, one source: the scope is GIVEN by the renderer — the engine never derives it from the token's format (a token is
721
+ * opaque to it, and a format guess would be a second source that can disagree with the scope the center binds). Absent from
722
+ * the engine's env ⇒ the default scope, {@link GLOBAL_SCOPE} (`api/scopes`) — the same default the center reads where nothing
723
+ * names a scope; this note does not restate the value. Like {@link FLEET_GENERATION_ENV} it is a reserved worker env name
724
+ * (`WorkerSpec.env` / `secretEnv` cannot set it).
725
+ */
726
+ export declare const FLEET_SCOPE_ENV = "FLEET_SCOPE";
602
727
  /**
603
728
  * The serving lease — the ONE fence between a superseded generation and the traffic it must stop taking. A fleet-member
604
729
  * engine renews it with the center; the center answers per the lifecycle object it holds for that instance. Three constants
@@ -625,8 +750,11 @@ export declare function clampServingLeaseTtlMs(configuredMs: number | undefined)
625
750
  * Engine → center: "may this instance keep serving?" The identity triple names the instance exactly — a lease is never
626
751
  * asked for by worker name alone. `generation` is the env echo ({@link FLEET_GENERATION_ENV}); `instanceId` is the engine's
627
752
  * own per-process identity ({@link EngineSignal}`.instanceId`), so a restarted process is a NEW instance to the center.
628
- * `scope` is the tenant scope the worker lives in (absent = the single-scope deployment). Only fleet members ask: an engine
629
- * without a generation (the standalone form) has no lease and sends nothing.
753
+ * `scope` — the tenant scope the lease is asked in. A renewal is taken on a PER-WORKER token only — the credential binds
754
+ * (scope, worker) — so the credential decides the scope and this field can only agree: absent = the scope the token binds; present
755
+ * ⇒ it must equal that scope (a mismatch is refused, never rewritten either way). The shared token may not renew a lease at all
756
+ * (the center answers 403). Only fleet members ask: an engine without a generation (the standalone form) has no lease and sends
757
+ * nothing.
630
758
  */
631
759
  export declare const ServingLeaseRequest: z.ZodObject<{
632
760
  worker: z.ZodString;
@@ -758,7 +886,10 @@ export declare function readServingLeaseResponse(body: unknown): ServingLeaseRea
758
886
  * A reader asks `readCenterCapabilities(body)?.servingLease === true` and starts the lease client only on that.
759
887
  * Unknown bits are stripped (a newer center's extra bits never break an older reader); a known bit whose value is not `true`
760
888
  * makes the whole read `undefined`, which reads exactly as absent.
761
- * This package fixes the shape and the reader, not which center endpoint carries the object.
889
+ * It rides in ONE place: the heartbeat's 2xx reply ({@link FLEET_PATHS}`.instances`), under {@link HEARTBEAT_REPLY_CAPABILITIES_KEY}.
890
+ * The center answers the bits per CALLER CREDENTIAL: under a per-worker token, by what the center supports; under the shared token
891
+ * `servingLease` is always absent (the serving-lease endpoint takes per-worker tokens only, so announcing it there would only push
892
+ * that caller into the unreachable arm).
762
893
  */
763
894
  export declare const CenterCapabilities: z.ZodObject<{
764
895
  servingLease: z.ZodOptional<z.ZodLiteral<true>>;
@@ -768,6 +899,14 @@ export declare const CenterCapabilities: z.ZodObject<{
768
899
  servingLease?: true | undefined;
769
900
  }>;
770
901
  export type CenterCapabilities = z.infer<typeof CenterCapabilities>;
902
+ /**
903
+ * The key of the heartbeat's 2xx reply body under which the center puts its {@link CenterCapabilities} — the object's only carrier.
904
+ * The center writes `{ …, [HEARTBEAT_REPLY_CAPABILITIES_KEY]: caps }`; the engine reads
905
+ * `readCenterCapabilities(reply[HEARTBEAT_REPLY_CAPABILITIES_KEY])`, and only on a 2xx (a failed heartbeat says nothing about the
906
+ * bits). Nested, not flat: the reply's own keys and the capability bits never share a namespace. A reply without the key is an
907
+ * older center — no capability said.
908
+ */
909
+ export declare const HEARTBEAT_REPLY_CAPABILITIES_KEY = "capabilities";
771
910
  /** Engine side: the center's capability object. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws
772
911
  * (and `undefined` means "no capability said" — the same as every bit absent). */
773
912
  export declare function readCenterCapabilities(body: unknown): CenterCapabilities | undefined;
package/dist/fleet.js CHANGED
@@ -19,6 +19,43 @@
19
19
  */
20
20
  import { z } from "zod";
21
21
  import { fnv1a64 } from "./hash.js";
22
+ // ── The center's fleet endpoints (paths, one spelling) ─────────────────────────────────────────────────────
23
+ /**
24
+ * The center's `/api/fleet/*` endpoints that are addressed from outside the center, relative to its origin. ONE spelling: whoever
25
+ * calls one (the engine, the executor) and whoever serves or routes it (the center, the deployment's Service / Ingress) imports it
26
+ * here — nobody writes the string. Membership rule: an endpoint is here iff this package types its body, or it belongs to the
27
+ * liveness group ({@link FLEET_LIVE_PATHS}); a center-internal route is not.
28
+ * - `instances` — `POST` = the heartbeat ({@link InstanceAnnouncement}; its 2xx reply carries the center's
29
+ * {@link CenterCapabilities} under {@link HEARTBEAT_REPLY_CAPABILITIES_KEY}), `DELETE` = the goodbye ({@link InstanceDeregistration}).
30
+ * - `usage` — `POST` a {@link UsageReport}.
31
+ * - `quotaLeases` — the quota lease ({@link QuotaLease}).
32
+ * - `servingLease` — `POST` a {@link ServingLeaseRequest}, answered with a {@link ServingLeaseResponse}.
33
+ * - `hostPlanner` — `GET` one host's planner record, for the executor (its body is the center's and the executor's contract, not
34
+ * this package's; it is here because the deployment routes it as a member of the liveness group).
35
+ * Notation: a value is a path TEMPLATE — a parameter is a whole segment written `{name}` (`hostPlanner` ends in `/{host}`, filled
36
+ * with the URL-encoded host id); the other values have none and are the path itself. A prefix router (an Ingress rule) takes the
37
+ * template up to its first `{`.
38
+ */
39
+ export const FLEET_PATHS = {
40
+ instances: "/api/fleet/instances",
41
+ usage: "/api/fleet/usage",
42
+ quotaLeases: "/api/fleet/leases",
43
+ servingLease: "/api/fleet/live/serving-lease",
44
+ hostPlanner: "/api/fleet/live/host-planner/{host}",
45
+ };
46
+ /**
47
+ * The LIVENESS group — the fleet endpoints a center answers without its readiness gate and without its config-governance gates
48
+ * (they read no config domain and take service credentials only), so that a center whose management face is refused never turns
49
+ * into a fleet that gets no answers. A deployment routes exactly these past the readiness gate (on Kubernetes: a second Service
50
+ * with `publishNotReadyAddresses`, and the Ingress rules for them point at it). Membership is THIS list, not a path prefix — the
51
+ * heartbeat predates the `/api/fleet/live/` prefix; every {@link FLEET_PATHS} entry under that prefix is a member. The
52
+ * quota-lease and usage endpoints are not.
53
+ *
54
+ * The same two constants ship as JSON for tooling that cannot import TypeScript (a Helm chart, a smoke script): the subpath
55
+ * `@sema-agent/settings-schema/fleet-paths.json` is `{ "FLEET_PATHS": …, "FLEET_LIVE_PATHS": … }`, written from these values at
56
+ * build time — never edited by hand.
57
+ */
58
+ export const FLEET_LIVE_PATHS = [FLEET_PATHS.instances, FLEET_PATHS.servingLease, FLEET_PATHS.hostPlanner];
22
59
  // ── Domain 1: drain protocol (graceful rolling update — the ground every form stands on) ──────────────────
23
60
  /** Lifecycle of a draining instance. `running`→`draining` on the stop signal; `drained` once in-flight hits
24
61
  * zero (clean) or the grace deadline forces the issue. Exit always follows `drained`. */
@@ -42,6 +79,50 @@ export function drainVerdict(s) {
42
79
  }
43
80
  /** A wall-clock instant as epoch milliseconds — one quantity, one rule, wherever the fleet contract carries a point in time. */
44
81
  const EpochMs = z.number().int().nonnegative();
82
+ /**
83
+ * A generation is a non-negative integer written as its CANONICAL decimal string: `"0"`, `"7"`, `"10"` — no sign, no
84
+ * leading zero (`"07"` is not a generation), no fraction, no whitespace, ASCII digits only, and a string, never a number.
85
+ * One spelling per value, so equality is string equality and the order is numeric ({@link compareGeneration}) — the center's
86
+ * persisted generation floor and every "is this generation below the floor" judgement rest on that order. The renderer writes
87
+ * the value into {@link FLEET_GENERATION_ENV}; an env value that is present but not canonical is a misconfiguration to refuse
88
+ * loudly, not an absent generation (absence is what marks a non-member). Wherever a wire shape here carries a generation
89
+ * ({@link EngineSignal}`.generation`, {@link ServingLeaseRequest}, {@link DrainRequest}`.expectedGeneration`) it is judged by
90
+ * this one predicate.
91
+ */
92
+ const CANONICAL_GENERATION = /^(?:0|[1-9][0-9]*)$/;
93
+ export function isCanonicalGeneration(value) {
94
+ return typeof value === "string" && CANONICAL_GENERATION.test(value);
95
+ }
96
+ /** Numeric order of two canonical generations: `-1` when `a` is older than `b`, `0` when equal, `1` when newer — `"9"` is
97
+ * older than `"10"`; any number of digits. Throws a `TypeError` on an input that is not canonical (a comparison that quietly
98
+ * returned some number would be a fence decision made on garbage); judge untrusted input with {@link isCanonicalGeneration}
99
+ * first — the wire shapes of this file already do. */
100
+ export function compareGeneration(a, b) {
101
+ if (!isCanonicalGeneration(a) || !isCanonicalGeneration(b)) {
102
+ throw new TypeError(`compareGeneration: a generation is a canonical non-negative decimal string (got ${JSON.stringify(isCanonicalGeneration(a) ? b : a)})`);
103
+ }
104
+ if (a.length !== b.length)
105
+ return a.length < b.length ? -1 : 1; // canonical ⇒ no leading zeros ⇒ more digits is larger
106
+ return a === b ? 0 : a < b ? -1 : 1;
107
+ }
108
+ /** The generation field of every wire shape that carries one — a string judged by {@link isCanonicalGeneration}. */
109
+ const Generation = z.string().refine(isCanonicalGeneration, { message: "a generation is a canonical non-negative decimal string (no sign, no leading zero)" });
110
+ /**
111
+ * The serving lease as the ENGINE holds it — a CLOSED set of four words, owned here (the engine's vocabulary, word for word;
112
+ * a reader that switches on it exhaustively is told by the compiler when a word is added):
113
+ * - `none` — the lease is not in effect: the center has not announced {@link CenterCapabilities}`.servingLease` (an older or
114
+ * degraded center). The engine behaves exactly as an engine without a lease — no renewals, no fence.
115
+ * - `granted` — the engine holds a lease; `leaseUntil` says until when (epoch ms, the engine's own computation from the
116
+ * granted `ttlMs`, see {@link ServingLeaseResponse}).
117
+ * - `expired` — the lease is in effect but no grant is valid right now (the center is unreachable, answered `unknown`, or has
118
+ * not granted one yet): the engine keeps serving until its own outage allowance runs out. Not a denial.
119
+ * - `denied` — the center denied this instance on positive evidence (the `denied` verdict): sticky; the engine is draining and
120
+ * stops at its deadline.
121
+ * Only a fleet member says it: an engine without a generation has no lease and carries no `leaseState` at all (there is no
122
+ * fifth word for "not a member" — absence is that).
123
+ */
124
+ export const LEASE_STATES = ["none", "granted", "expired", "denied"];
125
+ export const LeaseState = z.enum(LEASE_STATES);
45
126
  /**
46
127
  * "This instance must restart to apply config" — the engine's OWN observation (S-741), defined ONCE for both exits that
47
128
  * carry it: the fleet heartbeat ({@link InstanceAnnouncement}`.restart`, typed) and GET /health (`restart`, read through
@@ -236,7 +317,10 @@ export const STALE_HEARTBEAT_MULTIPLIER = 2;
236
317
  /** Register/heartbeat upsert (worker → center, D2: worker-active). ONE shape for both — registration is the
237
318
  * first heartbeat; every heartbeat re-announces the full row (idempotent upsert, no drift between a
238
319
  * "register" and a "renew" schema). `draining:true` is the instance SELF-removing from the routable set the
239
- * moment the stop signal lands — faster than any observer. */
320
+ * moment the stop signal lands — faster than any observer.
321
+ * Scope: the body carries NO `scope` — this shape has no such key, and one sent anyway is stripped. The row lands in the scope
322
+ * the CREDENTIAL names: under a per-worker token, the scope the token binds (a `?scope=` query parameter, when given, must equal
323
+ * it); under the shared token, the `?scope=` query parameter, absent ⇒ `"global"`. */
240
324
  export const InstanceAnnouncement = z.object({
241
325
  instanceId: z.string().min(1),
242
326
  /** worker (deployment) this instance belongs to — the dispatch key */
@@ -264,6 +348,20 @@ export const InstanceAnnouncement = z.object({
264
348
  * ABSENT (`.catch`) — it never fails the announcement, so it can never make a healthy instance unroutable (unlike
265
349
  * `restart`, whose shape still gates the row). The key may then be present with the value `undefined`. */
266
350
  modelRefusals: ModelRefusalsWire.optional().catch(undefined),
351
+ /** Fleet membership and the lease reading, flat on the heartbeat body under the names and values {@link EngineSignal} gives
352
+ * them (one mint point on the engine; this shape reuses that shape's schemas, not copies):
353
+ * - `generation` — the canonical decimal generation ({@link isCanonicalGeneration}); present ⇔ the instance is a fleet member.
354
+ * - `leaseState` — {@link LeaseState}; present ⇔ fleet member.
355
+ * - `leaseUntil` — epoch ms until which the held lease runs; present ⇔ `leaseState` is `granted`.
356
+ * The presence law between the three is the engine's; this shape does not judge it across keys. Every heartbeat re-announces
357
+ * the full row, so a later heartbeat without a key means the engine no longer says it (the stored row is replaced, never merged).
358
+ * Failure direction = meaning direction: these are evidence a center keeps, never routing input — a value that breaks its schema
359
+ * drops that ONE key to ABSENT (`.catch`) and the heartbeat is still accepted, so a lease word a newer engine adds can never make
360
+ * an older center refuse every heartbeat of that engine. A dropped key reads exactly like an unsaid one; the key may then be
361
+ * present with the value `undefined`. */
362
+ generation: Generation.optional().catch(undefined),
363
+ leaseState: LeaseState.optional().catch(undefined),
364
+ leaseUntil: EpochMs.optional().catch(undefined),
267
365
  });
268
366
  /** Explicit goodbye (stop signal handler) — removes the row immediately instead of waiting out staleness. */
269
367
  export const InstanceDeregistration = z.object({
@@ -305,40 +403,24 @@ export const EngineDraining = z.object({
305
403
  since: EpochMs,
306
404
  deadlineAt: EpochMs,
307
405
  });
308
- /**
309
- * A generation is a non-negative integer written as its CANONICAL decimal string: `"0"`, `"7"`, `"10"` — no sign, no
310
- * leading zero (`"07"` is not a generation), no fraction, no whitespace, ASCII digits only, and a string, never a number.
311
- * One spelling per value, so equality is string equality and the order is numeric ({@link compareGeneration}) — the center's
312
- * persisted generation floor and every "is this generation below the floor" judgement rest on that order. The renderer writes
313
- * the value into {@link FLEET_GENERATION_ENV}; an env value that is present but not canonical is a misconfiguration to refuse
314
- * loudly, not an absent generation (absence is what marks a non-member). Wherever a wire shape here carries a generation
315
- * ({@link EngineSignal}`.generation`, {@link ServingLeaseRequest}, {@link DrainRequest}`.expectedGeneration`) it is judged by
316
- * this one predicate.
317
- */
318
- const CANONICAL_GENERATION = /^(?:0|[1-9][0-9]*)$/;
319
- export function isCanonicalGeneration(value) {
320
- return typeof value === "string" && CANONICAL_GENERATION.test(value);
321
- }
322
- /** Numeric order of two canonical generations: `-1` when `a` is older than `b`, `0` when equal, `1` when newer — `"9"` is
323
- * older than `"10"`; any number of digits. Throws a `TypeError` on an input that is not canonical (a comparison that quietly
324
- * returned some number would be a fence decision made on garbage); judge untrusted input with {@link isCanonicalGeneration}
325
- * first — the wire shapes of this file already do. */
326
- export function compareGeneration(a, b) {
327
- if (!isCanonicalGeneration(a) || !isCanonicalGeneration(b)) {
328
- throw new TypeError(`compareGeneration: a generation is a canonical non-negative decimal string (got ${JSON.stringify(isCanonicalGeneration(a) ? b : a)})`);
329
- }
330
- if (a.length !== b.length)
331
- return a.length < b.length ? -1 : 1; // canonical ⇒ no leading zeros ⇒ more digits is larger
332
- return a === b ? 0 : a < b ? -1 : 1;
333
- }
334
- /** The generation field of every wire shape that carries one — a string judged by {@link isCanonicalGeneration}. */
335
- const Generation = z.string().refine(isCanonicalGeneration, { message: "a generation is a canonical non-negative decimal string (no sign, no leading zero)" });
336
406
  /**
337
407
  * Everything the ENGINE itself mints about its own state, as one object — the closed form of the engine-owned subset of the
338
408
  * /health self-description ({@link InstanceHealth} is that self-description: an open record, `.passthrough()`, which also
339
- * carries what the host adds — build, pid, port, store posture). One mint point; the heartbeat and `/health` carry the same
340
- * keys with the same values, flat (`ready`, `draining`, `restart`, … sit directly on the body, no wrapper key), and
341
- * {@link readEngineSignal} is the one reader of either.
409
+ * carries what the host adds — build, pid, port, store posture). One mint point on the engine; {@link readEngineSignal} reads
410
+ * this shape off a body whose keys sit flat on it (no wrapper key).
411
+ *
412
+ * Where it rides today: on NEITHER exit whole. The heartbeat ({@link InstanceAnnouncement}) and `/health` ({@link InstanceHealth})
413
+ * still speak the 8.0 dialect for the keys they share with this shape, in three ways:
414
+ * 1. `draining` is a BOOLEAN there, not the {@link EngineDraining} object. The heartbeat always carries it (`false` included);
415
+ * `/health` carries `draining: true` only while draining.
416
+ * 2. `/health` carries `ready` only while NOT ready (`ready: false`); a ready engine's `/health` body has no `ready` key.
417
+ * 3. The engine writes `configTargetVersion` / `configAppliedVersion` as numbers; this shape types them as strings.
418
+ * So `readEngineSignal` over a heartbeat body is always `undefined` (point 1), and over a `/health` body it is `undefined` whenever
419
+ * any of the three applies. What does ride both exits flat, under these names and with these values: `restart`, `instanceId`
420
+ * and the fleet-membership keys `generation` / `leaseState` / `leaseUntil` — on the heartbeat, read them off the parsed
421
+ * {@link InstanceAnnouncement} (which types all five). The next major retires the boolean pair (`draining` / `drainingSince`),
422
+ * has both exits carry this shape whole and moves {@link isReadyForTraffic} / {@link isRoutable} onto it; until then this is
423
+ * the target form, and `readEngineSignal` is the reader for a body that already speaks it.
342
424
  *
343
425
  * - `ready` — always said (never "absent while ready"): the engine can take new work right now.
344
426
  * - `draining` — present ⇔ draining ({@link EngineDraining}). NOTE the dialect: {@link InstanceHealth}`.draining` and
@@ -351,8 +433,10 @@ const Generation = z.string().refine(isCanonicalGeneration, { message: "a genera
351
433
  * - `instanceId` — the engine's OWN process identity, minted by the engine every time the process starts (never read from
352
434
  * env: replicas of one Deployment share one env, and a restarted container reuses its env, so env cannot tell two
353
435
  * processes apart). It is present on a standalone engine too — only `generation` marks fleet membership.
436
+ * - `leaseState` — the serving lease as the engine holds it ({@link LeaseState}: `none` / `granted` / `expired` / `denied`);
437
+ * present ⇔ fleet member (the engine says it on every beat once it has a generation).
354
438
  * - `leaseUntil` — epoch ms until which the engine holds its serving lease, as the engine computed it from the last
355
- * `ttlMs` it was granted ({@link ServingLeaseResponse}); absent when it holds none.
439
+ * `ttlMs` it was granted ({@link ServingLeaseResponse}); present ⇔ `leaseState` is `granted`.
356
440
  * - `configTargetVersion` / `configAppliedVersion` — the config-center version the engine was told to converge on, and the
357
441
  * one it has applied (opaque strings; equal ⇔ converged).
358
442
  *
@@ -368,15 +452,16 @@ export const EngineSignal = z.object({
368
452
  restart: RestartSignal.optional(),
369
453
  generation: Generation.optional(),
370
454
  instanceId: z.string().min(1).optional(),
455
+ leaseState: LeaseState.optional(),
371
456
  leaseUntil: EpochMs.optional(),
372
457
  configTargetVersion: z.string().min(1).optional(),
373
458
  configAppliedVersion: z.string().min(1).optional(),
374
459
  });
375
460
  /**
376
- * The reader of {@link EngineSignal}, for either exit (a /health body or a heartbeat body — the keys are flat on both): the
377
- * same guarded body reader as {@link readRestartSignal} and {@link readModelRefusals}, so the same odd input gets the same
378
- * answer from all three. Shape mismatch, a missing `ready`, a non-object body, even a throwing accessor ⇒ `undefined` —
379
- * never throws. See {@link EngineSignal} for what `undefined` means (not "ready").
461
+ * The reader of {@link EngineSignal}: the shape's keys, flat on the given body, judged by the same guarded body reader as
462
+ * {@link readRestartSignal} and {@link readModelRefusals}, so the same odd input gets the same answer from all three. Shape
463
+ * mismatch, a missing `ready`, a non-object body, even a throwing accessor ⇒ `undefined` — never throws. See {@link EngineSignal}
464
+ * for what `undefined` means (not "ready") and for why today's heartbeat and `/health` bodies read as `undefined` here.
380
465
  */
381
466
  export function readEngineSignal(health) {
382
467
  return readSignalBody(health, wholeBody, EngineSignal);
@@ -389,6 +474,18 @@ export function readEngineSignal(health) {
389
474
  * ({@link EngineSignal}`.instanceId`), and env cannot carry it (see there).
390
475
  */
391
476
  export const FLEET_GENERATION_ENV = "FLEET_GENERATION";
477
+ /**
478
+ * The env name a fleet member's tenant scope travels under — the config renderer sets it to the scope this member's per-worker
479
+ * token binds (the same value, taken from the same place the token is issued for), and the engine reads it as the scope of its
480
+ * OWN identity: the scope it compares a {@link DrainRequest}'s `scope` against, sends as {@link ServingLeaseRequest}`.scope`, and
481
+ * names on its heartbeat ({@link InstanceAnnouncement}'s `?scope=`). Both sides import THIS constant; neither spells the name.
482
+ * One fact, one source: the scope is GIVEN by the renderer — the engine never derives it from the token's format (a token is
483
+ * opaque to it, and a format guess would be a second source that can disagree with the scope the center binds). Absent from
484
+ * the engine's env ⇒ the default scope, {@link GLOBAL_SCOPE} (`api/scopes`) — the same default the center reads where nothing
485
+ * names a scope; this note does not restate the value. Like {@link FLEET_GENERATION_ENV} it is a reserved worker env name
486
+ * (`WorkerSpec.env` / `secretEnv` cannot set it).
487
+ */
488
+ export const FLEET_SCOPE_ENV = "FLEET_SCOPE";
392
489
  /**
393
490
  * The serving lease — the ONE fence between a superseded generation and the traffic it must stop taking. A fleet-member
394
491
  * engine renews it with the center; the center answers per the lifecycle object it holds for that instance. Three constants
@@ -419,8 +516,11 @@ export function clampServingLeaseTtlMs(configuredMs) {
419
516
  * Engine → center: "may this instance keep serving?" The identity triple names the instance exactly — a lease is never
420
517
  * asked for by worker name alone. `generation` is the env echo ({@link FLEET_GENERATION_ENV}); `instanceId` is the engine's
421
518
  * own per-process identity ({@link EngineSignal}`.instanceId`), so a restarted process is a NEW instance to the center.
422
- * `scope` is the tenant scope the worker lives in (absent = the single-scope deployment). Only fleet members ask: an engine
423
- * without a generation (the standalone form) has no lease and sends nothing.
519
+ * `scope` — the tenant scope the lease is asked in. A renewal is taken on a PER-WORKER token only — the credential binds
520
+ * (scope, worker) — so the credential decides the scope and this field can only agree: absent = the scope the token binds; present
521
+ * ⇒ it must equal that scope (a mismatch is refused, never rewritten either way). The shared token may not renew a lease at all
522
+ * (the center answers 403). Only fleet members ask: an engine without a generation (the standalone form) has no lease and sends
523
+ * nothing.
424
524
  */
425
525
  export const ServingLeaseRequest = z.object({
426
526
  worker: z.string().min(1),
@@ -497,11 +597,22 @@ export function readServingLeaseResponse(body) {
497
597
  * A reader asks `readCenterCapabilities(body)?.servingLease === true` and starts the lease client only on that.
498
598
  * Unknown bits are stripped (a newer center's extra bits never break an older reader); a known bit whose value is not `true`
499
599
  * makes the whole read `undefined`, which reads exactly as absent.
500
- * This package fixes the shape and the reader, not which center endpoint carries the object.
600
+ * It rides in ONE place: the heartbeat's 2xx reply ({@link FLEET_PATHS}`.instances`), under {@link HEARTBEAT_REPLY_CAPABILITIES_KEY}.
601
+ * The center answers the bits per CALLER CREDENTIAL: under a per-worker token, by what the center supports; under the shared token
602
+ * `servingLease` is always absent (the serving-lease endpoint takes per-worker tokens only, so announcing it there would only push
603
+ * that caller into the unreachable arm).
501
604
  */
502
605
  export const CenterCapabilities = z.object({
503
606
  servingLease: z.literal(true).optional(),
504
607
  });
608
+ /**
609
+ * The key of the heartbeat's 2xx reply body under which the center puts its {@link CenterCapabilities} — the object's only carrier.
610
+ * The center writes `{ …, [HEARTBEAT_REPLY_CAPABILITIES_KEY]: caps }`; the engine reads
611
+ * `readCenterCapabilities(reply[HEARTBEAT_REPLY_CAPABILITIES_KEY])`, and only on a 2xx (a failed heartbeat says nothing about the
612
+ * bits). Nested, not flat: the reply's own keys and the capability bits never share a namespace. A reply without the key is an
613
+ * older center — no capability said.
614
+ */
615
+ export const HEARTBEAT_REPLY_CAPABILITIES_KEY = "capabilities";
505
616
  /** Engine side: the center's capability object. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws
506
617
  * (and `undefined` means "no capability said" — the same as every bit absent). */
507
618
  export function readCenterCapabilities(body) {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The ONE rendering of a zod issue into a sentence that may leave in a report which promises to name fields, never values (the
3
+ * workers migration's `refused[].reason`, the `migrate-workers` CLI report). Internal — not on any package subpath.
4
+ *
5
+ * zod's own messages name the expected shape, not the input, with one exception: the default `invalid_enum_value` message ends in
6
+ * `, received '<the value>'`. That issue is re-worded from its options alone (the closed set it expected); every other issue keeps
7
+ * its message as is. The result still goes through the usual secret redaction where the caller applies it.
8
+ */
9
+ import type { z } from "zod";
10
+ export declare function issueText(issue: z.ZodIssue): string;
@@ -0,0 +1,8 @@
1
+ export function issueText(issue) {
2
+ switch (issue.code) {
3
+ case "invalid_enum_value":
4
+ return `Invalid enum value. Expected ${issue.options.map((o) => `'${String(o)}'`).join(" | ")}`;
5
+ default:
6
+ return issue.message;
7
+ }
8
+ }