gh-glance 0.18.0 → 0.18.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 +33 -1
- package/README.md +16 -13
- package/index.mjs +264 -57
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.18.1] - 2026-10-31
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- A collector that serves several repositories through one provider gives each
|
|
15
|
+
target its own immutable request scope, so one repository's rows can no
|
|
16
|
+
longer be fetched or published under another (#163). Security capability
|
|
17
|
+
backoff is now scoped by host and repository, so one repository's disabled
|
|
18
|
+
feature no longer hides another's alerts or resets its backoff ladder (#165).
|
|
19
|
+
- A secondary rate limit no longer blocks a resource until the hourly primary
|
|
20
|
+
reset; it stays under the shared transport cooldown (#164). A transport paused
|
|
21
|
+
after repeated throttles now recovers on `r` or after the primary reset, and
|
|
22
|
+
recovery never shortens a server-supplied Retry-After (#164).
|
|
23
|
+
- A GraphQL observer claim no longer delays core-only work, and the reverse
|
|
24
|
+
(#169). Governor waits that change nothing no longer rewrite the ledger (#174).
|
|
25
|
+
- Collector Issues and PR results compare as unchanged when their rows are the
|
|
26
|
+
same, so quiet queries reach the quiet cadence instead of polling at the
|
|
27
|
+
floor (#166). Adopting a peer's snapshot updates the local unchanged sentinel
|
|
28
|
+
(#167).
|
|
29
|
+
- A Security batch that only replayed capability backoff no longer advances the
|
|
30
|
+
success time or completes webhook refresh work (#168).
|
|
31
|
+
- A same-epoch collector reconnect shows Connected again without waiting for a
|
|
32
|
+
newer generation (#170), and followers no longer re-send unchanged snapshots
|
|
33
|
+
(#173).
|
|
34
|
+
- The Actions clock advances in collector mode, and collector source age counts
|
|
35
|
+
time spent suspended (#171).
|
|
36
|
+
- The inferred display cache is keyed by the repository resolved from the
|
|
37
|
+
remote, so a changed remote in the same directory no longer restores another
|
|
38
|
+
repository's rows (#172). Existing directory-only cache entries start cold
|
|
39
|
+
once.
|
|
40
|
+
|
|
10
41
|
## [0.18.0] - 2026-10-29
|
|
11
42
|
|
|
12
43
|
### Added
|
|
@@ -1361,7 +1392,8 @@ engineering, security, QA and UX. What follows is what changed as a result.
|
|
|
1361
1392
|
- The `main` field from `package.json`. It advertised the file as importable,
|
|
1362
1393
|
but importing it took over the terminal or exited the host process.
|
|
1363
1394
|
|
|
1364
|
-
[Unreleased]: https://github.com/juan294/gh-glance/compare/v0.18.
|
|
1395
|
+
[Unreleased]: https://github.com/juan294/gh-glance/compare/v0.18.1...HEAD
|
|
1396
|
+
[0.18.1]: https://github.com/juan294/gh-glance/compare/v0.18.0...v0.18.1
|
|
1365
1397
|
[0.18.0]: https://github.com/juan294/gh-glance/compare/v0.17.1...v0.18.0
|
|
1366
1398
|
[0.17.1]: https://github.com/juan294/gh-glance/compare/v0.17.0...v0.17.1
|
|
1367
1399
|
[0.17.0]: https://github.com/juan294/gh-glance/compare/v0.16.2...v0.17.0
|
package/README.md
CHANGED
|
@@ -673,14 +673,15 @@ $XDG_CONFIG_HOME/gh-glance/dashboard-cache.json
|
|
|
673
673
|
```
|
|
674
674
|
|
|
675
675
|
The cache is scoped to the repository, host, provider, and effective access
|
|
676
|
-
partition. In inferred standalone mode, the host
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
676
|
+
partition. In inferred standalone mode, the host, the current working directory
|
|
677
|
+
and the repository resolved offline from its git remote form the identity, so
|
|
678
|
+
rows from one checkout, remote or credential context cannot appear in another.
|
|
679
|
+
A directory whose remote now names a different repository starts cold.
|
|
680
|
+
Human-provider access includes one-way digests of the selected credential and
|
|
681
|
+
its authorization generation; App-provider access also binds the configured
|
|
682
|
+
repository and permission restrictions. Raw credentials are never stored. It
|
|
683
|
+
retains at most 32 recent targets and 60 rows per tab; a shortened cached tab
|
|
684
|
+
keeps its `+` marker rather than presenting the saved count as exact.
|
|
684
685
|
|
|
685
686
|
Only successfully parsed, non-blind observations replace saved rows. A failed
|
|
686
687
|
request can add a live error or `?` marker, but it cannot turn known Security
|
|
@@ -945,10 +946,12 @@ After a sustained hold, the notice names the Core or GraphQL cause, the next
|
|
|
945
946
|
automatic retry or required action, and the age of the last complete source
|
|
946
947
|
observation. A successful budget check, cache load, or heartbeat cannot make
|
|
947
948
|
old rows fresh. A validated unchanged `304` response does count as a new source
|
|
948
|
-
observation.
|
|
949
|
-
and
|
|
950
|
-
|
|
951
|
-
|
|
949
|
+
observation. A Security check answered only from capability backoff sends no
|
|
950
|
+
request and does not count. Unknown older spend remains charged until
|
|
951
|
+
owner/child quiescence and a later matching observer establish safe retirement.
|
|
952
|
+
A verified new system boot can establish that boundary for otherwise
|
|
953
|
+
unidentifiable older children; the app does not assume a restart or reset alone
|
|
954
|
+
refunded the charge.
|
|
952
955
|
|
|
953
956
|
The enforceable boundary is admission from fresh, conservatively debited
|
|
954
957
|
evidence. Another program can spend after the probe, and standalone panes in a
|
|
@@ -1165,7 +1168,7 @@ GH_GLANCE_ICONS=ascii gh-glance
|
|
|
1165
1168
|
| `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`. |
|
|
1166
1169
|
| `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. |
|
|
1167
1170
|
| `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. |
|
|
1168
|
-
| `GitHub request limit` | A shared secondary hold preserves GitHub's retry deadline. Wait for the shown time
|
|
1171
|
+
| `GitHub request limit` | A shared secondary hold preserves GitHub's retry deadline and does not block the resource until the hourly reset. Wait for the shown time. After repeated limits the transport pauses: press `r` (or `R`) once to resume it, or wait for the primary reset, after which one pane resumes it. Resuming never shortens GitHub's retry deadline, and repeated refresh cannot bypass a hold. |
|
|
1169
1172
|
| `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. |
|
|
1170
1173
|
| `Shared HTTP request or cooldown in progress` | Panes share up to three admitted requests per host, FIFO starts with at least 250 ms spacing, and one cooldown. The pool is full, a queued request is ahead, or a server-sent `Retry-After` is still running. Normal pacing, not an error. |
|
|
1171
1174
|
| `Verified identity unavailable; waiting to retry` | The account behind your credential has not been confirmed yet -- not signed in, a denied or malformed proof, or a slow credential helper. Check `gh auth status`. Retries are rate-limited, so this clears on its own rather than immediately. |
|
package/index.mjs
CHANGED
|
@@ -761,6 +761,9 @@ async function runGh(args, { signal, operation, input = null, execute = null,
|
|
|
761
761
|
let control = null;
|
|
762
762
|
const identityCoordinator = bound?.identityCoordinator ?? runtimeIdentityCoordinator;
|
|
763
763
|
const requestNow = typeof bound?.now === "function" ? bound.now : Date.now;
|
|
764
|
+
if (identityCoordinator && bound && operation === "budget-core-observer") {
|
|
765
|
+
retryTransportAfterReset(identityCoordinator, bound, requestNow);
|
|
766
|
+
}
|
|
764
767
|
const permit = identityCoordinator && !local
|
|
765
768
|
? await acquireIdentityHttpPermit(identityCoordinator, { signal, now: requestNow,
|
|
766
769
|
operationDeadline: CONTROL_OPERATIONS.includes(operation) ? undefined : bound?.receiptCapability?.deadline,
|
|
@@ -2555,13 +2558,14 @@ async function fetchGraphqlList(kind, mapRow, {
|
|
|
2555
2558
|
const incomplete = merged.incomplete || truncatedWalk;
|
|
2556
2559
|
// Serialized rows are the payload identity the unchanged-frame suppression
|
|
2557
2560
|
// and the cache both compare on, so it must cover exactly what is rendered --
|
|
2558
|
-
// including the markers that say the list is not all of it.
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
hasNextPage: merged.hasNextPage,
|
|
2561
|
+
// including the markers that say the list is not all of it. It is the shared
|
|
2562
|
+
// representation a stored snapshot rebuilds, so a collector producer that
|
|
2563
|
+
// compares against its normalized previous snapshot can see "unchanged".
|
|
2564
|
+
// `truncated` is what pollResultTransition derives from the limit below.
|
|
2565
|
+
const raw = acquisitionContentRaw(kind, {
|
|
2564
2566
|
rows: merged.rows,
|
|
2567
|
+
truncated: incomplete || merged.rows.length >= LIST_LIMIT,
|
|
2568
|
+
hasNextPage: merged.hasNextPage,
|
|
2565
2569
|
});
|
|
2566
2570
|
return {
|
|
2567
2571
|
raw: raw === previousRaw ? previousRaw : raw,
|
|
@@ -4187,6 +4191,19 @@ function classifyThrottle({ status = null, headers = null, graphqlErrors = null,
|
|
|
4187
4191
|
return { kind: "none" };
|
|
4188
4192
|
}
|
|
4189
4193
|
|
|
4194
|
+
// A "rate limit" message does not say which limit. Secondary limits are already
|
|
4195
|
+
// held by the shared transport cooldown; publishing one as a resource block until
|
|
4196
|
+
// the sampled primary reset would hold every pane for the rest of the window.
|
|
4197
|
+
// Only evidence classified as secondary is excluded. Stderr alone ("API rate
|
|
4198
|
+
// limit exceeded" with no headers) classifies as none and stays primary.
|
|
4199
|
+
function secondaryThrottleEvidence(error) {
|
|
4200
|
+
if (error?.coordinationReason === "throttle-paused") return true;
|
|
4201
|
+
const response = nativeCiThrottleEvidence.get(error) ?? error?.apiResponse ??
|
|
4202
|
+
parseGhApiResponse(error?.stdout ?? "");
|
|
4203
|
+
return classifyThrottle({ status: response.status, headers: response.headers, stderr: errText(error) })
|
|
4204
|
+
.kind === "secondary";
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4190
4207
|
function transportCooldownDeadline({ retryAfter, status, secondary, at, attempts = 0 }) {
|
|
4191
4208
|
const supplied = typeof retryAfter === "string" && /^\d+(?:\.\d+)?$/.test(retryAfter)
|
|
4192
4209
|
? at + Number(retryAfter) * 1000
|
|
@@ -4271,17 +4288,40 @@ function releaseIdentityHttpPermit(coordinator, permit, error = null, now = Date
|
|
|
4271
4288
|
return result;
|
|
4272
4289
|
}
|
|
4273
4290
|
|
|
4274
|
-
// The explicit retry a paused transport waits
|
|
4275
|
-
//
|
|
4276
|
-
//
|
|
4277
|
-
|
|
4291
|
+
// The explicit retry, or the reset-triggered recovery, a paused transport waits
|
|
4292
|
+
// for. Clears the ladder and the pause but never the deadline: a server-supplied
|
|
4293
|
+
// Retry-After is honoured exactly (ADR 0003), so the next request still waits it
|
|
4294
|
+
// out. The registry lock is the election: only the first caller resumes a given
|
|
4295
|
+
// pause. A reset recovers only a pause whose last throttle predates that reset,
|
|
4296
|
+
// so one primary window elects at most one recovery.
|
|
4297
|
+
function retryThrottledTransport(root, host, { now = Date.now, resetAt = null } = {}) {
|
|
4298
|
+
const at = now();
|
|
4278
4299
|
return withIdentityRegistry(root, (state) => {
|
|
4279
4300
|
const transport = state.hosts[host];
|
|
4280
4301
|
if (!transport?.throttle?.paused) return { ok: true, value: { resumed: false } };
|
|
4302
|
+
if (resetAt !== null &&
|
|
4303
|
+
!(Number.isFinite(resetAt) && at >= resetAt && transport.throttle.lastAt < resetAt)) {
|
|
4304
|
+
return { ok: true, value: { resumed: false } };
|
|
4305
|
+
}
|
|
4281
4306
|
transport.throttle = emptyTransportThrottle();
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4307
|
+
return { ok: true, value: { resumed: true, cooldownUntil: transport.cooldownUntil } };
|
|
4308
|
+
}, { now: at });
|
|
4309
|
+
}
|
|
4310
|
+
|
|
4311
|
+
function retryPausedTransport(coordinator, options = {}) {
|
|
4312
|
+
const host = coordinator?.current?.()?.host;
|
|
4313
|
+
if (typeof coordinator?.root !== "string" || !host) return { ok: true, value: { resumed: false } };
|
|
4314
|
+
return retryThrottledTransport(coordinator.root, host, options);
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
// Only the pane holding the core observer claim runs budget-core-observer, so the
|
|
4318
|
+
// claim elects the one request that finds out whether the limit has lifted.
|
|
4319
|
+
function retryTransportAfterReset(coordinator, scope, now = Date.now) {
|
|
4320
|
+
const at = now();
|
|
4321
|
+
const snapshot = inspectGovernor({ ...scope, identityProvider: null }, at);
|
|
4322
|
+
const resetAt = snapshot.ok ? snapshot.value.budgets.core?.resetMs : null;
|
|
4323
|
+
if (!Number.isFinite(resetAt) || at < resetAt) return { ok: true, value: { resumed: false } };
|
|
4324
|
+
return retryPausedTransport(coordinator, { now: () => at, resetAt });
|
|
4285
4325
|
}
|
|
4286
4326
|
|
|
4287
4327
|
function identityCoordinationMessage(reason) {
|
|
@@ -5363,7 +5403,7 @@ function readGovernorState(path, nowMs, { persistMigration = true } = {}) {
|
|
|
5363
5403
|
if (Buffer.byteLength(raw) > GOVERNOR_MAX_LEDGER_BYTES) return { ok: false, reason: "capacity" };
|
|
5364
5404
|
const parsed = JSON.parse(raw);
|
|
5365
5405
|
const normalized = normalizeGovernorState(parsed, nowMs);
|
|
5366
|
-
if (normalized) return { ok: true, value: normalized };
|
|
5406
|
+
if (normalized) return { ok: true, value: normalized, bytes: raw };
|
|
5367
5407
|
if (Number.isSafeInteger(parsed?.version) && parsed.version > GOVERNOR_STATE_VERSION) {
|
|
5368
5408
|
return { ok: false, reason: "version-mismatch" };
|
|
5369
5409
|
}
|
|
@@ -5785,6 +5825,13 @@ function mutateGovernor(scope, nowMs, mutate) {
|
|
|
5785
5825
|
if (!governorAccountingSafe(state, at)) return returnAfterMigration(ACCOUNTING_OVERFLOW);
|
|
5786
5826
|
const normalized = normalizeGovernorState(state, at);
|
|
5787
5827
|
if (!normalized) return returnAfterMigration({ ok: false, reason: "corrupt" });
|
|
5828
|
+
// A wait or decision read that changed nothing must not bump the revision
|
|
5829
|
+
// and replace the file: compare the would-be bytes, as the identity
|
|
5830
|
+
// registry does. Migrations and explicit rejected writes always commit.
|
|
5831
|
+
if (migration === null && value?.ok !== false && typeof loaded.bytes === "string" &&
|
|
5832
|
+
serializeGovernorState(normalized) === loaded.bytes) {
|
|
5833
|
+
return { ok: true, value: value?.value };
|
|
5834
|
+
}
|
|
5788
5835
|
const written = commitGovernorMigration(scope.path, normalized, loaded.migration);
|
|
5789
5836
|
if (!written.ok) return written;
|
|
5790
5837
|
return value?.ok === false ? { ok: false, reason: value.reason } : { ok: true, value: value?.value };
|
|
@@ -6512,9 +6559,11 @@ function startReservation(scope, reservationId, nowMs) {
|
|
|
6512
6559
|
return { ok: false, reason: "stale", write: Boolean(reservation) };
|
|
6513
6560
|
}
|
|
6514
6561
|
if (reservation.notBefore > at) return { value: { status: "waiting", notBefore: reservation.notBefore } };
|
|
6515
|
-
//
|
|
6516
|
-
//
|
|
6562
|
+
// A live observer claim defers only work that charges its resource. The
|
|
6563
|
+
// other resource's observer says nothing about this counter, and the shared
|
|
6564
|
+
// HTTP pool, start gap and host cooldown still bound the two together.
|
|
6517
6565
|
const liveClaim = RATE_RESOURCES
|
|
6566
|
+
.filter((resource) => reservation.costs[resource] > 0)
|
|
6518
6567
|
.map((resource) => state.probeClaims[resource])
|
|
6519
6568
|
.filter((claim) => claim && claim.leaseUntil > at)
|
|
6520
6569
|
.sort((left, right) => right.leaseUntil - left.leaseUntil)[0];
|
|
@@ -7240,9 +7289,15 @@ function mergeAlertRows(groups) {
|
|
|
7240
7289
|
// mid-session is picked up within the hour.
|
|
7241
7290
|
const alertBackoff = new Map();
|
|
7242
7291
|
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7292
|
+
// A capability verdict is a statement about one repository on one host, so the
|
|
7293
|
+
// bound host and repository are part of the key: one collector credential can
|
|
7294
|
+
// serve several repositories, and one repository's 403 must not replay for
|
|
7295
|
+
// (or be cleared by) another.
|
|
7296
|
+
function backoffStorageKey(key, accessKey = null) {
|
|
7297
|
+
const access = accessKey ?? requestIdentityStorage.getStore()?.accessKey ??
|
|
7298
|
+
runtimeIdentityCoordinator?.current()?.accessKey;
|
|
7299
|
+
const scoped = `${effectiveRuntimeHost() ?? ""}\0${effectiveRuntimeRepository()?.toLowerCase() ?? ""}\0${key}`;
|
|
7300
|
+
return access ? `${access}\0${scoped}` : scoped;
|
|
7246
7301
|
}
|
|
7247
7302
|
|
|
7248
7303
|
function backoffActive(key, now) {
|
|
@@ -7428,6 +7483,7 @@ async function fetchAlertSource(source, signal, now, {
|
|
|
7428
7483
|
rest200,
|
|
7429
7484
|
rest304,
|
|
7430
7485
|
verdict,
|
|
7486
|
+
secondaryThrottle: verdict === "rate-limited" && secondaryThrottleEvidence(err),
|
|
7431
7487
|
allNotModified: false,
|
|
7432
7488
|
stagedEntities: new Map(),
|
|
7433
7489
|
observations,
|
|
@@ -7499,7 +7555,15 @@ async function fetchSecurity(signal, {
|
|
|
7499
7555
|
requestMetrics,
|
|
7500
7556
|
graphqlSpent: 0,
|
|
7501
7557
|
measuredSuccess: parts.every((part) => part.verdict === "ok"),
|
|
7502
|
-
|
|
7558
|
+
// Whether this batch observed the source at all: some source made a real
|
|
7559
|
+
// request and GitHub answered it (200/304, or a current capability refusal).
|
|
7560
|
+
// A batch in which every source replayed its backoff made no request, so it
|
|
7561
|
+
// must not advance freshness or complete webhook work (plan D6, ADR 0005).
|
|
7562
|
+
// With no sources (an App granted no Security permission) there is nothing
|
|
7563
|
+
// to observe, and the configuration answer keeps its existing behavior.
|
|
7564
|
+
sourceObserved: parts.length === 0 || parts.some((part) => part.httpRequests > 0 &&
|
|
7565
|
+
(part.verdict === "ok" || part.verdict === "unavailable")),
|
|
7566
|
+
rateLimited: parts.some((part) => part.verdict === "rate-limited" && !part.secondaryThrottle),
|
|
7503
7567
|
stagedEntities,
|
|
7504
7568
|
observations: parts.flatMap((part) => part.observations ?? []),
|
|
7505
7569
|
capabilities,
|
|
@@ -7774,7 +7838,16 @@ function createCollectorSourceAgeTracker({ now = Date.now, monotonicNow = () =>
|
|
|
7774
7838
|
lowerBound += Math.max(0, now() - checkpoint.clientCheckpointAt);
|
|
7775
7839
|
}
|
|
7776
7840
|
let measuredAt = monotonicNow();
|
|
7777
|
-
|
|
7841
|
+
// The monotonic clock does not advance across a suspend, so positive wall
|
|
7842
|
+
// elapsed time also bounds the age. A forward wall step can only overstate
|
|
7843
|
+
// staleness; the high-water mark keeps a backward step from shrinking it.
|
|
7844
|
+
let wallMeasuredAt = now();
|
|
7845
|
+
let reported = lowerBound;
|
|
7846
|
+
const current = () => {
|
|
7847
|
+
reported = Math.max(reported, lowerBound +
|
|
7848
|
+
Math.max(0, monotonicNow() - measuredAt, now() - wallMeasuredAt));
|
|
7849
|
+
return reported;
|
|
7850
|
+
};
|
|
7778
7851
|
return {
|
|
7779
7852
|
observe({ lastSuccessAt, serverNow }) {
|
|
7780
7853
|
if (!Number.isFinite(lastSuccessAt) || !Number.isFinite(serverNow)) return current();
|
|
@@ -7784,6 +7857,8 @@ function createCollectorSourceAgeTracker({ now = Date.now, monotonicNow = () =>
|
|
|
7784
7857
|
else lowerBound = Math.max(elapsed, candidate);
|
|
7785
7858
|
sourceAt = sourceAt === null ? lastSuccessAt : Math.max(sourceAt, lastSuccessAt);
|
|
7786
7859
|
measuredAt = monotonicNow();
|
|
7860
|
+
wallMeasuredAt = now();
|
|
7861
|
+
reported = lowerBound;
|
|
7787
7862
|
return lowerBound;
|
|
7788
7863
|
},
|
|
7789
7864
|
current,
|
|
@@ -10045,7 +10120,11 @@ async function createCollectorService({ config, pathOptions = {}, runtime, platf
|
|
|
10045
10120
|
}
|
|
10046
10121
|
|
|
10047
10122
|
function collectorPublicationFromResult(resource, result, previous, demand, completedAt, state = {}) {
|
|
10048
|
-
|
|
10123
|
+
// A Security batch that only replayed capability backoff made no request. It
|
|
10124
|
+
// republishes the previous observation unchanged, with its success time, so
|
|
10125
|
+
// neither freshness nor webhook completion can advance on it.
|
|
10126
|
+
const observed = result.sourceObserved !== false;
|
|
10127
|
+
const transition = !observed ? { kind: "unchanged", completedAt } : pollResultTransition({
|
|
10049
10128
|
key: resource,
|
|
10050
10129
|
previousRaw: previous?.raw ?? null,
|
|
10051
10130
|
raw: result.raw,
|
|
@@ -10074,7 +10153,8 @@ function collectorPublicationFromResult(resource, result, previous, demand, comp
|
|
|
10074
10153
|
},
|
|
10075
10154
|
raw: transition.kind === "changed" ? transition.nextRaw : previous.raw,
|
|
10076
10155
|
entities,
|
|
10077
|
-
lastSuccessAt: completedAt,
|
|
10156
|
+
lastSuccessAt: observed ? completedAt : previous.lastSuccessAt,
|
|
10157
|
+
sourceObserved: observed,
|
|
10078
10158
|
lastChangedAt,
|
|
10079
10159
|
nextDueAt: completedAt + pollPolicyInterval({
|
|
10080
10160
|
tab: resource,
|
|
@@ -10181,11 +10261,13 @@ function createCollectorAcquisitionRuntime({
|
|
|
10181
10261
|
providerContexts.set(target.provider, context);
|
|
10182
10262
|
}
|
|
10183
10263
|
let identity;
|
|
10264
|
+
let credentialEnvironment = null;
|
|
10265
|
+
let capabilities = null;
|
|
10184
10266
|
if (context.definition.type === "github-app") {
|
|
10185
10267
|
const resolved = await context.coordinator.resolve({ signal });
|
|
10186
10268
|
identity = resolved.identity;
|
|
10187
|
-
|
|
10188
|
-
|
|
10269
|
+
credentialEnvironment = resolved.environment;
|
|
10270
|
+
capabilities = resolved.capabilities;
|
|
10189
10271
|
if (context.identity?.accessKey !== identity.accessKey) {
|
|
10190
10272
|
const persisted = persistGitHubAppIdentity(context.coordinator.root, identity, now());
|
|
10191
10273
|
if (!persisted.ok) throw new Error("collector provider unavailable");
|
|
@@ -10194,13 +10276,10 @@ function createCollectorAcquisitionRuntime({
|
|
|
10194
10276
|
const resolved = await context.coordinator.refresh();
|
|
10195
10277
|
if (!resolved.ok) throw new Error("collector provider unavailable");
|
|
10196
10278
|
identity = resolved.value;
|
|
10197
|
-
context.credentialEnvironment = null;
|
|
10198
|
-
context.capabilities = null;
|
|
10199
10279
|
context.readBudgets = readBudgets;
|
|
10200
10280
|
}
|
|
10201
10281
|
if (identity.host !== target.host) throw new Error("collector provider unavailable");
|
|
10202
|
-
|
|
10203
|
-
context.scope = {
|
|
10282
|
+
const scope = Object.freeze({
|
|
10204
10283
|
...createQuotaScope(identity, {
|
|
10205
10284
|
root: context.coordinator.root,
|
|
10206
10285
|
now,
|
|
@@ -10210,9 +10289,28 @@ function createCollectorAcquisitionRuntime({
|
|
|
10210
10289
|
repository: target.repo,
|
|
10211
10290
|
providerType: context.definition.type,
|
|
10212
10291
|
...(executeGh ? { executeGh } : {}),
|
|
10213
|
-
...(
|
|
10214
|
-
};
|
|
10215
|
-
|
|
10292
|
+
...(credentialEnvironment ? { credentialEnvironment } : {}),
|
|
10293
|
+
});
|
|
10294
|
+
// The provider context keeps only provider-wide state (coordinator,
|
|
10295
|
+
// lease, budget reader, latest access identity). Its scope is used to
|
|
10296
|
+
// release the provider lease on close, never to bind a request.
|
|
10297
|
+
context.identity = identity;
|
|
10298
|
+
context.credentialEnvironment = credentialEnvironment;
|
|
10299
|
+
context.capabilities = capabilities;
|
|
10300
|
+
context.scope = { ...scope };
|
|
10301
|
+
// Each target operation gets its own immutable binding, captured once, so
|
|
10302
|
+
// another target's resolution cannot redirect it after an await.
|
|
10303
|
+
return Object.freeze({
|
|
10304
|
+
coordinator: context.coordinator,
|
|
10305
|
+
definition: context.definition,
|
|
10306
|
+
leaseId: context.leaseId,
|
|
10307
|
+
readBudgets: context.readBudgets,
|
|
10308
|
+
onObserverPublished: context.onObserverPublished,
|
|
10309
|
+
identity,
|
|
10310
|
+
scope,
|
|
10311
|
+
credentialEnvironment,
|
|
10312
|
+
capabilities,
|
|
10313
|
+
});
|
|
10216
10314
|
}
|
|
10217
10315
|
|
|
10218
10316
|
async function defaultProduce({ target, resource, demand, snapshot, claim, markStarted, provider, signal, force }) {
|
|
@@ -10444,9 +10542,17 @@ function createCollectorAcquisitionRuntime({
|
|
|
10444
10542
|
item.invalidations.clear();
|
|
10445
10543
|
}
|
|
10446
10544
|
|
|
10545
|
+
// Identifies one delivered generation for one engine subscription and
|
|
10546
|
+
// binding. A rebind or a new subscription (a new connection) changes it.
|
|
10547
|
+
function collectorDeliveryKey(subscriptionId, bindingRevision, snapshot) {
|
|
10548
|
+
return Number.isSafeInteger(snapshot?.generation)
|
|
10549
|
+
? `${subscriptionId}\0${bindingRevision}\0${snapshot.generation}` : null;
|
|
10550
|
+
}
|
|
10551
|
+
|
|
10447
10552
|
function deliverCollectorSnapshot(item, snapshot, bindingRevision, { publication = false } = {}) {
|
|
10448
10553
|
if (bindingRevision !== item.bindingRevision) return false;
|
|
10449
10554
|
item.nextDueAt = snapshot.nextDueAt;
|
|
10555
|
+
item.lastDeliveryKey = collectorDeliveryKey(item.id, bindingRevision, snapshot);
|
|
10450
10556
|
if (Number.isSafeInteger(snapshot.generation)) item.lastDeliveredGeneration = snapshot.generation;
|
|
10451
10557
|
if (Number.isFinite(snapshot.lastSuccessAt)) item.lastDeliveredSuccessAt = snapshot.lastSuccessAt;
|
|
10452
10558
|
if (publication && item.requestedAt !== null && snapshot.lastSuccessAt >= item.requestedAt) {
|
|
@@ -10454,13 +10560,21 @@ function createCollectorAcquisitionRuntime({
|
|
|
10454
10560
|
}
|
|
10455
10561
|
item.onSnapshot(snapshot);
|
|
10456
10562
|
if (!publication || !Number.isSafeInteger(snapshot.generation)) return true;
|
|
10563
|
+
let awaitingObservation = false;
|
|
10457
10564
|
for (const waiter of item.invalidations) {
|
|
10458
10565
|
if (bindingRevision !== waiter.bindingRevision || waiter.requiredGeneration === null ||
|
|
10459
10566
|
snapshot.generation < waiter.requiredGeneration) continue;
|
|
10567
|
+
// A newer generation that republished an older observation (a Security
|
|
10568
|
+
// capability-backoff replay) is not API validation of the webhook. Leave
|
|
10569
|
+
// the waiter for the next real observation on the ordinary cadence.
|
|
10570
|
+
if (Number.isFinite(waiter.invalidatedAt) && !(snapshot.lastSuccessAt >= waiter.invalidatedAt)) {
|
|
10571
|
+
awaitingObservation = true;
|
|
10572
|
+
continue;
|
|
10573
|
+
}
|
|
10460
10574
|
item.invalidations.delete(waiter);
|
|
10461
10575
|
waiter.resolve(snapshot);
|
|
10462
10576
|
}
|
|
10463
|
-
if (item.invalidations.size > 0 && !closed && !item.closed && demandEnabled(item.demand)) {
|
|
10577
|
+
if (item.invalidations.size > 0 && !awaitingObservation && !closed && !item.closed && demandEnabled(item.demand)) {
|
|
10464
10578
|
queueMicrotask(() => startPoll(item, { manual: true }));
|
|
10465
10579
|
}
|
|
10466
10580
|
return true;
|
|
@@ -10531,11 +10645,15 @@ function createCollectorAcquisitionRuntime({
|
|
|
10531
10645
|
retryIn = 1;
|
|
10532
10646
|
return;
|
|
10533
10647
|
}
|
|
10534
|
-
|
|
10648
|
+
const provider = refreshedProvider;
|
|
10649
|
+
item.provider = provider;
|
|
10535
10650
|
const ownership = await engine.refresh(subscriptionId, { force: manual });
|
|
10536
10651
|
if (!ownership.ok) { item.onHold?.("disconnected"); retryIn = 1_000; return; }
|
|
10537
10652
|
if (ownership.value.role !== "producer") {
|
|
10538
|
-
|
|
10653
|
+
// A follower wake re-reads the shared record; re-send only a
|
|
10654
|
+
// generation this subscription has not already delivered.
|
|
10655
|
+
if (ownership.value.snapshot && item.lastDeliveryKey !==
|
|
10656
|
+
collectorDeliveryKey(subscriptionId, bindingRevision, ownership.value.snapshot)) {
|
|
10539
10657
|
deliverCollectorSnapshot(item, ownership.value.snapshot, bindingRevision);
|
|
10540
10658
|
}
|
|
10541
10659
|
if (ownership.value.reason !== "fresh") {
|
|
@@ -10566,7 +10684,7 @@ function createCollectorAcquisitionRuntime({
|
|
|
10566
10684
|
active: ownership.value.demand?.active || item.requestedAt !== null },
|
|
10567
10685
|
snapshot: ownership.value.snapshot,
|
|
10568
10686
|
claim: { ...claim, state: item },
|
|
10569
|
-
provider
|
|
10687
|
+
provider,
|
|
10570
10688
|
signal: item.controller.signal,
|
|
10571
10689
|
force,
|
|
10572
10690
|
markStarted: async (receipt) => {
|
|
@@ -10576,11 +10694,11 @@ function createCollectorAcquisitionRuntime({
|
|
|
10576
10694
|
return started;
|
|
10577
10695
|
},
|
|
10578
10696
|
}));
|
|
10579
|
-
const produced =
|
|
10580
|
-
? await requestIdentityStorage.run(
|
|
10697
|
+
const produced = provider?.scope
|
|
10698
|
+
? await requestIdentityStorage.run(provider.scope, produceBound)
|
|
10581
10699
|
: await produceBound();
|
|
10582
|
-
if (
|
|
10583
|
-
|
|
10700
|
+
if (provider?.definition?.type === "github-app" &&
|
|
10701
|
+
provider.coordinator.current()?.accessKey !== claim.accessKey) {
|
|
10584
10702
|
engine.refresh(subscriptionId, { claim, failure: {
|
|
10585
10703
|
hold: "disconnected",
|
|
10586
10704
|
requestMetrics: produced.requestMetrics ?? {},
|
|
@@ -10610,13 +10728,16 @@ function createCollectorAcquisitionRuntime({
|
|
|
10610
10728
|
const published = await engine.refresh(subscriptionId, { claim, publish: produced });
|
|
10611
10729
|
if (!published.ok) { item.onHold?.("disconnected"); retryIn = 1_000; }
|
|
10612
10730
|
else {
|
|
10613
|
-
|
|
10731
|
+
provider?.coordinator?.markAuthorized?.();
|
|
10732
|
+
// A replay answers the request as far as backoff allows without
|
|
10733
|
+
// advancing the success time that would otherwise clear it.
|
|
10734
|
+
if (produced.sourceObserved === false) item.requestedAt = null;
|
|
10614
10735
|
retryIn = Math.max(1, published.value.snapshot.nextDueAt - now());
|
|
10615
10736
|
}
|
|
10616
10737
|
} catch (error) {
|
|
10617
|
-
if (
|
|
10738
|
+
if (provider?.definition?.type === "github-app" && error?.apiResponse?.status === 401 &&
|
|
10618
10739
|
!error.appCredentialInvalidated) {
|
|
10619
|
-
|
|
10740
|
+
provider.coordinator.invalidate("unauthorized");
|
|
10620
10741
|
}
|
|
10621
10742
|
const hold = error?.notStarted ? "shared-wait" : acquisitionFailureHold(error);
|
|
10622
10743
|
if (claim && error?.notStarted) engine.refresh(subscriptionId, { cancel: claim });
|
|
@@ -10625,7 +10746,7 @@ function createCollectorAcquisitionRuntime({
|
|
|
10625
10746
|
requestMetrics: error?.requestMetrics ?? {},
|
|
10626
10747
|
} });
|
|
10627
10748
|
else engine.setHold(subscriptionId, hold, { resource: item.resource, accessKey: item.identity.accessKey });
|
|
10628
|
-
item.onHold?.(
|
|
10749
|
+
item.onHold?.(provider?.definition?.type === "github-app" &&
|
|
10629
10750
|
(error?.apiResponse?.status === 401 || error?.message === "GitHub App token unavailable" ||
|
|
10630
10751
|
classify(error) === "auth-problem")
|
|
10631
10752
|
? "app-auth" : hold);
|
|
@@ -10713,7 +10834,7 @@ function createCollectorAcquisitionRuntime({
|
|
|
10713
10834
|
const item = { target, resource, demand: normalizedDemand, onSnapshot, onHold, id: null, identity: null,
|
|
10714
10835
|
provider: null, polling: false, pollPhase: null, pollPromise: null, controller: null,
|
|
10715
10836
|
initializePromise: null, closed: false, timer: null, queuedRefresh: null, requestedAt: null,
|
|
10716
|
-
bindingRevision: 0, invalidations: new Set(), lastDeliveredGeneration: null,
|
|
10837
|
+
bindingRevision: 0, invalidations: new Set(), lastDeliveredGeneration: null, lastDeliveryKey: null,
|
|
10717
10838
|
lastDeliveredSuccessAt: null, promotionGeneration: null, promotionAt: null };
|
|
10718
10839
|
items.add(item);
|
|
10719
10840
|
void startInitialize(item);
|
|
@@ -10748,7 +10869,10 @@ function createCollectorAcquisitionRuntime({
|
|
|
10748
10869
|
}
|
|
10749
10870
|
return updated;
|
|
10750
10871
|
},
|
|
10751
|
-
refresh(force = false) {
|
|
10872
|
+
refresh(force = false) {
|
|
10873
|
+
retryPausedTransport(item.provider?.scope?.identityCoordinator);
|
|
10874
|
+
return startPoll(item, { manual: true, force: force === true });
|
|
10875
|
+
},
|
|
10752
10876
|
inspect() {
|
|
10753
10877
|
if (!item.id) return { source: "local collector", status: "initializing", resource: item.resource };
|
|
10754
10878
|
const inspected = engine.inspect(item.id);
|
|
@@ -10825,6 +10949,7 @@ function createCollectorAcquisitionRuntime({
|
|
|
10825
10949
|
}
|
|
10826
10950
|
item.invalidations.add({
|
|
10827
10951
|
requiredGeneration,
|
|
10952
|
+
invalidatedAt: now(),
|
|
10828
10953
|
bindingRevision: item.bindingRevision,
|
|
10829
10954
|
accessRemoved: normalized.accessRemoved,
|
|
10830
10955
|
accessOnly: normalized.accessRemoved && !demandEnabled(item.demand),
|
|
@@ -10863,7 +10988,9 @@ function createCollectorAcquisitionRuntime({
|
|
|
10863
10988
|
for (const entry of targetInitializations.values()) entry.controller.abort();
|
|
10864
10989
|
await Promise.allSettled([...items].flatMap((item) =>
|
|
10865
10990
|
[item.initializePromise, item.pollPromise].filter(Boolean)));
|
|
10866
|
-
|
|
10991
|
+
// Per-target operation contexts are frozen; drop them so a retained
|
|
10992
|
+
// handle does not keep the credential environment reachable.
|
|
10993
|
+
for (const item of items) { if (item.id) engine.unsubscribe(item.id); item.provider = null; }
|
|
10867
10994
|
items.clear();
|
|
10868
10995
|
engine.close();
|
|
10869
10996
|
for (const context of providerContexts.values()) {
|
|
@@ -11001,7 +11128,13 @@ function createCollectorProtocolClient({ createTransport, onReady = () => {},
|
|
|
11001
11128
|
resourceForId: (id) => subscriptions.get(id)?.resource ?? null,
|
|
11002
11129
|
onSnapshot(snapshot, frame) {
|
|
11003
11130
|
const item = subscriptions.get(frame.id);
|
|
11004
|
-
if (!item || frame.serverEpoch !== serverEpoch || snapshot.generation
|
|
11131
|
+
if (!item || frame.serverEpoch !== serverEpoch || snapshot.generation < item.generation) return;
|
|
11132
|
+
// A same-epoch reconnect keeps the generation fence, and the server
|
|
11133
|
+
// replays its cached generation for the new subscription. The first
|
|
11134
|
+
// equal-generation snapshot on a connection acknowledges that
|
|
11135
|
+
// connection; later repeats on it are not new deliveries.
|
|
11136
|
+
if (snapshot.generation === item.generation && item.connectionAcknowledged) return;
|
|
11137
|
+
item.connectionAcknowledged = true;
|
|
11005
11138
|
item.generation = snapshot.generation;
|
|
11006
11139
|
item.controlGeneration = Math.max(item.controlGeneration, snapshot.generation);
|
|
11007
11140
|
const sourceAgeMs = item.sourceAge.observe({
|
|
@@ -11036,6 +11169,7 @@ function createCollectorProtocolClient({ createTransport, onReady = () => {},
|
|
|
11036
11169
|
item.generation = -1;
|
|
11037
11170
|
item.controlGeneration = -1;
|
|
11038
11171
|
}
|
|
11172
|
+
item.connectionAcknowledged = false;
|
|
11039
11173
|
write({ type: "subscribe", id: item.id, host: item.host, repo: item.repo,
|
|
11040
11174
|
resource: item.resource, demand: item.demand });
|
|
11041
11175
|
flushRefresh(item);
|
|
@@ -11129,7 +11263,7 @@ function createCollectorProtocolClient({ createTransport, onReady = () => {},
|
|
|
11129
11263
|
return { ok: false, reason: "invalid" };
|
|
11130
11264
|
}
|
|
11131
11265
|
const item = { ...subscription,
|
|
11132
|
-
onSnapshot, onHold, onError, onDiagnostic, generation: -1, controlGeneration: -1, pendingRefresh: null,
|
|
11266
|
+
onSnapshot, onHold, onError, onDiagnostic, generation: -1, controlGeneration: -1, connectionAcknowledged: false, pendingRefresh: null,
|
|
11133
11267
|
sourceAge: createCollectorSourceAgeTracker({ now, monotonicNow, checkpoint: sourceCheckpoint }) };
|
|
11134
11268
|
subscriptions.set(id, item);
|
|
11135
11269
|
if (welcomed) write({ type: "subscribe", id: item.id, host: item.host, repo: item.repo,
|
|
@@ -13833,8 +13967,10 @@ function authCacheIdentity({
|
|
|
13833
13967
|
}
|
|
13834
13968
|
|
|
13835
13969
|
// Explicit targets are stable across working directories. In inferred mode the
|
|
13836
|
-
// directory
|
|
13837
|
-
//
|
|
13970
|
+
// directory and the remote slug, both resolved offline without the GitHub call
|
|
13971
|
+
// that cannot succeed when this cache is needed, form the identity: a changed
|
|
13972
|
+
// remote or a reused checkout path must not restore another repository's rows.
|
|
13973
|
+
// A null slug (no remote, or remotes that disagree) is its own key.
|
|
13838
13974
|
// JSON encoding avoids delimiter ambiguity in host, repo, and path strings.
|
|
13839
13975
|
function dashboardCacheTarget({
|
|
13840
13976
|
repo = null,
|
|
@@ -13842,11 +13978,13 @@ function dashboardCacheTarget({
|
|
|
13842
13978
|
host = null,
|
|
13843
13979
|
cwd = process.cwd(),
|
|
13844
13980
|
account = authCacheIdentity(),
|
|
13981
|
+
remoteUrls = [],
|
|
13845
13982
|
} = {}) {
|
|
13846
13983
|
const target = repo || ghRepo;
|
|
13847
13984
|
return target
|
|
13848
13985
|
? JSON.stringify({ kind: "repo", host: String(host ?? ""), repo: String(target), account })
|
|
13849
|
-
: JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd),
|
|
13986
|
+
: JSON.stringify({ kind: "cwd", host: String(host ?? ""), cwd: String(cwd),
|
|
13987
|
+
remote: resolveEffectiveRepository({ remoteUrls }), account });
|
|
13850
13988
|
}
|
|
13851
13989
|
|
|
13852
13990
|
function sshCollectorCacheIdentity(alias) {
|
|
@@ -13932,6 +14070,33 @@ function normalizeCachedItem(tabKey, item) {
|
|
|
13932
14070
|
return null;
|
|
13933
14071
|
}
|
|
13934
14072
|
|
|
14073
|
+
// The one "unchanged" identity for an acquired result. Fetchers emit it, the
|
|
14074
|
+
// shared store rebuilds it from a validated snapshot, and adoption carries it,
|
|
14075
|
+
// so a producer comparing against a stored snapshot sees the same string it
|
|
14076
|
+
// would have produced itself. It covers what the tab renders: the rows through
|
|
14077
|
+
// the stored-snapshot validator, the markers that say the list is incomplete
|
|
14078
|
+
// and, for Security, the coverage notes. Timestamps are excluded on purpose.
|
|
14079
|
+
function acquisitionContentRaw(resource, { rows, truncated = false, hasNextPage = false, notes = [], blind = false }) {
|
|
14080
|
+
return JSON.stringify({
|
|
14081
|
+
v: GRAPHQL_QUERY_VERSION,
|
|
14082
|
+
resource,
|
|
14083
|
+
truncated: truncated === true,
|
|
14084
|
+
hasNextPage: hasNextPage === true,
|
|
14085
|
+
notes: resource === "security" ? notes : [],
|
|
14086
|
+
blind: resource === "security" && blind === true,
|
|
14087
|
+
rows: rows.map((row) => normalizeCachedItem(resource, row)),
|
|
14088
|
+
});
|
|
14089
|
+
}
|
|
14090
|
+
|
|
14091
|
+
// A pane's unchanged sentinel must describe the rows it displays. Adopting a
|
|
14092
|
+
// peer's different content without moving it let an A, B, A sequence compare
|
|
14093
|
+
// the third result against A and republish B as "unchanged". A snapshot that
|
|
14094
|
+
// does not carry the shared representation clears it, which costs one parse.
|
|
14095
|
+
function adoptedAcquisitionRaw(currentRaw, snapshot, sameContent) {
|
|
14096
|
+
if (sameContent) return currentRaw;
|
|
14097
|
+
return typeof snapshot?.raw === "string" ? snapshot.raw : null;
|
|
14098
|
+
}
|
|
14099
|
+
|
|
13935
14100
|
function normalizeDashboardCacheEntry(entry) {
|
|
13936
14101
|
if (!isRecord(entry) || !isRecord(entry.tabs)) return null;
|
|
13937
14102
|
const tabs = {};
|
|
@@ -14558,6 +14723,7 @@ function normalizeAcquisitionSnapshot(raw, query, generation, producerEpoch) {
|
|
|
14558
14723
|
const content = JSON.stringify(rows);
|
|
14559
14724
|
const contentDigest = createHash("sha256").update(content).digest("hex");
|
|
14560
14725
|
if (raw.raw === undefined && raw.contentDigest !== contentDigest) return null;
|
|
14726
|
+
const securityNotes = raw.securityNotes.filter((note) => typeof note === "string").map(safe);
|
|
14561
14727
|
return {
|
|
14562
14728
|
schema: 1,
|
|
14563
14729
|
producerEpoch,
|
|
@@ -14565,7 +14731,8 @@ function normalizeAcquisitionSnapshot(raw, query, generation, producerEpoch) {
|
|
|
14565
14731
|
generation,
|
|
14566
14732
|
rows,
|
|
14567
14733
|
pageInfo,
|
|
14568
|
-
raw:
|
|
14734
|
+
raw: acquisitionContentRaw(query.resource, { rows, truncated: raw.meta.truncated,
|
|
14735
|
+
hasNextPage: pageInfo?.hasNextPage, notes: securityNotes, blind: raw.securityBlind }),
|
|
14569
14736
|
contentDigest,
|
|
14570
14737
|
entities,
|
|
14571
14738
|
lastSuccessAt: raw.lastSuccessAt,
|
|
@@ -14574,7 +14741,7 @@ function normalizeAcquisitionSnapshot(raw, query, generation, producerEpoch) {
|
|
|
14574
14741
|
hold: null,
|
|
14575
14742
|
capabilities,
|
|
14576
14743
|
meta: { at: raw.meta.at, truncated: raw.meta.truncated },
|
|
14577
|
-
securityNotes
|
|
14744
|
+
securityNotes,
|
|
14578
14745
|
securityBlind: raw.securityBlind,
|
|
14579
14746
|
};
|
|
14580
14747
|
}
|
|
@@ -18525,6 +18692,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
18525
18692
|
host: runtimeIdentityCoordinator?.current()?.host ?? effectiveRuntimeHost(),
|
|
18526
18693
|
cwd: process.cwd(),
|
|
18527
18694
|
account: sshCacheIdentity ?? runtimeIdentityCoordinator?.current()?.accessKey ?? "unverified",
|
|
18695
|
+
remoteUrls: runtimeRemoteUrls,
|
|
18528
18696
|
}),
|
|
18529
18697
|
);
|
|
18530
18698
|
const [loadedCache] = useState(() => loadDashboardCache(cachePath, cacheTarget));
|
|
@@ -19522,6 +19690,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19522
19690
|
} else if (input==='d' && TABS[activeIndexRef.current].key==='actions') {
|
|
19523
19691
|
void ciEvidenceRef.current?.('open');
|
|
19524
19692
|
} else if (featureRef.current?.mode==='ci' && input==='r') {
|
|
19693
|
+
retryPausedTransport(runtimeIdentityCoordinator);
|
|
19525
19694
|
ciControllerRef.current?.abort();attentionControllerRef.current?.abort();jevOperationRef.current=null;runtimeJevSession?.clear();setFeature(null);void fetchTabRef.current?.('actions',manualRefreshRequest(refreshIntent(input)));
|
|
19526
19695
|
} else if (featureRef.current?.mode==='focus' && input==='x') {
|
|
19527
19696
|
jevOperationRef.current=null;const current=featureRef.current;
|
|
@@ -19570,6 +19739,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
19570
19739
|
// validators: `r` on a quiet repository answers 304 and costs nothing,
|
|
19571
19740
|
// which is exactly when the key is most likely to be pressed.
|
|
19572
19741
|
const request = manualRefreshRequest(refreshIntent(input));
|
|
19742
|
+
// The explicit retry a paused transport waits for. A collector client has
|
|
19743
|
+
// no local coordinator; its collector resumes on the refresh it receives.
|
|
19744
|
+
if (request) retryPausedTransport(runtimeIdentityCoordinator);
|
|
19573
19745
|
if (request) fetchTabRef.current?.(TABS[activeIndexRef.current].key, request);
|
|
19574
19746
|
} else if (input.length === 1 && input >= "1" && input <= String(TABS.length)) {
|
|
19575
19747
|
setActiveIndex(Number(input) - 1);
|
|
@@ -20054,6 +20226,17 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
20054
20226
|
setSecurityBlind(true);
|
|
20055
20227
|
return;
|
|
20056
20228
|
}
|
|
20229
|
+
// Every Security source replayed its capability backoff: no request
|
|
20230
|
+
// was made, so nothing was observed. Keep the displayed rows, the
|
|
20231
|
+
// freshness clock and the peers' snapshot as they are (plan D6), and
|
|
20232
|
+
// let the ordinary cadence retry. The request itself is answered.
|
|
20233
|
+
if (result?.sourceObserved === false) {
|
|
20234
|
+
await cancelAcquisition(acquisition);
|
|
20235
|
+
requestedTabs.delete(key);
|
|
20236
|
+
rescheduleTab(key, completedAt);
|
|
20237
|
+
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
20238
|
+
return;
|
|
20239
|
+
}
|
|
20057
20240
|
// The shared nonce/generation is the publication fence. A superseded
|
|
20058
20241
|
// owner must not update any local freshness, rows, cadence, or cache.
|
|
20059
20242
|
const publicationView = stagedAcquisitionPublicationView({
|
|
@@ -20152,7 +20335,9 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
20152
20335
|
const recovery = requestFailureRecovery(err, verdict);
|
|
20153
20336
|
recordRuntimeRecovery(key, recovery.reason, { origin: recovery.origin, retryAt });
|
|
20154
20337
|
}
|
|
20155
|
-
|
|
20338
|
+
// A secondary limit is held by the shared transport cooldown, not by a
|
|
20339
|
+
// resource block until the primary reset.
|
|
20340
|
+
if (verdict === "rate-limited" && !secondaryThrottleEvidence(err)) {
|
|
20156
20341
|
const snapshot = inspectGovernor(scope, Date.now());
|
|
20157
20342
|
const costs = tabRequestCost(key);
|
|
20158
20343
|
const blocks = [];
|
|
@@ -20304,6 +20489,10 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
20304
20489
|
entityRef.current.set(entity.key, { etag: entity.etag, body: entity.body });
|
|
20305
20490
|
}
|
|
20306
20491
|
const sameRows = JSON.stringify(dataRef.current[key]) === JSON.stringify(snapshot.rows);
|
|
20492
|
+
const sameContent = sameRows && (key !== "security" ||
|
|
20493
|
+
JSON.stringify(securityNotesRef.current) === JSON.stringify(snapshot.securityNotes ?? []) &&
|
|
20494
|
+
securityBlindRef.current === (snapshot.securityBlind === true));
|
|
20495
|
+
rawRef.current[key] = adoptedAcquisitionRaw(rawRef.current[key], snapshot, sameContent);
|
|
20307
20496
|
if (!sameRows) setData((current) => ({ ...current, [key]: snapshot.rows }));
|
|
20308
20497
|
setMeta((current) => current[key]?.at === snapshotMeta.at &&
|
|
20309
20498
|
current[key]?.truncated === snapshotMeta.truncated
|
|
@@ -20312,7 +20501,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
20312
20501
|
const accessKey = identity()?.accessKey;
|
|
20313
20502
|
if (accessKey) {
|
|
20314
20503
|
for (const [sourceKey, capability] of Object.entries(snapshot.capabilities ?? {})) {
|
|
20315
|
-
alertBackoff.set(
|
|
20504
|
+
alertBackoff.set(backoffStorageKey(sourceKey, accessKey), {
|
|
20316
20505
|
...capability,
|
|
20317
20506
|
until: performance.now() + Math.max(0, capability.until - Date.now()),
|
|
20318
20507
|
});
|
|
@@ -20863,7 +21052,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
20863
21052
|
recoveryRef.current = savedRecovery.status === "healthy"
|
|
20864
21053
|
? { ...savedRecovery.current } : { core: null, graphql: null };
|
|
20865
21054
|
setRecoveryCauses(recoveryRef.current);
|
|
20866
|
-
const nextTarget = dashboardCacheTarget({ repo: runtime.repo, ghRepo: process.env.GH_REPO, host: current.host, account: current.accessKey });
|
|
21055
|
+
const nextTarget = dashboardCacheTarget({ repo: runtime.repo, ghRepo: process.env.GH_REPO, host: current.host, account: current.accessKey, remoteUrls: runtimeRemoteUrls });
|
|
20867
21056
|
// The mount-time target had to guess the account, and guesses
|
|
20868
21057
|
// "unverified" whenever the bounded warm resolution above did not land
|
|
20869
21058
|
// in time -- so a session that has rows on disk can start with none.
|
|
@@ -21985,6 +22174,21 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
21985
22174
|
return () => clearInterval(timer);
|
|
21986
22175
|
}, [showCachedAge]);
|
|
21987
22176
|
|
|
22177
|
+
// Collector mode returns from the acquisition effect before any standalone
|
|
22178
|
+
// wake can advance `now`, which froze running TIME and every UPDATED age
|
|
22179
|
+
// while connected. Apply the standalone rule from a display-only timer: every
|
|
22180
|
+
// second while a run is in progress, otherwise at most once a minute (a same
|
|
22181
|
+
// Date leaves React's render bail-out intact). It never feeds freshness.
|
|
22182
|
+
const collectorDisplayClock = runtime.connect !== null;
|
|
22183
|
+
useEffect(() => {
|
|
22184
|
+
if (!collectorDisplayClock) return;
|
|
22185
|
+
const timer = setInterval(() => setNow((previous) => {
|
|
22186
|
+
const at = Date.now();
|
|
22187
|
+
return hasInProgressRef.current || at - previous.getTime() >= 60_000 ? new Date(at) : previous;
|
|
22188
|
+
}), 1000);
|
|
22189
|
+
return () => clearInterval(timer);
|
|
22190
|
+
}, [collectorDisplayClock]);
|
|
22191
|
+
|
|
21988
22192
|
const allItems = items ?? [];
|
|
21989
22193
|
const tabOffsetRaw = offset[tab.key] ?? 0;
|
|
21990
22194
|
const selectedKey = selected[tab.key] ?? null;
|
|
@@ -22637,12 +22841,14 @@ export {
|
|
|
22637
22841
|
maintainControlLease,
|
|
22638
22842
|
claimProbe,
|
|
22639
22843
|
classifyThrottle,
|
|
22844
|
+
secondaryThrottleEvidence,
|
|
22640
22845
|
applyTransportCooldown,
|
|
22641
22846
|
clearTransportThrottle,
|
|
22642
22847
|
emptyTransportThrottle,
|
|
22643
22848
|
throttleLadderMs,
|
|
22644
22849
|
transportCooldownDeadline,
|
|
22645
22850
|
retryThrottledTransport,
|
|
22851
|
+
retryPausedTransport,
|
|
22646
22852
|
THROTTLE_LADDER_MS,
|
|
22647
22853
|
THROTTLE_PAUSE_AFTER,
|
|
22648
22854
|
MANUAL_GRANT_STREAK_LIMIT,
|
|
@@ -22806,6 +23012,7 @@ export {
|
|
|
22806
23012
|
saveDashboardCache,
|
|
22807
23013
|
mergeDashboardCacheSnapshots,
|
|
22808
23014
|
nextSecurityRaw,
|
|
23015
|
+
adoptedAcquisitionRaw,
|
|
22809
23016
|
shouldCheckpointFreshness,
|
|
22810
23017
|
ACQUISITION_STORE_VERSION,
|
|
22811
23018
|
ACQUISITION_CLAIM_TTL_MS,
|
package/package.json
CHANGED