omp-conductor 0.3.25 → 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/README.md +733 -30
- package/package.json +1 -1
- package/src/board.ts +24 -5
- package/src/briefs/orchestrator.md +106 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +155 -2
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1000 -32
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +71 -3
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +42 -19
- package/src/orchestrator.ts +32 -5
- package/src/plugin.ts +267 -15
- package/src/release-policy.ts +191 -29
- package/src/reports.ts +440 -0
- package/src/session-host.ts +307 -0
- package/src/setup-host.ts +19 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +506 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +847 -10
- package/src/usage.ts +726 -0
- package/src/verbs/actions.ts +142 -0
- package/src/verbs/client.ts +207 -0
- package/src/verbs/ledger.ts +77 -0
- package/src/verbs/protocol.ts +465 -0
- package/src/verbs/server.ts +1098 -0
- package/src/verbs/socket.ts +446 -0
- package/src/worker.ts +36 -7
- package/src/worktree.ts +202 -109
- package/systemd/omp-conductor.service.example +96 -8
package/src/config.ts
CHANGED
|
@@ -17,21 +17,36 @@ import { homedir } from "node:os";
|
|
|
17
17
|
import { dirname, isAbsolute, join } from "node:path";
|
|
18
18
|
import {
|
|
19
19
|
AUTHORITY_HOLDERS,
|
|
20
|
+
BASE_FRESHNESS,
|
|
21
|
+
BEHIND_BASE_ACTIONS,
|
|
20
22
|
CONFIG_VERSION,
|
|
23
|
+
CREDENTIAL_ISOLATIONS,
|
|
21
24
|
DEFAULT_AUTHORITY,
|
|
22
25
|
DEFAULT_CAPS,
|
|
23
|
-
|
|
26
|
+
DEFAULT_PROJECT_POLICY,
|
|
24
27
|
DEFAULT_REPORT_SCOPE,
|
|
28
|
+
DENIED_RELEASE_GRANTS,
|
|
29
|
+
DRAFT_POLICIES,
|
|
30
|
+
LEGACY_RELEASE_POLICIES,
|
|
31
|
+
OPERATOR_BRIEF_GRANTS,
|
|
25
32
|
ORCHESTRATOR_MODES,
|
|
26
33
|
READABLE_CONFIG_VERSIONS,
|
|
27
|
-
|
|
34
|
+
RELEASE_REQUIREMENTS,
|
|
35
|
+
RELEASE_SHAPES,
|
|
28
36
|
REPORT_SCOPES,
|
|
29
37
|
type Caps,
|
|
30
38
|
type ConductorConfig,
|
|
39
|
+
type CredentialConfig,
|
|
40
|
+
type CredentialIsolation,
|
|
41
|
+
type MergePreconditions,
|
|
42
|
+
type PlanUsageCap,
|
|
31
43
|
type ProjectConfig,
|
|
44
|
+
type ProjectPolicy,
|
|
45
|
+
type ReleasePreconditions,
|
|
46
|
+
type ReleaseRequirement,
|
|
32
47
|
type ReportScope,
|
|
33
|
-
type ReleasePolicy,
|
|
34
48
|
type RepoTarget,
|
|
49
|
+
type ResolvedGrants,
|
|
35
50
|
} from "./types.ts";
|
|
36
51
|
|
|
37
52
|
/**
|
|
@@ -53,6 +68,13 @@ const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
|
|
|
53
68
|
const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
|
|
54
69
|
const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
|
|
55
70
|
const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
|
|
71
|
+
const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
|
|
72
|
+
const LEGACY_RELEASE_POLICY_LIST = quoteList(LEGACY_RELEASE_POLICIES);
|
|
73
|
+
const CREDENTIAL_ISOLATION_LIST = quoteList(CREDENTIAL_ISOLATIONS);
|
|
74
|
+
const BASE_FRESHNESS_LIST = quoteList(BASE_FRESHNESS);
|
|
75
|
+
const DRAFT_POLICY_LIST = quoteList(DRAFT_POLICIES);
|
|
76
|
+
const BEHIND_BASE_ACTION_LIST = quoteList(BEHIND_BASE_ACTIONS);
|
|
77
|
+
const RELEASE_REQUIREMENT_LIST = quoteList(RELEASE_REQUIREMENTS);
|
|
56
78
|
|
|
57
79
|
/** `owner/repo`, the only tracker spelling `gh` accepts without a host. */
|
|
58
80
|
const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
@@ -85,6 +107,82 @@ export function stateDir(): string {
|
|
|
85
107
|
return dirname(configPath());
|
|
86
108
|
}
|
|
87
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Root of everything a run principal must reach: worktrees, mirrors, per-run
|
|
112
|
+
* session transcripts and per-run boundary homes.
|
|
113
|
+
*
|
|
114
|
+
* **Outside the daemon's home, not a sibling of the state directory**, and both
|
|
115
|
+
* halves of that are load-bearing:
|
|
116
|
+
*
|
|
117
|
+
* - It cannot live *inside* {@link stateDir}, which stays `0700` because it
|
|
118
|
+
* holds `config.json`, `conductor.db` and the WAL/SHM files SQLite recreates
|
|
119
|
+
* at runtime with the process umask. Making it searchable — all a slot needs
|
|
120
|
+
* — would publish fleet, run and report history to every local account, and
|
|
121
|
+
* chmod-ing the leaves once would not hold.
|
|
122
|
+
* - It cannot live under the daemon's **home** either, which the boundary
|
|
123
|
+
* requires to be `0700` so a slot cannot reach `gh` config, `~/.ssh` or
|
|
124
|
+
* `~/.npmrc`. A shared root at `~/.omp/conductor-shared` is unreachable for
|
|
125
|
+
* exactly the same reason, and the two hardening steps would silently cancel
|
|
126
|
+
* each other out — the boundary would look correct and no run could start.
|
|
127
|
+
*
|
|
128
|
+
* So it is a system path: `0711` — traversable by a slot that already knows its
|
|
129
|
+
* own path, listable by nobody, writable by nobody but the daemon. Only used
|
|
130
|
+
* when a per-run boundary is active; an `isolation: "none"` fleet keeps every
|
|
131
|
+
* path exactly where it was (#125).
|
|
132
|
+
*
|
|
133
|
+
* `$OMP_CONDUCTOR_SHARED` overrides it, which is how tests and non-FHS hosts
|
|
134
|
+
* relocate it. Uninstall is two paths instead of one, and the README says so.
|
|
135
|
+
*/
|
|
136
|
+
export function sharedRoot(): string {
|
|
137
|
+
const override = process.env["OMP_CONDUCTOR_SHARED"];
|
|
138
|
+
if (override !== undefined && override.length > 0) return expandHome(override);
|
|
139
|
+
// Deliberately NOT derived from whether `$OMP_CONDUCTOR_HOME` happens to be
|
|
140
|
+
// set: generated systemd units set it even for a default install, so keying
|
|
141
|
+
// the security topology off it lets the CLI, setup and the daemon each pick a
|
|
142
|
+
// different root depending on how they were invoked. One explicit override,
|
|
143
|
+
// or the platform default.
|
|
144
|
+
// Not `/tmp`: a shared root a slot could rename or replace would hand one run
|
|
145
|
+
// control of another's checkout path.
|
|
146
|
+
return process.platform === "linux" ? "/var/lib/omp-conductor" : `${stateDir()}-shared`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Where per-run worktrees and bare mirrors live when a project names neither.
|
|
151
|
+
*
|
|
152
|
+
* Exported because two readers need the same answer: `normalizeProject` below,
|
|
153
|
+
* and the orchestrator jail (`confinement.ts`, #127), which must deny the
|
|
154
|
+
* worker checkouts even when the config was too broken to load and say where
|
|
155
|
+
* they are. Two copies of this default would mean a jail guarding the wrong
|
|
156
|
+
* directory on exactly the day the config is broken.
|
|
157
|
+
*
|
|
158
|
+
* `base` is what makes these usable under a per-run boundary. A slot principal
|
|
159
|
+
* cannot traverse the `0700` state directory, and dispatch now refuses rather
|
|
160
|
+
* than widening it — so an isolated fleet whose roots defaulted under the state
|
|
161
|
+
* dir would provision a repository and then fail before launching anything.
|
|
162
|
+
* Setup passes {@link sharedRoot} for those fleets and `stateDir()` for the
|
|
163
|
+
* rest, so an `isolation: "none"` install keeps every path exactly where it was
|
|
164
|
+
* (#125).
|
|
165
|
+
*/
|
|
166
|
+
export function defaultWorkspaceRoot(base: string = stateDir()): string {
|
|
167
|
+
return join(base, "worktrees");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function defaultMirrorRoot(base: string = stateDir()): string {
|
|
171
|
+
return join(base, "mirrors");
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The root a project's slot-accessible trees belong under, given its isolation.
|
|
175
|
+
*
|
|
176
|
+
* Only `per-run` moves. `group-mode` runs sessions as the daemon's *own* uid —
|
|
177
|
+
* it separates checkouts by group and mode, not by principal — so it can reach
|
|
178
|
+
* the state directory perfectly well, and sending it to a system path would
|
|
179
|
+
* demand privileged provisioning a group-mode host may deliberately not have.
|
|
180
|
+
* `none` never moves either.
|
|
181
|
+
*/
|
|
182
|
+
export function projectTreeBase(isolation: CredentialIsolation): string {
|
|
183
|
+
return isolation === "per-run" ? sharedRoot() : stateDir();
|
|
184
|
+
}
|
|
185
|
+
|
|
88
186
|
/**
|
|
89
187
|
* Reads, validates and normalises the config. Throws an `Error` naming the
|
|
90
188
|
* path and the fix; never returns a partially-shaped `ConductorConfig`.
|
|
@@ -149,6 +247,7 @@ export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
|
|
|
149
247
|
return {
|
|
150
248
|
maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
|
|
151
249
|
dailySpendUsd: o.dailySpendUsd !== undefined ? o.dailySpendUsd : defaults.dailySpendUsd,
|
|
250
|
+
planUsage: o.planUsage !== undefined ? o.planUsage : defaults.planUsage,
|
|
152
251
|
workerMaxTurns: o.workerMaxTurns ?? defaults.workerMaxTurns,
|
|
153
252
|
workerWallClockMs: o.workerWallClockMs ?? defaults.workerWallClockMs,
|
|
154
253
|
maxAttemptsPerIssue: o.maxAttemptsPerIssue ?? defaults.maxAttemptsPerIssue,
|
|
@@ -157,9 +256,83 @@ export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
|
|
|
157
256
|
};
|
|
158
257
|
}
|
|
159
258
|
|
|
160
|
-
/**
|
|
161
|
-
|
|
162
|
-
|
|
259
|
+
/**
|
|
260
|
+
* The effective grant for every shape. Old configs, an omitted key and a
|
|
261
|
+
* hand-written partial map are all fail-closed here: a shape the map does not
|
|
262
|
+
* name is denied, never inherited from a neighbour and never opened.
|
|
263
|
+
*
|
|
264
|
+
* `loadConfig` has already migrated what it read, so for a loaded project this
|
|
265
|
+
* is a copy. It stays because a `ProjectConfig` also reaches the gate from
|
|
266
|
+
* `buildProject` and from tests, and #122's point is that "absent means denied"
|
|
267
|
+
* has exactly one spelling in the package.
|
|
268
|
+
*/
|
|
269
|
+
export function resolveReleaseGrants(p: ProjectConfig): ResolvedGrants {
|
|
270
|
+
const policy = p.releasePolicy;
|
|
271
|
+
if (policy === undefined || policy === "none") return { ...DENIED_RELEASE_GRANTS };
|
|
272
|
+
|
|
273
|
+
const grants = { ...DENIED_RELEASE_GRANTS };
|
|
274
|
+
for (const shape of RELEASE_SHAPES) {
|
|
275
|
+
const holder = policy[shape];
|
|
276
|
+
if (holder !== undefined) grants[shape] = holder;
|
|
277
|
+
}
|
|
278
|
+
return grants;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The gating conditions for one project, complete (#129).
|
|
283
|
+
*
|
|
284
|
+
* The loader always writes a complete `policy`, so this is only ever the
|
|
285
|
+
* fallback for a `ProjectConfig` that never went through it — a hand-built one
|
|
286
|
+
* in a test, or a config written before the key existed. Spelled once here so a
|
|
287
|
+
* caller cannot invent a different answer for the absent case, which is exactly
|
|
288
|
+
* how "conditions live in typed config" degrades back into "conditions live
|
|
289
|
+
* wherever the reader looked".
|
|
290
|
+
*/
|
|
291
|
+
export function resolvePolicy(p: ProjectConfig): ProjectPolicy {
|
|
292
|
+
return clonePolicy(p.policy ?? DEFAULT_PROJECT_POLICY);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* A policy with no array shared with its source.
|
|
297
|
+
*
|
|
298
|
+
* {@link DEFAULT_PROJECT_POLICY} is one object for the whole process, and every
|
|
299
|
+
* consumer of a resolved policy holds something it may hand to the wizard and
|
|
300
|
+
* back. A shared `requiredChecks` array would let one project's amend push a
|
|
301
|
+
* check onto every project that had never answered the question.
|
|
302
|
+
*/
|
|
303
|
+
export function clonePolicy(policy: ProjectPolicy): ProjectPolicy {
|
|
304
|
+
return {
|
|
305
|
+
merge: { ...policy.merge, requiredChecks: [...policy.merge.requiredChecks] },
|
|
306
|
+
release: {
|
|
307
|
+
...policy.release,
|
|
308
|
+
requires: [...policy.release.requires],
|
|
309
|
+
requiredChecks: [...policy.release.requiredChecks],
|
|
310
|
+
artefacts: [...policy.release.artefacts],
|
|
311
|
+
environments: [...policy.release.environments],
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The runtime twin of {@link pickLiteral}, for a value a *model* supplied rather
|
|
318
|
+
* than a value an operator wrote (#129).
|
|
319
|
+
*
|
|
320
|
+
* Throws rather than returning a flag: a verb's `reason` is the field the ledger
|
|
321
|
+
* records the act under, and a caller that forgets to check a boolean has
|
|
322
|
+
* written an unexplained act into the audit trail instead of refusing one. The
|
|
323
|
+
* message names every accepted value, because the reader is a session that can
|
|
324
|
+
* only correct itself from what the refusal tells it — "invalid reason" costs a
|
|
325
|
+
* turn and teaches nothing.
|
|
326
|
+
*
|
|
327
|
+
* Free-form rationale is not this function's business. It rides beside the
|
|
328
|
+
* reason as a `rationale` field, is logged verbatim, and is never matched.
|
|
329
|
+
*/
|
|
330
|
+
export function requireReason<R extends string>(value: unknown, allowed: readonly R[], field: string): R {
|
|
331
|
+
const hit = allowed.find((a) => a === value);
|
|
332
|
+
if (hit === undefined) {
|
|
333
|
+
throw new Error(`${field} must be ${quoteList(allowed)}, found ${JSON.stringify(value)}`);
|
|
334
|
+
}
|
|
335
|
+
return hit;
|
|
163
336
|
}
|
|
164
337
|
|
|
165
338
|
/**
|
|
@@ -282,14 +455,9 @@ function normalizeProject(
|
|
|
282
455
|
|
|
283
456
|
const escalation = normalizeEscalation(raw["escalation"], label, problems);
|
|
284
457
|
const authority = normalizeAuthority(raw["authority"], label, problems);
|
|
285
|
-
const releasePolicy =
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
DEFAULT_RELEASE_POLICY,
|
|
289
|
-
`${label}: releasePolicy`,
|
|
290
|
-
RELEASE_POLICIES.map((value) => JSON.stringify(value)).join(" or "),
|
|
291
|
-
problems,
|
|
292
|
-
);
|
|
458
|
+
const releasePolicy = normalizeReleasePolicy(raw["releasePolicy"], label, problems);
|
|
459
|
+
const credentials = normalizeCredentials(raw["credentials"], label, problems);
|
|
460
|
+
const policy = normalizeProjectPolicy(raw["policy"], label, problems);
|
|
293
461
|
|
|
294
462
|
const caps = coerceCaps(raw["caps"], `${label}: caps`, problems, legacyCaps);
|
|
295
463
|
const reporting = normalizeReporting(raw["reporting"], label, problems);
|
|
@@ -298,6 +466,11 @@ function normalizeProject(
|
|
|
298
466
|
// (logged by `runWorker`) is what tells the operator the pattern missed.
|
|
299
467
|
const rawWorkerModel = raw["workerModel"];
|
|
300
468
|
const workerModel = nonEmptyString(rawWorkerModel) ? rawWorkerModel : undefined;
|
|
469
|
+
const orchestratorReadPaths = normalizeOrchestratorReadPaths(
|
|
470
|
+
raw["orchestratorReadPaths"],
|
|
471
|
+
label,
|
|
472
|
+
problems,
|
|
473
|
+
);
|
|
301
474
|
|
|
302
475
|
if (problems.length > before) return undefined;
|
|
303
476
|
|
|
@@ -316,9 +489,20 @@ function normalizeProject(
|
|
|
316
489
|
escalation,
|
|
317
490
|
authority,
|
|
318
491
|
releasePolicy,
|
|
492
|
+
policy,
|
|
493
|
+
credentials,
|
|
319
494
|
reporting,
|
|
320
|
-
|
|
321
|
-
|
|
495
|
+
...(orchestratorReadPaths === undefined ? {} : { orchestratorReadPaths }),
|
|
496
|
+
// Derived from this project's own isolation, not from `stateDir()`: a
|
|
497
|
+
// hand-written `per-run` config that omits these optional keys would
|
|
498
|
+
// otherwise resolve under the 0700 private tree and be refused at dispatch,
|
|
499
|
+
// having never gone through the setup wizard that knows better (#125).
|
|
500
|
+
workspaceRoot: expandHome(
|
|
501
|
+
pickString(raw["workspaceRoot"], defaultWorkspaceRoot(projectTreeBase(credentials.isolation))),
|
|
502
|
+
),
|
|
503
|
+
mirrorRoot: expandHome(
|
|
504
|
+
pickString(raw["mirrorRoot"], defaultMirrorRoot(projectTreeBase(credentials.isolation))),
|
|
505
|
+
),
|
|
322
506
|
};
|
|
323
507
|
}
|
|
324
508
|
|
|
@@ -432,6 +616,355 @@ function normalizeAuthority(parsed: unknown, label: string, problems: string[]):
|
|
|
432
616
|
};
|
|
433
617
|
}
|
|
434
618
|
|
|
619
|
+
/**
|
|
620
|
+
* Per-shape release grants, and the migration off the two legacy strings.
|
|
621
|
+
*
|
|
622
|
+
* Normalised to a *complete* map at load, so everything downstream — the gate,
|
|
623
|
+
* `status`, the standing orders, the wizard — reads the same five answers and
|
|
624
|
+
* none of them has to know which spelling was on disk.
|
|
625
|
+
*
|
|
626
|
+
* `"operator-brief"` migrates to every shape except `deploy` (#122). That is the
|
|
627
|
+
* safe reading of what an operator believed the binary gate opened: a stale
|
|
628
|
+
* `operator-brief` was enough for an orchestrator session to invoke Komodo
|
|
629
|
+
* `DeployStack`, which is the grant nobody knowingly gave.
|
|
630
|
+
*
|
|
631
|
+
* An unknown shape key is rejected rather than ignored, as in `authority` and
|
|
632
|
+
* for the same reason: the set is closed, so `{ "deploy-prod": "orchestrator" }`
|
|
633
|
+
* is a typo every time — and a config that loaded it cleanly would read as
|
|
634
|
+
* granted in the file while the gate denied every call.
|
|
635
|
+
*/
|
|
636
|
+
function normalizeReleasePolicy(parsed: unknown, label: string, problems: string[]): ResolvedGrants {
|
|
637
|
+
if (parsed === undefined || parsed === "none") return { ...DENIED_RELEASE_GRANTS };
|
|
638
|
+
if (parsed === "operator-brief") return { ...OPERATOR_BRIEF_GRANTS };
|
|
639
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
640
|
+
problems.push(
|
|
641
|
+
`${label}: releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ` +
|
|
642
|
+
`${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${JSON.stringify(parsed)}`,
|
|
643
|
+
);
|
|
644
|
+
return { ...DENIED_RELEASE_GRANTS };
|
|
645
|
+
}
|
|
646
|
+
const raw = parsed as Raw;
|
|
647
|
+
|
|
648
|
+
const unknownKeys = Object.keys(raw).filter((k) => !Object.hasOwn(DENIED_RELEASE_GRANTS, k));
|
|
649
|
+
if (unknownKeys.length > 0) {
|
|
650
|
+
problems.push(
|
|
651
|
+
`${label}: releasePolicy has unknown release shape(s): ${unknownKeys.join(", ")} — expected ${RELEASE_SHAPE_LIST}`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const grants = { ...DENIED_RELEASE_GRANTS };
|
|
656
|
+
for (const shape of RELEASE_SHAPES) {
|
|
657
|
+
// An absent shape stays denied; a present-but-malformed one is reported and
|
|
658
|
+
// also stays denied, never folded to the value the operator asked for.
|
|
659
|
+
grants[shape] = pickLiteral(
|
|
660
|
+
raw[shape],
|
|
661
|
+
AUTHORITY_HOLDERS,
|
|
662
|
+
"human",
|
|
663
|
+
`${label}: releasePolicy.${shape}`,
|
|
664
|
+
AUTHORITY_HOLDER_LIST,
|
|
665
|
+
problems,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
return grants;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* The merge and release gating conditions (#129).
|
|
673
|
+
*
|
|
674
|
+
* These used to be sentences in the operator's POLICY.md, which meant a verb
|
|
675
|
+
* could only honour them by asking a model to read prose. Typed and normalised
|
|
676
|
+
* here to a *complete* value, so the gate, the wizard, the plan summary and the
|
|
677
|
+
* brief all read the same answers and none of them has to know which fields
|
|
678
|
+
* were spelled on disk.
|
|
679
|
+
*
|
|
680
|
+
* Fail-closed throughout, as in `authority` and for the same reason: both
|
|
681
|
+
* sections have a closed key set, so an unrecognised key is a typo every time —
|
|
682
|
+
* and a `policy: { merge: { requiredCheck: [...] } }` that loaded cleanly would
|
|
683
|
+
* read as configured in the file while the gate went on requiring every check.
|
|
684
|
+
* A malformed value takes the documented default and is reported; it is never
|
|
685
|
+
* folded to whatever the operator asked for.
|
|
686
|
+
*/
|
|
687
|
+
function normalizeProjectPolicy(parsed: unknown, label: string, problems: string[]): ProjectPolicy {
|
|
688
|
+
if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY);
|
|
689
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
690
|
+
problems.push(`${label}: policy must be an object with "merge" and "release" sections`);
|
|
691
|
+
return clonePolicy(DEFAULT_PROJECT_POLICY);
|
|
692
|
+
}
|
|
693
|
+
const raw = parsed as Raw;
|
|
694
|
+
|
|
695
|
+
const unknownKeys = Object.keys(raw).filter((k) => k !== "merge" && k !== "release");
|
|
696
|
+
if (unknownKeys.length > 0) {
|
|
697
|
+
problems.push(`${label}: policy has unknown key(s): ${unknownKeys.join(", ")} — expected "merge" or "release"`);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return {
|
|
701
|
+
merge: normalizeMergePreconditions(raw["merge"], `${label}: policy.merge`, problems),
|
|
702
|
+
release: normalizeReleasePreconditions(raw["release"], `${label}: policy.release`, problems),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** When a pull request may be merged. Absent members take {@link DEFAULT_PROJECT_POLICY}. */
|
|
707
|
+
function normalizeMergePreconditions(parsed: unknown, at: string, problems: string[]): MergePreconditions {
|
|
708
|
+
const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).merge;
|
|
709
|
+
if (parsed === undefined) return fallback;
|
|
710
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
711
|
+
problems.push(`${at} must be an object`);
|
|
712
|
+
return fallback;
|
|
713
|
+
}
|
|
714
|
+
const raw = parsed as Raw;
|
|
715
|
+
|
|
716
|
+
const known = new Set(Object.keys(fallback));
|
|
717
|
+
const unknownKeys = Object.keys(raw).filter((k) => !known.has(k));
|
|
718
|
+
if (unknownKeys.length > 0) {
|
|
719
|
+
problems.push(`${at} has unknown key(s): ${unknownKeys.join(", ")} — expected ${quoteList([...known])}`);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return {
|
|
723
|
+
requiredChecks: normalizeNameList(raw["requiredChecks"], `${at}.requiredChecks`, problems),
|
|
724
|
+
baseFreshness: pickLiteral(
|
|
725
|
+
raw["baseFreshness"],
|
|
726
|
+
BASE_FRESHNESS,
|
|
727
|
+
fallback.baseFreshness,
|
|
728
|
+
`${at}.baseFreshness`,
|
|
729
|
+
BASE_FRESHNESS_LIST,
|
|
730
|
+
problems,
|
|
731
|
+
),
|
|
732
|
+
drafts: pickLiteral(raw["drafts"], DRAFT_POLICIES, fallback.drafts, `${at}.drafts`, DRAFT_POLICY_LIST, problems),
|
|
733
|
+
whenBehindBase: pickLiteral(
|
|
734
|
+
raw["whenBehindBase"],
|
|
735
|
+
BEHIND_BASE_ACTIONS,
|
|
736
|
+
fallback.whenBehindBase,
|
|
737
|
+
`${at}.whenBehindBase`,
|
|
738
|
+
BEHIND_BASE_ACTION_LIST,
|
|
739
|
+
problems,
|
|
740
|
+
),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** When a release may be cut, what it produces, and where it may go. */
|
|
745
|
+
function normalizeReleasePreconditions(parsed: unknown, at: string, problems: string[]): ReleasePreconditions {
|
|
746
|
+
const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).release;
|
|
747
|
+
if (parsed === undefined) return fallback;
|
|
748
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
749
|
+
problems.push(`${at} must be an object`);
|
|
750
|
+
return fallback;
|
|
751
|
+
}
|
|
752
|
+
const raw = parsed as Raw;
|
|
753
|
+
|
|
754
|
+
const known = new Set(Object.keys(fallback));
|
|
755
|
+
const unknownKeys = Object.keys(raw).filter((k) => !known.has(k));
|
|
756
|
+
if (unknownKeys.length > 0) {
|
|
757
|
+
problems.push(`${at} has unknown key(s): ${unknownKeys.join(", ")} — expected ${quoteList([...known])}`);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return {
|
|
761
|
+
requires: normalizeReleaseRequirements(raw["requires"], `${at}.requires`, fallback.requires, problems),
|
|
762
|
+
requiredChecks: normalizeNameList(raw["requiredChecks"], `${at}.requiredChecks`, problems),
|
|
763
|
+
artefacts: normalizeNameList(raw["artefacts"], `${at}.artefacts`, problems),
|
|
764
|
+
environments: normalizeNameList(raw["environments"], `${at}.environments`, problems),
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* The `requires` set, in the vocabulary's own order rather than the file's.
|
|
770
|
+
*
|
|
771
|
+
* Canonical order and de-duplication because this list is rendered into a
|
|
772
|
+
* refusal and into the plan summary: two configs that require the same three
|
|
773
|
+
* things must read identically, or an operator diffing them sees a change that
|
|
774
|
+
* is not one.
|
|
775
|
+
*/
|
|
776
|
+
function normalizeReleaseRequirements(
|
|
777
|
+
parsed: unknown,
|
|
778
|
+
at: string,
|
|
779
|
+
fallback: readonly ReleaseRequirement[],
|
|
780
|
+
problems: string[],
|
|
781
|
+
): ReleaseRequirement[] {
|
|
782
|
+
if (parsed === undefined) return [...fallback];
|
|
783
|
+
if (!Array.isArray(parsed)) {
|
|
784
|
+
problems.push(`${at} must be an array of ${RELEASE_REQUIREMENT_LIST}`);
|
|
785
|
+
return [...fallback];
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const chosen = new Set<string>();
|
|
789
|
+
parsed.forEach((entry: unknown, i) => {
|
|
790
|
+
if (!RELEASE_REQUIREMENTS.some((r) => r === entry)) {
|
|
791
|
+
problems.push(`${at}[${i}] must be ${RELEASE_REQUIREMENT_LIST}, found ${JSON.stringify(entry)}`);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
chosen.add(entry as ReleaseRequirement);
|
|
795
|
+
});
|
|
796
|
+
return RELEASE_REQUIREMENTS.filter((r) => chosen.has(r));
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* A list of names an operator wrote: check names, artefacts, environments.
|
|
801
|
+
*
|
|
802
|
+
* Open-ended by necessity — this package cannot know what a fleet publishes —
|
|
803
|
+
* but a malformed entry still rejects the whole config rather than being
|
|
804
|
+
* dropped. A silently dropped environment name is an operator debugging a
|
|
805
|
+
* refusal that reads exactly like a correctly-denied one, which is the same
|
|
806
|
+
* trap `orchestratorReadPaths` documents.
|
|
807
|
+
*/
|
|
808
|
+
function normalizeNameList(parsed: unknown, at: string, problems: string[]): string[] {
|
|
809
|
+
if (parsed === undefined) return [];
|
|
810
|
+
if (!Array.isArray(parsed)) {
|
|
811
|
+
problems.push(`${at} must be an array of non-empty strings`);
|
|
812
|
+
return [];
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const names: string[] = [];
|
|
816
|
+
parsed.forEach((entry: unknown, i) => {
|
|
817
|
+
if (!nonEmptyString(entry)) {
|
|
818
|
+
problems.push(`${at}[${i}] must be a non-empty string, found ${JSON.stringify(entry)}`);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
names.push(entry.trim());
|
|
822
|
+
});
|
|
823
|
+
return names;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* The credential boundary, and the one migration that must never brick a fleet.
|
|
829
|
+
*
|
|
830
|
+
* `omp/systemd/omp-conductor.service.example` runs the daemon as an
|
|
831
|
+
* unprivileged account. A naive "fail closed unless per-run uids are available"
|
|
832
|
+
* release would therefore stop every *existing* fleet from dispatching on
|
|
833
|
+
* upgrade — a production outage caused by a security feature, which is a worse
|
|
834
|
+
* outcome than the day before. The issue's rule ("never the silent default")
|
|
835
|
+
* still holds; it is satisfied by a **migration** rather than by a default:
|
|
836
|
+
*
|
|
837
|
+
* 1. Key present → honoured exactly.
|
|
838
|
+
* 2. Key absent → resolves to `"none"`, and `migrateCredentialsOnDisk` rewrites
|
|
839
|
+
* the file so the answer is explicit on disk, exactly the way the v1→v2 caps
|
|
840
|
+
* migration normalises on load. The operator ends up with
|
|
841
|
+
* `"isolation": "none"` in their config plus one log line naming it, and
|
|
842
|
+
* nothing stops dispatching.
|
|
843
|
+
* 3. A new config from `setup` asks, and only offers `per-run` as the default
|
|
844
|
+
* when the host capability probe passes.
|
|
845
|
+
*
|
|
846
|
+
* A malformed value is rejected rather than folded to `"none"`, for the reason
|
|
847
|
+
* every other vocabulary here is: a misspelt `"per_run"` that silently resolved
|
|
848
|
+
* to unprotected would read as configured in the file while the fleet ran open.
|
|
849
|
+
*/
|
|
850
|
+
function normalizeCredentials(parsed: unknown, label: string, problems: string[]): CredentialConfig {
|
|
851
|
+
if (parsed === undefined) return { isolation: "none" };
|
|
852
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
853
|
+
problems.push(
|
|
854
|
+
`${label}: credentials must be an object with an "isolation" of ${CREDENTIAL_ISOLATION_LIST}, ` +
|
|
855
|
+
`found ${JSON.stringify(parsed)}`,
|
|
856
|
+
);
|
|
857
|
+
return { isolation: "none" };
|
|
858
|
+
}
|
|
859
|
+
const raw = parsed as Raw;
|
|
860
|
+
const isolation = pickLiteral(
|
|
861
|
+
raw["isolation"],
|
|
862
|
+
CREDENTIAL_ISOLATIONS,
|
|
863
|
+
"none",
|
|
864
|
+
`${label}: credentials.isolation`,
|
|
865
|
+
CREDENTIAL_ISOLATION_LIST,
|
|
866
|
+
problems,
|
|
867
|
+
);
|
|
868
|
+
const rawToken = raw["readToken"];
|
|
869
|
+
if (rawToken !== undefined && !nonEmptyString(rawToken)) {
|
|
870
|
+
problems.push(
|
|
871
|
+
`${label}: credentials.readToken must be a non-empty string when present — an empty token is not ` +
|
|
872
|
+
`"no token", it is a token that fails every call`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
return {
|
|
876
|
+
isolation,
|
|
877
|
+
...(nonEmptyString(rawToken) ? { readToken: rawToken } : {}),
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* The effective boundary for a project. `loadConfig` always writes one, so this
|
|
883
|
+
* only matters for a `ProjectConfig` some caller hand-built — and it resolves
|
|
884
|
+
* the same way the loader does rather than inventing a second answer.
|
|
885
|
+
*/
|
|
886
|
+
export function resolveCredentials(p: ProjectConfig): CredentialConfig {
|
|
887
|
+
return p.credentials ?? { isolation: "none" };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Named projects whose on-disk config predates `credentials` — the input to the
|
|
892
|
+
* migration, and pure so it can be tested against a raw object.
|
|
893
|
+
*/
|
|
894
|
+
export function projectsMissingCredentials(parsed: unknown): string[] {
|
|
895
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
|
|
896
|
+
const projects = (parsed as Raw)["projects"];
|
|
897
|
+
if (!Array.isArray(projects)) return [];
|
|
898
|
+
const missing: string[] = [];
|
|
899
|
+
projects.forEach((p: unknown, i) => {
|
|
900
|
+
if (typeof p !== "object" || p === null || Array.isArray(p)) return;
|
|
901
|
+
const raw = p as Raw;
|
|
902
|
+
const credentials = raw["credentials"];
|
|
903
|
+
const explicit =
|
|
904
|
+
typeof credentials === "object" &&
|
|
905
|
+
credentials !== null &&
|
|
906
|
+
!Array.isArray(credentials) &&
|
|
907
|
+
(credentials as Raw)["isolation"] !== undefined;
|
|
908
|
+
if (!explicit) missing.push(nonEmptyString(raw["name"]) ? raw["name"] : `projects[${String(i)}]`);
|
|
909
|
+
});
|
|
910
|
+
return missing;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Write the migration through, once. Returns the projects that gained an
|
|
915
|
+
* explicit key, so the caller can log it by name.
|
|
916
|
+
*
|
|
917
|
+
* Separate from `loadConfig` on purpose: `loadConfig` is read-only and is called
|
|
918
|
+
* by every CLI surface, and a loader with a write side-effect is one an
|
|
919
|
+
* operator cannot run against a config they are only inspecting. A failure to
|
|
920
|
+
* persist is logged by the caller and is **not** fatal — the in-memory value is
|
|
921
|
+
* already `"none"`, and refusing to boot because the config file is read-only
|
|
922
|
+
* would be exactly the outage this migration exists to avoid.
|
|
923
|
+
*/
|
|
924
|
+
export function migrateCredentialsOnDisk(): { migrated: string[]; path: string } {
|
|
925
|
+
const path = configPath();
|
|
926
|
+
let parsed: unknown;
|
|
927
|
+
try {
|
|
928
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
929
|
+
} catch {
|
|
930
|
+
return { migrated: [], path };
|
|
931
|
+
}
|
|
932
|
+
const migrated = projectsMissingCredentials(parsed);
|
|
933
|
+
if (migrated.length === 0) return { migrated: [], path };
|
|
934
|
+
// Through the loader and back out, so the rewrite is the same normalisation
|
|
935
|
+
// every other key already gets rather than a second, divergent writer.
|
|
936
|
+
saveConfig(loadConfig());
|
|
937
|
+
return { migrated, path };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* A clone URL carrying credentials voids the entire boundary (#125).
|
|
942
|
+
*
|
|
943
|
+
* `git clone` persists whatever is in the URL into the mirror's config, and the
|
|
944
|
+
* run repository inherits `origin` from it — so a `https://<pat>@github.com/…`
|
|
945
|
+
* hands every session the write credential the rest of this release removes,
|
|
946
|
+
* through a file no environment scrub touches. This used to be a `ponytail`
|
|
947
|
+
* note on `worktree.ts`'s `ensureMirror`; a warning is not a boundary, so it is
|
|
948
|
+
* now rejected at load with the field named.
|
|
949
|
+
*
|
|
950
|
+
* An SSH URL with a plain username (`ssh://git@github.com/o/r`, `git@github.com:o/r`)
|
|
951
|
+
* is not a credential and is left alone — that is how nearly every fleet is
|
|
952
|
+
* configured, and rejecting it would be a migration nobody asked for.
|
|
953
|
+
*/
|
|
954
|
+
export function cloneUrlCredentialProblem(url: string): string | undefined {
|
|
955
|
+
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/]*)@/.exec(url);
|
|
956
|
+
if (m === null) return undefined;
|
|
957
|
+
const scheme = (m[1] ?? "").toLowerCase();
|
|
958
|
+
const userinfo = m[2] ?? "";
|
|
959
|
+
if (userinfo.includes(":")) {
|
|
960
|
+
return "embeds a user:password — git persists it into the mirror config, which hands every session the credential";
|
|
961
|
+
}
|
|
962
|
+
if (scheme === "http" || scheme === "https") {
|
|
963
|
+
return "embeds userinfo in an http(s) URL, which is how a personal access token is spelled — git persists it into the mirror config";
|
|
964
|
+
}
|
|
965
|
+
return undefined;
|
|
966
|
+
}
|
|
967
|
+
|
|
435
968
|
function normalizeRepos(parsed: unknown, label: string, problems: string[]): Record<string, RepoTarget> {
|
|
436
969
|
const repos: Record<string, RepoTarget> = {};
|
|
437
970
|
|
|
@@ -449,6 +982,14 @@ function normalizeRepos(parsed: unknown, label: string, problems: string[]): Rec
|
|
|
449
982
|
problems.push(`${label}: routing.repos.${key}.cloneUrl must be a non-empty string`);
|
|
450
983
|
continue;
|
|
451
984
|
}
|
|
985
|
+
const credentialInUrl = cloneUrlCredentialProblem(cloneUrl);
|
|
986
|
+
if (credentialInUrl !== undefined) {
|
|
987
|
+
problems.push(
|
|
988
|
+
`${label}: routing.repos.${key}.cloneUrl ${credentialInUrl} (#125). Use an SSH URL, or an https URL ` +
|
|
989
|
+
`backed by the daemon's own credential helper.`,
|
|
990
|
+
);
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
452
993
|
const target: RepoTarget = {
|
|
453
994
|
name: pickString(value?.["name"], key),
|
|
454
995
|
cloneUrl,
|
|
@@ -490,6 +1031,53 @@ function normalizeGraphProject(parsed: unknown, label: string, problems: string[
|
|
|
490
1031
|
return undefined;
|
|
491
1032
|
}
|
|
492
1033
|
|
|
1034
|
+
/**
|
|
1035
|
+
* Extra roots the orchestrator's tool gate will let it read (#127).
|
|
1036
|
+
*
|
|
1037
|
+
* Fail-closed for the same reason `graphProject` is: the value is written by a
|
|
1038
|
+
* human here and *enforced* in another process, by a session whose cwd is the
|
|
1039
|
+
* state directory. A relative `../notes` would name a different directory for
|
|
1040
|
+
* every reader, so it is an error rather than something resolved against
|
|
1041
|
+
* whichever cwd happened to load the file — and a jail entry that resolves
|
|
1042
|
+
* somewhere unintended is worse than no entry at all.
|
|
1043
|
+
*
|
|
1044
|
+
* A malformed entry rejects the whole config rather than being dropped: an
|
|
1045
|
+
* operator who mistyped a path they believed they had granted would otherwise
|
|
1046
|
+
* debug a refusal that reads exactly like a correctly-denied one.
|
|
1047
|
+
*/
|
|
1048
|
+
function normalizeOrchestratorReadPaths(
|
|
1049
|
+
parsed: unknown,
|
|
1050
|
+
label: string,
|
|
1051
|
+
problems: string[],
|
|
1052
|
+
): string[] | undefined {
|
|
1053
|
+
if (parsed === undefined) return undefined;
|
|
1054
|
+
if (!Array.isArray(parsed)) {
|
|
1055
|
+
problems.push(`${label}.orchestratorReadPaths must be an array of absolute paths`);
|
|
1056
|
+
return undefined;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const paths: string[] = [];
|
|
1060
|
+
parsed.forEach((entry: unknown, i) => {
|
|
1061
|
+
if (!nonEmptyString(entry)) {
|
|
1062
|
+
problems.push(
|
|
1063
|
+
`${label}.orchestratorReadPaths[${i}] must be a non-empty absolute path, found ${JSON.stringify(entry)}`,
|
|
1064
|
+
);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
const path = expandHome(entry.trim());
|
|
1068
|
+
if (!isAbsolute(path)) {
|
|
1069
|
+
problems.push(
|
|
1070
|
+
`${label}.orchestratorReadPaths[${i}] must be an absolute path (or start with "~") — the ` +
|
|
1071
|
+
`orchestrator session's cwd is the state directory, not wherever this file was edited — ` +
|
|
1072
|
+
`found ${JSON.stringify(entry)}`,
|
|
1073
|
+
);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
paths.push(path);
|
|
1077
|
+
});
|
|
1078
|
+
return paths.length === 0 ? undefined : paths;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
493
1081
|
/**
|
|
494
1082
|
* Gates are the pre-push CI equivalent, so a malformed entry is an error, not
|
|
495
1083
|
* something to drop quietly: a skipped gate is exactly how a lint failure
|
|
@@ -537,6 +1125,14 @@ function coerceCaps(parsed: unknown, label: string, problems: string[], legacy:
|
|
|
537
1125
|
for (const key of CAP_KEYS) {
|
|
538
1126
|
const v = raw[key];
|
|
539
1127
|
if (v === undefined) continue;
|
|
1128
|
+
// The plan-allowance guard is the only cap that is an object rather than a
|
|
1129
|
+
// number, so it validates its own shape before the numeric rule below can
|
|
1130
|
+
// reject it wholesale.
|
|
1131
|
+
if (key === "planUsage") {
|
|
1132
|
+
const cap = coercePlanUsage(v, `${label}.planUsage`, problems);
|
|
1133
|
+
if (cap !== undefined) out.planUsage = cap;
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
540
1136
|
// Spend is the only cap that may be null (= no gate). Every other ceiling
|
|
541
1137
|
// is a non-negative number; 0 remains a hard stop where it already was.
|
|
542
1138
|
if (key === "dailySpendUsd" && v === null) {
|
|
@@ -566,6 +1162,63 @@ function coerceCaps(parsed: unknown, label: string, problems: string[], legacy:
|
|
|
566
1162
|
return out;
|
|
567
1163
|
}
|
|
568
1164
|
|
|
1165
|
+
/**
|
|
1166
|
+
* `{ windowId, maxUsedFraction }`, or `null` for unmetered.
|
|
1167
|
+
*
|
|
1168
|
+
* Fail-closed for the same reason every other guard here is: a plan-allowance
|
|
1169
|
+
* cap the daemon cannot read is a ceiling the operator believes they have. Two
|
|
1170
|
+
* mistakes in particular are rejected by name rather than folded:
|
|
1171
|
+
*
|
|
1172
|
+
* - **`maxUsedFraction: 85`.** The threshold is a fraction, so `85` compares
|
|
1173
|
+
* as "hold at 8500% consumed" — a guard that can never fire, and one that
|
|
1174
|
+
* reads in the config file exactly like a deliberate 85% ceiling.
|
|
1175
|
+
* - **A misspelled key.** `windowID`, `window`, `maxUsedPercent` and friends
|
|
1176
|
+
* would leave a cap with a missing half, which is the same silent
|
|
1177
|
+
* never-fires outcome.
|
|
1178
|
+
*
|
|
1179
|
+
* A `windowId` no provider reports cannot be caught here — only a live reading
|
|
1180
|
+
* knows what exists — so that case is caught at admission instead, where it
|
|
1181
|
+
* holds dispatch and names the window (see `planUsageStatus` in `usage.ts`).
|
|
1182
|
+
*/
|
|
1183
|
+
function coercePlanUsage(
|
|
1184
|
+
v: unknown,
|
|
1185
|
+
label: string,
|
|
1186
|
+
problems: string[],
|
|
1187
|
+
): PlanUsageCap | null | undefined {
|
|
1188
|
+
if (v === null) return null;
|
|
1189
|
+
if (typeof v !== "object" || Array.isArray(v)) {
|
|
1190
|
+
problems.push(
|
|
1191
|
+
`${label} must be { windowId, maxUsedFraction } or null (unmetered), found ${JSON.stringify(v)}`,
|
|
1192
|
+
);
|
|
1193
|
+
return undefined;
|
|
1194
|
+
}
|
|
1195
|
+
const raw = v as Raw;
|
|
1196
|
+
const rawId = raw["windowId"];
|
|
1197
|
+
const rawFraction = raw["maxUsedFraction"];
|
|
1198
|
+
const windowId = nonEmptyString(rawId) ? rawId.trim() : undefined;
|
|
1199
|
+
const maxUsedFraction =
|
|
1200
|
+
typeof rawFraction === "number" && Number.isFinite(rawFraction) && rawFraction >= 0 && rawFraction <= 1
|
|
1201
|
+
? rawFraction
|
|
1202
|
+
: undefined;
|
|
1203
|
+
const unknownKeys = Object.keys(raw).filter((k) => k !== "windowId" && k !== "maxUsedFraction");
|
|
1204
|
+
|
|
1205
|
+
if (windowId === undefined) {
|
|
1206
|
+
problems.push(
|
|
1207
|
+
`${label}.windowId must be a non-empty allowance id such as "anthropic:7d", found ${JSON.stringify(rawId)}`,
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
if (maxUsedFraction === undefined) {
|
|
1211
|
+
problems.push(
|
|
1212
|
+
`${label}.maxUsedFraction must be a fraction between 0 and 1 (0.85 holds at 85% of the allowance), found ${JSON.stringify(rawFraction)}`,
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
if (unknownKeys.length > 0) {
|
|
1216
|
+
problems.push(`${label} has unknown key(s): ${unknownKeys.join(", ")} — expected windowId and maxUsedFraction`);
|
|
1217
|
+
}
|
|
1218
|
+
if (windowId === undefined || maxUsedFraction === undefined || unknownKeys.length > 0) return undefined;
|
|
1219
|
+
return { windowId, maxUsedFraction };
|
|
1220
|
+
}
|
|
1221
|
+
|
|
569
1222
|
function nonEmptyString(v: unknown): v is string {
|
|
570
1223
|
return typeof v === "string" && v.trim().length > 0;
|
|
571
1224
|
}
|