omp-conductor 0.10.0 → 0.12.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 +35 -2
- package/package.json +1 -1
- package/src/board.ts +124 -10
- package/src/briefs/orchestrator.md +18 -2
- package/src/briefs/policy.md +4 -1
- package/src/chain-check.ts +157 -0
- package/src/cli.ts +90 -2
- package/src/config.ts +185 -13
- package/src/daemon.ts +238 -27
- package/src/diff-flags.ts +44 -3
- package/src/digest-schedule.ts +59 -0
- package/src/escalate.ts +17 -0
- package/src/failure-class.ts +25 -0
- package/src/fleet.ts +1 -1
- package/src/gitops.ts +49 -0
- package/src/omp.ts +36 -5
- package/src/orchestrator-tick.ts +73 -3
- package/src/plugin.ts +18 -2
- package/src/reports.ts +5 -6
- package/src/session-host.ts +19 -3
- package/src/setup.ts +39 -8
- package/src/store.ts +73 -2
- package/src/types.ts +110 -4
- package/src/verbs/server.ts +49 -0
- package/src/worker.ts +152 -14
package/src/cli.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
startHerdrFleet,
|
|
37
37
|
} from "./fleet.ts";
|
|
38
38
|
import { formatGraphSetup, graphRepos, writeGraphSetup, type GraphSetupWrite } from "./graph.ts";
|
|
39
|
+
import { readBaseChain } from "./gitops.ts";
|
|
39
40
|
import {
|
|
40
41
|
clearRecord,
|
|
41
42
|
DEFAULT_PORT,
|
|
@@ -48,6 +49,7 @@ import {
|
|
|
48
49
|
} from "./lifecycle.ts";
|
|
49
50
|
import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
|
|
50
51
|
import { digestDedupeKey } from "./reports.ts";
|
|
52
|
+
import { digestDue } from "./digest-schedule.ts";
|
|
51
53
|
import {
|
|
52
54
|
briefPathForProject,
|
|
53
55
|
policyPathForProject,
|
|
@@ -61,7 +63,7 @@ import { formatVerbLedgerEntry } from "./verbs/ledger.ts";
|
|
|
61
63
|
import { makeTracker } from "./tracker/github.ts";
|
|
62
64
|
import { githubVerbActions } from "./verbs/actions.ts";
|
|
63
65
|
import { handleVerbCall, type VerbChannel } from "./verbs/server.ts";
|
|
64
|
-
import { REPORT_KINDS, VERB_NAMES } from "./types.ts";
|
|
66
|
+
import { REPORT_KINDS, DEFAULT_REPORT_POLICY, VERB_NAMES } from "./types.ts";
|
|
65
67
|
import type { ProjectConfig, ReportKind } from "./types.ts";
|
|
66
68
|
import { formatUnblock, unblockIssue } from "./unblock.ts";
|
|
67
69
|
import { DEFAULT_DEPS, drainAndRestart, upgradeConductor, type UpgradeDeps } from "./upgrade.ts";
|
|
@@ -102,6 +104,8 @@ usage:
|
|
|
102
104
|
omp-conductor release-pane [--project NAME]
|
|
103
105
|
omp-conductor tail <issue> [--project NAME]
|
|
104
106
|
omp-conductor extend <issue> --turns N [--project NAME]
|
|
107
|
+
omp-conductor worker pause <issue> [--project NAME]
|
|
108
|
+
omp-conductor worker resume <issue> [--project NAME]
|
|
105
109
|
omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
|
|
106
110
|
omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
|
|
107
111
|
omp-conductor daemon [--once] [--port N] [--project NAME]
|
|
@@ -170,6 +174,9 @@ usage:
|
|
|
170
174
|
transcript has stopped growing.
|
|
171
175
|
extend monotonically raise a live run's turn ceiling without restarting its
|
|
172
176
|
session. Refuses settled runs and values at or below its current cap.
|
|
177
|
+
worker cooperatively pause one live worker at harness idle, then resume the
|
|
178
|
+
same session with a continuation prompt. Its wall clock is frozen
|
|
179
|
+
while parked. Distinct from fleet-level pause/resume.
|
|
173
180
|
unblock clear <issue>'s blocked and failed labels so the next tick can claim
|
|
174
181
|
it again — the supported way back for an escalation you answered,
|
|
175
182
|
and why the brief's "never hand-edit a state label" rule can stay
|
|
@@ -784,6 +791,50 @@ try {
|
|
|
784
791
|
break;
|
|
785
792
|
}
|
|
786
793
|
|
|
794
|
+
case "worker": {
|
|
795
|
+
const sub = argv[1];
|
|
796
|
+
if (sub !== "pause" && sub !== "resume") {
|
|
797
|
+
process.stderr.write(
|
|
798
|
+
"omp-conductor: worker needs pause or resume, then an issue number\n",
|
|
799
|
+
);
|
|
800
|
+
process.exit(2);
|
|
801
|
+
}
|
|
802
|
+
const issue = issueArg("worker", argv[2]);
|
|
803
|
+
const project = findProject(loadConfig(), flag(argv, "project"));
|
|
804
|
+
const daemon = livingDaemon();
|
|
805
|
+
if (daemon === undefined) throw new Error("daemon is not running");
|
|
806
|
+
if (daemon.project !== undefined && daemon.project !== project.name) {
|
|
807
|
+
throw new Error(
|
|
808
|
+
`daemon serves project "${daemon.project}", not requested project "${project.name}"`,
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
const response = await fetch(
|
|
812
|
+
`http://127.0.0.1:${daemon.port}/runs/${issue}/${sub}`,
|
|
813
|
+
{
|
|
814
|
+
method: "PUT",
|
|
815
|
+
headers: { "content-type": "application/json" },
|
|
816
|
+
body: JSON.stringify({ project: project.name }),
|
|
817
|
+
},
|
|
818
|
+
);
|
|
819
|
+
const payload = (await response.json()) as {
|
|
820
|
+
error?: unknown;
|
|
821
|
+
runId?: unknown;
|
|
822
|
+
phase?: unknown;
|
|
823
|
+
};
|
|
824
|
+
if (!response.ok) {
|
|
825
|
+
throw new Error(
|
|
826
|
+
typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
if (typeof payload.runId !== "string" || typeof payload.phase !== "string") {
|
|
830
|
+
throw new Error("daemon returned an invalid worker-control response");
|
|
831
|
+
}
|
|
832
|
+
process.stdout.write(
|
|
833
|
+
`#${issue} worker ${payload.phase} (run ${payload.runId})\n`,
|
|
834
|
+
);
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
|
|
787
838
|
case "unblock": {
|
|
788
839
|
const issue = issueArg("unblock", argv[1]);
|
|
789
840
|
const cfg = loadConfig();
|
|
@@ -873,6 +924,7 @@ try {
|
|
|
873
924
|
pausedAt,
|
|
874
925
|
log: (m) => process.stderr.write(`${m}\n`),
|
|
875
926
|
now: () => Date.now(),
|
|
927
|
+
chain: { readBaseChain },
|
|
876
928
|
},
|
|
877
929
|
channel,
|
|
878
930
|
{ verb: name, args },
|
|
@@ -914,15 +966,51 @@ try {
|
|
|
914
966
|
const store = openStore(dbPath());
|
|
915
967
|
try {
|
|
916
968
|
const at = Date.now();
|
|
969
|
+
// #229: the policy decides what may interrupt the phone. A kind the
|
|
970
|
+
// policy defers is refused here rather than silently turning into a
|
|
971
|
+
// page, or a digest going out off-schedule.
|
|
972
|
+
const policy = project.reporting;
|
|
973
|
+
if (kind === "digest") {
|
|
974
|
+
const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
|
|
975
|
+
if (digestPolicy.cadence === "none") {
|
|
976
|
+
process.stderr.write(`omp-conductor: report: digest cadence is "none" for this project\n`);
|
|
977
|
+
process.exit(2);
|
|
978
|
+
}
|
|
979
|
+
if (digestPolicy.cadence === "daily" && digestPolicy.at !== undefined) {
|
|
980
|
+
const lastKey = store.lastDigestDedupeKey(project.name);
|
|
981
|
+
const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
|
|
982
|
+
if (!digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at)) {
|
|
983
|
+
process.stderr.write(
|
|
984
|
+
`omp-conductor: report: the daily digest is not due until ${digestPolicy.at}` +
|
|
985
|
+
`${digestPolicy.timezone === undefined ? "" : ` ${digestPolicy.timezone}`}\n`,
|
|
986
|
+
);
|
|
987
|
+
process.exit(2);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
if (kind === "material") {
|
|
992
|
+
const interruptOn = policy?.interruptOn;
|
|
993
|
+
if (interruptOn !== undefined && !interruptOn.includes("material")) {
|
|
994
|
+
process.stderr.write(
|
|
995
|
+
"omp-conductor: report: material updates are digest-only under this reporting policy; fold this into the next digest (--kind digest)\n",
|
|
996
|
+
);
|
|
997
|
+
process.exit(2);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
917
1000
|
const { report, deduped } = store.enqueueReport({
|
|
918
1001
|
project: project.name,
|
|
919
1002
|
kind,
|
|
920
1003
|
body,
|
|
921
1004
|
// Only the digest is at-most-once. A material report describes one
|
|
922
1005
|
// event as it happens, and two of those in a day are two events.
|
|
923
|
-
...(kind === "digest"
|
|
1006
|
+
...(kind === "digest"
|
|
1007
|
+
? { dedupeKey: digestDedupeKey(at, project.reporting?.digest?.timezone) }
|
|
1008
|
+
: {}),
|
|
924
1009
|
at,
|
|
925
1010
|
});
|
|
1011
|
+
// The held notices an accepted digest re-surfaces are now owed by it,
|
|
1012
|
+
// whatever the model goes on to write.
|
|
1013
|
+
if (kind === "digest") store.markNoticesDigested(project.name, at);
|
|
926
1014
|
process.stdout.write(
|
|
927
1015
|
deduped
|
|
928
1016
|
? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` +
|
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);
|
|
@@ -482,34 +490,173 @@ function normalizeProject(
|
|
|
482
490
|
* that silently resolved to `"material"` would read as configured on the day the
|
|
483
491
|
* operator meant to turn the volume down, and the config would keep lying.
|
|
484
492
|
*/
|
|
485
|
-
|
|
486
|
-
|
|
493
|
+
/** The legacy `reporting.scope` presets, materialised as explicit policies.
|
|
494
|
+
* Kept separate from {@link DEFAULT_REPORT_POLICY} (the "no key on disk"
|
|
495
|
+
* default, which must stay `material`): each preset records `scopePreset` so
|
|
496
|
+
* the tick prompt can keep saying the exact legacy words (#229). Exported so
|
|
497
|
+
* the setup wizard and the config validator agree on one mapping. */
|
|
498
|
+
export const SCOPE_PRESETS: Record<ReportScope, ReportingPolicy> = {
|
|
499
|
+
material: {
|
|
500
|
+
interruptOn: [...INTERRUPT_CATEGORIES],
|
|
501
|
+
digest: { cadence: "per-tick" },
|
|
502
|
+
scopePreset: "material",
|
|
503
|
+
},
|
|
504
|
+
decisions: {
|
|
505
|
+
interruptOn: ["tier2", "decision-needed", "fleet-stopped"],
|
|
506
|
+
digest: { cadence: "per-tick" },
|
|
507
|
+
scopePreset: "decisions",
|
|
508
|
+
},
|
|
509
|
+
escalations: {
|
|
510
|
+
interruptOn: ["tier2", "fleet-stopped"],
|
|
511
|
+
digest: { cadence: "daily" },
|
|
512
|
+
scopePreset: "escalations",
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
function defaultReporting(): ReportingPolicy {
|
|
517
|
+
return { ...DEFAULT_REPORT_POLICY, interruptOn: [...DEFAULT_REPORT_POLICY.interruptOn] };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The 24-hour `HH:MM` shape `digest.at` must take. */
|
|
521
|
+
const DIGEST_AT = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The reporting policy: a legacy `scope` preset, or the explicit
|
|
525
|
+
* `interruptOn` + `digest` form. The two forms are mutually exclusive — a
|
|
526
|
+
* preset IS a policy, so configuring alongside it says one thing and means
|
|
527
|
+
* another (#229).
|
|
528
|
+
*/
|
|
529
|
+
function normalizeReporting(parsed: unknown, label: string, problems: string[]): ReportingPolicy {
|
|
530
|
+
if (parsed === undefined) return defaultReporting();
|
|
487
531
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
488
|
-
problems.push(`${label}: reporting must be an object with a "scope"
|
|
489
|
-
return
|
|
532
|
+
problems.push(`${label}: reporting must be an object with a "scope" preset or explicit "interruptOn"/"digest"`);
|
|
533
|
+
return defaultReporting();
|
|
490
534
|
}
|
|
491
535
|
const raw = parsed as Raw;
|
|
492
|
-
|
|
493
|
-
|
|
536
|
+
const keys = Object.keys(raw);
|
|
537
|
+
// `scopePreset` is written by a fully-normalised policy (the setup wizard
|
|
538
|
+
// saves presets materialised); `scope` is the legacy form. Both are known.
|
|
539
|
+
const known = ["scope", "interruptOn", "digest", "scopePreset"];
|
|
540
|
+
const unknownKeys = keys.filter((k) => !known.includes(k));
|
|
494
541
|
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
542
|
problems.push(`${label}: reporting has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
499
543
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
544
|
+
const hasScope = raw["scope"] !== undefined;
|
|
545
|
+
const hasExplicit = keys.includes("interruptOn") || keys.includes("digest");
|
|
546
|
+
if (hasScope && hasExplicit) {
|
|
547
|
+
problems.push(`${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest explicitly`);
|
|
548
|
+
return defaultReporting();
|
|
549
|
+
}
|
|
550
|
+
if (hasScope || (!hasExplicit && keys.length === 0)) {
|
|
551
|
+
const scope = pickLiteral(
|
|
503
552
|
raw["scope"],
|
|
504
553
|
REPORT_SCOPES,
|
|
505
554
|
DEFAULT_REPORT_SCOPE,
|
|
506
555
|
`${label}: reporting.scope`,
|
|
507
556
|
REPORT_SCOPE_LIST,
|
|
508
557
|
problems,
|
|
509
|
-
)
|
|
558
|
+
);
|
|
559
|
+
const preset = SCOPE_PRESETS[scope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE];
|
|
560
|
+
return {
|
|
561
|
+
interruptOn: [...preset.interruptOn],
|
|
562
|
+
digest: { ...preset.digest },
|
|
563
|
+
scopePreset: preset.scopePreset,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// A fully-normalised policy (what the setup wizard writes and saveConfig
|
|
568
|
+
// round-trips) has interruptOn/digest and may carry scopePreset; keep that
|
|
569
|
+
// back-annotation so the tick prompt can still speak the legacy words.
|
|
570
|
+
const storedPreset = raw["scopePreset"];
|
|
571
|
+
const scopePreset =
|
|
572
|
+
typeof storedPreset === "string" && (REPORT_SCOPES as readonly string[]).includes(storedPreset)
|
|
573
|
+
? (storedPreset as ReportScope)
|
|
574
|
+
: undefined;
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
interruptOn: normalizeInterruptOn(raw["interruptOn"], label, problems),
|
|
578
|
+
digest: normalizeDigest(raw["digest"], label, problems),
|
|
579
|
+
...(scopePreset === undefined ? {} : { scopePreset }),
|
|
510
580
|
};
|
|
511
581
|
}
|
|
512
582
|
|
|
583
|
+
function normalizeInterruptOn(parsed: unknown, label: string, problems: string[]): InterruptCategory[] {
|
|
584
|
+
const fallback = [...INTERRUPT_CATEGORIES];
|
|
585
|
+
if (parsed === undefined) {
|
|
586
|
+
problems.push(`${label}: reporting.interruptOn is required in the explicit form (or use reporting.scope)`);
|
|
587
|
+
return fallback;
|
|
588
|
+
}
|
|
589
|
+
if (!Array.isArray(parsed)) {
|
|
590
|
+
problems.push(`${label}: reporting.interruptOn must be an array of ${INTERRUPT_CATEGORY_LIST}`);
|
|
591
|
+
return fallback;
|
|
592
|
+
}
|
|
593
|
+
const out: InterruptCategory[] = [];
|
|
594
|
+
for (const item of parsed) {
|
|
595
|
+
if (typeof item !== "string" || !(INTERRUPT_CATEGORIES as readonly string[]).includes(item)) {
|
|
596
|
+
problems.push(`${label}: reporting.interruptOn has unknown category ${JSON.stringify(item)} — one of ${INTERRUPT_CATEGORY_LIST}`);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
const category = item as InterruptCategory;
|
|
600
|
+
if (!out.includes(category)) out.push(category);
|
|
601
|
+
}
|
|
602
|
+
if (out.length === 0) {
|
|
603
|
+
problems.push(`${label}: reporting.interruptOn must name at least one category`);
|
|
604
|
+
return fallback;
|
|
605
|
+
}
|
|
606
|
+
return out;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function normalizeDigest(parsed: unknown, label: string, problems: string[]): ReportingPolicy["digest"] {
|
|
610
|
+
const fallback: ReportingPolicy["digest"] = { cadence: "per-tick" };
|
|
611
|
+
if (parsed === undefined) {
|
|
612
|
+
problems.push(`${label}: reporting.digest is required in the explicit form (or use reporting.scope)`);
|
|
613
|
+
return fallback;
|
|
614
|
+
}
|
|
615
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
616
|
+
problems.push(`${label}: reporting.digest must be an object with a "cadence" of ${DIGEST_CADENCE_LIST}`);
|
|
617
|
+
return fallback;
|
|
618
|
+
}
|
|
619
|
+
const raw = parsed as Raw;
|
|
620
|
+
const unknownKeys = Object.keys(raw).filter((k) => !["cadence", "at", "timezone"].includes(k));
|
|
621
|
+
if (unknownKeys.length > 0) {
|
|
622
|
+
problems.push(`${label}: reporting.digest has unknown key(s): ${unknownKeys.join(", ")}`);
|
|
623
|
+
}
|
|
624
|
+
const cadence = pickLiteral(
|
|
625
|
+
raw["cadence"],
|
|
626
|
+
DIGEST_CADENCES,
|
|
627
|
+
"per-tick",
|
|
628
|
+
`${label}: reporting.digest.cadence`,
|
|
629
|
+
DIGEST_CADENCE_LIST,
|
|
630
|
+
problems,
|
|
631
|
+
);
|
|
632
|
+
const digest: ReportingPolicy["digest"] = { cadence };
|
|
633
|
+
if (cadence !== "daily") {
|
|
634
|
+
if (raw["at"] !== undefined) problems.push(`${label}: reporting.digest.at is only valid with cadence "daily"`);
|
|
635
|
+
if (raw["timezone"] !== undefined) problems.push(`${label}: reporting.digest.timezone is only valid with cadence "daily"`);
|
|
636
|
+
return digest;
|
|
637
|
+
}
|
|
638
|
+
if (raw["at"] !== undefined) {
|
|
639
|
+
if (typeof raw["at"] !== "string" || !DIGEST_AT.test(raw["at"])) {
|
|
640
|
+
problems.push(`${label}: reporting.digest.at must be a 24h HH:MM time`);
|
|
641
|
+
} else {
|
|
642
|
+
digest.at = raw["at"];
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (raw["timezone"] !== undefined) {
|
|
646
|
+
if (typeof raw["timezone"] !== "string") {
|
|
647
|
+
problems.push(`${label}: reporting.digest.timezone must be a string`);
|
|
648
|
+
} else {
|
|
649
|
+
try {
|
|
650
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: raw["timezone"] });
|
|
651
|
+
digest.timezone = raw["timezone"];
|
|
652
|
+
} catch {
|
|
653
|
+
problems.push(`${label}: reporting.digest.timezone is not a known IANA timezone`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return digest;
|
|
658
|
+
}
|
|
659
|
+
|
|
513
660
|
/**
|
|
514
661
|
* Who triages escalations, and how they are delivered when nobody answers.
|
|
515
662
|
*
|
|
@@ -853,12 +1000,37 @@ function normalizeRepos(parsed: unknown, label: string, problems: string[]): Rec
|
|
|
853
1000
|
};
|
|
854
1001
|
const graph = normalizeGraphProject(value?.["graphProject"], `${label}: routing.repos.${key}`, problems);
|
|
855
1002
|
if (graph !== undefined) target.graphProject = graph;
|
|
1003
|
+
const migrations = normalizeMigrationsDir(value?.["migrations"], `${label}: routing.repos.${key}`, problems);
|
|
1004
|
+
if (migrations !== undefined) target.migrations = { dir: migrations };
|
|
856
1005
|
repos[key] = target;
|
|
857
1006
|
}
|
|
858
1007
|
|
|
859
1008
|
return repos;
|
|
860
1009
|
}
|
|
861
1010
|
|
|
1011
|
+
/**
|
|
1012
|
+
* The ordered-migration-chain directory (#227), or `undefined` when the repo
|
|
1013
|
+
* opts out of the chain check.
|
|
1014
|
+
*
|
|
1015
|
+
* Repo-relative, no leading `/`, and no `..` segment — the value is read in one
|
|
1016
|
+
* process and used in another against the base branch's tree, so anything that
|
|
1017
|
+
* is not plainly a directory name is a guess the guard must not make.
|
|
1018
|
+
*/
|
|
1019
|
+
function normalizeMigrationsDir(parsed: unknown, label: string, problems: string[]): string | undefined {
|
|
1020
|
+
if (parsed === undefined) return undefined;
|
|
1021
|
+
const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined;
|
|
1022
|
+
const dir = raw?.["dir"];
|
|
1023
|
+
if (!nonEmptyString(dir)) {
|
|
1024
|
+
problems.push(`${label}.migrations.dir must be a non-empty string`);
|
|
1025
|
+
return undefined;
|
|
1026
|
+
}
|
|
1027
|
+
if (dir.startsWith("/") || dir.split("/").includes("..")) {
|
|
1028
|
+
problems.push(`${label}.migrations.dir must be a repo-relative directory`);
|
|
1029
|
+
return undefined;
|
|
1030
|
+
}
|
|
1031
|
+
return dir;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
862
1034
|
/**
|
|
863
1035
|
* The path of the index-only clone whose code graph this repo's workers query,
|
|
864
1036
|
* or `undefined` when the repo has none.
|