omp-conductor 0.13.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 +549 -234
- package/package.json +8 -5
- package/schema/config.schema.json +609 -0
- package/src/availability.ts +165 -0
- package/src/board.ts +19 -32
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +72 -31
- package/src/briefs/policy.md +48 -36
- 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 +356 -212
- package/src/config-schema.ts +352 -0
- package/src/config.ts +1037 -679
- package/src/confinement.ts +54 -0
- package/src/daemon.ts +644 -390
- package/src/diff-flags.ts +73 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +89 -22
- package/src/fleet.ts +351 -46
- 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 +732 -56
- package/src/privileged.ts +264 -0
- package/src/reports.ts +203 -6
- 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/setup-wizard.ts +1946 -0
- package/src/setup.ts +457 -53
- package/src/store.ts +610 -98
- package/src/tracker/github.ts +43 -5
- package/src/types.ts +153 -14
- package/src/upgrade.ts +44 -10
- package/src/verbs/actions.ts +131 -13
- package/src/verbs/server.ts +40 -18
- package/src/wizard-ui.ts +249 -0
- package/src/worker.ts +24 -7
- package/skills/conductor-onboarding/SKILL.md +0 -748
- package/skills/conductor-update/SKILL.md +0 -51
- package/src/plugin.ts +0 -1495
package/src/config.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
REPORT_SCOPES,
|
|
38
38
|
INTERRUPT_CATEGORIES,
|
|
39
39
|
DIGEST_CADENCES,
|
|
40
|
+
WEEKDAYS,
|
|
40
41
|
type Caps,
|
|
41
42
|
type ConductorConfig,
|
|
42
43
|
type DigestCadence,
|
|
@@ -49,38 +50,43 @@ import {
|
|
|
49
50
|
type ReleaseRequirement,
|
|
50
51
|
type ReportScope,
|
|
51
52
|
type ReportingPolicy,
|
|
53
|
+
type Weekday,
|
|
54
|
+
type WeeklyAvailability,
|
|
52
55
|
type RepoTarget,
|
|
53
56
|
type ResolvedGrants,
|
|
54
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";
|
|
55
74
|
|
|
56
75
|
/**
|
|
57
76
|
* A JSON node whose fields are all still unproven. Reading a field off a
|
|
58
77
|
* non-object (string, number, null) yields `undefined` at runtime, so every
|
|
59
|
-
* field read below is safe
|
|
60
|
-
* validating — no structural guard needed.
|
|
78
|
+
* field read below is safe.
|
|
61
79
|
*
|
|
62
|
-
* ponytail: this is
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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.
|
|
65
84
|
*/
|
|
66
85
|
type Raw = { readonly [key: string]: unknown };
|
|
67
86
|
|
|
68
87
|
/** Derived from the data so a new `Caps` field cannot be silently ignored. */
|
|
69
88
|
const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
|
|
70
89
|
|
|
71
|
-
/** Quoted for error messages, from the same data the guards below read. */
|
|
72
|
-
const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
|
|
73
|
-
const INTERRUPT_CATEGORY_LIST = quoteList(INTERRUPT_CATEGORIES);
|
|
74
|
-
const DIGEST_CADENCE_LIST = quoteList(DIGEST_CADENCES);
|
|
75
|
-
const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
|
|
76
|
-
const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
|
|
77
|
-
const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
|
|
78
|
-
const LEGACY_RELEASE_POLICY_LIST = quoteList(LEGACY_RELEASE_POLICIES);
|
|
79
|
-
const BASE_FRESHNESS_LIST = quoteList(BASE_FRESHNESS);
|
|
80
|
-
const DRAFT_POLICY_LIST = quoteList(DRAFT_POLICIES);
|
|
81
|
-
const BEHIND_BASE_ACTION_LIST = quoteList(BEHIND_BASE_ACTIONS);
|
|
82
|
-
const RELEASE_REQUIREMENT_LIST = quoteList(RELEASE_REQUIREMENTS);
|
|
83
|
-
|
|
84
90
|
/** `owner/repo`, the only tracker spelling `gh` accepts without a host. */
|
|
85
91
|
const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
86
92
|
|
|
@@ -142,7 +148,7 @@ export function loadConfig(): ConductorConfig {
|
|
|
142
148
|
const path = configPath();
|
|
143
149
|
|
|
144
150
|
if (!existsSync(path)) {
|
|
145
|
-
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.`);
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
let raw: string;
|
|
@@ -182,9 +188,23 @@ export function saveConfig(c: ConductorConfig): void {
|
|
|
182
188
|
* rewrote a live fleet's config into a dialect the previous release rejected.
|
|
183
189
|
*/
|
|
184
190
|
export function writeConfigFile(c: unknown): void {
|
|
185
|
-
|
|
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`);
|
|
186
196
|
}
|
|
187
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
|
+
|
|
188
208
|
/**
|
|
189
209
|
* Atomic write of exact bytes.
|
|
190
210
|
*
|
|
@@ -349,20 +369,621 @@ export function findProject(c: ConductorConfig, name?: string): ProjectConfig {
|
|
|
349
369
|
}
|
|
350
370
|
|
|
351
371
|
// ---------------------------------------------------------------------------
|
|
352
|
-
//
|
|
372
|
+
// load boundary: zod parse + residue normalisation
|
|
353
373
|
// ---------------------------------------------------------------------------
|
|
354
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
|
+
*/
|
|
355
389
|
function validate(parsed: unknown, path: string): ConductorConfig {
|
|
356
390
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
357
|
-
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)}`;
|
|
943
|
+
}
|
|
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`;
|
|
358
948
|
}
|
|
359
|
-
|
|
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;
|
|
360
984
|
const problems: string[] = [];
|
|
361
985
|
|
|
362
986
|
const version = root["version"];
|
|
363
|
-
// A v1 file predates the retirement of a cap key, so its caps are read
|
|
364
|
-
// leniently and the result is normalised up to v2. Any other version is a
|
|
365
|
-
// config this build cannot honestly claim to understand.
|
|
366
987
|
const legacyCaps = version === 1;
|
|
367
988
|
if (!READABLE_CONFIG_VERSIONS.some((v) => v === version)) {
|
|
368
989
|
problems.push(
|
|
@@ -370,13 +991,12 @@ function validate(parsed: unknown, path: string): ConductorConfig {
|
|
|
370
991
|
);
|
|
371
992
|
}
|
|
372
993
|
|
|
373
|
-
const configuredDefaults =
|
|
994
|
+
const configuredDefaults = reconcileCaps(root["defaults"], `"defaults"`, problems, legacyCaps);
|
|
374
995
|
const defaultWorkerMaxTurns = configuredDefaults.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns;
|
|
375
996
|
const defaults: Caps = {
|
|
376
997
|
...DEFAULT_CAPS,
|
|
377
998
|
...configuredDefaults,
|
|
378
|
-
workerMaxTurnsCeiling:
|
|
379
|
-
configuredDefaults.workerMaxTurnsCeiling ?? defaultWorkerMaxTurns * 2,
|
|
999
|
+
workerMaxTurnsCeiling: configuredDefaults.workerMaxTurnsCeiling ?? defaultWorkerMaxTurns * 2,
|
|
380
1000
|
};
|
|
381
1001
|
|
|
382
1002
|
const rawProjects = root["projects"];
|
|
@@ -385,94 +1005,58 @@ function validate(parsed: unknown, path: string): ConductorConfig {
|
|
|
385
1005
|
problems.push(`"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`);
|
|
386
1006
|
} else {
|
|
387
1007
|
rawProjects.forEach((p: unknown, i) => {
|
|
388
|
-
const project =
|
|
1008
|
+
const project = finalizeProject(p, i, problems, legacyCaps);
|
|
389
1009
|
if (project !== undefined) projects.push(project);
|
|
390
1010
|
});
|
|
391
1011
|
}
|
|
392
1012
|
|
|
393
|
-
if (problems.length > 0)
|
|
394
|
-
throw new Error(
|
|
395
|
-
`Invalid conductor config at ${path}:\n${problems.map((p) => ` - ${p}`).join("\n")}\n` +
|
|
396
|
-
`Fix the file or run /conductor setup.`,
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Always v2 out: a loaded v1 config is migrated in memory, and the next
|
|
401
|
-
// `saveConfig` is what persists the migration. `loadConfig` stays read-only.
|
|
1013
|
+
if (problems.length > 0) throw new Error(problemEnvelope(path, problems));
|
|
402
1014
|
return { version: CONFIG_VERSION, defaults, projects };
|
|
403
1015
|
}
|
|
404
1016
|
|
|
405
|
-
|
|
406
|
-
function normalizeProject(
|
|
1017
|
+
function finalizeProject(
|
|
407
1018
|
parsed: unknown,
|
|
408
1019
|
index: number,
|
|
409
1020
|
problems: string[],
|
|
410
1021
|
legacyCaps: boolean,
|
|
411
1022
|
): ProjectConfig | undefined {
|
|
412
1023
|
const at = `projects[${index}]`;
|
|
413
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
414
|
-
problems.push(`${at} must be an object`);
|
|
415
|
-
return undefined;
|
|
416
|
-
}
|
|
417
|
-
const raw = parsed as Raw;
|
|
418
1024
|
const before = problems.length;
|
|
1025
|
+
const p = parsed as Raw;
|
|
1026
|
+
|
|
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;
|
|
419
1031
|
|
|
420
|
-
const
|
|
421
|
-
let name = "";
|
|
422
|
-
if (nonEmptyString(rawName)) name = rawName;
|
|
423
|
-
else problems.push(`${at}.name must be a non-empty string`);
|
|
424
|
-
const label = name === "" ? at : `project "${name}"`;
|
|
425
|
-
|
|
426
|
-
const tracker = raw["tracker"] as Raw | undefined;
|
|
427
|
-
const rawRepo = tracker?.["repo"];
|
|
428
|
-
let trackerRepo = "";
|
|
429
|
-
if (nonEmptyString(rawRepo) && REPO_RE.test(rawRepo)) trackerRepo = rawRepo;
|
|
430
|
-
else problems.push(`${label}: tracker.repo must look like "owner/repo", found ${JSON.stringify(rawRepo)}`);
|
|
431
|
-
const rawKind = tracker?.["kind"];
|
|
432
|
-
if (rawKind !== undefined && rawKind !== "github") {
|
|
433
|
-
problems.push(`${label}: tracker.kind must be "github", found ${JSON.stringify(rawKind)}`);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
const rawQueueLabel = raw["queueLabel"];
|
|
437
|
-
let queueLabel = "";
|
|
438
|
-
if (nonEmptyString(rawQueueLabel)) queueLabel = rawQueueLabel;
|
|
439
|
-
else problems.push(`${label}: queueLabel must be a non-empty string — it is the human sign-off gate`);
|
|
440
|
-
|
|
441
|
-
// Tolerant like the other optional fields: an integer >= 1 is a grooming
|
|
442
|
-
// trigger, and anything else degrades to absent (the tick default) rather
|
|
443
|
-
// than invalidating the whole project.
|
|
444
|
-
const rawGroomBelow = raw["groomBelow"];
|
|
1032
|
+
const rawGroomBelow = p["groomBelow"];
|
|
445
1033
|
const groomBelow =
|
|
446
1034
|
typeof rawGroomBelow === "number" && Number.isInteger(rawGroomBelow) && rawGroomBelow >= 1
|
|
447
1035
|
? rawGroomBelow
|
|
448
1036
|
: undefined;
|
|
449
1037
|
|
|
450
|
-
const
|
|
451
|
-
const rawPrefix =
|
|
1038
|
+
const rawRouting = p["routing"] as Raw | undefined;
|
|
1039
|
+
const rawPrefix = rawRouting?.["labelPrefix"];
|
|
452
1040
|
const labelPrefix = typeof rawPrefix === "string" ? rawPrefix : DEFAULT_LABEL_PREFIX;
|
|
453
|
-
const repos =
|
|
454
|
-
|
|
455
|
-
const stateLabels =
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
const
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
// dropped rather than reported, and the session's own model-fallback notice
|
|
466
|
-
// (logged by `runWorker`) is what tells the operator the pattern missed.
|
|
467
|
-
const rawWorkerModel = raw["workerModel"];
|
|
468
|
-
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;
|
|
469
1053
|
|
|
470
1054
|
if (problems.length > before) return undefined;
|
|
471
1055
|
|
|
472
1056
|
return {
|
|
473
1057
|
name,
|
|
474
1058
|
tracker: { kind: "github", repo: trackerRepo },
|
|
475
|
-
queueLabel,
|
|
1059
|
+
queueLabel: p["queueLabel"] as string,
|
|
476
1060
|
...(groomBelow === undefined ? {} : { groomBelow }),
|
|
477
1061
|
stateLabels: {
|
|
478
1062
|
inProgress: pickString(stateLabels?.["inProgress"], DEFAULT_STATE_LABELS.inProgress),
|
|
@@ -487,508 +1071,137 @@ function normalizeProject(
|
|
|
487
1071
|
releasePolicy,
|
|
488
1072
|
policy,
|
|
489
1073
|
reporting,
|
|
490
|
-
workspaceRoot: expandHome(pickString(
|
|
491
|
-
mirrorRoot: expandHome(pickString(
|
|
1074
|
+
workspaceRoot: expandHome(pickString(p["workspaceRoot"], defaultWorkspaceRoot())),
|
|
1075
|
+
mirrorRoot: expandHome(pickString(p["mirrorRoot"], defaultMirrorRoot())),
|
|
492
1076
|
};
|
|
493
1077
|
}
|
|
494
1078
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
function defaultReporting(): ReportingPolicy {
|
|
525
|
-
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;
|
|
526
1106
|
}
|
|
527
1107
|
|
|
528
|
-
|
|
529
|
-
|
|
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
|
+
}
|
|
530
1115
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
* preset IS a policy, so configuring alongside it says one thing and means
|
|
535
|
-
* another (#229).
|
|
536
|
-
*/
|
|
537
|
-
function normalizeReporting(parsed: unknown, label: string, problems: string[]): ReportingPolicy {
|
|
538
|
-
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 };
|
|
539
1119
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
540
|
-
problems.push(
|
|
541
|
-
|
|
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 };
|
|
542
1125
|
}
|
|
543
1126
|
const raw = parsed as Raw;
|
|
544
|
-
const
|
|
545
|
-
// `scopePreset` is written by a fully-normalised policy (the setup wizard
|
|
546
|
-
// saves presets materialised); `scope` is the legacy form. Both are known.
|
|
547
|
-
const known = ["scope", "interruptOn", "digest", "scopePreset"];
|
|
548
|
-
const unknownKeys = keys.filter((k) => !known.includes(k));
|
|
1127
|
+
const unknownKeys = Object.keys(raw).filter((k) => !Object.hasOwn(DENIED_RELEASE_GRANTS, k));
|
|
549
1128
|
if (unknownKeys.length > 0) {
|
|
550
|
-
problems.push(
|
|
551
|
-
|
|
552
|
-
const hasScope = raw["scope"] !== undefined;
|
|
553
|
-
const hasExplicit = keys.includes("interruptOn") || keys.includes("digest");
|
|
554
|
-
if (hasScope && hasExplicit) {
|
|
555
|
-
problems.push(`${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest explicitly`);
|
|
556
|
-
return defaultReporting();
|
|
557
|
-
}
|
|
558
|
-
if (hasScope || (!hasExplicit && keys.length === 0)) {
|
|
559
|
-
const scope = pickLiteral(
|
|
560
|
-
raw["scope"],
|
|
561
|
-
REPORT_SCOPES,
|
|
562
|
-
DEFAULT_REPORT_SCOPE,
|
|
563
|
-
`${label}: reporting.scope`,
|
|
564
|
-
REPORT_SCOPE_LIST,
|
|
565
|
-
problems,
|
|
1129
|
+
problems.push(
|
|
1130
|
+
`${label}: releasePolicy has unknown release shape(s): ${unknownKeys.join(", ")} — expected ${RELEASE_SHAPE_LIST}`,
|
|
566
1131
|
);
|
|
567
|
-
const preset = SCOPE_PRESETS[scope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE];
|
|
568
|
-
return {
|
|
569
|
-
interruptOn: [...preset.interruptOn],
|
|
570
|
-
digest: { ...preset.digest },
|
|
571
|
-
scopePreset: preset.scopePreset,
|
|
572
|
-
};
|
|
573
1132
|
}
|
|
574
|
-
|
|
575
|
-
// A fully-normalised policy (what the setup wizard writes and saveConfig
|
|
576
|
-
// round-trips) has interruptOn/digest and may carry scopePreset; keep that
|
|
577
|
-
// back-annotation so the tick prompt can still speak the legacy words.
|
|
578
|
-
const storedPreset = raw["scopePreset"];
|
|
579
|
-
const scopePreset =
|
|
580
|
-
typeof storedPreset === "string" && (REPORT_SCOPES as readonly string[]).includes(storedPreset)
|
|
581
|
-
? (storedPreset as ReportScope)
|
|
582
|
-
: undefined;
|
|
583
|
-
|
|
584
|
-
return {
|
|
585
|
-
interruptOn: normalizeInterruptOn(raw["interruptOn"], label, problems),
|
|
586
|
-
digest: normalizeDigest(raw["digest"], label, problems),
|
|
587
|
-
...(scopePreset === undefined ? {} : { scopePreset }),
|
|
588
|
-
};
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
function normalizeInterruptOn(parsed: unknown, label: string, problems: string[]): InterruptCategory[] {
|
|
592
|
-
const fallback = [...INTERRUPT_CATEGORIES];
|
|
593
|
-
if (parsed === undefined) {
|
|
594
|
-
problems.push(`${label}: reporting.interruptOn is required in the explicit form (or use reporting.scope)`);
|
|
595
|
-
return fallback;
|
|
596
|
-
}
|
|
597
|
-
if (!Array.isArray(parsed)) {
|
|
598
|
-
problems.push(`${label}: reporting.interruptOn must be an array of ${INTERRUPT_CATEGORY_LIST}`);
|
|
599
|
-
return fallback;
|
|
600
|
-
}
|
|
601
|
-
const out: InterruptCategory[] = [];
|
|
602
|
-
for (const item of parsed) {
|
|
603
|
-
if (typeof item !== "string" || !(INTERRUPT_CATEGORIES as readonly string[]).includes(item)) {
|
|
604
|
-
problems.push(`${label}: reporting.interruptOn has unknown category ${JSON.stringify(item)} — one of ${INTERRUPT_CATEGORY_LIST}`);
|
|
605
|
-
continue;
|
|
606
|
-
}
|
|
607
|
-
const category = item as InterruptCategory;
|
|
608
|
-
if (!out.includes(category)) out.push(category);
|
|
609
|
-
}
|
|
610
|
-
if (out.length === 0) {
|
|
611
|
-
problems.push(`${label}: reporting.interruptOn must name at least one category`);
|
|
612
|
-
return fallback;
|
|
613
|
-
}
|
|
614
|
-
return out;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function normalizeDigest(parsed: unknown, label: string, problems: string[]): ReportingPolicy["digest"] {
|
|
618
|
-
const fallback: ReportingPolicy["digest"] = { cadence: "per-tick" };
|
|
619
|
-
if (parsed === undefined) {
|
|
620
|
-
problems.push(`${label}: reporting.digest is required in the explicit form (or use reporting.scope)`);
|
|
621
|
-
return fallback;
|
|
622
|
-
}
|
|
623
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
624
|
-
problems.push(`${label}: reporting.digest must be an object with a "cadence" of ${DIGEST_CADENCE_LIST}`);
|
|
625
|
-
return fallback;
|
|
626
|
-
}
|
|
627
|
-
const raw = parsed as Raw;
|
|
628
|
-
const unknownKeys = Object.keys(raw).filter((k) => !["cadence", "at", "timezone"].includes(k));
|
|
629
|
-
if (unknownKeys.length > 0) {
|
|
630
|
-
problems.push(`${label}: reporting.digest has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
631
|
-
}
|
|
632
|
-
const cadence = pickLiteral(
|
|
633
|
-
raw["cadence"],
|
|
634
|
-
DIGEST_CADENCES,
|
|
635
|
-
"per-tick",
|
|
636
|
-
`${label}: reporting.digest.cadence`,
|
|
637
|
-
DIGEST_CADENCE_LIST,
|
|
638
|
-
problems,
|
|
639
|
-
);
|
|
640
|
-
const digest: ReportingPolicy["digest"] = { cadence };
|
|
641
|
-
if (cadence !== "daily") {
|
|
642
|
-
if (raw["at"] !== undefined) problems.push(`${label}: reporting.digest.at is only valid with cadence "daily"`);
|
|
643
|
-
if (raw["timezone"] !== undefined) problems.push(`${label}: reporting.digest.timezone is only valid with cadence "daily"`);
|
|
644
|
-
return digest;
|
|
645
|
-
}
|
|
646
|
-
if (raw["at"] !== undefined) {
|
|
647
|
-
if (typeof raw["at"] !== "string" || !DIGEST_AT.test(raw["at"])) {
|
|
648
|
-
problems.push(`${label}: reporting.digest.at must be a 24h HH:MM time`);
|
|
649
|
-
} else {
|
|
650
|
-
digest.at = raw["at"];
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
if (raw["timezone"] !== undefined) {
|
|
654
|
-
if (typeof raw["timezone"] !== "string") {
|
|
655
|
-
problems.push(`${label}: reporting.digest.timezone must be a string`);
|
|
656
|
-
} else {
|
|
657
|
-
try {
|
|
658
|
-
new Intl.DateTimeFormat("en-GB", { timeZone: raw["timezone"] });
|
|
659
|
-
digest.timezone = raw["timezone"];
|
|
660
|
-
} catch {
|
|
661
|
-
problems.push(`${label}: reporting.digest.timezone is not a known IANA timezone`);
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
return digest;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
* Who triages escalations, and how they are delivered when nobody answers.
|
|
670
|
-
*
|
|
671
|
-
* `orchestrator` is validated rather than folded to the default for the reason
|
|
672
|
-
* `reporting.scope` is: a misspelt `"externl"` that quietly resolved to
|
|
673
|
-
* `"embedded"` would start a second brain beside the operator's own session,
|
|
674
|
-
* and both of them would triage the same issue from different transcripts.
|
|
675
|
-
*/
|
|
676
|
-
function normalizeEscalation(parsed: unknown, label: string, problems: string[]): ProjectConfig["escalation"] {
|
|
677
|
-
let raw: Raw = {};
|
|
678
|
-
if (parsed !== undefined) {
|
|
679
|
-
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) raw = parsed as Raw;
|
|
680
|
-
else problems.push(`${label}: escalation must be an object`);
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
const escalation: ProjectConfig["escalation"] = {
|
|
684
|
-
// Absent means "yes, still tell me": a silently stuck run is the worst case.
|
|
685
|
-
fallbackToIssueComment: raw["fallbackToIssueComment"] !== false,
|
|
686
|
-
orchestrator: pickLiteral(
|
|
687
|
-
raw["orchestrator"],
|
|
688
|
-
ORCHESTRATOR_MODES,
|
|
689
|
-
"embedded",
|
|
690
|
-
`${label}: escalation.orchestrator`,
|
|
691
|
-
ORCHESTRATOR_MODE_LIST,
|
|
692
|
-
problems,
|
|
693
|
-
),
|
|
694
|
-
};
|
|
695
|
-
const chatId = raw["telegramChatId"];
|
|
696
|
-
if (nonEmptyString(chatId)) escalation.telegramChatId = chatId;
|
|
697
|
-
return escalation;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
/**
|
|
701
|
-
* Who lands PRs and who cuts releases. Both default to the human: this is the
|
|
702
|
-
* one config value that decides whether an unattended session may write to a
|
|
703
|
-
* main branch, so it is granted explicitly or not at all.
|
|
704
|
-
*
|
|
705
|
-
* Unknown keys are rejected outright, as in `reporting` and for the same
|
|
706
|
-
* reason: the object has exactly two members, so an unrecognised one is a typo
|
|
707
|
-
* every time — and a `authority: { merges: "orchestrator" }` that loaded
|
|
708
|
-
* cleanly would read as delegated while the orchestrator was still told to keep
|
|
709
|
-
* its hands off.
|
|
710
|
-
*/
|
|
711
|
-
function normalizeAuthority(parsed: unknown, label: string, problems: string[]): ProjectConfig["authority"] {
|
|
712
|
-
if (parsed === undefined) return { ...DEFAULT_AUTHORITY };
|
|
713
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
714
|
-
problems.push(`${label}: authority must be an object with "merge" and "release" of ${AUTHORITY_HOLDER_LIST}`);
|
|
715
|
-
return { ...DEFAULT_AUTHORITY };
|
|
716
|
-
}
|
|
717
|
-
const raw = parsed as Raw;
|
|
718
|
-
|
|
719
|
-
const unknownKeys = Object.keys(raw).filter((k) => k !== "merge" && k !== "release");
|
|
720
|
-
if (unknownKeys.length > 0) {
|
|
721
|
-
problems.push(`${label}: authority has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
return {
|
|
725
|
-
merge: pickLiteral(
|
|
726
|
-
raw["merge"],
|
|
727
|
-
AUTHORITY_HOLDERS,
|
|
728
|
-
DEFAULT_AUTHORITY.merge,
|
|
729
|
-
`${label}: authority.merge`,
|
|
730
|
-
AUTHORITY_HOLDER_LIST,
|
|
731
|
-
problems,
|
|
732
|
-
),
|
|
733
|
-
release: pickLiteral(
|
|
734
|
-
raw["release"],
|
|
735
|
-
AUTHORITY_HOLDERS,
|
|
736
|
-
DEFAULT_AUTHORITY.release,
|
|
737
|
-
`${label}: authority.release`,
|
|
738
|
-
AUTHORITY_HOLDER_LIST,
|
|
739
|
-
problems,
|
|
740
|
-
),
|
|
741
|
-
};
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
/**
|
|
745
|
-
* Per-shape release grants, and the migration off the two legacy strings.
|
|
746
|
-
*
|
|
747
|
-
* Normalised to a *complete* map at load, so everything downstream — the gate,
|
|
748
|
-
* `status`, the standing orders, the wizard — reads the same five answers and
|
|
749
|
-
* none of them has to know which spelling was on disk.
|
|
750
|
-
*
|
|
751
|
-
* `"operator-brief"` migrates to every shape except `deploy` (#122). That is the
|
|
752
|
-
* safe reading of what an operator believed the binary gate opened: a stale
|
|
753
|
-
* `operator-brief` was enough for an orchestrator session to invoke Komodo
|
|
754
|
-
* `DeployStack`, which is the grant nobody knowingly gave.
|
|
755
|
-
*
|
|
756
|
-
* An unknown shape key is rejected rather than ignored, as in `authority` and
|
|
757
|
-
* for the same reason: the set is closed, so `{ "deploy-prod": "orchestrator" }`
|
|
758
|
-
* is a typo every time — and a config that loaded it cleanly would read as
|
|
759
|
-
* granted in the file while the gate denied every call.
|
|
760
|
-
*/
|
|
761
|
-
function normalizeReleasePolicy(parsed: unknown, label: string, problems: string[]): ResolvedGrants {
|
|
762
|
-
if (parsed === undefined || parsed === "none") return { ...DENIED_RELEASE_GRANTS };
|
|
763
|
-
if (parsed === "operator-brief") return { ...OPERATOR_BRIEF_GRANTS };
|
|
764
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
765
|
-
problems.push(
|
|
766
|
-
`${label}: releasePolicy must be an object mapping release shapes (${RELEASE_SHAPE_LIST}) to ` +
|
|
767
|
-
`${AUTHORITY_HOLDER_LIST}, or the legacy ${LEGACY_RELEASE_POLICY_LIST}, found ${JSON.stringify(parsed)}`,
|
|
768
|
-
);
|
|
769
|
-
return { ...DENIED_RELEASE_GRANTS };
|
|
770
|
-
}
|
|
771
|
-
const raw = parsed as Raw;
|
|
772
|
-
|
|
773
|
-
const unknownKeys = Object.keys(raw).filter((k) => !Object.hasOwn(DENIED_RELEASE_GRANTS, k));
|
|
774
|
-
if (unknownKeys.length > 0) {
|
|
775
|
-
problems.push(
|
|
776
|
-
`${label}: releasePolicy has unknown release shape(s): ${unknownKeys.join(", ")} — expected ${RELEASE_SHAPE_LIST}`,
|
|
777
|
-
);
|
|
778
|
-
}
|
|
779
|
-
|
|
780
1133
|
const grants = { ...DENIED_RELEASE_GRANTS };
|
|
781
1134
|
for (const shape of RELEASE_SHAPES) {
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
raw[shape],
|
|
786
|
-
AUTHORITY_HOLDERS,
|
|
787
|
-
"human",
|
|
788
|
-
`${label}: releasePolicy.${shape}`,
|
|
789
|
-
AUTHORITY_HOLDER_LIST,
|
|
790
|
-
problems,
|
|
791
|
-
);
|
|
1135
|
+
grants[shape] = (AUTHORITY_HOLDERS as readonly string[]).includes(raw[shape] as string)
|
|
1136
|
+
? (raw[shape] as ResolvedGrants[typeof shape])
|
|
1137
|
+
: "human";
|
|
792
1138
|
}
|
|
793
1139
|
return grants;
|
|
794
1140
|
}
|
|
795
1141
|
|
|
796
|
-
|
|
797
|
-
* The merge and release gating conditions (#129).
|
|
798
|
-
*
|
|
799
|
-
* These used to be sentences in the operator's POLICY.md, which meant a verb
|
|
800
|
-
* could only honour them by asking a model to read prose. Typed and normalised
|
|
801
|
-
* here to a *complete* value, so the gate, the wizard, the plan summary and the
|
|
802
|
-
* brief all read the same answers and none of them has to know which fields
|
|
803
|
-
* were spelled on disk.
|
|
804
|
-
*
|
|
805
|
-
* Fail-closed throughout, as in `authority` and for the same reason: both
|
|
806
|
-
* sections have a closed key set, so an unrecognised key is a typo every time —
|
|
807
|
-
* and a `policy: { merge: { requiredCheck: [...] } }` that loaded cleanly would
|
|
808
|
-
* read as configured in the file while the gate went on requiring every check.
|
|
809
|
-
* A malformed value takes the documented default and is reported; it is never
|
|
810
|
-
* folded to whatever the operator asked for.
|
|
811
|
-
*/
|
|
812
|
-
function normalizeProjectPolicy(parsed: unknown, label: string, problems: string[]): ProjectPolicy {
|
|
1142
|
+
function finalizePolicy(parsed: unknown, label: string, problems: string[]): ProjectPolicy {
|
|
813
1143
|
if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY);
|
|
814
1144
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
815
1145
|
problems.push(`${label}: policy must be an object with "merge" and "release" sections`);
|
|
816
1146
|
return clonePolicy(DEFAULT_PROJECT_POLICY);
|
|
817
1147
|
}
|
|
818
1148
|
const raw = parsed as Raw;
|
|
819
|
-
|
|
820
|
-
const unknownKeys = Object.keys(raw).filter((k) => k !== "merge" && k !== "release");
|
|
821
|
-
if (unknownKeys.length > 0) {
|
|
822
|
-
problems.push(`${label}: policy has unknown key(s): ${unknownKeys.join(", ")} — expected "merge" or "release"`);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
1149
|
return {
|
|
826
|
-
merge:
|
|
827
|
-
release:
|
|
1150
|
+
merge: finalizeMergePreconditions(raw["merge"], problems),
|
|
1151
|
+
release: finalizeReleasePreconditions(raw["release"], problems),
|
|
828
1152
|
};
|
|
829
1153
|
}
|
|
830
1154
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).merge;
|
|
834
|
-
if (parsed === undefined) return fallback;
|
|
835
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
836
|
-
problems.push(`${at} must be an object`);
|
|
837
|
-
return fallback;
|
|
838
|
-
}
|
|
1155
|
+
function finalizeMergePreconditions(parsed: unknown, problems: string[]): MergePreconditions {
|
|
1156
|
+
if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY).merge;
|
|
839
1157
|
const raw = parsed as Raw;
|
|
840
|
-
|
|
841
|
-
const known = new Set(Object.keys(fallback));
|
|
842
|
-
const unknownKeys = Object.keys(raw).filter((k) => !known.has(k));
|
|
843
|
-
if (unknownKeys.length > 0) {
|
|
844
|
-
problems.push(`${at} has unknown key(s): ${unknownKeys.join(", ")} — expected ${quoteList([...known])}`);
|
|
845
|
-
}
|
|
846
|
-
|
|
1158
|
+
const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).merge;
|
|
847
1159
|
return {
|
|
848
|
-
requiredChecks:
|
|
849
|
-
baseFreshness:
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
fallback.baseFreshness,
|
|
853
|
-
`${at}.baseFreshness`,
|
|
854
|
-
BASE_FRESHNESS_LIST,
|
|
855
|
-
problems,
|
|
856
|
-
),
|
|
857
|
-
drafts: pickLiteral(raw["drafts"], DRAFT_POLICIES, fallback.drafts, `${at}.drafts`, DRAFT_POLICY_LIST, problems),
|
|
858
|
-
whenBehindBase: pickLiteral(
|
|
859
|
-
raw["whenBehindBase"],
|
|
860
|
-
BEHIND_BASE_ACTIONS,
|
|
861
|
-
fallback.whenBehindBase,
|
|
862
|
-
`${at}.whenBehindBase`,
|
|
863
|
-
BEHIND_BASE_ACTION_LIST,
|
|
864
|
-
problems,
|
|
865
|
-
),
|
|
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,
|
|
866
1164
|
};
|
|
867
1165
|
}
|
|
868
1166
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).release;
|
|
872
|
-
if (parsed === undefined) return fallback;
|
|
873
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
874
|
-
problems.push(`${at} must be an object`);
|
|
875
|
-
return fallback;
|
|
876
|
-
}
|
|
1167
|
+
function finalizeReleasePreconditions(parsed: unknown, problems: string[]): ReleasePreconditions {
|
|
1168
|
+
if (parsed === undefined) return clonePolicy(DEFAULT_PROJECT_POLICY).release;
|
|
877
1169
|
const raw = parsed as Raw;
|
|
878
|
-
|
|
879
|
-
const known = new Set(Object.keys(fallback));
|
|
880
|
-
const unknownKeys = Object.keys(raw).filter((k) => !known.has(k));
|
|
881
|
-
if (unknownKeys.length > 0) {
|
|
882
|
-
problems.push(`${at} has unknown key(s): ${unknownKeys.join(", ")} — expected ${quoteList([...known])}`);
|
|
883
|
-
}
|
|
884
|
-
|
|
1170
|
+
const fallback = clonePolicy(DEFAULT_PROJECT_POLICY).release;
|
|
885
1171
|
return {
|
|
886
|
-
requires:
|
|
887
|
-
requiredChecks:
|
|
888
|
-
artefacts:
|
|
889
|
-
environments:
|
|
1172
|
+
requires: canonicalRequirements(raw["requires"]),
|
|
1173
|
+
requiredChecks: trimNames(raw["requiredChecks"]),
|
|
1174
|
+
artefacts: trimNames(raw["artefacts"]),
|
|
1175
|
+
environments: trimNames(raw["environments"]),
|
|
890
1176
|
};
|
|
891
1177
|
}
|
|
892
1178
|
|
|
893
|
-
/**
|
|
894
|
-
|
|
895
|
-
*
|
|
896
|
-
* Canonical order and de-duplication because this list is rendered into a
|
|
897
|
-
* refusal and into the plan summary: two configs that require the same three
|
|
898
|
-
* things must read identically, or an operator diffing them sees a change that
|
|
899
|
-
* is not one.
|
|
900
|
-
*/
|
|
901
|
-
function normalizeReleaseRequirements(
|
|
902
|
-
parsed: unknown,
|
|
903
|
-
at: string,
|
|
904
|
-
fallback: readonly ReleaseRequirement[],
|
|
905
|
-
problems: string[],
|
|
906
|
-
): ReleaseRequirement[] {
|
|
907
|
-
if (parsed === undefined) return [...fallback];
|
|
908
|
-
if (!Array.isArray(parsed)) {
|
|
909
|
-
problems.push(`${at} must be an array of ${RELEASE_REQUIREMENT_LIST}`);
|
|
910
|
-
return [...fallback];
|
|
911
|
-
}
|
|
912
|
-
|
|
1179
|
+
/** The `requires` set in vocabulary order, deduplicated (#129's promise). */
|
|
1180
|
+
function canonicalRequirements(parsed: unknown): ReleaseRequirement[] {
|
|
913
1181
|
const chosen = new Set<string>();
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
problems.push(`${at}[${i}] must be ${RELEASE_REQUIREMENT_LIST}, found ${JSON.stringify(entry)}`);
|
|
917
|
-
return;
|
|
918
|
-
}
|
|
919
|
-
chosen.add(entry as ReleaseRequirement);
|
|
920
|
-
});
|
|
1182
|
+
if (!Array.isArray(parsed)) return [];
|
|
1183
|
+
for (const entry of parsed) chosen.add(entry as string);
|
|
921
1184
|
return RELEASE_REQUIREMENTS.filter((r) => chosen.has(r));
|
|
922
1185
|
}
|
|
923
1186
|
|
|
924
|
-
/**
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
* but a malformed entry still rejects the whole config rather than being
|
|
929
|
-
* dropped. A silently dropped environment name is an operator debugging a
|
|
930
|
-
* refusal that reads exactly like a correctly-denied one.
|
|
931
|
-
*/
|
|
932
|
-
function normalizeNameList(parsed: unknown, at: string, problems: string[]): string[] {
|
|
933
|
-
if (parsed === undefined) return [];
|
|
934
|
-
if (!Array.isArray(parsed)) {
|
|
935
|
-
problems.push(`${at} must be an array of non-empty strings`);
|
|
936
|
-
return [];
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
const names: string[] = [];
|
|
940
|
-
parsed.forEach((entry: unknown, i) => {
|
|
941
|
-
if (!nonEmptyString(entry)) {
|
|
942
|
-
problems.push(`${at}[${i}] must be a non-empty string, found ${JSON.stringify(entry)}`);
|
|
943
|
-
return;
|
|
944
|
-
}
|
|
945
|
-
names.push(entry.trim());
|
|
946
|
-
});
|
|
947
|
-
return names;
|
|
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());
|
|
948
1191
|
}
|
|
949
1192
|
|
|
950
|
-
|
|
951
|
-
* A clone URL carrying a password or token is rejected at load.
|
|
952
|
-
*
|
|
953
|
-
* `git clone` persists whatever is in the URL into the mirror's config, and the
|
|
954
|
-
* run repository inherits `origin` from it — so a `https://<pat>@github.com/…`
|
|
955
|
-
* writes the operator's credential into a file on disk that every later run
|
|
956
|
-
* reads, where nobody is looking for it. This used to be a `ponytail` note on
|
|
957
|
-
* `worktree.ts`'s `ensureMirror`; a warning nobody reads is not a check, so it
|
|
958
|
-
* is now rejected at load with the field named.
|
|
959
|
-
*
|
|
960
|
-
* An SSH URL with a plain username (`ssh://git@github.com/o/r`, `git@github.com:o/r`)
|
|
961
|
-
* is not a credential and is left alone — that is how nearly every fleet is
|
|
962
|
-
* configured, and rejecting it would be a migration nobody asked for.
|
|
963
|
-
*/
|
|
964
|
-
export function cloneUrlCredentialProblem(url: string): string | undefined {
|
|
965
|
-
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/]*)@/.exec(url);
|
|
966
|
-
if (m === null) return undefined;
|
|
967
|
-
const scheme = (m[1] ?? "").toLowerCase();
|
|
968
|
-
const userinfo = m[2] ?? "";
|
|
969
|
-
if (userinfo.includes(":")) {
|
|
970
|
-
return "embeds a user:password — git persists it into the mirror config, which hands every session the credential";
|
|
971
|
-
}
|
|
972
|
-
if (scheme === "http" || scheme === "https") {
|
|
973
|
-
return "embeds userinfo in an http(s) URL, which is how a personal access token is spelled — git persists it into the mirror config";
|
|
974
|
-
}
|
|
975
|
-
return undefined;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
function normalizeRepos(parsed: unknown, label: string, problems: string[]): Record<string, RepoTarget> {
|
|
1193
|
+
function finalizeRepos(parsed: unknown, label: string, problems: string[]): Record<string, RepoTarget> {
|
|
979
1194
|
const repos: Record<string, RepoTarget> = {};
|
|
980
|
-
|
|
981
1195
|
const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined;
|
|
982
1196
|
const entries = raw === undefined ? [] : Object.entries(raw);
|
|
983
1197
|
if (entries.length === 0) {
|
|
984
1198
|
problems.push(`${label}: routing.repos needs at least one repo entry, or no issue can be routed`);
|
|
985
1199
|
return repos;
|
|
986
1200
|
}
|
|
987
|
-
|
|
988
1201
|
for (const [key, entry] of entries) {
|
|
989
1202
|
const value = entry as Raw | undefined;
|
|
990
|
-
const cloneUrl = value?.["cloneUrl"];
|
|
991
|
-
if (
|
|
1203
|
+
const cloneUrl = value?.["cloneUrl"] as string | undefined;
|
|
1204
|
+
if (typeof cloneUrl !== "string" || cloneUrl.trim() === "") {
|
|
992
1205
|
problems.push(`${label}: routing.repos.${key}.cloneUrl must be a non-empty string`);
|
|
993
1206
|
continue;
|
|
994
1207
|
}
|
|
@@ -1004,31 +1217,41 @@ function normalizeRepos(parsed: unknown, label: string, problems: string[]): Rec
|
|
|
1004
1217
|
name: pickString(value?.["name"], key),
|
|
1005
1218
|
cloneUrl,
|
|
1006
1219
|
defaultBranch: pickString(value?.["defaultBranch"], "main"),
|
|
1007
|
-
gates:
|
|
1220
|
+
gates: finalizeGates(value?.["gates"], `${label}: routing.repos.${key}`, problems),
|
|
1008
1221
|
};
|
|
1009
|
-
const graph =
|
|
1222
|
+
const graph = finalizeGraphProject(value?.["graphProject"], `${label}: routing.repos.${key}`, problems);
|
|
1010
1223
|
if (graph !== undefined) target.graphProject = graph;
|
|
1011
|
-
const migrations =
|
|
1224
|
+
const migrations = finalizeMigrationsDir(value?.["migrations"], `${label}: routing.repos.${key}`, problems);
|
|
1012
1225
|
if (migrations !== undefined) target.migrations = { dir: migrations };
|
|
1013
1226
|
repos[key] = target;
|
|
1014
1227
|
}
|
|
1015
|
-
|
|
1016
1228
|
return repos;
|
|
1017
1229
|
}
|
|
1018
1230
|
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1231
|
+
function finalizeGates(parsed: unknown, label: string, problems: string[]): { cmd: string; cwd: string }[] {
|
|
1232
|
+
if (parsed === undefined) return [];
|
|
1233
|
+
if (!Array.isArray(parsed)) {
|
|
1234
|
+
problems.push(`${label}.gates must be an array of { cmd, cwd }`);
|
|
1235
|
+
return [];
|
|
1236
|
+
}
|
|
1237
|
+
const gates: { cmd: string; cwd: string }[] = [];
|
|
1238
|
+
parsed.forEach((entry: unknown, i) => {
|
|
1239
|
+
const gate = entry as Raw | undefined;
|
|
1240
|
+
const cmd = gate?.["cmd"];
|
|
1241
|
+
if (typeof cmd !== "string" || cmd.trim() === "") {
|
|
1242
|
+
problems.push(`${label}.gates[${i}] must be { cmd, cwd } with a non-empty cmd`);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
gates.push({ cmd, cwd: pickString(gate?.["cwd"], ".") });
|
|
1246
|
+
});
|
|
1247
|
+
return gates;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function finalizeMigrationsDir(parsed: unknown, label: string, problems: string[]): string | undefined {
|
|
1028
1251
|
if (parsed === undefined) return undefined;
|
|
1029
1252
|
const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined;
|
|
1030
1253
|
const dir = raw?.["dir"];
|
|
1031
|
-
if (
|
|
1254
|
+
if (typeof dir !== "string" || dir.trim() === "") {
|
|
1032
1255
|
problems.push(`${label}.migrations.dir must be a non-empty string`);
|
|
1033
1256
|
return undefined;
|
|
1034
1257
|
}
|
|
@@ -1039,24 +1262,12 @@ function normalizeMigrationsDir(parsed: unknown, label: string, problems: string
|
|
|
1039
1262
|
return dir;
|
|
1040
1263
|
}
|
|
1041
1264
|
|
|
1042
|
-
|
|
1043
|
-
* The path of the index-only clone whose code graph this repo's workers query,
|
|
1044
|
-
* or `undefined` when the repo has none.
|
|
1045
|
-
*
|
|
1046
|
-
* A relative path is rejected rather than resolved, and that rejection is the
|
|
1047
|
-
* whole reason this is validated here: the value is written in one process and
|
|
1048
|
-
* *used* in another, by a session whose cwd is its own throwaway worktree. So
|
|
1049
|
-
* `../graph/api` would name a different directory for every reader, and none of
|
|
1050
|
-
* them the one that was indexed. There is no cwd this file could honestly
|
|
1051
|
-
* resolve it against, so it says so rather than guessing.
|
|
1052
|
-
*/
|
|
1053
|
-
function normalizeGraphProject(parsed: unknown, label: string, problems: string[]): string | undefined {
|
|
1265
|
+
function finalizeGraphProject(parsed: unknown, label: string, problems: string[]): string | undefined {
|
|
1054
1266
|
if (parsed === undefined) return undefined;
|
|
1055
|
-
if (
|
|
1267
|
+
if (typeof parsed !== "string" || parsed.trim() === "") {
|
|
1056
1268
|
problems.push(`${label}.graphProject must be a non-empty absolute path, found ${JSON.stringify(parsed)}`);
|
|
1057
1269
|
return undefined;
|
|
1058
1270
|
}
|
|
1059
|
-
|
|
1060
1271
|
const path = expandHome(parsed.trim());
|
|
1061
1272
|
if (isAbsolute(path)) return path;
|
|
1062
1273
|
problems.push(
|
|
@@ -1067,53 +1278,16 @@ function normalizeGraphProject(parsed: unknown, label: string, problems: string[
|
|
|
1067
1278
|
}
|
|
1068
1279
|
|
|
1069
1280
|
/**
|
|
1070
|
-
*
|
|
1071
|
-
*
|
|
1072
|
-
*
|
|
1073
|
-
*
|
|
1074
|
-
* It is deliberately *not* validated, and not rejected either: project-level
|
|
1075
|
-
* keys this build does not know are ignored, so a live fleet whose config still
|
|
1076
|
-
* carries it keeps loading. A retired key that failed validation would brick
|
|
1077
|
-
* exactly the fleets that adopted it, which is the 0.4.1→0.4.2 outage repeated
|
|
1078
|
-
* on purpose.
|
|
1079
|
-
*/
|
|
1080
|
-
|
|
1081
|
-
/**
|
|
1082
|
-
* Gates are the pre-push CI equivalent, so a malformed entry is an error, not
|
|
1083
|
-
* something to drop quietly: a skipped gate is exactly how a lint failure
|
|
1084
|
-
* reaches the runners unattended.
|
|
1085
|
-
*/
|
|
1086
|
-
function normalizeGates(parsed: unknown, label: string, problems: string[]): { cmd: string; cwd: string }[] {
|
|
1087
|
-
if (parsed === undefined) return [];
|
|
1088
|
-
if (!Array.isArray(parsed)) {
|
|
1089
|
-
problems.push(`${label}.gates must be an array of { cmd, cwd }`);
|
|
1090
|
-
return [];
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
const gates: { cmd: string; cwd: string }[] = [];
|
|
1094
|
-
parsed.forEach((entry: unknown, i) => {
|
|
1095
|
-
const gate = entry as Raw | undefined;
|
|
1096
|
-
const cmd = gate?.["cmd"];
|
|
1097
|
-
if (!nonEmptyString(cmd)) {
|
|
1098
|
-
problems.push(`${label}.gates[${i}] must be { cmd, cwd } with a non-empty cmd`);
|
|
1099
|
-
return;
|
|
1100
|
-
}
|
|
1101
|
-
gates.push({ cmd, cwd: pickString(gate?.["cwd"], ".") });
|
|
1102
|
-
});
|
|
1103
|
-
return gates;
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
/**
|
|
1107
|
-
* Keeps only well-formed numeric caps; a value of the wrong shape is always
|
|
1108
|
-
* reported, because a ceiling the daemon cannot read is worth stopping for.
|
|
1109
|
-
*
|
|
1110
|
-
* `legacy` decides what an *unrecognised* key means. In a v1 file it is a cap
|
|
1111
|
-
* this version retired, so it is dropped and the config still loads — refusing
|
|
1112
|
-
* would strand a fleet on upgrade. In a v2 file every key this build writes is
|
|
1113
|
-
* current, so an unknown one is a typo and is reported: otherwise a mistyped
|
|
1114
|
-
* `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.
|
|
1115
1284
|
*/
|
|
1116
|
-
function
|
|
1285
|
+
function reconcileCaps(
|
|
1286
|
+
parsed: unknown,
|
|
1287
|
+
label: string,
|
|
1288
|
+
problems: string[],
|
|
1289
|
+
legacy: boolean,
|
|
1290
|
+
): Partial<Caps> {
|
|
1117
1291
|
const out: Partial<Caps> = {};
|
|
1118
1292
|
if (parsed === undefined) return out;
|
|
1119
1293
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
@@ -1125,29 +1299,24 @@ function coerceCaps(parsed: unknown, label: string, problems: string[], legacy:
|
|
|
1125
1299
|
for (const key of CAP_KEYS) {
|
|
1126
1300
|
const v = raw[key];
|
|
1127
1301
|
if (v === undefined) continue;
|
|
1128
|
-
// The plan-allowance guard is the only cap that is an object rather than a
|
|
1129
|
-
// number, so it validates its own shape before the numeric rule below can
|
|
1130
|
-
// reject it wholesale.
|
|
1131
1302
|
if (key === "planUsage") {
|
|
1132
1303
|
const cap = coercePlanUsage(v, `${label}.planUsage`, problems);
|
|
1133
1304
|
if (cap !== undefined) out.planUsage = cap;
|
|
1134
1305
|
continue;
|
|
1135
1306
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
if (key === "dailySpendUsd" && v === null) {
|
|
1139
|
-
out.dailySpendUsd = null;
|
|
1307
|
+
if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
|
|
1308
|
+
(out as Record<string, unknown>)[key as string] = v;
|
|
1140
1309
|
continue;
|
|
1141
1310
|
}
|
|
1142
|
-
if (
|
|
1143
|
-
|
|
1144
|
-
key === "dailySpendUsd"
|
|
1145
|
-
? `${label}.${key} must be a non-negative finite number or null (no cap), found ${JSON.stringify(v)}`
|
|
1146
|
-
: `${label}.${key} must be a non-negative finite number, found ${JSON.stringify(v)}`,
|
|
1147
|
-
);
|
|
1311
|
+
if (key === "dailySpendUsd" && v === null) {
|
|
1312
|
+
out.dailySpendUsd = null;
|
|
1148
1313
|
continue;
|
|
1149
1314
|
}
|
|
1150
|
-
|
|
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
|
+
);
|
|
1151
1320
|
}
|
|
1152
1321
|
|
|
1153
1322
|
if (!legacy) {
|
|
@@ -1158,28 +1327,9 @@ function coerceCaps(parsed: unknown, label: string, problems: string[], legacy:
|
|
|
1158
1327
|
);
|
|
1159
1328
|
}
|
|
1160
1329
|
}
|
|
1161
|
-
|
|
1162
1330
|
return out;
|
|
1163
1331
|
}
|
|
1164
1332
|
|
|
1165
|
-
/**
|
|
1166
|
-
* `{ windowId, maxUsedFraction }`, or `null` for unmetered.
|
|
1167
|
-
*
|
|
1168
|
-
* Fail-closed for the same reason every other guard here is: a plan-allowance
|
|
1169
|
-
* cap the daemon cannot read is a ceiling the operator believes they have. Two
|
|
1170
|
-
* mistakes in particular are rejected by name rather than folded:
|
|
1171
|
-
*
|
|
1172
|
-
* - **`maxUsedFraction: 85`.** The threshold is a fraction, so `85` compares
|
|
1173
|
-
* as "hold at 8500% consumed" — a guard that can never fire, and one that
|
|
1174
|
-
* reads in the config file exactly like a deliberate 85% ceiling.
|
|
1175
|
-
* - **A misspelled key.** `windowID`, `window`, `maxUsedPercent` and friends
|
|
1176
|
-
* would leave a cap with a missing half, which is the same silent
|
|
1177
|
-
* never-fires outcome.
|
|
1178
|
-
*
|
|
1179
|
-
* A `windowId` no provider reports cannot be caught here — only a live reading
|
|
1180
|
-
* knows what exists — so that case is caught at admission instead, where it
|
|
1181
|
-
* holds dispatch and names the window (see `planUsageStatus` in `usage.ts`).
|
|
1182
|
-
*/
|
|
1183
1333
|
function coercePlanUsage(
|
|
1184
1334
|
v: unknown,
|
|
1185
1335
|
label: string,
|
|
@@ -1193,15 +1343,14 @@ function coercePlanUsage(
|
|
|
1193
1343
|
return undefined;
|
|
1194
1344
|
}
|
|
1195
1345
|
const raw = v as Raw;
|
|
1346
|
+
const unknownKeys = Object.keys(raw).filter((k) => k !== "windowId" && k !== "maxUsedFraction");
|
|
1196
1347
|
const rawId = raw["windowId"];
|
|
1348
|
+
const windowId = typeof rawId === "string" && rawId.trim() !== "" ? rawId.trim() : undefined;
|
|
1197
1349
|
const rawFraction = raw["maxUsedFraction"];
|
|
1198
|
-
const windowId = nonEmptyString(rawId) ? rawId.trim() : undefined;
|
|
1199
1350
|
const maxUsedFraction =
|
|
1200
1351
|
typeof rawFraction === "number" && Number.isFinite(rawFraction) && rawFraction >= 0 && rawFraction <= 1
|
|
1201
1352
|
? rawFraction
|
|
1202
1353
|
: undefined;
|
|
1203
|
-
const unknownKeys = Object.keys(raw).filter((k) => k !== "windowId" && k !== "maxUsedFraction");
|
|
1204
|
-
|
|
1205
1354
|
if (windowId === undefined) {
|
|
1206
1355
|
problems.push(
|
|
1207
1356
|
`${label}.windowId must be a non-empty allowance id such as "anthropic:7d", found ${JSON.stringify(rawId)}`,
|
|
@@ -1219,51 +1368,260 @@ function coercePlanUsage(
|
|
|
1219
1368
|
return { windowId, maxUsedFraction };
|
|
1220
1369
|
}
|
|
1221
1370
|
|
|
1222
|
-
|
|
1223
|
-
|
|
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] };
|
|
1224
1402
|
}
|
|
1225
1403
|
|
|
1226
|
-
/**
|
|
1227
|
-
|
|
1228
|
-
|
|
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
|
+
};
|
|
1229
1456
|
}
|
|
1230
1457
|
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
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 };
|
|
1234
1582
|
}
|
|
1235
1583
|
|
|
1236
1584
|
/**
|
|
1237
|
-
*
|
|
1238
|
-
* default", used by every such field here.
|
|
1585
|
+
* A clone URL carrying a password or token is rejected at load.
|
|
1239
1586
|
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1244
|
-
*
|
|
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.
|
|
1593
|
+
*
|
|
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.
|
|
1245
1597
|
*/
|
|
1246
|
-
function
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
): T {
|
|
1254
|
-
if (v === undefined) return fallback;
|
|
1255
|
-
const hit = allowed.find((a) => a === v);
|
|
1256
|
-
if (hit === undefined) {
|
|
1257
|
-
problems.push(`${field} must be ${quoted}, found ${JSON.stringify(v)}`);
|
|
1258
|
-
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";
|
|
1259
1605
|
}
|
|
1260
|
-
|
|
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;
|
|
1261
1619
|
}
|
|
1262
1620
|
|
|
1263
1621
|
/**
|
|
1264
1622
|
* `~/x` in a hand-written config must not create a literal `~` directory.
|
|
1265
1623
|
*
|
|
1266
|
-
* Exported because the wizard and `graph
|
|
1624
|
+
* Exported because the wizard and `setup graph` derive paths the operator may
|
|
1267
1625
|
* have typed with a `~` in them, and one spelling of this rule in the package
|
|
1268
1626
|
* is the only way a path shown in a plan matches the path a validator accepts.
|
|
1269
1627
|
*/
|