gh-glance 0.11.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/CHANGELOG.md +67 -1
- package/README.md +36 -13
- package/index.mjs +1592 -232
- package/package.json +2 -2
package/index.mjs
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
process.env.NODE_ENV ??= "production";
|
|
24
24
|
|
|
25
25
|
import { execFile, spawn } from "node:child_process";
|
|
26
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
26
27
|
import { createHash, randomUUID } from "node:crypto";
|
|
27
28
|
import {
|
|
28
29
|
chmodSync,
|
|
@@ -43,6 +44,7 @@ import { fileURLToPath } from "node:url";
|
|
|
43
44
|
import { promisify } from "node:util";
|
|
44
45
|
|
|
45
46
|
const execFileAsync = promisify(execFile);
|
|
47
|
+
const requestIdentityStorage = new AsyncLocalStorage();
|
|
46
48
|
|
|
47
49
|
// Running as the CLI vs. being imported by a test. Everything with a side
|
|
48
50
|
// effect -- argv handling, the TTY guard, the preflight, entering the alternate
|
|
@@ -616,6 +618,7 @@ function registerLiveAbort(controller) {
|
|
|
616
618
|
liveAbort = controller;
|
|
617
619
|
}
|
|
618
620
|
function abortLiveRequests() {
|
|
621
|
+
runtimeIdentityCoordinator?.close();
|
|
619
622
|
liveAbort?.abort();
|
|
620
623
|
liveAbort = null;
|
|
621
624
|
}
|
|
@@ -627,24 +630,133 @@ function forwardSignalToChild(child, signal) {
|
|
|
627
630
|
return child.kill(signal);
|
|
628
631
|
}
|
|
629
632
|
|
|
630
|
-
|
|
633
|
+
function inspectAdmittedHttpStart(scope, operation, now = Date.now) {
|
|
634
|
+
const costs = operationCost(operation);
|
|
635
|
+
if (!costs) return { ok: false, reason: "undeclared" };
|
|
636
|
+
if (RATE_RESOURCES.every((resource) => costs[resource] === 0)) return { ok: true };
|
|
637
|
+
const readNow = typeof now === "function" ? now : () => now;
|
|
638
|
+
const snapshot = inspectGovernor(scope, readNow());
|
|
639
|
+
if (!snapshot.ok || snapshot.missing) return snapshot.ok ? { ok: false, reason: "corrupt" } : snapshot;
|
|
640
|
+
const checkedAt = readNow();
|
|
641
|
+
for (const resource of RATE_RESOURCES) {
|
|
642
|
+
if (costs[resource] <= 0) continue;
|
|
643
|
+
const budget = snapshot.value.budgets[resource];
|
|
644
|
+
if (!budget) return { ok: false, reason: "budget-unknown", resource };
|
|
645
|
+
if (budget.blockUntil > checkedAt) return { ok: false, reason: "blocked", resource, retryAt: budget.blockUntil };
|
|
646
|
+
if (budget.resetMs <= checkedAt) return { ok: false, reason: "budget-reset", resource, retryAt: budget.resetMs + BUDGET_RESET_GRACE_MS };
|
|
647
|
+
}
|
|
648
|
+
return { ok: true };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Re-checked either side of every wait a request can sit in, because admission,
|
|
652
|
+
// the transport permit and the subprocess boundary are each far enough apart
|
|
653
|
+
// that the account can change in between. One definition so the three checks
|
|
654
|
+
// cannot drift, and so a request costs one identity snapshot instead of three.
|
|
655
|
+
function assertBoundCredential(bound) {
|
|
656
|
+
if (bound?.accessKey && bound.accessKey !== runtimeIdentityCoordinator?.current()?.accessKey) {
|
|
657
|
+
throw new Error("Credential changed before request start");
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// GraphQL documents are supplied on stdin rather than as an argument, so a
|
|
662
|
+
// query never lands in argv where `ps` or an error string could carry it, and
|
|
663
|
+
// so its size is bounded by a pipe rather than by the platform argument limit.
|
|
664
|
+
// promisify(execFile) exposes the child, which is what lets stdin be written
|
|
665
|
+
// without giving up the timeout, kill signal, maxBuffer, abort and redaction
|
|
666
|
+
// contracts every other call depends on.
|
|
667
|
+
// The control plane cannot be gated on the capacity it exists to establish.
|
|
668
|
+
// Both observers are exempt from the data-admission recheck for that reason:
|
|
669
|
+
// a resource whose budget is unknown would otherwise refuse the one request
|
|
670
|
+
// that could learn it, and the tab would wait for a number nothing can fetch.
|
|
671
|
+
// They remain bounded elsewhere -- by the rolling attempt allowance, the shared
|
|
672
|
+
// transport permit and the secondary cooldown -- so this is an exemption from
|
|
673
|
+
// data admission, not from accounting.
|
|
674
|
+
const CONTROL_OPERATIONS = ["budget-core-observer", "graphql-observer"];
|
|
675
|
+
|
|
676
|
+
// An operation whose actual cost exceeded its declared bound is paused until its
|
|
677
|
+
// resource resets. Continuing to spend against a bound already known to be wrong
|
|
678
|
+
// is how a reserve gets crossed with every individual request looking admissible.
|
|
679
|
+
// Process-local on purpose: the bound is a property of this build's query text,
|
|
680
|
+
// so a differently-built pane's bound is not this one's to suspend.
|
|
681
|
+
const pausedOperations = new Map();
|
|
682
|
+
|
|
683
|
+
function pauseOperation(operation, untilMs) {
|
|
684
|
+
if (!Number.isFinite(untilMs)) return;
|
|
685
|
+
pausedOperations.set(operation, Math.max(pausedOperations.get(operation) ?? 0, untilMs));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function operationPausedUntil(operation, now = Date.now()) {
|
|
689
|
+
const until = pausedOperations.get(operation);
|
|
690
|
+
if (until === undefined) return null;
|
|
691
|
+
if (until <= now) { pausedOperations.delete(operation); return null; }
|
|
692
|
+
return until;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function runGh(args, { signal, operation, input = null } = {}) {
|
|
631
696
|
if (operationCost(operation) === null) {
|
|
632
697
|
throw new Error(`undeclared gh operation: ${operation ?? "missing"}`);
|
|
633
698
|
}
|
|
699
|
+
const bound = requestIdentityStorage.getStore();
|
|
700
|
+
assertBoundCredential(bound);
|
|
701
|
+
const local = ["version", "local-git"].includes(operation);
|
|
702
|
+
let control = null;
|
|
703
|
+
const permit = runtimeIdentityCoordinator && !local
|
|
704
|
+
? await acquireIdentityHttpPermit(runtimeIdentityCoordinator, { signal }) : null;
|
|
634
705
|
const startedAt = Date.now();
|
|
706
|
+
let requestError = null;
|
|
707
|
+
let requestStdout = null;
|
|
635
708
|
try {
|
|
636
|
-
|
|
709
|
+
assertBoundCredential(bound);
|
|
710
|
+
if (runtimeIdentityCoordinator && operation === "budget-core-observer") {
|
|
711
|
+
control = startIdentityControl(runtimeIdentityCoordinator);
|
|
712
|
+
if (!control.ok) throw new Error(identityCoordinationMessage(control.reason));
|
|
713
|
+
}
|
|
714
|
+
// Admission can precede this per-call transport slot by many seconds.
|
|
715
|
+
// A sibling request may publish a shared hold while this call is queued.
|
|
716
|
+
// Keep the started envelope charged, but recheck resource validity at the
|
|
717
|
+
// actual subprocess boundary without reserving the same work again.
|
|
718
|
+
const pausedUntil = operationPausedUntil(operation);
|
|
719
|
+
if (pausedUntil !== null) {
|
|
720
|
+
throw new Error(`Operation paused after an unbounded cost (retry after ${new Date(pausedUntil).toISOString()})`);
|
|
721
|
+
}
|
|
722
|
+
if (bound && !local && !CONTROL_OPERATIONS.includes(operation)) {
|
|
723
|
+
const ready = inspectAdmittedHttpStart(bound, operation);
|
|
724
|
+
if (!ready.ok) throw new Error(`API budget paused (${ready.reason})`);
|
|
725
|
+
}
|
|
726
|
+
assertBoundCredential(bound);
|
|
727
|
+
const pending = execFileAsync("gh", args, {
|
|
637
728
|
timeout: GH_TIMEOUT_MS,
|
|
638
729
|
killSignal: "SIGKILL",
|
|
639
730
|
maxBuffer: GH_MAX_BUFFER,
|
|
640
731
|
env: { ...process.env, ...GH_ENV_OVERRIDES },
|
|
641
732
|
signal,
|
|
642
733
|
});
|
|
734
|
+
if (typeof input === "string") {
|
|
735
|
+
// A child killed by the timeout or the abort signal closes stdin under
|
|
736
|
+
// us; that EPIPE is the kill's consequence, not the failure worth
|
|
737
|
+
// reporting, so let the awaited result below carry the real error.
|
|
738
|
+
pending.child.stdin?.on("error", () => {});
|
|
739
|
+
pending.child.stdin?.end(input);
|
|
740
|
+
}
|
|
741
|
+
const { stdout } = await pending;
|
|
742
|
+
requestStdout = stdout;
|
|
643
743
|
logGh(args, startedAt, `ok ${stdout.length}B`);
|
|
644
744
|
return stdout;
|
|
645
745
|
} catch (err) {
|
|
746
|
+
requestError = err;
|
|
646
747
|
logGh(args, startedAt, `FAILED ${shortErr(err)}`);
|
|
647
748
|
throw err;
|
|
749
|
+
} finally {
|
|
750
|
+
if (permit) {
|
|
751
|
+
const release = () => releaseIdentityHttpPermit(runtimeIdentityCoordinator, permit, requestError);
|
|
752
|
+
const released = await retryIdentityCompletion(release, { timeoutMs: IDENTITY_RELEASE_WAIT_MS, shouldRetry: () => !runtimeIdentityCoordinator.isClosed() });
|
|
753
|
+
if (!released.ok) runtimeIdentityCoordinator.deferCompletion(`permit:${permit.nonce}`, release);
|
|
754
|
+
}
|
|
755
|
+
if (control?.ok) {
|
|
756
|
+
const settle = () => settleIdentityControl(runtimeIdentityCoordinator, control.value, requestStdout ?? requestError?.stdout);
|
|
757
|
+
const settled = await retryIdentityCompletion(settle, { timeoutMs: IDENTITY_RELEASE_WAIT_MS, shouldRetry: () => !runtimeIdentityCoordinator.isClosed() });
|
|
758
|
+
if (!settled.ok) runtimeIdentityCoordinator.deferCompletion(`control:${control.value.id}`, settle);
|
|
759
|
+
}
|
|
648
760
|
}
|
|
649
761
|
}
|
|
650
762
|
|
|
@@ -734,14 +846,141 @@ async function ghApi(args, { operation, signal, etag = null, run = runGh } = {})
|
|
|
734
846
|
}
|
|
735
847
|
}
|
|
736
848
|
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
//
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
849
|
+
// ---------- Explicit GraphQL ----------
|
|
850
|
+
|
|
851
|
+
// The version travels in the query key, so a changed document invalidates its
|
|
852
|
+
// cursors instead of paging a new shape with an old cursor.
|
|
853
|
+
const GRAPHQL_QUERY_VERSION = 1;
|
|
854
|
+
// GitHub prices a connection by the pages it could return, and the row's single
|
|
855
|
+
// label is a nested connection of its own. Two points is the conservative bound
|
|
856
|
+
// for one page; the observer selects nothing but the meter and costs one.
|
|
857
|
+
const GRAPHQL_PAGE_POINTS = 2;
|
|
858
|
+
const GRAPHQL_OBSERVER_POINTS = 1;
|
|
859
|
+
// 50 rows per page and no more than three pages preserves the existing
|
|
860
|
+
// LIST_LIMIT of 150 while making every page a separately admitted request.
|
|
861
|
+
const GRAPHQL_PAGE_SIZE = 50;
|
|
862
|
+
// Two hours: comfortably past any real primary window, and short enough that a
|
|
863
|
+
// nonsense timestamp cannot park the budget in a reset that never arrives.
|
|
864
|
+
const GRAPHQL_MAX_RESET_MS = 2 * 60 * 60 * 1000;
|
|
865
|
+
|
|
866
|
+
// Issues and pull requests are deliberately two documents, not one with two
|
|
867
|
+
// connections: a combined query cannot publish either tab until both halves
|
|
868
|
+
// are complete, which is exactly the completion barrier this phase removes.
|
|
869
|
+
// Both select only what a row renders, which is also what keeps the cost at
|
|
870
|
+
// its declared bound.
|
|
871
|
+
const ISSUE_PAGE_QUERY = `query($owner:String!,$name:String!,$first:Int!,$after:String){
|
|
872
|
+
repository(owner:$owner,name:$name){
|
|
873
|
+
id
|
|
874
|
+
name
|
|
875
|
+
nameWithOwner
|
|
876
|
+
url
|
|
877
|
+
issues(first:$first,after:$after,states:OPEN,orderBy:{field:UPDATED_AT,direction:DESC}){
|
|
878
|
+
totalCount
|
|
879
|
+
pageInfo{hasNextPage endCursor}
|
|
880
|
+
nodes{id number title url updatedAt author{login} labels(first:1){nodes{name}}}
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
rateLimit{cost limit used remaining resetAt}
|
|
884
|
+
}`;
|
|
885
|
+
|
|
886
|
+
const PULL_PAGE_QUERY = `query($owner:String!,$name:String!,$first:Int!,$after:String){
|
|
887
|
+
repository(owner:$owner,name:$name){
|
|
888
|
+
id
|
|
889
|
+
name
|
|
890
|
+
nameWithOwner
|
|
891
|
+
url
|
|
892
|
+
pullRequests(first:$first,after:$after,states:OPEN,orderBy:{field:UPDATED_AT,direction:DESC}){
|
|
893
|
+
totalCount
|
|
894
|
+
pageInfo{hasNextPage endCursor}
|
|
895
|
+
nodes{id number title url updatedAt author{login} headRefName isDraft reviewDecision}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
rateLimit{cost limit used remaining resetAt}
|
|
899
|
+
}`;
|
|
900
|
+
|
|
901
|
+
// Selects the meter and nothing else. This is the only claimed GraphQL
|
|
902
|
+
// observer; a data page's counters constrain the same epoch but never open a
|
|
903
|
+
// new one.
|
|
904
|
+
const GRAPHQL_OBSERVER_QUERY = `query{rateLimit{cost limit used remaining resetAt}}`;
|
|
905
|
+
|
|
906
|
+
// Replaces `gh repo view --json`, whose GraphQL cost was real but undeclared.
|
|
907
|
+
const REPOSITORY_QUERY = `query($owner:String!,$name:String!){
|
|
908
|
+
repository(owner:$owner,name:$name){
|
|
909
|
+
id
|
|
910
|
+
nameWithOwner
|
|
911
|
+
url
|
|
912
|
+
viewerPermission
|
|
913
|
+
}
|
|
914
|
+
rateLimit{cost limit used remaining resetAt}
|
|
915
|
+
}`;
|
|
916
|
+
|
|
917
|
+
const GRAPHQL_QUERIES = Object.freeze({
|
|
918
|
+
repository: { query: REPOSITORY_QUERY, connection: null, points: GRAPHQL_OBSERVER_POINTS },
|
|
919
|
+
issues: { query: ISSUE_PAGE_QUERY, connection: "issues", points: GRAPHQL_PAGE_POINTS },
|
|
920
|
+
prs: { query: PULL_PAGE_QUERY, connection: "pullRequests", points: GRAPHQL_PAGE_POINTS },
|
|
921
|
+
observer: { query: GRAPHQL_OBSERVER_QUERY, connection: null, points: GRAPHQL_OBSERVER_POINTS },
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
// `--input -` keeps the document and its variables on stdin as one typed JSON
|
|
925
|
+
// object, so variables stay typed rather than becoming the strings that
|
|
926
|
+
// `-f`/`-F` would produce, and nothing reaches argv.
|
|
927
|
+
function graphqlArgs(host = effectiveRuntimeHost()) {
|
|
928
|
+
return ["api", "-i", "graphql", "--input", "-", ...apiHostArgs(host)];
|
|
743
929
|
}
|
|
744
930
|
|
|
931
|
+
function graphqlInput(kind, variables) {
|
|
932
|
+
const declared = pick(GRAPHQL_QUERIES, kind, null);
|
|
933
|
+
if (!declared) throw new Error(`undeclared graphql query: ${kind}`);
|
|
934
|
+
if (variables === null) throw new Error(UNRESOLVED_REPOSITORY);
|
|
935
|
+
return JSON.stringify({ query: declared.query, variables });
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// `gh` resolves an omitted repository from the working directory. GraphQL has
|
|
939
|
+
// no such inference, so the slug must be resolved here or the document cannot
|
|
940
|
+
// be built at all -- and running without `--repo` is the documented default.
|
|
941
|
+
//
|
|
942
|
+
// Refusing is deliberate. A half-filled variable set is worse than no request:
|
|
943
|
+
// GitHub answers a missing `String!` with HTTP 200 and an errors array, which
|
|
944
|
+
// is indistinguishable from a denied query, so the tab would report a
|
|
945
|
+
// permission problem for what is really an unresolved target.
|
|
946
|
+
function graphqlRepositoryVariables(repository = effectiveRuntimeRepository()) {
|
|
947
|
+
if (!repository) return null;
|
|
948
|
+
const [owner, name] = repository.split("/");
|
|
949
|
+
return { owner, name };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function graphqlPageVariables(after = null, repository = effectiveRuntimeRepository()) {
|
|
953
|
+
const target = graphqlRepositoryVariables(repository);
|
|
954
|
+
return target && { ...target, first: GRAPHQL_PAGE_SIZE, after };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// GitHub answers a rejected query with HTTP 200 and an `errors` array, and can
|
|
958
|
+
// answer a partly-resolvable one with both `data` and `errors`. Headers and
|
|
959
|
+
// envelope are therefore read separately: budget evidence stays usable even
|
|
960
|
+
// when the data is not, which is the only way a failed page can still be paid
|
|
961
|
+
// for honestly.
|
|
962
|
+
function parseGraphqlEnvelope(body) {
|
|
963
|
+
let envelope;
|
|
964
|
+
try { envelope = JSON.parse(body); } catch { return { ok: false, reason: "unparseable", errors: [], data: null, rateLimit: null }; }
|
|
965
|
+
if (!isRecord(envelope)) return { ok: false, reason: "unparseable", errors: [], data: null, rateLimit: null };
|
|
966
|
+
const errors = Array.isArray(envelope.errors) ? envelope.errors : [];
|
|
967
|
+
const data = isRecord(envelope.data) ? envelope.data : null;
|
|
968
|
+
const meter = isRecord(data?.rateLimit) ? data.rateLimit : null;
|
|
969
|
+
const number = (value) => (Number.isSafeInteger(value) && value >= 0 ? value : null);
|
|
970
|
+
// A primary window is an hour; anything beyond a generous ceiling is not a
|
|
971
|
+
// reset this app can wait for, and accepting it would pin an epoch that
|
|
972
|
+
// `budget.resetMs <= now` never clears.
|
|
973
|
+
const parsedReset = typeof meter?.resetAt === "string" ? Date.parse(meter.resetAt) : Number.NaN;
|
|
974
|
+
const resetMs = Number.isFinite(parsedReset) && parsedReset <= Date.now() + GRAPHQL_MAX_RESET_MS
|
|
975
|
+
? parsedReset
|
|
976
|
+
: Number.NaN;
|
|
977
|
+
const rateLimit = meter && [number(meter.limit), number(meter.used), number(meter.remaining)].every((value) => value !== null) && Number.isFinite(resetMs)
|
|
978
|
+
? { resource: "graphql", limit: meter.limit, used: meter.used, remaining: meter.remaining, resetMs, cost: number(meter.cost) }
|
|
979
|
+
: null;
|
|
980
|
+
return { ok: errors.length === 0 && data !== null, reason: errors.length > 0 ? "graphql-errors" : data === null ? "no-data" : null, errors, data, rateLimit };
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
|
|
745
984
|
function normalizeHost(value) {
|
|
746
985
|
if (typeof value !== "string") return null;
|
|
747
986
|
const host = value.trim().toLowerCase();
|
|
@@ -787,6 +1026,51 @@ function resolveEffectiveHost({
|
|
|
787
1026
|
return hosts.size === 1 ? [...hosts][0] : null;
|
|
788
1027
|
}
|
|
789
1028
|
|
|
1029
|
+
const UNRESOLVED_REPOSITORY = "Repository could not be resolved; pass --repo owner/name";
|
|
1030
|
+
|
|
1031
|
+
// The owner/name half of the same question resolveEffectiveHost answers for the
|
|
1032
|
+
// host, and in the same precedence: an explicit --repo, then GH_REPO, then an
|
|
1033
|
+
// unambiguous git remote. Ambiguity resolves to null rather than to a guess.
|
|
1034
|
+
function remoteSlug(value) {
|
|
1035
|
+
if (typeof value !== "string" || value.length === 0) return null;
|
|
1036
|
+
let path;
|
|
1037
|
+
if (value.includes("://")) {
|
|
1038
|
+
try { path = new URL(value).pathname; } catch { return null; }
|
|
1039
|
+
} else {
|
|
1040
|
+
path = /^(?:[^@\s]+@)?[^:/\s]+:([^\s]+)$/.exec(value)?.[1] ?? null;
|
|
1041
|
+
}
|
|
1042
|
+
if (path === null) return null;
|
|
1043
|
+
// Trimmed by index rather than with /\/+$/. An end-anchored quantifier is
|
|
1044
|
+
// retried from every position, so a remote URL carrying a long run of slashes
|
|
1045
|
+
// costs quadratic time to reject (CodeQL js/polynomial-redos, high). Remote
|
|
1046
|
+
// URLs are library input: `git remote -v` reports whatever is configured.
|
|
1047
|
+
let start = 0;
|
|
1048
|
+
let end = path.length;
|
|
1049
|
+
while (start < end && path[start] === "/") start += 1;
|
|
1050
|
+
while (end > start && path[end - 1] === "/") end -= 1;
|
|
1051
|
+
const trimmed = path.slice(start, end);
|
|
1052
|
+
const slug = trimmed.endsWith(".git") ? trimmed.slice(0, -4) : trimmed;
|
|
1053
|
+
return REPO_PATTERN.test(slug) ? slug : null;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function resolveEffectiveRepository({ runtimeRepo = null, ghRepo = null, remoteUrls = [] } = {}) {
|
|
1057
|
+
if (runtimeRepo) return runtimeRepo;
|
|
1058
|
+
if (ghRepo) {
|
|
1059
|
+
try { return parseRepoTarget(ghRepo).slug; } catch { return null; }
|
|
1060
|
+
}
|
|
1061
|
+
const slugs = new Set(remoteUrls.map(remoteSlug).filter(Boolean));
|
|
1062
|
+
return slugs.size === 1 ? [...slugs][0] : null;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function effectiveRuntimeRepository(options = {}) {
|
|
1066
|
+
return resolveEffectiveRepository({
|
|
1067
|
+
runtimeRepo: runtime.repo,
|
|
1068
|
+
ghRepo: process.env.GH_REPO,
|
|
1069
|
+
remoteUrls: runtimeRemoteUrls,
|
|
1070
|
+
...options,
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
|
|
790
1074
|
function effectiveRuntimeHost(options = {}) {
|
|
791
1075
|
return resolveEffectiveHost({
|
|
792
1076
|
runtimeHost: runtime.host,
|
|
@@ -799,11 +1083,6 @@ function effectiveRuntimeHost(options = {}) {
|
|
|
799
1083
|
});
|
|
800
1084
|
}
|
|
801
1085
|
|
|
802
|
-
// `--repo` for the list subcommands. Empty when unset, so the argv vector stays
|
|
803
|
-
// byte-identical to what shipped before.
|
|
804
|
-
function repoArgs() {
|
|
805
|
-
return runtime.repo ? ["--repo", qualifiedRepo()] : [];
|
|
806
|
-
}
|
|
807
1086
|
|
|
808
1087
|
// `gh api` has no --repo, so a host-qualified target has nowhere to put its
|
|
809
1088
|
// host: GH_REPO=host/owner/repo supplies the owner and repo and *ignores* the
|
|
@@ -831,32 +1110,12 @@ function apiPath(path) {
|
|
|
831
1110
|
// inline, because `--doctor` reports these vectors and a second copy of them
|
|
832
1111
|
// would be a report that drifts away from what the dashboard actually sends --
|
|
833
1112
|
// which is the failure mode the whole diagnostics command exists to rule out.
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
return [
|
|
837
|
-
"repo",
|
|
838
|
-
"view",
|
|
839
|
-
...(target ? [target] : []),
|
|
840
|
-
"--json",
|
|
841
|
-
"nameWithOwner,url,viewerPermission",
|
|
842
|
-
];
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
function authContextArgs() {
|
|
846
|
-
return [
|
|
847
|
-
"auth",
|
|
848
|
-
"status",
|
|
849
|
-
"--active",
|
|
850
|
-
"--json",
|
|
851
|
-
"hosts",
|
|
852
|
-
"--jq",
|
|
853
|
-
".hosts | to_entries | map(.key as $host | .value[] | select(.active == true) | {host: $host, login: .login})",
|
|
854
|
-
];
|
|
855
|
-
}
|
|
856
|
-
|
|
1113
|
+
// Parses the explicit repository query's envelope. The shape the rest of the
|
|
1114
|
+
// failure context consumes is unchanged; only how it was obtained is.
|
|
857
1115
|
function parseRepoContext(raw) {
|
|
858
1116
|
try {
|
|
859
|
-
const
|
|
1117
|
+
const envelope = JSON.parse(raw);
|
|
1118
|
+
const value = isRecord(envelope?.data?.repository) ? envelope.data.repository : envelope;
|
|
860
1119
|
if (
|
|
861
1120
|
value == null ||
|
|
862
1121
|
Array.isArray(value) ||
|
|
@@ -927,15 +1186,17 @@ async function resolveFailureContext(signal, governor = null, { run = runGh } =
|
|
|
927
1186
|
...governor,
|
|
928
1187
|
operation: "failure-context:repository",
|
|
929
1188
|
signal,
|
|
930
|
-
run: (admittedSignal) => run(
|
|
1189
|
+
run: (admittedSignal) => run(graphqlArgs(), {
|
|
931
1190
|
signal: admittedSignal,
|
|
932
1191
|
operation: "failure-context:repository",
|
|
933
|
-
|
|
1192
|
+
input: graphqlInput("repository", graphqlRepositoryVariables()),
|
|
1193
|
+
}).then((stdout) => parseGhApiResponse(stdout).body),
|
|
934
1194
|
}).then((result) => result.ok ? result.value : Promise.reject(result.error))
|
|
935
1195
|
: Promise.reject(new Error("API budget unavailable"));
|
|
1196
|
+
const identity = runtimeIdentityCoordinator?.current();
|
|
936
1197
|
const [repo, auth] = await Promise.allSettled([
|
|
937
1198
|
repoCall,
|
|
938
|
-
|
|
1199
|
+
identity ? Promise.resolve(JSON.stringify([{ host: identity.host, login: identity.login }])) : Promise.reject(new Error("Verified identity unavailable")),
|
|
939
1200
|
]);
|
|
940
1201
|
return buildFailureContext(repo, auth);
|
|
941
1202
|
}
|
|
@@ -987,7 +1248,12 @@ function actionsRunsArgs(limit) {
|
|
|
987
1248
|
apiPath(`repos/{owner}/{repo}/actions/runs?exclude_pull_requests=true&per_page=${limit}`),
|
|
988
1249
|
...apiHostArgs(),
|
|
989
1250
|
"--jq",
|
|
990
|
-
|
|
1251
|
+
// html_url is projected because a run's page must come from the row itself.
|
|
1252
|
+
// Deriving it from the repository slug only works when --repo was given;
|
|
1253
|
+
// with an inferred repository the app never learns the slug, and the row is
|
|
1254
|
+
// the one place the answer is always present. It costs nothing extra: the
|
|
1255
|
+
// field is already in the response being parsed.
|
|
1256
|
+
"[.workflow_runs[] | {databaseId: .id, displayTitle: .display_title, number: .run_number, headBranch: .head_branch, status, conclusion, startedAt: .run_started_at, updatedAt: .updated_at, workflowId: .workflow_id, url: .html_url}]",
|
|
991
1257
|
];
|
|
992
1258
|
}
|
|
993
1259
|
|
|
@@ -1015,6 +1281,7 @@ function parseActionsBodies(runsBody, workflowsBody) {
|
|
|
1015
1281
|
conclusion: run.conclusion,
|
|
1016
1282
|
startedAt: run.startedAt,
|
|
1017
1283
|
updatedAt: run.updatedAt,
|
|
1284
|
+
url: safe(run.url ?? ""),
|
|
1018
1285
|
}));
|
|
1019
1286
|
}
|
|
1020
1287
|
|
|
@@ -1140,85 +1407,188 @@ async function fetchActions(limit, signal, {
|
|
|
1140
1407
|
};
|
|
1141
1408
|
}
|
|
1142
1409
|
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
|
|
1151
|
-
|
|
1410
|
+
// Ordering is part of the query now (`orderBy: UPDATED_AT DESC`) rather than a
|
|
1411
|
+
// `--search sort:` flag. The AGE column renders updatedAt, so a created-order
|
|
1412
|
+
// list made that column non-monotonic and, worse, truncating to pane height
|
|
1413
|
+
// dropped the oldest-*created* rows -- a PR opened six months ago and reviewed
|
|
1414
|
+
// five minutes ago was invisible. The old fix routed `gh issue list` through
|
|
1415
|
+
// GraphQL implicitly and at an unobservable cost; asking for the order directly
|
|
1416
|
+
// keeps the behaviour and makes the price visible.
|
|
1417
|
+
|
|
1418
|
+
// One page of an explicit connection. Headers and envelope are settled
|
|
1419
|
+
// separately so a rejected query still pays honestly: GitHub answers those with
|
|
1420
|
+
// HTTP 200 and an `errors` array, and its counters are trustworthy even when
|
|
1421
|
+
// its data is not.
|
|
1422
|
+
async function fetchGraphqlPage(kind, { signal, after = null, run = runGh, operation, host = effectiveRuntimeHost(), variables = null } = {}) {
|
|
1423
|
+
const declared = pick(GRAPHQL_QUERIES, kind, null);
|
|
1424
|
+
if (!declared) throw new Error(`undeclared graphql query: ${kind}`);
|
|
1425
|
+
let stdout;
|
|
1426
|
+
let failure = null;
|
|
1427
|
+
try {
|
|
1428
|
+
stdout = await run(graphqlArgs(host), { signal, operation, input: graphqlInput(kind, variables ?? graphqlPageVariables(after)) });
|
|
1429
|
+
} catch (error) {
|
|
1430
|
+
failure = error;
|
|
1431
|
+
// A failed call can still carry a complete response body: `gh` exits
|
|
1432
|
+
// non-zero on 4xx/5xx but has already written the headers and envelope,
|
|
1433
|
+
// and that is where trustworthy budget evidence lives.
|
|
1434
|
+
stdout = typeof error?.stdout === "string" ? error.stdout : "";
|
|
1435
|
+
}
|
|
1436
|
+
const parsed = parseGhApiResponse(stdout);
|
|
1437
|
+
const envelope = parseGraphqlEnvelope(parsed.body);
|
|
1438
|
+
// Prefer the envelope's meter: it carries this query's actual `cost`, which
|
|
1439
|
+
// the headers do not. Headers remain the fallback when the body is unusable.
|
|
1440
|
+
const meter = envelope.rateLimit ?? pickRateLimit(parsed.headers);
|
|
1441
|
+
// Absent evidence never refunds. An unobserved page keeps its conservative
|
|
1442
|
+
// reservation rather than being treated as free.
|
|
1443
|
+
const observedCost = Number.isSafeInteger(envelope.rateLimit?.cost)
|
|
1444
|
+
? envelope.rateLimit.cost
|
|
1445
|
+
: declared.points;
|
|
1446
|
+
const observation = meter && meter.resource === "graphql"
|
|
1447
|
+
? responseHeaderObservation(meter, observedCost)
|
|
1448
|
+
: null;
|
|
1449
|
+
return {
|
|
1450
|
+
kind,
|
|
1451
|
+
status: parsed.status,
|
|
1452
|
+
ok: failure === null && parsed.status === 200 && envelope.ok,
|
|
1453
|
+
reason: failure ? "transport" : parsed.status !== 200 ? "http" : envelope.reason,
|
|
1454
|
+
failure,
|
|
1455
|
+
data: envelope.data,
|
|
1456
|
+
// Recorded in full even when it exceeds the declared bound: an overrun is
|
|
1457
|
+
// evidence to reconcile, not a bookkeeping error to discard.
|
|
1458
|
+
observedCost,
|
|
1459
|
+
overrun: observedCost > declared.points,
|
|
1460
|
+
observations: observation ? [observation] : [],
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1152
1463
|
|
|
1153
|
-
function
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
...repoArgs(),
|
|
1158
|
-
"--state",
|
|
1159
|
-
"open",
|
|
1160
|
-
"--limit",
|
|
1161
|
-
String(LIST_LIMIT),
|
|
1162
|
-
...SORT_RECENT,
|
|
1163
|
-
"--json",
|
|
1164
|
-
"number,title,author,labels,createdAt,updatedAt",
|
|
1165
|
-
];
|
|
1464
|
+
function graphqlConnection(page, connection) {
|
|
1465
|
+
const repository = isRecord(page.data?.repository) ? page.data.repository : null;
|
|
1466
|
+
const value = isRecord(repository?.[connection]) ? repository[connection] : null;
|
|
1467
|
+
return value && Array.isArray(value.nodes) ? value : null;
|
|
1166
1468
|
}
|
|
1167
1469
|
|
|
1168
|
-
|
|
1169
|
-
|
|
1470
|
+
const ISSUE_ROW = (node) => ({
|
|
1471
|
+
number: node.number,
|
|
1472
|
+
title: safe(node.title),
|
|
1473
|
+
author: safe(node.author?.login ?? ""),
|
|
1474
|
+
label: safe(node.labels?.nodes?.[0]?.name ?? ""),
|
|
1475
|
+
updatedAt: node.updatedAt,
|
|
1476
|
+
url: safe(node.url ?? ""),
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
const PR_ROW = (node) => ({
|
|
1480
|
+
number: node.number,
|
|
1481
|
+
title: safe(node.title),
|
|
1482
|
+
author: safe(node.author?.login ?? ""),
|
|
1483
|
+
headRefName: safe(node.headRefName),
|
|
1484
|
+
isDraft: node.isDraft,
|
|
1485
|
+
reviewDecision: node.reviewDecision,
|
|
1486
|
+
updatedAt: node.updatedAt,
|
|
1487
|
+
url: safe(node.url ?? ""),
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
// Walks the connection one explicitly admitted page at a time. The first page
|
|
1491
|
+
// is covered by the tab's own reservation; each later page reserves its own
|
|
1492
|
+
// envelope, so a denial there is an ordinary scheduling outcome rather than a
|
|
1493
|
+
// hidden overspend. Rows already collected survive that denial and the result
|
|
1494
|
+
// is marked incomplete rather than being published as a complete tab.
|
|
1495
|
+
async function fetchGraphqlList(kind, mapRow, {
|
|
1496
|
+
signal,
|
|
1497
|
+
governor = null,
|
|
1498
|
+
previousRaw = null,
|
|
1499
|
+
// Seams, so the paging loop can be exercised without a live governor: the
|
|
1500
|
+
// settlement figure it produces was wrong for months of repositories with
|
|
1501
|
+
// more than one page, and nothing could see it.
|
|
1502
|
+
fetchPage = fetchGraphqlPage,
|
|
1503
|
+
admit = runAdmittedOperation,
|
|
1504
|
+
} = {}) {
|
|
1505
|
+
const connection = GRAPHQL_QUERIES[kind].connection;
|
|
1506
|
+
const operation = `tab:${kind}`;
|
|
1507
|
+
const first = await fetchPage(kind, { signal, operation });
|
|
1508
|
+
if (!first.ok) {
|
|
1509
|
+
const error = first.failure ?? new Error(`GraphQL ${kind} page unavailable (${first.reason})`);
|
|
1510
|
+
error.budgetObservations = first.observations;
|
|
1511
|
+
throw error;
|
|
1512
|
+
}
|
|
1513
|
+
const rows = [];
|
|
1514
|
+
const observations = [...first.observations];
|
|
1515
|
+
// Only the first page. Pages past it opened their own reservation inside
|
|
1516
|
+
// runAdmittedOperation and settle against it, so adding them here charges the
|
|
1517
|
+
// same work twice -- and a settlement above its reservation is rejected as
|
|
1518
|
+
// corrupt, which silently leaks the tab's reservation and discards every
|
|
1519
|
+
// budget observation the fetch gathered.
|
|
1520
|
+
const envelopeSpent = first.observedCost;
|
|
1521
|
+
let spent = first.observedCost;
|
|
1522
|
+
let page = graphqlConnection(first, connection);
|
|
1523
|
+
let incomplete = false;
|
|
1524
|
+
let overrun = first.overrun;
|
|
1525
|
+
let totalCount = page?.totalCount ?? null;
|
|
1526
|
+
for (const node of page?.nodes ?? []) rows.push(mapRow(node));
|
|
1527
|
+
while (page?.pageInfo?.hasNextPage && rows.length < LIST_LIMIT) {
|
|
1528
|
+
const cursor = page.pageInfo.endCursor;
|
|
1529
|
+
if (typeof cursor !== "string" || cursor.length === 0) { incomplete = true; break; }
|
|
1530
|
+
if (!governor) { incomplete = true; break; }
|
|
1531
|
+
const admitted = await admit({
|
|
1532
|
+
...governor,
|
|
1533
|
+
operation: `page:${kind}`,
|
|
1534
|
+
priority: "background",
|
|
1535
|
+
signal,
|
|
1536
|
+
run: (admittedSignal) => fetchPage(kind, { signal: admittedSignal, after: cursor, operation: `page:${kind}` }),
|
|
1537
|
+
});
|
|
1538
|
+
// A later page that is denied, fails, or returns errors leaves the rows
|
|
1539
|
+
// already gathered exactly as they are. Losing page one because page two
|
|
1540
|
+
// was refused would turn a budget decision into data loss.
|
|
1541
|
+
//
|
|
1542
|
+
// Note that runAdmittedOperation settles these at their *declared* cost, not
|
|
1543
|
+
// their observed one -- conservative, so never an under-charge. A later
|
|
1544
|
+
// page's real meter reaches the ledger through the observations forwarded to
|
|
1545
|
+
// the tab settlement rather than through its own reservation.
|
|
1546
|
+
// Evidence first, and unconditionally: a page that failed as data still
|
|
1547
|
+
// observed the meter, and dropping that is the one thing this phase says
|
|
1548
|
+
// it will not do.
|
|
1549
|
+
if (admitted.value?.observations) observations.push(...admitted.value.observations);
|
|
1550
|
+
if (admitted.value?.overrun) overrun = true;
|
|
1551
|
+
if (!admitted.ok || !admitted.value?.ok) { incomplete = true; break; }
|
|
1552
|
+
const next = admitted.value;
|
|
1553
|
+
spent += next.observedCost;
|
|
1554
|
+
page = graphqlConnection(next, connection);
|
|
1555
|
+
if (!page) { incomplete = true; break; }
|
|
1556
|
+
for (const node of page.nodes) rows.push(mapRow(node));
|
|
1557
|
+
}
|
|
1558
|
+
if (page?.pageInfo?.hasNextPage && rows.length >= LIST_LIMIT) incomplete = true;
|
|
1559
|
+
if (overrun) {
|
|
1560
|
+
// Recorded at its real cost above; suspended here until the resource resets,
|
|
1561
|
+
// which is the soonest a corrected bound could be trusted again.
|
|
1562
|
+
const reset = observations.at(-1)?.resetMs;
|
|
1563
|
+
pauseOperation(operation, Number.isFinite(reset) ? reset + BUDGET_RESET_GRACE_MS : Date.now() + BUDGET_PROBE_MS);
|
|
1564
|
+
}
|
|
1565
|
+
const limited = rows.slice(0, LIST_LIMIT);
|
|
1566
|
+
// Serialized rows are the payload identity the unchanged-frame suppression
|
|
1567
|
+
// and the cache both compare on, so it must cover exactly what is rendered.
|
|
1568
|
+
const raw = JSON.stringify({ v: GRAPHQL_QUERY_VERSION, totalCount, incomplete, rows: limited });
|
|
1170
1569
|
return {
|
|
1171
|
-
raw,
|
|
1172
|
-
limit
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
})),
|
|
1570
|
+
raw: raw === previousRaw ? previousRaw : raw,
|
|
1571
|
+
// An incomplete walk reports the rows it has as its own limit, so the
|
|
1572
|
+
// existing truncation indicator fires. "More rows exist than are shown" is
|
|
1573
|
+
// exactly what incomplete means, and publishing 50 of 150 rows as a
|
|
1574
|
+
// complete tab is indistinguishable from a repository with 50 open issues.
|
|
1575
|
+
limit: incomplete ? limited.length : LIST_LIMIT,
|
|
1576
|
+
restSpent: 0,
|
|
1577
|
+
graphqlSpent: envelopeSpent,
|
|
1578
|
+
// What the whole walk cost, for reporting. Never the settlement figure.
|
|
1579
|
+
graphqlSpentTotal: spent,
|
|
1580
|
+
observations,
|
|
1581
|
+
incomplete,
|
|
1582
|
+
parse: () => limited,
|
|
1185
1583
|
};
|
|
1186
1584
|
}
|
|
1187
1585
|
|
|
1188
|
-
function
|
|
1189
|
-
return
|
|
1190
|
-
"pr",
|
|
1191
|
-
"list",
|
|
1192
|
-
...repoArgs(),
|
|
1193
|
-
"--state",
|
|
1194
|
-
"open",
|
|
1195
|
-
"--limit",
|
|
1196
|
-
String(LIST_LIMIT),
|
|
1197
|
-
...SORT_RECENT,
|
|
1198
|
-
"--json",
|
|
1199
|
-
"number,title,author,headRefName,isDraft,reviewDecision,createdAt,updatedAt",
|
|
1200
|
-
];
|
|
1586
|
+
async function fetchIssues(signal, { governor = null, previousRaw = null } = {}) {
|
|
1587
|
+
return fetchGraphqlList("issues", ISSUE_ROW, { signal, governor, previousRaw });
|
|
1201
1588
|
}
|
|
1202
1589
|
|
|
1203
|
-
async function fetchPRs(signal) {
|
|
1204
|
-
|
|
1205
|
-
return {
|
|
1206
|
-
raw,
|
|
1207
|
-
limit: LIST_LIMIT,
|
|
1208
|
-
// Zero for the same reason as issues: --search makes this a GraphQL call.
|
|
1209
|
-
restSpent: REST_PER_FETCH.prs,
|
|
1210
|
-
graphqlSpent: GRAPHQL_PER_FETCH.prs,
|
|
1211
|
-
parse: () =>
|
|
1212
|
-
parseJsonOutput(raw).map((p) => ({
|
|
1213
|
-
number: p.number,
|
|
1214
|
-
title: safe(p.title),
|
|
1215
|
-
author: safe(p.author?.login ?? ""),
|
|
1216
|
-
headRefName: safe(p.headRefName),
|
|
1217
|
-
isDraft: p.isDraft,
|
|
1218
|
-
reviewDecision: p.reviewDecision,
|
|
1219
|
-
updatedAt: p.updatedAt,
|
|
1220
|
-
})),
|
|
1221
|
-
};
|
|
1590
|
+
async function fetchPRs(signal, { governor = null, previousRaw = null } = {}) {
|
|
1591
|
+
return fetchGraphqlList("prs", PR_ROW, { signal, governor, previousRaw });
|
|
1222
1592
|
}
|
|
1223
1593
|
|
|
1224
1594
|
// The three alert endpoints were three near-identical 26-line blocks that had
|
|
@@ -1320,11 +1690,14 @@ const OPERATION_COSTS = Object.freeze({
|
|
|
1320
1690
|
"tab:actions-runs": { core: 1, graphql: 0 },
|
|
1321
1691
|
"tab:actions-workflows": { core: 1, graphql: 0 },
|
|
1322
1692
|
"tab:security-endpoint": { core: 1, graphql: 0 },
|
|
1323
|
-
"failure-context:repository": { core: 0, graphql:
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
"
|
|
1327
|
-
"
|
|
1693
|
+
"failure-context:repository": { core: 0, graphql: GRAPHQL_OBSERVER_POINTS },
|
|
1694
|
+
// Each page past the first reserves its own envelope, so a denial there is a
|
|
1695
|
+
// scheduling outcome rather than an unadmitted overspend.
|
|
1696
|
+
"page:issues": { core: 0, graphql: GRAPHQL_PAGE_POINTS },
|
|
1697
|
+
"page:prs": { core: 0, graphql: GRAPHQL_PAGE_POINTS },
|
|
1698
|
+
// The one claimed GraphQL observer. Not free, and not describable as free:
|
|
1699
|
+
// it selects the meter and pays a point for it.
|
|
1700
|
+
"graphql-observer": { core: 0, graphql: GRAPHQL_OBSERVER_POINTS },
|
|
1328
1701
|
"doctor:repository": { core: 0, graphql: 1 },
|
|
1329
1702
|
"doctor:actions-runs": { core: 1, graphql: 0 },
|
|
1330
1703
|
"doctor:actions-workflows": { core: 1, graphql: 0 },
|
|
@@ -1337,7 +1710,6 @@ const OPERATION_COSTS = Object.freeze({
|
|
|
1337
1710
|
"budget-core-observer": { core: 1, graphql: 0 },
|
|
1338
1711
|
"rate-limit": { core: 0, graphql: 0 },
|
|
1339
1712
|
"version": { core: 0, graphql: 0 },
|
|
1340
|
-
"auth-status": { core: 0, graphql: 0 },
|
|
1341
1713
|
"local-git": { core: 0, graphql: 0 },
|
|
1342
1714
|
});
|
|
1343
1715
|
|
|
@@ -1856,13 +2228,718 @@ function scheduleIntents({
|
|
|
1856
2228
|
};
|
|
1857
2229
|
}
|
|
1858
2230
|
|
|
2231
|
+
// ---------- Credential identities and restart-safe coordination ----------
|
|
2232
|
+
const IDENTITY_VERSION = 1;
|
|
2233
|
+
const IDENTITY_ATTEMPT_WINDOW_MS = 15 * 60_000;
|
|
2234
|
+
const IDENTITY_ATTEMPT_LIMIT = 3;
|
|
2235
|
+
const IDENTITY_HOST_ATTEMPT_LIMIT = 12;
|
|
2236
|
+
const IDENTITY_MAX_ATTEMPTS = 4096;
|
|
2237
|
+
const HTTP_START_GAP_MS = 250;
|
|
2238
|
+
const HTTP_MAX_WAITERS = 128;
|
|
2239
|
+
// A permit is reclaimable once it cannot still belong to a live request. The
|
|
2240
|
+
// owner-death check alone cannot see a process that is stopped, or one whose
|
|
2241
|
+
// release lost its lock, so a permit outliving the subprocess timeout it guards
|
|
2242
|
+
// is stale by construction rather than by guess.
|
|
2243
|
+
const HTTP_PERMIT_MAX_MS = GH_TIMEOUT_MS + 5_000;
|
|
2244
|
+
// The longest a primary window can run. An attempt that never obtained reset
|
|
2245
|
+
// evidence is still covered once a full window has demonstrably elapsed since it
|
|
2246
|
+
// started, which is what keeps an unprovable charge from being retained forever.
|
|
2247
|
+
const IDENTITY_UNCERTAIN_MAX_MS = 3_600_000 + BUDGET_RESET_GRACE_MS;
|
|
2248
|
+
// Bound the in-band wait for the registry lock when a request is already
|
|
2249
|
+
// finishing. The permit is what other panes queue behind, so a slow release must
|
|
2250
|
+
// hand off to deferCompletion quickly instead of holding the whole machine.
|
|
2251
|
+
const IDENTITY_RELEASE_WAIT_MS = 500;
|
|
2252
|
+
// How long startup will wait for a warm local identity before painting. Long
|
|
2253
|
+
// enough to win the ordinary case, short enough that a stuck credential helper
|
|
2254
|
+
// costs a cache-cold first frame rather than a blank terminal.
|
|
2255
|
+
const WARM_IDENTITY_WAIT_MS = 1_000;
|
|
2256
|
+
// "busy" and "unwritable" say the registry could not be read or written *right
|
|
2257
|
+
// now*. Every other reason is a statement about the identity itself. Three
|
|
2258
|
+
// callers each drew this line differently -- retryIdentityCompletion retried
|
|
2259
|
+
// both, refresh() treated them as proof the identity was gone, and permit
|
|
2260
|
+
// acquisition threw on them -- so a 250 ms lock wait could be reported as an
|
|
2261
|
+
// account change. One definition keeps them from drifting apart again.
|
|
2262
|
+
const RETRYABLE_COORDINATION_REASONS = ["busy", "unwritable"];
|
|
2263
|
+
// 60 s doubling per prior attempt in the window. The two callers deliberately
|
|
2264
|
+
// count different prior sets -- bootstrap also has a per-host unknown-principal
|
|
2265
|
+
// allowance and the transport start gap to respect, while a known-capacity
|
|
2266
|
+
// observer is admitted normally and only the exceptional branch is throttled --
|
|
2267
|
+
// but the delay they apply is one law and belongs in one place.
|
|
2268
|
+
function attemptRetryDelayMs(priorAttempts) {
|
|
2269
|
+
return 60_000 * 2 ** priorAttempts;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
function retryableCoordination(reason) {
|
|
2273
|
+
return RETRYABLE_COORDINATION_REASONS.includes(reason);
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// A dead owner releases its permit immediately; a live owner keeps it only for
|
|
2277
|
+
// as long as a request could still be running under it. Without the second
|
|
2278
|
+
// test a stopped process, or one whose release never won the lock, holds the
|
|
2279
|
+
// single machine-wide permit forever and every pane queues behind it.
|
|
2280
|
+
function staleHttpPermit(permit, now, kill = process.kill.bind(process)) {
|
|
2281
|
+
if (pidIsDead(permit.pid, kill)) return true;
|
|
2282
|
+
return Number.isFinite(permit.startedAt) && now - permit.startedAt > HTTP_PERMIT_MAX_MS;
|
|
2283
|
+
}
|
|
2284
|
+
let runtimeIdentityCoordinator = null;
|
|
2285
|
+
|
|
2286
|
+
function privateIdentityDigest(...parts) {
|
|
2287
|
+
return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
function identityRegistryRoot(pathOptions = {}) {
|
|
2291
|
+
return join(dirname(widthPreferencesPath(pathOptions)), "coordination-v2");
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
function credentialEnvironmentNames(host) {
|
|
2295
|
+
return host === "github.com" || host.endsWith(".ghe.com")
|
|
2296
|
+
? ["GH_TOKEN", "GITHUB_TOKEN"] : ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"];
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// Deliberately not memoized, despite being called on the render and request
|
|
2300
|
+
// paths. resolveEffectiveCredential brackets the `gh auth token` subprocess with
|
|
2301
|
+
// a before/after comparison of this value to catch a credential swapped while
|
|
2302
|
+
// resolution was in flight, so any cache whose window can cover that gap makes
|
|
2303
|
+
// the process bind the wrong credential -- and a local token can resolve in
|
|
2304
|
+
// under a millisecond. The cost is one statSync plus a digest of four numbers;
|
|
2305
|
+
// reduce the number of callers instead of caching the answer.
|
|
2306
|
+
function credentialConfigurationRevision(host, env = process.env) {
|
|
2307
|
+
const names = credentialEnvironmentNames(host);
|
|
2308
|
+
const selected = names.find((name) => env[name]);
|
|
2309
|
+
if (selected) return privateIdentityDigest(host, String(env[selected]));
|
|
2310
|
+
const directory = effectiveGhConfigDir({ env });
|
|
2311
|
+
let stamp = null;
|
|
2312
|
+
try {
|
|
2313
|
+
const value = statSync(join(directory, "hosts.yml"));
|
|
2314
|
+
stamp = [value.dev, value.ino, value.size, value.mtimeMs];
|
|
2315
|
+
} catch { /* missing local configuration is resolved honestly by gh */ }
|
|
2316
|
+
return privateIdentityDigest(host, directory, stamp);
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
async function resolveEffectiveCredential({ host, env = process.env, runLocalToken } = {}) {
|
|
2320
|
+
host = normalizeHost(host);
|
|
2321
|
+
if (!host) return { ok: false, reason: "unknown-host" };
|
|
2322
|
+
const names = credentialEnvironmentNames(host);
|
|
2323
|
+
const initialRevision = credentialConfigurationRevision(host, env);
|
|
2324
|
+
let token;
|
|
2325
|
+
try {
|
|
2326
|
+
const selected = names.find((name) => env[name]);
|
|
2327
|
+
if (selected) token = String(env[selected]);
|
|
2328
|
+
else {
|
|
2329
|
+
// This seam never passes through runGh/logGh and never exposes the child
|
|
2330
|
+
// error, stdout or stderr. Credentials exist only in this lexical scope.
|
|
2331
|
+
const result = await (runLocalToken ?? (async (args) => execFileAsync("gh", args, {
|
|
2332
|
+
env: { ...env, ...GH_ENV_OVERRIDES }, timeout: GH_TIMEOUT_MS,
|
|
2333
|
+
maxBuffer: 1024 * 1024, killSignal: "SIGKILL",
|
|
2334
|
+
})))(["auth", "token", "--hostname", host]);
|
|
2335
|
+
token = String(typeof result === "string" ? result : result.stdout ?? "").trim();
|
|
2336
|
+
}
|
|
2337
|
+
if (credentialConfigurationRevision(host, env) !== initialRevision) return { ok: false, reason: "credential-changed" };
|
|
2338
|
+
if (!token || /\s/.test(token)) return { ok: false, reason: "credential-unavailable" };
|
|
2339
|
+
const credentialKey = privateIdentityDigest("credential-v1", host, token);
|
|
2340
|
+
token = null;
|
|
2341
|
+
return { ok: true, value: { host, credentialKey, configurationRevision: initialRevision } };
|
|
2342
|
+
} catch {
|
|
2343
|
+
return { ok: false, reason: "credential-unavailable" };
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function emptyIdentityRegistry() {
|
|
2348
|
+
return { version: IDENTITY_VERSION, migration: { activated: false, holdUntil: 0 }, identities: {}, attempts: {}, hosts: {} };
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
function normalizeIdentityRegistry(raw) {
|
|
2352
|
+
if (!exactKeys(raw, ["version", "migration", "identities", "attempts", "hosts"]) || raw.version !== IDENTITY_VERSION ||
|
|
2353
|
+
!exactKeys(raw.migration, ["activated", "holdUntil"]) || typeof raw.migration.activated !== "boolean" ||
|
|
2354
|
+
!Number.isFinite(raw.migration.holdUntil) || raw.migration.holdUntil < 0 ||
|
|
2355
|
+
!isRecord(raw.identities) || !isRecord(raw.attempts) || !isRecord(raw.hosts) ||
|
|
2356
|
+
Object.keys(raw.attempts).length > IDENTITY_MAX_ATTEMPTS) return null;
|
|
2357
|
+
const digest = (value) => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
2358
|
+
const time = (value) => Number.isFinite(value) && value >= 0;
|
|
2359
|
+
for (const [key, identity] of Object.entries(raw.identities)) {
|
|
2360
|
+
if (!digest(key) || !exactKeys(identity, ["host", "kind", "id", "login", "quotaKey", "accessKey", "generation", "observedAt"]) ||
|
|
2361
|
+
!normalizeHost(identity.host) || identity.kind !== "user" || !Number.isSafeInteger(identity.id) || identity.id <= 0 ||
|
|
2362
|
+
typeof identity.login !== "string" || identity.login !== safe(identity.login) || !digest(identity.quotaKey) || !digest(identity.accessKey) ||
|
|
2363
|
+
!Number.isSafeInteger(identity.generation) || identity.generation < 1 || !time(identity.observedAt)) return null;
|
|
2364
|
+
}
|
|
2365
|
+
for (const [id, attempt] of Object.entries(raw.attempts)) {
|
|
2366
|
+
if (!validGovernorId(id) || !exactKeys(attempt, ["credentialKey", "host", "resource", "startedAt", "retryAt", "ownerPid", "nonce", "status", "quotaKey", "resetMs", "accounted", "imported", "exceptional"]) ||
|
|
2367
|
+
!digest(attempt.credentialKey) || !normalizeHost(attempt.host) || !RATE_RESOURCES.includes(attempt.resource) ||
|
|
2368
|
+
!time(attempt.startedAt) || !time(attempt.retryAt) || !Number.isSafeInteger(attempt.ownerPid) || attempt.ownerPid < 1 || !validGovernorId(attempt.nonce) ||
|
|
2369
|
+
!["started", "finished", "uncertain"].includes(attempt.status) || (attempt.quotaKey !== null && !digest(attempt.quotaKey)) ||
|
|
2370
|
+
(attempt.resetMs !== null && !time(attempt.resetMs)) || typeof attempt.accounted !== "boolean" || typeof attempt.imported !== "boolean" || typeof attempt.exceptional !== "boolean") return null;
|
|
2371
|
+
}
|
|
2372
|
+
for (const [host, transport] of Object.entries(raw.hosts)) {
|
|
2373
|
+
if (!isRecord(transport)) return null;
|
|
2374
|
+
const keys = ["lastStartedAt", "cooldownUntil", "permit"];
|
|
2375
|
+
if (Object.hasOwn(transport, "waiters")) keys.push("waiters");
|
|
2376
|
+
if (!normalizeHost(host) || !exactKeys(transport, keys) ||
|
|
2377
|
+
!time(transport.lastStartedAt) || !time(transport.cooldownUntil)) return null;
|
|
2378
|
+
if (transport.permit !== null && (!exactKeys(transport.permit, ["pid", "nonce", "startedAt"]) ||
|
|
2379
|
+
!Number.isSafeInteger(transport.permit.pid) || transport.permit.pid < 1 || !validGovernorId(transport.permit.nonce) || !time(transport.permit.startedAt))) return null;
|
|
2380
|
+
if (!Object.hasOwn(transport, "waiters")) transport.waiters = [];
|
|
2381
|
+
if (!Array.isArray(transport.waiters) || transport.waiters.length > HTTP_MAX_WAITERS ||
|
|
2382
|
+
new Set(transport.waiters.map((waiter) => waiter?.nonce)).size !== transport.waiters.length ||
|
|
2383
|
+
transport.waiters.some((waiter) => !exactKeys(waiter, ["pid", "nonce", "queuedAt", "deadline"]) ||
|
|
2384
|
+
!Number.isSafeInteger(waiter.pid) || waiter.pid < 1 || !validGovernorId(waiter.nonce) ||
|
|
2385
|
+
!time(waiter.queuedAt) || !time(waiter.deadline) || waiter.deadline <= waiter.queuedAt ||
|
|
2386
|
+
waiter.deadline - waiter.queuedAt > GH_TIMEOUT_MS)) return null;
|
|
2387
|
+
}
|
|
2388
|
+
return raw;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
function withIdentityRegistry(root, operation, { now = Date.now(), kill = process.kill.bind(process) } = {}) {
|
|
2392
|
+
const path = join(root, "registry.json");
|
|
2393
|
+
const scope = { path, hash: "identity-registry", host: "github.com", identityProvider: null, kill };
|
|
2394
|
+
return withGovernorLock(scope, () => {
|
|
2395
|
+
// Read and parse are separated for the same reason readGovernorState
|
|
2396
|
+
// separates them: EACCES, EIO or EMFILE mean "try again", while unparseable
|
|
2397
|
+
// bytes mean "stop and preserve the evidence". Collapsing the two reported a
|
|
2398
|
+
// transient descriptor shortage as permanent corruption.
|
|
2399
|
+
let raw = null;
|
|
2400
|
+
try { raw = readFileSync(path, "utf8"); }
|
|
2401
|
+
catch (error) { if (error?.code !== "ENOENT") return { ok: false, reason: "unwritable" }; }
|
|
2402
|
+
let state;
|
|
2403
|
+
if (raw === null) state = emptyIdentityRegistry();
|
|
2404
|
+
else {
|
|
2405
|
+
try { state = normalizeIdentityRegistry(JSON.parse(raw)); }
|
|
2406
|
+
catch { return { ok: false, reason: "corrupt" }; }
|
|
2407
|
+
}
|
|
2408
|
+
if (!state) return { ok: false, reason: "corrupt" };
|
|
2409
|
+
// Captured before the prunes below so their deletions still count as a
|
|
2410
|
+
// change. Every governor operation -- including read-only inspection --
|
|
2411
|
+
// funnels through here, and each one used to serialize and rename the
|
|
2412
|
+
// registry while holding the one machine-wide lock.
|
|
2413
|
+
const before = JSON.stringify(state);
|
|
2414
|
+
for (const transport of Object.values(state.hosts)) {
|
|
2415
|
+
transport.waiters = transport.waiters.filter((waiter) => waiter.deadline > now && !pidIsDead(waiter.pid, kill));
|
|
2416
|
+
}
|
|
2417
|
+
for (const [id, attempt] of Object.entries(state.attempts)) {
|
|
2418
|
+
// Past its rolling window *and* past any backoff it still imposes. retryAt
|
|
2419
|
+
// is what carries known exhaustion (settlement pushes it out to reset plus
|
|
2420
|
+
// grace, beyond the window), so testing it is what lets an aged-out entry
|
|
2421
|
+
// be dropped without forgiving a hold that is still in force.
|
|
2422
|
+
if (attempt.startedAt + IDENTITY_ATTEMPT_WINDOW_MS > now || attempt.retryAt > now) continue;
|
|
2423
|
+
const identity = state.identities[attempt.credentialKey];
|
|
2424
|
+
// No identity for this credential means no quota scope, so there is no
|
|
2425
|
+
// ledger receipt the attempt could be holding and nothing to reconcile.
|
|
2426
|
+
// These are the failed bootstraps -- they never reach importIdentityDebts,
|
|
2427
|
+
// so they used to be immortal and wedged claimIdentityBootstrap at
|
|
2428
|
+
// IDENTITY_MAX_ATTEMPTS permanently, with deleting registry.json by hand
|
|
2429
|
+
// as the only way out.
|
|
2430
|
+
if (!identity) { delete state.attempts[id]; continue; }
|
|
2431
|
+
const scope = { ...createQuotaScope({ ...identity, credentialKey: attempt.credentialKey }, { root, now: () => now, kill }), rootLocked: true };
|
|
2432
|
+
const retired = withGovernorLock(scope, () => {
|
|
2433
|
+
const ledger = readIdentityQuotaState(state, scope, now);
|
|
2434
|
+
if (!ledger.ok || ledger.missing) return ledger.ok ? { ok: false, reason: "corrupt" } : ledger;
|
|
2435
|
+
const budget = ledger.value.budgets[attempt.resource];
|
|
2436
|
+
const resetCovered = attempt.resetMs !== null
|
|
2437
|
+
? now >= attempt.resetMs && budget?.resetMs > attempt.resetMs
|
|
2438
|
+
// An attempt whose observer never returned core evidence has no reset
|
|
2439
|
+
// of its own to wait for. A window boundary proven to fall after it
|
|
2440
|
+
// started covers its charge just as well, and is the only thing that
|
|
2441
|
+
// stops the entry from being retained for the life of the install.
|
|
2442
|
+
: budget?.resetMs > attempt.startedAt + IDENTITY_UNCERTAIN_MAX_MS;
|
|
2443
|
+
if (!attempt.accounted && !resetCovered) return { ok: true, value: false };
|
|
2444
|
+
delete ledger.value.reservations[`reservation:${id}`];
|
|
2445
|
+
const write = writeGovernorState(scope.path, ledger.value);
|
|
2446
|
+
return write.ok ? { ok: true, value: true } : write;
|
|
2447
|
+
});
|
|
2448
|
+
if (!retired.ok) return retired;
|
|
2449
|
+
if (retired.value) delete state.attempts[id];
|
|
2450
|
+
}
|
|
2451
|
+
const result = operation(state, now);
|
|
2452
|
+
if (!normalizeIdentityRegistry(state)) return { ok: false, reason: "corrupt" };
|
|
2453
|
+
if (JSON.stringify(state) === before) return result;
|
|
2454
|
+
const written = writeGovernorState(path, state);
|
|
2455
|
+
return written.ok ? result : written;
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
function inspectIdentityRegistry(root, options) {
|
|
2460
|
+
return withIdentityRegistry(root, (state) => ({ ok: true, value: structuredClone(state) }), options);
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
function inspectLegacyMigration(root, state, now) {
|
|
2464
|
+
let names;
|
|
2465
|
+
try { names = readdirSync(dirname(root)).filter((name) => /^rate-governor-v1-[0-9a-f]{64}\.json$/.test(name)); }
|
|
2466
|
+
catch (error) { return { ok: false, reason: error.code === "ENOENT" ? "unwritable" : "corrupt" }; }
|
|
2467
|
+
let holdUntil = state.migration.holdUntil;
|
|
2468
|
+
for (const name of names) {
|
|
2469
|
+
let raw;
|
|
2470
|
+
try { raw = JSON.parse(readFileSync(join(dirname(root), name), "utf8")); } catch { return { ok: false, reason: "legacy-corrupt" }; }
|
|
2471
|
+
// Evidence is never rewritten. Recognize only the pinned legacy schemas.
|
|
2472
|
+
// Every protocol an older pane could still be running, newest first. The set
|
|
2473
|
+
// grows as GOVERNOR_STATE_VERSION advances: a version bump makes an old
|
|
2474
|
+
// binary fail closed on its own gate, but this side must still recognise
|
|
2475
|
+
// that binary's file well enough to see it holds a live lease.
|
|
2476
|
+
const legacy = LEGACY_GOVERNOR_VERSIONS
|
|
2477
|
+
.map((version) => normalizeGovernorState(raw, now, { prune: false, acceptVersion: version }))
|
|
2478
|
+
.find(Boolean) ?? migrateGovernorState(raw, now);
|
|
2479
|
+
if (legacy && raw.version === 1 && raw.budgets?.core) {
|
|
2480
|
+
const core = raw.budgets.core;
|
|
2481
|
+
const preserved = normalizeGovernorBudget({ ...core, source: "core-observer", factorBaseline: { epoch: core.epoch, used: core.used, observedAt: core.observedAt }, knownLocalUsed: 0 }, now, "core");
|
|
2482
|
+
if (!preserved) return { ok: false, reason: "legacy-corrupt" };
|
|
2483
|
+
// This is a migration hold view only, never published as new authority.
|
|
2484
|
+
legacy.budgets.core = preserved;
|
|
2485
|
+
legacy.epochs.core = preserved.epoch;
|
|
2486
|
+
}
|
|
2487
|
+
if (!legacy) return { ok: false, reason: "legacy-corrupt" };
|
|
2488
|
+
if (Object.values(legacy.leases).some((lease) => lease.expiresAt > now)) return { ok: false, reason: "restart-required" };
|
|
2489
|
+
for (const resource of RATE_RESOURCES) {
|
|
2490
|
+
const budget = legacy.budgets[resource];
|
|
2491
|
+
holdUntil = Math.max(holdUntil, budget?.blockUntil ?? 0);
|
|
2492
|
+
const uncertain = Object.values(legacy.reservations).some((reservation) =>
|
|
2493
|
+
["started", "completed"].includes(reservation.status) && reservationCost(reservation, resource, legacy.leases, now) > 0);
|
|
2494
|
+
if (uncertain) {
|
|
2495
|
+
const reset = budget?.resetMs;
|
|
2496
|
+
if (!Number.isFinite(reset)) return { ok: false, reason: "legacy-unresolved" };
|
|
2497
|
+
holdUntil = Math.max(holdUntil, reset + BUDGET_RESET_GRACE_MS);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
state.migration.holdUntil = holdUntil;
|
|
2502
|
+
if (holdUntil > now) return { ok: false, reason: "migration-hold", retryAt: holdUntil };
|
|
2503
|
+
// Deliberately a record, not a gate. Re-listing the legacy directory on every
|
|
2504
|
+
// operation is what satisfies "detect reappearing legacy leases during
|
|
2505
|
+
// operation": an old binary started after migration must still be able to
|
|
2506
|
+
// pause new admission, and it cannot announce itself through a sentinel it
|
|
2507
|
+
// does not read. Short-circuiting on this flag would be faster and wrong.
|
|
2508
|
+
state.migration.activated = true;
|
|
2509
|
+
return { ok: true };
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
function claimIdentityBootstrap(root, { credentialKey, host, resource = "core", now = Date.now(), kill = process.kill.bind(process) } = {}) {
|
|
2513
|
+
return withIdentityRegistry(root, (state) => {
|
|
2514
|
+
const migration = inspectLegacyMigration(root, state, now);
|
|
2515
|
+
if (!migration.ok) return migration;
|
|
2516
|
+
const transport = state.hosts[host] ??= { lastStartedAt: 0, cooldownUntil: 0, permit: null };
|
|
2517
|
+
if (transport.permit && staleHttpPermit(transport.permit, now, kill)) transport.permit = null;
|
|
2518
|
+
if (transport.permit || transport.waiters?.length) return { ok: false, reason: "identity-busy", retryAt: now + 1000 };
|
|
2519
|
+
const attempts = Object.values(state.attempts);
|
|
2520
|
+
const recent = attempts.filter((attempt) => attempt.startedAt > now - IDENTITY_ATTEMPT_WINDOW_MS);
|
|
2521
|
+
const own = recent.filter((attempt) => attempt.credentialKey === credentialKey && attempt.resource === resource && attempt.exceptional);
|
|
2522
|
+
const unknown = recent.filter((attempt) => attempt.host === host && attempt.exceptional);
|
|
2523
|
+
const retryAt = Math.max(transport.cooldownUntil, transport.lastStartedAt + HTTP_START_GAP_MS,
|
|
2524
|
+
...attempts.filter((attempt) => attempt.credentialKey === credentialKey && attempt.resource === resource).map((attempt) => attempt.retryAt),
|
|
2525
|
+
own.length >= IDENTITY_ATTEMPT_LIMIT ? Math.min(...own.map((attempt) => attempt.startedAt)) + IDENTITY_ATTEMPT_WINDOW_MS : 0,
|
|
2526
|
+
unknown.length >= IDENTITY_HOST_ATTEMPT_LIMIT ? Math.min(...unknown.map((attempt) => attempt.startedAt)) + IDENTITY_ATTEMPT_WINDOW_MS : 0);
|
|
2527
|
+
if (retryAt > now) return { ok: false, reason: "identity-backoff", retryAt };
|
|
2528
|
+
if (attempts.length >= IDENTITY_MAX_ATTEMPTS) return { ok: false, reason: "identity-capacity" };
|
|
2529
|
+
const id = governorId();
|
|
2530
|
+
const nonce = governorId();
|
|
2531
|
+
state.attempts[id] = { credentialKey, host, resource, startedAt: now, retryAt: now + attemptRetryDelayMs(own.length),
|
|
2532
|
+
ownerPid: process.pid, nonce, status: "started", quotaKey: null, resetMs: null, accounted: false, imported: false, exceptional: true };
|
|
2533
|
+
transport.permit = { pid: process.pid, nonce, startedAt: now };
|
|
2534
|
+
transport.lastStartedAt = now;
|
|
2535
|
+
return { ok: true, value: { id, nonce } };
|
|
2536
|
+
}, { now, kill });
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
function readIdentityQuotaState(registry, scope, now) {
|
|
2540
|
+
const loaded = readGovernorState(scope.path, now);
|
|
2541
|
+
const known = Object.values(registry.identities).some((identity) => identity.quotaKey === scope.quotaKey) ||
|
|
2542
|
+
Object.values(registry.attempts).some((attempt) => attempt.imported && attempt.quotaKey === scope.quotaKey);
|
|
2543
|
+
return loaded.ok && loaded.missing && known ? { ok: false, reason: "corrupt" } : loaded;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
function importIdentityDebts(root, state, credentialKey, identity, now) {
|
|
2547
|
+
const scope = { ...createQuotaScope({ ...identity, credentialKey }, { root, now: () => now }), rootLocked: true };
|
|
2548
|
+
return withGovernorLock(scope, () => {
|
|
2549
|
+
const loaded = readIdentityQuotaState(state, scope, now);
|
|
2550
|
+
if (!loaded.ok) return loaded;
|
|
2551
|
+
const transferred = [];
|
|
2552
|
+
for (const [id, attempt] of Object.entries(state.attempts)) {
|
|
2553
|
+
if (attempt.credentialKey !== credentialKey || attempt.imported) continue;
|
|
2554
|
+
const reservationId = `reservation:${id}`;
|
|
2555
|
+
loaded.value.reservations[reservationId] ??= {
|
|
2556
|
+
leaseId: id, intentId: id, costs: { core: 0, graphql: 0, [attempt.resource]: 1 }, actualCosts: null,
|
|
2557
|
+
accountedCosts: { core: 0, graphql: 0, [attempt.resource]: attempt.accounted ? 1 : 0 },
|
|
2558
|
+
notBefore: attempt.startedAt, status: "started", epochs: { core: null, graphql: null },
|
|
2559
|
+
startedAt: attempt.startedAt, completedAt: null, outcome: null,
|
|
2560
|
+
};
|
|
2561
|
+
transferred.push(id);
|
|
2562
|
+
}
|
|
2563
|
+
// importIdentityDebts writes this ledger before the registry transaction
|
|
2564
|
+
// that records the mapping commits. A crash in between leaves a charged
|
|
2565
|
+
// receipt here whose attempt is gone from the registry, and nothing else
|
|
2566
|
+
// ever looks at it again -- it presents only as a permanently smaller
|
|
2567
|
+
// budget. Re-bootstrapping the same credential is exactly when it can be
|
|
2568
|
+
// recognised, and a full window since it started proves a reset covered it.
|
|
2569
|
+
for (const key of Object.keys(loaded.value.reservations)) {
|
|
2570
|
+
const attemptId = /^reservation:(.+)$/.exec(key)?.[1];
|
|
2571
|
+
if (!attemptId || state.attempts[attemptId]) continue;
|
|
2572
|
+
const reservation = loaded.value.reservations[key];
|
|
2573
|
+
if (reservation.startedAt + IDENTITY_UNCERTAIN_MAX_MS > now) continue;
|
|
2574
|
+
delete loaded.value.reservations[key];
|
|
2575
|
+
}
|
|
2576
|
+
const written = writeGovernorState(scope.path, loaded.value);
|
|
2577
|
+
if (!written.ok) return written;
|
|
2578
|
+
// Marked transferred only once the receipts are durable. withIdentityRegistry
|
|
2579
|
+
// persists the registry whatever this returns, so setting these before the
|
|
2580
|
+
// ledger write meant a failed write could leave the registry claiming a debt
|
|
2581
|
+
// had moved to a ledger that never received it -- and the `imported` guard
|
|
2582
|
+
// would then skip those attempts forever, dropping the conservative charge.
|
|
2583
|
+
for (const id of transferred) {
|
|
2584
|
+
state.attempts[id].quotaKey = identity.quotaKey;
|
|
2585
|
+
state.attempts[id].imported = true;
|
|
2586
|
+
}
|
|
2587
|
+
return written;
|
|
2588
|
+
});
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
function finishIdentityBootstrap(root, { credentialKey, id, nonce, response = null, now = Date.now(), isCurrent = () => true } = {}) {
|
|
2592
|
+
return withIdentityRegistry(root, (state) => {
|
|
2593
|
+
const attempt = state.attempts[id];
|
|
2594
|
+
if (!attempt || attempt.nonce !== nonce || attempt.credentialKey !== credentialKey) return { ok: false, reason: "stale" };
|
|
2595
|
+
const transport = state.hosts[attempt.host];
|
|
2596
|
+
if (transport.permit?.nonce === nonce) transport.permit = null;
|
|
2597
|
+
attempt.status = response ? "finished" : "uncertain";
|
|
2598
|
+
const parsed = response?.body;
|
|
2599
|
+
const evidence = response?.rateLimit;
|
|
2600
|
+
applyTransportCooldown(transport, { retryAfter: response?.retryAfter, status: response?.status, secondary: response?.secondary, at: now });
|
|
2601
|
+
if (evidence?.resource === "core" && normalizeBudgetResource(evidence, now)) {
|
|
2602
|
+
attempt.resetMs = evidence.resetMs;
|
|
2603
|
+
if (evidence.remaining === 0) attempt.retryAt = Math.max(attempt.retryAt, evidence.resetMs + BUDGET_RESET_GRACE_MS);
|
|
2604
|
+
}
|
|
2605
|
+
if (!isCurrent()) return { ok: false, reason: "stale" };
|
|
2606
|
+
if ((response?.status ?? 200) !== 200 || !isRecord(parsed) || !Number.isSafeInteger(parsed.id) || parsed.id <= 0 || typeof parsed.login !== "string" ||
|
|
2607
|
+
parsed.login.length === 0 || !evidence || evidence.resource !== "core" || !normalizeBudgetResource(evidence, now)) return { ok: false, reason: "identity-unavailable" };
|
|
2608
|
+
const generation = state.identities[credentialKey]?.generation ?? 1;
|
|
2609
|
+
const identity = { host: attempt.host, kind: "user", id: parsed.id, login: safe(parsed.login),
|
|
2610
|
+
quotaKey: privateIdentityDigest("quota-v2", attempt.host, "user", parsed.id),
|
|
2611
|
+
accessKey: privateIdentityDigest("access-v2", credentialKey, generation), generation, observedAt: now };
|
|
2612
|
+
attempt.accounted = true;
|
|
2613
|
+
for (const previous of Object.values(state.attempts)) {
|
|
2614
|
+
if (previous.credentialKey === credentialKey && previous.resource === "core") previous.resetMs ??= evidence.resetMs;
|
|
2615
|
+
}
|
|
2616
|
+
// Record the mapping only after the ledger contains every debt. A crash
|
|
2617
|
+
// between writes replays stable reservation IDs while the root lock prevents
|
|
2618
|
+
// observers from deleting receipts before the mapping transaction finishes.
|
|
2619
|
+
const imported = importIdentityDebts(root, state, credentialKey, identity, now);
|
|
2620
|
+
if (!imported.ok) return imported;
|
|
2621
|
+
// The admitted identity proof supplies core authority. Retain its validator
|
|
2622
|
+
// so later observers can confirm unchanged identity without primary spend.
|
|
2623
|
+
{
|
|
2624
|
+
const scope = { ...createQuotaScope({ ...identity, credentialKey }, { root, now: () => now }), rootLocked: true };
|
|
2625
|
+
const seeded = withGovernorLock(scope, () => {
|
|
2626
|
+
const ledger = readIdentityQuotaState(state, scope, now);
|
|
2627
|
+
if (!ledger.ok) return ledger;
|
|
2628
|
+
const observation = budgetFromObservation(evidence, ledger.value.budgets.core, now, { resource: "core", source: "core-observer", receivedAt: now, allowEpochChange: true });
|
|
2629
|
+
if (observation.status === "accepted") {
|
|
2630
|
+
ledger.value.budgets.core = observation.budget;
|
|
2631
|
+
ledger.value.epochs.core = observation.budget.epoch;
|
|
2632
|
+
ledger.value.observers.core = { etag: typeof response.etag === "string" ? response.etag : null, outcome: "healthy", at: now, nextAt: now + BUDGET_PROBE_MS };
|
|
2633
|
+
}
|
|
2634
|
+
return writeGovernorState(scope.path, ledger.value);
|
|
2635
|
+
});
|
|
2636
|
+
if (!seeded.ok) return seeded;
|
|
2637
|
+
}
|
|
2638
|
+
if (!isCurrent()) return { ok: false, reason: "stale" };
|
|
2639
|
+
state.identities[credentialKey] = identity;
|
|
2640
|
+
return { ok: true, value: { ...identity, credentialKey } };
|
|
2641
|
+
}, { now });
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
function createSettlementContext(scope, coordinator) {
|
|
2645
|
+
return { scope: { ...scope, identityProvider: null }, isCurrent: () => coordinator.current()?.accessKey === scope.accessKey };
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
function createQuotaScope(identity, { root = identityRegistryRoot(), now = Date.now, identityProvider = null, kill = process.kill.bind(process) } = {}) {
|
|
2649
|
+
return { hash: identity.quotaKey, quotaKey: identity.quotaKey, credentialKey: identity.credentialKey, accessKey: identity.accessKey,
|
|
2650
|
+
path: join(root, `quota-${identity.quotaKey}.json`), coordinationRoot: root, host: identity.host, authIdentity: identity.quotaKey,
|
|
2651
|
+
identityProvider, now, kill };
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
function createIdentityCoordinator({ host, pathOptions = {}, env = process.env, now = Date.now, kill = process.kill.bind(process), runLocalToken, requestIdentity } = {}) {
|
|
2655
|
+
const root = identityRegistryRoot(pathOptions);
|
|
2656
|
+
let current = null;
|
|
2657
|
+
let revision = null;
|
|
2658
|
+
let pending = null;
|
|
2659
|
+
let failure = null;
|
|
2660
|
+
let resolvedCredential = null;
|
|
2661
|
+
let closed = false;
|
|
2662
|
+
const bootstrapAbort = new AbortController();
|
|
2663
|
+
const completions = new Map();
|
|
2664
|
+
const deferCompletion = (key, operation) => completions.set(key, operation);
|
|
2665
|
+
const flushCompletions = async () => {
|
|
2666
|
+
for (const [key, operation] of completions) {
|
|
2667
|
+
const result = await retryIdentityCompletion(operation, { shouldRetry: () => !closed });
|
|
2668
|
+
if (!result.ok && ["busy", "unwritable"].includes(result.reason)) return result;
|
|
2669
|
+
// A malformed/denied proof is a completed durable attempt, not pending
|
|
2670
|
+
// storage work. Drop that callback so ordinary backoff can recover.
|
|
2671
|
+
completions.delete(key);
|
|
2672
|
+
if (!result.ok && result.reason === "corrupt") return result;
|
|
2673
|
+
}
|
|
2674
|
+
return { ok: true };
|
|
2675
|
+
};
|
|
2676
|
+
const resolveHost = () => normalizeHost(typeof host === "function" ? host() : host);
|
|
2677
|
+
const snapshot = () => {
|
|
2678
|
+
if (closed) return null;
|
|
2679
|
+
const selectedHost = resolveHost();
|
|
2680
|
+
if (current && (current.host !== selectedHost || credentialConfigurationRevision(selectedHost, env) !== revision)) current = null;
|
|
2681
|
+
return current;
|
|
2682
|
+
};
|
|
2683
|
+
const refresh = ({ allowBootstrap = true } = {}) => {
|
|
2684
|
+
if (closed) return Promise.resolve({ ok: false, reason: "closed" });
|
|
2685
|
+
if (pending) return pending;
|
|
2686
|
+
pending = (async () => {
|
|
2687
|
+
const completed = await flushCompletions();
|
|
2688
|
+
if (!completed.ok) { failure = completed; return completed; }
|
|
2689
|
+
const selectedHost = resolveHost();
|
|
2690
|
+
const wantedRevision = selectedHost ? credentialConfigurationRevision(selectedHost, env) : null;
|
|
2691
|
+
const resolved = resolvedCredential?.value?.configurationRevision === wantedRevision
|
|
2692
|
+
? resolvedCredential : await resolveEffectiveCredential({ host: selectedHost, env, runLocalToken });
|
|
2693
|
+
if (resolved.ok) resolvedCredential = resolved;
|
|
2694
|
+
if (closed) return { ok: false, reason: "closed" };
|
|
2695
|
+
if (!resolved.ok) { current = null; failure = resolved; return resolved; }
|
|
2696
|
+
revision = resolved.value.configurationRevision;
|
|
2697
|
+
const { credentialKey } = resolved.value;
|
|
2698
|
+
const stored = withIdentityRegistry(root, (state) => {
|
|
2699
|
+
const migration = inspectLegacyMigration(root, state, now());
|
|
2700
|
+
if (!migration.ok) return migration;
|
|
2701
|
+
const identity = state.identities[credentialKey];
|
|
2702
|
+
return { ok: true, value: identity ? { ...identity, credentialKey } : null };
|
|
2703
|
+
}, { now: now(), kill });
|
|
2704
|
+
// A retryable lock failure says nothing about the identity, and nulling
|
|
2705
|
+
// `current` for one makes ensureScope retire the lease and blank every
|
|
2706
|
+
// tab -- discarding the ETags that make the next round cheap.
|
|
2707
|
+
if (!stored.ok) { if (!retryableCoordination(stored.reason)) current = null; failure = stored; return stored; }
|
|
2708
|
+
if (stored.value) { current = stored.value; failure = null; return { ok: true, value: current }; }
|
|
2709
|
+
if (!allowBootstrap) {
|
|
2710
|
+
failure = { ok: false, reason: "identity-unavailable" };
|
|
2711
|
+
return failure;
|
|
2712
|
+
}
|
|
2713
|
+
const claim = claimIdentityBootstrap(root, { credentialKey, host: selectedHost, now: now(), kill });
|
|
2714
|
+
if (!claim.ok) { if (!retryableCoordination(claim.reason)) current = null; failure = claim; return claim; }
|
|
2715
|
+
const proofRevision = resolved.value.configurationRevision;
|
|
2716
|
+
// Check after claim lock waits as well as inside deferred completion:
|
|
2717
|
+
// either wait can outlive the host/configuration that selected this token.
|
|
2718
|
+
const isCurrent = () => !closed && resolveHost() === selectedHost && credentialConfigurationRevision(selectedHost, env) === proofRevision;
|
|
2719
|
+
let response = null;
|
|
2720
|
+
try {
|
|
2721
|
+
if (isCurrent()) response = await (requestIdentity ?? (async (requestHost, { signal }) => {
|
|
2722
|
+
let stdout;
|
|
2723
|
+
try {
|
|
2724
|
+
({ stdout } = await execFileAsync("gh", ["api", "-i", "user", "--hostname", requestHost], {
|
|
2725
|
+
env: { ...env, ...GH_ENV_OVERRIDES }, timeout: GH_TIMEOUT_MS, maxBuffer: GH_MAX_BUFFER, killSignal: "SIGKILL", signal,
|
|
2726
|
+
}));
|
|
2727
|
+
} catch (error) { stdout = error.stdout ?? ""; }
|
|
2728
|
+
const parsed = parseGhApiResponse(stdout);
|
|
2729
|
+
let body = null;
|
|
2730
|
+
try { body = JSON.parse(parsed.body); } catch { /* selected error evidence is still useful */ }
|
|
2731
|
+
return { status: parsed.status, body, rateLimit: pickRateLimit(parsed.headers), etag: parsed.headers.etag ?? null,
|
|
2732
|
+
retryAfter: parsed.headers["retry-after"] ?? null, secondary: SECONDARY_LIMIT_PATTERN.test(String(body?.message ?? "")) };
|
|
2733
|
+
}))(selectedHost, { signal: bootstrapAbort.signal });
|
|
2734
|
+
} catch { /* raw authentication errors never leave the credential seam */ }
|
|
2735
|
+
const finish = () => finishIdentityBootstrap(root, { credentialKey, ...claim.value, response, now: now(), isCurrent });
|
|
2736
|
+
const settled = await retryIdentityCompletion(finish, { shouldRetry: () => !closed });
|
|
2737
|
+
if (!settled.ok && ["busy", "unwritable"].includes(settled.reason)) deferCompletion(`bootstrap:${claim.value.id}`, finish);
|
|
2738
|
+
if (!isCurrent()) { current = null; return { ok: false, reason: "stale" }; }
|
|
2739
|
+
current = settled.ok ? settled.value : null;
|
|
2740
|
+
failure = settled.ok ? null : settled;
|
|
2741
|
+
return settled;
|
|
2742
|
+
})().finally(() => { pending = null; });
|
|
2743
|
+
return pending;
|
|
2744
|
+
};
|
|
2745
|
+
return { root, current: snapshot, refresh, deferCompletion, flushCompletions, inspect: () => failure, isClosed: () => closed, close: () => { closed = true; current = null; bootstrapAbort.abort(); } };
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
async function retryIdentityCompletion(operation, { timeoutMs = GH_TIMEOUT_MS, shouldRetry = () => true } = {}) {
|
|
2749
|
+
const deadline = performance.now() + timeoutMs;
|
|
2750
|
+
for (;;) {
|
|
2751
|
+
const result = operation();
|
|
2752
|
+
if (result.ok || !retryableCoordination(result.reason) || !shouldRetry() || performance.now() >= deadline) return result;
|
|
2753
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2754
|
+
// Shutdown retains the unresolved receipt; do not keep the CLI alive to
|
|
2755
|
+
// retry a lock owned by another live process. Account switches still retry.
|
|
2756
|
+
if (!shouldRetry()) return result;
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
function startIdentityControl(coordinator, now = Date.now()) {
|
|
2761
|
+
const identity = coordinator.current();
|
|
2762
|
+
if (!identity) return { ok: false, reason: "identity-unavailable" };
|
|
2763
|
+
return withIdentityRegistry(coordinator.root, (registry) => {
|
|
2764
|
+
const scope = { ...createQuotaScope(identity, { root: coordinator.root, now: () => now }), rootLocked: true };
|
|
2765
|
+
return withGovernorLock(scope, () => {
|
|
2766
|
+
const loaded = readIdentityQuotaState(registry, scope, now);
|
|
2767
|
+
if (!loaded.ok) return loaded;
|
|
2768
|
+
const state = loaded.value;
|
|
2769
|
+
const budget = state.budgets.core;
|
|
2770
|
+
const exceptional = !budget || now >= budget.resetMs + BUDGET_RESET_GRACE_MS;
|
|
2771
|
+
if (budget && now >= budget.resetMs && !exceptional) return { ok: false, reason: "identity-backoff", retryAt: budget.resetMs + BUDGET_RESET_GRACE_MS };
|
|
2772
|
+
if (!exceptional) {
|
|
2773
|
+
const reserved = Object.values(state.reservations).reduce((sum, reservation) => sum + reservationCost(reservation, "core", state.leases, now), 0);
|
|
2774
|
+
if (budget.remaining - resourceReserve(budget.limit) - reserved < 1) return { ok: false, reason: "identity-backoff", retryAt: budget.resetMs + BUDGET_RESET_GRACE_MS };
|
|
2775
|
+
}
|
|
2776
|
+
const recent = Object.values(registry.attempts).filter((attempt) => attempt.credentialKey === identity.credentialKey && attempt.resource === "core" && attempt.exceptional && attempt.startedAt > now - IDENTITY_ATTEMPT_WINDOW_MS);
|
|
2777
|
+
const retryAt = Math.max(0, ...recent.map((attempt) => attempt.retryAt), recent.length >= IDENTITY_ATTEMPT_LIMIT ? Math.min(...recent.map((attempt) => attempt.startedAt)) + IDENTITY_ATTEMPT_WINDOW_MS : 0);
|
|
2778
|
+
if (exceptional && retryAt > now) return { ok: false, reason: "identity-backoff", retryAt };
|
|
2779
|
+
if (Object.keys(registry.attempts).length >= IDENTITY_MAX_ATTEMPTS || Object.keys(state.reservations).length >= GOVERNOR_MAX_RESERVATIONS) return { ok: false, reason: "identity-capacity" };
|
|
2780
|
+
const id = governorId();
|
|
2781
|
+
registry.attempts[id] = { credentialKey: identity.credentialKey, host: identity.host, resource: "core", startedAt: now,
|
|
2782
|
+
retryAt: exceptional ? now + attemptRetryDelayMs(recent.length) : now, ownerPid: process.pid, nonce: governorId(), status: "started",
|
|
2783
|
+
quotaKey: identity.quotaKey, resetMs: budget?.resetMs ?? null, accounted: false, imported: true, exceptional };
|
|
2784
|
+
state.reservations[`reservation:${id}`] = { leaseId: id, intentId: id, costs: { core: 1, graphql: 0 }, actualCosts: null,
|
|
2785
|
+
accountedCosts: { core: 0, graphql: 0 }, notBefore: now, status: "started", epochs: { core: budget?.epoch ?? null, graphql: null },
|
|
2786
|
+
startedAt: now, completedAt: null, outcome: null };
|
|
2787
|
+
const written = writeGovernorState(scope.path, state);
|
|
2788
|
+
return written.ok ? { ok: true, value: { id, identity } } : written;
|
|
2789
|
+
});
|
|
2790
|
+
}, { now });
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
function settleIdentityControl(coordinator, control, stdout, now = Date.now()) {
|
|
2794
|
+
return withIdentityRegistry(coordinator.root, (registry) => {
|
|
2795
|
+
const attempt = registry.attempts[control.id];
|
|
2796
|
+
if (!attempt) return { ok: false, reason: "stale" };
|
|
2797
|
+
const parsed = parseGhApiResponse(stdout ?? "");
|
|
2798
|
+
const evidence = pickRateLimit(parsed.headers);
|
|
2799
|
+
const proven = [200, 304].includes(parsed.status) && evidence?.resource === "core";
|
|
2800
|
+
attempt.status = proven ? "finished" : "uncertain";
|
|
2801
|
+
attempt.accounted = proven;
|
|
2802
|
+
if (evidence?.resource === "core") {
|
|
2803
|
+
attempt.resetMs = evidence.resetMs;
|
|
2804
|
+
if (evidence.remaining === 0) attempt.retryAt = Math.max(attempt.retryAt, evidence.resetMs + BUDGET_RESET_GRACE_MS);
|
|
2805
|
+
}
|
|
2806
|
+
const scope = { ...createQuotaScope(control.identity, { root: coordinator.root, now: () => now }), rootLocked: true };
|
|
2807
|
+
return withGovernorLock(scope, () => {
|
|
2808
|
+
const loaded = readIdentityQuotaState(registry, scope, now);
|
|
2809
|
+
if (!loaded.ok) return loaded;
|
|
2810
|
+
const reservation = loaded.value.reservations[`reservation:${control.id}`];
|
|
2811
|
+
if (!reservation) return { ok: false, reason: "stale" };
|
|
2812
|
+
if (proven) {
|
|
2813
|
+
const cost = parsed.status === 304 ? 0 : 1;
|
|
2814
|
+
reservation.status = "completed";
|
|
2815
|
+
reservation.completedAt = now;
|
|
2816
|
+
reservation.outcome = "measured-success";
|
|
2817
|
+
reservation.actualCosts = { core: cost, graphql: 0 };
|
|
2818
|
+
reservation.accountedCosts = { core: cost, graphql: 0 };
|
|
2819
|
+
}
|
|
2820
|
+
return writeGovernorState(scope.path, loaded.value);
|
|
2821
|
+
});
|
|
2822
|
+
}, { now });
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
async function acquireIdentityHttpPermit(coordinator, { signal, now = Date.now } = {}) {
|
|
2826
|
+
const flushed = await coordinator.flushCompletions?.();
|
|
2827
|
+
if (flushed && !flushed.ok) throw new Error(identityCoordinationMessage(flushed.reason));
|
|
2828
|
+
const identity = coordinator.current();
|
|
2829
|
+
if (!identity) throw new Error("Verified identity unavailable");
|
|
2830
|
+
const queuedAt = now();
|
|
2831
|
+
const deadline = queuedAt + GH_TIMEOUT_MS;
|
|
2832
|
+
const nonce = governorId();
|
|
2833
|
+
let acquired = false;
|
|
2834
|
+
try {
|
|
2835
|
+
for (;;) {
|
|
2836
|
+
if (signal?.aborted) throw new Error("Request cancelled");
|
|
2837
|
+
if (coordinator.current()?.accessKey !== identity.accessKey) throw new Error("Credential changed before request start");
|
|
2838
|
+
if (now() >= deadline) throw new Error(identityCoordinationMessage("transport-busy"));
|
|
2839
|
+
const result = withIdentityRegistry(coordinator.root, (state) => {
|
|
2840
|
+
const migration = inspectLegacyMigration(coordinator.root, state, now());
|
|
2841
|
+
if (!migration.ok) return migration;
|
|
2842
|
+
if (now() >= deadline) return { ok: false, reason: "transport-busy", retryAt: deadline };
|
|
2843
|
+
const transport = state.hosts[identity.host] ??= { lastStartedAt: 0, cooldownUntil: 0, permit: null, waiters: [] };
|
|
2844
|
+
if (transport.permit && staleHttpPermit(transport.permit, now())) transport.permit = null;
|
|
2845
|
+
// Admission can happen during the mandatory start gap. Persist call
|
|
2846
|
+
// order once so later polling cannot overtake an earlier caller.
|
|
2847
|
+
if (!transport.waiters.some((waiter) => waiter.nonce === nonce)) {
|
|
2848
|
+
if (transport.waiters.length >= HTTP_MAX_WAITERS) return { ok: false, reason: "identity-capacity" };
|
|
2849
|
+
transport.waiters.push({ pid: process.pid, nonce, queuedAt, deadline });
|
|
2850
|
+
}
|
|
2851
|
+
const retryAt = Math.max(transport.cooldownUntil, transport.lastStartedAt + HTTP_START_GAP_MS);
|
|
2852
|
+
if (transport.permit || transport.waiters[0].nonce !== nonce || retryAt > now()) {
|
|
2853
|
+
return { ok: false, reason: "transport-busy", retryAt: Math.max(retryAt, now() + 50) };
|
|
2854
|
+
}
|
|
2855
|
+
transport.waiters.shift();
|
|
2856
|
+
transport.permit = { pid: process.pid, nonce, startedAt: now() };
|
|
2857
|
+
transport.lastStartedAt = now();
|
|
2858
|
+
return { ok: true, value: { nonce, host: identity.host } };
|
|
2859
|
+
}, { now: now() });
|
|
2860
|
+
if (result.ok) { acquired = true; return result.value; }
|
|
2861
|
+
// A contended registry lock is the same class of wait as a held permit:
|
|
2862
|
+
// both mean "not yet", not "no identity". Only transport-busy carries a
|
|
2863
|
+
// retryAt, so give the rest the poll interval the loop already uses.
|
|
2864
|
+
const retryAt = result.retryAt ?? now() + 50;
|
|
2865
|
+
if ((result.reason !== "transport-busy" && !retryableCoordination(result.reason)) ||
|
|
2866
|
+
now() >= deadline || retryAt >= deadline) throw new Error(identityCoordinationMessage(result.reason));
|
|
2867
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(1, Math.min(50, retryAt - now()))));
|
|
2868
|
+
}
|
|
2869
|
+
} finally {
|
|
2870
|
+
if (!acquired) {
|
|
2871
|
+
const cleanup = () => withIdentityRegistry(coordinator.root, (state) => {
|
|
2872
|
+
const transport = state.hosts[identity.host];
|
|
2873
|
+
if (transport) transport.waiters = transport.waiters.filter((waiter) => waiter.nonce !== nonce);
|
|
2874
|
+
return { ok: true };
|
|
2875
|
+
}, { now: now() });
|
|
2876
|
+
const cleaned = cleanup();
|
|
2877
|
+
if (!cleaned.ok) coordinator.deferCompletion?.(`waiter:${nonce}`, cleanup);
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
// One cooldown law, two call sites: the bootstrap observer reads its evidence
|
|
2883
|
+
// from a parsed response, the permit release from a failed subprocess's captured
|
|
2884
|
+
// output. They had already drifted into two implementations with two different
|
|
2885
|
+
// secondary-limit detectors -- which is how a tuning applied to one quietly
|
|
2886
|
+
// stops applying to the other.
|
|
2887
|
+
const SECONDARY_LIMIT_PATTERN = /secondary rate|abuse/i;
|
|
2888
|
+
|
|
2889
|
+
function transportCooldownDeadline({ retryAfter, status, secondary, at }) {
|
|
2890
|
+
const deadline = typeof retryAfter === "string" && /^\d+(?:\.\d+)?$/.test(retryAfter)
|
|
2891
|
+
? at + Number(retryAfter) * 1000
|
|
2892
|
+
: Date.parse(retryAfter ?? "");
|
|
2893
|
+
if (Number.isFinite(deadline)) return deadline;
|
|
2894
|
+
return status === 429 || secondary === true ? at + 60_000 : null;
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
function applyTransportCooldown(transport, evidence) {
|
|
2898
|
+
const deadline = transportCooldownDeadline(evidence);
|
|
2899
|
+
if (deadline !== null) transport.cooldownUntil = Math.max(transport.cooldownUntil, deadline);
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
function releaseIdentityHttpPermit(coordinator, permit, error = null) {
|
|
2903
|
+
return withIdentityRegistry(coordinator.root, (state) => {
|
|
2904
|
+
const transport = state.hosts[permit.host];
|
|
2905
|
+
if (transport?.permit?.nonce === permit.nonce) transport.permit = null;
|
|
2906
|
+
if (transport && error) {
|
|
2907
|
+
const response = parseGhApiResponse(error.stdout ?? "");
|
|
2908
|
+
applyTransportCooldown(transport, {
|
|
2909
|
+
retryAfter: response.headers["retry-after"],
|
|
2910
|
+
status: response.status,
|
|
2911
|
+
secondary: SECONDARY_LIMIT_PATTERN.test(String(error.stderr ?? "")),
|
|
2912
|
+
at: Date.now(),
|
|
2913
|
+
});
|
|
2914
|
+
}
|
|
2915
|
+
return { ok: true };
|
|
2916
|
+
});
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
function identityCoordinationMessage(reason) {
|
|
2920
|
+
if (reason === "restart-required") return "Restart required: close older gh-glance panes";
|
|
2921
|
+
if (["migration-hold", "legacy-unresolved"].includes(reason)) return "Upgrade waiting for legacy quota reset";
|
|
2922
|
+
if (["legacy-corrupt", "corrupt"].includes(reason)) return "Coordination state unavailable; evidence preserved";
|
|
2923
|
+
if (reason === "transport-busy") return "Shared HTTP request or cooldown in progress";
|
|
2924
|
+
if (retryableCoordination(reason)) return "Coordinating with your other panes";
|
|
2925
|
+
if (reason === "identity-capacity") return "Coordination state full; retrying after the current window";
|
|
2926
|
+
return "Verified identity unavailable; waiting to retry";
|
|
2927
|
+
}
|
|
2928
|
+
|
|
1859
2929
|
// ---------- Shared account governor ----------
|
|
1860
2930
|
|
|
1861
2931
|
// The scope version is deliberately stable. Stored protocol revisions must use
|
|
1862
2932
|
// the same file so an older process sees the new exact shape and fails closed
|
|
1863
2933
|
// instead of coordinating through a second file.
|
|
1864
2934
|
const GOVERNOR_SCOPE_VERSION = 1;
|
|
1865
|
-
|
|
2935
|
+
// 3: GraphQL budgets carry `graphql-observer` provenance. An older build does
|
|
2936
|
+
// not know that source and would reject the budget -- and normalizeGovernorState
|
|
2937
|
+
// turns one rejected budget into total loss, discarding every live pane's
|
|
2938
|
+
// leases, intents and reservations. The version gate makes it fail closed on the
|
|
2939
|
+
// file instead, which is the whole point of having one.
|
|
2940
|
+
const GOVERNOR_STATE_VERSION = 3;
|
|
2941
|
+
// Readable-as-evidence, never written: 2 is the shape a 0.11.x pane still holds.
|
|
2942
|
+
const LEGACY_GOVERNOR_VERSIONS = [GOVERNOR_STATE_VERSION, 2];
|
|
1866
2943
|
const GOVERNOR_MAX_LEASES = 128;
|
|
1867
2944
|
const GOVERNOR_MAX_INTENTS = 512;
|
|
1868
2945
|
const GOVERNOR_MAX_RESERVATIONS = 512;
|
|
@@ -1965,7 +3042,8 @@ function currentGovernorScope(scope) {
|
|
|
1965
3042
|
} catch {
|
|
1966
3043
|
return { ok: false, reason: "stale" };
|
|
1967
3044
|
}
|
|
1968
|
-
const hash = governorScopeHash(current?.effectiveHost, current?.authIdentity);
|
|
3045
|
+
const hash = scope.quotaKey ? current?.quotaKey : governorScopeHash(current?.effectiveHost, current?.authIdentity);
|
|
3046
|
+
if (scope.quotaKey && current?.accessKey !== scope.accessKey) return { ok: false, reason: "stale" };
|
|
1969
3047
|
return hash === scope.hash ? { ok: true, value: scope } : { ok: false, reason: "stale" };
|
|
1970
3048
|
}
|
|
1971
3049
|
|
|
@@ -2013,8 +3091,12 @@ function normalizeGovernorBudget(raw, nowMs, resource = null) {
|
|
|
2013
3091
|
(raw.blockReason !== null && !GOVERNOR_BLOCK_REASONS.has(raw.blockReason)) ||
|
|
2014
3092
|
(raw.roundRobinCursor !== null && !validGovernorId(raw.roundRobinCursor)) ||
|
|
2015
3093
|
!Number.isFinite(raw.lastExternalFactor) || raw.lastExternalFactor < 1 ||
|
|
2016
|
-
!["response-header", "core-observer", "rate-limit-probe"].includes(raw.source) ||
|
|
2017
|
-
|
|
3094
|
+
!["response-header", "core-observer", "graphql-observer", "rate-limit-probe"].includes(raw.source) ||
|
|
3095
|
+
// "rate-limit-probe" is retained as readable legacy provenance only: ledgers
|
|
3096
|
+
// written before the claimed GraphQL observer existed still carry it. Nothing
|
|
3097
|
+
// writes it any more, and neither it nor the GraphQL observer may ever stand
|
|
3098
|
+
// as core authority.
|
|
3099
|
+
["rate-limit-probe", "graphql-observer"].includes(raw.source) && resource === "core" ||
|
|
2018
3100
|
!exactKeys(raw.factorBaseline, ["epoch", "used", "observedAt"]) ||
|
|
2019
3101
|
!validGovernorEpoch(raw.factorBaseline.epoch) ||
|
|
2020
3102
|
!Number.isSafeInteger(raw.factorBaseline.used) || raw.factorBaseline.used < 0 ||
|
|
@@ -2144,11 +3226,15 @@ function normalizeManualProbe(raw, nowMs) {
|
|
|
2144
3226
|
return { ...raw };
|
|
2145
3227
|
}
|
|
2146
3228
|
|
|
2147
|
-
|
|
3229
|
+
// `acceptVersion` exists for one caller: the legacy inspector, which must be
|
|
3230
|
+
// able to *read* a still-running older pane's file to see its live leases. It
|
|
3231
|
+
// never publishes what it reads. Every other caller takes the default, so a
|
|
3232
|
+
// version this build does not write is unreadable and fails closed.
|
|
3233
|
+
function normalizeGovernorState(raw, nowMs, { prune = true, acceptVersion = GOVERNOR_STATE_VERSION } = {}) {
|
|
2148
3234
|
if (!exactKeys(raw, [
|
|
2149
3235
|
"version", "epochs", "budgets", "observers", "probeClaim", "probeOutcome", "leases",
|
|
2150
3236
|
"intents", "reservations", "manualProbe",
|
|
2151
|
-
]) || raw.version !==
|
|
3237
|
+
]) || raw.version !== acceptVersion) return null;
|
|
2152
3238
|
if (
|
|
2153
3239
|
!exactKeys(raw.epochs, RATE_RESOURCES) ||
|
|
2154
3240
|
RATE_RESOURCES.some((resource) => raw.epochs[resource] !== null && !validGovernorEpoch(raw.epochs[resource])) ||
|
|
@@ -2220,16 +3306,15 @@ function migrateGovernorState(raw, nowMs) {
|
|
|
2220
3306
|
// preserve it as bootstrap evidence. GraphQL remains valid.
|
|
2221
3307
|
delete migrated.budgets.core;
|
|
2222
3308
|
migrated.epochs.core = null;
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
}
|
|
3309
|
+
// The v1 GraphQL value came from /rate_limit, for the same reason the core
|
|
3310
|
+
// value did, and is dropped for the same reason: this phase makes that
|
|
3311
|
+
// endpoint explicitly non-authoritative, and "never a source of spendable
|
|
3312
|
+
// capacity" cannot have an exception for numbers that arrived before the rule.
|
|
3313
|
+
// Keeping it would let a migrated pane admit GraphQL work against it and let
|
|
3314
|
+
// it establish the epoch later headers refine. The claimed observer
|
|
3315
|
+
// re-establishes capacity on the next probe.
|
|
3316
|
+
delete migrated.budgets.graphql;
|
|
3317
|
+
migrated.epochs.graphql = null;
|
|
2233
3318
|
for (const reservation of Object.values(migrated.reservations)) {
|
|
2234
3319
|
reservation.accountedCosts = { core: 0, graphql: 0 };
|
|
2235
3320
|
}
|
|
@@ -2410,6 +3495,16 @@ function withGovernorLock(scope, operation, {
|
|
|
2410
3495
|
kill = scope?.kill ?? process.kill.bind(process),
|
|
2411
3496
|
observeArtifact = null,
|
|
2412
3497
|
} = {}) {
|
|
3498
|
+
if (scope?.coordinationRoot && !scope.rootLocked) {
|
|
3499
|
+
return withIdentityRegistry(scope.coordinationRoot, (registry, at) => {
|
|
3500
|
+
const migration = inspectLegacyMigration(scope.coordinationRoot, registry, at);
|
|
3501
|
+
if (!migration.ok) return migration;
|
|
3502
|
+
if (registry.identities[scope.credentialKey]) {
|
|
3503
|
+
try { statSync(scope.path); } catch { return { ok: false, reason: "corrupt" }; }
|
|
3504
|
+
}
|
|
3505
|
+
return withGovernorLock({ ...scope, rootLocked: true }, operation, { pid, nonce, waitMs, kill, observeArtifact });
|
|
3506
|
+
}, { now: scopeNow(scope), kill });
|
|
3507
|
+
}
|
|
2413
3508
|
const current = currentGovernorScope(scope);
|
|
2414
3509
|
if (!current.ok) return current;
|
|
2415
3510
|
const lockPath = `${scope.path}.lock`;
|
|
@@ -2714,8 +3809,8 @@ function budgetFromObservation(raw, previous, nowMs, {
|
|
|
2714
3809
|
if (
|
|
2715
3810
|
!normalized || normalized.resetMs <= nowMs || receivedAt > nowMs ||
|
|
2716
3811
|
!RATE_RESOURCES.includes(resource) ||
|
|
2717
|
-
!["response-header", "core-observer", "rate-limit-probe"].includes(source) ||
|
|
2718
|
-
resource === "core" &&
|
|
3812
|
+
!["response-header", "core-observer", "graphql-observer", "rate-limit-probe"].includes(source) ||
|
|
3813
|
+
resource === "core" && ["rate-limit-probe", "graphql-observer"].includes(source)
|
|
2719
3814
|
) return { status: "invalid" };
|
|
2720
3815
|
const epoch = `${normalized.limit}:${normalized.resetMs}`;
|
|
2721
3816
|
// Endpoint responses can carry cached or endpoint-specific rate headers.
|
|
@@ -2776,12 +3871,16 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
|
|
|
2776
3871
|
if (!claim.resources.every((resource) => budgets?.[resource])) {
|
|
2777
3872
|
return { ok: false, reason: "corrupt" };
|
|
2778
3873
|
}
|
|
3874
|
+
const observerCosts = {};
|
|
2779
3875
|
for (const resource of claim.resources) {
|
|
2780
3876
|
const supplied = budgets?.[resource];
|
|
2781
3877
|
if (!supplied) continue;
|
|
2782
3878
|
const raw = supplied.budget ?? supplied;
|
|
2783
|
-
|
|
2784
|
-
|
|
3879
|
+
// The owner source names what actually established the number. Calling a
|
|
3880
|
+
// GraphQL budget a "rate-limit-probe" was accurate when /rate_limit supplied
|
|
3881
|
+
// it and is a lie now that the claimed observer does.
|
|
3882
|
+
const source = supplied.source ?? (resource === "core" ? "core-observer" : "graphql-observer");
|
|
3883
|
+
const ownerSource = resource === "core" ? "core-observer" : "graphql-observer";
|
|
2785
3884
|
if (source !== ownerSource) return { ok: false, reason: "corrupt" };
|
|
2786
3885
|
const observed = budgetFromObservation(raw, state.budgets[resource], at, {
|
|
2787
3886
|
resource,
|
|
@@ -2792,6 +3891,10 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
|
|
|
2792
3891
|
});
|
|
2793
3892
|
if (observed.status === "invalid") return { ok: false, reason: "corrupt" };
|
|
2794
3893
|
if (observed.status === "ignored") continue;
|
|
3894
|
+
// The observer's own cost is local spend. Left out, the external-factor
|
|
3895
|
+
// reconciliation below attributes it to other clients, and the governor
|
|
3896
|
+
// throttles the user's real work to make room for its own probing.
|
|
3897
|
+
observerCosts[resource] = Number.isSafeInteger(supplied.cost) && supplied.cost > 0 ? supplied.cost : 0;
|
|
2795
3898
|
nextBudgets[resource] = observed.budget;
|
|
2796
3899
|
if (resource === "core" && supplied.blocked === true) {
|
|
2797
3900
|
nextBudgets[resource].blockUntil = observed.budget.resetMs;
|
|
@@ -2820,7 +3923,7 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs) {
|
|
|
2820
3923
|
const factor = nextExternalFactor({
|
|
2821
3924
|
lastExternalFactor: previous.lastExternalFactor,
|
|
2822
3925
|
globalUsedDelta: nextBudgets[resource].used - baseline.used,
|
|
2823
|
-
sharedCompletedDelta: previous.knownLocalUsed + legacyCompletedDelta,
|
|
3926
|
+
sharedCompletedDelta: previous.knownLocalUsed + legacyCompletedDelta + (observerCosts[resource] ?? 0),
|
|
2824
3927
|
});
|
|
2825
3928
|
if (factor === null) return { ok: false, reason: "corrupt" };
|
|
2826
3929
|
nextBudgets[resource].lastExternalFactor = factor;
|
|
@@ -2970,16 +4073,23 @@ function registerIntent(scope, intent) {
|
|
|
2970
4073
|
});
|
|
2971
4074
|
}
|
|
2972
4075
|
|
|
2973
|
-
function readIntentDecision(scope, intentId, nowMs) {
|
|
4076
|
+
function readIntentDecision(scope, intentId, nowMs, previousEvidence = null) {
|
|
2974
4077
|
return mutateGovernor(scope, nowMs, (state, at) => {
|
|
2975
4078
|
const scheduled = scheduleGovernorState(state, at);
|
|
2976
4079
|
const reservationId = `reservation:${intentId}`;
|
|
2977
4080
|
const reservation = state.reservations[reservationId];
|
|
2978
4081
|
const grant = scheduled.grants.find((item) => item.id === reservationId);
|
|
4082
|
+
// UI provenance stays process-local. Reuse it only for this same pending
|
|
4083
|
+
// intent while its original owners and pane count remain valid.
|
|
4084
|
+
const retainedSharing = previousEvidence?.intentId === intentId &&
|
|
4085
|
+
reservation?.status === "scheduled" && reservation.notBefore > at &&
|
|
4086
|
+
currentSharedLaneProvenance(previousEvidence, state.leases, at).waitCause
|
|
4087
|
+
? sharedLaneEvidence(previousEvidence) : {};
|
|
2979
4088
|
if (reservation) return { value: {
|
|
2980
4089
|
status: reservation.status,
|
|
2981
4090
|
reservationId,
|
|
2982
4091
|
...reservation,
|
|
4092
|
+
...retainedSharing,
|
|
2983
4093
|
...sharedLaneEvidence(grant),
|
|
2984
4094
|
} };
|
|
2985
4095
|
if (!state.intents[intentId]) return { ok: false, reason: "stale" };
|
|
@@ -3309,10 +4419,24 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
3309
4419
|
failProbeClaim(scope, leaseId, nonce, now());
|
|
3310
4420
|
return renewed;
|
|
3311
4421
|
}
|
|
4422
|
+
// Known exhaustion waits for its actual reset. The GraphQL observer costs a
|
|
4423
|
+
// point, so probing an already-empty budget to be told it is still empty digs
|
|
4424
|
+
// the hole deeper -- the /rate_limit read this replaced was free and could
|
|
4425
|
+
// afford to be unconditional.
|
|
4426
|
+
const beforeRead = inspect(scope, now());
|
|
4427
|
+
const spendable = resources.filter((resource) => {
|
|
4428
|
+
if (resource !== "graphql" || !beforeRead.ok) return true;
|
|
4429
|
+
const budget = beforeRead.value.budgets.graphql;
|
|
4430
|
+
return !(budget && budget.remaining <= 0 && now() < budget.resetMs + BUDGET_RESET_GRACE_MS);
|
|
4431
|
+
});
|
|
4432
|
+
if (spendable.length === 0) {
|
|
4433
|
+
failProbeClaim(scope, leaseId, nonce, now());
|
|
4434
|
+
return { ok: false, reason: "budget-reset" };
|
|
4435
|
+
}
|
|
3312
4436
|
let budgets;
|
|
3313
4437
|
try {
|
|
3314
|
-
budgets = await readBudgets(signal, scope.host, {
|
|
3315
|
-
resources,
|
|
4438
|
+
budgets = await requestIdentityStorage.run(scope, () => readBudgets(signal, scope.host, {
|
|
4439
|
+
resources: spendable,
|
|
3316
4440
|
coreEtag,
|
|
3317
4441
|
renewClaim: async () => {
|
|
3318
4442
|
const startedAt = now();
|
|
@@ -3328,7 +4452,7 @@ async function refreshSharedBudget(scope, leaseId, signal, {
|
|
|
3328
4452
|
if (extended.ok) renewed.value.leaseUntil = extended.value.leaseUntil;
|
|
3329
4453
|
return extended.ok;
|
|
3330
4454
|
},
|
|
3331
|
-
});
|
|
4455
|
+
}));
|
|
3332
4456
|
} catch {
|
|
3333
4457
|
budgets = null;
|
|
3334
4458
|
}
|
|
@@ -3417,8 +4541,13 @@ function mergeAlertRows(groups) {
|
|
|
3417
4541
|
// mid-session is picked up within the hour.
|
|
3418
4542
|
const alertBackoff = new Map();
|
|
3419
4543
|
|
|
4544
|
+
function backoffStorageKey(key) {
|
|
4545
|
+
const access = requestIdentityStorage.getStore()?.accessKey ?? runtimeIdentityCoordinator?.current()?.accessKey;
|
|
4546
|
+
return access ? `${access}\0${key}` : key;
|
|
4547
|
+
}
|
|
4548
|
+
|
|
3420
4549
|
function backoffActive(key, now) {
|
|
3421
|
-
const state = alertBackoff.get(key);
|
|
4550
|
+
const state = alertBackoff.get(backoffStorageKey(key));
|
|
3422
4551
|
return Boolean(state) && now < state.until;
|
|
3423
4552
|
}
|
|
3424
4553
|
|
|
@@ -3434,13 +4563,13 @@ const FAILURE_LADDER = {
|
|
|
3434
4563
|
// which is the drift the ALERT_SOURCES comment below warns about: two copies of
|
|
3435
4564
|
// this would diverge the first time one of them was fixed.
|
|
3436
4565
|
function recordFailure(key, now, steps = BACKOFF_STEPS_MS) {
|
|
3437
|
-
const previous = alertBackoff.get(key);
|
|
4566
|
+
const previous = alertBackoff.get(backoffStorageKey(key));
|
|
3438
4567
|
const step = Math.min((previous?.step ?? -1) + 1, steps.length - 1);
|
|
3439
|
-
alertBackoff.set(key, { step, until: now + steps[step] });
|
|
4568
|
+
alertBackoff.set(backoffStorageKey(key), { step, until: now + steps[step] });
|
|
3440
4569
|
}
|
|
3441
4570
|
|
|
3442
4571
|
function clearBackoff(key) {
|
|
3443
|
-
alertBackoff.delete(key);
|
|
4572
|
+
alertBackoff.delete(backoffStorageKey(key));
|
|
3444
4573
|
}
|
|
3445
4574
|
|
|
3446
4575
|
function forcedBackoffKeys(key) {
|
|
@@ -3462,7 +4591,7 @@ async function fetchAlertSource(source, signal, now, {
|
|
|
3462
4591
|
request = fetchConditionalEntity,
|
|
3463
4592
|
} = {}) {
|
|
3464
4593
|
if (backoffActive(source.key, now)) {
|
|
3465
|
-
const { note, verdict } = alertBackoff.get(source.key);
|
|
4594
|
+
const { note, verdict } = alertBackoff.get(backoffStorageKey(source.key));
|
|
3466
4595
|
// The verdict is replayed alongside the note. Replaying only the note left
|
|
3467
4596
|
// the tab unable to tell "Dependabot is switched off here" from "we cannot
|
|
3468
4597
|
// see Dependabot" for the whole length of a backoff window.
|
|
@@ -3515,7 +4644,10 @@ async function fetchAlertSource(source, signal, now, {
|
|
|
3515
4644
|
completedCalls,
|
|
3516
4645
|
verdict: "ok",
|
|
3517
4646
|
allNotModified: batch.allNotModified,
|
|
3518
|
-
|
|
4647
|
+
// Every other fetchAlertSource path returns a Map. An all-304 batch has
|
|
4648
|
+
// no staged writes, so conditionalBatchResult returns null; normalize it
|
|
4649
|
+
// here before fetchSecurity merges the independently fetched sources.
|
|
4650
|
+
stagedEntities: batch.stagedEntities ?? new Map(),
|
|
3519
4651
|
observations,
|
|
3520
4652
|
parse: () => {
|
|
3521
4653
|
const rows = mergeAlertRows(groups);
|
|
@@ -3554,7 +4686,7 @@ async function fetchAlertSource(source, signal, now, {
|
|
|
3554
4686
|
const steps = pick(FAILURE_LADDER, verdict, null);
|
|
3555
4687
|
if (steps) {
|
|
3556
4688
|
recordFailure(source.key, now, steps);
|
|
3557
|
-
Object.assign(alertBackoff.get(source.key), { note, verdict });
|
|
4689
|
+
Object.assign(alertBackoff.get(backoffStorageKey(source.key)), { note, verdict });
|
|
3558
4690
|
}
|
|
3559
4691
|
return {
|
|
3560
4692
|
raw: `unavailable:${note}`,
|
|
@@ -3665,36 +4797,72 @@ function reconcileSelectionViewport({ items, key, offset = 0, rows = 1 }) {
|
|
|
3665
4797
|
return { key, offset: nextOffset };
|
|
3666
4798
|
}
|
|
3667
4799
|
|
|
3668
|
-
//
|
|
3669
|
-
//
|
|
3670
|
-
|
|
3671
|
-
|
|
4800
|
+
// The tabs with a per-item URL. Security is absent on purpose: an alert has no
|
|
4801
|
+
// stable per-item page this app can construct honestly.
|
|
4802
|
+
const OPENABLE = ["actions", "issues", "prs"];
|
|
4803
|
+
|
|
4804
|
+
// The platform's opener, by fixed argv and without a shell, so a URL is an
|
|
4805
|
+
// argument and never a fragment of a command line.
|
|
4806
|
+
// rundll32 rather than `cmd /c start ""`: cmd re-parses its /c string, so a URL
|
|
4807
|
+
// handed to it is a fragment of a command line and its metacharacters are live.
|
|
4808
|
+
// FileProtocolHandler takes the URL as one real argument, which is the whole
|
|
4809
|
+
// point of using execFile without a shell.
|
|
4810
|
+
const BROWSER_OPENERS = {
|
|
4811
|
+
darwin: ["open"],
|
|
4812
|
+
linux: ["xdg-open"],
|
|
4813
|
+
win32: ["rundll32", "url.dll,FileProtocolHandler"],
|
|
4814
|
+
};
|
|
3672
4815
|
|
|
3673
|
-
//
|
|
3674
|
-
//
|
|
3675
|
-
//
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
4816
|
+
// Only https, and only the host this pane is actually talking to. A row is
|
|
4817
|
+
// remote data; without this a crafted `url` could point the user's browser
|
|
4818
|
+
// anywhere, or hand a `file:`/`javascript:` URL to the platform opener.
|
|
4819
|
+
function admittedRowUrl(value, host) {
|
|
4820
|
+
if (typeof value !== "string" || value.length === 0) return null;
|
|
4821
|
+
let parsed;
|
|
4822
|
+
try { parsed = new URL(value); } catch { return null; }
|
|
4823
|
+
if (parsed.protocol !== "https:") return null;
|
|
4824
|
+
return parsed.host === host ? parsed.toString() : null;
|
|
4825
|
+
}
|
|
4826
|
+
|
|
4827
|
+
// Actions rows come from REST and carry no URL, but a run's page is a pure
|
|
4828
|
+
// function of the repository and its databaseId -- both already validated --
|
|
4829
|
+
// so it is derived locally rather than fetched. Issues and PRs select `url` in
|
|
4830
|
+
// their query, which is validated against the same host before use.
|
|
4831
|
+
function rowBrowserUrl(tabKey, item, { host = effectiveRuntimeHost(), repo = effectiveRuntimeRepository() } = {}) {
|
|
4832
|
+
if (!OPENABLE.includes(tabKey) || !isRecord(item) || !host) return null;
|
|
4833
|
+
// The row's own URL first, for every tab. A run only falls back to a derived
|
|
4834
|
+
// URL when the payload predates the projection, and that fallback needs the
|
|
4835
|
+
// slug, which the app only has when --repo was given.
|
|
4836
|
+
const declared = admittedRowUrl(item.url, host);
|
|
4837
|
+
if (declared || tabKey !== "actions") return declared;
|
|
4838
|
+
const id = item.databaseId ?? item.number;
|
|
4839
|
+
if (!Number.isSafeInteger(id) || id <= 0 || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(repo ?? ""))) return null;
|
|
4840
|
+
return `https://${host}/${repo}/actions/runs/${id}`;
|
|
4841
|
+
}
|
|
4842
|
+
|
|
4843
|
+
// Opening a page the row already names costs nothing and needs no admission:
|
|
4844
|
+
// `gh <kind> view --web` spent a request to be told a URL that was already in
|
|
4845
|
+
// hand. Output is still captured rather than inherited, because stdout is ink's
|
|
4846
|
+
// frame stream and an opener's chatter would corrupt the alternate screen.
|
|
4847
|
+
async function openInBrowser(tabKey, item, signal, _governor = null, { run = null, host, repo } = {}) {
|
|
4848
|
+
if (!OPENABLE.includes(tabKey)) return;
|
|
4849
|
+
const url = rowBrowserUrl(tabKey, item, {
|
|
4850
|
+
...(host === undefined ? {} : { host }),
|
|
4851
|
+
...(repo === undefined ? {} : { repo }),
|
|
3696
4852
|
});
|
|
3697
|
-
|
|
4853
|
+
// Returning quietly here is how a dead Enter key looks: the row has no usable
|
|
4854
|
+
// page, and the user is entitled to know that rather than press it again.
|
|
4855
|
+
if (!url) throw new Error("This row has no page to open yet; refresh and try again");
|
|
4856
|
+
const opener = pick(BROWSER_OPENERS, process.platform, null);
|
|
4857
|
+
if (!opener) throw new Error(`Opening a browser is not supported on ${process.platform}`);
|
|
4858
|
+
const [command, ...prefix] = opener;
|
|
4859
|
+
const launch = run ?? ((argv) => execFileAsync(argv[0], argv.slice(1), {
|
|
4860
|
+
timeout: GH_TIMEOUT_MS,
|
|
4861
|
+
killSignal: "SIGKILL",
|
|
4862
|
+
maxBuffer: GH_MAX_BUFFER,
|
|
4863
|
+
signal,
|
|
4864
|
+
}));
|
|
4865
|
+
await launch([command, ...prefix, url]);
|
|
3698
4866
|
}
|
|
3699
4867
|
|
|
3700
4868
|
function createOpenRequestRegistry() {
|
|
@@ -3860,10 +5028,16 @@ async function captureGh(args, operation) {
|
|
|
3860
5028
|
|
|
3861
5029
|
const PROBE_STDERR_LIMIT = 400;
|
|
3862
5030
|
|
|
3863
|
-
async function probe(name, args, operation) {
|
|
5031
|
+
async function probe(name, args, operation, input = null) {
|
|
3864
5032
|
const startedAt = Date.now();
|
|
5033
|
+
// A declared local prerequisite the run cannot satisfy is reported, not
|
|
5034
|
+
// attempted: starting a request whose document cannot be built would spend a
|
|
5035
|
+
// reservation to receive a GraphQL error that says nothing about the tenant.
|
|
5036
|
+
if (input === UNRESOLVED_REPOSITORY) {
|
|
5037
|
+
return { name, args, ms: 0, failed: true, stderr: UNRESOLVED_REPOSITORY, http: null, classified: "unavailable" };
|
|
5038
|
+
}
|
|
3865
5039
|
try {
|
|
3866
|
-
const stdout = await runGh(args, { operation });
|
|
5040
|
+
const stdout = await runGh(args, { operation, input });
|
|
3867
5041
|
return { name, args, ms: Date.now() - startedAt, bytes: stdout.length, classified: "ok" };
|
|
3868
5042
|
} catch (err) {
|
|
3869
5043
|
return {
|
|
@@ -3944,6 +5118,28 @@ function targetSource() {
|
|
|
3944
5118
|
// measures as free (verified: delta 0), so this is safe to run on a diagnostic
|
|
3945
5119
|
// path. GHES tenants can be configured with a different ceiling, which is
|
|
3946
5120
|
// exactly why this reports the server's own numbers rather than asserting 5,000.
|
|
5121
|
+
// The claimed GraphQL observer, and the only source of spendable GraphQL
|
|
5122
|
+
// capacity. `/rate_limit` used to supply this number for free, but a counter
|
|
5123
|
+
// obtained for free is not authority: it is another endpoint's view of the
|
|
5124
|
+
// meter, it can lag behind the spend it is meant to bound, and it can never
|
|
5125
|
+
// report what a particular query actually cost. This query costs a point and
|
|
5126
|
+
// says exactly what it cost, which is what makes it reconcilable.
|
|
5127
|
+
async function readGraphqlObserver(signal, host = effectiveRuntimeHost(), {
|
|
5128
|
+
fetchPage = fetchGraphqlPage,
|
|
5129
|
+
} = {}) {
|
|
5130
|
+
const page = await fetchPage("observer", { signal, operation: "graphql-observer", host, variables: {} });
|
|
5131
|
+
const observed = page.observations[0];
|
|
5132
|
+
if (!observed || observed.resource !== "graphql") return null;
|
|
5133
|
+
return {
|
|
5134
|
+
budget: { remaining: observed.remaining, limit: observed.limit, used: observed.used, resetMs: observed.resetMs },
|
|
5135
|
+
receivedAt: observed.receivedAt,
|
|
5136
|
+
cost: page.observedCost,
|
|
5137
|
+
};
|
|
5138
|
+
}
|
|
5139
|
+
|
|
5140
|
+
// Report data only, and explicitly not authority: it is left available because
|
|
5141
|
+
// a diagnostic that can contradict the observer is useful evidence, and pinned
|
|
5142
|
+
// as non-authoritative so it can never be mistaken for spendable capacity.
|
|
3947
5143
|
async function readRateLimitResources(signal, host = effectiveRuntimeHost()) {
|
|
3948
5144
|
const raw = await runGh(["api", "rate_limit", ...apiHostArgs(host)], {
|
|
3949
5145
|
signal,
|
|
@@ -4034,20 +5230,21 @@ async function readSharedBudgetSources(signal, host = effectiveRuntimeHost(), {
|
|
|
4034
5230
|
resources = RATE_RESOURCES,
|
|
4035
5231
|
coreEtag = null,
|
|
4036
5232
|
renewClaim = null,
|
|
4037
|
-
readGraphql =
|
|
5233
|
+
readGraphql = readGraphqlObserver,
|
|
4038
5234
|
readCore = readCoreBudget,
|
|
4039
5235
|
} = {}) {
|
|
4040
5236
|
const result = {};
|
|
4041
5237
|
if (resources.includes("graphql")) {
|
|
4042
|
-
const
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
result.graphql = { budget: graphql, receivedAt: Date.now(), cost: 0 };
|
|
5238
|
+
const observed = await readGraphql(signal, host).catch(() => null);
|
|
5239
|
+
if (!observed?.budget) return null;
|
|
5240
|
+
result.graphql = observed;
|
|
4046
5241
|
}
|
|
4047
5242
|
if (resources.includes("core")) {
|
|
4048
|
-
// Each gh command is independently bounded, but two sequential commands
|
|
4049
|
-
//
|
|
4050
|
-
//
|
|
5243
|
+
// Each gh command is independently bounded, but two sequential commands can
|
|
5244
|
+
// outlive one claim. Extend the same nonce between the two observers. The
|
|
5245
|
+
// GraphQL read is no longer the free one -- it costs a point of its own now
|
|
5246
|
+
// -- so this renewal separates two charged requests, not a free one from a
|
|
5247
|
+
// charged one.
|
|
4051
5248
|
if (resources.includes("graphql") && typeof renewClaim === "function" && !await renewClaim()) return null;
|
|
4052
5249
|
const core = await readCore(signal, host, coreEtag);
|
|
4053
5250
|
if (core) result.core = core;
|
|
@@ -4069,12 +5266,21 @@ function projectedHourlyCost(activeKey) {
|
|
|
4069
5266
|
}
|
|
4070
5267
|
|
|
4071
5268
|
function doctorProbePlan() {
|
|
5269
|
+
// GraphQL cannot infer a repository the way `gh` does, so a report run from a
|
|
5270
|
+
// directory with no unambiguous remote says so per probe instead of sending a
|
|
5271
|
+
// document with a missing variable and reporting the resulting error as if it
|
|
5272
|
+
// came from the server.
|
|
5273
|
+
const target = effectiveRuntimeRepository();
|
|
5274
|
+
const document = (kind, variables) => (variables ? graphqlInput(kind, variables) : UNRESOLVED_REPOSITORY);
|
|
4072
5275
|
return [
|
|
4073
|
-
|
|
5276
|
+
// Every probe is now an explicit request whose cost is declared. The old
|
|
5277
|
+
// Issues/PRs entries ran `gh issue list`/`gh pr list`, whose `--search`
|
|
5278
|
+
// routed through GraphQL at a price the report could not name.
|
|
5279
|
+
["Repository access", graphqlArgs(), "doctor:repository", document("repository", graphqlRepositoryVariables(target)), "repository"],
|
|
4074
5280
|
["Actions runs", ghApiArgs(actionsRunsArgs(MIN_RUN_LIMIT)), "doctor:actions-runs"],
|
|
4075
5281
|
["Actions workflows", ghApiArgs(actionsWorkflowsArgs()), "doctor:actions-workflows"],
|
|
4076
|
-
["Issues (
|
|
4077
|
-
["Pull requests (
|
|
5282
|
+
["Issues (first page)", graphqlArgs(), "doctor:issues", document("issues", graphqlPageVariables(null, target)), "issues"],
|
|
5283
|
+
["Pull requests (first page)", graphqlArgs(), "doctor:prs", document("prs", graphqlPageVariables(null, target)), "prs"],
|
|
4078
5284
|
...ALERT_SOURCES.flatMap((source) =>
|
|
4079
5285
|
alertRequestArgs(source).map((args, index) => [
|
|
4080
5286
|
index === 0 ? source.name : `${source.name} (priority ${index})`,
|
|
@@ -4125,18 +5331,23 @@ async function runDoctor() {
|
|
|
4125
5331
|
|
|
4126
5332
|
// Free checks run first. The display rate_limit read is report data only;
|
|
4127
5333
|
// admission re-reads its GraphQL source after owning the shared claim.
|
|
4128
|
-
const [ghVersion,
|
|
5334
|
+
const [ghVersion, remote, remoteUrls] = await Promise.all([
|
|
4129
5335
|
captureGh(["--version"], "version"),
|
|
4130
|
-
captureGh(["auth", "status"], "auth-status"),
|
|
4131
5336
|
gitRemote(),
|
|
4132
5337
|
gitRemoteUrls(),
|
|
4133
5338
|
]);
|
|
4134
5339
|
const effectiveHost = effectiveRuntimeHost({ remoteUrls });
|
|
4135
|
-
|
|
5340
|
+
runtimeIdentityCoordinator = createIdentityCoordinator({ host: effectiveHost });
|
|
5341
|
+
const verified = await runtimeIdentityCoordinator.refresh();
|
|
5342
|
+
const verifiedIdentity = runtimeIdentityCoordinator.current();
|
|
5343
|
+
const authStatus = verifiedIdentity ? `${verifiedIdentity.host}: ${verifiedIdentity.login} (verified)` : identityCoordinationMessage(verified.reason);
|
|
5344
|
+
const resources = verifiedIdentity ? await readRateLimitResources(undefined, effectiveHost).catch(() => null) : null;
|
|
4136
5345
|
const budget = resources
|
|
4137
5346
|
? await rateBudget(resources)
|
|
4138
5347
|
: { core: "unavailable", graphql: "unavailable" };
|
|
4139
|
-
const scopeResult =
|
|
5348
|
+
const scopeResult = verifiedIdentity
|
|
5349
|
+
? { ok: true, value: createQuotaScope(verifiedIdentity, { root: runtimeIdentityCoordinator.root, identityProvider: runtimeIdentityCoordinator.current }) }
|
|
5350
|
+
: verified;
|
|
4140
5351
|
let governorResult = scopeResult;
|
|
4141
5352
|
let results = [];
|
|
4142
5353
|
if (scopeResult.ok) {
|
|
@@ -4154,20 +5365,21 @@ async function runDoctor() {
|
|
|
4154
5365
|
try {
|
|
4155
5366
|
if (registered.ok) await refreshSharedBudget(scope, leaseId, undefined);
|
|
4156
5367
|
const admissionAt = Date.now();
|
|
4157
|
-
const admittedProbes = probes.map(([name, args, operation], index) => {
|
|
5368
|
+
const admittedProbes = probes.map(([name, args, operation, input = null, document = null], index) => {
|
|
4158
5369
|
const admitted = registered.ok
|
|
4159
5370
|
? admitGovernorOperation(scope, leaseId, operation, "diagnostic", admissionAt)
|
|
4160
5371
|
: registered;
|
|
4161
5372
|
if (!admitted?.ok || admitted.value.status !== "started") {
|
|
4162
|
-
return { index, skipped: skippedDoctorProbe(name, args, admitted) };
|
|
5373
|
+
return { index, skipped: { ...skippedDoctorProbe(name, args, admitted), ...(document ? { document } : {}) } };
|
|
4163
5374
|
}
|
|
4164
|
-
return { index, name, args, operation, reservationId: admitted.value.reservationId };
|
|
5375
|
+
return { index, name, args, operation, input, document, reservationId: admitted.value.reservationId };
|
|
4165
5376
|
});
|
|
4166
5377
|
results = admittedProbes.map((item) => item.skipped ?? null);
|
|
4167
5378
|
const runnable = admittedProbes.filter((item) => !item.skipped);
|
|
4168
5379
|
const settled = await mapAllSettledBounded(runnable, 4, async (item) => {
|
|
4169
|
-
const result = await probe(item.name, item.args, item.operation);
|
|
4170
|
-
|
|
5380
|
+
const result = await requestIdentityStorage.run(scope, () => probe(item.name, item.args, item.operation, item.input));
|
|
5381
|
+
if (item.document) result.document = item.document;
|
|
5382
|
+
completeReservation({ ...scope, identityProvider: null }, item.reservationId, result.failed
|
|
4171
5383
|
? { outcome: "rejected" }
|
|
4172
5384
|
: { outcome: "measured-success", actualCost: operationCost(item.operation) }, Date.now());
|
|
4173
5385
|
return result;
|
|
@@ -4208,7 +5420,12 @@ async function runDoctor() {
|
|
|
4208
5420
|
field("slug", runtime.repo ?? "(inferred from the working directory)"),
|
|
4209
5421
|
field("git remote", remote),
|
|
4210
5422
|
"",
|
|
4211
|
-
|
|
5423
|
+
// Labelled, because these two numbers come from `gh api rate_limit`, which
|
|
5424
|
+
// this version treats as a diagnostic and never as spendable capacity. The
|
|
5425
|
+
// "API governor" section below carries the numbers actually admitted
|
|
5426
|
+
// against, with their provenance. A reader who cannot tell the two apart
|
|
5427
|
+
// will believe the wrong one.
|
|
5428
|
+
...section("API budget (rate_limit, non-authoritative)"),
|
|
4212
5429
|
field("REST core", budget.core),
|
|
4213
5430
|
field("GraphQL", budget.graphql),
|
|
4214
5431
|
field(
|
|
@@ -4243,6 +5460,10 @@ async function runDoctor() {
|
|
|
4243
5460
|
for (const result of results) {
|
|
4244
5461
|
lines.push(` ${result.name}`);
|
|
4245
5462
|
probeLine("argv", `gh ${result.args.join(" ")}`);
|
|
5463
|
+
// Three GraphQL probes share one argv, and the document that distinguishes
|
|
5464
|
+
// them travels on stdin. A report that cannot say which query it sent is
|
|
5465
|
+
// exactly the drift this command exists to rule out.
|
|
5466
|
+
if (result.document) probeLine("document", result.document);
|
|
4246
5467
|
probeLine(
|
|
4247
5468
|
"outcome",
|
|
4248
5469
|
result.skipped
|
|
@@ -4623,6 +5844,21 @@ if (IS_MAIN) {
|
|
|
4623
5844
|
console.error(problem);
|
|
4624
5845
|
process.exit(3);
|
|
4625
5846
|
}
|
|
5847
|
+
runtimeRemoteUrls = await gitRemoteUrls();
|
|
5848
|
+
runtimeIdentityCoordinator = createIdentityCoordinator({ host: () => effectiveRuntimeHost() });
|
|
5849
|
+
// Resolve warm identity/cache locally; cold proof belongs to the mounted UI
|
|
5850
|
+
// so quit/signal handling remains available while GitHub is slow.
|
|
5851
|
+
//
|
|
5852
|
+
// Bounded, because the warm path still spawns `gh auth token`: a prompting
|
|
5853
|
+
// credential helper, a keyring daemon that is not up yet in a fresh login
|
|
5854
|
+
// session, or a cold binary on a network filesystem would otherwise hold the
|
|
5855
|
+
// first frame for the full subprocess timeout with nothing on screen at all.
|
|
5856
|
+
// Past the bound the same refresh is picked up by the mounted UI, and
|
|
5857
|
+
// ensureScope corrects the cache target and hydrates from it.
|
|
5858
|
+
await Promise.race([
|
|
5859
|
+
runtimeIdentityCoordinator.refresh({ allowBootstrap: false }),
|
|
5860
|
+
new Promise((resolve) => { setTimeout(resolve, WARM_IDENTITY_WAIT_MS).unref(); }),
|
|
5861
|
+
]);
|
|
4626
5862
|
}
|
|
4627
5863
|
|
|
4628
5864
|
const ReactModule = await import("react");
|
|
@@ -5306,7 +6542,7 @@ const TABS = [
|
|
|
5306
6542
|
},
|
|
5307
6543
|
{
|
|
5308
6544
|
key: "issues",
|
|
5309
|
-
fetch: ({ signal }) => fetchIssues(signal),
|
|
6545
|
+
fetch: ({ signal, governor, previousRaw }) => fetchIssues(signal, { governor, previousRaw }),
|
|
5310
6546
|
label: "Issues",
|
|
5311
6547
|
short: "Issues",
|
|
5312
6548
|
header: ISSUES_HEADER,
|
|
@@ -5316,7 +6552,7 @@ const TABS = [
|
|
|
5316
6552
|
},
|
|
5317
6553
|
{
|
|
5318
6554
|
key: "prs",
|
|
5319
|
-
fetch: ({ signal }) => fetchPRs(signal),
|
|
6555
|
+
fetch: ({ signal, governor, previousRaw }) => fetchPRs(signal, { governor, previousRaw }),
|
|
5320
6556
|
label: "Pull requests",
|
|
5321
6557
|
short: "PRs",
|
|
5322
6558
|
header: PRS_HEADER,
|
|
@@ -5866,7 +7102,8 @@ const createWidthPreferenceWriter = createCoalescedWriter;
|
|
|
5866
7102
|
|
|
5867
7103
|
// ---------- Last-known-good dashboard cache ----------
|
|
5868
7104
|
|
|
5869
|
-
|
|
7105
|
+
// 3: cached rows carry their page URL, without which they cannot be opened.
|
|
7106
|
+
const DASHBOARD_CACHE_VERSION = 3;
|
|
5870
7107
|
const MAX_DASHBOARD_CACHE_TARGETS = 5;
|
|
5871
7108
|
const MAX_DASHBOARD_CACHE_ROWS_PER_TAB = 60;
|
|
5872
7109
|
let dashboardCacheTempSequence = 0;
|
|
@@ -5955,6 +7192,9 @@ function normalizeCachedItem(tabKey, item) {
|
|
|
5955
7192
|
conclusion: item.conclusion == null ? null : safe(item.conclusion),
|
|
5956
7193
|
startedAt: safe(item.startedAt),
|
|
5957
7194
|
updatedAt: safe(item.updatedAt),
|
|
7195
|
+
// Retained because opening a row now reads its URL from the row. Dropping
|
|
7196
|
+
// it made Enter on a cache-hydrated row do nothing at all, with no error.
|
|
7197
|
+
url: safe(item.url ?? ""),
|
|
5958
7198
|
};
|
|
5959
7199
|
}
|
|
5960
7200
|
if (tabKey === "issues") {
|
|
@@ -5965,6 +7205,7 @@ function normalizeCachedItem(tabKey, item) {
|
|
|
5965
7205
|
author: safe(item.author),
|
|
5966
7206
|
label: safe(item.label),
|
|
5967
7207
|
updatedAt: safe(item.updatedAt),
|
|
7208
|
+
url: safe(item.url ?? ""),
|
|
5968
7209
|
};
|
|
5969
7210
|
}
|
|
5970
7211
|
if (tabKey === "prs") {
|
|
@@ -5977,6 +7218,7 @@ function normalizeCachedItem(tabKey, item) {
|
|
|
5977
7218
|
isDraft: Boolean(item.isDraft),
|
|
5978
7219
|
reviewDecision: safe(item.reviewDecision),
|
|
5979
7220
|
updatedAt: safe(item.updatedAt),
|
|
7221
|
+
url: safe(item.url ?? ""),
|
|
5980
7222
|
};
|
|
5981
7223
|
}
|
|
5982
7224
|
if (tabKey === "security") {
|
|
@@ -6436,10 +7678,27 @@ function rateLimitBlockDecision(results, resetMs) {
|
|
|
6436
7678
|
};
|
|
6437
7679
|
}
|
|
6438
7680
|
|
|
7681
|
+
// Identity reasons that name a condition the user can act on or wait out. The
|
|
7682
|
+
// list previously stopped short of identity-unavailable -- the ordinary "not
|
|
7683
|
+
// signed in" or "proof denied" case, and precisely the one this phase requires
|
|
7684
|
+
// to read as an honest unavailable state rather than a generic retry -- and of
|
|
7685
|
+
// identity-capacity and transport-busy.
|
|
7686
|
+
//
|
|
7687
|
+
// Purely internal reasons (corrupt, unwritable, unknown-host, closed) stay on
|
|
7688
|
+
// the generic message on purpose: this surface must not leak internal
|
|
7689
|
+
// vocabulary, which is the contract "coordination notices translate raw reasons
|
|
7690
|
+
// without exposing internal vocabulary" in test/unit.test.mjs pins.
|
|
7691
|
+
const IDENTITY_COORDINATION_REASONS = [
|
|
7692
|
+
"restart-required", "migration-hold", "legacy-unresolved", "legacy-corrupt",
|
|
7693
|
+
"credential-unavailable", "identity-backoff", "identity-busy",
|
|
7694
|
+
"identity-unavailable", "identity-capacity", "transport-busy",
|
|
7695
|
+
];
|
|
7696
|
+
|
|
6439
7697
|
function coordinationNotice(reason) {
|
|
6440
7698
|
if (reason === "unknown-scope") return "Confirming your GitHub login…";
|
|
6441
7699
|
if (reason === "block-unpublished") return "Holding until the rate-limit block is shared";
|
|
6442
7700
|
if (reason === "busy" || reason === "stale") return "Coordinating with your other panes";
|
|
7701
|
+
if (IDENTITY_COORDINATION_REASONS.includes(reason)) return identityCoordinationMessage(reason);
|
|
6443
7702
|
return "Can't coordinate API use — retrying";
|
|
6444
7703
|
}
|
|
6445
7704
|
|
|
@@ -6520,16 +7779,18 @@ async function runAdmittedOperation({ scope, leaseId, operation, priority = "man
|
|
|
6520
7779
|
return { ok: false, skipped: true, decision: admitted, error: new Error(`API budget paused${detail}`) };
|
|
6521
7780
|
}
|
|
6522
7781
|
const reservationId = admitted.value.reservationId;
|
|
7782
|
+
const settlementScope = { ...scope, identityProvider: null };
|
|
6523
7783
|
try {
|
|
6524
|
-
const value = await run(signal);
|
|
7784
|
+
const value = await requestIdentityStorage.run(scope, () => run(signal));
|
|
6525
7785
|
const costs = operationCost(operation);
|
|
6526
|
-
completeReservation(
|
|
7786
|
+
completeReservation(settlementScope, reservationId, {
|
|
6527
7787
|
outcome: "measured-success",
|
|
6528
7788
|
actualCost: costs,
|
|
6529
7789
|
}, Date.now());
|
|
7790
|
+
if (scope.accessKey && scope.accessKey !== runtimeIdentityCoordinator?.current()?.accessKey) return { ok: false, error: new Error("Credential changed"), reservationId };
|
|
6530
7791
|
return { ok: true, value, reservationId };
|
|
6531
7792
|
} catch (error) {
|
|
6532
|
-
completeReservation(
|
|
7793
|
+
completeReservation(settlementScope, reservationId, { outcome: governorOutcomeForError(error) }, Date.now());
|
|
6533
7794
|
return { ok: false, error, reservationId };
|
|
6534
7795
|
}
|
|
6535
7796
|
}
|
|
@@ -7180,8 +8441,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
7180
8441
|
dashboardCacheTarget({
|
|
7181
8442
|
repo: runtime.repo,
|
|
7182
8443
|
ghRepo: process.env.GH_REPO,
|
|
7183
|
-
host:
|
|
8444
|
+
host: runtimeIdentityCoordinator?.current()?.host ?? effectiveRuntimeHost(),
|
|
7184
8445
|
cwd: process.cwd(),
|
|
8446
|
+
account: runtimeIdentityCoordinator?.current()?.accessKey ?? "unverified",
|
|
7185
8447
|
}),
|
|
7186
8448
|
);
|
|
7187
8449
|
const [loadedCache] = useState(() => loadDashboardCache(cachePath, cacheTarget));
|
|
@@ -7882,6 +9144,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
7882
9144
|
// refresh the tab you just landed on without waiting on an unrelated
|
|
7883
9145
|
// background fetch -- and so a slow repo can't stack refreshes.
|
|
7884
9146
|
if (inFlightRef.current[key]) return Promise.resolve();
|
|
9147
|
+
const settlement = createSettlementContext(scope, runtimeIdentityCoordinator);
|
|
9148
|
+
const settlementScope = settlement.scope;
|
|
9149
|
+
const currentAccess = settlement.isCurrent;
|
|
7885
9150
|
inFlightRef.current[key] = true;
|
|
7886
9151
|
if (force) manualInFlight.add(key);
|
|
7887
9152
|
admittedCadenceRef.current[key] = nextAdmittedCadence(
|
|
@@ -7907,9 +9172,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
7907
9172
|
[key]: { automaticStatusVisible },
|
|
7908
9173
|
}));
|
|
7909
9174
|
}
|
|
7910
|
-
return run()
|
|
9175
|
+
return requestIdentityStorage.run(scope, run)
|
|
7911
9176
|
.then((result) => {
|
|
7912
|
-
settleReservationWithBudgetObservations(
|
|
9177
|
+
settleReservationWithBudgetObservations(settlementScope, leaseId, reservationId, result?.measuredSuccess === false
|
|
7913
9178
|
? { outcome: "rejected", observations: result?.observations ?? [] }
|
|
7914
9179
|
: {
|
|
7915
9180
|
outcome: "measured-success",
|
|
@@ -7919,6 +9184,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
7919
9184
|
},
|
|
7920
9185
|
observations: result?.observations ?? [],
|
|
7921
9186
|
}, Date.now());
|
|
9187
|
+
if (!currentAccess()) return;
|
|
7922
9188
|
if (result?.rateLimited) {
|
|
7923
9189
|
const blockAt = Date.now();
|
|
7924
9190
|
const budget = inspectGovernor(scope, blockAt).value?.budgets?.core;
|
|
@@ -8035,11 +9301,11 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8035
9301
|
});
|
|
8036
9302
|
})
|
|
8037
9303
|
.catch((err) => {
|
|
8038
|
-
settleReservationWithBudgetObservations(
|
|
9304
|
+
settleReservationWithBudgetObservations(settlementScope, leaseId, reservationId, {
|
|
8039
9305
|
outcome: governorOutcomeForError(err),
|
|
8040
9306
|
observations: err?.budgetObservations ?? [],
|
|
8041
9307
|
}, Date.now());
|
|
8042
|
-
if (cancelled || err?.name === "AbortError") return;
|
|
9308
|
+
if (cancelled || !currentAccess() || err?.name === "AbortError") return;
|
|
8043
9309
|
// Preserve both the verdict and the bounded raw error in state. The
|
|
8044
9310
|
// renderer translates recognized verdicts at draw time, which lets a
|
|
8045
9311
|
// later repository/account context refine the one-line remedy without
|
|
@@ -8073,6 +9339,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8073
9339
|
})
|
|
8074
9340
|
.finally(() => {
|
|
8075
9341
|
inFlightRef.current[key] = false;
|
|
9342
|
+
if (!currentAccess() && !Object.values(inFlightRef.current).some(Boolean) && runtimeIdentityCoordinator?.current()?.quotaKey !== settlementScope.quotaKey) releaseLease(settlementScope, leaseId);
|
|
8076
9343
|
manualInFlight.delete(key);
|
|
8077
9344
|
onSettled?.();
|
|
8078
9345
|
if (!cancelled) {
|
|
@@ -8106,6 +9373,10 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8106
9373
|
let scope = null;
|
|
8107
9374
|
let cleanupScope = null;
|
|
8108
9375
|
let registeredScopeHash = null;
|
|
9376
|
+
// The access partition the retained rows belong to, kept across a window
|
|
9377
|
+
// where the identity is momentarily unknown so that an account which comes
|
|
9378
|
+
// back *different* still clears them. Null means nothing is retained.
|
|
9379
|
+
let retainedAccessKey = null;
|
|
8109
9380
|
let remoteUrls = [];
|
|
8110
9381
|
let liveScheduling = false;
|
|
8111
9382
|
let controlEpochs = null;
|
|
@@ -8175,14 +9446,34 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8175
9446
|
}
|
|
8176
9447
|
|
|
8177
9448
|
function identity() {
|
|
8178
|
-
return
|
|
8179
|
-
|
|
8180
|
-
|
|
8181
|
-
|
|
9449
|
+
return runtimeIdentityCoordinator?.current() ?? null;
|
|
9450
|
+
}
|
|
9451
|
+
|
|
9452
|
+
function retireCurrentScope() {
|
|
9453
|
+
if (!cleanupScope || !registeredScopeHash) return;
|
|
9454
|
+
for (const item of pending.values()) cancelIntent(cleanupScope, item.intentId, Date.now());
|
|
9455
|
+
if (!Object.values(inFlightRef.current).some(Boolean)) releaseLease(cleanupScope, leaseId);
|
|
8182
9456
|
}
|
|
8183
9457
|
|
|
8184
|
-
|
|
9458
|
+
// discardData separates the two reasons this runs. An account *change* must
|
|
9459
|
+
// drop every row, validator and note, because they belong to an access
|
|
9460
|
+
// partition this pane no longer holds. An identity that is merely unknown
|
|
9461
|
+
// for a moment must not: the rows are still ours, and throwing away the
|
|
9462
|
+
// ETags with them makes the recovery round cost full price.
|
|
9463
|
+
function resetVisibleScope(nowMs, { immediate = false, discardData = true } = {}) {
|
|
8185
9464
|
pending.clear();
|
|
9465
|
+
coordinator.invalidate();
|
|
9466
|
+
setFailureContext(null);
|
|
9467
|
+
if (discardData) {
|
|
9468
|
+
rawRef.current = {};
|
|
9469
|
+
entityRef.current.clear();
|
|
9470
|
+
lastOkRef.current = {};
|
|
9471
|
+
setData(Object.fromEntries(TAB_KEYS.map((key) => [key, null])));
|
|
9472
|
+
setMeta({});
|
|
9473
|
+
setSecurityNotes([]);
|
|
9474
|
+
setSecurityBlind(false);
|
|
9475
|
+
retainedAccessKey = null;
|
|
9476
|
+
}
|
|
8186
9477
|
pendingBlockPublications.clear();
|
|
8187
9478
|
liveScheduling = false;
|
|
8188
9479
|
activePollAt = Number.POSITIVE_INFINITY;
|
|
@@ -8201,20 +9492,26 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8201
9492
|
|
|
8202
9493
|
function ensureScope(nowMs = Date.now(), { maintain = false } = {}) {
|
|
8203
9494
|
const current = identity();
|
|
8204
|
-
const nextHash =
|
|
9495
|
+
const nextHash = current?.quotaKey;
|
|
8205
9496
|
if (!nextHash) {
|
|
8206
9497
|
if (scope !== null) {
|
|
8207
|
-
|
|
9498
|
+
retireCurrentScope();
|
|
8208
9499
|
registeredScopeHash = null;
|
|
8209
9500
|
if (governorRef.current?.leaseId === leaseId) governorRef.current = null;
|
|
8210
9501
|
scope = null;
|
|
8211
9502
|
cleanupScope = null;
|
|
8212
|
-
|
|
9503
|
+
// Unknown is not the same as changed. A contended registry lock, a
|
|
9504
|
+
// `gh` write that only touched an unrelated host's entry, or a
|
|
9505
|
+
// re-resolution still in flight all arrive here, and none of them says
|
|
9506
|
+
// the rows on screen stopped being ours. Stop scheduling and let them
|
|
9507
|
+
// age visibly; retainedAccessKey is what still clears them if the
|
|
9508
|
+
// account turns out to have actually changed.
|
|
9509
|
+
resetVisibleScope(nowMs, { discardData: false });
|
|
8213
9510
|
}
|
|
8214
9511
|
return null;
|
|
8215
9512
|
}
|
|
8216
|
-
const migrating = scope !== null && scope.hash !== nextHash;
|
|
8217
|
-
if (scope?.hash === nextHash && registeredScopeHash === nextHash) {
|
|
9513
|
+
const migrating = scope !== null && (scope.hash !== nextHash || scope.accessKey !== current.accessKey);
|
|
9514
|
+
if (scope?.hash === nextHash && scope.accessKey === current.accessKey && registeredScopeHash === nextHash) {
|
|
8218
9515
|
if (!maintain) return scope;
|
|
8219
9516
|
const activeTab = TABS[activeIndexRef.current].key;
|
|
8220
9517
|
const kept = maintainControlLease(scope, leaseId, runtime.refreshMs, activeTab, nowMs);
|
|
@@ -8223,20 +9520,40 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8223
9520
|
if (governorRef.current?.leaseId === leaseId) governorRef.current = null;
|
|
8224
9521
|
return null;
|
|
8225
9522
|
}
|
|
8226
|
-
if (scope?.hash !== nextHash) {
|
|
8227
|
-
|
|
9523
|
+
if (scope?.hash !== nextHash || scope.accessKey !== current.accessKey) {
|
|
9524
|
+
retireCurrentScope();
|
|
8228
9525
|
registeredScopeHash = null;
|
|
8229
|
-
|
|
9526
|
+
// The second test covers the identity returning after a window where it
|
|
9527
|
+
// was unknown: scope is null by then, so `migrating` cannot see the
|
|
9528
|
+
// change, and retained rows from the previous access partition would
|
|
9529
|
+
// otherwise survive into an account that must not be able to read them.
|
|
9530
|
+
if (migrating || (scope === null && retainedAccessKey !== null && retainedAccessKey !== current.accessKey)) {
|
|
8230
9531
|
// The failed publication belonged to the old account scope. Releasing
|
|
8231
9532
|
// that lease guarantees this process will start no more work there;
|
|
8232
9533
|
// the new scope must establish its own authoritative budget instead
|
|
8233
9534
|
// of inheriting an unrelated account's local hold.
|
|
8234
9535
|
resetVisibleScope(nowMs, { immediate: true });
|
|
8235
9536
|
}
|
|
8236
|
-
|
|
8237
|
-
if (!created.ok) return null;
|
|
8238
|
-
scope = created.value;
|
|
9537
|
+
scope = createQuotaScope(current, { root: runtimeIdentityCoordinator.root, identityProvider: identity });
|
|
8239
9538
|
cleanupScope = { ...scope, identityProvider: null };
|
|
9539
|
+
const nextTarget = dashboardCacheTarget({ repo: runtime.repo, ghRepo: process.env.GH_REPO, host: current.host, account: current.accessKey });
|
|
9540
|
+
// The mount-time target had to guess the account, and guesses
|
|
9541
|
+
// "unverified" whenever the bounded warm resolution above did not land
|
|
9542
|
+
// in time -- so a session that has rows on disk can start with none.
|
|
9543
|
+
// Now that the partition is proven, take them. Guarded on having
|
|
9544
|
+
// published nothing yet, so this can never overwrite live rows.
|
|
9545
|
+
if (nextTarget !== dashboardCacheTargetRef.current && retainedAccessKey === null &&
|
|
9546
|
+
Object.values(dataRef.current).every((rows) => rows === null)) {
|
|
9547
|
+
const warm = pick(dashboardCacheRef.current, nextTarget, null);
|
|
9548
|
+
if (warm) {
|
|
9549
|
+
setData(Object.fromEntries(TABS.map((candidate) => [candidate.key, warm.tabs[candidate.key]?.data ?? null])));
|
|
9550
|
+
setMeta(Object.fromEntries(TABS.map((candidate) => [candidate.key, warm.tabs[candidate.key]?.meta ?? null])));
|
|
9551
|
+
setSecurityNotes(warm.securityNotes ?? []);
|
|
9552
|
+
setSecurityBlind(warm.securityBlind ?? false);
|
|
9553
|
+
}
|
|
9554
|
+
}
|
|
9555
|
+
retainedAccessKey = current.accessKey;
|
|
9556
|
+
dashboardCacheTargetRef.current = nextTarget;
|
|
8240
9557
|
}
|
|
8241
9558
|
const activeTab = TABS[activeIndexRef.current].key;
|
|
8242
9559
|
const registered = registerLease(scope, {
|
|
@@ -8413,6 +9730,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8413
9730
|
runLimit: runLimitRef.current,
|
|
8414
9731
|
entities: entityRef.current,
|
|
8415
9732
|
force,
|
|
9733
|
+
governor: { scope: currentScope, leaseId },
|
|
8416
9734
|
previousRaw: rawRef.current[key] ?? null,
|
|
8417
9735
|
}),
|
|
8418
9736
|
{
|
|
@@ -8452,13 +9770,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8452
9770
|
if (inFlightRef.current[key]) continue;
|
|
8453
9771
|
const currentScope = ensureScope(nowMs);
|
|
8454
9772
|
if (!currentScope) continue;
|
|
8455
|
-
const decision = readIntentDecision(currentScope, item.intentId, nowMs);
|
|
9773
|
+
const decision = readIntentDecision(currentScope, item.intentId, nowMs, item);
|
|
8456
9774
|
if (!decision.ok) {
|
|
8457
9775
|
pauseCoordination(key, decision.reason);
|
|
8458
9776
|
if (pendingFailureIsTerminal(decision.reason)) pending.delete(key);
|
|
8459
9777
|
else armWake("data", governorControlRetryAt(nowMs, runtime.refreshMs), dataWake);
|
|
8460
9778
|
continue;
|
|
8461
9779
|
}
|
|
9780
|
+
Object.assign(item, sharedLaneEvidence(decision.value));
|
|
8462
9781
|
if (decision.value.status !== "scheduled" || decision.value.notBefore > nowMs) {
|
|
8463
9782
|
item.wasDeferred = true;
|
|
8464
9783
|
setTabGovernorDecision(key, visibleGovernorDecision({
|
|
@@ -8498,6 +9817,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8498
9817
|
runLimit: runLimitRef.current,
|
|
8499
9818
|
entities: entityRef.current,
|
|
8500
9819
|
force: item.force,
|
|
9820
|
+
governor: { scope: currentScope, leaseId },
|
|
8501
9821
|
previousRaw: rawRef.current[key] ?? null,
|
|
8502
9822
|
}),
|
|
8503
9823
|
{
|
|
@@ -8582,6 +9902,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8582
9902
|
});
|
|
8583
9903
|
|
|
8584
9904
|
async function controlWake() {
|
|
9905
|
+
if (cancelled) return;
|
|
9906
|
+
await runtimeIdentityCoordinator.refresh();
|
|
8585
9907
|
if (cancelled) return;
|
|
8586
9908
|
const nowMs = Date.now();
|
|
8587
9909
|
const currentScope = ensureScope(nowMs, { maintain: true });
|
|
@@ -8596,7 +9918,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8596
9918
|
}
|
|
8597
9919
|
const refreshed = currentScope
|
|
8598
9920
|
? await refreshSharedBudget(currentScope, leaseId, controller.signal)
|
|
8599
|
-
: { ok: false, reason: "stale" };
|
|
9921
|
+
: { ok: false, reason: runtimeIdentityCoordinator?.inspect()?.reason ?? "stale" };
|
|
8600
9922
|
if (cancelled) return;
|
|
8601
9923
|
const checkedAt = Date.now();
|
|
8602
9924
|
const snapshot = currentScope ? inspectGovernor(currentScope, checkedAt) : refreshed;
|
|
@@ -8642,12 +9964,13 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8642
9964
|
}
|
|
8643
9965
|
|
|
8644
9966
|
async function bootstrap() {
|
|
8645
|
-
remoteUrls = await gitRemoteUrls();
|
|
9967
|
+
remoteUrls = runtimeRemoteUrls.length > 0 ? runtimeRemoteUrls : await gitRemoteUrls();
|
|
8646
9968
|
runtimeRemoteUrls = remoteUrls;
|
|
9969
|
+
await runtimeIdentityCoordinator.refresh();
|
|
8647
9970
|
if (cancelled) return;
|
|
8648
9971
|
const currentScope = ensureScope(Date.now());
|
|
8649
9972
|
if (!currentScope) {
|
|
8650
|
-
pauseCoordination(TABS[activeIndexRef.current].key, "unknown-scope");
|
|
9973
|
+
pauseCoordination(TABS[activeIndexRef.current].key, runtimeIdentityCoordinator?.inspect()?.reason ?? "unknown-scope");
|
|
8651
9974
|
armWake("control", governorControlRetryAt(Date.now(), runtime.refreshMs), controlWake);
|
|
8652
9975
|
return;
|
|
8653
9976
|
}
|
|
@@ -9146,7 +10469,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9146
10469
|
canMove: allItems.length > 0,
|
|
9147
10470
|
canOpen:
|
|
9148
10471
|
selectedKey !== null &&
|
|
9149
|
-
|
|
10472
|
+
OPENABLE.includes(tab.key) &&
|
|
9150
10473
|
allItems.some((item) => itemKey(item) === selectedKey),
|
|
9151
10474
|
canResize: fullHeaderVisible,
|
|
9152
10475
|
}),
|
|
@@ -9339,6 +10662,10 @@ if (IS_MAIN) {
|
|
|
9339
10662
|
});
|
|
9340
10663
|
};
|
|
9341
10664
|
app = render(e(App, { onCreateRemote: createRemote }), { incrementalRendering: true });
|
|
10665
|
+
// Ink's q/Esc/Ctrl+C exit unmounts directly rather than calling unmountApp.
|
|
10666
|
+
// Only close the identity coordinator after that actual application exit,
|
|
10667
|
+
// not when a polling effect reruns. Rejections reach the crash handler above.
|
|
10668
|
+
void app.waitUntilExit().then(abortLiveRequests);
|
|
9342
10669
|
|
|
9343
10670
|
// 128 + signal number, so a supervisor or `timeout` can tell an interrupted
|
|
9344
10671
|
// run from a clean one. These fire on external `kill` and when raw mode is
|
|
@@ -9368,6 +10695,7 @@ if (IS_MAIN) {
|
|
|
9368
10695
|
const finish = () => {
|
|
9369
10696
|
unmountApp();
|
|
9370
10697
|
restoreScreen();
|
|
10698
|
+
abortLiveRequests();
|
|
9371
10699
|
process.exit(code);
|
|
9372
10700
|
};
|
|
9373
10701
|
|
|
@@ -9393,6 +10721,19 @@ if (IS_MAIN) {
|
|
|
9393
10721
|
// Exported for unit tests. The dashboard itself is still one file; these are
|
|
9394
10722
|
// the pure functions worth pinning, and nothing here is part of the public API.
|
|
9395
10723
|
export {
|
|
10724
|
+
retryIdentityCompletion,
|
|
10725
|
+
createSettlementContext,
|
|
10726
|
+
startIdentityControl,
|
|
10727
|
+
settleIdentityControl,
|
|
10728
|
+
resolveEffectiveCredential,
|
|
10729
|
+
identityRegistryRoot,
|
|
10730
|
+
inspectIdentityRegistry,
|
|
10731
|
+
claimIdentityBootstrap,
|
|
10732
|
+
finishIdentityBootstrap,
|
|
10733
|
+
createIdentityCoordinator,
|
|
10734
|
+
createQuotaScope,
|
|
10735
|
+
acquireIdentityHttpPermit,
|
|
10736
|
+
releaseIdentityHttpPermit,
|
|
9396
10737
|
parseArgs,
|
|
9397
10738
|
validateArgs,
|
|
9398
10739
|
parseRepoTarget,
|
|
@@ -9472,6 +10813,7 @@ export {
|
|
|
9472
10813
|
settleReservationWithBudgetObservations,
|
|
9473
10814
|
recordResourceBlock,
|
|
9474
10815
|
releaseLease,
|
|
10816
|
+
inspectAdmittedHttpStart,
|
|
9475
10817
|
inspectGovernor,
|
|
9476
10818
|
governorHealth,
|
|
9477
10819
|
refreshSharedBudget,
|
|
@@ -9573,6 +10915,24 @@ export {
|
|
|
9573
10915
|
forcedBackoffKeys,
|
|
9574
10916
|
clearForcedBackoffAfterStart,
|
|
9575
10917
|
doctorProbePlan,
|
|
10918
|
+
rowBrowserUrl,
|
|
10919
|
+
admittedRowUrl,
|
|
10920
|
+
remoteSlug,
|
|
10921
|
+
resolveEffectiveRepository,
|
|
10922
|
+
graphqlPageVariables,
|
|
10923
|
+
graphqlRepositoryVariables,
|
|
10924
|
+
parseGraphqlEnvelope,
|
|
10925
|
+
graphqlInput,
|
|
10926
|
+
graphqlArgs,
|
|
10927
|
+
fetchGraphqlPage,
|
|
10928
|
+
fetchGraphqlList,
|
|
10929
|
+
operationPausedUntil,
|
|
10930
|
+
LIST_LIMIT,
|
|
10931
|
+
readGraphqlObserver,
|
|
10932
|
+
GRAPHQL_QUERIES,
|
|
10933
|
+
GRAPHQL_PAGE_SIZE,
|
|
10934
|
+
GRAPHQL_PAGE_POINTS,
|
|
10935
|
+
GRAPHQL_OBSERVER_POINTS,
|
|
9576
10936
|
mapAllSettledBounded,
|
|
9577
10937
|
alertRequestArgs,
|
|
9578
10938
|
shouldFetchAlertPriorityLanes,
|