omp-conductor 0.14.0 → 0.15.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 +336 -169
- package/package.json +8 -5
- package/schema/config.schema.json +609 -0
- package/src/board.ts +19 -32
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +9 -5
- package/src/briefs/policy.md +4 -4
- package/src/briefs/probes/gates.md +51 -0
- package/src/briefs/probes/project-context.md +59 -0
- package/src/briefs/probes/release-procedure.md +81 -0
- package/src/cli.ts +235 -199
- package/src/config-schema.ts +352 -0
- package/src/config.ts +1046 -796
- package/src/confinement.ts +54 -0
- package/src/daemon.ts +442 -374
- package/src/escalate.ts +43 -3
- package/src/fleet.ts +317 -43
- package/src/generate-schema.ts +21 -0
- package/src/graph.ts +3 -3
- package/src/host.ts +16 -0
- package/src/omp.ts +21 -1
- package/src/orchestrator-tick.ts +298 -39
- package/src/privileged.ts +264 -0
- package/src/reports.ts +1 -1
- package/src/session-host.ts +3 -0
- package/src/setup-host.ts +209 -24
- package/src/setup-install.ts +320 -0
- package/src/setup-probe.ts +412 -0
- package/src/{plugin.ts → setup-wizard.ts} +790 -465
- package/src/setup.ts +264 -20
- package/src/types.ts +2 -2
- package/src/upgrade.ts +44 -10
- package/src/verbs/server.ts +32 -9
- package/src/wizard-ui.ts +249 -0
- package/src/worker.ts +6 -1
- package/skills/conductor-onboarding/SKILL.md +0 -748
- package/skills/conductor-update/SKILL.md +0 -51
package/src/config.ts
CHANGED
|
@@ -55,36 +55,38 @@ import {
|
|
|
55
55
|
type RepoTarget,
|
|
56
56
|
type ResolvedGrants,
|
|
57
57
|
} from "./types.ts";
|
|
58
|
+
import {
|
|
59
|
+
AUTHORITY_HOLDER_LIST,
|
|
60
|
+
BASE_FRESHNESS_LIST,
|
|
61
|
+
BEHIND_BASE_ACTION_LIST,
|
|
62
|
+
ConfigSchema,
|
|
63
|
+
DIGEST_CADENCE_LIST,
|
|
64
|
+
DRAFT_POLICY_LIST,
|
|
65
|
+
INTERRUPT_CATEGORY_LIST,
|
|
66
|
+
LEGACY_RELEASE_POLICY_LIST,
|
|
67
|
+
ORCHESTRATOR_MODE_LIST,
|
|
68
|
+
RELEASE_REQUIREMENT_LIST,
|
|
69
|
+
RELEASE_SHAPE_LIST,
|
|
70
|
+
REPORT_SCOPE_LIST,
|
|
71
|
+
WEEKDAY_LIST,
|
|
72
|
+
quoteList,
|
|
73
|
+
} from "./config-schema.ts";
|
|
58
74
|
|
|
59
75
|
/**
|
|
60
76
|
* A JSON node whose fields are all still unproven. Reading a field off a
|
|
61
77
|
* non-object (string, number, null) yields `undefined` at runtime, so every
|
|
62
|
-
* field read below is safe
|
|
63
|
-
* validating — no structural guard needed.
|
|
78
|
+
* field read below is safe.
|
|
64
79
|
*
|
|
65
|
-
* ponytail: this is
|
|
66
|
-
*
|
|
67
|
-
*
|
|
80
|
+
* ponytail: this is no longer the validator — `ConfigSchema` in
|
|
81
|
+
* `config-schema.ts` owns every shape/type/enum/required-optional decision, and
|
|
82
|
+
* the normalisers below read already-validated values and apply only what zod
|
|
83
|
+
* cannot express.
|
|
68
84
|
*/
|
|
69
85
|
type Raw = { readonly [key: string]: unknown };
|
|
70
86
|
|
|
71
87
|
/** Derived from the data so a new `Caps` field cannot be silently ignored. */
|
|
72
88
|
const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
|
|
73
89
|
|
|
74
|
-
/** Quoted for error messages, from the same data the guards below read. */
|
|
75
|
-
const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
|
|
76
|
-
const INTERRUPT_CATEGORY_LIST = quoteList(INTERRUPT_CATEGORIES);
|
|
77
|
-
const DIGEST_CADENCE_LIST = quoteList(DIGEST_CADENCES);
|
|
78
|
-
const WEEKDAY_LIST = quoteList(WEEKDAYS);
|
|
79
|
-
const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
|
|
80
|
-
const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
|
|
81
|
-
const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
|
|
82
|
-
const LEGACY_RELEASE_POLICY_LIST = quoteList(LEGACY_RELEASE_POLICIES);
|
|
83
|
-
const BASE_FRESHNESS_LIST = quoteList(BASE_FRESHNESS);
|
|
84
|
-
const DRAFT_POLICY_LIST = quoteList(DRAFT_POLICIES);
|
|
85
|
-
const BEHIND_BASE_ACTION_LIST = quoteList(BEHIND_BASE_ACTIONS);
|
|
86
|
-
const RELEASE_REQUIREMENT_LIST = quoteList(RELEASE_REQUIREMENTS);
|
|
87
|
-
|
|
88
90
|
/** `owner/repo`, the only tracker spelling `gh` accepts without a host. */
|
|
89
91
|
const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
90
92
|
|
|
@@ -146,7 +148,7 @@ export function loadConfig(): ConductorConfig {
|
|
|
146
148
|
const path = configPath();
|
|
147
149
|
|
|
148
150
|
if (!existsSync(path)) {
|
|
149
|
-
throw new Error(`No conductor config at ${path} — run
|
|
151
|
+
throw new Error(`No conductor config at ${path} — run \`omp-conductor setup\` to create one.`);
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
let raw: string;
|
|
@@ -186,9 +188,23 @@ export function saveConfig(c: ConductorConfig): void {
|
|
|
186
188
|
* rewrote a live fleet's config into a dialect the previous release rejected.
|
|
187
189
|
*/
|
|
188
190
|
export function writeConfigFile(c: unknown): void {
|
|
189
|
-
|
|
191
|
+
const withSchema =
|
|
192
|
+
typeof c === "object" && c !== null && !Array.isArray(c) && "version" in c && !("$schema" in c)
|
|
193
|
+
? { ...(c as Record<string, unknown>), $schema: SCHEMA_URI }
|
|
194
|
+
: c;
|
|
195
|
+
writeConfigRaw(`${JSON.stringify(withSchema, null, 2)}\n`);
|
|
190
196
|
}
|
|
191
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Where the shipped `config.schema.json` is installed, referenced from the top
|
|
200
|
+
* of every config `saveConfig` writes. It is derived from this module's own
|
|
201
|
+
* location (`import.meta.dir` is the installed package's `src/`), so the URI
|
|
202
|
+
* points at the actual installed copy rather than a dangling relative path —
|
|
203
|
+
* the config lives at `$OMP_CONDUCTOR_HOME/config.json`, which has no
|
|
204
|
+
* `node_modules` beside it.
|
|
205
|
+
*/
|
|
206
|
+
const SCHEMA_URI = join(dirname(import.meta.dir), "schema", "config.schema.json");
|
|
207
|
+
|
|
192
208
|
/**
|
|
193
209
|
* Atomic write of exact bytes.
|
|
194
210
|
*
|
|
@@ -353,20 +369,621 @@ export function findProject(c: ConductorConfig, name?: string): ProjectConfig {
|
|
|
353
369
|
}
|
|
354
370
|
|
|
355
371
|
// ---------------------------------------------------------------------------
|
|
356
|
-
//
|
|
372
|
+
// load boundary: zod parse + residue normalisation
|
|
357
373
|
// ---------------------------------------------------------------------------
|
|
358
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Validates and normalises an on-disk config into a `ConductorConfig`.
|
|
377
|
+
*
|
|
378
|
+
* Per-field shape, type, enum, numeric-bound and required/optional validity is
|
|
379
|
+
* enforced entirely by `ConfigSchema` (zod) at this boundary — the hand-rolled
|
|
380
|
+
* field guards that used to live below are gone. What zod cannot express is
|
|
381
|
+
* done here as a thin residue layer, in today's exact wording:
|
|
382
|
+
*
|
|
383
|
+
* - cross-field coherence (reporting scope-vs-explicit, digest/availability
|
|
384
|
+
* times, `start !== end`);
|
|
385
|
+
* - the version-keyed cap migration (v1 drops a retired key, v2 rejects it);
|
|
386
|
+
* - the clone-URL credential rejection; and
|
|
387
|
+
* - legacy-key / path normalisation (defaults, presets, `~` and default roots).
|
|
388
|
+
*/
|
|
359
389
|
function validate(parsed: unknown, path: string): ConductorConfig {
|
|
360
390
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
361
|
-
throw new Error(`Conductor config at ${path} must be a JSON object — run
|
|
391
|
+
throw new Error(`Conductor config at ${path} must be a JSON object — run \`omp-conductor setup\` to recreate it.`);
|
|
392
|
+
}
|
|
393
|
+
const shape = ConfigSchema.safeParse(parsed);
|
|
394
|
+
if (!shape.success) {
|
|
395
|
+
// Shape faults from zod, plus the cross-field/credential complaints that
|
|
396
|
+
// only the residue layer can voice. For the nested objects below, zod stops
|
|
397
|
+
// collecting member faults once any sibling fails, so zod's issues for
|
|
398
|
+
// those objects are folded into a single accumulated pass that reports
|
|
399
|
+
// every faulty member (matching the envelope's "whole list" promise).
|
|
400
|
+
const expanded = shape.error.issues.flatMap(expandIssue);
|
|
401
|
+
const problems = [
|
|
402
|
+
...expanded.filter((issue) => !inAccumulatedObject(issue.path)).map((issue) => issueToProblem(issue, parsed)),
|
|
403
|
+
...crossFieldProblems(parsed),
|
|
404
|
+
...nestedObjectProblems(parsed),
|
|
405
|
+
];
|
|
406
|
+
throw new Error(problemEnvelope(path, problems));
|
|
407
|
+
}
|
|
408
|
+
return finalize(shape.data as unknown, path);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* True when a zod issue path lives inside one of the objects whose member
|
|
413
|
+
* faults must all be reported together (availability, digest, caps, authority,
|
|
414
|
+
* per-repo gates). zod skips those once a sibling fails, so the failure path
|
|
415
|
+
* drops zod's issues for them and lets {@link nestedObjectProblems} own them.
|
|
416
|
+
*/
|
|
417
|
+
function inAccumulatedObject(path: readonly PropertyKey[]): boolean {
|
|
418
|
+
if (path[0] !== "projects" || typeof path[1] !== "number") return false;
|
|
419
|
+
const rel = path.slice(2).map(String);
|
|
420
|
+
if (rel[0] === "caps" || rel[0] === "authority") return true;
|
|
421
|
+
if (rel[0] === "reporting" && (rel[1] === "digest" || rel[1] === "availability")) return true;
|
|
422
|
+
if (rel[0] === "routing" && rel[1] === "repos" && rel[3] === "gates") return true;
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Reports every faulty member of the nested objects in one pass, in today's
|
|
428
|
+
* wording, so an operator hears the whole list the first time. zod cannot do
|
|
429
|
+
* this (it abandons an object's remaining members once one fails), so the
|
|
430
|
+
* failure path substitutes this for zod's issues inside those objects.
|
|
431
|
+
* Cross-field items (scope-vs-explicit, at-only-with-daily, timezone match,
|
|
432
|
+
* start != end, clone URLs) are {@link crossFieldProblems}' job, not this one's.
|
|
433
|
+
*/
|
|
434
|
+
function nestedObjectProblems(parsed: unknown): string[] {
|
|
435
|
+
const problems: string[] = [];
|
|
436
|
+
const root = parsed as Raw | undefined;
|
|
437
|
+
const list = root?.["projects"];
|
|
438
|
+
if (!Array.isArray(list)) return problems;
|
|
439
|
+
const legacyCaps = root?.["version"] === 1;
|
|
440
|
+
|
|
441
|
+
list.forEach((p: unknown, i) => {
|
|
442
|
+
const proj = p as Raw;
|
|
443
|
+
if (typeof proj !== "object" || proj === null) return;
|
|
444
|
+
const name = proj["name"];
|
|
445
|
+
const label = typeof name === "string" && name.trim() !== "" ? `project "${name}"` : `projects[${i}]`;
|
|
446
|
+
|
|
447
|
+
// availability — every member, even when days already failed.
|
|
448
|
+
const reporting = typeof proj["reporting"] === "object" && proj["reporting"] !== null && !Array.isArray(proj["reporting"])
|
|
449
|
+
? (proj["reporting"] as Raw)
|
|
450
|
+
: undefined;
|
|
451
|
+
const availability = reporting !== undefined && typeof reporting["availability"] === "object" && reporting["availability"] !== null && !Array.isArray(reporting["availability"])
|
|
452
|
+
? (reporting["availability"] as Raw)
|
|
453
|
+
: undefined;
|
|
454
|
+
if (availability !== undefined) checkAvailability(availability, label, problems);
|
|
455
|
+
|
|
456
|
+
// digest — every member even when cadence already failed.
|
|
457
|
+
if (reporting !== undefined && typeof reporting["digest"] === "object" && reporting["digest"] !== null && !Array.isArray(reporting["digest"])) {
|
|
458
|
+
checkDigest(reporting["digest"] as Raw, label, problems);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// authority
|
|
462
|
+
const authority = proj["authority"];
|
|
463
|
+
if (typeof authority === "object" && authority !== null && !Array.isArray(authority)) {
|
|
464
|
+
checkAuthority(authority as Raw, label, problems);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// caps
|
|
468
|
+
reconcileCaps(proj["caps"], `${label}: caps`, problems, legacyCaps);
|
|
469
|
+
|
|
470
|
+
// per-repo gates (two bad gates must both surface)
|
|
471
|
+
const routing = typeof proj["routing"] === "object" && proj["routing"] !== null && !Array.isArray(proj["routing"])
|
|
472
|
+
? (proj["routing"] as Raw)
|
|
473
|
+
: undefined;
|
|
474
|
+
const repos = routing !== undefined && typeof routing["repos"] === "object" && routing["repos"] !== null && !Array.isArray(routing["repos"])
|
|
475
|
+
? (routing["repos"] as Record<string, unknown>)
|
|
476
|
+
: undefined;
|
|
477
|
+
if (repos !== undefined) {
|
|
478
|
+
for (const [key, entry] of Object.entries(repos)) {
|
|
479
|
+
const e = entry as Raw | undefined;
|
|
480
|
+
if (e === undefined || typeof e !== "object") continue;
|
|
481
|
+
const gates = e["gates"];
|
|
482
|
+
if (gates === undefined) continue;
|
|
483
|
+
const base = `${label}: routing.repos.${key}`;
|
|
484
|
+
if (!Array.isArray(gates)) {
|
|
485
|
+
problems.push(`${base}.gates must be an array of { cmd, cwd }`);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
gates.forEach((g: unknown, gi: number) => {
|
|
489
|
+
const cmd = (g as Raw | undefined)?.["cmd"];
|
|
490
|
+
if (typeof cmd !== "string" || cmd.trim() === "") {
|
|
491
|
+
problems.push(`${base}.gates[${gi}] must be { cmd, cwd } with a non-empty cmd`);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
return problems;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function checkAvailability(a: Raw, label: string, problems: string[]): void {
|
|
501
|
+
if (typeof a["timezone"] !== "string" || a["timezone"].trim() === "") {
|
|
502
|
+
problems.push(`${label}: reporting.availability.timezone must be a known IANA timezone`);
|
|
503
|
+
} else {
|
|
504
|
+
try {
|
|
505
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: a["timezone"] });
|
|
506
|
+
} catch {
|
|
507
|
+
problems.push(`${label}: reporting.availability.timezone is not a known IANA timezone`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const days = a["days"];
|
|
511
|
+
if (!Array.isArray(days) || days.length === 0) {
|
|
512
|
+
problems.push(`${label}: reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`);
|
|
513
|
+
} else {
|
|
514
|
+
for (const d of days) {
|
|
515
|
+
if (typeof d !== "string" || !(WEEKDAYS as readonly string[]).includes(d)) {
|
|
516
|
+
problems.push(`${label}: reporting.availability.days has unknown day ${JSON.stringify(d)} — one of ${WEEKDAY_LIST}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (typeof a["start"] !== "string" || !DIGEST_AT.test(a["start"])) {
|
|
521
|
+
problems.push(`${label}: reporting.availability.start must be a 24h HH:MM time`);
|
|
522
|
+
}
|
|
523
|
+
if (typeof a["end"] !== "string" || !DIGEST_AT.test(a["end"])) {
|
|
524
|
+
problems.push(`${label}: reporting.availability.end must be a 24h HH:MM time`);
|
|
525
|
+
}
|
|
526
|
+
if (!Array.isArray(a["bypass"])) {
|
|
527
|
+
problems.push(`${label}: reporting.availability.bypass must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`);
|
|
528
|
+
} else {
|
|
529
|
+
for (const b of a["bypass"]) {
|
|
530
|
+
if (typeof b !== "string" || !(INTERRUPT_CATEGORIES as readonly string[]).includes(b)) {
|
|
531
|
+
problems.push(`${label}: reporting.availability.bypass has unknown category ${JSON.stringify(b)} — one of ${INTERRUPT_CATEGORY_LIST}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function checkDigest(d: Raw, label: string, problems: string[]): void {
|
|
538
|
+
if (typeof d["cadence"] !== "string" || !(DIGEST_CADENCES as readonly string[]).includes(d["cadence"])) {
|
|
539
|
+
problems.push(`${label}: reporting.digest.cadence must be ${DIGEST_CADENCE_LIST}`);
|
|
540
|
+
}
|
|
541
|
+
if (d["at"] !== undefined && (typeof d["at"] !== "string" || !DIGEST_AT.test(d["at"]))) {
|
|
542
|
+
problems.push(`${label}: reporting.digest.at must be a 24h HH:MM time`);
|
|
543
|
+
}
|
|
544
|
+
if (d["timezone"] !== undefined) {
|
|
545
|
+
if (typeof d["timezone"] !== "string") {
|
|
546
|
+
problems.push(`${label}: reporting.digest.timezone must be a string`);
|
|
547
|
+
} else {
|
|
548
|
+
try {
|
|
549
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: d["timezone"] });
|
|
550
|
+
} catch {
|
|
551
|
+
problems.push(`${label}: reporting.digest.timezone is not a known IANA timezone`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function checkAuthority(a: Raw, label: string, problems: string[]): void {
|
|
558
|
+
const merge = a["merge"];
|
|
559
|
+
const release = a["release"];
|
|
560
|
+
if (merge !== undefined && typeof merge === "string" && !(AUTHORITY_HOLDERS as readonly string[]).includes(merge)) {
|
|
561
|
+
problems.push(`${label}: authority.merge must be ${AUTHORITY_HOLDER_LIST}, found ${JSON.stringify(merge)}`);
|
|
562
|
+
}
|
|
563
|
+
if (release !== undefined && typeof release === "string" && !(AUTHORITY_HOLDERS as readonly string[]).includes(release)) {
|
|
564
|
+
problems.push(`${label}: authority.release must be ${AUTHORITY_HOLDER_LIST}, found ${JSON.stringify(release)}`);
|
|
565
|
+
}
|
|
566
|
+
const unknown = Object.keys(a).filter((k) => k !== "merge" && k !== "release");
|
|
567
|
+
if (unknown.length > 0) problems.push(`${label}: authority has unknown key(s): ${unknown.join(", ")}`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* The cross-field and credential complaints zod cannot express, stated against
|
|
572
|
+
* the raw document so they are reported even when a sibling key fails its
|
|
573
|
+
* shape. On the success path the same checks run inside `finalize`; this is the
|
|
574
|
+
* failure-path twin, keeping the multi-problem envelope cumulative.
|
|
575
|
+
*/
|
|
576
|
+
function crossFieldProblems(parsed: unknown): string[] {
|
|
577
|
+
const problems: string[] = [];
|
|
578
|
+
const list = (parsed as Raw | undefined)?.["projects"];
|
|
579
|
+
if (!Array.isArray(list)) return problems;
|
|
580
|
+
list.forEach((p: unknown, i) => {
|
|
581
|
+
const proj = p as Raw;
|
|
582
|
+
if (typeof proj !== "object" || proj === null) return;
|
|
583
|
+
const name = proj["name"];
|
|
584
|
+
const label = typeof name === "string" && name.trim() !== "" ? `project "${name}"` : `projects[${i}]`;
|
|
585
|
+
|
|
586
|
+
const reporting = proj["reporting"];
|
|
587
|
+
if (typeof reporting === "object" && reporting !== null && !Array.isArray(reporting)) {
|
|
588
|
+
const r = reporting as Raw;
|
|
589
|
+
const hasScope = r["scope"] !== undefined;
|
|
590
|
+
const hasExplicit =
|
|
591
|
+
r["interruptOn"] !== undefined || r["digest"] !== undefined || r["availability"] !== undefined;
|
|
592
|
+
if (hasScope && hasExplicit) {
|
|
593
|
+
problems.push(
|
|
594
|
+
`${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest/availability explicitly`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
const digest = typeof r["digest"] === "object" && r["digest"] !== null && !Array.isArray(r["digest"])
|
|
598
|
+
? (r["digest"] as Raw)
|
|
599
|
+
: undefined;
|
|
600
|
+
if (digest !== undefined && digest["cadence"] !== undefined && digest["cadence"] !== "daily") {
|
|
601
|
+
if (digest["at"] !== undefined) problems.push(`${label}: reporting.digest.at is only valid with cadence "daily"`);
|
|
602
|
+
if (digest["timezone"] !== undefined) {
|
|
603
|
+
problems.push(`${label}: reporting.digest.timezone is only valid with cadence "daily"`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const availability = typeof r["availability"] === "object" && r["availability"] !== null && !Array.isArray(r["availability"])
|
|
607
|
+
? (r["availability"] as Raw)
|
|
608
|
+
: undefined;
|
|
609
|
+
if (availability !== undefined) {
|
|
610
|
+
const start = availability["start"];
|
|
611
|
+
const end = availability["end"];
|
|
612
|
+
if (typeof start === "string" && typeof end === "string" && start === end) {
|
|
613
|
+
problems.push(`${label}: reporting.availability.start and end must differ`);
|
|
614
|
+
}
|
|
615
|
+
if (
|
|
616
|
+
digest !== undefined &&
|
|
617
|
+
digest["cadence"] === "daily" &&
|
|
618
|
+
digest["timezone"] !== undefined &&
|
|
619
|
+
availability["timezone"] !== undefined &&
|
|
620
|
+
digest["timezone"] !== availability["timezone"]
|
|
621
|
+
) {
|
|
622
|
+
problems.push(`${label}: reporting.digest.timezone must match reporting.availability.timezone`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const routing = proj["routing"];
|
|
628
|
+
const repos = typeof routing === "object" && routing !== null && !Array.isArray(routing)
|
|
629
|
+
? (routing as Raw)["repos"]
|
|
630
|
+
: undefined;
|
|
631
|
+
if (typeof repos === "object" && repos !== null && !Array.isArray(repos)) {
|
|
632
|
+
for (const [key, entry] of Object.entries(repos as Record<string, unknown>)) {
|
|
633
|
+
const cloneUrl = (entry as Raw | undefined)?.["cloneUrl"];
|
|
634
|
+
if (typeof cloneUrl === "string") {
|
|
635
|
+
const credential = cloneUrlCredentialProblem(cloneUrl);
|
|
636
|
+
if (credential !== undefined) {
|
|
637
|
+
problems.push(
|
|
638
|
+
`${label}: routing.repos.${key}.cloneUrl ${credential}. Use an SSH URL, or an https URL ` +
|
|
639
|
+
`backed by the daemon's own credential helper.`,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
return problems;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Expands a zod `invalid_union` into its most specific branch — the one with
|
|
651
|
+
* the deepest path — so `releasePolicy: { deploy: "worker" }` reports the
|
|
652
|
+
* offending holder (and its field) rather than a bare "Invalid input". The
|
|
653
|
+
* legacy-literal branch of the same union always errors for an object and is
|
|
654
|
+
* the one we discard.
|
|
655
|
+
*/
|
|
656
|
+
type LooseIssue = {
|
|
657
|
+
path: PropertyKey[];
|
|
658
|
+
code: string;
|
|
659
|
+
message: string;
|
|
660
|
+
keys?: readonly string[];
|
|
661
|
+
values?: readonly unknown[];
|
|
662
|
+
received?: unknown;
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
function expandIssue(issue: unknown): LooseIssue[] {
|
|
666
|
+
const raw = issue as { code?: string; path?: PropertyKey[]; errors?: unknown; message?: string };
|
|
667
|
+
const base = { ...(issue as object) } as LooseIssue;
|
|
668
|
+
if (raw.code !== "invalid_union" || !Array.isArray(raw.errors)) {
|
|
669
|
+
return [{ ...base, path: raw.path ?? [], code: raw.code ?? "invalid_type", message: raw.message ?? "Invalid input" }];
|
|
670
|
+
}
|
|
671
|
+
let best: { path?: PropertyKey[]; code?: string; message?: string } | undefined;
|
|
672
|
+
let bestDepth = -1;
|
|
673
|
+
for (const branch of raw.errors) {
|
|
674
|
+
for (const e of Array.isArray(branch) ? branch : [branch]) {
|
|
675
|
+
const er = e as { path?: PropertyKey[] };
|
|
676
|
+
const depth = (raw.path?.length ?? 0) + (er.path?.length ?? 0);
|
|
677
|
+
if (depth > bestDepth) {
|
|
678
|
+
bestDepth = depth;
|
|
679
|
+
best = { path: er.path, code: (e as { code?: string }).code, message: (e as { message?: string }).message };
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (best === undefined) {
|
|
684
|
+
return [{ ...base, path: raw.path ?? [], code: raw.code, message: raw.message ?? "Invalid input" }];
|
|
685
|
+
}
|
|
686
|
+
return [
|
|
687
|
+
{ ...base, path: [...(raw.path ?? []), ...(best.path ?? [])], code: best.code ?? "invalid_type", message: best.message ?? "Invalid input" },
|
|
688
|
+
];
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function problemEnvelope(path: string, problems: string[]): string {
|
|
692
|
+
return (
|
|
693
|
+
`Invalid conductor config at ${path}:\n${problems.map((p) => ` - ${p}`).join("\n")}\n` +
|
|
694
|
+
`Fix the file or run \`omp-conductor setup\`.`
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** The operator-facing label of a project from its issue path, or undefined. */
|
|
699
|
+
function projectLabel(root: unknown, index: number): string | undefined {
|
|
700
|
+
const list = (root as Raw | undefined)?.["projects"];
|
|
701
|
+
if (!Array.isArray(list)) return undefined;
|
|
702
|
+
const p = list[index] as Raw | undefined;
|
|
703
|
+
const name = p?.["name"];
|
|
704
|
+
return typeof name === "string" && name.trim() !== "" ? `project "${name}"` : `projects[${index}]`;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** Joins zod's issue path segments into the dotted config path an operator sees. */
|
|
708
|
+
function dottedPath(segments: readonly PropertyKey[]): string {
|
|
709
|
+
return segments
|
|
710
|
+
.map((seg) => (typeof seg === "number" ? `[${seg}]` : `.${String(seg)}`))
|
|
711
|
+
.join("")
|
|
712
|
+
.replace(/^\./, "");
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Reads a value out of the original parsed document at a zod issue path. */
|
|
716
|
+
function rawAt(root: unknown, path: readonly PropertyKey[]): unknown {
|
|
717
|
+
let cur: unknown = root;
|
|
718
|
+
for (const seg of path) {
|
|
719
|
+
if (cur === null || typeof cur !== "object") return undefined;
|
|
720
|
+
cur = (cur as Record<string, unknown>)[String(seg)];
|
|
721
|
+
}
|
|
722
|
+
return cur;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const FOUND = (v: unknown): string => (v === "" ? '""' : JSON.stringify(v));
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Renders a zod issue the way the loader has always worded faults — the dotted
|
|
729
|
+
* config path, the exact alternatives, the offending value — so moving the
|
|
730
|
+
* validation to zod changes how a problem is *found*, never how it reads.
|
|
731
|
+
*/
|
|
732
|
+
function issueToProblem(issue: {
|
|
733
|
+
path: readonly PropertyKey[];
|
|
734
|
+
code: string;
|
|
735
|
+
message: string;
|
|
736
|
+
keys?: readonly string[];
|
|
737
|
+
values?: readonly unknown[];
|
|
738
|
+
}, root: unknown): string {
|
|
739
|
+
const path = issue.path;
|
|
740
|
+
|
|
741
|
+
// Top-level version / projects faults carry no project label.
|
|
742
|
+
if (path.length === 1 && (path[0] === "version" || path[0] === "projects")) {
|
|
743
|
+
if (path[0] === "version") {
|
|
744
|
+
return `"version" must be 1 or 2, found ${JSON.stringify(rawAt(root, path))} — this config was written by a different conductor`;
|
|
745
|
+
}
|
|
746
|
+
return `"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (path[0] === "projects" && typeof path[1] === "number") {
|
|
750
|
+
const rel = path.slice(2);
|
|
751
|
+
// The project's own name is invalid precisely when *its* field fails, and
|
|
752
|
+
// that fault has always been voiced as `projects[N].name …`.
|
|
753
|
+
if (rel.length === 1 && rel[0] === "name") {
|
|
754
|
+
return `projects[${path[1]}].name must be a non-empty string`;
|
|
755
|
+
}
|
|
756
|
+
const label = projectLabel(root, path[1]) ?? `projects[${path[1]}]`;
|
|
757
|
+
return `${label}: ${clauseFor(rel, issue, root, path)}`;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return clauseFor(path, issue, root, path);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** The clause after the project label — `<dotted-path> <finding>`. */
|
|
764
|
+
function clauseFor(rel: readonly PropertyKey[], issue: {
|
|
765
|
+
code: string;
|
|
766
|
+
message: string;
|
|
767
|
+
keys?: readonly string[];
|
|
768
|
+
values?: readonly unknown[];
|
|
769
|
+
}, root: unknown, fullPath: readonly PropertyKey[]): string {
|
|
770
|
+
const pathStr = dottedPath(rel);
|
|
771
|
+
const found = (at?: readonly PropertyKey[]): string => FOUND(rawAt(root, at ?? fullPath));
|
|
772
|
+
|
|
773
|
+
if (rel.length === 1 && rel[0] === "releasePolicy") {
|
|
774
|
+
return `releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${found()}`;
|
|
775
|
+
}
|
|
776
|
+
if (rel[0] === "routing" && (rel.length === 1 || (rel.length === 2 && rel[1] === "repos"))) {
|
|
777
|
+
return `routing.repos needs at least one repo entry, or no issue can be routed`;
|
|
778
|
+
}
|
|
779
|
+
if (rel.length >= 2 && rel[rel.length - 1] === "dir" && rel.includes("migrations")) {
|
|
780
|
+
return `${pathStr} must be a non-empty string`;
|
|
781
|
+
}
|
|
782
|
+
const NAME_LIST_FIELDS = new Set(["requiredChecks", "artefacts", "environments"]);
|
|
783
|
+
// A whole name-list field of the wrong shape.
|
|
784
|
+
if (
|
|
785
|
+
rel.length >= 2 &&
|
|
786
|
+
NAME_LIST_FIELDS.has(String(rel[rel.length - 1])) &&
|
|
787
|
+
(rel[rel.length - 2] === "merge" || rel[rel.length - 2] === "release")
|
|
788
|
+
) {
|
|
789
|
+
return `${pathStr} must be an array of non-empty strings`;
|
|
790
|
+
}
|
|
791
|
+
// One malformed element of a name-list.
|
|
792
|
+
if (
|
|
793
|
+
typeof rel[rel.length - 1] === "number" &&
|
|
794
|
+
rel.length >= 3 &&
|
|
795
|
+
NAME_LIST_FIELDS.has(String(rel[rel.length - 2]))
|
|
796
|
+
) {
|
|
797
|
+
return `${pathStr} must be a non-empty string, found ${found()}`;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Schema-authored findings (`.superRefine` / `.message` carried on the node
|
|
801
|
+
// with a `custom` code) assemble as `<dotted-path> <finding>`.
|
|
802
|
+
if (issue.code === "custom") return `${pathStr} ${issue.message}`;
|
|
803
|
+
|
|
804
|
+
// Presence of an object-looking field where a singular scalar belongs, or a
|
|
805
|
+
// whole object/array of the wrong type — the "must be an object" family.
|
|
806
|
+
if (issue.code === "invalid_type") {
|
|
807
|
+
const relStr = (rel[0] as string) ?? "";
|
|
808
|
+
if (rel.length === 1 && relStr === "reporting") {
|
|
809
|
+
return `reporting must be an object with a "scope" preset or explicit "interruptOn"/"digest"`;
|
|
810
|
+
}
|
|
811
|
+
if (rel.length === 2 && relStr === "reporting" && rel[1] === "digest") {
|
|
812
|
+
return `reporting.digest must be an object with a "cadence" of ${DIGEST_CADENCE_LIST}`;
|
|
813
|
+
}
|
|
814
|
+
if (rel.length === 2 && relStr === "reporting" && rel[1] === "availability") {
|
|
815
|
+
return `reporting.availability must be an object`;
|
|
816
|
+
}
|
|
817
|
+
if (rel.length === 1 && relStr === "escalation") return `escalation must be an object`;
|
|
818
|
+
if (rel.length === 1 && relStr === "authority") {
|
|
819
|
+
return `authority must be an object with "merge" and "release" of ${AUTHORITY_HOLDER_LIST}`;
|
|
820
|
+
}
|
|
821
|
+
if (rel.length === 1 && relStr === "policy") {
|
|
822
|
+
return `policy must be an object with "merge" and "release" sections`;
|
|
823
|
+
}
|
|
824
|
+
if (rel.length === 2 && relStr === "policy") return `${pathStr} must be an object`;
|
|
825
|
+
if (rel.length === 1 && relStr === "caps") return `caps must be an object`;
|
|
826
|
+
if (rel.length === 2 && relStr === "caps" && rel[1] === "planUsage") {
|
|
827
|
+
return `caps.planUsage must be { windowId, maxUsedFraction } or null (unmetered), found ${found()}`;
|
|
828
|
+
}
|
|
829
|
+
if (rel.length === 1 && relStr === "releasePolicy") {
|
|
830
|
+
return `releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${found()}`;
|
|
831
|
+
}
|
|
832
|
+
if (rel.length === 3 && rel[0] === "routing" && rel[1] === "repos") {
|
|
833
|
+
return `routing.repos needs at least one repo entry, or no issue can be routed`;
|
|
834
|
+
}
|
|
835
|
+
if (rel.length === 1 && relStr === "tracker") {
|
|
836
|
+
return `tracker.repo must look like "owner/repo", found undefined`;
|
|
837
|
+
}
|
|
838
|
+
// A numeric cap field of the wrong shape (zod union over number|null).
|
|
839
|
+
if (rel.length === 2 && relStr === "caps") {
|
|
840
|
+
return capProblem(rel[1] as string, found());
|
|
841
|
+
}
|
|
842
|
+
// A missing/wrong name-list element.
|
|
843
|
+
if (typeof rel[rel.length - 1] === "number") {
|
|
844
|
+
return `${pathStr} must be a non-empty string, found ${found()}`;
|
|
845
|
+
}
|
|
846
|
+
// Every remaining require/missing-property case is authored here; never
|
|
847
|
+
// fall through to zod's own rendering.
|
|
848
|
+
if (rel[0] === "tracker") return `tracker.repo must look like "owner/repo", found undefined`;
|
|
849
|
+
if (pathStr === "reporting.digest.cadence") return `reporting.digest.cadence must be ${DIGEST_CADENCE_LIST}`;
|
|
850
|
+
if (pathStr === "reporting.digest.at") return `reporting.digest.at must be a 24h HH:MM time`;
|
|
851
|
+
if (pathStr === "reporting.digest.timezone") return `reporting.digest.timezone must be a string`;
|
|
852
|
+
if (pathStr === "reporting.availability.timezone") return `reporting.availability.timezone must be a known IANA timezone`;
|
|
853
|
+
if (pathStr === "reporting.availability.start" || pathStr === "reporting.availability.end") {
|
|
854
|
+
return `${pathStr} must be a 24h HH:MM time`;
|
|
855
|
+
}
|
|
856
|
+
if (pathStr === "reporting.availability.days") {
|
|
857
|
+
return `reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`;
|
|
858
|
+
}
|
|
859
|
+
if (pathStr === "reporting.availability.bypass") {
|
|
860
|
+
return `reporting.availability.bypass must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`;
|
|
861
|
+
}
|
|
862
|
+
if (pathStr === "reporting.interruptOn") return `reporting.interruptOn must name at least one category`;
|
|
863
|
+
if (rel.length >= 2 && String(rel[rel.length - 1]).endsWith("graphProject")) {
|
|
864
|
+
return `${pathStr} must be a non-empty absolute path, found ${found()}`;
|
|
865
|
+
}
|
|
866
|
+
if (rel.length >= 2 && String(rel[rel.length - 1]) === "gates") return `${pathStr} must be an array of { cmd, cwd }`;
|
|
867
|
+
if (rel.length === 2 && rel[0] === "caps") return capProblem(rel[1] as string, found());
|
|
868
|
+
return `${pathStr} is invalid`;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (issue.code === "unrecognized_keys") {
|
|
872
|
+
const keys = (issue.keys ?? []).join(", ");
|
|
873
|
+
if (pathStr === "policy") return `policy has unknown key(s): ${keys} — expected "merge" or "release"`;
|
|
874
|
+
if (pathStr === "policy.merge") return `${pathStr} has unknown key(s): ${keys} — expected ${POLICY_MERGE_KEYS}`;
|
|
875
|
+
if (pathStr === "policy.release") return `${pathStr} has unknown key(s): ${keys} — expected ${POLICY_RELEASE_KEYS}`;
|
|
876
|
+
if (pathStr === "caps.planUsage") {
|
|
877
|
+
return `caps.planUsage has unknown key(s): ${keys} — expected windowId and maxUsedFraction`;
|
|
878
|
+
}
|
|
879
|
+
if (pathStr === "releasePolicy") {
|
|
880
|
+
return `releasePolicy has unknown release shape(s): ${keys} — expected ${RELEASE_SHAPE_LIST}`;
|
|
881
|
+
}
|
|
882
|
+
return `${pathStr} has unknown key(s): ${keys}`;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Enum membership (invalid_value). The wording depends on the field.
|
|
886
|
+
if (issue.code === "invalid_value") {
|
|
887
|
+
const tail = rel[rel.length - 1];
|
|
888
|
+
const parent = dottedPath(rel.slice(0, rel.length - 1));
|
|
889
|
+
// Array-element vocabularies stated with their own phrasing.
|
|
890
|
+
if (parent === "reporting.interruptOn") {
|
|
891
|
+
return `reporting.interruptOn has unknown category ${found()} — one of ${INTERRUPT_CATEGORY_LIST}`;
|
|
892
|
+
}
|
|
893
|
+
if (parent === "reporting.availability.bypass") {
|
|
894
|
+
return `reporting.availability.bypass has unknown category ${found()} — one of ${INTERRUPT_CATEGORY_LIST}`;
|
|
895
|
+
}
|
|
896
|
+
if (parent === "reporting.availability.days") {
|
|
897
|
+
return `reporting.availability.days has unknown day ${found()} — one of ${WEEKDAY_LIST}`;
|
|
898
|
+
}
|
|
899
|
+
if (parent === "policy.release.requires") {
|
|
900
|
+
return `${pathStr} must be ${RELEASE_REQUIREMENT_LIST}, found ${found()}`;
|
|
901
|
+
}
|
|
902
|
+
if (rel.length >= 2 && rel[0] === "releasePolicy") {
|
|
903
|
+
return `releasePolicy.${String(tail)} must be ${AUTHORITY_HOLDER_LIST}, found ${found()}`;
|
|
904
|
+
}
|
|
905
|
+
if (pathStr === "tracker.kind") return `tracker.kind must be "github", found ${found()}`;
|
|
906
|
+
if (pathStr === "reporting.scope" || pathStr === "reporting.scopePreset") {
|
|
907
|
+
return `${pathStr} must be ${REPORT_SCOPE_LIST}, found ${found()}`;
|
|
908
|
+
}
|
|
909
|
+
if (pathStr === "reporting.digest.cadence") return `${pathStr} must be ${DIGEST_CADENCE_LIST}, found ${found()}`;
|
|
910
|
+
if (pathStr === "escalation.orchestrator") return `${pathStr} must be ${ORCHESTRATOR_MODE_LIST}, found ${found()}`;
|
|
911
|
+
if (pathStr === "authority.merge" || pathStr === "authority.release") {
|
|
912
|
+
return `${pathStr} must be ${AUTHORITY_HOLDER_LIST}, found ${found()}`;
|
|
913
|
+
}
|
|
914
|
+
if (pathStr === "policy.merge.baseFreshness") return `${pathStr} must be ${BASE_FRESHNESS_LIST}, found ${found()}`;
|
|
915
|
+
if (pathStr === "policy.merge.drafts") return `${pathStr} must be ${DRAFT_POLICY_LIST}, found ${found()}`;
|
|
916
|
+
if (pathStr === "policy.merge.whenBehindBase") return `${pathStr} must be ${BEHIND_BASE_ACTION_LIST}, found ${found()}`;
|
|
917
|
+
const values = (issue.values ?? []).map(String);
|
|
918
|
+
return `${pathStr} must be ${quoteList(values)}, found ${found()}`;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Regex / custom-message failures carry the finding in issue.message.
|
|
922
|
+
if (issue.code === "too_small") {
|
|
923
|
+
// Empty array (reporting.interruptOn, availability.days) has its own line.
|
|
924
|
+
if (parentOf(rel) === "reporting.interruptOn") return `reporting.interruptOn must name at least one category`;
|
|
925
|
+
if (pathStr === "reporting.availability.days") return `reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`;
|
|
926
|
+
if (pathStr === "reporting.availability.timezone") return `reporting.availability.timezone must be a known IANA timezone`;
|
|
927
|
+
if (pathStr === "routing.repos" ) return `routing.repos needs at least one repo entry, or no issue can be routed`;
|
|
928
|
+
if (cloneUrlPath(rel) ) return `${pathStr} must be a non-empty string`;
|
|
929
|
+
if (gatesCmdPath(rel)) return `${pathStr} must be { cmd, cwd } with a non-empty cmd`;
|
|
930
|
+
if (rel.length === 2 && rel[0] === "caps") return capProblem(rel[1] as string, found());
|
|
931
|
+
if (pathStr === "caps.planUsage.maxUsedFraction") {
|
|
932
|
+
return `caps.planUsage.maxUsedFraction must be a fraction between 0 and 1 (0.85 holds at 85% of the allowance), found ${found()}`;
|
|
933
|
+
}
|
|
934
|
+
return `${pathStr} ${finding(issue.message)}`;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (issue.code === "too_big") {
|
|
938
|
+
if (rel.length === 2 && rel[0] === "caps") return capProblem(rel[1] as string, found());
|
|
939
|
+
if (pathStr === "caps.planUsage.maxUsedFraction") {
|
|
940
|
+
return `caps.planUsage.maxUsedFraction must be a fraction between 0 and 1 (0.85 holds at 85% of the allowance), found ${found()}`;
|
|
941
|
+
}
|
|
942
|
+
return `${pathStr} ${finding(issue.message)}`;
|
|
362
943
|
}
|
|
363
|
-
|
|
944
|
+
|
|
945
|
+
// invalid_format (the repo / HH:MM regexes) and anything else.
|
|
946
|
+
if (pathStr === "reporting.digest.at" || pathStr === "reporting.availability.start" || pathStr === "reporting.availability.end") {
|
|
947
|
+
return `${pathStr} must be a 24h HH:MM time`;
|
|
948
|
+
}
|
|
949
|
+
if (pathStr === "tracker.repo") return `tracker.repo must look like "owner/repo", found ${found()}`;
|
|
950
|
+
return `${pathStr} ${finding(issue.message)}`;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/** zod's own phrasing, which must never reach an operator-facing problem line. */
|
|
954
|
+
const LEAKY = /(Invalid input|expected |received |Unrecognized key|Too small|Too big)/;
|
|
955
|
+
function finding(message: string): string {
|
|
956
|
+
return LEAKY.test(message) ? "is not valid" : message;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function parentOf(rel: readonly PropertyKey[]): string {
|
|
960
|
+
return dottedPath(rel.slice(0, rel.length - 1));
|
|
961
|
+
}
|
|
962
|
+
function cloneUrlPath(rel: readonly PropertyKey[]): boolean {
|
|
963
|
+
return rel.length >= 4 && rel[rel.length - 1] === "cloneUrl";
|
|
964
|
+
}
|
|
965
|
+
function gatesCmdPath(rel: readonly PropertyKey[]): boolean {
|
|
966
|
+
return rel.some((s, i) => s === "gates" && typeof rel[i + 1] === "number" && rel[rel.length - 1] === "cmd");
|
|
967
|
+
}
|
|
968
|
+
function capProblem(key: string, found: string): string {
|
|
969
|
+
return key === "dailySpendUsd"
|
|
970
|
+
? `caps.dailySpendUsd must be a non-negative finite number or null (no cap), found ${found}`
|
|
971
|
+
: `caps.${key} must be a non-negative finite number, found ${found}`;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** Quted fallback key lists for policy unknown-key errors. */
|
|
975
|
+
const POLICY_MERGE_KEYS = quoteList(Object.keys(clonePolicy(DEFAULT_PROJECT_POLICY).merge));
|
|
976
|
+
const POLICY_RELEASE_KEYS = quoteList(Object.keys(clonePolicy(DEFAULT_PROJECT_POLICY).release));
|
|
977
|
+
|
|
978
|
+
// ---------------------------------------------------------------------------
|
|
979
|
+
// residue: cross-field coherence, migrations, defaults, path expansion
|
|
980
|
+
// ---------------------------------------------------------------------------
|
|
981
|
+
|
|
982
|
+
function finalize(data: unknown, path: string): ConductorConfig {
|
|
983
|
+
const root = data as Raw;
|
|
364
984
|
const problems: string[] = [];
|
|
365
985
|
|
|
366
986
|
const version = root["version"];
|
|
367
|
-
// A v1 file predates the retirement of a cap key, so its caps are read
|
|
368
|
-
// leniently and the result is normalised up to v2. Any other version is a
|
|
369
|
-
// config this build cannot honestly claim to understand.
|
|
370
987
|
const legacyCaps = version === 1;
|
|
371
988
|
if (!READABLE_CONFIG_VERSIONS.some((v) => v === version)) {
|
|
372
989
|
problems.push(
|
|
@@ -374,13 +991,12 @@ function validate(parsed: unknown, path: string): ConductorConfig {
|
|
|
374
991
|
);
|
|
375
992
|
}
|
|
376
993
|
|
|
377
|
-
const configuredDefaults =
|
|
994
|
+
const configuredDefaults = reconcileCaps(root["defaults"], `"defaults"`, problems, legacyCaps);
|
|
378
995
|
const defaultWorkerMaxTurns = configuredDefaults.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns;
|
|
379
996
|
const defaults: Caps = {
|
|
380
997
|
...DEFAULT_CAPS,
|
|
381
998
|
...configuredDefaults,
|
|
382
|
-
workerMaxTurnsCeiling:
|
|
383
|
-
configuredDefaults.workerMaxTurnsCeiling ?? defaultWorkerMaxTurns * 2,
|
|
999
|
+
workerMaxTurnsCeiling: configuredDefaults.workerMaxTurnsCeiling ?? defaultWorkerMaxTurns * 2,
|
|
384
1000
|
};
|
|
385
1001
|
|
|
386
1002
|
const rawProjects = root["projects"];
|
|
@@ -389,94 +1005,58 @@ function validate(parsed: unknown, path: string): ConductorConfig {
|
|
|
389
1005
|
problems.push(`"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`);
|
|
390
1006
|
} else {
|
|
391
1007
|
rawProjects.forEach((p: unknown, i) => {
|
|
392
|
-
const project =
|
|
1008
|
+
const project = finalizeProject(p, i, problems, legacyCaps);
|
|
393
1009
|
if (project !== undefined) projects.push(project);
|
|
394
1010
|
});
|
|
395
1011
|
}
|
|
396
1012
|
|
|
397
|
-
if (problems.length > 0)
|
|
398
|
-
throw new Error(
|
|
399
|
-
`Invalid conductor config at ${path}:\n${problems.map((p) => ` - ${p}`).join("\n")}\n` +
|
|
400
|
-
`Fix the file or run /conductor setup.`,
|
|
401
|
-
);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Always v2 out: a loaded v1 config is migrated in memory, and the next
|
|
405
|
-
// `saveConfig` is what persists the migration. `loadConfig` stays read-only.
|
|
1013
|
+
if (problems.length > 0) throw new Error(problemEnvelope(path, problems));
|
|
406
1014
|
return { version: CONFIG_VERSION, defaults, projects };
|
|
407
1015
|
}
|
|
408
1016
|
|
|
409
|
-
|
|
410
|
-
function normalizeProject(
|
|
1017
|
+
function finalizeProject(
|
|
411
1018
|
parsed: unknown,
|
|
412
1019
|
index: number,
|
|
413
1020
|
problems: string[],
|
|
414
1021
|
legacyCaps: boolean,
|
|
415
1022
|
): ProjectConfig | undefined {
|
|
416
1023
|
const at = `projects[${index}]`;
|
|
417
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
418
|
-
problems.push(`${at} must be an object`);
|
|
419
|
-
return undefined;
|
|
420
|
-
}
|
|
421
|
-
const raw = parsed as Raw;
|
|
422
1024
|
const before = problems.length;
|
|
1025
|
+
const p = parsed as Raw;
|
|
423
1026
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const tracker = raw["tracker"] as Raw | undefined;
|
|
431
|
-
const rawRepo = tracker?.["repo"];
|
|
432
|
-
let trackerRepo = "";
|
|
433
|
-
if (nonEmptyString(rawRepo) && REPO_RE.test(rawRepo)) trackerRepo = rawRepo;
|
|
434
|
-
else problems.push(`${label}: tracker.repo must look like "owner/repo", found ${JSON.stringify(rawRepo)}`);
|
|
435
|
-
const rawKind = tracker?.["kind"];
|
|
436
|
-
if (rawKind !== undefined && rawKind !== "github") {
|
|
437
|
-
problems.push(`${label}: tracker.kind must be "github", found ${JSON.stringify(rawKind)}`);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const rawQueueLabel = raw["queueLabel"];
|
|
441
|
-
let queueLabel = "";
|
|
442
|
-
if (nonEmptyString(rawQueueLabel)) queueLabel = rawQueueLabel;
|
|
443
|
-
else problems.push(`${label}: queueLabel must be a non-empty string — it is the human sign-off gate`);
|
|
444
|
-
|
|
445
|
-
// Tolerant like the other optional fields: an integer >= 1 is a grooming
|
|
446
|
-
// trigger, and anything else degrades to absent (the tick default) rather
|
|
447
|
-
// than invalidating the whole project.
|
|
448
|
-
const rawGroomBelow = raw["groomBelow"];
|
|
1027
|
+
// zod guarantees name/repo/queueLabel/kind are present and well-formed.
|
|
1028
|
+
const name = p["name"] as string;
|
|
1029
|
+
const label = `project "${name}"`;
|
|
1030
|
+
const trackerRepo = ((p["tracker"] as Raw | undefined)?.["repo"]) as string;
|
|
1031
|
+
|
|
1032
|
+
const rawGroomBelow = p["groomBelow"];
|
|
449
1033
|
const groomBelow =
|
|
450
1034
|
typeof rawGroomBelow === "number" && Number.isInteger(rawGroomBelow) && rawGroomBelow >= 1
|
|
451
1035
|
? rawGroomBelow
|
|
452
1036
|
: undefined;
|
|
453
1037
|
|
|
454
|
-
const
|
|
455
|
-
const rawPrefix =
|
|
1038
|
+
const rawRouting = p["routing"] as Raw | undefined;
|
|
1039
|
+
const rawPrefix = rawRouting?.["labelPrefix"];
|
|
456
1040
|
const labelPrefix = typeof rawPrefix === "string" ? rawPrefix : DEFAULT_LABEL_PREFIX;
|
|
457
|
-
const repos =
|
|
458
|
-
|
|
459
|
-
const stateLabels =
|
|
460
|
-
|
|
461
|
-
const
|
|
462
|
-
const
|
|
463
|
-
const
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
// dropped rather than reported, and the session's own model-fallback notice
|
|
470
|
-
// (logged by `runWorker`) is what tells the operator the pattern missed.
|
|
471
|
-
const rawWorkerModel = raw["workerModel"];
|
|
472
|
-
const workerModel = nonEmptyString(rawWorkerModel) ? rawWorkerModel : undefined;
|
|
1041
|
+
const repos = finalizeRepos(rawRouting?.["repos"], label, problems);
|
|
1042
|
+
|
|
1043
|
+
const stateLabels = p["stateLabels"] as Raw | undefined;
|
|
1044
|
+
const escalation = finalizeEscalation(p["escalation"] as Raw | undefined);
|
|
1045
|
+
const authority = finalizeAuthority(p["authority"] as Raw | undefined);
|
|
1046
|
+
const releasePolicy = finalizeReleasePolicy(p["releasePolicy"], label, problems);
|
|
1047
|
+
const policy = finalizePolicy(p["policy"], label, problems);
|
|
1048
|
+
const caps = reconcileCaps(p["caps"], `${label}: caps`, problems, legacyCaps);
|
|
1049
|
+
const reporting = finalizeReporting(p["reporting"], label, problems);
|
|
1050
|
+
const rawWorkerModel = p["workerModel"];
|
|
1051
|
+
const workerModel =
|
|
1052
|
+
typeof rawWorkerModel === "string" && rawWorkerModel.trim() !== "" ? rawWorkerModel : undefined;
|
|
473
1053
|
|
|
474
1054
|
if (problems.length > before) return undefined;
|
|
475
1055
|
|
|
476
1056
|
return {
|
|
477
1057
|
name,
|
|
478
1058
|
tracker: { kind: "github", repo: trackerRepo },
|
|
479
|
-
queueLabel,
|
|
1059
|
+
queueLabel: p["queueLabel"] as string,
|
|
480
1060
|
...(groomBelow === undefined ? {} : { groomBelow }),
|
|
481
1061
|
stateLabels: {
|
|
482
1062
|
inProgress: pickString(stateLabels?.["inProgress"], DEFAULT_STATE_LABELS.inProgress),
|
|
@@ -491,612 +1071,137 @@ function normalizeProject(
|
|
|
491
1071
|
releasePolicy,
|
|
492
1072
|
policy,
|
|
493
1073
|
reporting,
|
|
494
|
-
workspaceRoot: expandHome(pickString(
|
|
495
|
-
mirrorRoot: expandHome(pickString(
|
|
1074
|
+
workspaceRoot: expandHome(pickString(p["workspaceRoot"], defaultWorkspaceRoot())),
|
|
1075
|
+
mirrorRoot: expandHome(pickString(p["mirrorRoot"], defaultMirrorRoot())),
|
|
496
1076
|
};
|
|
497
1077
|
}
|
|
498
1078
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
};
|
|
527
|
-
|
|
528
|
-
function defaultReporting(): ReportingPolicy {
|
|
529
|
-
return { ...DEFAULT_REPORT_POLICY, interruptOn: [...DEFAULT_REPORT_POLICY.interruptOn] };
|
|
1079
|
+
function finalizeEscalation(parsed: Raw | undefined): ProjectConfig["escalation"] {
|
|
1080
|
+
if (parsed === undefined) {
|
|
1081
|
+
return { fallbackToIssueComment: true, orchestrator: "embedded" };
|
|
1082
|
+
}
|
|
1083
|
+
const escalation = {
|
|
1084
|
+
fallbackToIssueComment: parsed["fallbackToIssueComment"] !== false,
|
|
1085
|
+
orchestrator: (parsed["orchestrator"] as ProjectConfig["escalation"]["orchestrator"]) ?? "embedded",
|
|
1086
|
+
} as ProjectConfig["escalation"];
|
|
1087
|
+
const chatId = parsed["telegramChatId"];
|
|
1088
|
+
if (typeof chatId === "string" && chatId.trim() !== "") escalation.telegramChatId = chatId;
|
|
1089
|
+
// Admit a finite integer topic id; numeric strings that parse cleanly count.
|
|
1090
|
+
// Non-integers are omitted rather than rejected so a hand-edit never bricks load.
|
|
1091
|
+
const rawTopic = parsed["telegramTopicId"];
|
|
1092
|
+
if (typeof rawTopic === "number") {
|
|
1093
|
+
if (Number.isFinite(rawTopic) && Number.isSafeInteger(rawTopic)) {
|
|
1094
|
+
escalation.telegramTopicId = rawTopic;
|
|
1095
|
+
}
|
|
1096
|
+
} else if (typeof rawTopic === "string") {
|
|
1097
|
+
const trimmed = rawTopic.trim();
|
|
1098
|
+
if (trimmed !== "") {
|
|
1099
|
+
const n = Number(trimmed);
|
|
1100
|
+
if (Number.isFinite(n) && Number.isSafeInteger(n) && String(n) === trimmed) {
|
|
1101
|
+
escalation.telegramTopicId = n;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return escalation;
|
|
530
1106
|
}
|
|
531
1107
|
|
|
532
|
-
|
|
533
|
-
|
|
1108
|
+
function finalizeAuthority(parsed: Raw | undefined): ProjectConfig["authority"] {
|
|
1109
|
+
if (parsed === undefined) return { ...DEFAULT_AUTHORITY };
|
|
1110
|
+
return {
|
|
1111
|
+
merge: (parsed["merge"] as ProjectConfig["authority"]["merge"]) ?? DEFAULT_AUTHORITY.merge,
|
|
1112
|
+
release: (parsed["release"] as ProjectConfig["authority"]["release"]) ?? DEFAULT_AUTHORITY.release,
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
534
1115
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
* preset IS a policy, so configuring alongside it says one thing and means
|
|
539
|
-
* another (#229).
|
|
540
|
-
*/
|
|
541
|
-
function normalizeReporting(parsed: unknown, label: string, problems: string[]): ReportingPolicy {
|
|
542
|
-
if (parsed === undefined) return defaultReporting();
|
|
1116
|
+
function finalizeReleasePolicy(parsed: unknown, label: string, problems: string[]): ResolvedGrants {
|
|
1117
|
+
if (parsed === undefined || parsed === "none") return { ...DENIED_RELEASE_GRANTS };
|
|
1118
|
+
if (parsed === "operator-brief") return { ...OPERATOR_BRIEF_GRANTS };
|
|
543
1119
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
544
|
-
problems.push(
|
|
545
|
-
|
|
1120
|
+
problems.push(
|
|
1121
|
+
`${label}: releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ` +
|
|
1122
|
+
`${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${JSON.stringify(parsed)}`,
|
|
1123
|
+
);
|
|
1124
|
+
return { ...DENIED_RELEASE_GRANTS };
|
|
546
1125
|
}
|
|
547
1126
|
const raw = parsed as Raw;
|
|
548
|
-
const
|
|
549
|
-
// `scopePreset` is written by a fully-normalised policy (the setup wizard
|
|
550
|
-
// saves presets materialised); `scope` is the legacy form. Both are known.
|
|
551
|
-
const known = ["scope", "interruptOn", "digest", "availability", "scopePreset"];
|
|
552
|
-
const unknownKeys = keys.filter((k) => !known.includes(k));
|
|
1127
|
+
const unknownKeys = Object.keys(raw).filter((k) => !Object.hasOwn(DENIED_RELEASE_GRANTS, k));
|
|
553
1128
|
if (unknownKeys.length > 0) {
|
|
554
|
-
problems.push(`${label}: reporting has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
555
|
-
}
|
|
556
|
-
const hasScope = raw["scope"] !== undefined;
|
|
557
|
-
const hasExplicit = keys.includes("interruptOn") || keys.includes("digest") || keys.includes("availability");
|
|
558
|
-
if (hasScope && hasExplicit) {
|
|
559
1129
|
problems.push(
|
|
560
|
-
`${label}:
|
|
1130
|
+
`${label}: releasePolicy has unknown release shape(s): ${unknownKeys.join(", ")} — expected ${RELEASE_SHAPE_LIST}`,
|
|
561
1131
|
);
|
|
562
|
-
return defaultReporting();
|
|
563
1132
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
`${label}: reporting.scope`,
|
|
570
|
-
REPORT_SCOPE_LIST,
|
|
571
|
-
problems,
|
|
572
|
-
);
|
|
573
|
-
const preset = SCOPE_PRESETS[scope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE];
|
|
574
|
-
return {
|
|
575
|
-
interruptOn: [...preset.interruptOn],
|
|
576
|
-
digest: { ...preset.digest },
|
|
577
|
-
scopePreset: preset.scopePreset,
|
|
578
|
-
};
|
|
1133
|
+
const grants = { ...DENIED_RELEASE_GRANTS };
|
|
1134
|
+
for (const shape of RELEASE_SHAPES) {
|
|
1135
|
+
grants[shape] = (AUTHORITY_HOLDERS as readonly string[]).includes(raw[shape] as string)
|
|
1136
|
+
? (raw[shape] as ResolvedGrants[typeof shape])
|
|
1137
|
+
: "human";
|
|
579
1138
|
}
|
|
1139
|
+
return grants;
|
|
1140
|
+
}
|
|
580
1141
|
|
|
581
|
-
|
|
582
|
-
// round-trips) has interruptOn/digest and may carry scopePreset; keep that
|
|
583
|
-
// back-annotation so the tick prompt can still speak the legacy words.
|
|
584
|
-
const storedPreset = raw["scopePreset"];
|
|
585
|
-
const scopePreset =
|
|
586
|
-
typeof storedPreset === "string" && (REPORT_SCOPES as readonly string[]).includes(storedPreset)
|
|
587
|
-
? (storedPreset as ReportScope)
|
|
588
|
-
: undefined;
|
|
589
|
-
|
|
590
|
-
const interruptOn = normalizeInterruptOn(raw["interruptOn"], label, problems);
|
|
591
|
-
const digest = normalizeDigest(raw["digest"], label, problems);
|
|
592
|
-
const availability = normalizeAvailability(raw["availability"], label, problems);
|
|
593
|
-
// One setup answer supplies both clocks. Hand-written configs may omit the
|
|
594
|
-
// digest copy; the availability zone then becomes its daily clock too.
|
|
595
|
-
if (availability !== undefined && digest.cadence === "daily") {
|
|
596
|
-
if (digest.timezone === undefined) digest.timezone = availability.timezone;
|
|
597
|
-
else if (digest.timezone !== availability.timezone) {
|
|
598
|
-
problems.push(
|
|
599
|
-
`${label}: reporting.digest.timezone must match reporting.availability.timezone`,
|
|
600
|
-
);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
return {
|
|
604
|
-
interruptOn,
|
|
605
|
-
digest,
|
|
606
|
-
...(availability === undefined ? {} : { availability }),
|
|
607
|
-
...(scopePreset === undefined ? {} : { scopePreset }),
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
function normalizeInterruptOn(parsed: unknown, label: string, problems: string[]): InterruptCategory[] {
|
|
612
|
-
const fallback = [...INTERRUPT_CATEGORIES];
|
|
613
|
-
if (parsed === undefined) {
|
|
614
|
-
problems.push(`${label}: reporting.interruptOn is required in the explicit form (or use reporting.scope)`);
|
|
615
|
-
return fallback;
|
|
616
|
-
}
|
|
617
|
-
if (!Array.isArray(parsed)) {
|
|
618
|
-
problems.push(`${label}: reporting.interruptOn must be an array of ${INTERRUPT_CATEGORY_LIST}`);
|
|
619
|
-
return fallback;
|
|
620
|
-
}
|
|
621
|
-
const out: InterruptCategory[] = [];
|
|
622
|
-
for (const item of parsed) {
|
|
623
|
-
if (typeof item !== "string" || !(INTERRUPT_CATEGORIES as readonly string[]).includes(item)) {
|
|
624
|
-
problems.push(`${label}: reporting.interruptOn has unknown category ${JSON.stringify(item)} — one of ${INTERRUPT_CATEGORY_LIST}`);
|
|
625
|
-
continue;
|
|
626
|
-
}
|
|
627
|
-
const category = item as InterruptCategory;
|
|
628
|
-
if (!out.includes(category)) out.push(category);
|
|
629
|
-
}
|
|
630
|
-
if (out.length === 0) {
|
|
631
|
-
problems.push(`${label}: reporting.interruptOn must name at least one category`);
|
|
632
|
-
return fallback;
|
|
633
|
-
}
|
|
634
|
-
return out;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
function normalizeDigest(parsed: unknown, label: string, problems: string[]): ReportingPolicy["digest"] {
|
|
638
|
-
const fallback: ReportingPolicy["digest"] = { cadence: "per-tick" };
|
|
639
|
-
if (parsed === undefined) {
|
|
640
|
-
problems.push(`${label}: reporting.digest is required in the explicit form (or use reporting.scope)`);
|
|
641
|
-
return fallback;
|
|
642
|
-
}
|
|
643
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
644
|
-
problems.push(`${label}: reporting.digest must be an object with a "cadence" of ${DIGEST_CADENCE_LIST}`);
|
|
645
|
-
return fallback;
|
|
646
|
-
}
|
|
647
|
-
const raw = parsed as Raw;
|
|
648
|
-
const unknownKeys = Object.keys(raw).filter((k) => !["cadence", "at", "timezone"].includes(k));
|
|
649
|
-
if (unknownKeys.length > 0) {
|
|
650
|
-
problems.push(`${label}: reporting.digest has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
651
|
-
}
|
|
652
|
-
const cadence = pickLiteral(
|
|
653
|
-
raw["cadence"],
|
|
654
|
-
DIGEST_CADENCES,
|
|
655
|
-
"per-tick",
|
|
656
|
-
`${label}: reporting.digest.cadence`,
|
|
657
|
-
DIGEST_CADENCE_LIST,
|
|
658
|
-
problems,
|
|
659
|
-
);
|
|
660
|
-
const digest: ReportingPolicy["digest"] = { cadence };
|
|
661
|
-
if (cadence !== "daily") {
|
|
662
|
-
if (raw["at"] !== undefined) problems.push(`${label}: reporting.digest.at is only valid with cadence "daily"`);
|
|
663
|
-
if (raw["timezone"] !== undefined) problems.push(`${label}: reporting.digest.timezone is only valid with cadence "daily"`);
|
|
664
|
-
return digest;
|
|
665
|
-
}
|
|
666
|
-
if (raw["at"] !== undefined) {
|
|
667
|
-
if (typeof raw["at"] !== "string" || !DIGEST_AT.test(raw["at"])) {
|
|
668
|
-
problems.push(`${label}: reporting.digest.at must be a 24h HH:MM time`);
|
|
669
|
-
} else {
|
|
670
|
-
digest.at = raw["at"];
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
if (raw["timezone"] !== undefined) {
|
|
674
|
-
if (typeof raw["timezone"] !== "string") {
|
|
675
|
-
problems.push(`${label}: reporting.digest.timezone must be a string`);
|
|
676
|
-
} else {
|
|
677
|
-
try {
|
|
678
|
-
new Intl.DateTimeFormat("en-GB", { timeZone: raw["timezone"] });
|
|
679
|
-
digest.timezone = raw["timezone"];
|
|
680
|
-
} catch {
|
|
681
|
-
problems.push(`${label}: reporting.digest.timezone is not a known IANA timezone`);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
return digest;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
function normalizeAvailability(
|
|
689
|
-
parsed: unknown,
|
|
690
|
-
label: string,
|
|
691
|
-
problems: string[],
|
|
692
|
-
): WeeklyAvailability | undefined {
|
|
693
|
-
if (parsed === undefined) return undefined;
|
|
694
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
695
|
-
problems.push(`${label}: reporting.availability must be an object`);
|
|
696
|
-
return undefined;
|
|
697
|
-
}
|
|
698
|
-
const raw = parsed as Raw;
|
|
699
|
-
const unknownKeys = Object.keys(raw).filter(
|
|
700
|
-
(key) => !["timezone", "days", "start", "end", "bypass"].includes(key),
|
|
701
|
-
);
|
|
702
|
-
if (unknownKeys.length > 0) {
|
|
703
|
-
problems.push(`${label}: reporting.availability has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
let timezone: string | undefined;
|
|
707
|
-
if (typeof raw["timezone"] !== "string" || raw["timezone"].trim() === "") {
|
|
708
|
-
problems.push(`${label}: reporting.availability.timezone must be a known IANA timezone`);
|
|
709
|
-
} else {
|
|
710
|
-
try {
|
|
711
|
-
new Intl.DateTimeFormat("en-GB", { timeZone: raw["timezone"] });
|
|
712
|
-
timezone = raw["timezone"];
|
|
713
|
-
} catch {
|
|
714
|
-
problems.push(`${label}: reporting.availability.timezone is not a known IANA timezone`);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
const days: Weekday[] = [];
|
|
719
|
-
if (!Array.isArray(raw["days"]) || raw["days"].length === 0) {
|
|
720
|
-
problems.push(`${label}: reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`);
|
|
721
|
-
} else {
|
|
722
|
-
for (const item of raw["days"]) {
|
|
723
|
-
if (typeof item !== "string" || !(WEEKDAYS as readonly string[]).includes(item)) {
|
|
724
|
-
problems.push(
|
|
725
|
-
`${label}: reporting.availability.days has unknown day ${JSON.stringify(item)} — one of ${WEEKDAY_LIST}`,
|
|
726
|
-
);
|
|
727
|
-
continue;
|
|
728
|
-
}
|
|
729
|
-
const day = item as Weekday;
|
|
730
|
-
if (!days.includes(day)) days.push(day);
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
const start = raw["start"];
|
|
735
|
-
const end = raw["end"];
|
|
736
|
-
if (typeof start !== "string" || !DIGEST_AT.test(start)) {
|
|
737
|
-
problems.push(`${label}: reporting.availability.start must be a 24h HH:MM time`);
|
|
738
|
-
}
|
|
739
|
-
if (typeof end !== "string" || !DIGEST_AT.test(end)) {
|
|
740
|
-
problems.push(`${label}: reporting.availability.end must be a 24h HH:MM time`);
|
|
741
|
-
}
|
|
742
|
-
if (typeof start === "string" && typeof end === "string" && start === end) {
|
|
743
|
-
problems.push(`${label}: reporting.availability.start and end must differ`);
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
const bypass: InterruptCategory[] = [];
|
|
747
|
-
if (!Array.isArray(raw["bypass"])) {
|
|
748
|
-
problems.push(
|
|
749
|
-
`${label}: reporting.availability.bypass must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`,
|
|
750
|
-
);
|
|
751
|
-
} else {
|
|
752
|
-
for (const item of raw["bypass"]) {
|
|
753
|
-
if (typeof item !== "string" || !(INTERRUPT_CATEGORIES as readonly string[]).includes(item)) {
|
|
754
|
-
problems.push(
|
|
755
|
-
`${label}: reporting.availability.bypass has unknown category ${JSON.stringify(item)} — one of ${INTERRUPT_CATEGORY_LIST}`,
|
|
756
|
-
);
|
|
757
|
-
continue;
|
|
758
|
-
}
|
|
759
|
-
const category = item as InterruptCategory;
|
|
760
|
-
if (!bypass.includes(category)) bypass.push(category);
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
return timezone === undefined ||
|
|
765
|
-
days.length === 0 ||
|
|
766
|
-
typeof start !== "string" ||
|
|
767
|
-
!DIGEST_AT.test(start) ||
|
|
768
|
-
typeof end !== "string" ||
|
|
769
|
-
!DIGEST_AT.test(end) ||
|
|
770
|
-
start === end ||
|
|
771
|
-
!Array.isArray(raw["bypass"])
|
|
772
|
-
? undefined
|
|
773
|
-
: { timezone, days, start, end, bypass };
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/**
|
|
777
|
-
* Who triages escalations, and how they are delivered when nobody answers.
|
|
778
|
-
*
|
|
779
|
-
* `orchestrator` is validated rather than folded to the default for the reason
|
|
780
|
-
* `reporting.scope` is: a misspelt `"externl"` that quietly resolved to
|
|
781
|
-
* `"embedded"` would start a second brain beside the operator's own session,
|
|
782
|
-
* and both of them would triage the same issue from different transcripts.
|
|
783
|
-
*/
|
|
784
|
-
function normalizeEscalation(parsed: unknown, label: string, problems: string[]): ProjectConfig["escalation"] {
|
|
785
|
-
let raw: Raw = {};
|
|
786
|
-
if (parsed !== undefined) {
|
|
787
|
-
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) raw = parsed as Raw;
|
|
788
|
-
else problems.push(`${label}: escalation must be an object`);
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
const escalation: ProjectConfig["escalation"] = {
|
|
792
|
-
// Absent means "yes, still tell me": a silently stuck run is the worst case.
|
|
793
|
-
fallbackToIssueComment: raw["fallbackToIssueComment"] !== false,
|
|
794
|
-
orchestrator: pickLiteral(
|
|
795
|
-
raw["orchestrator"],
|
|
796
|
-
ORCHESTRATOR_MODES,
|
|
797
|
-
"embedded",
|
|
798
|
-
`${label}: escalation.orchestrator`,
|
|
799
|
-
ORCHESTRATOR_MODE_LIST,
|
|
800
|
-
problems,
|
|
801
|
-
),
|
|
802
|
-
};
|
|
803
|
-
const chatId = raw["telegramChatId"];
|
|
804
|
-
if (nonEmptyString(chatId)) escalation.telegramChatId = chatId;
|
|
805
|
-
return escalation;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/**
|
|
809
|
-
* Who lands PRs and who cuts releases. Both default to the human: this is the
|
|
810
|
-
* one config value that decides whether an unattended session may write to a
|
|
811
|
-
* main branch, so it is granted explicitly or not at all.
|
|
812
|
-
*
|
|
813
|
-
* Unknown keys are rejected outright, as in `reporting` and for the same
|
|
814
|
-
* reason: the object has exactly two members, so an unrecognised one is a typo
|
|
815
|
-
* every time — and a `authority: { merges: "orchestrator" }` that loaded
|
|
816
|
-
* cleanly would read as delegated while the orchestrator was still told to keep
|
|
817
|
-
* its hands off.
|
|
818
|
-
*/
|
|
819
|
-
function normalizeAuthority(parsed: unknown, label: string, problems: string[]): ProjectConfig["authority"] {
|
|
820
|
-
if (parsed === undefined) return { ...DEFAULT_AUTHORITY };
|
|
821
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
822
|
-
problems.push(`${label}: authority must be an object with "merge" and "release" of ${AUTHORITY_HOLDER_LIST}`);
|
|
823
|
-
return { ...DEFAULT_AUTHORITY };
|
|
824
|
-
}
|
|
825
|
-
const raw = parsed as Raw;
|
|
826
|
-
|
|
827
|
-
const unknownKeys = Object.keys(raw).filter((k) => k !== "merge" && k !== "release");
|
|
828
|
-
if (unknownKeys.length > 0) {
|
|
829
|
-
problems.push(`${label}: authority has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
return {
|
|
833
|
-
merge: pickLiteral(
|
|
834
|
-
raw["merge"],
|
|
835
|
-
AUTHORITY_HOLDERS,
|
|
836
|
-
DEFAULT_AUTHORITY.merge,
|
|
837
|
-
`${label}: authority.merge`,
|
|
838
|
-
AUTHORITY_HOLDER_LIST,
|
|
839
|
-
problems,
|
|
840
|
-
),
|
|
841
|
-
release: pickLiteral(
|
|
842
|
-
raw["release"],
|
|
843
|
-
AUTHORITY_HOLDERS,
|
|
844
|
-
DEFAULT_AUTHORITY.release,
|
|
845
|
-
`${label}: authority.release`,
|
|
846
|
-
AUTHORITY_HOLDER_LIST,
|
|
847
|
-
problems,
|
|
848
|
-
),
|
|
849
|
-
};
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
/**
|
|
853
|
-
* Per-shape release grants, and the migration off the two legacy strings.
|
|
854
|
-
*
|
|
855
|
-
* Normalised to a *complete* map at load, so everything downstream — the gate,
|
|
856
|
-
* `status`, the standing orders, the wizard — reads the same five answers and
|
|
857
|
-
* none of them has to know which spelling was on disk.
|
|
858
|
-
*
|
|
859
|
-
* `"operator-brief"` migrates to every shape except `deploy` (#122). That is the
|
|
860
|
-
* safe reading of what an operator believed the binary gate opened: a stale
|
|
861
|
-
* `operator-brief` was enough for an orchestrator session to invoke Komodo
|
|
862
|
-
* `DeployStack`, which is the grant nobody knowingly gave.
|
|
863
|
-
*
|
|
864
|
-
* An unknown shape key is rejected rather than ignored, as in `authority` and
|
|
865
|
-
* for the same reason: the set is closed, so `{ "deploy-prod": "orchestrator" }`
|
|
866
|
-
* is a typo every time — and a config that loaded it cleanly would read as
|
|
867
|
-
* granted in the file while the gate denied every call.
|
|
868
|
-
*/
|
|
869
|
-
function normalizeReleasePolicy(parsed: unknown, label: string, problems: string[]): ResolvedGrants {
|
|
870
|
-
if (parsed === undefined || parsed === "none") return { ...DENIED_RELEASE_GRANTS };
|
|
871
|
-
if (parsed === "operator-brief") return { ...OPERATOR_BRIEF_GRANTS };
|
|
872
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
873
|
-
problems.push(
|
|
874
|
-
`${label}: releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ` +
|
|
875
|
-
`${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${JSON.stringify(parsed)}`,
|
|
876
|
-
);
|
|
877
|
-
return { ...DENIED_RELEASE_GRANTS };
|
|
878
|
-
}
|
|
879
|
-
const raw = parsed as Raw;
|
|
880
|
-
|
|
881
|
-
const unknownKeys = Object.keys(raw).filter((k) => !Object.hasOwn(DENIED_RELEASE_GRANTS, k));
|
|
882
|
-
if (unknownKeys.length > 0) {
|
|
883
|
-
problems.push(
|
|
884
|
-
`${label}: releasePolicy has unknown release shape(s): ${unknownKeys.join(", ")} — expected ${RELEASE_SHAPE_LIST}`,
|
|
885
|
-
);
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
const grants = { ...DENIED_RELEASE_GRANTS };
|
|
889
|
-
for (const shape of RELEASE_SHAPES) {
|
|
890
|
-
// An absent shape stays denied; a present-but-malformed one is reported and
|
|
891
|
-
// also stays denied, never folded to the value the operator asked for.
|
|
892
|
-
grants[shape] = pickLiteral(
|
|
893
|
-
raw[shape],
|
|
894
|
-
AUTHORITY_HOLDERS,
|
|
895
|
-
"human",
|
|
896
|
-
`${label}: releasePolicy.${shape}`,
|
|
897
|
-
AUTHORITY_HOLDER_LIST,
|
|
898
|
-
problems,
|
|
899
|
-
);
|
|
900
|
-
}
|
|
901
|
-
return grants;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
/**
|
|
905
|
-
* The merge and release gating conditions (#129).
|
|
906
|
-
*
|
|
907
|
-
* These used to be sentences in the operator's POLICY.md, which meant a verb
|
|
908
|
-
* could only honour them by asking a model to read prose. Typed and normalised
|
|
909
|
-
* here to a *complete* value, so the gate, the wizard, the plan summary and the
|
|
910
|
-
* brief all read the same answers and none of them has to know which fields
|
|
911
|
-
* were spelled on disk.
|
|
912
|
-
*
|
|
913
|
-
* Fail-closed throughout, as in `authority` and for the same reason: both
|
|
914
|
-
* sections have a closed key set, so an unrecognised key is a typo every time —
|
|
915
|
-
* and a `policy: { merge: { requiredCheck: [...] } }` that loaded cleanly would
|
|
916
|
-
* read as configured in the file while the gate went on requiring every check.
|
|
917
|
-
* A malformed value takes the documented default and is reported; it is never
|
|
918
|
-
* folded to whatever the operator asked for.
|
|
919
|
-
*/
|
|
920
|
-
function normalizeProjectPolicy(parsed: unknown, label: string, problems: string[]): ProjectPolicy {
|
|
1142
|
+
function finalizePolicy(parsed: unknown, label: string, problems: string[]): ProjectPolicy {
|
|
921
1143
|
if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY);
|
|
922
1144
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
923
1145
|
problems.push(`${label}: policy must be an object with "merge" and "release" sections`);
|
|
924
1146
|
return clonePolicy(DEFAULT_PROJECT_POLICY);
|
|
925
1147
|
}
|
|
926
1148
|
const raw = parsed as Raw;
|
|
927
|
-
|
|
928
|
-
const unknownKeys = Object.keys(raw).filter((k) => k !== "merge" && k !== "release");
|
|
929
|
-
if (unknownKeys.length > 0) {
|
|
930
|
-
problems.push(`${label}: policy has unknown key(s): ${unknownKeys.join(", ")} — expected "merge" or "release"`);
|
|
931
|
-
}
|
|
932
|
-
|
|
933
1149
|
return {
|
|
934
|
-
merge:
|
|
935
|
-
release:
|
|
1150
|
+
merge: finalizeMergePreconditions(raw["merge"], problems),
|
|
1151
|
+
release: finalizeReleasePreconditions(raw["release"], problems),
|
|
936
1152
|
};
|
|
937
1153
|
}
|
|
938
1154
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).merge;
|
|
942
|
-
if (parsed === undefined) return fallback;
|
|
943
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
944
|
-
problems.push(`${at} must be an object`);
|
|
945
|
-
return fallback;
|
|
946
|
-
}
|
|
1155
|
+
function finalizeMergePreconditions(parsed: unknown, problems: string[]): MergePreconditions {
|
|
1156
|
+
if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY).merge;
|
|
947
1157
|
const raw = parsed as Raw;
|
|
948
|
-
|
|
949
|
-
const known = new Set(Object.keys(fallback));
|
|
950
|
-
const unknownKeys = Object.keys(raw).filter((k) => !known.has(k));
|
|
951
|
-
if (unknownKeys.length > 0) {
|
|
952
|
-
problems.push(`${at} has unknown key(s): ${unknownKeys.join(", ")} — expected ${quoteList([...known])}`);
|
|
953
|
-
}
|
|
954
|
-
|
|
1158
|
+
const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).merge;
|
|
955
1159
|
return {
|
|
956
|
-
requiredChecks:
|
|
957
|
-
baseFreshness:
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
fallback.baseFreshness,
|
|
961
|
-
`${at}.baseFreshness`,
|
|
962
|
-
BASE_FRESHNESS_LIST,
|
|
963
|
-
problems,
|
|
964
|
-
),
|
|
965
|
-
drafts: pickLiteral(raw["drafts"], DRAFT_POLICIES, fallback.drafts, `${at}.drafts`, DRAFT_POLICY_LIST, problems),
|
|
966
|
-
whenBehindBase: pickLiteral(
|
|
967
|
-
raw["whenBehindBase"],
|
|
968
|
-
BEHIND_BASE_ACTIONS,
|
|
969
|
-
fallback.whenBehindBase,
|
|
970
|
-
`${at}.whenBehindBase`,
|
|
971
|
-
BEHIND_BASE_ACTION_LIST,
|
|
972
|
-
problems,
|
|
973
|
-
),
|
|
1160
|
+
requiredChecks: trimNames(raw["requiredChecks"]),
|
|
1161
|
+
baseFreshness: (raw["baseFreshness"] as MergePreconditions["baseFreshness"]) ?? fallback.baseFreshness,
|
|
1162
|
+
drafts: (raw["drafts"] as MergePreconditions["drafts"]) ?? fallback.drafts,
|
|
1163
|
+
whenBehindBase: (raw["whenBehindBase"] as MergePreconditions["whenBehindBase"]) ?? fallback.whenBehindBase,
|
|
974
1164
|
};
|
|
975
1165
|
}
|
|
976
1166
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).release;
|
|
980
|
-
if (parsed === undefined) return fallback;
|
|
981
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
982
|
-
problems.push(`${at} must be an object`);
|
|
983
|
-
return fallback;
|
|
984
|
-
}
|
|
1167
|
+
function finalizeReleasePreconditions(parsed: unknown, problems: string[]): ReleasePreconditions {
|
|
1168
|
+
if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY).release;
|
|
985
1169
|
const raw = parsed as Raw;
|
|
986
|
-
|
|
987
|
-
const known = new Set(Object.keys(fallback));
|
|
988
|
-
const unknownKeys = Object.keys(raw).filter((k) => !known.has(k));
|
|
989
|
-
if (unknownKeys.length > 0) {
|
|
990
|
-
problems.push(`${at} has unknown key(s): ${unknownKeys.join(", ")} — expected ${quoteList([...known])}`);
|
|
991
|
-
}
|
|
992
|
-
|
|
1170
|
+
const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).release;
|
|
993
1171
|
return {
|
|
994
|
-
requires:
|
|
995
|
-
requiredChecks:
|
|
996
|
-
artefacts:
|
|
997
|
-
environments:
|
|
1172
|
+
requires: canonicalRequirements(raw["requires"]),
|
|
1173
|
+
requiredChecks: trimNames(raw["requiredChecks"]),
|
|
1174
|
+
artefacts: trimNames(raw["artefacts"]),
|
|
1175
|
+
environments: trimNames(raw["environments"]),
|
|
998
1176
|
};
|
|
999
1177
|
}
|
|
1000
1178
|
|
|
1001
|
-
/**
|
|
1002
|
-
|
|
1003
|
-
*
|
|
1004
|
-
* Canonical order and de-duplication because this list is rendered into a
|
|
1005
|
-
* refusal and into the plan summary: two configs that require the same three
|
|
1006
|
-
* things must read identically, or an operator diffing them sees a change that
|
|
1007
|
-
* is not one.
|
|
1008
|
-
*/
|
|
1009
|
-
function normalizeReleaseRequirements(
|
|
1010
|
-
parsed: unknown,
|
|
1011
|
-
at: string,
|
|
1012
|
-
fallback: readonly ReleaseRequirement[],
|
|
1013
|
-
problems: string[],
|
|
1014
|
-
): ReleaseRequirement[] {
|
|
1015
|
-
if (parsed === undefined) return [...fallback];
|
|
1016
|
-
if (!Array.isArray(parsed)) {
|
|
1017
|
-
problems.push(`${at} must be an array of ${RELEASE_REQUIREMENT_LIST}`);
|
|
1018
|
-
return [...fallback];
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1179
|
+
/** The `requires` set in vocabulary order, deduplicated (#129's promise). */
|
|
1180
|
+
function canonicalRequirements(parsed: unknown): ReleaseRequirement[] {
|
|
1021
1181
|
const chosen = new Set<string>();
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
problems.push(`${at}[${i}] must be ${RELEASE_REQUIREMENT_LIST}, found ${JSON.stringify(entry)}`);
|
|
1025
|
-
return;
|
|
1026
|
-
}
|
|
1027
|
-
chosen.add(entry as ReleaseRequirement);
|
|
1028
|
-
});
|
|
1182
|
+
if (!Array.isArray(parsed)) return [];
|
|
1183
|
+
for (const entry of parsed) chosen.add(entry as string);
|
|
1029
1184
|
return RELEASE_REQUIREMENTS.filter((r) => chosen.has(r));
|
|
1030
1185
|
}
|
|
1031
1186
|
|
|
1032
|
-
/**
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
* but a malformed entry still rejects the whole config rather than being
|
|
1037
|
-
* dropped. A silently dropped environment name is an operator debugging a
|
|
1038
|
-
* refusal that reads exactly like a correctly-denied one.
|
|
1039
|
-
*/
|
|
1040
|
-
function normalizeNameList(parsed: unknown, at: string, problems: string[]): string[] {
|
|
1041
|
-
if (parsed === undefined) return [];
|
|
1042
|
-
if (!Array.isArray(parsed)) {
|
|
1043
|
-
problems.push(`${at} must be an array of non-empty strings`);
|
|
1044
|
-
return [];
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
const names: string[] = [];
|
|
1048
|
-
parsed.forEach((entry: unknown, i) => {
|
|
1049
|
-
if (!nonEmptyString(entry)) {
|
|
1050
|
-
problems.push(`${at}[${i}] must be a non-empty string, found ${JSON.stringify(entry)}`);
|
|
1051
|
-
return;
|
|
1052
|
-
}
|
|
1053
|
-
names.push(entry.trim());
|
|
1054
|
-
});
|
|
1055
|
-
return names;
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
/**
|
|
1059
|
-
* A clone URL carrying a password or token is rejected at load.
|
|
1060
|
-
*
|
|
1061
|
-
* `git clone` persists whatever is in the URL into the mirror's config, and the
|
|
1062
|
-
* run repository inherits `origin` from it — so a `https://<pat>@github.com/…`
|
|
1063
|
-
* writes the operator's credential into a file on disk that every later run
|
|
1064
|
-
* reads, where nobody is looking for it. This used to be a `ponytail` note on
|
|
1065
|
-
* `worktree.ts`'s `ensureMirror`; a warning nobody reads is not a check, so it
|
|
1066
|
-
* is now rejected at load with the field named.
|
|
1067
|
-
*
|
|
1068
|
-
* An SSH URL with a plain username (`ssh://git@github.com/o/r`, `git@github.com:o/r`)
|
|
1069
|
-
* is not a credential and is left alone — that is how nearly every fleet is
|
|
1070
|
-
* configured, and rejecting it would be a migration nobody asked for.
|
|
1071
|
-
*/
|
|
1072
|
-
export function cloneUrlCredentialProblem(url: string): string | undefined {
|
|
1073
|
-
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/]*)@/.exec(url);
|
|
1074
|
-
if (m === null) return undefined;
|
|
1075
|
-
const scheme = (m[1] ?? "").toLowerCase();
|
|
1076
|
-
const userinfo = m[2] ?? "";
|
|
1077
|
-
if (userinfo.includes(":")) {
|
|
1078
|
-
return "embeds a user:password — git persists it into the mirror config, which hands every session the credential";
|
|
1079
|
-
}
|
|
1080
|
-
if (scheme === "http" || scheme === "https") {
|
|
1081
|
-
return "embeds userinfo in an http(s) URL, which is how a personal access token is spelled — git persists it into the mirror config";
|
|
1082
|
-
}
|
|
1083
|
-
return undefined;
|
|
1187
|
+
/** Trims name-list entries (validation is zod's; this only canonises). */
|
|
1188
|
+
function trimNames(parsed: unknown): string[] {
|
|
1189
|
+
if (!Array.isArray(parsed)) return [];
|
|
1190
|
+
return parsed.map((n) => (n as string).trim());
|
|
1084
1191
|
}
|
|
1085
1192
|
|
|
1086
|
-
function
|
|
1193
|
+
function finalizeRepos(parsed: unknown, label: string, problems: string[]): Record<string, RepoTarget> {
|
|
1087
1194
|
const repos: Record<string, RepoTarget> = {};
|
|
1088
|
-
|
|
1089
1195
|
const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined;
|
|
1090
1196
|
const entries = raw === undefined ? [] : Object.entries(raw);
|
|
1091
1197
|
if (entries.length === 0) {
|
|
1092
1198
|
problems.push(`${label}: routing.repos needs at least one repo entry, or no issue can be routed`);
|
|
1093
1199
|
return repos;
|
|
1094
1200
|
}
|
|
1095
|
-
|
|
1096
1201
|
for (const [key, entry] of entries) {
|
|
1097
1202
|
const value = entry as Raw | undefined;
|
|
1098
|
-
const cloneUrl = value?.["cloneUrl"];
|
|
1099
|
-
if (
|
|
1203
|
+
const cloneUrl = value?.["cloneUrl"] as string | undefined;
|
|
1204
|
+
if (typeof cloneUrl !== "string" || cloneUrl.trim() === "") {
|
|
1100
1205
|
problems.push(`${label}: routing.repos.${key}.cloneUrl must be a non-empty string`);
|
|
1101
1206
|
continue;
|
|
1102
1207
|
}
|
|
@@ -1112,97 +1217,28 @@ function normalizeRepos(parsed: unknown, label: string, problems: string[]): Rec
|
|
|
1112
1217
|
name: pickString(value?.["name"], key),
|
|
1113
1218
|
cloneUrl,
|
|
1114
1219
|
defaultBranch: pickString(value?.["defaultBranch"], "main"),
|
|
1115
|
-
gates:
|
|
1220
|
+
gates: finalizeGates(value?.["gates"], `${label}: routing.repos.${key}`, problems),
|
|
1116
1221
|
};
|
|
1117
|
-
const graph =
|
|
1222
|
+
const graph = finalizeGraphProject(value?.["graphProject"], `${label}: routing.repos.${key}`, problems);
|
|
1118
1223
|
if (graph !== undefined) target.graphProject = graph;
|
|
1119
|
-
const migrations =
|
|
1224
|
+
const migrations = finalizeMigrationsDir(value?.["migrations"], `${label}: routing.repos.${key}`, problems);
|
|
1120
1225
|
if (migrations !== undefined) target.migrations = { dir: migrations };
|
|
1121
1226
|
repos[key] = target;
|
|
1122
1227
|
}
|
|
1123
|
-
|
|
1124
1228
|
return repos;
|
|
1125
1229
|
}
|
|
1126
1230
|
|
|
1127
|
-
|
|
1128
|
-
* The ordered-migration-chain directory (#227), or `undefined` when the repo
|
|
1129
|
-
* opts out of the chain check.
|
|
1130
|
-
*
|
|
1131
|
-
* Repo-relative, no leading `/`, and no `..` segment — the value is read in one
|
|
1132
|
-
* process and used in another against the base branch's tree, so anything that
|
|
1133
|
-
* is not plainly a directory name is a guess the guard must not make.
|
|
1134
|
-
*/
|
|
1135
|
-
function normalizeMigrationsDir(parsed: unknown, label: string, problems: string[]): string | undefined {
|
|
1136
|
-
if (parsed === undefined) return undefined;
|
|
1137
|
-
const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined;
|
|
1138
|
-
const dir = raw?.["dir"];
|
|
1139
|
-
if (!nonEmptyString(dir)) {
|
|
1140
|
-
problems.push(`${label}.migrations.dir must be a non-empty string`);
|
|
1141
|
-
return undefined;
|
|
1142
|
-
}
|
|
1143
|
-
if (dir.startsWith("/") || dir.split("/").includes("..")) {
|
|
1144
|
-
problems.push(`${label}.migrations.dir must be a repo-relative directory`);
|
|
1145
|
-
return undefined;
|
|
1146
|
-
}
|
|
1147
|
-
return dir;
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
/**
|
|
1151
|
-
* The path of the index-only clone whose code graph this repo's workers query,
|
|
1152
|
-
* or `undefined` when the repo has none.
|
|
1153
|
-
*
|
|
1154
|
-
* A relative path is rejected rather than resolved, and that rejection is the
|
|
1155
|
-
* whole reason this is validated here: the value is written in one process and
|
|
1156
|
-
* *used* in another, by a session whose cwd is its own throwaway worktree. So
|
|
1157
|
-
* `../graph/api` would name a different directory for every reader, and none of
|
|
1158
|
-
* them the one that was indexed. There is no cwd this file could honestly
|
|
1159
|
-
* resolve it against, so it says so rather than guessing.
|
|
1160
|
-
*/
|
|
1161
|
-
function normalizeGraphProject(parsed: unknown, label: string, problems: string[]): string | undefined {
|
|
1162
|
-
if (parsed === undefined) return undefined;
|
|
1163
|
-
if (!nonEmptyString(parsed)) {
|
|
1164
|
-
problems.push(`${label}.graphProject must be a non-empty absolute path, found ${JSON.stringify(parsed)}`);
|
|
1165
|
-
return undefined;
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
const path = expandHome(parsed.trim());
|
|
1169
|
-
if (isAbsolute(path)) return path;
|
|
1170
|
-
problems.push(
|
|
1171
|
-
`${label}.graphProject must be an absolute path — it is read by sessions whose cwd is their own ` +
|
|
1172
|
-
`worktree — found ${JSON.stringify(parsed)}`,
|
|
1173
|
-
);
|
|
1174
|
-
return undefined;
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
/**
|
|
1178
|
-
* `orchestratorReadPaths` was the allowlist extension for the orchestrator's
|
|
1179
|
-
* file gate (#127). The gate is gone — the orchestrator is unconfined by
|
|
1180
|
-
* operator ruling (#143) — and with it every reader of this key.
|
|
1181
|
-
*
|
|
1182
|
-
* It is deliberately *not* validated, and not rejected either: project-level
|
|
1183
|
-
* keys this build does not know are ignored, so a live fleet whose config still
|
|
1184
|
-
* carries it keeps loading. A retired key that failed validation would brick
|
|
1185
|
-
* exactly the fleets that adopted it, which is the 0.4.1→0.4.2 outage repeated
|
|
1186
|
-
* on purpose.
|
|
1187
|
-
*/
|
|
1188
|
-
|
|
1189
|
-
/**
|
|
1190
|
-
* Gates are the pre-push CI equivalent, so a malformed entry is an error, not
|
|
1191
|
-
* something to drop quietly: a skipped gate is exactly how a lint failure
|
|
1192
|
-
* reaches the runners unattended.
|
|
1193
|
-
*/
|
|
1194
|
-
function normalizeGates(parsed: unknown, label: string, problems: string[]): { cmd: string; cwd: string }[] {
|
|
1231
|
+
function finalizeGates(parsed: unknown, label: string, problems: string[]): { cmd: string; cwd: string }[] {
|
|
1195
1232
|
if (parsed === undefined) return [];
|
|
1196
1233
|
if (!Array.isArray(parsed)) {
|
|
1197
1234
|
problems.push(`${label}.gates must be an array of { cmd, cwd }`);
|
|
1198
1235
|
return [];
|
|
1199
1236
|
}
|
|
1200
|
-
|
|
1201
1237
|
const gates: { cmd: string; cwd: string }[] = [];
|
|
1202
1238
|
parsed.forEach((entry: unknown, i) => {
|
|
1203
1239
|
const gate = entry as Raw | undefined;
|
|
1204
1240
|
const cmd = gate?.["cmd"];
|
|
1205
|
-
if (
|
|
1241
|
+
if (typeof cmd !== "string" || cmd.trim() === "") {
|
|
1206
1242
|
problems.push(`${label}.gates[${i}] must be { cmd, cwd } with a non-empty cmd`);
|
|
1207
1243
|
return;
|
|
1208
1244
|
}
|
|
@@ -1211,17 +1247,47 @@ function normalizeGates(parsed: unknown, label: string, problems: string[]): { c
|
|
|
1211
1247
|
return gates;
|
|
1212
1248
|
}
|
|
1213
1249
|
|
|
1250
|
+
function finalizeMigrationsDir(parsed: unknown, label: string, problems: string[]): string | undefined {
|
|
1251
|
+
if (parsed === undefined) return undefined;
|
|
1252
|
+
const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined;
|
|
1253
|
+
const dir = raw?.["dir"];
|
|
1254
|
+
if (typeof dir !== "string" || dir.trim() === "") {
|
|
1255
|
+
problems.push(`${label}.migrations.dir must be a non-empty string`);
|
|
1256
|
+
return undefined;
|
|
1257
|
+
}
|
|
1258
|
+
if (dir.startsWith("/") || dir.split("/").includes("..")) {
|
|
1259
|
+
problems.push(`${label}.migrations.dir must be a repo-relative directory`);
|
|
1260
|
+
return undefined;
|
|
1261
|
+
}
|
|
1262
|
+
return dir;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function finalizeGraphProject(parsed: unknown, label: string, problems: string[]): string | undefined {
|
|
1266
|
+
if (parsed === undefined) return undefined;
|
|
1267
|
+
if (typeof parsed !== "string" || parsed.trim() === "") {
|
|
1268
|
+
problems.push(`${label}.graphProject must be a non-empty absolute path, found ${JSON.stringify(parsed)}`);
|
|
1269
|
+
return undefined;
|
|
1270
|
+
}
|
|
1271
|
+
const path = expandHome(parsed.trim());
|
|
1272
|
+
if (isAbsolute(path)) return path;
|
|
1273
|
+
problems.push(
|
|
1274
|
+
`${label}.graphProject must be an absolute path — it is read by sessions whose cwd is their own ` +
|
|
1275
|
+
`worktree — found ${JSON.stringify(parsed)}`,
|
|
1276
|
+
);
|
|
1277
|
+
return undefined;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1214
1280
|
/**
|
|
1215
|
-
*
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
* `legacy` decides what an *unrecognised* key means. In a v1 file it is a cap
|
|
1219
|
-
* this version retired, so it is dropped and the config still loads — refusing
|
|
1220
|
-
* would strand a fleet on upgrade. In a v2 file every key this build writes is
|
|
1221
|
-
* current, so an unknown one is a typo and is reported: otherwise a mistyped
|
|
1222
|
-
* `dailySpendUsd` reads as configured while the real ceiling is the default.
|
|
1281
|
+
* Reconciles a caps object against the active key set, version-aware. zod has
|
|
1282
|
+
* already typed the *known* cap values; this drop-versus-reject is the one
|
|
1283
|
+
* version-keyed decision that impossibly lives in a value schema.
|
|
1223
1284
|
*/
|
|
1224
|
-
function
|
|
1285
|
+
function reconcileCaps(
|
|
1286
|
+
parsed: unknown,
|
|
1287
|
+
label: string,
|
|
1288
|
+
problems: string[],
|
|
1289
|
+
legacy: boolean,
|
|
1290
|
+
): Partial<Caps> {
|
|
1225
1291
|
const out: Partial<Caps> = {};
|
|
1226
1292
|
if (parsed === undefined) return out;
|
|
1227
1293
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
@@ -1233,29 +1299,24 @@ function coerceCaps(parsed: unknown, label: string, problems: string[], legacy:
|
|
|
1233
1299
|
for (const key of CAP_KEYS) {
|
|
1234
1300
|
const v = raw[key];
|
|
1235
1301
|
if (v === undefined) continue;
|
|
1236
|
-
// The plan-allowance guard is the only cap that is an object rather than a
|
|
1237
|
-
// number, so it validates its own shape before the numeric rule below can
|
|
1238
|
-
// reject it wholesale.
|
|
1239
1302
|
if (key === "planUsage") {
|
|
1240
1303
|
const cap = coercePlanUsage(v, `${label}.planUsage`, problems);
|
|
1241
1304
|
if (cap !== undefined) out.planUsage = cap;
|
|
1242
1305
|
continue;
|
|
1243
1306
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
if (key === "dailySpendUsd" && v === null) {
|
|
1247
|
-
out.dailySpendUsd = null;
|
|
1307
|
+
if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
|
|
1308
|
+
(out as Record<string, unknown>)[key as string] = v;
|
|
1248
1309
|
continue;
|
|
1249
1310
|
}
|
|
1250
|
-
if (
|
|
1251
|
-
|
|
1252
|
-
key === "dailySpendUsd"
|
|
1253
|
-
? `${label}.${key} must be a non-negative finite number or null (no cap), found ${JSON.stringify(v)}`
|
|
1254
|
-
: `${label}.${key} must be a non-negative finite number, found ${JSON.stringify(v)}`,
|
|
1255
|
-
);
|
|
1311
|
+
if (key === "dailySpendUsd" && v === null) {
|
|
1312
|
+
out.dailySpendUsd = null;
|
|
1256
1313
|
continue;
|
|
1257
1314
|
}
|
|
1258
|
-
|
|
1315
|
+
problems.push(
|
|
1316
|
+
key === "dailySpendUsd"
|
|
1317
|
+
? `${label}.${key} must be a non-negative finite number or null (no cap), found ${JSON.stringify(v)}`
|
|
1318
|
+
: `${label}.${key} must be a non-negative finite number, found ${JSON.stringify(v)}`,
|
|
1319
|
+
);
|
|
1259
1320
|
}
|
|
1260
1321
|
|
|
1261
1322
|
if (!legacy) {
|
|
@@ -1266,28 +1327,9 @@ function coerceCaps(parsed: unknown, label: string, problems: string[], legacy:
|
|
|
1266
1327
|
);
|
|
1267
1328
|
}
|
|
1268
1329
|
}
|
|
1269
|
-
|
|
1270
1330
|
return out;
|
|
1271
1331
|
}
|
|
1272
1332
|
|
|
1273
|
-
/**
|
|
1274
|
-
* `{ windowId, maxUsedFraction }`, or `null` for unmetered.
|
|
1275
|
-
*
|
|
1276
|
-
* Fail-closed for the same reason every other guard here is: a plan-allowance
|
|
1277
|
-
* cap the daemon cannot read is a ceiling the operator believes they have. Two
|
|
1278
|
-
* mistakes in particular are rejected by name rather than folded:
|
|
1279
|
-
*
|
|
1280
|
-
* - **`maxUsedFraction: 85`.** The threshold is a fraction, so `85` compares
|
|
1281
|
-
* as "hold at 8500% consumed" — a guard that can never fire, and one that
|
|
1282
|
-
* reads in the config file exactly like a deliberate 85% ceiling.
|
|
1283
|
-
* - **A misspelled key.** `windowID`, `window`, `maxUsedPercent` and friends
|
|
1284
|
-
* would leave a cap with a missing half, which is the same silent
|
|
1285
|
-
* never-fires outcome.
|
|
1286
|
-
*
|
|
1287
|
-
* A `windowId` no provider reports cannot be caught here — only a live reading
|
|
1288
|
-
* knows what exists — so that case is caught at admission instead, where it
|
|
1289
|
-
* holds dispatch and names the window (see `planUsageStatus` in `usage.ts`).
|
|
1290
|
-
*/
|
|
1291
1333
|
function coercePlanUsage(
|
|
1292
1334
|
v: unknown,
|
|
1293
1335
|
label: string,
|
|
@@ -1301,15 +1343,14 @@ function coercePlanUsage(
|
|
|
1301
1343
|
return undefined;
|
|
1302
1344
|
}
|
|
1303
1345
|
const raw = v as Raw;
|
|
1346
|
+
const unknownKeys = Object.keys(raw).filter((k) => k !== "windowId" && k !== "maxUsedFraction");
|
|
1304
1347
|
const rawId = raw["windowId"];
|
|
1348
|
+
const windowId = typeof rawId === "string" && rawId.trim() !== "" ? rawId.trim() : undefined;
|
|
1305
1349
|
const rawFraction = raw["maxUsedFraction"];
|
|
1306
|
-
const windowId = nonEmptyString(rawId) ? rawId.trim() : undefined;
|
|
1307
1350
|
const maxUsedFraction =
|
|
1308
1351
|
typeof rawFraction === "number" && Number.isFinite(rawFraction) && rawFraction >= 0 && rawFraction <= 1
|
|
1309
1352
|
? rawFraction
|
|
1310
1353
|
: undefined;
|
|
1311
|
-
const unknownKeys = Object.keys(raw).filter((k) => k !== "windowId" && k !== "maxUsedFraction");
|
|
1312
|
-
|
|
1313
1354
|
if (windowId === undefined) {
|
|
1314
1355
|
problems.push(
|
|
1315
1356
|
`${label}.windowId must be a non-empty allowance id such as "anthropic:7d", found ${JSON.stringify(rawId)}`,
|
|
@@ -1327,51 +1368,260 @@ function coercePlanUsage(
|
|
|
1327
1368
|
return { windowId, maxUsedFraction };
|
|
1328
1369
|
}
|
|
1329
1370
|
|
|
1330
|
-
|
|
1331
|
-
|
|
1371
|
+
/**
|
|
1372
|
+
* Reporting scope decides whether the orchestrator speaks up or stays quiet, so
|
|
1373
|
+
* a typo is rejected rather than folded to the default: a misspelt `"materal"`
|
|
1374
|
+
* that silently resolved to `"material"` would read as configured on the day the
|
|
1375
|
+
* operator meant to turn the volume down, and the config would keep lying.
|
|
1376
|
+
*/
|
|
1377
|
+
/** The legacy `reporting.scope` presets, materialised as explicit policies.
|
|
1378
|
+
* Kept separate from {@link DEFAULT_REPORT_POLICY} (the "no key on disk"
|
|
1379
|
+
* default, which must stay `material`): each preset records `scopePreset` so
|
|
1380
|
+
* the tick prompt can keep saying the exact legacy words (#229). Exported so
|
|
1381
|
+
* the setup wizard and the config validator agree on one mapping. */
|
|
1382
|
+
export const SCOPE_PRESETS: Record<ReportScope, ReportingPolicy> = {
|
|
1383
|
+
material: {
|
|
1384
|
+
interruptOn: [...INTERRUPT_CATEGORIES],
|
|
1385
|
+
digest: { cadence: "per-tick" },
|
|
1386
|
+
scopePreset: "material",
|
|
1387
|
+
},
|
|
1388
|
+
decisions: {
|
|
1389
|
+
interruptOn: ["tier2", "decision-needed", "fleet-stopped"],
|
|
1390
|
+
digest: { cadence: "per-tick" },
|
|
1391
|
+
scopePreset: "decisions",
|
|
1392
|
+
},
|
|
1393
|
+
escalations: {
|
|
1394
|
+
interruptOn: ["tier2", "fleet-stopped"],
|
|
1395
|
+
digest: { cadence: "daily" },
|
|
1396
|
+
scopePreset: "escalations",
|
|
1397
|
+
},
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
function defaultReporting(): ReportingPolicy {
|
|
1401
|
+
return { ...DEFAULT_REPORT_POLICY, interruptOn: [...DEFAULT_REPORT_POLICY.interruptOn] };
|
|
1332
1402
|
}
|
|
1333
1403
|
|
|
1334
|
-
/**
|
|
1335
|
-
|
|
1336
|
-
|
|
1404
|
+
/** The 24-hour `HH:MM` shape `digest.at` must take. */
|
|
1405
|
+
const DIGEST_AT = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* The reporting policy: a legacy `scope` preset, or the explicit
|
|
1409
|
+
* `interruptOn` + `digest` form. The two forms are mutually exclusive — a
|
|
1410
|
+
* preset IS a policy, so configuring alongside it says one thing and means
|
|
1411
|
+
* another (#229). zod validates the shapes; this enforces the mutual exclusion,
|
|
1412
|
+
* materialises a preset, and reconciles the availability/digest clocks.
|
|
1413
|
+
*/
|
|
1414
|
+
function finalizeReporting(parsed: unknown, label: string, problems: string[]): ReportingPolicy {
|
|
1415
|
+
if (parsed === undefined) return defaultReporting();
|
|
1416
|
+
const raw = parsed as Raw;
|
|
1417
|
+
const hasScope = raw["scope"] !== undefined;
|
|
1418
|
+
const hasExplicit = raw["interruptOn"] !== undefined || raw["digest"] !== undefined || raw["availability"] !== undefined;
|
|
1419
|
+
if (hasScope && hasExplicit) {
|
|
1420
|
+
problems.push(
|
|
1421
|
+
`${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest/availability explicitly`,
|
|
1422
|
+
);
|
|
1423
|
+
return defaultReporting();
|
|
1424
|
+
}
|
|
1425
|
+
if (hasScope || (!hasExplicit && Object.keys(raw).length === 0)) {
|
|
1426
|
+
const scope = (raw["scope"] as ReportScope) ?? DEFAULT_REPORT_SCOPE;
|
|
1427
|
+
const preset = SCOPE_PRESETS[scope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE];
|
|
1428
|
+
return {
|
|
1429
|
+
interruptOn: [...preset.interruptOn],
|
|
1430
|
+
digest: { ...preset.digest },
|
|
1431
|
+
scopePreset: preset.scopePreset,
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const storedPreset = raw["scopePreset"];
|
|
1436
|
+
const scopePreset =
|
|
1437
|
+
typeof storedPreset === "string" && (REPORT_SCOPES as readonly string[]).includes(storedPreset)
|
|
1438
|
+
? (storedPreset as ReportScope)
|
|
1439
|
+
: undefined;
|
|
1440
|
+
|
|
1441
|
+
const interruptOn = finalizeInterruptOn(raw["interruptOn"], label, problems);
|
|
1442
|
+
const digest = finalizeDigest(raw["digest"], label, problems);
|
|
1443
|
+
const availability = finalizeAvailability(raw["availability"], label, problems);
|
|
1444
|
+
if (availability !== undefined && digest.cadence === "daily") {
|
|
1445
|
+
if (digest.timezone === undefined) digest.timezone = availability.timezone;
|
|
1446
|
+
else if (digest.timezone !== availability.timezone) {
|
|
1447
|
+
problems.push(`${label}: reporting.digest.timezone must match reporting.availability.timezone`);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
return {
|
|
1451
|
+
interruptOn,
|
|
1452
|
+
digest,
|
|
1453
|
+
...(availability === undefined ? {} : { availability }),
|
|
1454
|
+
...(scopePreset === undefined ? {} : { scopePreset }),
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function finalizeInterruptOn(
|
|
1459
|
+
parsed: unknown,
|
|
1460
|
+
label: string,
|
|
1461
|
+
problems: string[],
|
|
1462
|
+
): InterruptCategory[] {
|
|
1463
|
+
if (parsed === undefined) {
|
|
1464
|
+
problems.push(`${label}: reporting.interruptOn is required in the explicit form (or use reporting.scope)`);
|
|
1465
|
+
return [...INTERRUPT_CATEGORIES];
|
|
1466
|
+
}
|
|
1467
|
+
const out: InterruptCategory[] = [];
|
|
1468
|
+
for (const item of parsed as readonly unknown[]) {
|
|
1469
|
+
const category = item as InterruptCategory;
|
|
1470
|
+
if (!out.includes(category)) out.push(category);
|
|
1471
|
+
}
|
|
1472
|
+
if (out.length === 0) {
|
|
1473
|
+
problems.push(`${label}: reporting.interruptOn must name at least one category`);
|
|
1474
|
+
return [...INTERRUPT_CATEGORIES];
|
|
1475
|
+
}
|
|
1476
|
+
return out;
|
|
1337
1477
|
}
|
|
1338
1478
|
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1479
|
+
function finalizeDigest(parsed: unknown, label: string, problems: string[]): ReportingPolicy["digest"] {
|
|
1480
|
+
const fallback: ReportingPolicy["digest"] = { cadence: "per-tick" };
|
|
1481
|
+
if (parsed === undefined) {
|
|
1482
|
+
problems.push(`${label}: reporting.digest is required in the explicit form (or use reporting.scope)`);
|
|
1483
|
+
return fallback;
|
|
1484
|
+
}
|
|
1485
|
+
const raw = parsed as Raw;
|
|
1486
|
+
const cadence = (raw["cadence"] as DigestCadence) ?? "per-tick";
|
|
1487
|
+
const digest: ReportingPolicy["digest"] = { cadence };
|
|
1488
|
+
if (cadence !== "daily") {
|
|
1489
|
+
if (raw["at"] !== undefined) problems.push(`${label}: reporting.digest.at is only valid with cadence "daily"`);
|
|
1490
|
+
if (raw["timezone"] !== undefined) {
|
|
1491
|
+
problems.push(`${label}: reporting.digest.timezone is only valid with cadence "daily"`);
|
|
1492
|
+
}
|
|
1493
|
+
return digest;
|
|
1494
|
+
}
|
|
1495
|
+
if (raw["at"] !== undefined) {
|
|
1496
|
+
if (typeof raw["at"] !== "string" || !DIGEST_AT.test(raw["at"])) {
|
|
1497
|
+
problems.push(`${label}: reporting.digest.at must be a 24h HH:MM time`);
|
|
1498
|
+
} else {
|
|
1499
|
+
digest.at = raw["at"];
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
if (raw["timezone"] !== undefined) {
|
|
1503
|
+
const tz = raw["timezone"];
|
|
1504
|
+
if (typeof tz !== "string") {
|
|
1505
|
+
problems.push(`${label}: reporting.digest.timezone must be a string`);
|
|
1506
|
+
} else {
|
|
1507
|
+
try {
|
|
1508
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: tz });
|
|
1509
|
+
digest.timezone = tz;
|
|
1510
|
+
} catch {
|
|
1511
|
+
problems.push(`${label}: reporting.digest.timezone is not a known IANA timezone`);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
return digest;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function finalizeAvailability(
|
|
1519
|
+
parsed: unknown,
|
|
1520
|
+
label: string,
|
|
1521
|
+
problems: string[],
|
|
1522
|
+
): WeeklyAvailability | undefined {
|
|
1523
|
+
if (parsed === undefined) return undefined;
|
|
1524
|
+
const raw = parsed as Raw;
|
|
1525
|
+
|
|
1526
|
+
let timezone: string | undefined;
|
|
1527
|
+
if (typeof raw["timezone"] !== "string" || raw["timezone"].trim() === "") {
|
|
1528
|
+
problems.push(`${label}: reporting.availability.timezone must be a known IANA timezone`);
|
|
1529
|
+
} else {
|
|
1530
|
+
try {
|
|
1531
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: raw["timezone"] });
|
|
1532
|
+
timezone = raw["timezone"];
|
|
1533
|
+
} catch {
|
|
1534
|
+
problems.push(`${label}: reporting.availability.timezone is not a known IANA timezone`);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
const days: Weekday[] = [];
|
|
1539
|
+
if (!Array.isArray(raw["days"]) || raw["days"].length === 0) {
|
|
1540
|
+
problems.push(`${label}: reporting.availability.days must be a non-empty array of ${WEEKDAY_LIST}`);
|
|
1541
|
+
} else {
|
|
1542
|
+
for (const item of raw["days"] as readonly unknown[]) {
|
|
1543
|
+
const day = item as Weekday;
|
|
1544
|
+
if (!days.includes(day)) days.push(day);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const start = raw["start"];
|
|
1549
|
+
const end = raw["end"];
|
|
1550
|
+
if (typeof start !== "string" || !DIGEST_AT.test(start)) {
|
|
1551
|
+
problems.push(`${label}: reporting.availability.start must be a 24h HH:MM time`);
|
|
1552
|
+
}
|
|
1553
|
+
if (typeof end !== "string" || !DIGEST_AT.test(end)) {
|
|
1554
|
+
problems.push(`${label}: reporting.availability.end must be a 24h HH:MM time`);
|
|
1555
|
+
}
|
|
1556
|
+
if (typeof start === "string" && typeof end === "string" && start === end) {
|
|
1557
|
+
problems.push(`${label}: reporting.availability.start and end must differ`);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const bypass: InterruptCategory[] = [];
|
|
1561
|
+
if (!Array.isArray(raw["bypass"])) {
|
|
1562
|
+
problems.push(
|
|
1563
|
+
`${label}: reporting.availability.bypass must be an array of ${INTERRUPT_CATEGORY_LIST} (empty means none)`,
|
|
1564
|
+
);
|
|
1565
|
+
} else {
|
|
1566
|
+
for (const item of raw["bypass"] as readonly unknown[]) {
|
|
1567
|
+
const category = item as InterruptCategory;
|
|
1568
|
+
if (!bypass.includes(category)) bypass.push(category);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
return timezone === undefined ||
|
|
1573
|
+
days.length === 0 ||
|
|
1574
|
+
typeof start !== "string" ||
|
|
1575
|
+
!DIGEST_AT.test(start) ||
|
|
1576
|
+
typeof end !== "string" ||
|
|
1577
|
+
!DIGEST_AT.test(end) ||
|
|
1578
|
+
start === end ||
|
|
1579
|
+
!Array.isArray(raw["bypass"])
|
|
1580
|
+
? undefined
|
|
1581
|
+
: { timezone, days, start, end, bypass };
|
|
1342
1582
|
}
|
|
1343
1583
|
|
|
1344
1584
|
/**
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1585
|
+
* A clone URL carrying a password or token is rejected at load.
|
|
1586
|
+
*
|
|
1587
|
+
* `git clone` persists whatever is in the URL into the mirror's config, and the
|
|
1588
|
+
* run repository inherits `origin` from it — so a `https://<pat>@github.com/…`
|
|
1589
|
+
* writes the operator's credential into a file on disk that every later run
|
|
1590
|
+
* reads, where nobody is looking for it. This used to be a `ponytail` note on
|
|
1591
|
+
* `worktree.ts`'s `ensureMirror`; a warning nobody reads is not a check, so it
|
|
1592
|
+
* is now rejected at load with the field named.
|
|
1347
1593
|
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1350
|
-
*
|
|
1351
|
-
* loud the fleet is — and a typo that resolves to the default reads exactly
|
|
1352
|
-
* like a deliberate choice in the file afterwards.
|
|
1594
|
+
* An SSH URL with a plain username (`ssh://git@github.com/o/r`, `git@github.com:o/r`)
|
|
1595
|
+
* is not a credential and is left alone — that is how nearly every fleet is
|
|
1596
|
+
* configured, and rejecting it would be a migration nobody asked for.
|
|
1353
1597
|
*/
|
|
1354
|
-
function
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
): T {
|
|
1362
|
-
if (v === undefined) return fallback;
|
|
1363
|
-
const hit = allowed.find((a) => a === v);
|
|
1364
|
-
if (hit === undefined) {
|
|
1365
|
-
problems.push(`${field} must be ${quoted}, found ${JSON.stringify(v)}`);
|
|
1366
|
-
return fallback;
|
|
1598
|
+
export function cloneUrlCredentialProblem(url: string): string | undefined {
|
|
1599
|
+
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/]*)@/.exec(url);
|
|
1600
|
+
if (m === null) return undefined;
|
|
1601
|
+
const scheme = (m[1] ?? "").toLowerCase();
|
|
1602
|
+
const userinfo = m[2] ?? "";
|
|
1603
|
+
if (userinfo.includes(":")) {
|
|
1604
|
+
return "embeds a user:password — git persists it into the mirror config, which hands every session the credential";
|
|
1367
1605
|
}
|
|
1368
|
-
|
|
1606
|
+
if (scheme === "http" || scheme === "https") {
|
|
1607
|
+
return "embeds userinfo in an http(s) URL, which is how a personal access token is spelled — git persists it into the mirror config";
|
|
1608
|
+
}
|
|
1609
|
+
return undefined;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function nonEmptyString(v: unknown): v is string {
|
|
1613
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/** One rule for "a usable string, else the documented default", used throughout. */
|
|
1617
|
+
function pickString(v: unknown, fallback: string): string {
|
|
1618
|
+
return nonEmptyString(v) ? v : fallback;
|
|
1369
1619
|
}
|
|
1370
1620
|
|
|
1371
1621
|
/**
|
|
1372
1622
|
* `~/x` in a hand-written config must not create a literal `~` directory.
|
|
1373
1623
|
*
|
|
1374
|
-
* Exported because the wizard and `graph
|
|
1624
|
+
* Exported because the wizard and `setup graph` derive paths the operator may
|
|
1375
1625
|
* have typed with a `~` in them, and one spelling of this rule in the package
|
|
1376
1626
|
* is the only way a path shown in a plan matches the path a validator accepts.
|
|
1377
1627
|
*/
|