gh-glance 0.17.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -1
- package/README.md +27 -5
- package/index.mjs +181 -79
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.17.1] - 2026-10-26
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- A full debt-group journal no longer pins every pane of an account behind
|
|
15
|
+
"Local coordination accounting overflow". Quiescent groups now merge once the
|
|
16
|
+
journal is half full, after any active probe finishes or expires, so the
|
|
17
|
+
journal can free space for retiring a control receipt. Ledgers already wedged
|
|
18
|
+
in this state recover automatically after upgrading.
|
|
19
|
+
- Pasted or multi-character input such as `12` is ignored instead of being read
|
|
20
|
+
as a tab number. Single-digit tab keys are unchanged.
|
|
21
|
+
- Webhook events now match configured targets whose repository name differs in
|
|
22
|
+
case from the name GitHub sends, including access-removal events that fence
|
|
23
|
+
cached data. Ambiguity across hosts is still rejected.
|
|
24
|
+
- A collector view with an old snapshot shows `Stale` or `Cached` with its
|
|
25
|
+
source age. It shows `Disconnected`, still with that age, only when the
|
|
26
|
+
collector connection or its app authorization is lost.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Updated React to 19.3.0 and refreshed existing tooling/action versions.
|
|
31
|
+
Patched the development-only brace-expansion and Zod vulnerabilities.
|
|
32
|
+
- README: Homebrew installation (`brew install juan294/tap/gh-glance`) is
|
|
33
|
+
documented, with a note to use one installation method.
|
|
34
|
+
- Development CI monitoring uses Sutura v0.4.0.
|
|
35
|
+
- A journal that is genuinely full of unsettled requests reports "Local
|
|
36
|
+
coordination journal is full" with an automatic retry instead of the hard
|
|
37
|
+
accounting-overflow notice.
|
|
38
|
+
- `gh-glance --doctor` reports debt-journal occupancy as `debt groups`, and
|
|
39
|
+
for a hard coordination failure, including a ledger that is missing or
|
|
40
|
+
unreadable, names the state file and the `repair`: quit every pane, move the
|
|
41
|
+
ledger, its recovery journal and `registry.json` aside, relaunch.
|
|
42
|
+
|
|
10
43
|
## [0.17.0] - 2026-10-25
|
|
11
44
|
|
|
12
45
|
### Added
|
|
@@ -1282,7 +1315,8 @@ engineering, security, QA and UX. What follows is what changed as a result.
|
|
|
1282
1315
|
- The `main` field from `package.json`. It advertised the file as importable,
|
|
1283
1316
|
but importing it took over the terminal or exited the host process.
|
|
1284
1317
|
|
|
1285
|
-
[Unreleased]: https://github.com/juan294/gh-glance/compare/v0.17.
|
|
1318
|
+
[Unreleased]: https://github.com/juan294/gh-glance/compare/v0.17.1...HEAD
|
|
1319
|
+
[0.17.1]: https://github.com/juan294/gh-glance/compare/v0.17.0...v0.17.1
|
|
1286
1320
|
[0.17.0]: https://github.com/juan294/gh-glance/compare/v0.16.2...v0.17.0
|
|
1287
1321
|
[0.16.2]: https://github.com/juan294/gh-glance/compare/v0.16.1...v0.16.2
|
|
1288
1322
|
[0.16.1]: https://github.com/juan294/gh-glance/compare/v0.16.0...v0.16.1
|
package/README.md
CHANGED
|
@@ -121,10 +121,28 @@ current.
|
|
|
121
121
|
|
|
122
122
|
## Install
|
|
123
123
|
|
|
124
|
+
With Homebrew on macOS or Linux:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
brew install juan294/tap/gh-glance
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Future updates use your normal `brew upgrade`. The
|
|
131
|
+
[Homebrew tap](https://github.com/juan294/homebrew-tap) follows published npm
|
|
132
|
+
releases automatically and supplies its own Node runtime. If moving from npm,
|
|
133
|
+
remove existing npm copies first; the tap README includes migration commands.
|
|
134
|
+
Restart running dashboard panes after upgrading.
|
|
135
|
+
|
|
136
|
+
Alternatively, install with npm (Node.js 22 or newer):
|
|
137
|
+
|
|
124
138
|
```bash
|
|
125
139
|
npm install -g gh-glance
|
|
126
140
|
```
|
|
127
141
|
|
|
142
|
+
Use one installation method. NVM keeps global npm packages separate for each
|
|
143
|
+
Node installation, so an npm update may not affect a copy launched from another
|
|
144
|
+
prefix.
|
|
145
|
+
|
|
128
146
|
That is the whole install for the default standalone dashboard. `gh-glance`
|
|
129
147
|
ships as a single `index.mjs` with no build step, so there is nothing to
|
|
130
148
|
compile. The optional collector, SSH, webhook, and GitHub App modes use the
|
|
@@ -765,11 +783,14 @@ and the lock path separately. A readable metadata file does not make an aged
|
|
|
765
783
|
orphan lock or invalid snapshot healthy; plain doctor never repairs either.
|
|
766
784
|
When one cached identity names the selected host, plain doctor also reads its
|
|
767
785
|
quota ledger without changing it or contacting GitHub. It reports receipt and
|
|
768
|
-
debt totals, observer age, the current
|
|
769
|
-
when one is known. If several cached
|
|
770
|
-
|
|
786
|
+
debt totals, debt-group occupancy (`debt groups`), observer age, the current
|
|
787
|
+
recovery cause, and an actual retry time when one is known. If several cached
|
|
788
|
+
identities match, it says the scope is unavailable instead of choosing an
|
|
789
|
+
account. For a corrupt or unreadable quota
|
|
771
790
|
file, it names the coordination directory and a `quota-[redacted].json` file
|
|
772
|
-
pattern with the cause.
|
|
791
|
+
pattern with the cause. For a hard coordination failure it also prints a
|
|
792
|
+
`repair` line with the manual steps. The private quota fingerprint stays out of
|
|
793
|
+
reports.
|
|
773
794
|
|
|
774
795
|
Use `gh-glance --doctor --probe` when live capability evidence is needed. Probe
|
|
775
796
|
mode uses an ephemeral governor lease and admits every quota-consuming endpoint
|
|
@@ -1123,7 +1144,8 @@ GH_GLANCE_ICONS=ascii gh-glance
|
|
|
1123
1144
|
| `Upgrade waiting for legacy quota reset` | The older panes are gone, but left spend that cannot be proven settled. It waits for the affected rate-limit window to reset rather than assume the quota is free. This one has a deadline and clears itself. |
|
|
1124
1145
|
| `Upgrade blocked: older spend cannot be settled or waited out` | Older spend has no reliable reset or child evidence. Verify old children ended and use `gh-glance --doctor` to inspect reserved units. An unidentifiable old child may require a verified system restart before a later observer can retire its charge. Keep the quota files as accounting evidence. |
|
|
1125
1146
|
| `Coordination state unavailable; evidence preserved` | Coordination state could not be read and was deliberately not overwritten, so nothing is lost. Run `gh-glance --doctor` and check the config directory's permissions. |
|
|
1126
|
-
| `Local coordination
|
|
1147
|
+
| `Local coordination journal is full` | Every slot for unsettled or recently settled requests is in use. The app retries by itself, and older requests settle after about an hour. Nothing needs repair. `gh-glance --doctor` shows the occupancy as `debt groups`. |
|
|
1148
|
+
| `Local coordination` corruption, overflow, full storage, or unsafe permissions | Run `gh-glance --doctor`; its read-only report gives the exact cause and redacted local file location when known. For a hard failure it also prints a `repair` line: quit every pane, move the state file, its `.recovery.json` and `registry.json` aside, then relaunch. Otherwise repair storage or permissions and let the app retry. Preserve corrupt and overflow evidence for diagnosis. |
|
|
1127
1149
|
| `Saving request result` or `Recovering interrupted request` | The original request may have consumed quota. The app keeps its charge, retries settlement without repeating HTTP, and waits for safe observer evidence before retiring uncertain debt. |
|
|
1128
1150
|
| `GitHub request limit` | A shared secondary hold preserves GitHub's retry deadline. Wait for the shown time; if the repeated-limit circuit explicitly asks, press `r` once to retry. Repeated refresh cannot bypass a hold. |
|
|
1129
1151
|
| `Collector connection unavailable` or `GitHub App authorization unavailable` | Restore the selected collector connection or App authorization. The mode does not switch to independent requests. Cached rows remain labeled with their source age. |
|
package/index.mjs
CHANGED
|
@@ -292,7 +292,7 @@ const ANIMATE = !process.env.GH_GLANCE_NO_ANIMATION;
|
|
|
292
292
|
// legitimate mixed-direction Arabic and Hebrew titles render correctly, and
|
|
293
293
|
// preserving real RTL text is a stated property of this function.
|
|
294
294
|
// eslint-disable-next-line no-control-regex -- matching control characters is the entire purpose
|
|
295
|
-
const CONTROL_CHARS = /[
|
|
295
|
+
const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]+/g;
|
|
296
296
|
const BIDI_OVERRIDES = /[--]/g;
|
|
297
297
|
|
|
298
298
|
// Titles are rendered at whatever length GitHub returns, and ink memoizes
|
|
@@ -307,7 +307,7 @@ const MAX_FIELD_LENGTH = 300;
|
|
|
307
307
|
function safe(value) {
|
|
308
308
|
// Coerce first, sanitize always. Returning a non-string early skipped both the
|
|
309
309
|
// control-character strip and the length clamp, so an array or an object with a
|
|
310
|
-
// toString() came through untouched -- `safe(["
|
|
310
|
+
// toString() came through untouched -- `safe(["\x1b[2Jx"])` returned the escape
|
|
311
311
|
// verbatim. GitHub returns strings for every field this is called on today, so
|
|
312
312
|
// the guarantee was being provided by the upstream schema rather than by the
|
|
313
313
|
// function that claims to provide it.
|
|
@@ -3275,8 +3275,8 @@ function claimIdentityBootstrap(root, { credentialKey, host, resource = "core",
|
|
|
3275
3275
|
}, { now, kill });
|
|
3276
3276
|
}
|
|
3277
3277
|
|
|
3278
|
-
function readIdentityQuotaState(registry, scope, now) {
|
|
3279
|
-
const loaded = readGovernorState(scope.path, now);
|
|
3278
|
+
function readIdentityQuotaState(registry, scope, now, options = {}) {
|
|
3279
|
+
const loaded = readGovernorState(scope.path, now, options);
|
|
3280
3280
|
const known = Object.values(registry.identities).some((identity) => identity.quotaKey === scope.quotaKey) ||
|
|
3281
3281
|
Object.values(registry.attempts).some((attempt) => attempt.imported && attempt.quotaKey === scope.quotaKey);
|
|
3282
3282
|
return loaded.ok && loaded.missing && known ? { ok: false, reason: "corrupt" } : loaded;
|
|
@@ -3302,9 +3302,9 @@ function importIdentityDebts(root, state, credentialKey, identity, now) {
|
|
|
3302
3302
|
notBefore: attempt.startedAt, status: "started", epochs: { core: null, graphql: null },
|
|
3303
3303
|
startedAt: attempt.startedAt, completedAt: null, outcome: null,
|
|
3304
3304
|
};
|
|
3305
|
-
if (!loaded.value.importMarkers[id]
|
|
3306
|
-
|
|
3307
|
-
|
|
3305
|
+
if (!loaded.value.importMarkers[id]) {
|
|
3306
|
+
const failure = transferReceiptToDebt(loaded.value, legacyControl, now, id);
|
|
3307
|
+
if (failure) return failure;
|
|
3308
3308
|
}
|
|
3309
3309
|
if (Object.keys(loaded.value.importMarkers).length >= IDENTITY_MAX_ATTEMPTS &&
|
|
3310
3310
|
!loaded.value.importMarkers[id]) return { ok: false, reason: "identity-capacity" };
|
|
@@ -3519,7 +3519,8 @@ function startIdentityControl(coordinator, now = Date.now()) {
|
|
|
3519
3519
|
if (Object.keys(registry.attempts).length >= IDENTITY_MAX_ATTEMPTS) return { ok: false, reason: "identity-capacity" };
|
|
3520
3520
|
const priorControl = state.controlReceipts.core;
|
|
3521
3521
|
if (priorControl) {
|
|
3522
|
-
|
|
3522
|
+
const failure = retireControlReceiptToDebt(state, "core", now);
|
|
3523
|
+
if (failure) return failure;
|
|
3523
3524
|
}
|
|
3524
3525
|
const id = governorId();
|
|
3525
3526
|
registry.attempts[id] = { credentialKey: identity.credentialKey, host: identity.host, resource: "core", startedAt: now,
|
|
@@ -3574,8 +3575,9 @@ function settleIdentityControl(coordinator, control, stdout, now = Date.now(),
|
|
|
3574
3575
|
reservation.actualCosts = { core: cost, graphql: 0 };
|
|
3575
3576
|
reservation.accountedCosts = { core: cost, graphql: 0 };
|
|
3576
3577
|
loaded.value.controlReceipts.core = null;
|
|
3577
|
-
} else
|
|
3578
|
-
|
|
3578
|
+
} else {
|
|
3579
|
+
const failure = retireControlReceiptToDebt(loaded.value, "core", now);
|
|
3580
|
+
if (failure) return failure;
|
|
3579
3581
|
}
|
|
3580
3582
|
return writeGovernorQuotaState(scope.path, loaded.value, now);
|
|
3581
3583
|
});
|
|
@@ -3844,6 +3846,7 @@ function identityCoordinationMessage(reason) {
|
|
|
3844
3846
|
if (reason === "throttle-paused") return "Paused after repeated rate limits; refresh to retry";
|
|
3845
3847
|
if (retryableCoordination(reason)) return "Coordinating with your other panes";
|
|
3846
3848
|
if (reason === "identity-capacity") return "Coordination state full; retrying after the current window";
|
|
3849
|
+
if (reason === "debt-journal-full") return "Coordination journal full; retrying after older requests settle";
|
|
3847
3850
|
return "Verified identity unavailable; waiting to retry";
|
|
3848
3851
|
}
|
|
3849
3852
|
|
|
@@ -3889,6 +3892,9 @@ const GOVERNOR_MAX_LEASES = 128;
|
|
|
3889
3892
|
const GOVERNOR_MAX_INTENTS = 512;
|
|
3890
3893
|
const GOVERNOR_MAX_RESERVATIONS = 512;
|
|
3891
3894
|
const GOVERNOR_MAX_DEBT_GROUPS = 128;
|
|
3895
|
+
// Quiescent groups merge once the journal is this full, so retirement never
|
|
3896
|
+
// waits on a probe publication that a full journal would itself block.
|
|
3897
|
+
const DEBT_JOURNAL_COMPACT_AT = GOVERNOR_MAX_DEBT_GROUPS / 2;
|
|
3892
3898
|
const GOVERNOR_MAX_LEDGER_BYTES = 2 * 1024 * 1024;
|
|
3893
3899
|
const GOVERNOR_LOCK_WAIT_MS = 250;
|
|
3894
3900
|
// How old an unreadable lock record may be before it counts as abandoned. A
|
|
@@ -4526,10 +4532,8 @@ function migrateLegacyGovernorReceipts(legacy, nowMs) {
|
|
|
4526
4532
|
if (RATE_RESOURCES.some((resource) => units[resource] > 0)) {
|
|
4527
4533
|
const nonce = governorId();
|
|
4528
4534
|
state.debtGroups[nonce] = {
|
|
4529
|
-
nonce,
|
|
4530
|
-
|
|
4531
|
-
units, counts, at: nowMs, barrierAt: null,
|
|
4532
|
-
barrierRevisions: { core: 0, graphql: 0 },
|
|
4535
|
+
...newDebtGroup({ nonce, bootId: coordinationBootId(), unknownChild: true, at: nowMs }),
|
|
4536
|
+
units, counts,
|
|
4533
4537
|
};
|
|
4534
4538
|
for (const resource of RATE_RESOURCES) {
|
|
4535
4539
|
state.debt[resource].unresolvedUnits = units[resource];
|
|
@@ -4596,25 +4600,57 @@ function debtGroupTarget(state, receipt, groupId = null) {
|
|
|
4596
4600
|
return state.debtGroups[UNKNOWN_DEBT_GROUP_ID] ? governorId() : UNKNOWN_DEBT_GROUP_ID;
|
|
4597
4601
|
}
|
|
4598
4602
|
|
|
4603
|
+
const ACCOUNTING_OVERFLOW = Object.freeze({ ok: false, reason: "accounting-overflow" });
|
|
4604
|
+
|
|
4605
|
+
// The one shape normalizeGovernorState accepts for a group; every site that
|
|
4606
|
+
// creates a group starts from it so a new field cannot be missed in one copy.
|
|
4607
|
+
function newDebtGroup({ nonce = governorId(), bootId, unknownChild, at, quiescent = false, barrierAt = null }) {
|
|
4608
|
+
return {
|
|
4609
|
+
nonce, ownerNonce: null, ownerPid: null, ownerBirth: null, bootId,
|
|
4610
|
+
unknownChild, sealed: true, quiescent,
|
|
4611
|
+
units: { core: 0, graphql: 0 }, counts: { core: 0, graphql: 0 },
|
|
4612
|
+
at, barrierAt, barrierRevisions: { core: 0, graphql: 0 },
|
|
4613
|
+
};
|
|
4614
|
+
}
|
|
4615
|
+
|
|
4616
|
+
// When age alone settles an unresolved group. Only a sealed group ages out;
|
|
4617
|
+
// an owner acknowledgement can settle either kind earlier.
|
|
4618
|
+
function debtGroupSettlesAt(group) {
|
|
4619
|
+
return group.sealed ? group.at + IDENTITY_UNCERTAIN_MAX_MS : Number.POSITIVE_INFINITY;
|
|
4620
|
+
}
|
|
4621
|
+
|
|
4622
|
+
function hasLiveProbeClaim(state, at) {
|
|
4623
|
+
return RATE_RESOURCES.some((resource) => state.probeClaims[resource]?.leaseUntil > at);
|
|
4624
|
+
}
|
|
4625
|
+
|
|
4626
|
+
// A journal with no free slot is a capacity condition, not an arithmetic one.
|
|
4627
|
+
// A retry can succeed once an unresolved group ages out or a live claim ends
|
|
4628
|
+
// and lets compaction run; the probe interval bounds the wait so a pane never
|
|
4629
|
+
// shows a retry hours away.
|
|
4630
|
+
function debtJournalFullFailure(state, nowMs) {
|
|
4631
|
+
const unblockAt = Math.min(
|
|
4632
|
+
...Object.values(state.debtGroups).filter((group) => !group.quiescent).map(debtGroupSettlesAt),
|
|
4633
|
+
...RATE_RESOURCES.map((resource) => state.probeClaims[resource]?.leaseUntil ?? Number.POSITIVE_INFINITY),
|
|
4634
|
+
nowMs + BUDGET_PROBE_MS,
|
|
4635
|
+
);
|
|
4636
|
+
return { ok: false, reason: "debt-journal-full", retryAt: Math.max(unblockAt, nowMs + 1000) };
|
|
4637
|
+
}
|
|
4638
|
+
|
|
4639
|
+
// Returns null when the charge is recorded, otherwise the failure to report.
|
|
4599
4640
|
function transferReceiptToDebt(state, receipt, nowMs, groupId = null) {
|
|
4600
4641
|
const units = Object.fromEntries(RATE_RESOURCES.map((resource) => [resource,
|
|
4601
4642
|
reservationCost(receipt, resource, state.leases, nowMs)]));
|
|
4602
|
-
if (RATE_RESOURCES.every((resource) => units[resource] === 0)) return
|
|
4643
|
+
if (RATE_RESOURCES.every((resource) => units[resource] === 0)) return null;
|
|
4603
4644
|
const id = debtGroupTarget(state, receipt, groupId);
|
|
4604
|
-
if (id === null) return
|
|
4645
|
+
if (id === null) return debtJournalFullFailure(state, nowMs);
|
|
4605
4646
|
const incomingBootId = receipt.receipt?.bootId ?? coordinationBootId();
|
|
4606
4647
|
const dedicated = id === groupId;
|
|
4607
|
-
const group = state.debtGroups[id] ?? {
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
units: { core: 0, graphql: 0 }, counts: { core: 0, graphql: 0 },
|
|
4612
|
-
at: nowMs, barrierAt: null,
|
|
4613
|
-
barrierRevisions: { core: 0, graphql: 0 },
|
|
4614
|
-
};
|
|
4615
|
-
if (group.quiescent) return false;
|
|
4648
|
+
const group = state.debtGroups[id] ?? newDebtGroup({
|
|
4649
|
+
bootId: incomingBootId, unknownChild: !dedicated || !receipt.receipt, at: nowMs,
|
|
4650
|
+
});
|
|
4651
|
+
if (group.quiescent) return ACCOUNTING_OVERFLOW;
|
|
4616
4652
|
if (receipt.receipt && dedicated) {
|
|
4617
|
-
if (group.ownerNonce !== null && group.ownerNonce !== receipt.receipt.ownerNonce) return
|
|
4653
|
+
if (group.ownerNonce !== null && group.ownerNonce !== receipt.receipt.ownerNonce) return ACCOUNTING_OVERFLOW;
|
|
4618
4654
|
group.ownerNonce = receipt.receipt.ownerNonce;
|
|
4619
4655
|
group.ownerPid = receipt.receipt.ownerPid;
|
|
4620
4656
|
group.ownerBirth = receipt.receipt.ownerBirth;
|
|
@@ -4629,7 +4665,7 @@ function transferReceiptToDebt(state, receipt, nowMs, groupId = null) {
|
|
|
4629
4665
|
!Number.isSafeInteger(group.units[resource] + amount) ||
|
|
4630
4666
|
!Number.isSafeInteger(state.debt[resource].unresolvedCount + (amount > 0 ? 1 : 0)) ||
|
|
4631
4667
|
!Number.isSafeInteger(state.debt[resource].revision + 1) ||
|
|
4632
|
-
!Number.isSafeInteger(group.counts[resource] + (amount > 0 ? 1 : 0))) return
|
|
4668
|
+
!Number.isSafeInteger(group.counts[resource] + (amount > 0 ? 1 : 0))) return ACCOUNTING_OVERFLOW;
|
|
4633
4669
|
}
|
|
4634
4670
|
for (const resource of RATE_RESOURCES) {
|
|
4635
4671
|
const amount = units[resource];
|
|
@@ -4643,7 +4679,7 @@ function transferReceiptToDebt(state, receipt, nowMs, groupId = null) {
|
|
|
4643
4679
|
// which reconcileBootDebt may treat the group as settled.
|
|
4644
4680
|
group.at = Math.max(group.at, nowMs);
|
|
4645
4681
|
state.debtGroups[id] = group;
|
|
4646
|
-
return
|
|
4682
|
+
return null;
|
|
4647
4683
|
}
|
|
4648
4684
|
|
|
4649
4685
|
function makeDebtGroupQuiescent(state, group, at) {
|
|
@@ -4670,23 +4706,25 @@ function makeDebtGroupQuiescent(state, group, at) {
|
|
|
4670
4706
|
return true;
|
|
4671
4707
|
}
|
|
4672
4708
|
|
|
4709
|
+
// Returns null when the receipt is retired, otherwise the failure to report.
|
|
4673
4710
|
function retireControlReceiptToDebt(state, resource, at) {
|
|
4674
4711
|
const control = state.controlReceipts[resource];
|
|
4675
|
-
if (!control) return
|
|
4712
|
+
if (!control) return null;
|
|
4676
4713
|
// A control receipt without an issued dispatch cannot have started HTTP.
|
|
4677
4714
|
// Removing it also fences a late owner before it can issue a child.
|
|
4678
4715
|
if (control.receipt.dispatches.length === 0) {
|
|
4679
4716
|
state.controlReceipts[resource] = null;
|
|
4680
|
-
return
|
|
4717
|
+
return null;
|
|
4681
4718
|
}
|
|
4682
4719
|
const generation = control.receipt.generation;
|
|
4683
|
-
|
|
4720
|
+
const failure = transferReceiptToDebt(state, control, at, generation);
|
|
4721
|
+
if (failure) return failure;
|
|
4684
4722
|
const group = state.debtGroups[generation];
|
|
4685
4723
|
if (group && !group.unknownChild && group.ownerPid !== null &&
|
|
4686
4724
|
group.ownerBirth !== null && group.bootId !== null &&
|
|
4687
|
-
!makeDebtGroupQuiescent(state, group, at)) return
|
|
4725
|
+
!makeDebtGroupQuiescent(state, group, at)) return ACCOUNTING_OVERFLOW;
|
|
4688
4726
|
state.controlReceipts[resource] = null;
|
|
4689
|
-
return
|
|
4727
|
+
return null;
|
|
4690
4728
|
}
|
|
4691
4729
|
|
|
4692
4730
|
// A reboot proves an unknown owner's children are gone. So does age: a group's
|
|
@@ -4698,16 +4736,49 @@ function reconcileBootDebt(state, at) {
|
|
|
4698
4736
|
for (const group of Object.values(state.debtGroups)) {
|
|
4699
4737
|
if (group.quiescent) continue;
|
|
4700
4738
|
const priorBoot = bootId !== null && group.bootId !== null && group.bootId !== bootId;
|
|
4701
|
-
const outlivedWindow =
|
|
4739
|
+
const outlivedWindow = at >= debtGroupSettlesAt(group);
|
|
4702
4740
|
if (!priorBoot && !outlivedWindow) continue;
|
|
4703
4741
|
if (!makeDebtGroupQuiescent(state, group, at)) {
|
|
4704
|
-
return
|
|
4742
|
+
return ACCOUNTING_OVERFLOW;
|
|
4705
4743
|
}
|
|
4706
4744
|
changed = true;
|
|
4707
4745
|
}
|
|
4708
4746
|
return { ok: true, changed };
|
|
4709
4747
|
}
|
|
4710
4748
|
|
|
4749
|
+
// Quiescent groups are frozen until a probe publication retires them, and a
|
|
4750
|
+
// publication needs a claim that first charges its control receipt to a group.
|
|
4751
|
+
// With the journal full of quiescent groups that charge has nowhere to go, so
|
|
4752
|
+
// the only operation that drains the journal is the one it blocks. Merging the
|
|
4753
|
+
// frozen groups keeps the totals and frees the slots. A claim compares the
|
|
4754
|
+
// snapshot it took against the groups it finds at publication, so the shape may
|
|
4755
|
+
// only change while no claim is live.
|
|
4756
|
+
function compactQuiescentDebtGroups(state, at) {
|
|
4757
|
+
if (Object.keys(state.debtGroups).length < DEBT_JOURNAL_COMPACT_AT || hasLiveProbeClaim(state, at)) {
|
|
4758
|
+
return { ok: true, changed: false };
|
|
4759
|
+
}
|
|
4760
|
+
const quiescent = Object.entries(state.debtGroups).filter(([, group]) => group.quiescent);
|
|
4761
|
+
let changed = false;
|
|
4762
|
+
for (const [bootId, entries] of Map.groupBy(quiescent, ([, group]) => group.bootId)) {
|
|
4763
|
+
if (entries.length < 2) continue;
|
|
4764
|
+
const groups = entries.map(([, group]) => group);
|
|
4765
|
+
const merged = newDebtGroup({ bootId, unknownChild: true, quiescent: true,
|
|
4766
|
+
at: Math.max(...groups.map((group) => group.at)),
|
|
4767
|
+
barrierAt: Math.max(0, ...groups.map((group) => group.barrierAt ?? 0)) });
|
|
4768
|
+
for (const resource of RATE_RESOURCES) {
|
|
4769
|
+
merged.units[resource] = groups.reduce((sum, group) => sum + group.units[resource], 0);
|
|
4770
|
+
merged.counts[resource] = groups.reduce((sum, group) => sum + group.counts[resource], 0);
|
|
4771
|
+
if (!Number.isSafeInteger(merged.units[resource]) ||
|
|
4772
|
+
!Number.isSafeInteger(merged.counts[resource])) return ACCOUNTING_OVERFLOW;
|
|
4773
|
+
merged.barrierRevisions[resource] = Math.max(...groups.map((group) => group.barrierRevisions[resource]));
|
|
4774
|
+
}
|
|
4775
|
+
for (const [id] of entries) delete state.debtGroups[id];
|
|
4776
|
+
state.debtGroups[merged.nonce] = merged;
|
|
4777
|
+
changed = true;
|
|
4778
|
+
}
|
|
4779
|
+
return { ok: true, changed };
|
|
4780
|
+
}
|
|
4781
|
+
|
|
4711
4782
|
function compactGovernorReceipts(state, nowMs) {
|
|
4712
4783
|
const sealed = new Set();
|
|
4713
4784
|
const full = Object.keys(state.reservations).length >= GOVERNOR_MAX_RESERVATIONS;
|
|
@@ -4731,18 +4802,16 @@ function compactGovernorReceipts(state, nowMs) {
|
|
|
4731
4802
|
const trial = structuredClone(state);
|
|
4732
4803
|
for (const reservation of candidates) {
|
|
4733
4804
|
const groupId = reservation.receipt?.generation ?? null;
|
|
4734
|
-
|
|
4735
|
-
if (
|
|
4736
|
-
|
|
4737
|
-
}
|
|
4805
|
+
const failure = transferReceiptToDebt(trial, reservation, nowMs, groupId);
|
|
4806
|
+
if (failure?.reason === "debt-journal-full") return { ok: true, changed: false };
|
|
4807
|
+
if (failure) return failure;
|
|
4738
4808
|
}
|
|
4739
4809
|
}
|
|
4740
4810
|
if (full) {
|
|
4741
4811
|
for (const [id, reservation] of Object.entries(state.reservations)) {
|
|
4742
4812
|
if (reservation.status !== "completed" || reservation.receipt) continue;
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
}
|
|
4813
|
+
const failure = transferReceiptToDebt(state, reservation, nowMs);
|
|
4814
|
+
if (failure) return failure;
|
|
4746
4815
|
delete state.reservations[id];
|
|
4747
4816
|
changed = true;
|
|
4748
4817
|
}
|
|
@@ -4763,9 +4832,9 @@ function compactGovernorReceipts(state, nowMs) {
|
|
|
4763
4832
|
for (const generation of sealed) {
|
|
4764
4833
|
for (const [id, reservation] of Object.entries(state.reservations)) {
|
|
4765
4834
|
if (reservation.receipt?.generation !== generation) continue;
|
|
4766
|
-
if (!(reservation.status === "started" && reservation.receipt.dispatches.length === 0)
|
|
4767
|
-
|
|
4768
|
-
|
|
4835
|
+
if (!(reservation.status === "started" && reservation.receipt.dispatches.length === 0)) {
|
|
4836
|
+
const failure = transferReceiptToDebt(state, reservation, nowMs, generation);
|
|
4837
|
+
if (failure) return failure;
|
|
4769
4838
|
}
|
|
4770
4839
|
delete state.reservations[id];
|
|
4771
4840
|
state.ownerGenerations[reservation.leaseId] = governorId();
|
|
@@ -4774,7 +4843,7 @@ function compactGovernorReceipts(state, nowMs) {
|
|
|
4774
4843
|
if (group && !group.unknownChild && group.ownerPid !== null &&
|
|
4775
4844
|
group.ownerBirth !== null && group.bootId !== null &&
|
|
4776
4845
|
!makeDebtGroupQuiescent(state, group, nowMs)) {
|
|
4777
|
-
return
|
|
4846
|
+
return ACCOUNTING_OVERFLOW;
|
|
4778
4847
|
}
|
|
4779
4848
|
}
|
|
4780
4849
|
retireOwnerGenerations(state);
|
|
@@ -4860,7 +4929,7 @@ function writeGovernorState(path, state) {
|
|
|
4860
4929
|
try {
|
|
4861
4930
|
if (state?.version === GOVERNOR_STATE_VERSION) {
|
|
4862
4931
|
if (!Number.isSafeInteger(state.revision) || state.revision === Number.MAX_SAFE_INTEGER) {
|
|
4863
|
-
return
|
|
4932
|
+
return ACCOUNTING_OVERFLOW;
|
|
4864
4933
|
}
|
|
4865
4934
|
state.revision += 1;
|
|
4866
4935
|
}
|
|
@@ -4890,7 +4959,7 @@ function writeGovernorQuotaState(path, state, nowMs) {
|
|
|
4890
4959
|
const normalized = normalizeGovernorState(state, nowMs, { prune: false });
|
|
4891
4960
|
if (!normalized) return { ok: false, reason: "corrupt" };
|
|
4892
4961
|
if (!governorAccountingSafe(normalized, nowMs)) {
|
|
4893
|
-
return
|
|
4962
|
+
return ACCOUNTING_OVERFLOW;
|
|
4894
4963
|
}
|
|
4895
4964
|
return writeGovernorState(path, normalized);
|
|
4896
4965
|
}
|
|
@@ -5204,17 +5273,19 @@ function mutateGovernor(scope, nowMs, mutate) {
|
|
|
5204
5273
|
const loaded = readGovernorState(scope.path, at, { persistMigration: false });
|
|
5205
5274
|
if (!loaded.ok) return loaded;
|
|
5206
5275
|
const state = loaded.value;
|
|
5207
|
-
if (!governorAccountingSafe(state, at)) return
|
|
5276
|
+
if (!governorAccountingSafe(state, at)) return ACCOUNTING_OVERFLOW;
|
|
5208
5277
|
const oldBootReconciled = reconcileBootDebt(state, at);
|
|
5209
5278
|
if (!oldBootReconciled.ok) return oldBootReconciled;
|
|
5210
5279
|
const compacted = compactGovernorReceipts(state, at);
|
|
5211
5280
|
if (!compacted.ok) return compacted;
|
|
5212
5281
|
const bootReconciled = reconcileBootDebt(state, at);
|
|
5213
5282
|
if (!bootReconciled.ok) return bootReconciled;
|
|
5283
|
+
const journal = compactQuiescentDebtGroups(state, at);
|
|
5284
|
+
if (!journal.ok) return journal;
|
|
5214
5285
|
// A mutator can reject without writing. Keep an untouched migration so
|
|
5215
5286
|
// that even that path upgrades the file once before releasing the lock.
|
|
5216
5287
|
const migration = loaded.migrated || compacted.changed ||
|
|
5217
|
-
oldBootReconciled.changed || bootReconciled.changed
|
|
5288
|
+
oldBootReconciled.changed || bootReconciled.changed || journal.changed
|
|
5218
5289
|
? structuredClone(state) : null;
|
|
5219
5290
|
const returnAfterMigration = (result) => {
|
|
5220
5291
|
if (migration === null) return result;
|
|
@@ -5223,7 +5294,7 @@ function mutateGovernor(scope, nowMs, mutate) {
|
|
|
5223
5294
|
};
|
|
5224
5295
|
const value = mutate(state, at);
|
|
5225
5296
|
if (value?.ok === false && value.write !== true) return returnAfterMigration(value);
|
|
5226
|
-
if (!governorAccountingSafe(state, at)) return returnAfterMigration(
|
|
5297
|
+
if (!governorAccountingSafe(state, at)) return returnAfterMigration(ACCOUNTING_OVERFLOW);
|
|
5227
5298
|
const normalized = normalizeGovernorState(state, at);
|
|
5228
5299
|
if (!normalized) return returnAfterMigration({ ok: false, reason: "corrupt" });
|
|
5229
5300
|
const written = writeGovernorState(scope.path, normalized);
|
|
@@ -5455,9 +5526,8 @@ function claimProbe(scope, leaseId, nowMs, resource) {
|
|
|
5455
5526
|
if (resource === "graphql" && state.controlReceipts.graphql) {
|
|
5456
5527
|
// The old claim's lease has expired. Keep an issued child charged as
|
|
5457
5528
|
// debt, and fence the old capability so a new observer can make progress.
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
}
|
|
5529
|
+
const failure = retireControlReceiptToDebt(state, "graphql", at);
|
|
5530
|
+
if (failure) return failure;
|
|
5461
5531
|
}
|
|
5462
5532
|
const nonce = randomUUID();
|
|
5463
5533
|
const startedReservationIds = Object.entries(state.reservations)
|
|
@@ -5476,7 +5546,7 @@ function claimProbe(scope, leaseId, nowMs, resource) {
|
|
|
5476
5546
|
const debtUnits = quiescentGroups.reduce((sum, group) => sum + group.units[resource], 0);
|
|
5477
5547
|
const debtCount = quiescentGroups.reduce((sum, group) => sum + group.counts[resource], 0);
|
|
5478
5548
|
if (!Number.isSafeInteger(debtUnits) || !Number.isSafeInteger(debtCount)) {
|
|
5479
|
-
return
|
|
5549
|
+
return ACCOUNTING_OVERFLOW;
|
|
5480
5550
|
}
|
|
5481
5551
|
const debtSnapshot = {
|
|
5482
5552
|
units: debtUnits,
|
|
@@ -5683,7 +5753,7 @@ function publishProbe(scope, leaseId, nonce, budgets, nowMs, resource) {
|
|
|
5683
5753
|
}
|
|
5684
5754
|
if (changedResources.length === 0) return { ok: false, reason: "corrupt" };
|
|
5685
5755
|
if (!retireClaimedQuiescentDebt(state, resource, claim)) {
|
|
5686
|
-
return
|
|
5756
|
+
return ACCOUNTING_OVERFLOW;
|
|
5687
5757
|
}
|
|
5688
5758
|
const completedBeforeClaim = Object.entries(state.reservations)
|
|
5689
5759
|
.filter(([, reservation]) =>
|
|
@@ -5803,9 +5873,8 @@ function failProbeClaim(scope, leaseId, nonce, nowMs, resource) {
|
|
|
5803
5873
|
const control = state.controlReceipts.graphql;
|
|
5804
5874
|
if (control.receipt.dispatches.length === 0) state.controlReceipts.graphql = null;
|
|
5805
5875
|
else if (control.receipt.dispatches.every((dispatch) => dispatch.terminalAt !== null)) {
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
}
|
|
5876
|
+
const failure = retireControlReceiptToDebt(state, "graphql", at);
|
|
5877
|
+
if (failure) return failure;
|
|
5809
5878
|
}
|
|
5810
5879
|
}
|
|
5811
5880
|
state.observers[resource] = {
|
|
@@ -6109,7 +6178,7 @@ function acknowledgeSealedGeneration(scope, capability, sealedNonce, nowMs) {
|
|
|
6109
6178
|
reservation.receipt?.generation === capability.generation)) {
|
|
6110
6179
|
return { ok: false, reason: "quiescence-unverified" };
|
|
6111
6180
|
}
|
|
6112
|
-
if (!makeDebtGroupQuiescent(state, group, at)) return
|
|
6181
|
+
if (!makeDebtGroupQuiescent(state, group, at)) return ACCOUNTING_OVERFLOW;
|
|
6113
6182
|
return { value: { status: "acknowledged", nonce: group.nonce } };
|
|
6114
6183
|
});
|
|
6115
6184
|
}
|
|
@@ -6368,13 +6437,16 @@ function governorHealth(result, nowMs = Date.now()) {
|
|
|
6368
6437
|
if (!result?.ok) return { status: `unavailable (${result?.reason ?? "unknown"})`, leases: 0, resources: {} };
|
|
6369
6438
|
const state = result.value;
|
|
6370
6439
|
let status = "healthy";
|
|
6371
|
-
if (
|
|
6440
|
+
if (hasLiveProbeClaim(state, nowMs)) status = "waiting for probe";
|
|
6372
6441
|
else if (RATE_RESOURCES.some((resource) => !state.budgets[resource] ||
|
|
6373
6442
|
nowMs - state.budgets[resource].observedAt > budgetSnapshotTtl(resource))) status = "stale";
|
|
6374
6443
|
else if (RATE_RESOURCES.some((resource) => state.budgets[resource].blockUntil > nowMs)) status = "blocked";
|
|
6444
|
+
const groups = Object.values(state.debtGroups);
|
|
6375
6445
|
return {
|
|
6376
6446
|
status,
|
|
6377
6447
|
leases: Object.keys(state.leases).length,
|
|
6448
|
+
debtGroups: groups.length,
|
|
6449
|
+
quiescentGroups: groups.filter((group) => group.quiescent).length,
|
|
6378
6450
|
resources: Object.fromEntries(RATE_RESOURCES.flatMap((resource) => {
|
|
6379
6451
|
const budget = state.budgets[resource];
|
|
6380
6452
|
return budget ? [[resource, {
|
|
@@ -6413,12 +6485,15 @@ function doctorCachedQuotaScope(host, { root = identityRegistryRoot(), nowMs = D
|
|
|
6413
6485
|
try {
|
|
6414
6486
|
if (!lstatSync(path).isFile()) return { ok: false, reason: "unsafe quota path", path };
|
|
6415
6487
|
} catch (error) {
|
|
6416
|
-
|
|
6488
|
+
if (error?.code !== "ENOENT") return { ok: false, reason: "cached quota unreadable", path };
|
|
6417
6489
|
}
|
|
6418
|
-
|
|
6490
|
+
// The runtime's own guard classifies the file: a missing ledger for a quota
|
|
6491
|
+
// key the registry still lists is corrupt, never a fresh start, so doctor
|
|
6492
|
+
// reports the same reason the panes act on.
|
|
6493
|
+
const result = readIdentityQuotaState(registry, { path, quotaKey }, nowMs, { persistMigration: false });
|
|
6419
6494
|
return result.ok && !result.missing
|
|
6420
6495
|
? { ok: true, result, path, quotaKey }
|
|
6421
|
-
: { ok: false, reason: result.
|
|
6496
|
+
: { ok: false, reason: result.ok ? "cached quota missing" : result.reason, path };
|
|
6422
6497
|
}
|
|
6423
6498
|
|
|
6424
6499
|
async function retryGovernorMutation(run, { now, wait, deadline, signal }) {
|
|
@@ -8075,7 +8150,7 @@ function mapWebhookInvalidations({ event, payload, config, enterpriseHost = null
|
|
|
8075
8150
|
? config.targets.map((target) => ({ ...target, resources: TAB_KEYS }))
|
|
8076
8151
|
: config.webhook.targets;
|
|
8077
8152
|
let targets = mappedTargets.filter((target) =>
|
|
8078
|
-
(!selectedHost || target.host === selectedHost) && (event === "installation" || names.has(target.repo)));
|
|
8153
|
+
(!selectedHost || target.host === selectedHost) && (event === "installation" || names.has(target.repo.toLowerCase())));
|
|
8079
8154
|
if (["installation", "installation_repositories"].includes(event)) {
|
|
8080
8155
|
targets = targets.filter((target) => {
|
|
8081
8156
|
const provider = config.providers[target.provider];
|
|
@@ -8085,7 +8160,10 @@ function mapWebhookInvalidations({ event, payload, config, enterpriseHost = null
|
|
|
8085
8160
|
}
|
|
8086
8161
|
if (!selectedHost && event !== "installation") {
|
|
8087
8162
|
const hostsByRepo = new Map();
|
|
8088
|
-
for (const target of targets)
|
|
8163
|
+
for (const target of targets) {
|
|
8164
|
+
const repo = target.repo.toLowerCase();
|
|
8165
|
+
hostsByRepo.set(repo, (hostsByRepo.get(repo) ?? new Set()).add(target.host));
|
|
8166
|
+
}
|
|
8089
8167
|
if ([...hostsByRepo.values()].some((hosts) => hosts.size !== 1)) return { ok: false, reason: "target" };
|
|
8090
8168
|
}
|
|
8091
8169
|
if (targets.length === 0) return ["installation", "installation_repositories"].includes(event)
|
|
@@ -11268,6 +11346,11 @@ async function runDoctor({ probeEndpoints = false } = {}) {
|
|
|
11268
11346
|
.filter(Boolean).sort((left, right) => right.at - left.at)[0] ?? null;
|
|
11269
11347
|
const lastFailedRecovery = [...(recoveryJournal?.entries ?? [])].reverse()
|
|
11270
11348
|
.find((entry) => entry.code !== "clear") ?? null;
|
|
11349
|
+
const ledgerPath = cachedQuota?.path ?? diagnosticScope?.path ?? null;
|
|
11350
|
+
// The journal cannot speak for a ledger that cannot be read, so the cached
|
|
11351
|
+
// read's own reason counts too; both use the governor's reason vocabulary.
|
|
11352
|
+
const hardRecovery = Boolean(ledgerPath) &&
|
|
11353
|
+
[currentRecovery?.code, cachedQuota?.reason].some((reason) => HARD_STORAGE_REASONS.includes(reason));
|
|
11271
11354
|
const retryDates = RATE_RESOURCES.flatMap((resource) => {
|
|
11272
11355
|
const active = recoveryJournal?.current[resource];
|
|
11273
11356
|
const observer = (probeEndpoints ? governorResult : cachedQuota?.result)?.value?.observers[resource];
|
|
@@ -11324,11 +11407,16 @@ async function runDoctor({ probeEndpoints = false } = {}) {
|
|
|
11324
11407
|
"",
|
|
11325
11408
|
...section("API governor"),
|
|
11326
11409
|
field("cached scope", probeEndpoints ? "verified by probe" : cachedQuota?.ok ? "read-only cached identity" : `unavailable (${cachedQuota?.reason ?? "unknown"})`),
|
|
11327
|
-
|
|
11328
|
-
|
|
11410
|
+
// The user has to find the file to move it.
|
|
11411
|
+
...(ledgerPath && cachedQuota?.ok === false || hardRecovery
|
|
11412
|
+
? [field("state file", `${join(dirname(ledgerPath), "quota-[redacted].json")} (quota fingerprint withheld)`)]
|
|
11329
11413
|
: []),
|
|
11414
|
+
...(hardRecovery ? [field("repair", LEDGER_REPAIR_ADVICE)] : []),
|
|
11330
11415
|
field("status", governor.status),
|
|
11331
11416
|
field("live leases", governor.leases),
|
|
11417
|
+
field("debt groups", governor.debtGroups == null
|
|
11418
|
+
? "unavailable"
|
|
11419
|
+
: `${governor.debtGroups}/${GOVERNOR_MAX_DEBT_GROUPS} (${governor.quiescentGroups} quiescent)`),
|
|
11332
11420
|
...RATE_RESOURCES.map((resource) => {
|
|
11333
11421
|
const detail = governor.resources[resource];
|
|
11334
11422
|
return field(
|
|
@@ -15118,7 +15206,7 @@ function createAcquisitionEngine({
|
|
|
15118
15206
|
ledger.debt[resource].unresolvedUnits + ledger.debt[resource].quiescentUnits +
|
|
15119
15207
|
reservationCost(ledger.controlReceipts[resource], resource, ledger.leases, now()))]));
|
|
15120
15208
|
if (Object.values(units).some((value) => !Number.isSafeInteger(value) || value < 0)) {
|
|
15121
|
-
return
|
|
15209
|
+
return ACCOUNTING_OVERFLOW;
|
|
15122
15210
|
}
|
|
15123
15211
|
return transact((state) => {
|
|
15124
15212
|
const activeKeys = new Set(Object.values(state.subscriptions)
|
|
@@ -15699,6 +15787,14 @@ function coordinationNotice(reason) {
|
|
|
15699
15787
|
const RECOVERY_JOURNAL_MAX_ENTRIES = 128;
|
|
15700
15788
|
const RECOVERY_JOURNAL_MAX_BYTES = 128 * 1024;
|
|
15701
15789
|
const RECOVERY_ENTRY_MAX_BYTES = 1024;
|
|
15790
|
+
// Reasons that no retry can clear: the ledger itself must be repaired. The
|
|
15791
|
+
// runtime keeps them sticky and doctor names the manual remedy for them.
|
|
15792
|
+
const HARD_STORAGE_REASONS = Object.freeze(["corrupt", "unwritable", "capacity", "accounting-overflow"]);
|
|
15793
|
+
// A source failure on a tab may be superseded by these coordination causes.
|
|
15794
|
+
const STICKY_OVER_SOURCE_REASONS = Object.freeze([...HARD_STORAGE_REASONS, "receipt-retry", "interrupted-request"]);
|
|
15795
|
+
const LEDGER_REPAIR_ADVICE = "quit every gh-glance pane, then move the state file, its .recovery.json and " +
|
|
15796
|
+
"registry.json out of that directory and relaunch; a fresh ledger starts, and at most the receipt and " +
|
|
15797
|
+
"debt units below (when shown) may be spent twice this hour";
|
|
15702
15798
|
const RECOVERY_COPY = Object.freeze({
|
|
15703
15799
|
busy: ["Coordinating with your other panes", "Retry automatically", "Other pane; retry"],
|
|
15704
15800
|
stale: ["Coordinating with your other panes", "Retry automatically", "Other pane; retry"],
|
|
@@ -15708,6 +15804,7 @@ const RECOVERY_COPY = Object.freeze({
|
|
|
15708
15804
|
corrupt: ["Local coordination is corrupt", "Run gh-glance --doctor; preserve the reported state file", "State corrupt; doctor"],
|
|
15709
15805
|
capacity: ["Local coordination is full", "Run gh-glance --doctor; preserve the reported state file", "State full; doctor"],
|
|
15710
15806
|
"accounting-overflow": ["Local coordination accounting overflow", "Run gh-glance --doctor; preserve the reported state file", "Overflow; doctor"],
|
|
15807
|
+
"debt-journal-full": ["Local coordination journal is full", "Retry automatically; older requests settle within an hour", "Journal full; retry"],
|
|
15711
15808
|
"reservations-invalid": ["Local coordination reservation invalid", "Run gh-glance --doctor; preserve the reported state file", "Reservation; doctor"],
|
|
15712
15809
|
"external-factor-invalid": ["Local coordination budget invalid", "Run gh-glance --doctor; preserve the reported state file", "Budget invalid; doctor"],
|
|
15713
15810
|
"pacing-invalid": ["Local coordination pacing invalid", "Run gh-glance --doctor; preserve the reported state file", "Pacing invalid; doctor"],
|
|
@@ -15791,8 +15888,8 @@ function shouldClearRecoveryCause(cause, { sourceSuccess = false, tab = null, sc
|
|
|
15791
15888
|
|
|
15792
15889
|
function recoveryRetryAt(decision, budget, observer, nowMs) {
|
|
15793
15890
|
if (budget?.blockUntil > nowMs) return budget.blockUntil;
|
|
15794
|
-
if (["reservations-invalid", "external-factor-invalid", "pacing-invalid",
|
|
15795
|
-
"
|
|
15891
|
+
if ([...HARD_STORAGE_REASONS, "reservations-invalid", "external-factor-invalid", "pacing-invalid",
|
|
15892
|
+
"budget-resource"].includes(decision?.reason)) return null;
|
|
15796
15893
|
const resetHold = ["budget-reset", "reset", "rate-limit"].includes(decision?.reason);
|
|
15797
15894
|
if (decision?.mode === "probe" && !resetHold) {
|
|
15798
15895
|
return observer?.nextAt > nowMs ? observer.nextAt : null;
|
|
@@ -17937,7 +18034,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
17937
18034
|
// which is exactly when the key is most likely to be pressed.
|
|
17938
18035
|
const request = manualRefreshRequest(refreshIntent(input));
|
|
17939
18036
|
if (request) fetchTabRef.current?.(TABS[activeIndexRef.current].key, request);
|
|
17940
|
-
} else if (input >= "1" && input <= String(TABS.length)) {
|
|
18037
|
+
} else if (input.length === 1 && input >= "1" && input <= String(TABS.length)) {
|
|
17941
18038
|
setActiveIndex(Number(input) - 1);
|
|
17942
18039
|
} else if (key.tab && key.shift) {
|
|
17943
18040
|
setActiveIndex((i) => (i - 1 + TABS.length) % TABS.length);
|
|
@@ -18042,18 +18139,16 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18042
18139
|
reason, resource, at, retryAt, sourceAt: lastOkRef.current[key] ?? null,
|
|
18043
18140
|
origin, tab: origin === "source" ? key : null,
|
|
18044
18141
|
});
|
|
18045
|
-
const
|
|
18046
|
-
const hardFailure = storageFailures.includes(candidate.code);
|
|
18142
|
+
const hardFailure = HARD_STORAGE_REASONS.includes(candidate.code);
|
|
18047
18143
|
// Contention does not prove that previously unreadable storage recovered.
|
|
18048
18144
|
// The existing healthy-control/source-success paths clear that remedy.
|
|
18049
|
-
if (!hardFailure &&
|
|
18145
|
+
if (!hardFailure && HARD_STORAGE_REASONS.includes(previous?.code)) return;
|
|
18050
18146
|
if (!hardFailure && previous?.code === "receipt-retry" &&
|
|
18051
18147
|
terminalizationBacklogForResource(terminalizationBacklog, scope?.hash, resource) > 0) return;
|
|
18052
18148
|
if (!hardFailure && previous?.code === "request-queue" &&
|
|
18053
18149
|
cleanupQueueForResource(cleanupQueue, resource) > 0) return;
|
|
18054
18150
|
if (previous?.origin === "source" && previous.tab === key && origin === "coordination" &&
|
|
18055
|
-
!
|
|
18056
|
-
"receipt-retry", "interrupted-request"].includes(candidate.code)) return;
|
|
18151
|
+
!STICKY_OVER_SOURCE_REASONS.includes(candidate.code)) return;
|
|
18057
18152
|
if (previous?.code === candidate.code && previous?.retryAt === candidate.retryAt &&
|
|
18058
18153
|
previous?.origin === candidate.origin && previous?.tab === candidate.tab &&
|
|
18059
18154
|
at - previous.at < 60_000) return;
|
|
@@ -18166,8 +18261,11 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18166
18261
|
if (!refreshed?.ok && !tabReady) {
|
|
18167
18262
|
const resource = RATE_RESOURCES.find((candidate) => tabRequestCost(key)[candidate] > 0 &&
|
|
18168
18263
|
snapshot.value.observers[candidate]?.outcome !== "healthy");
|
|
18264
|
+
// A rejected mutation writes nothing, so the observer's stored nextAt
|
|
18265
|
+
// predates the failure; a failure that names its own retry time wins.
|
|
18169
18266
|
pauseCoordination(key, refreshed?.reason, "observer", false,
|
|
18170
|
-
|
|
18267
|
+
Number.isFinite(refreshed?.retryAt) ? refreshed.retryAt
|
|
18268
|
+
: resource ? snapshot.value.observers[resource]?.nextAt : null);
|
|
18171
18269
|
return;
|
|
18172
18270
|
}
|
|
18173
18271
|
publishGovernorEpochs(snapshot);
|
|
@@ -20169,7 +20267,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
20169
20267
|
activeGovernorDecision?.cause)) {
|
|
20170
20268
|
semanticStatus = refreshStatus({
|
|
20171
20269
|
stale: true,
|
|
20172
|
-
disconnected:
|
|
20270
|
+
disconnected: statusDisconnected,
|
|
20173
20271
|
});
|
|
20174
20272
|
}
|
|
20175
20273
|
|
|
@@ -20803,6 +20901,9 @@ export {
|
|
|
20803
20901
|
GOVERNOR_MAX_LEASES,
|
|
20804
20902
|
GOVERNOR_MAX_INTENTS,
|
|
20805
20903
|
GOVERNOR_MAX_RESERVATIONS,
|
|
20904
|
+
GOVERNOR_MAX_DEBT_GROUPS,
|
|
20905
|
+
DEBT_JOURNAL_COMPACT_AT,
|
|
20906
|
+
UNKNOWN_DEBT_GROUP_ID,
|
|
20806
20907
|
GOVERNOR_LOCK_ORPHAN_MS,
|
|
20807
20908
|
GOVERNOR_LOCK_WAIT_MS,
|
|
20808
20909
|
GOVERNOR_PROBE_DRAIN_MS,
|
|
@@ -20818,6 +20919,7 @@ export {
|
|
|
20818
20919
|
normalizeGovernorState,
|
|
20819
20920
|
serializeGovernorState,
|
|
20820
20921
|
readGovernorState,
|
|
20922
|
+
readIdentityQuotaState,
|
|
20821
20923
|
writeGovernorState,
|
|
20822
20924
|
writeGovernorQuotaState,
|
|
20823
20925
|
pidIsDead,
|
package/package.json
CHANGED