omp-conductor 0.13.0 → 0.14.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 +214 -66
- package/package.json +1 -1
- package/src/availability.ts +165 -0
- package/src/briefs/orchestrator.md +63 -26
- package/src/briefs/policy.md +44 -32
- package/src/cli.ts +122 -14
- package/src/config.ts +113 -5
- package/src/daemon.ts +218 -32
- package/src/diff-flags.ts +73 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +46 -19
- package/src/fleet.ts +34 -3
- package/src/orchestrator-tick.ts +437 -20
- package/src/plugin.ts +138 -12
- package/src/reports.ts +202 -5
- package/src/setup.ts +193 -33
- package/src/store.ts +610 -98
- package/src/tracker/github.ts +43 -5
- package/src/types.ts +151 -12
- package/src/verbs/actions.ts +131 -13
- package/src/verbs/server.ts +8 -9
- package/src/worker.ts +18 -6
package/src/setup.ts
CHANGED
|
@@ -52,18 +52,22 @@ import {
|
|
|
52
52
|
DEFAULT_REPORT_SCOPE,
|
|
53
53
|
DENIED_RELEASE_GRANTS,
|
|
54
54
|
RELEASE_SHAPES,
|
|
55
|
+
WEEKDAYS,
|
|
55
56
|
type BaseFreshness,
|
|
56
57
|
type BehindBaseAction,
|
|
57
58
|
type Caps,
|
|
58
59
|
type ConductorConfig,
|
|
60
|
+
type DigestCadence,
|
|
59
61
|
type DraftPolicy,
|
|
60
62
|
type OrchestratorMode,
|
|
63
|
+
type InterruptCategory,
|
|
61
64
|
type ProjectConfig,
|
|
62
65
|
type ProjectPolicy,
|
|
63
66
|
type ReleaseRequirement,
|
|
64
|
-
type ReportScope,
|
|
65
67
|
type ReportScopeChoice,
|
|
66
68
|
type ReportingPolicy,
|
|
69
|
+
type Weekday,
|
|
70
|
+
type WeeklyAvailability,
|
|
67
71
|
type RepoTarget,
|
|
68
72
|
type ResolvedGrants,
|
|
69
73
|
} from "./types.ts";
|
|
@@ -92,8 +96,19 @@ export interface SetupAnswers {
|
|
|
92
96
|
fallbackToIssueComment: boolean;
|
|
93
97
|
/** How loud the supervising orchestrator session should be. */
|
|
94
98
|
reportScope: ReportScopeChoice;
|
|
95
|
-
/**
|
|
96
|
-
|
|
99
|
+
/** One IANA zone shared by the configured window and its daily digest. */
|
|
100
|
+
reportingTimezone?: string;
|
|
101
|
+
/** Daily digest wall-clock (`HH:MM`) when an explicit daily policy is chosen. */
|
|
102
|
+
dailyDigestAt?: string;
|
|
103
|
+
/** Digest cadence when preserving or explicitly editing a reporting policy. */
|
|
104
|
+
digestCadence?: DigestCadence;
|
|
105
|
+
/** Weekly window without its shared timezone; absent keeps 24-hour interrupts. */
|
|
106
|
+
availability?: Omit<WeeklyAvailability, "timezone">;
|
|
107
|
+
/**
|
|
108
|
+
* Exact policy read from disk. Unrelated amend areas carry this through
|
|
109
|
+
* untouched; the reporting interview deletes it before applying new answers.
|
|
110
|
+
*/
|
|
111
|
+
preservedReporting?: ReportingPolicy;
|
|
97
112
|
/**
|
|
98
113
|
* Mechanical gate for release/deploy-shaped tool calls, per shape. Always
|
|
99
114
|
* complete: the wizard asks about every shape, so an answer object can never
|
|
@@ -189,7 +204,7 @@ export const REPORT_SCOPE_CHOICES: readonly {
|
|
|
189
204
|
scope: "decisions",
|
|
190
205
|
label: "Decisions interrupt, rest batches",
|
|
191
206
|
description:
|
|
192
|
-
"tier-2 decisions and fleet-stopping conditions immediately; every other material event ships with the
|
|
207
|
+
"tier-2 decisions and fleet-stopping conditions immediately; every other material event ships with the configured digest",
|
|
193
208
|
},
|
|
194
209
|
{
|
|
195
210
|
scope: "material",
|
|
@@ -510,6 +525,88 @@ export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]
|
|
|
510
525
|
return created;
|
|
511
526
|
}
|
|
512
527
|
|
|
528
|
+
const QUIET_INTERRUPT_ON: InterruptCategory[] = [
|
|
529
|
+
"tier2",
|
|
530
|
+
"fleet-stopped",
|
|
531
|
+
"confirmed-failure",
|
|
532
|
+
];
|
|
533
|
+
|
|
534
|
+
function interruptCategories(scope: ReportScopeChoice): InterruptCategory[] {
|
|
535
|
+
return scope === "quiet"
|
|
536
|
+
? [...QUIET_INTERRUPT_ON]
|
|
537
|
+
: [...(SCOPE_PRESETS[scope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn];
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Copy policy arrays so a setup amend cannot mutate the config it was seeded from. */
|
|
541
|
+
function cloneReportingPolicy(policy: ReportingPolicy): ReportingPolicy {
|
|
542
|
+
return {
|
|
543
|
+
interruptOn: [...policy.interruptOn],
|
|
544
|
+
digest: { ...policy.digest },
|
|
545
|
+
...(policy.availability === undefined
|
|
546
|
+
? {}
|
|
547
|
+
: {
|
|
548
|
+
availability: {
|
|
549
|
+
...policy.availability,
|
|
550
|
+
days: [...policy.availability.days],
|
|
551
|
+
bypass: [...policy.availability.bypass],
|
|
552
|
+
},
|
|
553
|
+
}),
|
|
554
|
+
...(policy.scopePreset === undefined ? {} : { scopePreset: policy.scopePreset }),
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Turn the wizard's local notification answers into the runtime policy. */
|
|
559
|
+
function reportingFromAnswers(a: SetupAnswers): ReportingPolicy {
|
|
560
|
+
if (a.preservedReporting !== undefined) return cloneReportingPolicy(a.preservedReporting);
|
|
561
|
+
|
|
562
|
+
const digest = (fallback: DigestCadence): ReportingPolicy["digest"] => {
|
|
563
|
+
const cadence = a.digestCadence ?? fallback;
|
|
564
|
+
if (cadence !== "daily") return { cadence };
|
|
565
|
+
const at = a.dailyDigestAt?.trim();
|
|
566
|
+
const timezone = a.reportingTimezone?.trim();
|
|
567
|
+
return {
|
|
568
|
+
cadence,
|
|
569
|
+
...(at === undefined || at.length === 0 ? {} : { at }),
|
|
570
|
+
...(timezone === undefined || timezone.length === 0 ? {} : { timezone }),
|
|
571
|
+
};
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const timezone = a.reportingTimezone?.trim();
|
|
575
|
+
if (a.availability !== undefined) {
|
|
576
|
+
if (timezone === undefined || timezone.length === 0) {
|
|
577
|
+
throw new Error("operator availability requires an IANA reporting timezone");
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
interruptOn: interruptCategories(a.reportScope),
|
|
581
|
+
digest: digest("daily"),
|
|
582
|
+
availability: {
|
|
583
|
+
timezone,
|
|
584
|
+
days: [...a.availability.days],
|
|
585
|
+
start: a.availability.start,
|
|
586
|
+
end: a.availability.end,
|
|
587
|
+
bypass: [...a.availability.bypass],
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
if (a.reportScope === "quiet") {
|
|
592
|
+
return {
|
|
593
|
+
interruptOn: [...QUIET_INTERRUPT_ON],
|
|
594
|
+
digest: digest("daily"),
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
const preset = SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE];
|
|
598
|
+
const resolvedDigest = digest(preset.digest.cadence);
|
|
599
|
+
const presetDigest =
|
|
600
|
+
resolvedDigest.cadence === preset.digest.cadence &&
|
|
601
|
+
resolvedDigest.at === preset.digest.at &&
|
|
602
|
+
resolvedDigest.timezone === preset.digest.timezone;
|
|
603
|
+
return {
|
|
604
|
+
interruptOn: [...preset.interruptOn],
|
|
605
|
+
digest: resolvedDigest,
|
|
606
|
+
...(presetDigest ? { scopePreset: preset.scopePreset } : {}),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
513
610
|
/**
|
|
514
611
|
* The single `ProjectConfig` the answers describe.
|
|
515
612
|
*
|
|
@@ -576,24 +673,10 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
576
673
|
// and a release require without anyone having to know a default (#129).
|
|
577
674
|
policy: clonePolicy(a.policy),
|
|
578
675
|
// Written out even when it is the default, so an operator amending the
|
|
579
|
-
// volume has a line in the file to point at.
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
reporting:
|
|
584
|
-
a.reportScope === "quiet"
|
|
585
|
-
? {
|
|
586
|
-
interruptOn: ["tier2", "fleet-stopped", "confirmed-failure"],
|
|
587
|
-
digest: {
|
|
588
|
-
cadence: "daily",
|
|
589
|
-
...(a.quietDigestAt !== undefined && a.quietDigestAt.length > 0 ? { at: a.quietDigestAt } : {}),
|
|
590
|
-
},
|
|
591
|
-
}
|
|
592
|
-
: {
|
|
593
|
-
interruptOn: [...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn],
|
|
594
|
-
digest: { ...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).digest },
|
|
595
|
-
scopePreset: (SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).scopePreset,
|
|
596
|
-
},
|
|
676
|
+
// volume has a line in the file to point at. Opting into availability makes
|
|
677
|
+
// the schedule explicit and daily; omitting it preserves the preset's
|
|
678
|
+
// legacy 24-hour behaviour (#273).
|
|
679
|
+
reporting: reportingFromAnswers(a),
|
|
597
680
|
// Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
|
|
598
681
|
// uninstall, and neither can land in a repo the daemon then tries to commit.
|
|
599
682
|
workspaceRoot: defaultWorkspaceRoot(),
|
|
@@ -667,19 +750,60 @@ export function defaultAnswers(projectName: string): SetupAnswers {
|
|
|
667
750
|
* changing an unrelated one. `buildProject(answersFromProject(p))` is pinned to
|
|
668
751
|
* `p` by a test for exactly that reason.
|
|
669
752
|
*
|
|
670
|
-
*
|
|
753
|
+
* Four fields cannot be a straight copy:
|
|
671
754
|
*
|
|
672
755
|
* - `writeOrchestratorBrief` is a decision rather than a value, and it starts
|
|
673
756
|
* `false` so an amend that never visits the brief area leaves that file alone.
|
|
674
|
-
* - `reportScope`
|
|
675
|
-
*
|
|
676
|
-
*
|
|
757
|
+
* - `reportScope` recognizes both preset metadata and the explicit quiet
|
|
758
|
+
* category set, because an availability schedule intentionally has no preset.
|
|
759
|
+
* - The reporting timezone is shared by availability and the daily digest in
|
|
760
|
+
* the wizard even though runtime config stores it in both places.
|
|
677
761
|
* - `graphRoot` is one answer for a whole project while the config stores one
|
|
678
762
|
* path per repo, so it comes back from whichever repo already has one. Repos
|
|
679
763
|
* that disagree — only a hand-edit can produce that — widen to all of them on
|
|
680
764
|
* the next write exactly as a full re-run would, and the plan summary names
|
|
681
765
|
* every clone before anything is written.
|
|
682
766
|
*/
|
|
767
|
+
function sameInterruptCategories(
|
|
768
|
+
actual: readonly InterruptCategory[],
|
|
769
|
+
expected: readonly InterruptCategory[],
|
|
770
|
+
): boolean {
|
|
771
|
+
return (
|
|
772
|
+
actual.length === expected.length &&
|
|
773
|
+
actual.every((category) => expected.includes(category))
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
export function reportScopeFromPolicy(policy: ReportingPolicy | undefined): ReportScopeChoice {
|
|
778
|
+
if (policy?.scopePreset !== undefined) return policy.scopePreset;
|
|
779
|
+
const actual = policy?.interruptOn ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE].interruptOn;
|
|
780
|
+
if (sameInterruptCategories(actual, QUIET_INTERRUPT_ON)) return "quiet";
|
|
781
|
+
for (const scope of ["material", "decisions", "escalations"] as const) {
|
|
782
|
+
if (sameInterruptCategories(actual, SCOPE_PRESETS[scope].interruptOn)) return scope;
|
|
783
|
+
}
|
|
784
|
+
return DEFAULT_REPORT_SCOPE;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function reportScopeDescriptionForPolicy(policy: ReportingPolicy | undefined): string {
|
|
788
|
+
const scope = reportScopeFromPolicy(policy);
|
|
789
|
+
const choice = REPORT_SCOPE_CHOICES.find((candidate) => candidate.scope === scope);
|
|
790
|
+
const expectedCadence =
|
|
791
|
+
scope === "quiet" ? "daily" : SCOPE_PRESETS[scope].digest.cadence;
|
|
792
|
+
const configured = policy?.digest ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE].digest;
|
|
793
|
+
if (configured.cadence === expectedCadence) {
|
|
794
|
+
return choice?.description ?? "unknown scope";
|
|
795
|
+
}
|
|
796
|
+
const digest =
|
|
797
|
+
configured.cadence === "none"
|
|
798
|
+
? "disabled"
|
|
799
|
+
: configured.cadence === "per-tick"
|
|
800
|
+
? "every tick"
|
|
801
|
+
: `daily at ${configured.at ?? "(model-timed)"}${
|
|
802
|
+
configured.timezone === undefined ? "" : ` ${configured.timezone}`
|
|
803
|
+
}`;
|
|
804
|
+
return `${choice?.label ?? scope}; configured digest ${digest}`;
|
|
805
|
+
}
|
|
806
|
+
|
|
683
807
|
export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
684
808
|
const answers: SetupAnswers = {
|
|
685
809
|
projectName: p.name,
|
|
@@ -699,14 +823,31 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
699
823
|
releaseGrants: resolveReleaseGrants(p),
|
|
700
824
|
policy: resolvePolicy(p),
|
|
701
825
|
orchestratorMode: p.escalation.orchestrator,
|
|
702
|
-
reportScope: p.reporting
|
|
826
|
+
reportScope: reportScopeFromPolicy(p.reporting),
|
|
703
827
|
writeOrchestratorBrief: false,
|
|
704
828
|
};
|
|
829
|
+
if (p.reporting !== undefined) {
|
|
830
|
+
answers.preservedReporting = cloneReportingPolicy(p.reporting);
|
|
831
|
+
answers.digestCadence = p.reporting.digest.cadence;
|
|
832
|
+
}
|
|
705
833
|
|
|
706
834
|
// Set only when present, never as an explicit `undefined`: an absent key is
|
|
707
835
|
// what keeps the rewritten config identical to the one that was read.
|
|
708
836
|
if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
|
|
709
837
|
if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
|
|
838
|
+
if (p.reporting?.digest.at !== undefined) answers.dailyDigestAt = p.reporting.digest.at;
|
|
839
|
+
if (p.reporting?.digest.timezone !== undefined) {
|
|
840
|
+
answers.reportingTimezone = p.reporting.digest.timezone;
|
|
841
|
+
}
|
|
842
|
+
if (p.reporting?.availability !== undefined) {
|
|
843
|
+
answers.reportingTimezone = p.reporting.availability.timezone;
|
|
844
|
+
answers.availability = {
|
|
845
|
+
days: [...p.reporting.availability.days],
|
|
846
|
+
start: p.reporting.availability.start,
|
|
847
|
+
end: p.reporting.availability.end,
|
|
848
|
+
bypass: [...p.reporting.availability.bypass],
|
|
849
|
+
};
|
|
850
|
+
}
|
|
710
851
|
const graphed = graphRepos(p)[0];
|
|
711
852
|
if (graphed !== undefined) answers.graphRoot = dirname(graphed.graphProject);
|
|
712
853
|
|
|
@@ -736,7 +877,6 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
|
|
|
736
877
|
QUEUE_LABEL: p.queueLabel,
|
|
737
878
|
RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
|
|
738
879
|
MERGE_DUTY: MERGE_DUTY[p.authority.merge],
|
|
739
|
-
REPORT_SCOPE: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
|
|
740
880
|
POLICY_SOURCE: policySourceLine(p),
|
|
741
881
|
};
|
|
742
882
|
}
|
|
@@ -931,6 +1071,12 @@ function pairedOwner(accessPath: string): string | undefined {
|
|
|
931
1071
|
return undefined;
|
|
932
1072
|
}
|
|
933
1073
|
|
|
1074
|
+
function describeAvailabilityDays(days: readonly Weekday[]): string {
|
|
1075
|
+
return days.length === WEEKDAYS.length && WEEKDAYS.every((day) => days.includes(day))
|
|
1076
|
+
? "every day"
|
|
1077
|
+
: days.join(",");
|
|
1078
|
+
}
|
|
1079
|
+
|
|
934
1080
|
/**
|
|
935
1081
|
* Everything that would change, as plain text, with no side effects at all.
|
|
936
1082
|
*
|
|
@@ -1083,12 +1229,27 @@ export function summarisePlan(
|
|
|
1083
1229
|
` environments ${a.policy.release.environments.join(", ") || "none declared — every deploy target is refused"}`,
|
|
1084
1230
|
);
|
|
1085
1231
|
|
|
1086
|
-
const
|
|
1232
|
+
const reporting = project.reporting as ReportingPolicy;
|
|
1087
1233
|
const briefPath = orchestratorBriefPath(a);
|
|
1088
1234
|
lines.push(
|
|
1089
1235
|
"",
|
|
1090
1236
|
"reporting",
|
|
1091
|
-
` scope ${a.reportScope} — ${
|
|
1237
|
+
` scope ${a.reportScope} — ${reportScopeDescriptionForPolicy(reporting)}`,
|
|
1238
|
+
);
|
|
1239
|
+
if (reporting.availability === undefined) {
|
|
1240
|
+
lines.push(" availability continuous — interrupt categories may page 24 hours a day");
|
|
1241
|
+
} else {
|
|
1242
|
+
lines.push(
|
|
1243
|
+
` availability ${describeAvailabilityDays(reporting.availability.days)} ` +
|
|
1244
|
+
`${reporting.availability.start}-${reporting.availability.end} ${reporting.availability.timezone}`,
|
|
1245
|
+
` bypass ${reporting.availability.bypass.join(", ") || "none"}`,
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
lines.push(
|
|
1249
|
+
` digest ${reporting.digest.cadence}` +
|
|
1250
|
+
(reporting.digest.cadence === "daily"
|
|
1251
|
+
? ` at ${reporting.digest.at ?? "(model-timed)"}${reporting.digest.timezone ? ` ${reporting.digest.timezone}` : ""}`
|
|
1252
|
+
: ""),
|
|
1092
1253
|
);
|
|
1093
1254
|
if (a.writeOrchestratorBrief) {
|
|
1094
1255
|
const policyPath = briefPath.replace(/ORCHESTRATOR\.md$/, "POLICY.md");
|
|
@@ -1267,9 +1428,8 @@ export const AMEND_AREAS: {
|
|
|
1267
1428
|
name: "reporting scope",
|
|
1268
1429
|
asks: "how much the orchestrator says unprompted",
|
|
1269
1430
|
describe: (p) => {
|
|
1270
|
-
const scope = p.reporting
|
|
1271
|
-
|
|
1272
|
-
return `${scope} — ${choice?.description ?? "unknown scope"}`;
|
|
1431
|
+
const scope = reportScopeFromPolicy(p.reporting);
|
|
1432
|
+
return `${scope} — ${reportScopeDescriptionForPolicy(p.reporting)}`;
|
|
1273
1433
|
},
|
|
1274
1434
|
},
|
|
1275
1435
|
brief: {
|