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/README.md +73 -19
- package/package.json +1 -1
- package/src/board.ts +125 -11
- package/src/briefs/orchestrator.md +50 -4
- package/src/briefs/policy.md +4 -1
- package/src/chain-check.ts +157 -0
- package/src/cli.ts +188 -19
- package/src/config.ts +195 -15
- package/src/daemon.ts +1017 -119
- package/src/diff-flags.ts +48 -3
- package/src/digest-schedule.ts +59 -0
- package/src/escalate.ts +17 -0
- package/src/failure-class.ts +31 -4
- package/src/fleet.ts +8 -3
- package/src/gitops.ts +49 -0
- package/src/omp.ts +44 -9
- package/src/orchestrator-tick.ts +94 -9
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +46 -4
- package/src/release-policy.ts +66 -6
- package/src/reports.ts +5 -6
- package/src/session-host.ts +23 -6
- package/src/setup.ts +43 -10
- package/src/store.ts +263 -31
- package/src/tracker/github.ts +261 -56
- package/src/types.ts +251 -32
- package/src/verbs/actions.ts +127 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +144 -13
- package/src/worker.ts +183 -17
- package/src/worktree.ts +5 -0
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
|
|
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
|
-
...
|
|
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
|
-
|
|
486
|
-
|
|
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"
|
|
489
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
502
|
-
|
|
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.
|