omp-conductor 0.9.1 → 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 +57 -12
- package/package.json +1 -1
- package/src/board.ts +203 -63
- package/src/briefs/orchestrator.md +45 -1
- 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 +331 -20
- 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 +72 -0
- package/src/fleet.ts +2 -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 +111 -4
- package/src/verbs/server.ts +49 -0
- package/src/worker.ts +201 -16
package/src/setup.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
resolveCaps,
|
|
41
41
|
resolvePolicy,
|
|
42
42
|
resolveReleaseGrants,
|
|
43
|
+
SCOPE_PRESETS,
|
|
43
44
|
stateDir,
|
|
44
45
|
} from "./config.ts";
|
|
45
46
|
import { graphProjectPath, graphRepos } from "./graph.ts";
|
|
@@ -61,6 +62,8 @@ import {
|
|
|
61
62
|
type ProjectPolicy,
|
|
62
63
|
type ReleaseRequirement,
|
|
63
64
|
type ReportScope,
|
|
65
|
+
type ReportScopeChoice,
|
|
66
|
+
type ReportingPolicy,
|
|
64
67
|
type RepoTarget,
|
|
65
68
|
type ResolvedGrants,
|
|
66
69
|
} from "./types.ts";
|
|
@@ -88,7 +91,9 @@ export interface SetupAnswers {
|
|
|
88
91
|
telegramChatId?: string;
|
|
89
92
|
fallbackToIssueComment: boolean;
|
|
90
93
|
/** How loud the supervising orchestrator session should be. */
|
|
91
|
-
reportScope:
|
|
94
|
+
reportScope: ReportScopeChoice;
|
|
95
|
+
/** The daily digest wall-clock (`HH:MM`) for the `quiet` choice, blank = model-timed. */
|
|
96
|
+
quietDigestAt?: string;
|
|
92
97
|
/**
|
|
93
98
|
* Mechanical gate for release/deploy-shaped tool calls, per shape. Always
|
|
94
99
|
* complete: the wizard asks about every shape, so an answer object can never
|
|
@@ -175,7 +180,11 @@ export const SETUP_DEFAULTS = {
|
|
|
175
180
|
* brief spells the same three options out — so "material" cannot come to mean
|
|
176
181
|
* one thing in the dialog and another in the session that has to honour it.
|
|
177
182
|
*/
|
|
178
|
-
export const REPORT_SCOPE_CHOICES: readonly {
|
|
183
|
+
export const REPORT_SCOPE_CHOICES: readonly {
|
|
184
|
+
scope: ReportScopeChoice;
|
|
185
|
+
label: string;
|
|
186
|
+
description: string;
|
|
187
|
+
}[] = [
|
|
179
188
|
{
|
|
180
189
|
scope: "decisions",
|
|
181
190
|
label: "Decisions interrupt, rest batches",
|
|
@@ -192,6 +201,12 @@ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string;
|
|
|
192
201
|
label: "Escalations only",
|
|
193
202
|
description: "escalations when they happen, plus one daily digest — silent otherwise",
|
|
194
203
|
},
|
|
204
|
+
{
|
|
205
|
+
scope: "quiet",
|
|
206
|
+
label: "Quiet (scheduled digest)",
|
|
207
|
+
description:
|
|
208
|
+
"interrupt only for escalations, fleet stops and confirmed failures; one daily rollup on your schedule",
|
|
209
|
+
},
|
|
195
210
|
];
|
|
196
211
|
|
|
197
212
|
/**
|
|
@@ -204,7 +219,7 @@ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string;
|
|
|
204
219
|
* answers "an operator being asked the question for the first time", where the
|
|
205
220
|
* recommended answer is the useful one — and they see it, and confirm it.
|
|
206
221
|
*/
|
|
207
|
-
export const SETUP_DEFAULT_REPORT_SCOPE:
|
|
222
|
+
export const SETUP_DEFAULT_REPORT_SCOPE: ReportScopeChoice = REPORT_SCOPE_CHOICES[0]!.scope;
|
|
208
223
|
|
|
209
224
|
/**
|
|
210
225
|
* What each precondition value means to the operator being asked about it, in
|
|
@@ -560,8 +575,24 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
560
575
|
// and a release require without anyone having to know a default (#129).
|
|
561
576
|
policy: clonePolicy(a.policy),
|
|
562
577
|
// Written out even when it is the default, so an operator amending the
|
|
563
|
-
// volume has a line in the file to point at.
|
|
564
|
-
|
|
578
|
+
// volume has a line in the file to point at. A `scope` answer is
|
|
579
|
+
// materialised as its preset policy (with `scopePreset` set) so the file
|
|
580
|
+
// and the prompt agree with no migration rule; `quiet` is the explicit
|
|
581
|
+
// form — a curated interrupt list and a scheduled digest (#229).
|
|
582
|
+
reporting:
|
|
583
|
+
a.reportScope === "quiet"
|
|
584
|
+
? {
|
|
585
|
+
interruptOn: ["tier2", "fleet-stopped", "confirmed-failure"],
|
|
586
|
+
digest: {
|
|
587
|
+
cadence: "daily",
|
|
588
|
+
...(a.quietDigestAt !== undefined && a.quietDigestAt.length > 0 ? { at: a.quietDigestAt } : {}),
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
: {
|
|
592
|
+
interruptOn: [...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn],
|
|
593
|
+
digest: { ...(SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).digest },
|
|
594
|
+
scopePreset: (SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).scopePreset,
|
|
595
|
+
},
|
|
565
596
|
// Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
|
|
566
597
|
// uninstall, and neither can land in a repo the daemon then tries to commit.
|
|
567
598
|
workspaceRoot: defaultWorkspaceRoot(),
|
|
@@ -667,7 +698,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
667
698
|
releaseGrants: resolveReleaseGrants(p),
|
|
668
699
|
policy: resolvePolicy(p),
|
|
669
700
|
orchestratorMode: p.escalation.orchestrator,
|
|
670
|
-
reportScope: p.reporting?.
|
|
701
|
+
reportScope: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
|
|
671
702
|
writeOrchestratorBrief: false,
|
|
672
703
|
};
|
|
673
704
|
|
|
@@ -704,7 +735,7 @@ function briefVarsForProject(p: ProjectConfig): Record<string, string> {
|
|
|
704
735
|
QUEUE_LABEL: p.queueLabel,
|
|
705
736
|
RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`],
|
|
706
737
|
MERGE_DUTY: MERGE_DUTY[p.authority.merge],
|
|
707
|
-
REPORT_SCOPE: p.reporting?.
|
|
738
|
+
REPORT_SCOPE: p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE,
|
|
708
739
|
POLICY_SOURCE: policySourceLine(p),
|
|
709
740
|
};
|
|
710
741
|
}
|
|
@@ -1234,7 +1265,7 @@ export const AMEND_AREAS: {
|
|
|
1234
1265
|
name: "reporting scope",
|
|
1235
1266
|
asks: "how much the orchestrator says unprompted",
|
|
1236
1267
|
describe: (p) => {
|
|
1237
|
-
const scope = p.reporting?.
|
|
1268
|
+
const scope = p.reporting?.scopePreset ?? DEFAULT_REPORT_SCOPE;
|
|
1238
1269
|
const choice = REPORT_SCOPE_CHOICES.find((c) => c.scope === scope);
|
|
1239
1270
|
return `${scope} — ${choice?.description ?? "unknown scope"}`;
|
|
1240
1271
|
},
|
package/src/store.ts
CHANGED
|
@@ -26,6 +26,8 @@ import type {
|
|
|
26
26
|
FrictionKind,
|
|
27
27
|
FrictionObservation,
|
|
28
28
|
FrictionSignal,
|
|
29
|
+
HeldNotice,
|
|
30
|
+
HeldNoticeDraft,
|
|
29
31
|
LabelOp,
|
|
30
32
|
MergeLock,
|
|
31
33
|
ReportDeliveryState,
|
|
@@ -332,6 +334,24 @@ CREATE UNIQUE INDEX IF NOT EXISTS reports_dedupe
|
|
|
332
334
|
CREATE INDEX IF NOT EXISTS reports_project_state
|
|
333
335
|
ON reports (project, state, nextAttemptAt);
|
|
334
336
|
|
|
337
|
+
-- Interrupts a project's reporting.interruptOn policy deferred to the digest
|
|
338
|
+
-- (#229): an escalation that must not page the operator's phone now is held
|
|
339
|
+
-- here so the daily rollup can surface it. digestedAt set -> re-surfaced by a
|
|
340
|
+
-- digest pass and no longer owed. A table rather than a flag on reports: these
|
|
341
|
+
-- are escalations that never reached a send, distinct from reports the model
|
|
342
|
+
-- wrote on purpose.
|
|
343
|
+
CREATE TABLE IF NOT EXISTS held_notices (
|
|
344
|
+
id TEXT PRIMARY KEY,
|
|
345
|
+
project TEXT NOT NULL,
|
|
346
|
+
category TEXT NOT NULL,
|
|
347
|
+
summary TEXT NOT NULL,
|
|
348
|
+
detail TEXT NOT NULL,
|
|
349
|
+
createdAt INTEGER NOT NULL,
|
|
350
|
+
digestedAt INTEGER
|
|
351
|
+
);
|
|
352
|
+
CREATE INDEX IF NOT EXISTS held_notices_project_undigested
|
|
353
|
+
ON held_notices (project, digestedAt) WHERE digestedAt IS NULL;
|
|
354
|
+
|
|
335
355
|
-- The action ledger for the conductor-owned mutation verbs (#126). Every
|
|
336
356
|
-- decided call lands here, refusals included: the question an escalation asks
|
|
337
357
|
-- is "what did this run try", and a table that only records what succeeded
|
|
@@ -902,12 +922,12 @@ export function openStore(dbPath: string): Store {
|
|
|
902
922
|
const countFailures = db.query<{ n: number }, [string, number]>(
|
|
903
923
|
`SELECT COUNT(*) AS n FROM runs
|
|
904
924
|
WHERE project = ? AND issue = ? AND state = 'failed'
|
|
905
|
-
AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra'))`,
|
|
925
|
+
AND (failureClass IS NULL OR failureClass NOT IN ('ci-infra', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient'))`,
|
|
906
926
|
);
|
|
907
927
|
const countContinuations = db.query<{ n: number }, [string, number]>(
|
|
908
928
|
`SELECT COUNT(*) AS n FROM runs
|
|
909
929
|
WHERE project = ? AND issue = ? AND state IN ('killed', 'orphaned', 'blocked')
|
|
910
|
-
AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra'))`,
|
|
930
|
+
AND (failureClass IS NULL OR failureClass NOT IN ('admin-kill', 'settlement-stuck', 'env-start-failure', 'dispatch-infra', 'provider-credit', 'provider-transient'))`,
|
|
911
931
|
);
|
|
912
932
|
// How many times one issue reached a given class. Recovery uses it to bound a
|
|
913
933
|
// retry loop whose cause is persistent (e.g. a mirror that will not refresh):
|
|
@@ -1079,6 +1099,33 @@ export function openStore(dbPath: string): Store {
|
|
|
1079
1099
|
ORDER BY createdAt ASC, rowid ASC
|
|
1080
1100
|
LIMIT ?`,
|
|
1081
1101
|
);
|
|
1102
|
+
|
|
1103
|
+
// Held notices (#229): escalations deferred to the digest by the project's
|
|
1104
|
+
// `reporting.interruptOn` policy. `digestedAt` NULL = still owed.
|
|
1105
|
+
const insertHeldNotice = db.query<unknown, SqlValue[]>(
|
|
1106
|
+
`INSERT INTO held_notices (id, project, category, summary, detail, createdAt, digestedAt)
|
|
1107
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
|
|
1108
|
+
);
|
|
1109
|
+
const selectUndigestedNotices = db.query<
|
|
1110
|
+
{ id: string; category: string; summary: string; detail: string; createdAt: number },
|
|
1111
|
+
[string]
|
|
1112
|
+
>(
|
|
1113
|
+
`SELECT id, category, summary, detail, createdAt FROM held_notices
|
|
1114
|
+
WHERE project = ? AND digestedAt IS NULL
|
|
1115
|
+
ORDER BY createdAt ASC`,
|
|
1116
|
+
);
|
|
1117
|
+
const markNoticesDigestedRow = db.query<unknown, [number, string]>(
|
|
1118
|
+
`UPDATE held_notices SET digestedAt = ? WHERE project = ? AND digestedAt IS NULL`,
|
|
1119
|
+
);
|
|
1120
|
+
// The newest digest dedupe key a project has actually run toward — an
|
|
1121
|
+
// `updatedAt`-newest row whose key is a digest prefix and that did not end in
|
|
1122
|
+
// failure. The CLI refuses an off-schedule digest by comparing this against
|
|
1123
|
+
// today's key under `digestDue` (#229).
|
|
1124
|
+
const selectLastDigestKey = db.query<{ key: string | null }, [string]>(
|
|
1125
|
+
`SELECT dedupeKey AS key FROM reports
|
|
1126
|
+
WHERE project = ? AND dedupeKey LIKE 'digest:%' AND state <> 'failed'
|
|
1127
|
+
ORDER BY createdAt DESC LIMIT 1`,
|
|
1128
|
+
);
|
|
1082
1129
|
const claimReportRow = db.query<unknown, [string, number, string, number]>(
|
|
1083
1130
|
`UPDATE reports
|
|
1084
1131
|
SET state = 'sending', attemptId = ?, attempts = attempts + 1, updatedAt = ?
|
|
@@ -1445,6 +1492,30 @@ export function openStore(dbPath: string): Store {
|
|
|
1445
1492
|
return row === null ? undefined : toDispatchSummary(row.summary);
|
|
1446
1493
|
},
|
|
1447
1494
|
|
|
1495
|
+
lastDigestDedupeKey(project: string): string | undefined {
|
|
1496
|
+
const row = selectLastDigestKey.get(project);
|
|
1497
|
+
return row?.key ?? undefined;
|
|
1498
|
+
},
|
|
1499
|
+
|
|
1500
|
+
addHeldNotice(notice: HeldNoticeDraft): void {
|
|
1501
|
+
insertHeldNotice.run(
|
|
1502
|
+
crypto.randomUUID(),
|
|
1503
|
+
notice.project,
|
|
1504
|
+
notice.category,
|
|
1505
|
+
notice.summary,
|
|
1506
|
+
notice.detail,
|
|
1507
|
+
notice.createdAt,
|
|
1508
|
+
);
|
|
1509
|
+
},
|
|
1510
|
+
|
|
1511
|
+
undigestedNotices(project: string): HeldNotice[] {
|
|
1512
|
+
return selectUndigestedNotices.all(project).map((row) => ({ ...row }) as HeldNotice);
|
|
1513
|
+
},
|
|
1514
|
+
|
|
1515
|
+
markNoticesDigested(project: string, at: number): void {
|
|
1516
|
+
markNoticesDigestedRow.run(at, project);
|
|
1517
|
+
},
|
|
1518
|
+
|
|
1448
1519
|
recordFriction,
|
|
1449
1520
|
|
|
1450
1521
|
recordGhRefusal,
|
package/src/types.ts
CHANGED
|
@@ -114,6 +114,14 @@ export interface RepoTarget {
|
|
|
114
114
|
* the worker brief.
|
|
115
115
|
*/
|
|
116
116
|
graphProject?: string;
|
|
117
|
+
/**
|
|
118
|
+
* Ordered-migration-chain guard (#227): repo-relative directory whose *.py
|
|
119
|
+
* files declare `revision`/`down_revision`. When set, `conductor_pr_merge`
|
|
120
|
+
* refuses a merge that would collide with the base branch's chain (id reuse,
|
|
121
|
+
* a deleted migration, or a combined graph with more than one head). Absent
|
|
122
|
+
* = no chain check.
|
|
123
|
+
*/
|
|
124
|
+
migrations?: { dir: string };
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
/**
|
|
@@ -140,6 +148,59 @@ export type ReportScope = (typeof REPORT_SCOPES)[number];
|
|
|
140
148
|
*/
|
|
141
149
|
export const DEFAULT_REPORT_SCOPE: ReportScope = "material";
|
|
142
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The four answers the wizard offers for "how loud should the orchestrator be".
|
|
153
|
+
* Three are legacy presets; `quiet` is the explicit form: interrupt only for
|
|
154
|
+
* escalations, fleet stops and confirmed failures, with one daily rollup
|
|
155
|
+
* (#229).
|
|
156
|
+
*/
|
|
157
|
+
export type ReportScopeChoice = ReportScope | "quiet";
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Which categories may interrupt the operator's phone. Each maps to a tier-2
|
|
161
|
+
* escalation channel: `tier2` (a plain decision), `decision-needed`, a
|
|
162
|
+
* fleet-stopping condition, a confirmed failure, or any material event. What an
|
|
163
|
+
* `interruptOn` list omits is not dropped — it accumulates for the digest.
|
|
164
|
+
* Data rather than booleans so the config validator, the escalator and the
|
|
165
|
+
* orchestrator prompt all enumerate the same vocabulary (#229).
|
|
166
|
+
*/
|
|
167
|
+
export const INTERRUPT_CATEGORIES = [
|
|
168
|
+
"tier2",
|
|
169
|
+
"decision-needed",
|
|
170
|
+
"fleet-stopped",
|
|
171
|
+
"confirmed-failure",
|
|
172
|
+
"material",
|
|
173
|
+
] as const;
|
|
174
|
+
|
|
175
|
+
export type InterruptCategory = (typeof INTERRUPT_CATEGORIES)[number];
|
|
176
|
+
|
|
177
|
+
/** When the daily/report rollup is due. */
|
|
178
|
+
export const DIGEST_CADENCES = ["none", "per-tick", "daily"] as const;
|
|
179
|
+
|
|
180
|
+
export type DigestCadence = (typeof DIGEST_CADENCES)[number];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Explicit reporting policy: which categories interrupt, and when the daily
|
|
184
|
+
* rollup happens. `scopePreset` is set when the config came from a legacy
|
|
185
|
+
* `scope:` preset, and drives the preset tick prompt text.
|
|
186
|
+
*/
|
|
187
|
+
export interface ReportingPolicy {
|
|
188
|
+
interruptOn: InterruptCategory[];
|
|
189
|
+
digest: { cadence: DigestCadence; at?: string; timezone?: string };
|
|
190
|
+
/** Set when the policy came from a legacy `scope` preset. */
|
|
191
|
+
scopePreset?: ReportScope;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The default policy: the legacy `material` preset, spelled out so an explicit
|
|
196
|
+
* config can name it and the wizard can show it.
|
|
197
|
+
*/
|
|
198
|
+
export const DEFAULT_REPORT_POLICY: ReportingPolicy = {
|
|
199
|
+
interruptOn: [...INTERRUPT_CATEGORIES],
|
|
200
|
+
digest: { cadence: "per-tick" },
|
|
201
|
+
scopePreset: DEFAULT_REPORT_SCOPE,
|
|
202
|
+
};
|
|
203
|
+
|
|
143
204
|
/**
|
|
144
205
|
* The irreversible tool-call shapes this package recognises and gates. Data
|
|
145
206
|
* here rather than in `release-policy.ts` because the config validator, the
|
|
@@ -431,6 +492,9 @@ export const LABEL_REASONS = [
|
|
|
431
492
|
"promoted-to-queue",
|
|
432
493
|
/** Re-briefed after a failed attempt, and put back on the queue. */
|
|
433
494
|
"re-briefed",
|
|
495
|
+
/** Delivered: the PR merged and the issue closed, and the queue label comes
|
|
496
|
+
* off as completed lifecycle cleanup — not superseded, not a re-brief (#230). */
|
|
497
|
+
"completed",
|
|
434
498
|
/** Parked: it needs a decision only a human can make. */
|
|
435
499
|
"needs-human",
|
|
436
500
|
"duplicate",
|
|
@@ -534,11 +598,13 @@ export interface ProjectConfig {
|
|
|
534
598
|
*/
|
|
535
599
|
policy?: ProjectPolicy;
|
|
536
600
|
/**
|
|
537
|
-
* How loud the orchestrator is
|
|
538
|
-
*
|
|
539
|
-
*
|
|
601
|
+
* How loud the orchestrator is, and when the daily rollup happens. Optional
|
|
602
|
+
* on disk — a config written before this key existed loads as
|
|
603
|
+
* {@link DEFAULT_REPORT_POLICY} — so read it through `resolveReportScope`
|
|
604
|
+
* rather than reaching for `.scope` directly. A legacy `scope:` value is
|
|
605
|
+
* accepted and materialised as a preset (`.scopePreset`).
|
|
540
606
|
*/
|
|
541
|
-
reporting?:
|
|
607
|
+
reporting?: ReportingPolicy;
|
|
542
608
|
/** Parent directory for per-run worktrees. */
|
|
543
609
|
workspaceRoot: string;
|
|
544
610
|
/** Cache of bare clones, so N runs share one fetch instead of N. */
|
|
@@ -831,6 +897,8 @@ export const FAILURE_CLASSES = [
|
|
|
831
897
|
"orphan-clean",
|
|
832
898
|
"orphan-dirty",
|
|
833
899
|
"settlement-stuck",
|
|
900
|
+
"provider-credit",
|
|
901
|
+
"provider-transient",
|
|
834
902
|
"unknown",
|
|
835
903
|
] as const;
|
|
836
904
|
|
|
@@ -968,6 +1036,9 @@ export interface DispatchSummary {
|
|
|
968
1036
|
ready: number;
|
|
969
1037
|
routed: number;
|
|
970
1038
|
admitted: number;
|
|
1039
|
+
/** Ready issues carrying a state label — claimed by a live or settling run.
|
|
1040
|
+
* Optional: persisted old rows lack it, so readers use `?? 0` (#228). */
|
|
1041
|
+
claimed?: number;
|
|
971
1042
|
/** True only for system/API failures, never ordinary policy holds. */
|
|
972
1043
|
degraded: boolean;
|
|
973
1044
|
holds: AdmissionHoldSummary[];
|
|
@@ -1222,6 +1293,15 @@ export interface Store {
|
|
|
1222
1293
|
wasNotified(key: string): boolean;
|
|
1223
1294
|
recordDispatch(project: string, summary: DispatchSummary): void;
|
|
1224
1295
|
latestDispatch(project: string): DispatchSummary | undefined;
|
|
1296
|
+
/** The newest `digest:` dedupe key a project has run toward without ending in
|
|
1297
|
+
* failure — what the off-schedule-digest refusal compares against (#229). */
|
|
1298
|
+
lastDigestDedupeKey(project: string): string | undefined;
|
|
1299
|
+
/** Hold a tier-2 escalation the interrupt policy deferred to the digest. */
|
|
1300
|
+
addHeldNotice(notice: HeldNoticeDraft): void;
|
|
1301
|
+
/** Held notices still owed (not yet re-surfaced by a digest pass). */
|
|
1302
|
+
undigestedNotices(project: string): HeldNotice[];
|
|
1303
|
+
/** Mark every undigested notice for a project as re-surfaced. */
|
|
1304
|
+
markNoticesDigested(project: string, at: number): void;
|
|
1225
1305
|
/** Add one bounded observation to the per-day friction rollup. */
|
|
1226
1306
|
recordFriction(project: string, observation: FrictionObservation): void;
|
|
1227
1307
|
/** Repeated signals not surfaced within the supplied cooldown window. */
|
|
@@ -1384,6 +1464,28 @@ export interface Escalation {
|
|
|
1384
1464
|
summary: string;
|
|
1385
1465
|
detail?: string;
|
|
1386
1466
|
runId?: string;
|
|
1467
|
+
/** Tier-2-only interrupt category; defaults to `"tier2"`. Drives whether the
|
|
1468
|
+
* escalation pages now or is held for the digest under the project's
|
|
1469
|
+
* `reporting.interruptOn` policy (#229). */
|
|
1470
|
+
category?: InterruptCategory;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** A tier-2 escalation the project's interrupt policy deferred to the digest. */
|
|
1474
|
+
export interface HeldNotice {
|
|
1475
|
+
id: string;
|
|
1476
|
+
category: InterruptCategory;
|
|
1477
|
+
summary: string;
|
|
1478
|
+
detail: string;
|
|
1479
|
+
createdAt: number;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/** What it takes to persist one held notice. */
|
|
1483
|
+
export interface HeldNoticeDraft {
|
|
1484
|
+
project: string;
|
|
1485
|
+
category: InterruptCategory;
|
|
1486
|
+
summary: string;
|
|
1487
|
+
detail: string;
|
|
1488
|
+
createdAt: number;
|
|
1387
1489
|
}
|
|
1388
1490
|
|
|
1389
1491
|
/**
|
|
@@ -1510,6 +1612,11 @@ export const VERB_REFUSALS = [
|
|
|
1510
1612
|
"release-shape-not-executable",
|
|
1511
1613
|
/** Config unreadable, project unknown, repo unrouted. Fail closed. */
|
|
1512
1614
|
"config-unreadable",
|
|
1615
|
+
/** Merging would corrupt the ordered migration chain in `migrations.dir` (#227). */
|
|
1616
|
+
"chain-conflict",
|
|
1617
|
+
/** The PR diff or the base-branch chain could not be read — fail closed rather
|
|
1618
|
+
* than merge blind into a chain that might be about to fork (#227). */
|
|
1619
|
+
"chain-unreadable",
|
|
1513
1620
|
/** The privileged half ran and the underlying command failed. */
|
|
1514
1621
|
"action-failed",
|
|
1515
1622
|
] as const;
|
package/src/verbs/server.ts
CHANGED
|
@@ -42,6 +42,8 @@ import { randomUUID } from "node:crypto";
|
|
|
42
42
|
import { createServer, type Server, type Socket } from "node:net";
|
|
43
43
|
|
|
44
44
|
import { resolvePolicy, resolveReleaseGrants } from "../config.ts";
|
|
45
|
+
import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
|
|
46
|
+
import type { readBaseChain as readBaseChainType } from "../gitops.ts";
|
|
45
47
|
import { releaseRefusal } from "../release-policy.ts";
|
|
46
48
|
import { LIVE_STATES } from "../store.ts";
|
|
47
49
|
import type {
|
|
@@ -181,6 +183,12 @@ export interface VerbDeps {
|
|
|
181
183
|
pausedAt: () => number | undefined;
|
|
182
184
|
log: (message: string) => void;
|
|
183
185
|
now: () => number;
|
|
186
|
+
/**
|
|
187
|
+
* Read-only access to the ordered-migration-chain guard. A read seam, kept
|
|
188
|
+
* off `VerbActions` (the mutation surface) on purpose: `prMergeVerb` consults
|
|
189
|
+
* whether the merge would fork the chain, it never changes it (#227).
|
|
190
|
+
*/
|
|
191
|
+
chain: { readBaseChain: typeof readBaseChainType };
|
|
184
192
|
}
|
|
185
193
|
|
|
186
194
|
/**
|
|
@@ -728,6 +736,47 @@ async function prMergeVerb(
|
|
|
728
736
|
);
|
|
729
737
|
}
|
|
730
738
|
|
|
739
|
+
const repoTarget = Object.values(project.routing.repos).find((r) => r.name === target.repo);
|
|
740
|
+
const chainDir = repoTarget?.migrations?.dir;
|
|
741
|
+
if (chainDir !== undefined && repoTarget !== undefined) {
|
|
742
|
+
// Checked against the base tip at merge time, not against a stale advisory:
|
|
743
|
+
// the author hit two merged PRs leaving main with two Alembic heads (#227).
|
|
744
|
+
const diff = await deps.tracker.prDiff(prUrl).catch(() => undefined);
|
|
745
|
+
if (diff === undefined) {
|
|
746
|
+
return refuse(
|
|
747
|
+
"chain-unreadable",
|
|
748
|
+
`refused: the diff of ${prUrl} could not be read, so the migration chain in ${chainDir} cannot be verified. Failing closed: a wrong merge here is a silent production schema drift (#227).`,
|
|
749
|
+
target.issue,
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
const { added, changed, deleted } = chainEntriesFromDiff(diff.files, chainDir);
|
|
753
|
+
if (added.length + changed.length + deleted.length > 0) {
|
|
754
|
+
if (diff.truncated) {
|
|
755
|
+
return refuse(
|
|
756
|
+
"chain-unreadable",
|
|
757
|
+
`refused: the diff of ${prUrl} was truncated and it touches ${chainDir} — the chain cannot be verified from a partial diff.`,
|
|
758
|
+
target.issue,
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
const base = await deps.chain.readBaseChain(project, repoTarget, chainDir);
|
|
762
|
+
if (!base.ok) {
|
|
763
|
+
return refuse(
|
|
764
|
+
"chain-unreadable",
|
|
765
|
+
`refused: could not read ${chainDir} at the tip of ${repoTarget.defaultBranch} (${base.stderr}).`,
|
|
766
|
+
target.issue,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
const violations = chainViolations({ base: base.entries, added, changed, deleted });
|
|
770
|
+
if (violations.length > 0) {
|
|
771
|
+
return refuse(
|
|
772
|
+
"chain-conflict",
|
|
773
|
+
`refused: merging ${prUrl} would corrupt the ordered migration chain in ${chainDir} — ${violations.join("; ")}. Rebase onto the current tip, regenerate the migration, and call again.`,
|
|
774
|
+
target.issue,
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
731
780
|
const outcome = await deps.actions.mergePr(prUrl, headSha);
|
|
732
781
|
if (!outcome.ok) {
|
|
733
782
|
return refuse("action-failed", `refused: gh could not merge:\n${outcome.stderr}`, target.issue);
|