omp-conductor 0.10.0 → 0.13.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/src/config.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  DEFAULT_AUTHORITY,
25
25
  DEFAULT_CAPS,
26
26
  DEFAULT_PROJECT_POLICY,
27
+ DEFAULT_REPORT_POLICY,
27
28
  DEFAULT_REPORT_SCOPE,
28
29
  DENIED_RELEASE_GRANTS,
29
30
  DRAFT_POLICIES,
@@ -34,8 +35,12 @@ import {
34
35
  RELEASE_REQUIREMENTS,
35
36
  RELEASE_SHAPES,
36
37
  REPORT_SCOPES,
38
+ INTERRUPT_CATEGORIES,
39
+ DIGEST_CADENCES,
37
40
  type Caps,
38
41
  type ConductorConfig,
42
+ type DigestCadence,
43
+ type InterruptCategory,
39
44
  type MergePreconditions,
40
45
  type PlanUsageCap,
41
46
  type ProjectConfig,
@@ -43,6 +48,7 @@ import {
43
48
  type ReleasePreconditions,
44
49
  type ReleaseRequirement,
45
50
  type ReportScope,
51
+ type ReportingPolicy,
46
52
  type RepoTarget,
47
53
  type ResolvedGrants,
48
54
  } from "./types.ts";
@@ -64,6 +70,8 @@ const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
64
70
 
65
71
  /** Quoted for error messages, from the same data the guards below read. */
66
72
  const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
73
+ const INTERRUPT_CATEGORY_LIST = quoteList(INTERRUPT_CATEGORIES);
74
+ const DIGEST_CADENCE_LIST = quoteList(DIGEST_CADENCES);
67
75
  const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
68
76
  const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
69
77
  const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
@@ -219,12 +227,16 @@ export function writeConfigRaw(text: string): void {
219
227
  */
220
228
  export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
221
229
  const o: Partial<Caps> = p.caps ?? {};
230
+ const workerMaxTurns = o.workerMaxTurns ?? defaults.workerMaxTurns;
222
231
  return {
223
232
  maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
224
233
  maxConcurrentWorkersPerRepo: o.maxConcurrentWorkersPerRepo ?? defaults.maxConcurrentWorkersPerRepo,
225
234
  dailySpendUsd: o.dailySpendUsd !== undefined ? o.dailySpendUsd : defaults.dailySpendUsd,
226
235
  planUsage: o.planUsage !== undefined ? o.planUsage : defaults.planUsage,
227
- workerMaxTurns: o.workerMaxTurns ?? defaults.workerMaxTurns,
236
+ workerMaxTurns,
237
+ workerMaxTurnsCeiling:
238
+ o.workerMaxTurnsCeiling ??
239
+ (o.workerMaxTurns === undefined ? defaults.workerMaxTurnsCeiling : workerMaxTurns * 2),
228
240
  workerWallClockMs: o.workerWallClockMs ?? defaults.workerWallClockMs,
229
241
  maxAttemptsPerIssue: o.maxAttemptsPerIssue ?? defaults.maxAttemptsPerIssue,
230
242
  maxContinuationsPerIssue:
@@ -358,9 +370,13 @@ function validate(parsed: unknown, path: string): ConductorConfig {
358
370
  );
359
371
  }
360
372
 
373
+ const configuredDefaults = coerceCaps(root["defaults"], `"defaults"`, problems, legacyCaps);
374
+ const defaultWorkerMaxTurns = configuredDefaults.workerMaxTurns ?? DEFAULT_CAPS.workerMaxTurns;
361
375
  const defaults: Caps = {
362
376
  ...DEFAULT_CAPS,
363
- ...coerceCaps(root["defaults"], `"defaults"`, problems, legacyCaps),
377
+ ...configuredDefaults,
378
+ workerMaxTurnsCeiling:
379
+ configuredDefaults.workerMaxTurnsCeiling ?? defaultWorkerMaxTurns * 2,
364
380
  };
365
381
 
366
382
  const rawProjects = root["projects"];
@@ -482,34 +498,173 @@ function normalizeProject(
482
498
  * that silently resolved to `"material"` would read as configured on the day the
483
499
  * operator meant to turn the volume down, and the config would keep lying.
484
500
  */
485
- function normalizeReporting(parsed: unknown, label: string, problems: string[]): ProjectConfig["reporting"] {
486
- if (parsed === undefined) return { scope: DEFAULT_REPORT_SCOPE };
501
+ /** The legacy `reporting.scope` presets, materialised as explicit policies.
502
+ * Kept separate from {@link DEFAULT_REPORT_POLICY} (the "no key on disk"
503
+ * default, which must stay `material`): each preset records `scopePreset` so
504
+ * the tick prompt can keep saying the exact legacy words (#229). Exported so
505
+ * the setup wizard and the config validator agree on one mapping. */
506
+ export const SCOPE_PRESETS: Record<ReportScope, ReportingPolicy> = {
507
+ material: {
508
+ interruptOn: [...INTERRUPT_CATEGORIES],
509
+ digest: { cadence: "per-tick" },
510
+ scopePreset: "material",
511
+ },
512
+ decisions: {
513
+ interruptOn: ["tier2", "decision-needed", "fleet-stopped"],
514
+ digest: { cadence: "per-tick" },
515
+ scopePreset: "decisions",
516
+ },
517
+ escalations: {
518
+ interruptOn: ["tier2", "fleet-stopped"],
519
+ digest: { cadence: "daily" },
520
+ scopePreset: "escalations",
521
+ },
522
+ };
523
+
524
+ function defaultReporting(): ReportingPolicy {
525
+ return { ...DEFAULT_REPORT_POLICY, interruptOn: [...DEFAULT_REPORT_POLICY.interruptOn] };
526
+ }
527
+
528
+ /** The 24-hour `HH:MM` shape `digest.at` must take. */
529
+ const DIGEST_AT = /^([01]\d|2[0-3]):[0-5]\d$/;
530
+
531
+ /**
532
+ * The reporting policy: a legacy `scope` preset, or the explicit
533
+ * `interruptOn` + `digest` form. The two forms are mutually exclusive — a
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();
487
539
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
488
- problems.push(`${label}: reporting must be an object with a "scope" of ${REPORT_SCOPE_LIST}`);
489
- return { scope: DEFAULT_REPORT_SCOPE };
540
+ problems.push(`${label}: reporting must be an object with a "scope" preset or explicit "interruptOn"/"digest"`);
541
+ return defaultReporting();
490
542
  }
491
543
  const raw = parsed as Raw;
492
-
493
- const unknownKeys = Object.keys(raw).filter((k) => k !== "scope");
544
+ const keys = Object.keys(raw);
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));
494
549
  if (unknownKeys.length > 0) {
495
- // Stricter than caps, which tolerate a retired key: `reporting` has exactly
496
- // one member, so an unrecognised key here is a typo every time, and the
497
- // block that ignores it looks configured either way.
498
550
  problems.push(`${label}: reporting has unknown key(s): ${unknownKeys.join(", ")}`);
499
551
  }
500
-
501
- return {
502
- scope: pickLiteral(
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(
503
560
  raw["scope"],
504
561
  REPORT_SCOPES,
505
562
  DEFAULT_REPORT_SCOPE,
506
563
  `${label}: reporting.scope`,
507
564
  REPORT_SCOPE_LIST,
508
565
  problems,
509
- ),
566
+ );
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
+ }
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 }),
510
588
  };
511
589
  }
512
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
+
513
668
  /**
514
669
  * Who triages escalations, and how they are delivered when nobody answers.
515
670
  *
@@ -853,12 +1008,37 @@ function normalizeRepos(parsed: unknown, label: string, problems: string[]): Rec
853
1008
  };
854
1009
  const graph = normalizeGraphProject(value?.["graphProject"], `${label}: routing.repos.${key}`, problems);
855
1010
  if (graph !== undefined) target.graphProject = graph;
1011
+ const migrations = normalizeMigrationsDir(value?.["migrations"], `${label}: routing.repos.${key}`, problems);
1012
+ if (migrations !== undefined) target.migrations = { dir: migrations };
856
1013
  repos[key] = target;
857
1014
  }
858
1015
 
859
1016
  return repos;
860
1017
  }
861
1018
 
1019
+ /**
1020
+ * The ordered-migration-chain directory (#227), or `undefined` when the repo
1021
+ * opts out of the chain check.
1022
+ *
1023
+ * Repo-relative, no leading `/`, and no `..` segment — the value is read in one
1024
+ * process and used in another against the base branch's tree, so anything that
1025
+ * is not plainly a directory name is a guess the guard must not make.
1026
+ */
1027
+ function normalizeMigrationsDir(parsed: unknown, label: string, problems: string[]): string | undefined {
1028
+ if (parsed === undefined) return undefined;
1029
+ const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined;
1030
+ const dir = raw?.["dir"];
1031
+ if (!nonEmptyString(dir)) {
1032
+ problems.push(`${label}.migrations.dir must be a non-empty string`);
1033
+ return undefined;
1034
+ }
1035
+ if (dir.startsWith("/") || dir.split("/").includes("..")) {
1036
+ problems.push(`${label}.migrations.dir must be a repo-relative directory`);
1037
+ return undefined;
1038
+ }
1039
+ return dir;
1040
+ }
1041
+
862
1042
  /**
863
1043
  * The path of the index-only clone whose code graph this repo's workers query,
864
1044
  * or `undefined` when the repo has none.