omp-conductor 0.16.2 → 0.17.1
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 +38 -4
- package/REFERENCE.md +18 -12
- package/package.json +2 -1
- package/schema/config.schema.json +16 -0
- package/src/admission.ts +159 -43
- package/src/availability.ts +27 -1
- package/src/briefs/worker.md +2 -0
- package/src/clack-ui.ts +83 -0
- package/src/command-manifest.ts +16 -7
- package/src/commands/arm.ts +11 -3
- package/src/commands/decision.ts +17 -7
- package/src/commands/doctor.ts +18 -1
- package/src/commands/hold.ts +9 -7
- package/src/commands/ledger.ts +25 -4
- package/src/commands/message.ts +32 -4
- package/src/commands/setup.ts +61 -10
- package/src/commands/stats.ts +9 -5
- package/src/commands/status.ts +32 -5
- package/src/commands/tail.ts +13 -1
- package/src/commands/watch.ts +16 -7
- package/src/config-schema.ts +20 -0
- package/src/config.ts +37 -0
- package/src/daemon.ts +1240 -18
- package/src/doctor.ts +310 -22
- package/src/escalate.ts +560 -57
- package/src/failure-class.ts +56 -13
- package/src/fleet.ts +224 -47
- package/src/gitops.ts +103 -24
- package/src/lifecycle.ts +7 -2
- package/src/orchestrator-tick.ts +372 -157
- package/src/privileged.ts +3 -0
- package/src/release-policy.ts +177 -5
- package/src/setup-answers.ts +135 -0
- package/src/setup-host.ts +193 -4
- package/src/setup-install.ts +2 -0
- package/src/setup-probe.ts +1 -0
- package/src/setup-wizard.ts +1296 -101
- package/src/setup.ts +60 -3
- package/src/status-render.ts +11 -1
- package/src/store.ts +333 -12
- package/src/tracker/github.ts +562 -13
- package/src/types.ts +204 -2
- package/src/ui/progress.ts +32 -0
- package/src/ui/style.ts +11 -0
- package/src/upgrade.ts +50 -19
- package/src/verbs/actions.ts +66 -18
- package/src/verbs/protocol.ts +45 -0
- package/src/verbs/server.ts +212 -11
- package/src/wizard-ui.ts +14 -5
- package/src/worker.ts +26 -0
- package/systemd/omp-conductor-recover.sh +73 -0
- package/systemd/recover-unit-test.sh +61 -0
package/src/tracker/github.ts
CHANGED
|
@@ -385,7 +385,13 @@ function checkVerdict(check: GhCheck): CheckVerdict {
|
|
|
385
385
|
return "failed";
|
|
386
386
|
}
|
|
387
387
|
if (check.status !== "COMPLETED") return "pending";
|
|
388
|
-
if (
|
|
388
|
+
if (
|
|
389
|
+
check.conclusion === "SUCCESS" ||
|
|
390
|
+
check.conclusion === "SKIPPED" ||
|
|
391
|
+
check.conclusion === "NEUTRAL"
|
|
392
|
+
) {
|
|
393
|
+
return "green";
|
|
394
|
+
}
|
|
389
395
|
return "failed";
|
|
390
396
|
}
|
|
391
397
|
|
|
@@ -461,6 +467,311 @@ function failedCheck(raw: string): GhCheck | undefined {
|
|
|
461
467
|
return checks.find((check) => checkVerdict(check) === "failed");
|
|
462
468
|
}
|
|
463
469
|
|
|
470
|
+
/** REST projection of a pull request read by the verifyPr fallback (#653). */
|
|
471
|
+
interface RestPrProjection {
|
|
472
|
+
draft?: unknown;
|
|
473
|
+
head_sha?: unknown;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** One REST check, normalized onto the rollup verdict vocabulary. */
|
|
477
|
+
interface RestCheckConclusion {
|
|
478
|
+
name: string;
|
|
479
|
+
/** The terminal `conclusion`/`status` (run) or `state` (commit status), as
|
|
480
|
+
* GitHub spelled it — surfaced in refusals exactly like the GraphQL one. */
|
|
481
|
+
state: string;
|
|
482
|
+
verdict: CheckVerdict;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** One REST check plane, parsed strictly. */
|
|
486
|
+
type RestCheckEvidence =
|
|
487
|
+
| { readonly ok: true; readonly checks: RestCheckConclusion[] }
|
|
488
|
+
| { readonly ok: false };
|
|
489
|
+
|
|
490
|
+
/** The live head SHA from a REST pull-request projection, or undefined. */
|
|
491
|
+
function restPrHeadFrom(raw: string): string | undefined {
|
|
492
|
+
let parsed: unknown;
|
|
493
|
+
try {
|
|
494
|
+
parsed = JSON.parse(raw) as unknown;
|
|
495
|
+
} catch {
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
if (parsed === null || typeof parsed !== "object") return undefined;
|
|
499
|
+
const sha = (parsed as RestPrProjection).head_sha;
|
|
500
|
+
return typeof sha === "string" && /^[0-9a-f]{40,64}$/i.test(sha) ? sha : undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Whether a REST pull-request projection is a draft. Absent or malformed is
|
|
504
|
+
* "draft state unknown", the same fail-closed answer {@link prVerificationFrom}
|
|
505
|
+
* gives for a missing rollup — it must never be read as a clean non-draft. */
|
|
506
|
+
function restPrDraftFrom(raw: string): boolean | undefined {
|
|
507
|
+
let parsed: unknown;
|
|
508
|
+
try {
|
|
509
|
+
parsed = JSON.parse(raw) as unknown;
|
|
510
|
+
} catch {
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
if (parsed === null || typeof parsed !== "object") return undefined;
|
|
514
|
+
const draft = (parsed as RestPrProjection).draft;
|
|
515
|
+
return typeof draft === "boolean" ? draft : undefined;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** One REST check-run member onto the rollup vocabulary, or undefined when the
|
|
519
|
+
* member's identity or lifecycle fields cannot be read. */
|
|
520
|
+
function restRunConclusionFrom(row: { readonly [key: string]: unknown }): RestCheckConclusion | undefined {
|
|
521
|
+
const name = row["name"];
|
|
522
|
+
const status = row["status"];
|
|
523
|
+
const conclusion = row["conclusion"];
|
|
524
|
+
if (typeof name !== "string" || name === "") return undefined;
|
|
525
|
+
if (typeof status !== "string") return undefined;
|
|
526
|
+
if (conclusion !== undefined && conclusion !== null && typeof conclusion !== "string") return undefined;
|
|
527
|
+
// statusCheckRollup semantics: an unfinished run is pending,
|
|
528
|
+
// `success`/`skipped`/`neutral` are green — GitHub's rollup counts a
|
|
529
|
+
// completed `neutral` check as successful — anything else is failed.
|
|
530
|
+
const verdict: CheckVerdict =
|
|
531
|
+
status !== "completed"
|
|
532
|
+
? "pending"
|
|
533
|
+
: conclusion === "success" || conclusion === "skipped" || conclusion === "neutral"
|
|
534
|
+
? "green"
|
|
535
|
+
: "failed";
|
|
536
|
+
return { name, state: typeof conclusion === "string" ? conclusion : status, verdict };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** One REST commit-status member onto the rollup vocabulary, or undefined when
|
|
540
|
+
* the identity or state cannot be read. */
|
|
541
|
+
function restStatusConclusionFrom(row: { readonly [key: string]: unknown }): RestCheckConclusion | undefined {
|
|
542
|
+
const context = row["context"];
|
|
543
|
+
const state = row["state"];
|
|
544
|
+
if (typeof context !== "string" || context === "") return undefined;
|
|
545
|
+
if (typeof state !== "string") return undefined;
|
|
546
|
+
// StatusContext semantics: `success` is green, `pending` pending, anything
|
|
547
|
+
// else (`failure`/`error`) is failed.
|
|
548
|
+
const verdict: CheckVerdict = state === "success" ? "green" : state === "pending" ? "pending" : "failed";
|
|
549
|
+
return { name: context, state, verdict };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Parse one raw REST page body of a check plane (`check_runs` or `statuses`)
|
|
554
|
+
* strictly. Any malformed view answers `{ ok: false }`, never a partial list:
|
|
555
|
+
* unparseable JSON, a top-level body that is not an object, a plane that is
|
|
556
|
+
* missing or not an array, or any member whose identity cannot be read. A
|
|
557
|
+
* garbage entry beside a green one must not let the plane read as green.
|
|
558
|
+
*/
|
|
559
|
+
function restPlaneFrom(
|
|
560
|
+
raw: string,
|
|
561
|
+
plane: "check_runs" | "statuses",
|
|
562
|
+
member: (row: { readonly [key: string]: unknown }) => RestCheckConclusion | undefined,
|
|
563
|
+
): RestCheckEvidence {
|
|
564
|
+
let parsed: unknown;
|
|
565
|
+
try {
|
|
566
|
+
parsed = JSON.parse(raw) as unknown;
|
|
567
|
+
} catch {
|
|
568
|
+
return { ok: false };
|
|
569
|
+
}
|
|
570
|
+
if (parsed === null || typeof parsed !== "object") return { ok: false };
|
|
571
|
+
const list = (parsed as { readonly [key: string]: unknown })[plane];
|
|
572
|
+
if (!Array.isArray(list)) return { ok: false };
|
|
573
|
+
const checks: RestCheckConclusion[] = [];
|
|
574
|
+
for (const entry of list) {
|
|
575
|
+
if (entry === null || typeof entry !== "object") return { ok: false };
|
|
576
|
+
const conclusion = member(entry as { readonly [key: string]: unknown });
|
|
577
|
+
if (conclusion === undefined) return { ok: false };
|
|
578
|
+
checks.push(conclusion);
|
|
579
|
+
}
|
|
580
|
+
return { ok: true, checks };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The projection-only verdict of a REST pull-request read (#653): state, draft
|
|
585
|
+
* and exact head, decided strictly from the one raw projection. These facts do
|
|
586
|
+
* not need the check endpoints, so a caller proves them as soon as the
|
|
587
|
+
* projection arrives — a stale head or a non-open PR answered here stays that
|
|
588
|
+
* refusal even when the check planes are independently degraded, and is never
|
|
589
|
+
* masked as unresolvable. A projection that cannot prove the head throws, so
|
|
590
|
+
* the caller maps it to `head-unresolvable` exactly as it does for the GraphQL
|
|
591
|
+
* parser's failures.
|
|
592
|
+
*/
|
|
593
|
+
function restProjectionVerdict(
|
|
594
|
+
prRaw: string,
|
|
595
|
+
expectedHead?: string,
|
|
596
|
+
opts?: { read?: boolean },
|
|
597
|
+
): { readonly ok: true; readonly headSha: string } | { readonly ok: false; readonly verdict: PrVerification } {
|
|
598
|
+
const headSha = restPrHeadFrom(prRaw);
|
|
599
|
+
if (headSha === undefined) throw new Error("PR head is unavailable");
|
|
600
|
+
const state = prStateFromRest(prRaw);
|
|
601
|
+
if (state !== "open") {
|
|
602
|
+
const stateName = state ?? "unknown";
|
|
603
|
+
if (opts?.read === true) {
|
|
604
|
+
return {
|
|
605
|
+
ok: false,
|
|
606
|
+
verdict: {
|
|
607
|
+
status: "failed",
|
|
608
|
+
reason: `PR is ${stateName}; checks are unavailable for a non-open PR`,
|
|
609
|
+
headSha,
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
ok: false,
|
|
615
|
+
verdict: { status: "failed", reason: `PR is ${stateName}, expected OPEN`, headSha },
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
if (restPrDraftFrom(prRaw) !== false) {
|
|
619
|
+
return {
|
|
620
|
+
ok: false,
|
|
621
|
+
verdict: { status: "failed", reason: "PR is draft or draft state is unknown", headSha },
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
if (expectedHead !== undefined && headSha.toLowerCase() !== expectedHead.toLowerCase()) {
|
|
625
|
+
return {
|
|
626
|
+
ok: false,
|
|
627
|
+
verdict: {
|
|
628
|
+
status: "failed",
|
|
629
|
+
reason: `PR head changed: expected ${expectedHead}, found ${headSha}`,
|
|
630
|
+
headSha,
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
return { ok: true, headSha };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* REST fallback for {@link prVerificationFrom} (#653): prove the same
|
|
639
|
+
* execution-time contract from the REST PR projection plus the check-runs and
|
|
640
|
+
* commit-status endpoints, when the primary GraphQL read cannot answer.
|
|
641
|
+
*
|
|
642
|
+
* The head is the REST projection's own `head.sha` — the same read that
|
|
643
|
+
* supplied state and draft — and the check evidence is pinned to that head via
|
|
644
|
+
* the commit endpoints, never to the caller's observation. Every page of both
|
|
645
|
+
* planes is flattened before the verdict is calculated: GitHub defaults each
|
|
646
|
+
* endpoint to 30 per page, and a non-green context past page one is exactly as
|
|
647
|
+
* decisive as one on page one. Missing check evidence is pending, never green.
|
|
648
|
+
* Throws on a payload that cannot prove the head or either check plane, so the
|
|
649
|
+
* caller maps it to `head-unresolvable` exactly as it does for the GraphQL
|
|
650
|
+
* parser.
|
|
651
|
+
*/
|
|
652
|
+
export function restVerificationFrom(
|
|
653
|
+
prRaw: string,
|
|
654
|
+
checkRunPages: string[],
|
|
655
|
+
statusPages: string[],
|
|
656
|
+
expectedHead?: string,
|
|
657
|
+
opts?: { read?: boolean },
|
|
658
|
+
): PrVerification {
|
|
659
|
+
const projection = restProjectionVerdict(prRaw, expectedHead, opts);
|
|
660
|
+
if (!projection.ok) return projection.verdict;
|
|
661
|
+
const headSha = projection.headSha;
|
|
662
|
+
|
|
663
|
+
const checks: RestCheckConclusion[] = [];
|
|
664
|
+
for (const page of checkRunPages) {
|
|
665
|
+
const evidence = restPlaneFrom(page, "check_runs", restRunConclusionFrom);
|
|
666
|
+
if (!evidence.ok) throw new Error("check-run evidence is unresolvable");
|
|
667
|
+
checks.push(...evidence.checks);
|
|
668
|
+
}
|
|
669
|
+
for (const page of statusPages) {
|
|
670
|
+
const evidence = restPlaneFrom(page, "statuses", restStatusConclusionFrom);
|
|
671
|
+
if (!evidence.ok) throw new Error("commit-status evidence is unresolvable");
|
|
672
|
+
checks.push(...evidence.checks);
|
|
673
|
+
}
|
|
674
|
+
if (checks.length === 0) {
|
|
675
|
+
return { status: "pending", reason: "GitHub has not reported any checks yet", headSha };
|
|
676
|
+
}
|
|
677
|
+
const failed = checks.filter((c) => c.verdict === "failed");
|
|
678
|
+
if (failed.length > 0) {
|
|
679
|
+
return {
|
|
680
|
+
status: "failed",
|
|
681
|
+
reason: `Checks failed: ${failed.map((c) => `${c.name} (${c.state})`).join(", ")}`,
|
|
682
|
+
headSha,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const pending = checks.filter((c) => c.verdict === "pending");
|
|
686
|
+
if (pending.length > 0) {
|
|
687
|
+
return {
|
|
688
|
+
status: "pending",
|
|
689
|
+
reason: `Checks pending: ${pending.map((c) => c.name).join(", ")}`,
|
|
690
|
+
headSha,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
return { status: "green", reason: `${checks.length} checks succeeded or were skipped`, headSha };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* The REST half of {@link Tracker.verifyPr} (#653): re-prove the execution-time
|
|
698
|
+
* exact-head and check verdict when the primary GraphQL read could not answer.
|
|
699
|
+
* State, draft and exact head are decided from the PR projection the moment it
|
|
700
|
+
* arrives, before any check-plane fetch — a refusal the projection already
|
|
701
|
+
* proves (a stale head, a non-open or draft PR) is returned as that refusal,
|
|
702
|
+
* never downgraded to unresolvable by an independently degraded check
|
|
703
|
+
* endpoint. Returns `undefined` only when the live head or the complete check
|
|
704
|
+
* evidence cannot be proven from REST, so a degraded verification port never
|
|
705
|
+
* weakens the merge gate — it only ever turns a would-be refusal into the same
|
|
706
|
+
* refusal reported as unresolvable.
|
|
707
|
+
*/
|
|
708
|
+
async function verifyPrRest(
|
|
709
|
+
runGh: typeof gh,
|
|
710
|
+
cache: RestListCache,
|
|
711
|
+
url: string,
|
|
712
|
+
expectedHead?: string,
|
|
713
|
+
opts?: { read?: boolean },
|
|
714
|
+
onNotModified?: () => void,
|
|
715
|
+
): Promise<PrVerification | undefined> {
|
|
716
|
+
const parts = prUrlParts(url);
|
|
717
|
+
if (parts === undefined) return undefined;
|
|
718
|
+
const base = `repos/${parts.owner}/${parts.repo}`;
|
|
719
|
+
|
|
720
|
+
let prRaw: string;
|
|
721
|
+
let headSha: string;
|
|
722
|
+
try {
|
|
723
|
+
prRaw = await runGh([
|
|
724
|
+
"api",
|
|
725
|
+
`${base}/pulls/${parts.number}`,
|
|
726
|
+
"--jq",
|
|
727
|
+
"{state, draft, head_sha: .head.sha, merged_at}",
|
|
728
|
+
]);
|
|
729
|
+
// The projection alone proves state, draft and exact head, so those
|
|
730
|
+
// verdicts are returned before any check evidence is fetched: a stale
|
|
731
|
+
// head proven here is reported as such even when the check endpoints are
|
|
732
|
+
// independently degraded — the check planes must not be able to mask an
|
|
733
|
+
// already-proven stale head as unresolvable.
|
|
734
|
+
const projection = restProjectionVerdict(prRaw, expectedHead, opts);
|
|
735
|
+
if (!projection.ok) return projection.verdict;
|
|
736
|
+
headSha = projection.headSha;
|
|
737
|
+
} catch {
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
let checkRunPages: string[];
|
|
742
|
+
let statusPages: string[];
|
|
743
|
+
try {
|
|
744
|
+
// Every page of both planes is fetched through the tracked REST funnel
|
|
745
|
+
// (breakers, rate-limit ledger, conditional revalidation) and flattened
|
|
746
|
+
// before the verdict runs: each endpoint defaults to 30 per page, so a PR
|
|
747
|
+
// with more checks than that would otherwise let a non-green context hide
|
|
748
|
+
// past page one. The evidence is pinned to the head proven by the PR
|
|
749
|
+
// projection — never to the caller's observation, which could be stale.
|
|
750
|
+
checkRunPages = await conditionalListPages(
|
|
751
|
+
runGh,
|
|
752
|
+
cache,
|
|
753
|
+
`${base}/commits/${headSha}/check-runs?per_page=100`,
|
|
754
|
+
/* paginate */ true,
|
|
755
|
+
onNotModified,
|
|
756
|
+
);
|
|
757
|
+
statusPages = await conditionalListPages(
|
|
758
|
+
runGh,
|
|
759
|
+
cache,
|
|
760
|
+
`${base}/commits/${headSha}/status?per_page=100`,
|
|
761
|
+
/* paginate */ true,
|
|
762
|
+
onNotModified,
|
|
763
|
+
);
|
|
764
|
+
} catch {
|
|
765
|
+
return undefined;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
return restVerificationFrom(prRaw, checkRunPages, statusPages, expectedHead, opts);
|
|
770
|
+
} catch {
|
|
771
|
+
return undefined;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
464
775
|
function runLogArgs(detailsUrl: string): string[] | undefined {
|
|
465
776
|
const match =
|
|
466
777
|
/^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/.exec(
|
|
@@ -972,6 +1283,103 @@ export function isRateLimitRefusal(err: unknown): boolean {
|
|
|
972
1283
|
);
|
|
973
1284
|
}
|
|
974
1285
|
|
|
1286
|
+
/**
|
|
1287
|
+
* True when a `gh` stderr text carries a transient GitHub server-side 5xx —
|
|
1288
|
+
* the API answering "try later" rather than judging the request. gh's stderr
|
|
1289
|
+
* carries the status in two spellings: the REST `HTTP 500: Internal Server
|
|
1290
|
+
* Error` style, and the GraphQL `No server is currently available to service
|
|
1291
|
+
* your request. (HTTP 503)` recorded in #642. Both mean "stop asking for a
|
|
1292
|
+
* while", and both open the tracker's bounded GraphQL breaker so admission
|
|
1293
|
+
* does not multiply one provider outage into O(queue size) spawns per pass.
|
|
1294
|
+
*
|
|
1295
|
+
* Deliberately narrower than "anything nonzero": a 4xx answers the request
|
|
1296
|
+
* (bad credentials, not found, an exhausted-but-named budget), and a GraphQL
|
|
1297
|
+
* product error carries no HTTP status at all — neither is a provider outage,
|
|
1298
|
+
* and neither may masquerade as the breaker event that holds every candidate.
|
|
1299
|
+
* The status must be a complete three-digit 5xx token (`HTTP 503)`, `HTTP 500:`):
|
|
1300
|
+
* a malformed suffix like `HTTP 5030` is an invalid response, not a provider
|
|
1301
|
+
* outage, so it stays a plain failure instead of opening the breaker.
|
|
1302
|
+
*/
|
|
1303
|
+
export function isTransientServer5xx(stderr: string): boolean {
|
|
1304
|
+
return /HTTP 5\d\d\b/i.test(stderr);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
export function isTransientServerError(err: unknown): boolean {
|
|
1308
|
+
return err instanceof GhError && isTransientServer5xx(err.stderr);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* True when a `gh` argv rides the GraphQL API surface rather than core REST.
|
|
1313
|
+
*
|
|
1314
|
+
* `gh api graphql` is the raw surface. The named subcommands ride GraphQL
|
|
1315
|
+
* mutations in the gh version this fleet pins (2.86): `issue edit`
|
|
1316
|
+
* (`addLabelsToLabelable` / `removeLabelsFromLabelable` / `updateIssue`),
|
|
1317
|
+
* `issue comment` (`addComment`), `issue close` (`closeIssue`), `pr create`
|
|
1318
|
+
* (`createPullRequest`), `pr edit` (`updatePullRequest`), `pr merge`
|
|
1319
|
+
* (`mergePullRequest`) and `pr update-branch` (`updatePullRequestBranch`).
|
|
1320
|
+
* gh's transports move between versions, which is why this list is pinned and
|
|
1321
|
+
* tested rather than guessed: a subcommand that stops riding GraphQL only
|
|
1322
|
+
* stops being gated — it never stops working.
|
|
1323
|
+
*
|
|
1324
|
+
* Everything else the tracker and the verb actions shell — `gh api
|
|
1325
|
+
* repos/...`, `gh pr diff`, `gh release create` — is core REST and must keep
|
|
1326
|
+
* running while the GraphQL breaker is open: #642 measured REST healthy
|
|
1327
|
+
* through the whole outage, and the ready-queue read and settle sweeps run on
|
|
1328
|
+
* it.
|
|
1329
|
+
*/
|
|
1330
|
+
export function isGraphqlSurface(argv: readonly string[]): boolean {
|
|
1331
|
+
if (argv[0] === "api") return argv[1] === "graphql";
|
|
1332
|
+
if (argv[0] === "issue") {
|
|
1333
|
+
return argv[1] === "edit" || argv[1] === "comment" || argv[1] === "close";
|
|
1334
|
+
}
|
|
1335
|
+
if (argv[0] === "pr") {
|
|
1336
|
+
return argv[1] === "create" || argv[1] === "edit" || argv[1] === "merge" || argv[1] === "update-branch";
|
|
1337
|
+
}
|
|
1338
|
+
return false;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* The process-local transient-server-error breaker for the GraphQL surface
|
|
1343
|
+
* (#642). One instance is shared by the tracker and the verb actions of a
|
|
1344
|
+
* project, so a 503 observed by either side gates both: admission's
|
|
1345
|
+
* `parentOf` / `openCloserFor` checks AND the orchestrator mutation commands
|
|
1346
|
+
* that ride GraphQL in gh 2.86 (`gh issue edit`, `gh pr merge`,
|
|
1347
|
+
* `gh pr update-branch`, ...) fast-fail instead of each spawning `gh` into
|
|
1348
|
+
* the same unavailable surface.
|
|
1349
|
+
*
|
|
1350
|
+
* Deliberately separate from the rate-limit breaker: a 5xx is a provider
|
|
1351
|
+
* outage, not budget exhaustion, and must not fire `onRefusal` or touch the
|
|
1352
|
+
* `gh_refusals` ledger. The cooldown is the same fixed window, because gh's
|
|
1353
|
+
* stderr carries no `Retry-After` for either class.
|
|
1354
|
+
*/
|
|
1355
|
+
export class GraphqlBreaker {
|
|
1356
|
+
private refusedUntil = 0;
|
|
1357
|
+
|
|
1358
|
+
constructor(private readonly cooldownMs: number = RATE_LIMIT_COOLDOWN_MS) {}
|
|
1359
|
+
|
|
1360
|
+
/** Record a transient 5xx: every GraphQL-surface call fast-fails until the
|
|
1361
|
+
* bounded cooldown elapses. */
|
|
1362
|
+
open(at = Date.now()): void {
|
|
1363
|
+
this.refusedUntil = at + this.cooldownMs;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/** Whether a GraphQL-surface call must fast-fail right now. */
|
|
1367
|
+
refused(at = Date.now()): boolean {
|
|
1368
|
+
return at < this.refusedUntil;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/** Wall-clock ms after which a real call may be attempted again; 0 = closed. */
|
|
1372
|
+
get until(): number {
|
|
1373
|
+
return this.refusedUntil;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** The fast-fail wording both the tracker's throw and the verb actions'
|
|
1378
|
+
* failed result carry, so a refusal reads the same from either side. */
|
|
1379
|
+
export function graphqlBreakerRefusal(untilMs: number): string {
|
|
1380
|
+
return `circuit breaker open (a prior GitHub server error holds the GraphQL cooldown until ${new Date(untilMs).toISOString()})`;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
975
1383
|
/**
|
|
976
1384
|
* A rate-limit refusal rethrown by {@link makeTracker}. This is the one
|
|
977
1385
|
* failure class the tracker names instead of swallowing: the circuit breaker
|
|
@@ -990,7 +1398,9 @@ export class GhRateLimitError extends GhError {
|
|
|
990
1398
|
}
|
|
991
1399
|
}
|
|
992
1400
|
|
|
993
|
-
/** How long a rate-limit refusal
|
|
1401
|
+
/** How long a rate-limit refusal or a transient server 5xx holds the tracker's
|
|
1402
|
+
* circuit breaker open. One window bounds both breakers: the all-call gate a
|
|
1403
|
+
* rate-limit refusal opens and the GraphQL-only gate a 5xx opens. */
|
|
994
1404
|
export const RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
995
1405
|
|
|
996
1406
|
/** Instrumentation hooks the daemon binds the tracker to (#198). */
|
|
@@ -998,7 +1408,10 @@ export interface TrackerHooks {
|
|
|
998
1408
|
/** Fired immediately before each `gh` spawn, so the daemon can count its own
|
|
999
1409
|
* API spend. Not fired for a breaker fast-fail, which spawns nothing. */
|
|
1000
1410
|
onCall?: () => void;
|
|
1001
|
-
/** Fired once per observed refusal, with the wall-clock moment.
|
|
1411
|
+
/** Fired once per observed rate-limit refusal, with the wall-clock moment.
|
|
1412
|
+
* A transient server 5xx opens the GraphQL breaker but is not a rate-limit
|
|
1413
|
+
* refusal and does not fire this hook (#642): the `gh_refusals` ledger and
|
|
1414
|
+
* the rate-limit decisions that read it stay about rate limits. */
|
|
1002
1415
|
onRefusal?: (at: number) => void;
|
|
1003
1416
|
/** Fired once per list page GitHub answered 304 for — a spawn that cost no
|
|
1004
1417
|
* primary-rate-limit budget. Lets the daemon's call counter tell billed
|
|
@@ -1007,8 +1420,13 @@ export interface TrackerHooks {
|
|
|
1007
1420
|
}
|
|
1008
1421
|
|
|
1009
1422
|
interface TrackerOpts {
|
|
1010
|
-
/** Override {@link RATE_LIMIT_COOLDOWN_MS}
|
|
1423
|
+
/** Override {@link RATE_LIMIT_COOLDOWN_MS}, the shared window for both
|
|
1424
|
+
* breakers (tests use a 0ms window). */
|
|
1011
1425
|
rateLimitCooldownMs?: number;
|
|
1426
|
+
/** The project's shared transient-server-error breaker (#642). Absent, the
|
|
1427
|
+
* tracker owns a fresh one; the daemon passes the same instance to the
|
|
1428
|
+
* verb actions so a 503 observed by either side gates both. */
|
|
1429
|
+
graphqlBreaker?: GraphqlBreaker;
|
|
1012
1430
|
/** Conditional-request cache for REST list reads; defaults to the shared
|
|
1013
1431
|
* module-level cache. Tests pass a fresh Map. */
|
|
1014
1432
|
listCache?: RestListCache;
|
|
@@ -1024,15 +1442,39 @@ export function makeTracker(
|
|
|
1024
1442
|
const refuseForMs = opts.rateLimitCooldownMs ?? RATE_LIMIT_COOLDOWN_MS;
|
|
1025
1443
|
const cache = opts.listCache ?? sharedRestListCache;
|
|
1026
1444
|
|
|
1027
|
-
// The single funnel around the injected `gh
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1445
|
+
// The single funnel around the injected `gh`, with two bounded breakers:
|
|
1446
|
+
//
|
|
1447
|
+
// - A rate-limit refusal is account-wide — every budget the token can spend
|
|
1448
|
+
// (REST and GraphQL) is exhausted or throttled — so it opens a breaker
|
|
1449
|
+
// that fast-fails EVERY call.
|
|
1450
|
+
// - A transient server 5xx is surface-specific. #642 measured a GraphQL 503
|
|
1451
|
+
// that admission then re-asked once per candidate (`parentOf` /
|
|
1452
|
+
// `openCloserFor`), multiplying one outage into O(queue size) spawns,
|
|
1453
|
+
// TLS handshakes, failures and latency per pass while REST stayed
|
|
1454
|
+
// healthy. It opens a breaker that fast-fails GraphQL calls only — every
|
|
1455
|
+
// GraphQL-surface call, tracker reads AND the mutation commands the
|
|
1456
|
+
// orchestrator verbs shell (`gh issue edit`, `gh pr merge`, ...), because
|
|
1457
|
+
// both ride the same broken surface — so the ready-queue read and the
|
|
1458
|
+
// settle sweeps keep running and the pass still degrades into holds
|
|
1459
|
+
// instead of dying.
|
|
1460
|
+
//
|
|
1461
|
+
// The GraphQL breaker is shared with the verb actions through the daemon
|
|
1462
|
+
// (#642 comment 4): a 503 observed while serving `conductor_pr_update_branch`
|
|
1463
|
+
// must gate the next admission pass too, and vice versa — one provider
|
|
1464
|
+
// outage must not be re-asked by every candidate AND every mutation.
|
|
1465
|
+
//
|
|
1466
|
+
// While a breaker is open every gated call fails fast WITHOUT spawning gh —
|
|
1467
|
+
// a burst under a persistent outage degrades the tick into holds
|
|
1468
|
+
// (`open-pr-lookup-error` / `parent-lookup-error` fail closed per candidate
|
|
1469
|
+
// and retry next tick) instead of an error storm or a stalled loop. No
|
|
1470
|
+
// in-wrapper sleeps. A 5xx is not a rate-limit refusal: it never fires
|
|
1471
|
+
// `onRefusal`, so the `gh_refusals` ledger and the rate-limit decisions
|
|
1472
|
+
// that read it are untouched by a provider outage.
|
|
1473
|
+
const graphqlBreaker = opts.graphqlBreaker ?? new GraphqlBreaker(refuseForMs);
|
|
1033
1474
|
let refusedUntil = 0;
|
|
1034
1475
|
const runGh = async (argv: string[], stdin?: string): Promise<string> => {
|
|
1035
1476
|
const now = Date.now();
|
|
1477
|
+
const graphql = isGraphqlSurface(argv);
|
|
1036
1478
|
if (now < refusedUntil) {
|
|
1037
1479
|
throw new GhRateLimitError(
|
|
1038
1480
|
argv,
|
|
@@ -1041,6 +1483,9 @@ export function makeTracker(
|
|
|
1041
1483
|
refusedUntil,
|
|
1042
1484
|
);
|
|
1043
1485
|
}
|
|
1486
|
+
if (graphql && graphqlBreaker.refused(now)) {
|
|
1487
|
+
throw new GhError(argv, 0, graphqlBreakerRefusal(graphqlBreaker.until));
|
|
1488
|
+
}
|
|
1044
1489
|
hooks.onCall?.();
|
|
1045
1490
|
try {
|
|
1046
1491
|
return await injectedRunGh(argv, stdin);
|
|
@@ -1058,6 +1503,14 @@ export function makeTracker(
|
|
|
1058
1503
|
cause?.stdout ?? "",
|
|
1059
1504
|
);
|
|
1060
1505
|
}
|
|
1506
|
+
// A transient 5xx on the GraphQL surface proves THAT surface unavailable
|
|
1507
|
+
// for this pass: the next candidate's parent/closer checks AND the next
|
|
1508
|
+
// GraphQL-backed mutation must fail fast instead of re-asking it. The
|
|
1509
|
+
// original error is rethrown unchanged, so the failing candidate holds
|
|
1510
|
+
// on the true provider message.
|
|
1511
|
+
if (graphql && isTransientServerError(err)) {
|
|
1512
|
+
graphqlBreaker.open();
|
|
1513
|
+
}
|
|
1061
1514
|
throw err;
|
|
1062
1515
|
}
|
|
1063
1516
|
};
|
|
@@ -1307,6 +1760,98 @@ export function makeTracker(
|
|
|
1307
1760
|
}
|
|
1308
1761
|
},
|
|
1309
1762
|
|
|
1763
|
+
async runFailedAttemptLogs(
|
|
1764
|
+
repo: string,
|
|
1765
|
+
runUrl: string,
|
|
1766
|
+
maxAttempts: number,
|
|
1767
|
+
): Promise<string[] | undefined> {
|
|
1768
|
+
// The attempt-blind Tracker surface cannot reach a failed job that a
|
|
1769
|
+
// rerun-to-green left behind; the attempt-scoped jobs register still
|
|
1770
|
+
// names it. Routed through this adapter's guarded `runGh`, so call/
|
|
1771
|
+
// refusal accounting and the rate-limit breaker apply to the historical
|
|
1772
|
+
// reconciliation's reads exactly as they do to a live tick's (#638).
|
|
1773
|
+
const match =
|
|
1774
|
+
/^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/actions\/runs\/(\d+)(?:\/job\/\d+)?/.exec(
|
|
1775
|
+
runUrl,
|
|
1776
|
+
);
|
|
1777
|
+
const runId = match?.[2];
|
|
1778
|
+
if (runId === undefined || !/^[^/\s]+\/[^/\s]+$/.test(repo)) return [];
|
|
1779
|
+
let latest: number;
|
|
1780
|
+
try {
|
|
1781
|
+
const raw = await runGh([
|
|
1782
|
+
"api",
|
|
1783
|
+
`repos/${repo}/actions/runs/${runId}`,
|
|
1784
|
+
"--jq",
|
|
1785
|
+
".run_attempt",
|
|
1786
|
+
]);
|
|
1787
|
+
latest = Number.parseInt(raw.trim(), 10);
|
|
1788
|
+
} catch {
|
|
1789
|
+
// The run register could not be read: a no-mutation refusal the next
|
|
1790
|
+
// pass asks again, not a determinately-clean answer.
|
|
1791
|
+
return undefined;
|
|
1792
|
+
}
|
|
1793
|
+
if (!Number.isInteger(latest) || latest <= 0) return undefined;
|
|
1794
|
+
// Refuse rather than classify a prefix as the complete attempt history:
|
|
1795
|
+
// infra evidence in attempts 1..N-1 must not waive a row whose later
|
|
1796
|
+
// attempt carries the real compile/test failure (review #654).
|
|
1797
|
+
if (latest > maxAttempts) return undefined;
|
|
1798
|
+
const chunks: string[] = [];
|
|
1799
|
+
for (let attempt = 1; attempt <= latest; attempt++) {
|
|
1800
|
+
// One chunk per failed job, not one aggregate per attempt: a setup
|
|
1801
|
+
// job's codeload 429 in the same attempt as a sibling matrix job's
|
|
1802
|
+
// compile error must surface as two chunks so the caller's all-infra
|
|
1803
|
+
// guard sees the mixed failure (review #654). Each chunk is the
|
|
1804
|
+
// failed steps of that one job at that attempt — the exact surface
|
|
1805
|
+
// the forward classifier's `checkLog` reads (`--log-failed`), so a
|
|
1806
|
+
// repaired row and a fresh row share one definition of "the log
|
|
1807
|
+
// proves infra". Full and untruncated: a truncated last-400-lines
|
|
1808
|
+
// view can discard contrary failure evidence.
|
|
1809
|
+
let failedJobIds: number[];
|
|
1810
|
+
try {
|
|
1811
|
+
const raw = await runGh([
|
|
1812
|
+
"api",
|
|
1813
|
+
`repos/${repo}/actions/runs/${runId}/attempts/${attempt}/jobs`,
|
|
1814
|
+
"--jq",
|
|
1815
|
+
'[.jobs[] | select(.conclusion == "action_required" or .conclusion == "failure" or .conclusion == "startup_failure" or .conclusion == "timed_out") | .id]',
|
|
1816
|
+
]);
|
|
1817
|
+
failedJobIds = JSON.parse(raw) as number[];
|
|
1818
|
+
} catch {
|
|
1819
|
+
// The attempt's job register could not be read: undecided, never a
|
|
1820
|
+
// clean answer assembled from whatever earlier attempts yielded.
|
|
1821
|
+
return undefined;
|
|
1822
|
+
}
|
|
1823
|
+
for (const jobId of failedJobIds) {
|
|
1824
|
+
try {
|
|
1825
|
+
const raw = await runGh([
|
|
1826
|
+
"run",
|
|
1827
|
+
"view",
|
|
1828
|
+
runId,
|
|
1829
|
+
"--repo",
|
|
1830
|
+
repo,
|
|
1831
|
+
"--job",
|
|
1832
|
+
String(jobId),
|
|
1833
|
+
"--attempt",
|
|
1834
|
+
String(attempt),
|
|
1835
|
+
"--log-failed",
|
|
1836
|
+
]);
|
|
1837
|
+
const cleaned = raw.replaceAll(/\u001b\[[0-9;]*m/g, "");
|
|
1838
|
+
// Push even an empty chunk: a failed job whose failed steps carry
|
|
1839
|
+
// no signature is determinately not infrastructure, and omitting
|
|
1840
|
+
// it would let a sibling 429 chunk waive the row on partial
|
|
1841
|
+
// evidence.
|
|
1842
|
+
chunks.push(cleaned);
|
|
1843
|
+
} catch {
|
|
1844
|
+
// Any per-attempt log read failure is a no-mutation refusal: the
|
|
1845
|
+
// earlier chunks are not the complete evidence set, and returning
|
|
1846
|
+
// them would let a later compile/test failure go unseen (review
|
|
1847
|
+
// #654).
|
|
1848
|
+
return undefined;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
return chunks;
|
|
1853
|
+
},
|
|
1854
|
+
|
|
1310
1855
|
async previousWorkflowRun(
|
|
1311
1856
|
repo: string,
|
|
1312
1857
|
workflowId: number,
|
|
@@ -1370,9 +1915,13 @@ export function makeTracker(
|
|
|
1370
1915
|
return verification;
|
|
1371
1916
|
}
|
|
1372
1917
|
} catch {
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
1375
|
-
|
|
1918
|
+
// The primary read is GraphQL (`gh pr view ... statusCheckRollup`).
|
|
1919
|
+
// When that plane is degraded (#653), the verification port is not
|
|
1920
|
+
// single-transport: re-prove the exact head and the complete check
|
|
1921
|
+
// verdict from REST, or report the head unresolvable. A REST read that
|
|
1922
|
+
// cannot prove green never weakens the contract — it only replaces one
|
|
1923
|
+
// refusal with the same refusal reported as unresolvable.
|
|
1924
|
+
return verifyPrRest(runGh, cache, url, expectedHead, opts, hooks.onNotModified);
|
|
1376
1925
|
}
|
|
1377
1926
|
},
|
|
1378
1927
|
|