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/tracker/github.ts
CHANGED
|
@@ -272,6 +272,28 @@ export function mergedPrInfoFrom(raw: string): MergedPrInfo | undefined {
|
|
|
272
272
|
: undefined;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
/** Parse the commit identity emitted by GitHub's branch REST endpoint. */
|
|
276
|
+
export function branchHeadFrom(raw: string): string | undefined {
|
|
277
|
+
let parsed: unknown;
|
|
278
|
+
try {
|
|
279
|
+
parsed = JSON.parse(raw) as unknown;
|
|
280
|
+
} catch {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
if (
|
|
284
|
+
parsed === null ||
|
|
285
|
+
typeof parsed !== "object" ||
|
|
286
|
+
!("commit" in parsed) ||
|
|
287
|
+
parsed.commit === null ||
|
|
288
|
+
typeof parsed.commit !== "object" ||
|
|
289
|
+
!("sha" in parsed.commit)
|
|
290
|
+
) {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
const sha = parsed.commit.sha;
|
|
294
|
+
return typeof sha === "string" && /^[0-9a-f]{40,64}$/i.test(sha) ? sha : undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
275
297
|
/** Parse an Actions workflow-run list, skipping malformed entries without
|
|
276
298
|
* turning a valid empty list into an unreadable response. */
|
|
277
299
|
export function workflowRunsFrom(raw: string): WorkflowRun[] | undefined {
|
|
@@ -1187,16 +1209,32 @@ export function makeTracker(
|
|
|
1187
1209
|
}
|
|
1188
1210
|
},
|
|
1189
1211
|
|
|
1190
|
-
async
|
|
1212
|
+
async branchHead(repo: string, branch: string): Promise<string | undefined> {
|
|
1213
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo) || branch.length === 0) return undefined;
|
|
1214
|
+
try {
|
|
1215
|
+
return branchHeadFrom(
|
|
1216
|
+
await runGh(["api", `repos/${repo}/branches/${encodeURIComponent(branch)}`]),
|
|
1217
|
+
);
|
|
1218
|
+
} catch {
|
|
1219
|
+
return undefined;
|
|
1220
|
+
}
|
|
1221
|
+
},
|
|
1222
|
+
|
|
1223
|
+
async workflowRunsAt(
|
|
1224
|
+
repo: string,
|
|
1225
|
+
sha: string,
|
|
1226
|
+
opts?: { event?: string; branch?: string },
|
|
1227
|
+
): Promise<WorkflowRun[] | undefined> {
|
|
1191
1228
|
if (!/^[^/\s]+\/[^/\s]+$/.test(repo) || !/^[0-9a-f]{40,64}$/i.test(sha)) {
|
|
1192
1229
|
return undefined;
|
|
1193
1230
|
}
|
|
1231
|
+
const query = [`head_sha=${encodeURIComponent(sha)}`];
|
|
1232
|
+
if (opts?.event !== undefined) query.push(`event=${encodeURIComponent(opts.event)}`);
|
|
1233
|
+
if (opts?.branch !== undefined) query.push(`branch=${encodeURIComponent(opts.branch)}`);
|
|
1234
|
+
query.push("per_page=50");
|
|
1194
1235
|
try {
|
|
1195
1236
|
return workflowRunsFrom(
|
|
1196
|
-
await runGh([
|
|
1197
|
-
"api",
|
|
1198
|
-
`repos/${repo}/actions/runs?head_sha=${encodeURIComponent(sha)}&per_page=50`,
|
|
1199
|
-
]),
|
|
1237
|
+
await runGh(["api", `repos/${repo}/actions/runs?${query.join("&")}`]),
|
|
1200
1238
|
);
|
|
1201
1239
|
} catch {
|
|
1202
1240
|
return undefined;
|
package/src/types.ts
CHANGED
|
@@ -136,8 +136,8 @@ export interface RepoTarget {
|
|
|
136
136
|
* `escalations` interrupts only for a tier-2 decision. `material` reports every
|
|
137
137
|
* material event as it happens. `decisions` sits between them: a decision the
|
|
138
138
|
* session needs, or a condition that stops the fleet, interrupts immediately;
|
|
139
|
-
* every other material event accumulates and ships with the
|
|
140
|
-
*
|
|
139
|
+
* every other material event accumulates and ships with the configured digest
|
|
140
|
+
* instead of as a ping per merge (#138).
|
|
141
141
|
*/
|
|
142
142
|
export const REPORT_SCOPES = ["escalations", "decisions", "material"] as const;
|
|
143
143
|
|
|
@@ -177,6 +177,24 @@ export const INTERRUPT_CATEGORIES = [
|
|
|
177
177
|
|
|
178
178
|
export type InterruptCategory = (typeof INTERRUPT_CATEGORIES)[number];
|
|
179
179
|
|
|
180
|
+
/** Operator-local weekdays used by a weekly availability window. */
|
|
181
|
+
export const WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
|
182
|
+
|
|
183
|
+
export type Weekday = (typeof WEEKDAYS)[number];
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One operator-local interruption window. A day names the day the window
|
|
187
|
+
* opens; when `start` is later than `end`, it closes on the following day.
|
|
188
|
+
*/
|
|
189
|
+
export interface WeeklyAvailability {
|
|
190
|
+
timezone: string;
|
|
191
|
+
days: Weekday[];
|
|
192
|
+
start: string;
|
|
193
|
+
end: string;
|
|
194
|
+
/** Categories that may still interrupt outside the window. */
|
|
195
|
+
bypass: InterruptCategory[];
|
|
196
|
+
}
|
|
197
|
+
|
|
180
198
|
/** When the daily/report rollup is due. */
|
|
181
199
|
export const DIGEST_CADENCES = ["none", "per-tick", "daily"] as const;
|
|
182
200
|
|
|
@@ -190,6 +208,8 @@ export type DigestCadence = (typeof DIGEST_CADENCES)[number];
|
|
|
190
208
|
export interface ReportingPolicy {
|
|
191
209
|
interruptOn: InterruptCategory[];
|
|
192
210
|
digest: { cadence: DigestCadence; at?: string; timezone?: string };
|
|
211
|
+
/** Absent preserves the legacy 24-hour interruption behaviour. */
|
|
212
|
+
availability?: WeeklyAvailability;
|
|
193
213
|
/** Set when the policy came from a legacy `scope` preset. */
|
|
194
214
|
scopePreset?: ReportScope;
|
|
195
215
|
}
|
|
@@ -710,6 +730,10 @@ export const SETTLEMENT_FLAG_KINDS = [
|
|
|
710
730
|
"changed-line-missing",
|
|
711
731
|
/** `changed:` named a path the PR never touched. The weaker direction. */
|
|
712
732
|
"unmatched-claim",
|
|
733
|
+
/** The same file appears as both claimed-but-untouched and
|
|
734
|
+
* touched-but-unclaimed — the `changed:` line's format defeated the parser;
|
|
735
|
+
* read the diff directly. */
|
|
736
|
+
"report-format-unparsed",
|
|
713
737
|
/** A test file left the tree and no rename in the PR accounts for it. */
|
|
714
738
|
"test-file-deleted",
|
|
715
739
|
/** A skip/only/focus marker appears on a line the PR added. */
|
|
@@ -869,7 +893,13 @@ export interface Tracker {
|
|
|
869
893
|
/** The merge commit and base ref for a merged pull request. */
|
|
870
894
|
mergedPrInfo(url: string): Promise<MergedPrInfo | undefined>;
|
|
871
895
|
/** Workflow runs GitHub associated with one exact commit SHA. */
|
|
872
|
-
workflowRunsAt(
|
|
896
|
+
workflowRunsAt(
|
|
897
|
+
repo: string,
|
|
898
|
+
sha: string,
|
|
899
|
+
opts?: { event?: string; branch?: string },
|
|
900
|
+
): Promise<WorkflowRun[] | undefined>;
|
|
901
|
+
/** The live commit at one repository branch, or undefined when unreadable. */
|
|
902
|
+
branchHead(repo: string, branch: string): Promise<string | undefined>;
|
|
873
903
|
/** The newest run of this workflow on the base branch before `before`.
|
|
874
904
|
* `null` means no earlier run; `undefined` means the history could not be read. */
|
|
875
905
|
previousWorkflowRun(
|
|
@@ -959,6 +989,9 @@ export const FAILURE_CLASSES = [
|
|
|
959
989
|
"settlement-stuck",
|
|
960
990
|
"provider-credit",
|
|
961
991
|
"provider-transient",
|
|
992
|
+
/** A reviewer closed pushed-green or pushed-pending work without merging it:
|
|
993
|
+
* a review decision, not a worker failure. */
|
|
994
|
+
"returned-for-revision",
|
|
962
995
|
"unknown",
|
|
963
996
|
] as const;
|
|
964
997
|
|
|
@@ -994,6 +1027,17 @@ export const BASE_CHECK_STATES = [
|
|
|
994
1027
|
|
|
995
1028
|
export type BaseCheckState = (typeof BASE_CHECK_STATES)[number];
|
|
996
1029
|
|
|
1030
|
+
/** Current push-workflow health at a routed repository's live base head. */
|
|
1031
|
+
export interface BaseHealth {
|
|
1032
|
+
repo: string;
|
|
1033
|
+
branch: string;
|
|
1034
|
+
headSha: string;
|
|
1035
|
+
verdict: "green" | "red" | "pending" | "unknown";
|
|
1036
|
+
runsCount: number;
|
|
1037
|
+
detail?: string;
|
|
1038
|
+
checkedAt: number;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
997
1041
|
/**
|
|
998
1042
|
* Execution state is separate from the tracker's own labels on purpose: labels
|
|
999
1043
|
* are coarse and human-editable, while the loop needs to distinguish "pushed
|
|
@@ -1266,6 +1310,43 @@ export interface ReportEnqueue {
|
|
|
1266
1310
|
deduped: boolean;
|
|
1267
1311
|
}
|
|
1268
1312
|
|
|
1313
|
+
/** Maximum ledger rows of each kind placed in one digest prompt. Older rows
|
|
1314
|
+
* stay first, so a busy day drains deterministically over later digests. */
|
|
1315
|
+
export const DIGEST_BACKLOG_LIMIT = 20;
|
|
1316
|
+
|
|
1317
|
+
/** One ordinary material outcome waiting for a rendered digest (#274). */
|
|
1318
|
+
export interface MaterialEvent {
|
|
1319
|
+
id: string;
|
|
1320
|
+
project: string;
|
|
1321
|
+
category: string;
|
|
1322
|
+
summary: string;
|
|
1323
|
+
evidence: string;
|
|
1324
|
+
occurredAt: number;
|
|
1325
|
+
recordedAt: number;
|
|
1326
|
+
/** The accepted outbox row that took responsibility for this event. */
|
|
1327
|
+
digestReportId?: string;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** What the orchestrator records when an outcome happens. */
|
|
1331
|
+
export interface MaterialEventDraft {
|
|
1332
|
+
project: string;
|
|
1333
|
+
category: string;
|
|
1334
|
+
summary: string;
|
|
1335
|
+
evidence: string;
|
|
1336
|
+
occurredAt: number;
|
|
1337
|
+
recordedAt: number;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/** Counts and oldest timestamps for the two distinct digest sources. */
|
|
1341
|
+
export interface DigestBacklog {
|
|
1342
|
+
materialCount: number;
|
|
1343
|
+
materialOldestAt?: number;
|
|
1344
|
+
heldNoticeCount: number;
|
|
1345
|
+
/** Subset held only for the next availability window. */
|
|
1346
|
+
availabilityHeldNoticeCount?: number;
|
|
1347
|
+
heldNoticeOldestAt?: number;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1269
1350
|
/**
|
|
1270
1351
|
* Where one operator decision stands (#136).
|
|
1271
1352
|
*
|
|
@@ -1359,8 +1440,15 @@ export interface Store {
|
|
|
1359
1440
|
recentRuns(project: string, mergedSinceEpochMs: number): RunRecord[];
|
|
1360
1441
|
/** Merged rows whose post-merge workflow verdict is still pending, oldest first. */
|
|
1361
1442
|
runsNeedingBaseCheck(project: string, limit?: number): RunRecord[];
|
|
1362
|
-
/**
|
|
1363
|
-
|
|
1443
|
+
/** Replace the current live-head health row for one routed repository. */
|
|
1444
|
+
upsertBaseHealth(project: string, row: BaseHealth): void;
|
|
1445
|
+
/** Current live-head health, ordered by routed repository name. */
|
|
1446
|
+
baseHealth(project: string): BaseHealth[];
|
|
1447
|
+
/** Recently merged routed repository/branch pairs that establish health scope. */
|
|
1448
|
+
mergedRepoBranches(
|
|
1449
|
+
project: string,
|
|
1450
|
+
sinceEpochMs: number,
|
|
1451
|
+
): { repo: string; baseRef?: string }[];
|
|
1364
1452
|
/** Newest attempt per issue that preserved work or failed to, so `status`
|
|
1365
1453
|
* can name every WIP tip a re-claim would build on and every tree that is
|
|
1366
1454
|
* still the only copy. */
|
|
@@ -1405,12 +1493,26 @@ export interface Store {
|
|
|
1405
1493
|
/** The newest `digest:` dedupe key a project has run toward without ending in
|
|
1406
1494
|
* failure — what the off-schedule-digest refusal compares against (#229). */
|
|
1407
1495
|
lastDigestDedupeKey(project: string): string | undefined;
|
|
1408
|
-
/** Hold a tier-2 escalation the interrupt policy
|
|
1496
|
+
/** Hold a tier-2 escalation the interrupt policy or availability window deferred. */
|
|
1409
1497
|
addHeldNotice(notice: HeldNoticeDraft): void;
|
|
1410
|
-
/**
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1498
|
+
/**
|
|
1499
|
+
* Held notices still owed, oldest first and bounded. `availabilityOnly`
|
|
1500
|
+
* selects notices that may be released at the next configured opening.
|
|
1501
|
+
*/
|
|
1502
|
+
undigestedNotices(
|
|
1503
|
+
project: string,
|
|
1504
|
+
limit?: number,
|
|
1505
|
+
availabilityOnly?: boolean,
|
|
1506
|
+
categories?: readonly InterruptCategory[],
|
|
1507
|
+
urgent?: boolean,
|
|
1508
|
+
): HeldNotice[];
|
|
1509
|
+
/** Persist one ordinary material outcome without sending it. */
|
|
1510
|
+
recordMaterialEvent(event: MaterialEventDraft): MaterialEvent;
|
|
1511
|
+
getMaterialEvent(id: string): MaterialEvent | undefined;
|
|
1512
|
+
/** Ordinary material outcomes still owed, oldest first and bounded. */
|
|
1513
|
+
undigestedMaterialEvents(project: string, limit?: number): MaterialEvent[];
|
|
1514
|
+
/** Count and age source for status and digest prompt bounds. */
|
|
1515
|
+
digestBacklog(project: string): DigestBacklog;
|
|
1414
1516
|
/** Add one bounded observation to the per-day friction rollup. */
|
|
1415
1517
|
recordFriction(project: string, observation: FrictionObservation): void;
|
|
1416
1518
|
/** Repeated signals not surfaced within the supplied cooldown window. */
|
|
@@ -1440,7 +1542,35 @@ export interface Store {
|
|
|
1440
1542
|
* suppressed without asking the model what it sent yesterday.
|
|
1441
1543
|
*/
|
|
1442
1544
|
enqueueReport(draft: ReportDraft): ReportEnqueue;
|
|
1545
|
+
/** Atomically hand a digest to the outbox and associate only the named
|
|
1546
|
+
* material events and deferred escalations. A handoff deduplicated by its
|
|
1547
|
+
* optional daily key consumes nothing new. */
|
|
1548
|
+
enqueueDigestReport(
|
|
1549
|
+
draft: ReportDraft,
|
|
1550
|
+
materialEventIds: readonly string[],
|
|
1551
|
+
heldNoticeIds: readonly string[],
|
|
1552
|
+
): ReportEnqueue;
|
|
1553
|
+
/** Atomically enqueue a working-hours catch-up unless a due digest currently
|
|
1554
|
+
* owns the handoff window. `undefined` leaves every notice unowned. */
|
|
1555
|
+
enqueueAvailabilityReport(
|
|
1556
|
+
draft: ReportDraft,
|
|
1557
|
+
heldNoticeIds: readonly string[],
|
|
1558
|
+
): ReportEnqueue | undefined;
|
|
1443
1559
|
getReport(id: string): ReportRecord | undefined;
|
|
1560
|
+
/** Atomically remove an unsent material report from the outbox and preserve
|
|
1561
|
+
* its body as a held notice after policy or availability changes. */
|
|
1562
|
+
deferPendingReportToNotice(id: string, notice: HeldNoticeDraft): boolean;
|
|
1563
|
+
/** Remove a still-pending working-hours catch-up and make its associated
|
|
1564
|
+
* notices digest-eligible again after a live policy change. */
|
|
1565
|
+
releasePendingAvailabilityReport(id: string, project: string, possibleRepeat?: boolean): boolean;
|
|
1566
|
+
/** Give one due digest a bounded, once-per-day lease on availability-held
|
|
1567
|
+
* notices. Pending catch-ups are released inside the same transaction. */
|
|
1568
|
+
reserveAvailabilityDigest(
|
|
1569
|
+
project: string,
|
|
1570
|
+
cycleKey: string,
|
|
1571
|
+
at: number,
|
|
1572
|
+
expiresAt: number,
|
|
1573
|
+
): boolean;
|
|
1444
1574
|
/** `pending` rows whose backoff has elapsed, oldest first. */
|
|
1445
1575
|
dueReports(project: string, now: number, limit: number): ReportRecord[];
|
|
1446
1576
|
/** Atomically take a due `pending` row into `sending` under `attemptId`, so
|
|
@@ -1582,22 +1712,31 @@ export interface Escalation {
|
|
|
1582
1712
|
urgent?: true;
|
|
1583
1713
|
}
|
|
1584
1714
|
|
|
1585
|
-
/** A tier-2 escalation the project's interrupt policy deferred to the digest. */
|
|
1586
1715
|
export interface HeldNotice {
|
|
1587
1716
|
id: string;
|
|
1588
1717
|
category: InterruptCategory;
|
|
1589
1718
|
summary: string;
|
|
1590
1719
|
detail: string;
|
|
1591
1720
|
createdAt: number;
|
|
1721
|
+
/**
|
|
1722
|
+
* This notice was otherwise interruptible and was held only by the
|
|
1723
|
+
* availability window. It may be surfaced when the current policy opens.
|
|
1724
|
+
*/
|
|
1725
|
+
releaseOnAvailable?: true;
|
|
1726
|
+
/** Urgent recovery may bypass category batching, but never availability. */
|
|
1727
|
+
urgent?: true;
|
|
1592
1728
|
}
|
|
1593
1729
|
|
|
1594
|
-
/** What it takes to persist one held notice. */
|
|
1595
1730
|
export interface HeldNoticeDraft {
|
|
1731
|
+
/** Caller-supplied identity when the handoff must print or deduplicate it. */
|
|
1732
|
+
id?: string;
|
|
1596
1733
|
project: string;
|
|
1597
1734
|
category: InterruptCategory;
|
|
1598
1735
|
summary: string;
|
|
1599
1736
|
detail: string;
|
|
1600
1737
|
createdAt: number;
|
|
1738
|
+
releaseOnAvailable?: true;
|
|
1739
|
+
urgent?: true;
|
|
1601
1740
|
}
|
|
1602
1741
|
|
|
1603
1742
|
/**
|
package/src/verbs/actions.ts
CHANGED
|
@@ -132,6 +132,30 @@ export function githubVerbActions(
|
|
|
132
132
|
`live ${repo.defaultBranch} lookup`,
|
|
133
133
|
);
|
|
134
134
|
|
|
135
|
+
const remoteTagCommit = async (
|
|
136
|
+
mirror: string,
|
|
137
|
+
tag: string,
|
|
138
|
+
): Promise<{ ok: true; sha: string | undefined } | { ok: false; stderr: string }> => {
|
|
139
|
+
const directRef = `refs/tags/${tag}`;
|
|
140
|
+
const peeledRef = `${directRef}^{}`;
|
|
141
|
+
const { argv, result } = await git(mirror, ["ls-remote", "origin", directRef, peeledRef]);
|
|
142
|
+
if (!result.ok) return failed(result, argv);
|
|
143
|
+
if (result.stdout.trim() === "") return { ok: true, sha: undefined };
|
|
144
|
+
|
|
145
|
+
const refs = new Map(
|
|
146
|
+
result.stdout
|
|
147
|
+
.trim()
|
|
148
|
+
.split("\n")
|
|
149
|
+
.map((line) => line.trim().split(/\s+/, 2))
|
|
150
|
+
.filter((entry): entry is [string, string] => entry.length === 2)
|
|
151
|
+
.map(([sha, ref]) => [ref, sha] as const),
|
|
152
|
+
);
|
|
153
|
+
const sha = commitFrom(refs.get(peeledRef) ?? refs.get(directRef) ?? "");
|
|
154
|
+
return sha === undefined
|
|
155
|
+
? { ok: false, stderr: `remote tag ${tag} lookup returned no full commit SHA` }
|
|
156
|
+
: { ok: true, sha };
|
|
157
|
+
};
|
|
158
|
+
|
|
135
159
|
const releaseTargetMoved = (repo: RepoTarget, target: string, live: string): { ok: false; stderr: string } => ({
|
|
136
160
|
ok: false,
|
|
137
161
|
stderr:
|
|
@@ -217,11 +241,40 @@ export function githubVerbActions(
|
|
|
217
241
|
if (!liveBefore.ok) return liveBefore;
|
|
218
242
|
if (target.sha !== liveBefore.sha) return releaseTargetMoved(execution.repo, target.sha, liveBefore.sha);
|
|
219
243
|
|
|
244
|
+
const localTag = await git(mirror, ["rev-parse", "-q", "--verify", `refs/tags/${tag}^{commit}`]);
|
|
245
|
+
let previousSha: string | undefined;
|
|
246
|
+
if (localTag.result.ok) {
|
|
247
|
+
previousSha = commitFrom(localTag.result.stdout);
|
|
248
|
+
if (previousSha === undefined) {
|
|
249
|
+
return { ok: false, stderr: `tag ${tag} returned no full commit SHA` };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const remoteTag = await remoteTagCommit(mirror, tag);
|
|
253
|
+
if (!remoteTag.ok) return remoteTag;
|
|
254
|
+
if (remoteTag.sha === target.sha) {
|
|
255
|
+
return {
|
|
256
|
+
ok: true,
|
|
257
|
+
sha: target.sha,
|
|
258
|
+
detail: `tag ${tag} already exists at ${target.sha} and is already on origin`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (remoteTag.sha !== undefined) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
stderr:
|
|
265
|
+
`refusing release: ${tag} already exists on origin at ${remoteTag.sha}. ` +
|
|
266
|
+
"A published tag is never re-cut or moved; choose a new tag name.",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const repointed = previousSha !== undefined;
|
|
220
272
|
// An unattended daemon cannot depend on a host-level Git identity or a
|
|
221
273
|
// signing key with an interactive passphrase.
|
|
222
274
|
const tagged = await git(mirror, [
|
|
223
275
|
...RELEASE_TAG_CONFIG,
|
|
224
276
|
"tag",
|
|
277
|
+
...(repointed ? ["-f"] : []),
|
|
225
278
|
"-a",
|
|
226
279
|
tag,
|
|
227
280
|
"-m",
|
|
@@ -232,30 +285,95 @@ export function githubVerbActions(
|
|
|
232
285
|
|
|
233
286
|
const liveAfter = await liveDefaultHead(mirror, execution.repo);
|
|
234
287
|
if (!liveAfter.ok || liveAfter.sha !== target.sha) {
|
|
235
|
-
const
|
|
288
|
+
const rollback = previousSha !== undefined
|
|
289
|
+
? await git(mirror, [
|
|
290
|
+
...RELEASE_TAG_CONFIG,
|
|
291
|
+
"tag",
|
|
292
|
+
"-f",
|
|
293
|
+
"-a",
|
|
294
|
+
tag,
|
|
295
|
+
"-m",
|
|
296
|
+
`release ${tag}`,
|
|
297
|
+
previousSha,
|
|
298
|
+
])
|
|
299
|
+
: await git(mirror, ["tag", "-d", tag]);
|
|
236
300
|
const reason = liveAfter.ok
|
|
237
301
|
? releaseTargetMoved(execution.repo, target.sha, liveAfter.sha).stderr
|
|
238
|
-
:
|
|
239
|
-
|
|
240
|
-
ok
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
};
|
|
302
|
+
: `${repointed ? "re-pointed" : "created"} ${tag} at ${target.sha}, but could not verify the live default branch: ${liveAfter.stderr}`;
|
|
303
|
+
const rollbackDetail = repointed
|
|
304
|
+
? rollback.result.ok
|
|
305
|
+
? ` The tag was restored to ${previousSha}.`
|
|
306
|
+
: ` WARNING: the tag could not be restored to ${previousSha}: ${failed(rollback.result, rollback.argv).stderr}`
|
|
307
|
+
: rollback.result.ok
|
|
308
|
+
? " The unpushed local tag was deleted."
|
|
309
|
+
: ` WARNING: the unpushed local tag could not be deleted: ${failed(rollback.result, rollback.argv).stderr}`;
|
|
310
|
+
return { ok: false, stderr: reason + rollbackDetail };
|
|
247
311
|
}
|
|
248
|
-
return {
|
|
312
|
+
return {
|
|
313
|
+
ok: true,
|
|
314
|
+
sha: target.sha,
|
|
315
|
+
detail: repointed
|
|
316
|
+
? `re-pointed unpushed ${tag} from ${previousSha} to ${target.sha}`
|
|
317
|
+
: `tagged ${tag} at ${target.sha}`,
|
|
318
|
+
};
|
|
249
319
|
}
|
|
250
320
|
|
|
251
321
|
const target = await readCommit(mirror, ["rev-parse", `refs/tags/${tag}^{commit}`], `tag ${tag}`);
|
|
252
322
|
if (!target.ok) return target;
|
|
323
|
+
let tagSha = target.sha;
|
|
253
324
|
const live = await liveDefaultHead(mirror, execution.repo);
|
|
254
325
|
if (!live.ok) return live;
|
|
255
|
-
|
|
326
|
+
|
|
327
|
+
const remoteTag = await remoteTagCommit(mirror, tag);
|
|
328
|
+
if (!remoteTag.ok) return remoteTag;
|
|
329
|
+
if (remoteTag.sha === tagSha) {
|
|
330
|
+
return { ok: true, sha: tagSha, detail: `refs/tags/${tag} already on origin at ${tagSha}` };
|
|
331
|
+
}
|
|
332
|
+
if (remoteTag.sha !== undefined) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
stderr:
|
|
336
|
+
`refusing release: origin already has ${tag} at ${remoteTag.sha}, ` +
|
|
337
|
+
`which differs from the local tag at ${tagSha}. ` +
|
|
338
|
+
"A published tag is never force-moved; cut a new tag instead.",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let oldSha: string | undefined;
|
|
343
|
+
if (tagSha !== live.sha) {
|
|
344
|
+
const fresh = await readCommit(
|
|
345
|
+
mirror,
|
|
346
|
+
["rev-parse", `refs/remotes/origin/${execution.repo.defaultBranch}^{commit}`],
|
|
347
|
+
`refreshed ${execution.repo.defaultBranch}`,
|
|
348
|
+
);
|
|
349
|
+
if (!fresh.ok) return fresh;
|
|
350
|
+
if (fresh.sha !== live.sha) return releaseTargetMoved(execution.repo, fresh.sha, live.sha);
|
|
351
|
+
|
|
352
|
+
oldSha = tagSha;
|
|
353
|
+
const retagged = await git(mirror, [
|
|
354
|
+
...RELEASE_TAG_CONFIG,
|
|
355
|
+
"tag",
|
|
356
|
+
"-f",
|
|
357
|
+
"-a",
|
|
358
|
+
tag,
|
|
359
|
+
"-m",
|
|
360
|
+
`release ${tag}`,
|
|
361
|
+
live.sha,
|
|
362
|
+
]);
|
|
363
|
+
if (!retagged.result.ok) return failed(retagged.result, retagged.argv);
|
|
364
|
+
tagSha = live.sha;
|
|
365
|
+
}
|
|
366
|
+
|
|
256
367
|
const pushed = await git(mirror, ["push", "origin", `refs/tags/${tag}`]);
|
|
257
368
|
return pushed.result.ok
|
|
258
|
-
? {
|
|
369
|
+
? {
|
|
370
|
+
ok: true,
|
|
371
|
+
sha: tagSha,
|
|
372
|
+
detail:
|
|
373
|
+
oldSha === undefined
|
|
374
|
+
? `pushed refs/tags/${tag} at ${tagSha}`
|
|
375
|
+
: `re-pointed unpushed ${tag} from ${oldSha} to ${tagSha}; pushed refs/tags/${tag} at ${tagSha}`,
|
|
376
|
+
}
|
|
259
377
|
: failed(pushed.result, pushed.argv);
|
|
260
378
|
},
|
|
261
379
|
};
|
package/src/verbs/server.ts
CHANGED
|
@@ -265,9 +265,9 @@ export interface ReleaseFacts {
|
|
|
265
265
|
openPrs: number;
|
|
266
266
|
/** Queue depth, or `undefined` when the tracker could not be read. */
|
|
267
267
|
queueDepth: number | undefined;
|
|
268
|
-
/**
|
|
268
|
+
/** Current live-head workflow verdict for the released routed repository. */
|
|
269
269
|
baseCheck?: RunRecord["baseCheck"];
|
|
270
|
-
/** Evidence attached to a
|
|
270
|
+
/** Evidence attached to a current red verdict. */
|
|
271
271
|
redBase?: string;
|
|
272
272
|
}
|
|
273
273
|
|
|
@@ -978,18 +978,17 @@ async function releaseVerb(
|
|
|
978
978
|
}
|
|
979
979
|
}
|
|
980
980
|
const wantsBase = policy.release.requires.includes("base-branch-green");
|
|
981
|
-
const
|
|
982
|
-
? deps.store.
|
|
981
|
+
const health = wantsBase
|
|
982
|
+
? deps.store.baseHealth(project.name).find((row) => row.repo === repoName)
|
|
983
983
|
: undefined;
|
|
984
|
-
const redBase = latestBase?.settlementFlags?.find(
|
|
985
|
-
(flag) => flag.kind === "base-branch-red",
|
|
986
|
-
)?.detail;
|
|
987
984
|
const unmet = releaseRequirementRefusal(policy.release.requires, {
|
|
988
985
|
unsettledRuns: active.length,
|
|
989
986
|
openPrs: active.filter((r) => r.prUrl !== undefined).length,
|
|
990
987
|
queueDepth,
|
|
991
|
-
...(
|
|
992
|
-
...(
|
|
988
|
+
...(health === undefined ? {} : { baseCheck: health.verdict }),
|
|
989
|
+
...(health?.verdict === "red" && health.detail !== undefined
|
|
990
|
+
? { redBase: health.detail }
|
|
991
|
+
: {}),
|
|
993
992
|
});
|
|
994
993
|
if (unmet !== undefined) return refuse("release-not-granted", `refused: ${unmet}`);
|
|
995
994
|
|
package/src/worker.ts
CHANGED
|
@@ -22,6 +22,8 @@ const BLOCKED_PATTERN = /^state:\s*blocked\s*$/im;
|
|
|
22
22
|
|
|
23
23
|
/** Any explicit verdict line, whatever it claims. */
|
|
24
24
|
const STATE_LINE_PATTERN = /^state:\s*\S+\s*$/im;
|
|
25
|
+
/** GitHub PR URLs in unstructured prose; capture their canonical `owner/repo`. */
|
|
26
|
+
const GITHUB_PR_URL_PATTERN = /https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/\d+\b/gi;
|
|
25
27
|
|
|
26
28
|
/** `{{KEY}}` placeholders in a brief template. */
|
|
27
29
|
const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
|
|
@@ -63,6 +65,8 @@ export interface WorkerOpts {
|
|
|
63
65
|
brief: string;
|
|
64
66
|
cwd: string;
|
|
65
67
|
caps: Caps;
|
|
68
|
+
/** Canonical `owner/repo` identity used to scope prose-only PR URLs. */
|
|
69
|
+
repoSlug?: string;
|
|
66
70
|
/**
|
|
67
71
|
* Directory the harness writes this run's transcript into — a directory, not
|
|
68
72
|
* a file. The SDK takes no `sessionFile` input, so naming a path here would
|
|
@@ -179,15 +183,23 @@ export function renderBrief(template: string, vars: Record<string, string>): str
|
|
|
179
183
|
* asks the tracker to verify those facts independently. Missing or malformed
|
|
180
184
|
* evidence fails closed.
|
|
181
185
|
*/
|
|
182
|
-
export function deriveResult(report: string): {
|
|
186
|
+
export function deriveResult(report: string, repoSlug?: string): {
|
|
183
187
|
state: RunState;
|
|
184
188
|
prUrl?: string;
|
|
185
189
|
headSha?: string;
|
|
186
190
|
} {
|
|
187
|
-
const
|
|
191
|
+
const structuredPrUrl = PR_URL_PATTERN.exec(report)?.[1];
|
|
188
192
|
const headSha = HEAD_SHA_PATTERN.exec(report)?.[1]?.toLowerCase();
|
|
189
|
-
if (PUSHED_GREEN_PATTERN.test(report) &&
|
|
190
|
-
return { state: "pushed-green", prUrl, headSha };
|
|
193
|
+
if (PUSHED_GREEN_PATTERN.test(report) && structuredPrUrl !== undefined && headSha !== undefined) {
|
|
194
|
+
return { state: "pushed-green", prUrl: structuredPrUrl, headSha };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let prUrl = structuredPrUrl;
|
|
198
|
+
if (prUrl === undefined && repoSlug !== undefined) {
|
|
199
|
+
const expectedRepo = repoSlug.toLowerCase();
|
|
200
|
+
for (const match of report.matchAll(GITHUB_PR_URL_PATTERN)) {
|
|
201
|
+
if (match[1]?.toLowerCase() === expectedRepo) prUrl = match[0];
|
|
202
|
+
}
|
|
191
203
|
}
|
|
192
204
|
|
|
193
205
|
const state: RunState = BLOCKED_PATTERN.test(report) ? "blocked" : "failed";
|
|
@@ -424,7 +436,7 @@ export async function runWorker(
|
|
|
424
436
|
const text = reportText(field(message, "content"));
|
|
425
437
|
if (text !== "") {
|
|
426
438
|
report = text;
|
|
427
|
-
const stated = deriveResult(text);
|
|
439
|
+
const stated = deriveResult(text, o.repoSlug);
|
|
428
440
|
if (stated.state === "pushed-green" && stated.prUrl !== undefined && stated.headSha !== undefined) {
|
|
429
441
|
claim = { prUrl: stated.prUrl, headSha: stated.headSha };
|
|
430
442
|
}
|
|
@@ -554,7 +566,7 @@ export async function runWorker(
|
|
|
554
566
|
return withSessionFacts({ state: "pushed-green", ...claim, turns, spendUsd, report });
|
|
555
567
|
}
|
|
556
568
|
return withSessionFacts({
|
|
557
|
-
...deriveResult(report),
|
|
569
|
+
...deriveResult(report, o.repoSlug),
|
|
558
570
|
turns,
|
|
559
571
|
spendUsd,
|
|
560
572
|
report,
|