@sema-agent/settings-schema 6.0.0 → 6.2.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/CHANGELOG.md +278 -0
- package/README.md +62 -5
- package/conformance/VERSION +1 -1
- package/conformance/cases/c01-entry-salvage-then-typo/expected.json +3 -0
- package/conformance/cases/c02-hosts-clamp-stacked/expected.json +3 -0
- package/conformance/cases/c23-mixed-validity-models/expected.json +3 -0
- package/conformance/cases/c40-s712-identity-only-what-reads-out/expected.json +166 -0
- package/conformance/cases/c40-s712-identity-only-what-reads-out/input.json +64 -0
- package/conformance/cases/c40-s712-identity-only-what-reads-out/meta.json +13 -0
- package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +8 -0
- package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +8 -0
- package/conformance/cases/t04-secret-literal-never-in-warning-value/expected.json +4 -0
- package/dist/api/auth-bridge.d.ts +2 -2
- package/dist/center-credential.d.ts +116 -0
- package/dist/center-credential.js +149 -0
- package/dist/config-fns.d.ts +52 -3
- package/dist/config-fns.js +78 -8
- package/dist/conformance/types.d.ts +6 -1
- package/dist/conformance/warnings.js +9 -2
- package/dist/fleet.d.ts +260 -8
- package/dist/fleet.js +167 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +72 -66
- package/dist/types.js +7 -1
- package/package.json +2 -2
package/dist/fleet.js
CHANGED
|
@@ -40,6 +40,151 @@ export function drainVerdict(s) {
|
|
|
40
40
|
const grace = Math.max(DRAIN_GRACE_FLOOR_MS, s.graceMs);
|
|
41
41
|
return s.nowMs - s.startedMs >= grace ? "exit_forced" : "wait";
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* "This instance must restart to apply config" — the engine's OWN observation (S-741), defined ONCE for both exits that
|
|
45
|
+
* carry it: the fleet heartbeat ({@link InstanceAnnouncement}`.restart`, typed) and GET /health (`restart`, read through
|
|
46
|
+
* {@link readRestartSignal} — {@link InstanceHealth} keeps carrying it untyped, see there). One mint point, two exits —
|
|
47
|
+
* never a second judgement.
|
|
48
|
+
*
|
|
49
|
+
* Shape = what the server already ships on /health (`restart: { reasons, version, since }`). PRESENCE is the signal:
|
|
50
|
+
* present ⇔ a restart is required, absent ⇔ not; there is deliberately no `required` boolean inside (a boolean next
|
|
51
|
+
* to presence is redundant, and two spellings of one fact drift).
|
|
52
|
+
* - `reasons` — which restart-to-apply slices differ from the value baked at boot (the vocabulary is the engine's;
|
|
53
|
+
* the contract only guarantees non-empty words, at least one).
|
|
54
|
+
* - `version` — the config-center version that currently differs from boot (the LATEST seen — it moves forward with
|
|
55
|
+
* later publishes, so a "one restart per version" dedupe bounds restarts per config version, not per reason set).
|
|
56
|
+
* Judged exactly as `readEffectiveWire` judges a config version (a finite number) — one quantity, one rule.
|
|
57
|
+
* - `since` — epoch ms when this restart first became required (stable across refreshes while `reasons` holds).
|
|
58
|
+
*/
|
|
59
|
+
export const RestartSignal = z.object({
|
|
60
|
+
reasons: z.array(z.string().min(1)).min(1),
|
|
61
|
+
version: z.number().finite(),
|
|
62
|
+
since: z.number().int().nonnegative(),
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* The ONE /health reader behind every engine-owned signal ({@link readRestartSignal}, {@link readModelRefusals}): `health[key]`
|
|
66
|
+
* judged against that signal's one shape. Shape mismatch, a missing key, a non-object body, even a throwing accessor ⇒
|
|
67
|
+
* `undefined` — never throws (a probe must not die on one odd body).
|
|
68
|
+
*/
|
|
69
|
+
function readHealthSignal(health, key, schema) {
|
|
70
|
+
try {
|
|
71
|
+
if (health === null || typeof health !== "object" || Array.isArray(health))
|
|
72
|
+
return undefined;
|
|
73
|
+
const parsed = schema.safeParse(health[key]);
|
|
74
|
+
return parsed.success ? parsed.data : undefined;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The /health exit of {@link RestartSignal}: `health.restart` judged against the one shape. Shape mismatch, a missing
|
|
82
|
+
* key, a non-object body, even a throwing accessor ⇒ `undefined` — never throws (a probe must not die on one odd body).
|
|
83
|
+
* Presence law: only `restart` counts; the top-level `restartRequired` boolean the server also ships is not read.
|
|
84
|
+
*/
|
|
85
|
+
export function readRestartSignal(health) {
|
|
86
|
+
return readHealthSignal(health, "restart", RestartSignal);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The model-refusal cause codes (S-768) — CLOSED sets, owned HERE, never a hand-copied second list. Adding a code is
|
|
90
|
+
* one edit in this file; who goes red at compile time: a reader that switches on the code exhaustively (a center), and
|
|
91
|
+
* a producer only where it holds a table KEYED BY the code (an exhaustive `switch` over the code, or a
|
|
92
|
+
* `Record<ModelEntryRefusalCode, …>`) — a producer table that merely `satisfies` these codes as values proves a subset,
|
|
93
|
+
* not equality, and stays green.
|
|
94
|
+
*
|
|
95
|
+
* - {@link MODEL_ENTRY_REFUSAL_CODES} — why ONE catalog entry is not served on this host (the other entries keep serving):
|
|
96
|
+
* `config.model_no_brain` — the entry's provider rides a brain this host has not mounted;
|
|
97
|
+
* `config.model_entry_invalid` — the entry does not match the `ModelEntry` shape (dropped by the read, or re-judged at
|
|
98
|
+
* the engine's own second boundary).
|
|
99
|
+
* - {@link MODEL_PLANE_REFUSAL_CODES} — why the model plane of the catalog the engine was handed was refused AS A GROUP
|
|
100
|
+
* (the engine keeps serving the model plane it had). This set mirrors the engine's group-refusal code TABLE as a
|
|
101
|
+
* whole family — never picked by which invariant is reachable on the hot path today (reachability moves, the table
|
|
102
|
+
* does not):
|
|
103
|
+
* `config.default_model_missing` — the candidate has no default model;
|
|
104
|
+
* `config.model_refused_name_minted` — a refused entry's name would be re-minted onto another entry by the
|
|
105
|
+
* tier / alias expansion (or the dropped-entry carrier cannot prove it would not be);
|
|
106
|
+
* `config.model_route_endpoint_missing` — an openai-compatible route of the candidate resolves to no endpoint;
|
|
107
|
+
* `config.gateway_fallback_without_primary` — fallback gateways are declared without a primary.
|
|
108
|
+
* Source (engine): the first two in `src/model-entry-refusal.ts`, the last two in `src/model-route-endpoint.ts:40,42`
|
|
109
|
+
* (invariant `model-route-endpoint`, judged by the model-plane stage in `src/config-center/apply-effective.ts:664-665`).
|
|
110
|
+
*/
|
|
111
|
+
export const MODEL_ENTRY_REFUSAL_CODES = ["config.model_no_brain", "config.model_entry_invalid"];
|
|
112
|
+
export const ModelEntryRefusalCode = z.enum(MODEL_ENTRY_REFUSAL_CODES);
|
|
113
|
+
export const MODEL_PLANE_REFUSAL_CODES = [
|
|
114
|
+
"config.default_model_missing",
|
|
115
|
+
"config.model_refused_name_minted",
|
|
116
|
+
"config.model_route_endpoint_missing",
|
|
117
|
+
"config.gateway_fallback_without_primary",
|
|
118
|
+
];
|
|
119
|
+
export const ModelPlaneRefusalCode = z.enum(MODEL_PLANE_REFUSAL_CODES);
|
|
120
|
+
/**
|
|
121
|
+
* "This instance does not serve part (or all) of the model catalog it was handed" — the engine's OWN per-host verdict
|
|
122
|
+
* (S-768), defined ONCE for both exits that carry it: the fleet heartbeat ({@link InstanceAnnouncement}`.modelRefusals`)
|
|
123
|
+
* and GET /health (`modelRefusals`; {@link InstanceHealth} keeps carrying it untyped, as it does `restart`). One mint point,
|
|
124
|
+
* two exits, ONE reader for both: {@link readModelRefusals}.
|
|
125
|
+
*
|
|
126
|
+
* Two faces, split on purpose — the failure direction must match the meaning (this is a DIAGNOSTIC key; an unknown word
|
|
127
|
+
* in it must never make a healthy instance unroutable):
|
|
128
|
+
* - {@link ModelRefusalsWire} — what rides the wire and what a stored heartbeat row holds: the SHAPE law only, codes as
|
|
129
|
+
* open non-empty strings. A code this package version does not know passes (it is "not said" to this reader, and the
|
|
130
|
+
* stored row still carries it for a reader that is upgraded later).
|
|
131
|
+
* - {@link ModelRefusalsSignal} — what {@link readModelRefusals} returns: the wire narrowed to the closed sets above (an
|
|
132
|
+
* unknown entry code or plane code is dropped; narrowed to nothing ⇒ `undefined`, rendered like "not said"). The
|
|
133
|
+
* closed-set types stay the compile-time red source for readers when a code is added.
|
|
134
|
+
*
|
|
135
|
+
* The wire shape:
|
|
136
|
+
* - `entries` — catalog entry NAME → its code. The value is ONLY the code, on purpose: the engine's refusal fact also
|
|
137
|
+
* holds the upstream `id`, the `provider` word, the bad field keys and an operator sentence, and none of them rides
|
|
138
|
+
* here — an `entry_invalid` row is by definition one nothing validated, so its `id` / `provider` may be whatever the
|
|
139
|
+
* broken row held, and the sentence is prose, not contract. "Which name, which reason" is the whole wire. A name that
|
|
140
|
+
* cannot ride out is treated as NAMELESS — the engine leaves it out of this map entirely (not keyed, not counted). The
|
|
141
|
+
* ONE predicate is `isReportableRefusalName(name)` (main barrel): this schema can carry the key (non-empty; `__proto__`
|
|
142
|
+
* is silently dropped by the record, so it cannot) AND `isDisclosableIdentity(name)` (not secret-shaped — the rule the
|
|
143
|
+
* dropped-entry identity on read warnings already uses). A producer calls it before keying; this schema deliberately
|
|
144
|
+
* does not re-judge secrecy (see the 6.2.0 notes).
|
|
145
|
+
* - `plane` — present ⇔ the model plane of the latest catalog the engine was handed was refused as a group (the engine
|
|
146
|
+
* keeps serving the model plane it had); ONE code, never a list — one candidate has exactly one group verdict (the
|
|
147
|
+
* engine judges the group invariants in a fixed order and stops at the first that fails; a refusal from the Runner
|
|
148
|
+
* swap step carries no code and is outside this signal).
|
|
149
|
+
* - `entries` and `plane` describe the same latest candidate catalog — the one the engine was handed last; with `plane`
|
|
150
|
+
* present, that is the refused candidate (its per-entry verdicts are usually why it has no default). The engine does
|
|
151
|
+
* not swap its own refusal table while the group stays refused (its selection channels still refuse by the previous
|
|
152
|
+
* generation), so with `plane` present read `entries` as "why this catalog cannot land", not "what a pick is refused
|
|
153
|
+
* right now".
|
|
154
|
+
*
|
|
155
|
+
* PRESENCE is the signal (same law as {@link RestartSignal}): present ⇔ at least one reportable refusal (an entry refusal
|
|
156
|
+
* under a name that rides out, or a group refusal). An empty signal (no entries and no `plane`) is refused by the shape
|
|
157
|
+
* law — "none" has exactly one spelling, the absent key. Inside, "no entry refusals" is spelled `entries: {}` (the key is
|
|
158
|
+
* required) — one spelling at each level.
|
|
159
|
+
*/
|
|
160
|
+
export const ModelRefusalsWire = z
|
|
161
|
+
.object({
|
|
162
|
+
entries: z.record(z.string().min(1), z.string().min(1)),
|
|
163
|
+
plane: z.string().min(1).optional(),
|
|
164
|
+
})
|
|
165
|
+
.refine((s) => Object.keys(s.entries).length > 0 || s.plane !== undefined, {
|
|
166
|
+
message: "an empty model-refusals signal is not a signal — omit the key instead (absent ⇔ no reportable refusals)",
|
|
167
|
+
});
|
|
168
|
+
const isModelEntryRefusalCode = (c) => MODEL_ENTRY_REFUSAL_CODES.includes(c);
|
|
169
|
+
const isModelPlaneRefusalCode = (c) => MODEL_PLANE_REFUSAL_CODES.includes(c);
|
|
170
|
+
/**
|
|
171
|
+
* The ONE reader of the model-refusals signal, for BOTH exits: pass a /health body or a parsed
|
|
172
|
+
* {@link InstanceAnnouncement} — it reads the `modelRefusals` key of whatever it is given. Shape mismatch, a missing key, a
|
|
173
|
+
* non-object body, even a throwing accessor ⇒ `undefined` — never throws. Then it narrows to the closed sets: an unknown
|
|
174
|
+
* entry code drops that entry, an unknown plane code drops `plane`; narrowed to nothing ⇒ `undefined`. So `undefined`
|
|
175
|
+
* means "nothing this reader can say"; whether that is "none" or "unknown" depends on whether the engine reports this key
|
|
176
|
+
* at all — it answers the key only and never judges the engine's version (see {@link InstanceAnnouncement}`.modelRefusals`).
|
|
177
|
+
*/
|
|
178
|
+
export function readModelRefusals(body) {
|
|
179
|
+
const wire = readHealthSignal(body, "modelRefusals", ModelRefusalsWire);
|
|
180
|
+
if (wire === undefined)
|
|
181
|
+
return undefined;
|
|
182
|
+
const entries = Object.fromEntries(Object.entries(wire.entries).filter((e) => isModelEntryRefusalCode(e[1])));
|
|
183
|
+
const plane = wire.plane !== undefined && isModelPlaneRefusalCode(wire.plane) ? wire.plane : undefined;
|
|
184
|
+
if (Object.keys(entries).length === 0 && plane === undefined)
|
|
185
|
+
return undefined;
|
|
186
|
+
return plane === undefined ? { entries } : { entries, plane };
|
|
187
|
+
}
|
|
43
188
|
/** The `/health` self-description every orchestrator reads (shipped shape, server 1.96.0). Liveness and
|
|
44
189
|
* readiness are deliberately SPLIT: `status` stays ok while draining (the process is alive); routability is
|
|
45
190
|
* `isReadyForTraffic`. Unknown fields pass through — health is a self-description, not a closed record. */
|
|
@@ -52,6 +197,11 @@ export const InstanceHealth = z
|
|
|
52
197
|
draining: z.boolean().optional(),
|
|
53
198
|
/** epoch ms, present iff draining */
|
|
54
199
|
drainingSince: z.number().optional(),
|
|
200
|
+
// S-741: the server's `restart` (and its redundant top-level `restartRequired` boolean) ride the passthrough
|
|
201
|
+
// UNTYPED on purpose — typing `restart` here would turn today's lenient read into a strict one (a /health body with
|
|
202
|
+
// an odd `restart` would stop parsing), which is a MAJOR by this package's release rules. Read it with
|
|
203
|
+
// {@link readRestartSignal}. Typing it on this schema is an item for the next major.
|
|
204
|
+
// S-768: `modelRefusals` rides the passthrough untyped for the same reason — read it with {@link readModelRefusals}.
|
|
55
205
|
})
|
|
56
206
|
.passthrough();
|
|
57
207
|
/** Readiness = the routability predicate (k8s readiness probe, gateway health filter, shell seam switch). */
|
|
@@ -76,10 +226,26 @@ export const InstanceAnnouncement = z.object({
|
|
|
76
226
|
/** reachable base URL for the deployment form: k8s pod IP:port / docker net alias / 127.0.0.1:<engine.port> */
|
|
77
227
|
address: z.string().min(1),
|
|
78
228
|
version: z.string().min(1),
|
|
79
|
-
/** self-declared:
|
|
229
|
+
/** self-declared readiness: model roster landed AND not draining (server ≥7.102.0; older engines: !draining only) */
|
|
80
230
|
ready: z.boolean(),
|
|
81
231
|
draining: z.boolean().default(false),
|
|
82
232
|
heartbeatMs: z.number().int().positive().default(DEFAULT_HEARTBEAT_MS),
|
|
233
|
+
/** S-741: present ⇔ this instance must restart to apply config — the SAME {@link RestartSignal} /health carries,
|
|
234
|
+
* minted by the same getter (self-reported: a worker-scoped credential can only report for its own worker).
|
|
235
|
+
* Every heartbeat re-announces the full row, so a later heartbeat WITHOUT it means "no longer required" — the
|
|
236
|
+
* stored row ({@link InstanceRegistration}) is replaced, never merged. */
|
|
237
|
+
restart: RestartSignal.optional(),
|
|
238
|
+
/** S-768: present ⇔ this instance has at least one reportable model refusal — the SAME {@link ModelRefusalsWire} /health
|
|
239
|
+
* carries, minted by the same getter; read it with {@link readModelRefusals}`(announcement)` (never off this field
|
|
240
|
+
* directly: the field is the open wire form, the reader narrows to the closed sets). Every heartbeat re-announces the
|
|
241
|
+
* full row, so a later heartbeat WITHOUT it means "no reportable refusals any more" (the stored row is replaced, never
|
|
242
|
+
* merged). Reading absence: from an engine that reports this key, absent = none reportable; an engine that predates
|
|
243
|
+
* the key never sends it, so there absence is UNKNOWN, not "none" — tell the two apart by the announced `version`.
|
|
244
|
+
* Failure direction = meaning direction (this is a diagnostic key): a code this package version does not know passes
|
|
245
|
+
* (it is "not said" to the reader), and a value that breaks the {@link ModelRefusalsWire} shape law is dropped to
|
|
246
|
+
* ABSENT (`.catch`) — it never fails the announcement, so it can never make a healthy instance unroutable (unlike
|
|
247
|
+
* `restart`, whose shape still gates the row). The key may then be present with the value `undefined`. */
|
|
248
|
+
modelRefusals: ModelRefusalsWire.optional().catch(undefined),
|
|
83
249
|
});
|
|
84
250
|
/** Explicit goodbye (stop signal handler) — removes the row immediately instead of waiting out staleness. */
|
|
85
251
|
export const InstanceDeregistration = z.object({
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export * from "./migrate.js"; // 0.5→0.6 migration tools (hosts grandfathering
|
|
|
21
21
|
export * from "./api/auth.js"; // /api/v1/auth/* wire contract (RFC 8628 device flow + rotating refresh + logout + approve) — REGISTRY-REBUILD-DESIGN §2, M1 shipped shapes frozen, pure/browser-safe
|
|
22
22
|
export * from "./api/auth-bridge.js"; // 鉴权桥 contract: worker verifies registry user JWT (RS256-only, JWKS kid-miss refresh + throttle, offline grace fail-closed) → VerifiedIdentity — design §5, the service verifier's spec
|
|
23
23
|
export * from "./api/scopes.js"; // multi-tenant scope contract: /api/v1/scopes* + /api/v1/auth/scope wire shapes + THE minting/switch decision functions (resolveTokenScope / canSwitchScope / effectiveScopeRole / tokenScopeOf) — registry imports these, never re-implements
|
|
24
|
+
export * from "./center-credential.js"; // S-698 center-credential FILE contract: CenterCredentialEnvelope (user-session envelope, strict) + parseCenterCredentialFile (bare / envelope / unreadable — the one discrimination law) + S-742 CENTER_CREDENTIAL_ENV (the two declaration keys) + judgeCenterCredentialDeclaration (pick-exactly-one), pure/browser-safe
|
|
24
25
|
// NOTE: the Node-fs-backed helpers (FileConfigStore + the file editor) are NOT in this barrel — they pull
|
|
25
26
|
// `node:fs`/`node:path` and would break a browser/client bundle that only wants the contract (e.g.
|
|
26
27
|
// sema-registry's client components import the zod types through here). Import them from the Node-only
|