gh-glance 0.13.3 → 0.14.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 +100 -1
- package/README.md +46 -18
- package/index.mjs +963 -274
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -92,13 +92,18 @@ const ICON_HINT_AFTER_MS = 3000;
|
|
|
92
92
|
// rows was paying several seconds per refresh for rows nobody sees. Actions is
|
|
93
93
|
// a scrolling log whose count is arbitrary anyway, so it fetches only what the
|
|
94
94
|
// pane can show -- one extra row tells us whether to render the count as "n+".
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
const
|
|
95
|
+
// One stable request, not a pane-height-derived one. The limit used to track
|
|
96
|
+
// the terminal's height, which meant two panes of different sizes asked two
|
|
97
|
+
// different questions about the same repository: neither could reuse the
|
|
98
|
+
// other's validator, and a resize invalidated the ETag for nothing. 60 is the
|
|
99
|
+
// old ceiling -- above it a fetch outlasts REFRESH_MS and ticks are absorbed by
|
|
100
|
+
// the in-flight guard -- and it comfortably covers the tallest pane.
|
|
101
|
+
const ACTIONS_RUN_LIMIT = 60;
|
|
102
|
+
|
|
103
|
+
// The workflow catalog is now a fallback for runs whose own `name` is missing,
|
|
104
|
+
// so it is worth caching for long enough that it costs nothing on a normal
|
|
105
|
+
// session and short enough that a workflow renamed today shows up today.
|
|
106
|
+
const WORKFLOW_CATALOG_TTL_MS = 15 * 60_000;
|
|
102
107
|
|
|
103
108
|
// Issues and PRs are sets rather than logs: the count *is* the signal, so these
|
|
104
109
|
// stay generous. They are also far cheaper, being bounded by what's open.
|
|
@@ -117,11 +122,37 @@ const ALERT_PER_PAGE = 100;
|
|
|
117
122
|
const ALERT_QUERY = `?state=open&per_page=${ALERT_PER_PAGE}&sort=created&direction=desc`;
|
|
118
123
|
|
|
119
124
|
// Inactive tabs only feed the tab-bar counts. One rotating background tab is
|
|
120
|
-
// considered
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
// the safe resource slot.
|
|
125
|
+
// considered per wake, so each inactive tab is considered about every Nth floor
|
|
126
|
+
// instead of all three starting together. The shared governor can schedule
|
|
127
|
+
// either active or background demand later when that is the safe resource slot.
|
|
124
128
|
const BACKGROUND_EVERY = 12;
|
|
129
|
+
|
|
130
|
+
// The cadence table. A floor is a floor and never a ceiling: every entry below
|
|
131
|
+
// is the *earliest* normal check, so `--refresh 120` still means 120 seconds.
|
|
132
|
+
//
|
|
133
|
+
// Running or queued Actions are the one thing worth asking about faster than a
|
|
134
|
+
// quiet repository, and 5s is fast enough that a run's transition is visible
|
|
135
|
+
// while it still means something.
|
|
136
|
+
const POLL_ACTIVE_CI_MS = 5_000;
|
|
137
|
+
// Two successful unchanged observations, not one. A single 304 is not evidence
|
|
138
|
+
// that a repository is quiet, and slowing to the quiet cadence on it would
|
|
139
|
+
// delay the first real change by a whole interval for no reason.
|
|
140
|
+
const POLL_QUIET_AFTER = 2;
|
|
141
|
+
const POLL_QUIET_MS = Object.freeze({
|
|
142
|
+
actions: 30_000,
|
|
143
|
+
issues: 30_000,
|
|
144
|
+
prs: 30_000,
|
|
145
|
+
// Alerts are the slowest-moving surface here and the most expensive to read.
|
|
146
|
+
security: 60_000,
|
|
147
|
+
});
|
|
148
|
+
// Inactive demand only feeds the tab-bar counts, so it is paced by whichever is
|
|
149
|
+
// slower: this app's own background interval, or BACKGROUND_EVERY floors.
|
|
150
|
+
const POLL_BACKGROUND_MS = Object.freeze({
|
|
151
|
+
actions: 120_000,
|
|
152
|
+
issues: 120_000,
|
|
153
|
+
prs: 120_000,
|
|
154
|
+
security: 300_000,
|
|
155
|
+
});
|
|
125
156
|
// What one fetch of each tab costs lives in REST_PER_FETCH / GRAPHQL_PER_FETCH,
|
|
126
157
|
// declared with ALERT_SOURCES below because the security figure derives from it.
|
|
127
158
|
|
|
@@ -129,7 +160,7 @@ const BACKGROUND_EVERY = 12;
|
|
|
129
160
|
// the in-flight guard was never cleared and that tab stopped fetching for the
|
|
130
161
|
// life of the process while the spinner kept insisting it was working. The
|
|
131
162
|
// timeout has to clear the slowest *legitimate* fetch by a wide margin -- the
|
|
132
|
-
//
|
|
163
|
+
// ACTIONS_RUN_LIMIT note above measures ~4.9s at 150 runs -- so it sits far above
|
|
133
164
|
// REFRESH_MS rather than near it. SIGKILL because `gh` mid-TLS-handshake can
|
|
134
165
|
// ignore SIGTERM.
|
|
135
166
|
const GH_TIMEOUT_MS = 30_000;
|
|
@@ -181,6 +212,7 @@ const runtime = {
|
|
|
181
212
|
host: null,
|
|
182
213
|
repoExplicit: false,
|
|
183
214
|
refreshMs: REFRESH_MS,
|
|
215
|
+
background: "all",
|
|
184
216
|
verbose: false,
|
|
185
217
|
initialTabIndex: 0,
|
|
186
218
|
};
|
|
@@ -396,6 +428,9 @@ function isAuthProblem(err) {
|
|
|
396
428
|
function isMissingRemote(err) {
|
|
397
429
|
return /(?:failed to determine base repo:\s*)?no git remotes found/i.test(errText(err));
|
|
398
430
|
}
|
|
431
|
+
// The shorter of the two, so a verdict recorded without a gh subprocess reads
|
|
432
|
+
// exactly like one gh produced and classifies the same way.
|
|
433
|
+
const NO_REMOTE_ERROR_TEXT = "no git remotes found";
|
|
399
434
|
|
|
400
435
|
const UNUSABLE_OUTPUT_CODE = "GH_GLANCE_UNUSABLE_OUTPUT";
|
|
401
436
|
|
|
@@ -850,7 +885,7 @@ async function ghApi(args, { operation, signal, etag = null, run = runGh } = {})
|
|
|
850
885
|
|
|
851
886
|
// The version travels in the query key, so a changed document invalidates its
|
|
852
887
|
// cursors instead of paging a new shape with an old cursor.
|
|
853
|
-
const GRAPHQL_QUERY_VERSION =
|
|
888
|
+
const GRAPHQL_QUERY_VERSION = 2;
|
|
854
889
|
// GitHub prices a connection by the pages it could return, and the row's single
|
|
855
890
|
// label is a nested connection of its own. Two points is the conservative bound
|
|
856
891
|
// for one page; the observer selects nothing but the meter and costs one.
|
|
@@ -1028,6 +1063,25 @@ function resolveEffectiveHost({
|
|
|
1028
1063
|
|
|
1029
1064
|
const UNRESOLVED_REPOSITORY = "Repository could not be resolved; pass --repo owner/name";
|
|
1030
1065
|
|
|
1066
|
+
// Whether there is a repository to talk about at all. A folder with no git
|
|
1067
|
+
// remote, no --repo and no GH_REPO is the onboarding state the no-remote verdict
|
|
1068
|
+
// renders -- but that verdict was only ever produced from a gh error, and with
|
|
1069
|
+
// no remote there is no host to verify an identity against, so no gh command
|
|
1070
|
+
// ran: the coordinator reported unknown-host and the dashboard sat on a generic
|
|
1071
|
+
// "retrying" notice, waiting for a budget it could never resolve. Decided from
|
|
1072
|
+
// the same inputs as the host so the two cannot disagree; an ambiguous set of
|
|
1073
|
+
// remotes is still a coordination question, not this one.
|
|
1074
|
+
function noRepositoryTarget({ runtimeRepo = null, ghRepo = null, remoteUrls = [] } = {}) {
|
|
1075
|
+
return !runtimeRepo && !ghRepo && remoteUrls.length === 0;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function runtimeHasNoRepositoryTarget(remoteUrls = runtimeRemoteUrls) {
|
|
1079
|
+
return noRepositoryTarget({ runtimeRepo: runtime.repo, ghRepo: process.env.GH_REPO, remoteUrls });
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const NO_REPOSITORY_TARGET_REPORT =
|
|
1083
|
+
"no repository target: this folder has no git remote (run `gh repo create`, or pass --repo owner/name)";
|
|
1084
|
+
|
|
1031
1085
|
// The owner/name half of the same question resolveEffectiveHost answers for the
|
|
1032
1086
|
// host, and in the same precedence: an explicit --repo, then GH_REPO, then an
|
|
1033
1087
|
// unambiguous git remote. Ambiguity resolves to null rather than to a guess.
|
|
@@ -1243,9 +1297,9 @@ function createFailureContextCoordinator({ resolve, commit, fallback }) {
|
|
|
1243
1297
|
return { ensure, invalidate };
|
|
1244
1298
|
}
|
|
1245
1299
|
|
|
1246
|
-
function actionsRunsArgs(
|
|
1300
|
+
function actionsRunsArgs() {
|
|
1247
1301
|
return [
|
|
1248
|
-
apiPath(`repos/{owner}/{repo}/actions/runs?exclude_pull_requests=true&per_page=${
|
|
1302
|
+
apiPath(`repos/{owner}/{repo}/actions/runs?exclude_pull_requests=true&per_page=${ACTIONS_RUN_LIMIT}`),
|
|
1249
1303
|
...apiHostArgs(),
|
|
1250
1304
|
"--jq",
|
|
1251
1305
|
// html_url is projected because a run's page must come from the row itself.
|
|
@@ -1253,7 +1307,11 @@ function actionsRunsArgs(limit) {
|
|
|
1253
1307
|
// with an inferred repository the app never learns the slug, and the row is
|
|
1254
1308
|
// the one place the answer is always present. It costs nothing extra: the
|
|
1255
1309
|
// field is already in the response being parsed.
|
|
1256
|
-
|
|
1310
|
+
//
|
|
1311
|
+
// `name` is the workflow's name as of the run. Selecting it here is what
|
|
1312
|
+
// turns the workflow catalog from an unconditional second request into a
|
|
1313
|
+
// fallback for the rows that lack one.
|
|
1314
|
+
"[.workflow_runs[] | {databaseId: .id, displayTitle: .display_title, workflowName: .name, number: .run_number, headBranch: .head_branch, status, conclusion, startedAt: .run_started_at, updatedAt: .updated_at, workflowId: .workflow_id, url: .html_url}]",
|
|
1257
1315
|
];
|
|
1258
1316
|
}
|
|
1259
1317
|
|
|
@@ -1266,15 +1324,11 @@ function actionsWorkflowsArgs() {
|
|
|
1266
1324
|
];
|
|
1267
1325
|
}
|
|
1268
1326
|
|
|
1269
|
-
function
|
|
1270
|
-
const workflows = new Map(parseJsonOutput(workflowsBody).map((workflow) => [
|
|
1271
|
-
workflow.id,
|
|
1272
|
-
safe(workflow.name),
|
|
1273
|
-
]));
|
|
1327
|
+
function parseActionsRuns(runsBody) {
|
|
1274
1328
|
return parseJsonOutput(runsBody).map((run) => ({
|
|
1275
1329
|
databaseId: run.databaseId,
|
|
1276
1330
|
displayTitle: safe(run.displayTitle),
|
|
1277
|
-
workflowName:
|
|
1331
|
+
workflowName: safe(run.workflowName ?? ""),
|
|
1278
1332
|
number: run.number,
|
|
1279
1333
|
headBranch: safe(run.headBranch),
|
|
1280
1334
|
status: run.status,
|
|
@@ -1282,9 +1336,56 @@ function parseActionsBodies(runsBody, workflowsBody) {
|
|
|
1282
1336
|
startedAt: run.startedAt,
|
|
1283
1337
|
updatedAt: run.updatedAt,
|
|
1284
1338
|
url: safe(run.url ?? ""),
|
|
1339
|
+
workflowId: run.workflowId,
|
|
1285
1340
|
}));
|
|
1286
1341
|
}
|
|
1287
1342
|
|
|
1343
|
+
function parseWorkflowCatalog(workflowsBody, at, previous = null) {
|
|
1344
|
+
// An attempt that produced no usable body still closes the TTL window, and
|
|
1345
|
+
// keeps whatever names were already known. Re-asking once per poll for a
|
|
1346
|
+
// catalog the server will not serve is the failure mode this replaces.
|
|
1347
|
+
if (typeof workflowsBody !== "string" || workflowsBody.length === 0) {
|
|
1348
|
+
return { at, names: previous?.names instanceof Map ? previous.names : new Map() };
|
|
1349
|
+
}
|
|
1350
|
+
return {
|
|
1351
|
+
at,
|
|
1352
|
+
names: new Map(parseJsonOutput(workflowsBody).map((workflow) => [workflow.id, safe(workflow.name)])),
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
function catalogNames(catalog) {
|
|
1357
|
+
return catalog?.names instanceof Map ? catalog.names : new Map();
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// The catalog is worth a request only when a run carries no name of its own and
|
|
1361
|
+
// its workflow id is not already known. A server that answers with names -- the
|
|
1362
|
+
// normal case -- never asks for it at all.
|
|
1363
|
+
function workflowCatalogDemand({ runs = [], catalog = null, nowMs = Date.now() }) {
|
|
1364
|
+
const fresh = catalog && Number.isFinite(catalog.at) &&
|
|
1365
|
+
nowMs - catalog.at < WORKFLOW_CATALOG_TTL_MS;
|
|
1366
|
+
if (fresh) return false;
|
|
1367
|
+
const names = catalogNames(catalog);
|
|
1368
|
+
return runs.some((run) => !run.workflowName && !names.has(run.workflowId));
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// A missing name renders empty rather than blocking the row. Everything else a
|
|
1372
|
+
// run carries -- its status, conclusion and title -- is exactly as useful
|
|
1373
|
+
// without the workflow's name, so an unreadable catalog must not cost CI status.
|
|
1374
|
+
function resolveWorkflowNames(runs, catalog) {
|
|
1375
|
+
const names = catalogNames(catalog);
|
|
1376
|
+
return runs.map(({ workflowId, ...run }) => ({
|
|
1377
|
+
...run,
|
|
1378
|
+
workflowName: run.workflowName || names.get(workflowId) || "",
|
|
1379
|
+
}));
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function parseActionsBodies(runsBody, workflowsBody) {
|
|
1383
|
+
return resolveWorkflowNames(
|
|
1384
|
+
parseActionsRuns(runsBody),
|
|
1385
|
+
parseWorkflowCatalog(workflowsBody, 0),
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1288
1389
|
function entityKey(tab, path) {
|
|
1289
1390
|
return `${tab}\0${path}`;
|
|
1290
1391
|
}
|
|
@@ -1315,12 +1416,24 @@ async function fetchConditionalEntity({
|
|
|
1315
1416
|
response.status === 200 ? 1 : 0,
|
|
1316
1417
|
);
|
|
1317
1418
|
if (response.status === 304) {
|
|
1419
|
+
// An entity that is present but empty is not an answer either: it parses to
|
|
1420
|
+
// nothing, and the validator that named it is never refreshed, so the tab
|
|
1421
|
+
// asks the same unanswerable question at every poll forever.
|
|
1422
|
+
const recovery = conditionalRecoveryPlan({ status: 304, entity: cached?.body });
|
|
1423
|
+
// A validator naming an entity this pane no longer holds describes a
|
|
1424
|
+
// question with no possible answer: the server will keep saying "unchanged"
|
|
1425
|
+
// and there is nothing here for that to mean. Drop the validator now --
|
|
1426
|
+
// directly, not through the staged publication, which only runs on a usable
|
|
1427
|
+
// transition -- so the next admitted check is unconditional. One recovery,
|
|
1428
|
+
// and the tab reports unusable in the meantime rather than looping.
|
|
1429
|
+
if (recovery.recover && entities instanceof Map) entities.delete(entityKey(tab, path));
|
|
1318
1430
|
return {
|
|
1319
1431
|
path,
|
|
1320
1432
|
status: 304,
|
|
1321
|
-
body:
|
|
1433
|
+
body: recovery.recover ? null : cached.body,
|
|
1322
1434
|
staged: null,
|
|
1323
1435
|
observations: observation ? [observation] : [],
|
|
1436
|
+
recovered: recovery.recover,
|
|
1324
1437
|
};
|
|
1325
1438
|
}
|
|
1326
1439
|
return {
|
|
@@ -1371,39 +1484,92 @@ function publishStagedEntities(entities, staged, transitionKind) {
|
|
|
1371
1484
|
return true;
|
|
1372
1485
|
}
|
|
1373
1486
|
|
|
1374
|
-
|
|
1487
|
+
const ACTIONS_QUERY_VERSION = 2;
|
|
1488
|
+
|
|
1489
|
+
async function fetchActions(signal, {
|
|
1375
1490
|
entities = new Map(),
|
|
1376
1491
|
force = false,
|
|
1377
1492
|
previousRaw = null,
|
|
1493
|
+
catalog = null,
|
|
1494
|
+
governor = null,
|
|
1495
|
+
// Seams, so the conditional catalog fallback can be exercised without a live
|
|
1496
|
+
// governor or a subprocess.
|
|
1497
|
+
request = ghApi,
|
|
1498
|
+
admit = runAdmittedOperation,
|
|
1378
1499
|
} = {}) {
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1500
|
+
const runsResponse = await fetchConditionalEntity({
|
|
1501
|
+
tab: "actions",
|
|
1502
|
+
args: actionsRunsArgs(),
|
|
1503
|
+
operation: "tab:actions-runs",
|
|
1504
|
+
signal,
|
|
1505
|
+
force,
|
|
1506
|
+
entities,
|
|
1507
|
+
request,
|
|
1508
|
+
});
|
|
1509
|
+
if (typeof runsResponse.body !== "string") {
|
|
1510
|
+
// The broken validator has already been dropped above, so the next admitted
|
|
1511
|
+
// check is unconditional. This poll reports unusable, which keeps the
|
|
1512
|
+
// last-good rows and their freshness clock rather than blanking the tab.
|
|
1513
|
+
return {
|
|
1514
|
+
raw: null,
|
|
1515
|
+
limit: ACTIONS_RUN_LIMIT,
|
|
1516
|
+
restSpent: 0,
|
|
1517
|
+
graphqlSpent: GRAPHQL_PER_FETCH.actions,
|
|
1518
|
+
stagedEntities: null,
|
|
1519
|
+
observations: runsResponse.observations,
|
|
1520
|
+
catalog,
|
|
1521
|
+
parse: () => parseActionsRuns(runsResponse.body ?? ""),
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
const responses = [runsResponse];
|
|
1525
|
+
const runs = parseActionsRuns(runsResponse.body);
|
|
1526
|
+
const startedAt = Date.now();
|
|
1527
|
+
let nextCatalog = catalog;
|
|
1528
|
+
if (governor && workflowCatalogDemand({ runs, catalog, nowMs: startedAt })) {
|
|
1529
|
+
// Separately admitted, like a later GraphQL page: the catalog is a fallback,
|
|
1530
|
+
// so a budget decision about it settles against its own reservation and can
|
|
1531
|
+
// never take the run list -- or the tab's own settlement -- down with it.
|
|
1532
|
+
const admitted = await admit({
|
|
1533
|
+
...governor,
|
|
1534
|
+
operation: "catalog:actions-workflows",
|
|
1535
|
+
priority: "background",
|
|
1392
1536
|
signal,
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1537
|
+
waitMs: GOVERNOR_ADMISSION_WAIT_MS,
|
|
1538
|
+
run: (admittedSignal) => fetchConditionalEntity({
|
|
1539
|
+
tab: "actions",
|
|
1540
|
+
args: actionsWorkflowsArgs(),
|
|
1541
|
+
operation: "catalog:actions-workflows",
|
|
1542
|
+
signal: admittedSignal,
|
|
1543
|
+
force,
|
|
1544
|
+
entities,
|
|
1545
|
+
request,
|
|
1546
|
+
}),
|
|
1547
|
+
});
|
|
1548
|
+
if (admitted.ok && admitted.value) {
|
|
1549
|
+
responses.push(admitted.value);
|
|
1550
|
+
nextCatalog = parseWorkflowCatalog(admitted.value.body, startedAt, catalog);
|
|
1551
|
+
}
|
|
1552
|
+
// A refusal leaves the catalog exactly as it was. Closing the TTL on a
|
|
1553
|
+
// scheduling outcome would hide missing names for fifteen minutes over a
|
|
1554
|
+
// moment of budget pressure.
|
|
1555
|
+
}
|
|
1556
|
+
const rows = resolveWorkflowNames(runs, nextCatalog);
|
|
1557
|
+
// The payload identity is what is rendered, not the concatenated bodies. The
|
|
1558
|
+
// catalog is fetched on some polls and not others, and joining raw bodies
|
|
1559
|
+
// made that alternation look like a content change every time.
|
|
1560
|
+
const raw = JSON.stringify({ v: ACTIONS_QUERY_VERSION, rows });
|
|
1561
|
+
const batch = conditionalBatchResult(responses, previousRaw, raw);
|
|
1399
1562
|
return {
|
|
1400
1563
|
raw: batch.raw,
|
|
1401
|
-
limit,
|
|
1402
|
-
|
|
1564
|
+
limit: ACTIONS_RUN_LIMIT,
|
|
1565
|
+
// The tab settles for its own request. The catalog settled against the
|
|
1566
|
+
// reservation it opened for itself.
|
|
1567
|
+
restSpent: runsResponse.status === 200 ? 1 : 0,
|
|
1403
1568
|
graphqlSpent: GRAPHQL_PER_FETCH.actions,
|
|
1404
1569
|
stagedEntities: batch.stagedEntities,
|
|
1405
1570
|
observations: batch.observations,
|
|
1406
|
-
|
|
1571
|
+
catalog: nextCatalog,
|
|
1572
|
+
parse: () => rows,
|
|
1407
1573
|
};
|
|
1408
1574
|
}
|
|
1409
1575
|
|
|
@@ -1487,15 +1653,101 @@ const PR_ROW = (node) => ({
|
|
|
1487
1653
|
url: safe(node.url ?? ""),
|
|
1488
1654
|
});
|
|
1489
1655
|
|
|
1490
|
-
//
|
|
1491
|
-
// is
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1494
|
-
|
|
1656
|
+
// How close to the end of the loaded rows the cursor must come before another
|
|
1657
|
+
// page is worth acquiring. Ten rows is roughly half a pane, so the next page
|
|
1658
|
+
// arrives before the user reaches the bottom without being fetched for a list
|
|
1659
|
+
// nobody scrolled.
|
|
1660
|
+
const PAGE_DEMAND_THRESHOLD = 10;
|
|
1661
|
+
|
|
1662
|
+
// A traversal's identity is the identity of its first page. A cursor is only
|
|
1663
|
+
// meaningful against the ordering that produced it, so when the first page
|
|
1664
|
+
// changes underneath an in-flight later page, that page describes a list that
|
|
1665
|
+
// no longer exists.
|
|
1666
|
+
function pageGeneration(rows) {
|
|
1667
|
+
return (Array.isArray(rows) ? rows : [])
|
|
1668
|
+
.map((row) => String(itemKey(row)))
|
|
1669
|
+
.join("\u0000");
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function paginationDemand({
|
|
1673
|
+
selectedIndex,
|
|
1674
|
+
loadedRows,
|
|
1675
|
+
hasNextPage,
|
|
1676
|
+
cap = LIST_LIMIT,
|
|
1677
|
+
threshold = PAGE_DEMAND_THRESHOLD,
|
|
1678
|
+
}) {
|
|
1679
|
+
if (hasNextPage !== true) return false;
|
|
1680
|
+
if (loadedRows >= cap) return false;
|
|
1681
|
+
return Number.isSafeInteger(selectedIndex) && selectedIndex >= loadedRows - threshold;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// Demand is a page count, not an event, which is what makes it coalesce:
|
|
1685
|
+
// holding `j` near the end asks for one more page however many times it fires.
|
|
1686
|
+
function demandedPageCount(previous, {
|
|
1687
|
+
selectedIndex,
|
|
1688
|
+
loadedRows,
|
|
1689
|
+
hasNextPage,
|
|
1690
|
+
cap = LIST_LIMIT,
|
|
1691
|
+
pageSize = GRAPHQL_PAGE_SIZE,
|
|
1692
|
+
}) {
|
|
1693
|
+
const current = Number.isSafeInteger(previous) && previous > 0 ? previous : 1;
|
|
1694
|
+
const max = Math.max(1, Math.ceil(cap / pageSize));
|
|
1695
|
+
if (!paginationDemand({ selectedIndex, loadedRows, hasNextPage, cap })) {
|
|
1696
|
+
return Math.min(current, max);
|
|
1697
|
+
}
|
|
1698
|
+
return Math.min(Math.max(current, Math.ceil(loadedRows / pageSize) + 1), max);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function mergeDemandedPages({ pages = [], cap = LIST_LIMIT, keyOf = itemKey }) {
|
|
1702
|
+
const list = (Array.isArray(pages) ? pages : []).filter(Boolean);
|
|
1703
|
+
const base = list[0] ?? null;
|
|
1704
|
+
const rows = [];
|
|
1705
|
+
const seen = new Set();
|
|
1706
|
+
let accepted = 0;
|
|
1707
|
+
let hasNextPage = false;
|
|
1708
|
+
let capped = false;
|
|
1709
|
+
for (const value of list) {
|
|
1710
|
+
if (value.generation !== base.generation) break;
|
|
1711
|
+
accepted += 1;
|
|
1712
|
+
hasNextPage = value.pageInfo?.hasNextPage === true;
|
|
1713
|
+
for (const row of value.rows ?? []) {
|
|
1714
|
+
const key = keyOf(row);
|
|
1715
|
+
// A node can legitimately appear on two pages when the list shifted
|
|
1716
|
+
// between them. Publishing it twice is a rendering bug, not extra data.
|
|
1717
|
+
if (key != null) {
|
|
1718
|
+
if (seen.has(key)) continue;
|
|
1719
|
+
seen.add(key);
|
|
1720
|
+
}
|
|
1721
|
+
if (rows.length >= cap) { capped = true; break; }
|
|
1722
|
+
rows.push(row);
|
|
1723
|
+
}
|
|
1724
|
+
if (capped) break;
|
|
1725
|
+
}
|
|
1726
|
+
return {
|
|
1727
|
+
rows,
|
|
1728
|
+
pages: accepted,
|
|
1729
|
+
// A capped or short merge is not a complete list, and must never render as
|
|
1730
|
+
// one: "50 of 999" and "50 open issues" are different claims.
|
|
1731
|
+
hasNextPage: hasNextPage || capped,
|
|
1732
|
+
incomplete: hasNextPage || capped || accepted < list.length,
|
|
1733
|
+
generation: base?.generation ?? null,
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// Acquires exactly the pages demand asks for, one explicitly admitted page at a
|
|
1738
|
+
// time. The first page is covered by the tab's own reservation; each later page
|
|
1739
|
+
// reserves its own envelope, so a denial there is an ordinary scheduling
|
|
1740
|
+
// outcome rather than a hidden overspend. Rows already collected survive that
|
|
1741
|
+
// denial and the result is marked incomplete rather than published as complete.
|
|
1495
1742
|
async function fetchGraphqlList(kind, mapRow, {
|
|
1496
1743
|
signal,
|
|
1497
1744
|
governor = null,
|
|
1498
1745
|
previousRaw = null,
|
|
1746
|
+
// How many pages the viewport has actually asked for. One, until a cursor
|
|
1747
|
+
// comes within PAGE_DEMAND_THRESHOLD rows of the end. The walk used to run to
|
|
1748
|
+
// LIST_LIMIT on the very first paint -- three requests for a pane showing
|
|
1749
|
+
// about twenty rows, on every tab, forever.
|
|
1750
|
+
pages = 1,
|
|
1499
1751
|
// Seams, so the paging loop can be exercised without a live governor: the
|
|
1500
1752
|
// settlement figure it produces was wrong for months of repositories with
|
|
1501
1753
|
// more than one page, and nothing could see it.
|
|
@@ -1510,7 +1762,6 @@ async function fetchGraphqlList(kind, mapRow, {
|
|
|
1510
1762
|
error.budgetObservations = first.observations;
|
|
1511
1763
|
throw error;
|
|
1512
1764
|
}
|
|
1513
|
-
const rows = [];
|
|
1514
1765
|
const observations = [...first.observations];
|
|
1515
1766
|
// Only the first page. Pages past it opened their own reservation inside
|
|
1516
1767
|
// runAdmittedOperation and settle against it, so adding them here charges the
|
|
@@ -1519,20 +1770,32 @@ async function fetchGraphqlList(kind, mapRow, {
|
|
|
1519
1770
|
// budget observation the fetch gathered.
|
|
1520
1771
|
const envelopeSpent = first.observedCost;
|
|
1521
1772
|
let spent = first.observedCost;
|
|
1522
|
-
let page = graphqlConnection(first, connection);
|
|
1523
|
-
let incomplete = false;
|
|
1524
1773
|
let overrun = first.overrun;
|
|
1525
|
-
let
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1774
|
+
let current = graphqlConnection(first, connection);
|
|
1775
|
+
const totalCount = current?.totalCount ?? null;
|
|
1776
|
+
const generation = pageGeneration((current?.nodes ?? []).map(mapRow));
|
|
1777
|
+
const wanted = Math.min(
|
|
1778
|
+
Math.max(1, Number.isSafeInteger(pages) ? pages : 1),
|
|
1779
|
+
Math.max(1, Math.ceil(LIST_LIMIT / GRAPHQL_PAGE_SIZE)),
|
|
1780
|
+
);
|
|
1781
|
+
const acquired = [];
|
|
1782
|
+
const collect = (value) => acquired.push({
|
|
1783
|
+
generation,
|
|
1784
|
+
rows: (value?.nodes ?? []).map(mapRow),
|
|
1785
|
+
pageInfo: value?.pageInfo ?? null,
|
|
1786
|
+
});
|
|
1787
|
+
let truncatedWalk = false;
|
|
1788
|
+
if (current) collect(current); else truncatedWalk = true;
|
|
1789
|
+
while (acquired.length < wanted && current?.pageInfo?.hasNextPage) {
|
|
1790
|
+
const cursor = current.pageInfo.endCursor;
|
|
1791
|
+
if (typeof cursor !== "string" || cursor.length === 0) { truncatedWalk = true; break; }
|
|
1792
|
+
if (!governor) { truncatedWalk = true; break; }
|
|
1531
1793
|
const admitted = await admit({
|
|
1532
1794
|
...governor,
|
|
1533
1795
|
operation: `page:${kind}`,
|
|
1534
1796
|
priority: "background",
|
|
1535
1797
|
signal,
|
|
1798
|
+
waitMs: GOVERNOR_ADMISSION_WAIT_MS,
|
|
1536
1799
|
run: (admittedSignal) => fetchPage(kind, { signal: admittedSignal, after: cursor, operation: `page:${kind}` }),
|
|
1537
1800
|
});
|
|
1538
1801
|
// A later page that is denied, fails, or returns errors leaves the rows
|
|
@@ -1548,47 +1811,57 @@ async function fetchGraphqlList(kind, mapRow, {
|
|
|
1548
1811
|
// it will not do.
|
|
1549
1812
|
if (admitted.value?.observations) observations.push(...admitted.value.observations);
|
|
1550
1813
|
if (admitted.value?.overrun) overrun = true;
|
|
1551
|
-
if (!admitted.ok || !admitted.value?.ok) {
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
}
|
|
1558
|
-
if (page?.pageInfo?.hasNextPage && rows.length >= LIST_LIMIT) incomplete = true;
|
|
1814
|
+
if (!admitted.ok || !admitted.value?.ok) { truncatedWalk = true; break; }
|
|
1815
|
+
spent += admitted.value.observedCost;
|
|
1816
|
+
current = graphqlConnection(admitted.value, connection);
|
|
1817
|
+
if (!current) { truncatedWalk = true; break; }
|
|
1818
|
+
collect(current);
|
|
1819
|
+
}
|
|
1559
1820
|
if (overrun) {
|
|
1560
1821
|
// Recorded at its real cost above; suspended here until the resource resets,
|
|
1561
1822
|
// which is the soonest a corrected bound could be trusted again.
|
|
1562
1823
|
const reset = observations.at(-1)?.resetMs;
|
|
1563
1824
|
pauseOperation(operation, Number.isFinite(reset) ? reset + BUDGET_RESET_GRACE_MS : Date.now() + BUDGET_PROBE_MS);
|
|
1564
1825
|
}
|
|
1565
|
-
const
|
|
1826
|
+
const merged = mergeDemandedPages({ pages: acquired, cap: LIST_LIMIT });
|
|
1827
|
+
const incomplete = merged.incomplete || truncatedWalk;
|
|
1566
1828
|
// Serialized rows are the payload identity the unchanged-frame suppression
|
|
1567
|
-
// and the cache both compare on, so it must cover exactly what is rendered
|
|
1568
|
-
|
|
1829
|
+
// and the cache both compare on, so it must cover exactly what is rendered --
|
|
1830
|
+
// including the markers that say the list is not all of it.
|
|
1831
|
+
const raw = JSON.stringify({
|
|
1832
|
+
v: GRAPHQL_QUERY_VERSION,
|
|
1833
|
+
totalCount,
|
|
1834
|
+
incomplete,
|
|
1835
|
+
hasNextPage: merged.hasNextPage,
|
|
1836
|
+
rows: merged.rows,
|
|
1837
|
+
});
|
|
1569
1838
|
return {
|
|
1570
1839
|
raw: raw === previousRaw ? previousRaw : raw,
|
|
1571
1840
|
// An incomplete walk reports the rows it has as its own limit, so the
|
|
1572
1841
|
// existing truncation indicator fires. "More rows exist than are shown" is
|
|
1573
1842
|
// exactly what incomplete means, and publishing 50 of 150 rows as a
|
|
1574
1843
|
// complete tab is indistinguishable from a repository with 50 open issues.
|
|
1575
|
-
limit: incomplete ?
|
|
1844
|
+
limit: incomplete ? merged.rows.length : LIST_LIMIT,
|
|
1576
1845
|
restSpent: 0,
|
|
1577
1846
|
graphqlSpent: envelopeSpent,
|
|
1578
1847
|
// What the whole walk cost, for reporting. Never the settlement figure.
|
|
1579
1848
|
graphqlSpentTotal: spent,
|
|
1580
1849
|
observations,
|
|
1581
1850
|
incomplete,
|
|
1582
|
-
|
|
1851
|
+
totalCount,
|
|
1852
|
+
hasNextPage: merged.hasNextPage,
|
|
1853
|
+
generation: merged.generation,
|
|
1854
|
+
loadedPages: merged.pages,
|
|
1855
|
+
parse: () => merged.rows,
|
|
1583
1856
|
};
|
|
1584
1857
|
}
|
|
1585
1858
|
|
|
1586
|
-
async function fetchIssues(signal, { governor = null, previousRaw = null } = {}) {
|
|
1587
|
-
return fetchGraphqlList("issues", ISSUE_ROW, { signal, governor, previousRaw });
|
|
1859
|
+
async function fetchIssues(signal, { governor = null, previousRaw = null, pages = 1 } = {}) {
|
|
1860
|
+
return fetchGraphqlList("issues", ISSUE_ROW, { signal, governor, previousRaw, pages });
|
|
1588
1861
|
}
|
|
1589
1862
|
|
|
1590
|
-
async function fetchPRs(signal, { governor = null, previousRaw = null } = {}) {
|
|
1591
|
-
return fetchGraphqlList("prs", PR_ROW, { signal, governor, previousRaw });
|
|
1863
|
+
async function fetchPRs(signal, { governor = null, previousRaw = null, pages = 1 } = {}) {
|
|
1864
|
+
return fetchGraphqlList("prs", PR_ROW, { signal, governor, previousRaw, pages });
|
|
1592
1865
|
}
|
|
1593
1866
|
|
|
1594
1867
|
// The three alert endpoints were three near-identical 26-line blocks that had
|
|
@@ -1662,10 +1935,10 @@ const ALERT_SOURCES = [
|
|
|
1662
1935
|
// `projectedHourlyCost` derives the hourly figure from it and the spend meter
|
|
1663
1936
|
// bills against it, so a corrected number cannot reach one and miss the other.
|
|
1664
1937
|
//
|
|
1665
|
-
// actions
|
|
1666
|
-
//
|
|
1667
|
-
//
|
|
1668
|
-
//
|
|
1938
|
+
// actions was 2 for as long as every fetch made both calls: measured
|
|
1939
|
+
// 2026-08-10 with `GH_DEBUG=api`, `gh run list` issued GET /actions/runs *and*
|
|
1940
|
+
// GET /actions/workflows. The catalog is now a conditional fallback with its
|
|
1941
|
+
// own reservation, so the tab reserves for the one call it always makes.
|
|
1669
1942
|
//
|
|
1670
1943
|
// issues and prs are 0 REST because SORT_RECENT's --search routes both through
|
|
1671
1944
|
// GraphQL entirely (2 POSTs each, confirmed by the same measurement).
|
|
@@ -1673,7 +1946,10 @@ const SECURITY_REQUESTS_PER_FETCH = ALERT_SOURCES.reduce(
|
|
|
1673
1946
|
(total, source) => total + 1 + source.priorityQueries.length,
|
|
1674
1947
|
0,
|
|
1675
1948
|
);
|
|
1676
|
-
|
|
1949
|
+
// Actions is one request now: the workflow catalog became a conditional
|
|
1950
|
+
// fallback that opens its own reservation, so the tab no longer reserves for
|
|
1951
|
+
// a second call it usually does not make.
|
|
1952
|
+
const REST_PER_FETCH = { actions: 1, issues: 0, prs: 0, security: SECURITY_REQUESTS_PER_FETCH };
|
|
1677
1953
|
const GRAPHQL_PER_FETCH = { actions: 0, issues: 2, prs: 2, security: 0 };
|
|
1678
1954
|
|
|
1679
1955
|
function tabRequestCost(tab) {
|
|
@@ -1688,7 +1964,9 @@ function tabRequestCost(tab) {
|
|
|
1688
1964
|
const OPERATION_COSTS = Object.freeze({
|
|
1689
1965
|
...Object.fromEntries(TAB_KEYS.map((tab) => [`tab:${tab}`, tabRequestCost(tab)])),
|
|
1690
1966
|
"tab:actions-runs": { core: 1, graphql: 0 },
|
|
1691
|
-
|
|
1967
|
+
// Separately admitted, like a later list page: only fetched when a run has
|
|
1968
|
+
// no name of its own, and settled against its own reservation.
|
|
1969
|
+
"catalog:actions-workflows": { core: 1, graphql: 0 },
|
|
1692
1970
|
"tab:security-endpoint": { core: 1, graphql: 0 },
|
|
1693
1971
|
"failure-context:repository": { core: 0, graphql: GRAPHQL_OBSERVER_POINTS },
|
|
1694
1972
|
// Each page past the first reserves its own envelope, so a denial there is a
|
|
@@ -2525,9 +2803,9 @@ function inspectLegacyMigration(root, state, now) {
|
|
|
2525
2803
|
for (const resource of RATE_RESOURCES) {
|
|
2526
2804
|
const budget = legacy.budgets[resource];
|
|
2527
2805
|
holdUntil = Math.max(holdUntil, budget?.blockUntil ?? 0);
|
|
2528
|
-
const uncertain = Object.values(legacy.reservations).
|
|
2806
|
+
const uncertain = Object.values(legacy.reservations).filter((reservation) =>
|
|
2529
2807
|
["started", "completed"].includes(reservation.status) && reservationCost(reservation, resource, legacy.leases, now) > 0);
|
|
2530
|
-
if (uncertain) {
|
|
2808
|
+
if (uncertain.length > 0) {
|
|
2531
2809
|
// The deadline may only be read from the migrated view for resources the
|
|
2532
2810
|
// migration kept. A v1 file's budgets are dropped on purpose -- they came
|
|
2533
2811
|
// from /rate_limit and are not spendable capacity -- but the reset they
|
|
@@ -2538,9 +2816,22 @@ function inspectLegacyMigration(root, state, now) {
|
|
|
2538
2816
|
// none, and reported legacy-unresolved -- which has no deadline and so
|
|
2539
2817
|
// never cleared. The file it came from had a perfectly good reset in it
|
|
2540
2818
|
// the whole time.
|
|
2819
|
+
//
|
|
2820
|
+
// A file can also simply never have recorded a budget for the resource:
|
|
2821
|
+
// a v2 ledger whose panes only ever observed GraphQL still charged core
|
|
2822
|
+
// per request, so its core reservations exist without a core budget to
|
|
2823
|
+
// ask. Each reservation records the epoch it was admitted against, and
|
|
2824
|
+
// that epoch's reset is when its window ended -- the same fact, kept in
|
|
2825
|
+
// the other place. Only a reservation with no budget *and* no epoch is
|
|
2826
|
+
// genuinely undated.
|
|
2827
|
+
const recorded = uncertain
|
|
2828
|
+
.map((reservation) => Number(String(reservation.epochs?.[resource] ?? "").split(":")[1]))
|
|
2829
|
+
.filter(Number.isFinite);
|
|
2541
2830
|
const reset = Number.isFinite(budget?.resetMs)
|
|
2542
2831
|
? budget.resetMs
|
|
2543
|
-
: Number(raw?.budgets?.[resource]?.resetMs)
|
|
2832
|
+
: Number.isFinite(Number(raw?.budgets?.[resource]?.resetMs))
|
|
2833
|
+
? Number(raw.budgets[resource].resetMs)
|
|
2834
|
+
: recorded.length > 0 ? Math.max(...recorded) : NaN;
|
|
2544
2835
|
if (!Number.isFinite(reset)) return { ok: false, reason: "legacy-unresolved" };
|
|
2545
2836
|
holdUntil = Math.max(holdUntil, reset + BUDGET_RESET_GRACE_MS);
|
|
2546
2837
|
}
|
|
@@ -3105,17 +3396,23 @@ const GOVERNOR_SCOPE_VERSION = 1;
|
|
|
3105
3396
|
// 5: scheduling fairness is persisted, because the starvation it prevents
|
|
3106
3397
|
// happens *between* planning passes -- each manual refresh is its own pass, so
|
|
3107
3398
|
// a counter that lives only inside one cannot see a run of them.
|
|
3108
|
-
const GOVERNOR_STATE_VERSION =
|
|
3399
|
+
const GOVERNOR_STATE_VERSION = 6;
|
|
3109
3400
|
// Readable-as-evidence, never written: the shapes a still-running older pane
|
|
3110
3401
|
// holds. Recognising that such a pane owns a live lease is what the restart
|
|
3111
3402
|
// boundary depends on.
|
|
3112
|
-
const LEGACY_GOVERNOR_VERSIONS = [GOVERNOR_STATE_VERSION, 4, 3, 2];
|
|
3403
|
+
const LEGACY_GOVERNOR_VERSIONS = [GOVERNOR_STATE_VERSION, 5, 4, 3, 2];
|
|
3113
3404
|
// The first version whose file is already in the per-resource observer shape.
|
|
3114
3405
|
// Anything below it is adapted before it can be read; see readLegacyGovernorState.
|
|
3115
3406
|
const GOVERNOR_OBSERVER_SPLIT_VERSION = 4;
|
|
3116
3407
|
// The first version carrying persisted scheduling fairness. A version 4 file is
|
|
3117
3408
|
// otherwise in the current shape and only wants the field defaulted.
|
|
3118
3409
|
const GOVERNOR_FAIRNESS_VERSION = 5;
|
|
3410
|
+
// The first version whose intents are priced by this build's cost table. An
|
|
3411
|
+
// intent must declare exactly what its tab costs, so a build that re-prices a
|
|
3412
|
+
// tab cannot read a live older pane's pending intents -- and that pane's file is
|
|
3413
|
+
// precisely the one carrying the leases and uncertain debts this build must see.
|
|
3414
|
+
// Bumping the version is what turns "corrupt" back into "older, and adaptable".
|
|
3415
|
+
const GOVERNOR_TAB_COST_VERSION = 6;
|
|
3119
3416
|
// Manual work outranks active work, which is right until it means "always". A
|
|
3120
3417
|
// user holding the refresh key issues one manual intent per planning pass, and
|
|
3121
3418
|
// without a bound the active owner behind them never gets a turn at all.
|
|
@@ -3481,14 +3778,41 @@ function normalizeGovernorState(raw, nowMs, { prune = true, acceptVersion = GOVE
|
|
|
3481
3778
|
function readLegacyGovernorState(raw, nowMs, version) {
|
|
3482
3779
|
if (!isRecord(raw) || raw.version !== version) return null;
|
|
3483
3780
|
const options = { prune: false, acceptVersion: version };
|
|
3484
|
-
if (version >=
|
|
3485
|
-
// A version
|
|
3486
|
-
//
|
|
3487
|
-
//
|
|
3488
|
-
const shaped = version >= GOVERNOR_OBSERVER_SPLIT_VERSION
|
|
3781
|
+
if (version >= GOVERNOR_TAB_COST_VERSION) return normalizeGovernorState(raw, nowMs, options);
|
|
3782
|
+
// A version 5 file is in the current shape and differs only in what it
|
|
3783
|
+
// believes a tab costs. Version 4 additionally wants the fairness field
|
|
3784
|
+
// defaulted; anything older needs its observer shape rewritten first.
|
|
3785
|
+
const shaped = version >= GOVERNOR_OBSERVER_SPLIT_VERSION
|
|
3786
|
+
? { ...raw }
|
|
3787
|
+
: adaptPreSplitGovernorShape(raw);
|
|
3489
3788
|
if (!shaped) return null;
|
|
3490
|
-
shaped.fairness = { manualStreak: 0 };
|
|
3491
|
-
return normalizeGovernorState(shaped, nowMs, options);
|
|
3789
|
+
if (version < GOVERNOR_FAIRNESS_VERSION) shaped.fairness = { manualStreak: 0 };
|
|
3790
|
+
return normalizeGovernorState(dropSupersededTabCosts(shaped), nowMs, options);
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
// An intent priced by a build that costed its tab differently. It is a pending
|
|
3794
|
+
// request and nothing more: an intent still in the file has not been granted,
|
|
3795
|
+
// because scheduleGovernorState deletes each one as it creates its reservation.
|
|
3796
|
+
// So dropping it releases nothing and forgives nothing -- the pane that
|
|
3797
|
+
// registered it re-registers at the current price on its next wake. Re-pricing
|
|
3798
|
+
// it here would be the one unsafe option, because the older pane will still make
|
|
3799
|
+
// the number of calls its own build declares.
|
|
3800
|
+
function dropSupersededTabCosts(raw) {
|
|
3801
|
+
if (!isRecord(raw.intents)) return raw;
|
|
3802
|
+
const intents = {};
|
|
3803
|
+
for (const [id, intent] of Object.entries(raw.intents)) {
|
|
3804
|
+
const expected = TAB_KEYS.includes(intent?.tab)
|
|
3805
|
+
? tabRequestCost(intent.tab)
|
|
3806
|
+
: operationCost(intent?.tab);
|
|
3807
|
+
const declared = exactResourceCosts(intent?.costs);
|
|
3808
|
+
// Anything unrecognisable is left exactly as it is, so normalization still
|
|
3809
|
+
// rejects a genuinely corrupt document rather than having it quietly pruned.
|
|
3810
|
+
if (!expected || !declared ||
|
|
3811
|
+
RATE_RESOURCES.every((resource) => expected[resource] === declared[resource])) {
|
|
3812
|
+
intents[id] = intent;
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
return { ...raw, intents };
|
|
3492
3816
|
}
|
|
3493
3817
|
|
|
3494
3818
|
// Versions 2 and 3 held one observer for core, a differently shaped
|
|
@@ -3555,7 +3879,7 @@ function migrateGovernorState(raw, nowMs) {
|
|
|
3555
3879
|
if (!shaped) return null;
|
|
3556
3880
|
if (!isRecord(shaped.fairness)) shaped.fairness = { manualStreak: 0 };
|
|
3557
3881
|
shaped.version = GOVERNOR_STATE_VERSION;
|
|
3558
|
-
return normalizeGovernorState(shaped, nowMs);
|
|
3882
|
+
return normalizeGovernorState(dropSupersededTabCosts(shaped), nowMs);
|
|
3559
3883
|
}
|
|
3560
3884
|
|
|
3561
3885
|
function migrateV1GovernorState(raw, nowMs) {
|
|
@@ -4897,14 +5221,6 @@ function externalSampleIsUsable(sample) {
|
|
|
4897
5221
|
);
|
|
4898
5222
|
}
|
|
4899
5223
|
|
|
4900
|
-
function resourcePerTick(table, activeKey) {
|
|
4901
|
-
let cost = table[activeKey] ?? 0;
|
|
4902
|
-
for (const key of TAB_KEYS) {
|
|
4903
|
-
if (key !== activeKey) cost += (table[key] ?? 0) / BACKGROUND_EVERY;
|
|
4904
|
-
}
|
|
4905
|
-
return cost;
|
|
4906
|
-
}
|
|
4907
|
-
|
|
4908
5224
|
function alertArgs(source, path = source.path) {
|
|
4909
5225
|
return [apiPath(path), ...apiHostArgs(), "--jq", source.jq];
|
|
4910
5226
|
}
|
|
@@ -5658,14 +5974,38 @@ async function readSharedBudgetSources(signal, host = effectiveRuntimeHost(), {
|
|
|
5658
5974
|
// and left to rot. The governor can admit less demand; this projection explains
|
|
5659
5975
|
// pressure, not actual granted spend. The per-fetch prices come from
|
|
5660
5976
|
// REST_PER_FETCH and GRAPHQL_PER_FETCH, which also feed the operation registry.
|
|
5661
|
-
function projectedHourlyCost(activeKey
|
|
5662
|
-
|
|
5977
|
+
function projectedHourlyCost(activeKey, {
|
|
5978
|
+
floorMs = runtime.refreshMs,
|
|
5979
|
+
background = runtime.background,
|
|
5980
|
+
} = {}) {
|
|
5981
|
+
const perHour = (intervalMs) =>
|
|
5982
|
+
Number.isFinite(intervalMs) && intervalMs > 0 ? 3_600_000 / intervalMs : 0;
|
|
5983
|
+
const totals = { rest: { min: 0, max: 0 }, graphql: { min: 0, max: 0 } };
|
|
5984
|
+
for (const tab of TAB_KEYS) {
|
|
5985
|
+
const demand = tab === activeKey ? "active" : "inactive";
|
|
5986
|
+
// Fastest: nothing has been observed unchanged yet, and Actions has work in
|
|
5987
|
+
// flight. Slowest: the quiet cadence this tab settles into.
|
|
5988
|
+
const fastest = pollPolicyInterval({
|
|
5989
|
+
tab, floorMs, demand, unchangedCount: 0, inProgressCI: true, background,
|
|
5990
|
+
});
|
|
5991
|
+
const slowest = pollPolicyInterval({
|
|
5992
|
+
tab, floorMs, demand, unchangedCount: POLL_QUIET_AFTER, background,
|
|
5993
|
+
});
|
|
5994
|
+
totals.rest.min += REST_PER_FETCH[tab] * perHour(slowest);
|
|
5995
|
+
totals.rest.max += REST_PER_FETCH[tab] * perHour(fastest);
|
|
5996
|
+
totals.graphql.min += GRAPHQL_PER_FETCH[tab] * perHour(slowest);
|
|
5997
|
+
totals.graphql.max += GRAPHQL_PER_FETCH[tab] * perHour(fastest);
|
|
5998
|
+
}
|
|
5663
5999
|
return {
|
|
5664
|
-
rest: Math.round(
|
|
5665
|
-
graphql: Math.round(
|
|
6000
|
+
rest: { min: Math.round(totals.rest.min), max: Math.round(totals.rest.max) },
|
|
6001
|
+
graphql: { min: Math.round(totals.graphql.min), max: Math.round(totals.graphql.max) },
|
|
5666
6002
|
};
|
|
5667
6003
|
}
|
|
5668
6004
|
|
|
6005
|
+
function formatProjectedRange({ min, max }) {
|
|
6006
|
+
return min === max ? `~${max}` : `~${min}-${max}`;
|
|
6007
|
+
}
|
|
6008
|
+
|
|
5669
6009
|
function doctorProbePlan() {
|
|
5670
6010
|
// GraphQL cannot infer a repository the way `gh` does, so a report run from a
|
|
5671
6011
|
// directory with no unambiguous remote says so per probe instead of sending a
|
|
@@ -5678,7 +6018,7 @@ function doctorProbePlan() {
|
|
|
5678
6018
|
// Issues/PRs entries ran `gh issue list`/`gh pr list`, whose `--search`
|
|
5679
6019
|
// routed through GraphQL at a price the report could not name.
|
|
5680
6020
|
["Repository access", graphqlArgs(), "doctor:repository", document("repository", graphqlRepositoryVariables(target)), "repository"],
|
|
5681
|
-
["Actions runs", ghApiArgs(actionsRunsArgs(
|
|
6021
|
+
["Actions runs", ghApiArgs(actionsRunsArgs()), "doctor:actions-runs"],
|
|
5682
6022
|
["Actions workflows", ghApiArgs(actionsWorkflowsArgs()), "doctor:actions-workflows"],
|
|
5683
6023
|
["Issues (first page)", graphqlArgs(), "doctor:issues", document("issues", graphqlPageVariables(null, target)), "issues"],
|
|
5684
6024
|
["Pull requests (first page)", graphqlArgs(), "doctor:prs", document("prs", graphqlPageVariables(null, target)), "prs"],
|
|
@@ -5741,7 +6081,9 @@ async function runDoctor() {
|
|
|
5741
6081
|
runtimeIdentityCoordinator = createIdentityCoordinator({ host: effectiveHost });
|
|
5742
6082
|
const verified = await runtimeIdentityCoordinator.refresh();
|
|
5743
6083
|
const verifiedIdentity = runtimeIdentityCoordinator.current();
|
|
5744
|
-
const authStatus = verifiedIdentity
|
|
6084
|
+
const authStatus = verifiedIdentity
|
|
6085
|
+
? `${verifiedIdentity.host}: ${verifiedIdentity.login} (verified)`
|
|
6086
|
+
: runtimeHasNoRepositoryTarget(remoteUrls) ? NO_REPOSITORY_TARGET_REPORT : identityCoordinationMessage(verified.reason);
|
|
5745
6087
|
const resources = verifiedIdentity ? await readRateLimitResources(undefined, effectiveHost).catch(() => null) : null;
|
|
5746
6088
|
const budget = resources
|
|
5747
6089
|
? await rateBudget(resources)
|
|
@@ -5830,11 +6172,16 @@ async function runDoctor() {
|
|
|
5830
6172
|
field("REST core", budget.core),
|
|
5831
6173
|
field("GraphQL", budget.graphql),
|
|
5832
6174
|
field(
|
|
5833
|
-
"
|
|
6175
|
+
"projected demand",
|
|
5834
6176
|
(() => {
|
|
5835
6177
|
const active = TAB_KEYS[runtime.initialTabIndex] ?? TAB_KEYS[0];
|
|
5836
6178
|
const { rest, graphql } = projectedHourlyCost(active);
|
|
5837
|
-
|
|
6179
|
+
// A range, because the cadence now depends on what the repository is
|
|
6180
|
+
// doing. Labelled "projected" rather than "spends": the governor admits
|
|
6181
|
+
// less than this, and the API governor section below carries what was
|
|
6182
|
+
// actually charged.
|
|
6183
|
+
return `${formatProjectedRange(rest)} REST + ${formatProjectedRange(graphql)} GraphQL per hour ` +
|
|
6184
|
+
`(floor ${runtime.refreshMs / 1000}s, background ${runtime.background}, "${active}" active)`;
|
|
5838
6185
|
})(),
|
|
5839
6186
|
),
|
|
5840
6187
|
"",
|
|
@@ -5943,6 +6290,7 @@ function parseRepoTarget(value) {
|
|
|
5943
6290
|
// in-flight guard absorbs every other one and the effective rate is whatever
|
|
5944
6291
|
// `gh` can sustain -- the requested interval silently stops being real. Clamping
|
|
5945
6292
|
// with a stated minimum is honest where silently accepting it would not be.
|
|
6293
|
+
const BACKGROUND_MODES = ["all", "off"];
|
|
5946
6294
|
const MIN_REFRESH_SECONDS = 2;
|
|
5947
6295
|
const MAX_REFRESH_SECONDS = 3600;
|
|
5948
6296
|
|
|
@@ -5963,6 +6311,10 @@ function parseArgs(argv) {
|
|
|
5963
6311
|
// parseArgs returns stays stated in one place.
|
|
5964
6312
|
refreshSource: null,
|
|
5965
6313
|
tab: null,
|
|
6314
|
+
// Whether inactive tabs are polled at all. `off` never requests data for a
|
|
6315
|
+
// tab you are not looking at; its counts stay at whatever was last known,
|
|
6316
|
+
// visibly aged.
|
|
6317
|
+
background: null,
|
|
5966
6318
|
verbose: false,
|
|
5967
6319
|
};
|
|
5968
6320
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -5990,6 +6342,8 @@ function parseArgs(argv) {
|
|
|
5990
6342
|
opts.refresh = takeValue("--refresh");
|
|
5991
6343
|
} else if (arg === "--tab" || arg.startsWith("--tab=")) {
|
|
5992
6344
|
opts.tab = takeValue("--tab");
|
|
6345
|
+
} else if (arg === "--background" || arg.startsWith("--background=")) {
|
|
6346
|
+
opts.background = takeValue("--background");
|
|
5993
6347
|
} else {
|
|
5994
6348
|
throw new Error(`unknown argument: ${arg}`);
|
|
5995
6349
|
}
|
|
@@ -5997,7 +6351,7 @@ function parseArgs(argv) {
|
|
|
5997
6351
|
return opts;
|
|
5998
6352
|
}
|
|
5999
6353
|
|
|
6000
|
-
// Returns { repo, refreshMs, tabKey, verbose } or throws with a message that
|
|
6354
|
+
// Returns { repo, refreshMs, tabKey, background, verbose } or throws with a message that
|
|
6001
6355
|
// says what to do about it.
|
|
6002
6356
|
function validateArgs(opts, tabKeys) {
|
|
6003
6357
|
const { host, slug } = opts.repo !== null ? parseRepoTarget(opts.repo) : { host: null, slug: null };
|
|
@@ -6023,6 +6377,10 @@ function validateArgs(opts, tabKeys) {
|
|
|
6023
6377
|
throw new Error(`--tab must be one of ${tabKeys.join(", ")}, got: ${opts.tab}`);
|
|
6024
6378
|
}
|
|
6025
6379
|
|
|
6380
|
+
if (opts.background !== null && !BACKGROUND_MODES.includes(opts.background)) {
|
|
6381
|
+
throw new Error(`--background must be one of ${BACKGROUND_MODES.join(", ")}, got: ${opts.background}`);
|
|
6382
|
+
}
|
|
6383
|
+
|
|
6026
6384
|
return {
|
|
6027
6385
|
help: opts.help,
|
|
6028
6386
|
showVersion: opts.showVersion,
|
|
@@ -6031,6 +6389,7 @@ function validateArgs(opts, tabKeys) {
|
|
|
6031
6389
|
host,
|
|
6032
6390
|
refreshMs,
|
|
6033
6391
|
tabKey: opts.tab,
|
|
6392
|
+
background: opts.background,
|
|
6034
6393
|
verbose: opts.verbose,
|
|
6035
6394
|
};
|
|
6036
6395
|
}
|
|
@@ -6051,6 +6410,7 @@ const KEY_TABLE = [
|
|
|
6051
6410
|
["PgUp / PgDn", "Move a page at a time"],
|
|
6052
6411
|
["Enter", "Open the selected item or accept a prompt"],
|
|
6053
6412
|
["r", "Refresh the current tab when a safe grant is available"],
|
|
6413
|
+
["R", "Resynchronize the current tab, ignoring cached validators and backoff"],
|
|
6054
6414
|
["w", "Adjust table column widths"],
|
|
6055
6415
|
["?", "Show the keys (any key closes it)"],
|
|
6056
6416
|
["q / Esc / Ctrl+C", "Quit (Esc leaves width mode)"],
|
|
@@ -6058,22 +6418,39 @@ const KEY_TABLE = [
|
|
|
6058
6418
|
const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
|
|
6059
6419
|
const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
|
|
6060
6420
|
|
|
6421
|
+
// Which bindings survive a short pane, most important first. Named rather than
|
|
6422
|
+
// indexed into KEY_TABLE: the order used to be a list of positions, so adding a
|
|
6423
|
+
// key silently reassigned every entry after it -- which is exactly how `R`
|
|
6424
|
+
// pushed Quit out of the four-row overlay.
|
|
6425
|
+
const HELP_PRIORITY = [
|
|
6426
|
+
"q / Esc / Ctrl+C",
|
|
6427
|
+
"r",
|
|
6428
|
+
"Enter",
|
|
6429
|
+
"Up / Down, j / k",
|
|
6430
|
+
"1 2 3 4",
|
|
6431
|
+
"w",
|
|
6432
|
+
"?",
|
|
6433
|
+
"Left / Right",
|
|
6434
|
+
"Tab / Shift+Tab",
|
|
6435
|
+
"PgUp / PgDn",
|
|
6436
|
+
"R",
|
|
6437
|
+
];
|
|
6438
|
+
|
|
6061
6439
|
function helpLines(maxRows) {
|
|
6062
6440
|
const rows = Math.max(1, Number.isSafeInteger(maxRows) ? maxRows : 1);
|
|
6063
6441
|
const all = keyTableLines();
|
|
6442
|
+
const lineFor = (binding) => all[KEY_TABLE.findIndex(([key]) => key === binding)];
|
|
6064
6443
|
if (all.length <= rows) return all;
|
|
6065
6444
|
if (rows === 1) return [`… ${all.length} keys: gh-glance --help`];
|
|
6066
6445
|
if (rows === 4) {
|
|
6067
6446
|
return [
|
|
6068
|
-
|
|
6069
|
-
|
|
6447
|
+
lineFor("q / Esc / Ctrl+C"),
|
|
6448
|
+
lineFor("r"),
|
|
6070
6449
|
"Ent/jk Open the selected/Move the cursor",
|
|
6071
6450
|
`… ${all.length - 4} more: gh-glance --help`,
|
|
6072
6451
|
];
|
|
6073
6452
|
}
|
|
6074
|
-
const priority =
|
|
6075
|
-
.slice(0, rows - 1)
|
|
6076
|
-
.map((index) => all[index]);
|
|
6453
|
+
const priority = HELP_PRIORITY.slice(0, rows - 1).map(lineFor);
|
|
6077
6454
|
return [...priority, `… ${all.length - priority.length} more: gh-glance --help`];
|
|
6078
6455
|
}
|
|
6079
6456
|
|
|
@@ -6084,6 +6461,7 @@ Usage:
|
|
|
6084
6461
|
gh-glance --repo owner/name Watch a specific repository instead
|
|
6085
6462
|
gh-glance --refresh 15 Set a 15-second active-tab poll floor
|
|
6086
6463
|
gh-glance --tab security Start on a specific tab
|
|
6464
|
+
gh-glance --background off Poll only the tab you are looking at
|
|
6087
6465
|
gh-glance --verbose 2>log Log every gh call to a file (see below)
|
|
6088
6466
|
gh-glance --doctor Print a diagnostic report and exit
|
|
6089
6467
|
gh-glance --help Show this help
|
|
@@ -6098,10 +6476,14 @@ Options:
|
|
|
6098
6476
|
and is unnecessary when running inside a clone of
|
|
6099
6477
|
that repository.
|
|
6100
6478
|
--refresh <seconds> Minimum active-tab poll interval (${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS},
|
|
6101
|
-
default ${REFRESH_MS / 1000}).
|
|
6102
|
-
|
|
6103
|
-
every ${
|
|
6479
|
+
default ${REFRESH_MS / 1000}). A floor, never a ceiling:
|
|
6480
|
+
a quiet tab slows to ${POLL_QUIET_MS.actions / 1000}s (${POLL_QUIET_MS.security / 1000}s for Security),
|
|
6481
|
+
and running Actions are checked every ${POLL_ACTIVE_CI_MS / 1000}s.
|
|
6482
|
+
Safe shared grants may run later.
|
|
6104
6483
|
--tab <name> Tab to start on: ${TAB_KEYS.join(", ")}.
|
|
6484
|
+
--background <mode> ${BACKGROUND_MODES.join(" or ")} (default all). \`off\` never requests data
|
|
6485
|
+
for a tab you are not looking at; its count stays at
|
|
6486
|
+
the last known value and visibly ages.
|
|
6105
6487
|
--verbose Write one line per gh invocation to stderr. stderr
|
|
6106
6488
|
must be redirected -- writing it to the terminal
|
|
6107
6489
|
would draw over the dashboard, so this refuses to
|
|
@@ -6117,10 +6499,12 @@ Run it from inside a locally cloned GitHub repository; the repo is inferred
|
|
|
6117
6499
|
from the git remote, the same way \`gh\` does it. Requires the \`gh\` CLI
|
|
6118
6500
|
(2.20 or newer), authenticated via \`gh auth login\`.
|
|
6119
6501
|
|
|
6120
|
-
On a healthy single pane, the active tab is considered every ${REFRESH_MS / 1000}s
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6502
|
+
On a healthy single pane, the active tab is considered every ${REFRESH_MS / 1000}s while its
|
|
6503
|
+
content is changing, then every ${POLL_QUIET_MS.actions / 1000}s once two checks in a row come back
|
|
6504
|
+
unchanged; each background tab is considered about every ${Math.max(REFRESH_MS * BACKGROUND_EVERY, POLL_BACKGROUND_MS.actions) / 1000}s. Quota-consuming
|
|
6505
|
+
calls still need a shared, resource-specific grant that preserves a hard reserve.
|
|
6506
|
+
Scheduled checks and manual refresh do not bypass that safety check: neither
|
|
6507
|
+
refresh key can spend past the reserve.
|
|
6124
6508
|
|
|
6125
6509
|
Row icons are GitHub Octicons and need a Nerd Font. Without one, set
|
|
6126
6510
|
GH_GLANCE_ICONS=unicode for Unicode status glyphs and text row substitutes, or
|
|
@@ -6195,6 +6579,7 @@ if (IS_MAIN) {
|
|
|
6195
6579
|
runtime.verbose = opts.verbose;
|
|
6196
6580
|
if (opts.refreshMs !== null) runtime.refreshMs = opts.refreshMs;
|
|
6197
6581
|
if (opts.tabKey !== null) runtime.initialTabIndex = TAB_KEYS.indexOf(opts.tabKey);
|
|
6582
|
+
if (opts.background !== null) runtime.background = opts.background;
|
|
6198
6583
|
|
|
6199
6584
|
// Checked after parsing rather than before, so `gh-glance --repo` with no
|
|
6200
6585
|
// value reports the missing value rather than silently printing help.
|
|
@@ -6933,7 +7318,7 @@ const MemoSecurityRow = React.memo(SecurityRow);
|
|
|
6933
7318
|
const TABS = [
|
|
6934
7319
|
{
|
|
6935
7320
|
key: "actions",
|
|
6936
|
-
fetch: ({ signal,
|
|
7321
|
+
fetch: ({ signal, ...options }) => fetchActions(signal, options),
|
|
6937
7322
|
label: "Actions",
|
|
6938
7323
|
short: "Actions",
|
|
6939
7324
|
header: ACTIONS_HEADER,
|
|
@@ -6943,7 +7328,8 @@ const TABS = [
|
|
|
6943
7328
|
},
|
|
6944
7329
|
{
|
|
6945
7330
|
key: "issues",
|
|
6946
|
-
fetch: ({ signal, governor, previousRaw }) =>
|
|
7331
|
+
fetch: ({ signal, governor, previousRaw, pages }) =>
|
|
7332
|
+
fetchIssues(signal, { governor, previousRaw, pages }),
|
|
6947
7333
|
label: "Issues",
|
|
6948
7334
|
short: "Issues",
|
|
6949
7335
|
header: ISSUES_HEADER,
|
|
@@ -6953,7 +7339,8 @@ const TABS = [
|
|
|
6953
7339
|
},
|
|
6954
7340
|
{
|
|
6955
7341
|
key: "prs",
|
|
6956
|
-
fetch: ({ signal, governor, previousRaw }) =>
|
|
7342
|
+
fetch: ({ signal, governor, previousRaw, pages }) =>
|
|
7343
|
+
fetchPRs(signal, { governor, previousRaw, pages }),
|
|
6957
7344
|
label: "Pull requests",
|
|
6958
7345
|
short: "PRs",
|
|
6959
7346
|
header: PRS_HEADER,
|
|
@@ -7788,19 +8175,92 @@ function nextSecurityRaw(previousRaw, raw, blind) {
|
|
|
7788
8175
|
}
|
|
7789
8176
|
|
|
7790
8177
|
const CACHE_FRESHNESS_CHECKPOINT_MS = 60_000;
|
|
7791
|
-
const SECURITY_UNCHANGED_POLL_MS = 60_000;
|
|
7792
8178
|
|
|
7793
8179
|
function shouldCheckpointFreshness({ persistedAt, completedAt }) {
|
|
7794
8180
|
return !Number.isFinite(persistedAt) || completedAt - persistedAt >= CACHE_FRESHNESS_CHECKPOINT_MS;
|
|
7795
8181
|
}
|
|
7796
8182
|
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
8183
|
+
// The one place the cadence table is decided. It used to be spread across a
|
|
8184
|
+
// fixed active floor, a fixed four-floor background slot, and a Security-only
|
|
8185
|
+
// unchanged rule -- three policies that could disagree, and did: Security
|
|
8186
|
+
// slowed after a single unchanged poll while every other tab never slowed at
|
|
8187
|
+
// all. Everything now derives from this function, so a resource's cadence is
|
|
8188
|
+
// stated once and read everywhere.
|
|
8189
|
+
function pollPolicyInterval({
|
|
8190
|
+
tab,
|
|
8191
|
+
floorMs,
|
|
8192
|
+
demand = "active",
|
|
8193
|
+
unchangedCount = 0,
|
|
8194
|
+
inProgressCI = false,
|
|
8195
|
+
background = "all",
|
|
8196
|
+
}) {
|
|
8197
|
+
if (demand === "none") return Number.POSITIVE_INFINITY;
|
|
8198
|
+
if (demand === "inactive") {
|
|
8199
|
+
if (background === "off") return Number.POSITIVE_INFINITY;
|
|
8200
|
+
return Math.max(BACKGROUND_EVERY * floorMs, pick(POLL_BACKGROUND_MS, tab, 0));
|
|
8201
|
+
}
|
|
8202
|
+
// Work in flight outranks the quiet counter: a repository whose list has not
|
|
8203
|
+
// changed in an hour still has a run finishing right now.
|
|
8204
|
+
if (tab === "actions" && inProgressCI === true) return Math.max(floorMs, POLL_ACTIVE_CI_MS);
|
|
8205
|
+
if (unchangedCount >= POLL_QUIET_AFTER) return Math.max(floorMs, pick(POLL_QUIET_MS, tab, 0));
|
|
8206
|
+
return floorMs;
|
|
8207
|
+
}
|
|
8208
|
+
|
|
8209
|
+
// Only a validated observation moves this counter. An error, a blind Security
|
|
8210
|
+
// source, or an unusable payload is the absence of evidence, not evidence of
|
|
8211
|
+
// quiet -- counting them would slow a tab precisely because it is broken.
|
|
8212
|
+
function advanceUnchangedCount(previous, outcome) {
|
|
8213
|
+
const current = Number.isSafeInteger(previous) && previous > 0 ? previous : 0;
|
|
8214
|
+
if (outcome === "unchanged") return current + 1;
|
|
8215
|
+
if (outcome === "changed" || outcome === "subscribed") return 0;
|
|
8216
|
+
return current;
|
|
8217
|
+
}
|
|
8218
|
+
|
|
8219
|
+
// Two manual intentions, deliberately separated. `r` used to mean both "check
|
|
8220
|
+
// now" and "throw away every validator", so the key that a user presses on a
|
|
8221
|
+
// quiet repository -- the one case where a conditional check is free -- was the
|
|
8222
|
+
// key guaranteed to spend.
|
|
8223
|
+
function refreshIntent(input, { widthMode = false } = {}) {
|
|
8224
|
+
// Width mode owns both keys: `r` resets the selected column and `R` resets
|
|
8225
|
+
// the tab's widths. Leaking a refresh into either would make a layout action
|
|
8226
|
+
// spend quota.
|
|
8227
|
+
if (widthMode === true) return null;
|
|
8228
|
+
if (input === "r") return "conditional";
|
|
8229
|
+
if (input === "R") return "resync";
|
|
8230
|
+
return null;
|
|
7800
8231
|
}
|
|
7801
8232
|
|
|
7802
|
-
function
|
|
7803
|
-
|
|
8233
|
+
function manualRefreshRequest(intent) {
|
|
8234
|
+
if (intent === "conditional") {
|
|
8235
|
+
return { kind: "manual", force: false, dropValidators: false, clearCapabilityBackoff: false };
|
|
8236
|
+
}
|
|
8237
|
+
if (intent === "resync") {
|
|
8238
|
+
return { kind: "manual", force: true, dropValidators: true, clearCapabilityBackoff: true };
|
|
8239
|
+
}
|
|
8240
|
+
return null;
|
|
8241
|
+
}
|
|
8242
|
+
|
|
8243
|
+
// A press during automatic work either rides that work or schedules exactly one
|
|
8244
|
+
// follow-up. Without the follow-up, pressing `r` while a poll that started
|
|
8245
|
+
// before the press was still running silently did nothing.
|
|
8246
|
+
function planManualRefresh({ requestedAt, inFlightStartedAt = null, followUpPending = false }) {
|
|
8247
|
+
if (!Number.isFinite(inFlightStartedAt)) return { join: false, start: true, followUp: false };
|
|
8248
|
+
if (inFlightStartedAt >= requestedAt) return { join: true, start: false, followUp: false };
|
|
8249
|
+
return { join: followUpPending, start: false, followUp: !followUpPending };
|
|
8250
|
+
}
|
|
8251
|
+
|
|
8252
|
+
// A 304 whose entity is gone is a broken pair, not data. One unconditional
|
|
8253
|
+
// recovery, separately admitted, and then the tab says so -- because the
|
|
8254
|
+
// alternative is a conditional request that can never be satisfied, retried at
|
|
8255
|
+
// the poll cadence forever.
|
|
8256
|
+
function conditionalRecoveryPlan({ status, entity, hasEntity = typeof entity === "string" && entity.length > 0, attempts = 0 }) {
|
|
8257
|
+
if (status !== 304 || hasEntity === true) return { recover: false, unusable: false };
|
|
8258
|
+
if (!Number.isSafeInteger(attempts) || attempts <= 0) return { recover: true, unusable: false };
|
|
8259
|
+
return { recover: false, unusable: true };
|
|
8260
|
+
}
|
|
8261
|
+
|
|
8262
|
+
function shouldShowFetchLoading({ hasData, manual }) {
|
|
8263
|
+
return !hasData || manual === true;
|
|
7804
8264
|
}
|
|
7805
8265
|
|
|
7806
8266
|
function tabHold(tab, heldResources = {}) {
|
|
@@ -7820,82 +8280,92 @@ function tabHold(tab, heldResources = {}) {
|
|
|
7820
8280
|
};
|
|
7821
8281
|
}
|
|
7822
8282
|
|
|
8283
|
+
function rotateFrom(keys, index) {
|
|
8284
|
+
if (keys.length === 0) return keys;
|
|
8285
|
+
const start = ((Number.isSafeInteger(index) ? index : 0) % keys.length + keys.length) % keys.length;
|
|
8286
|
+
return keys.map((_, offset) => keys[(start + offset) % keys.length]);
|
|
8287
|
+
}
|
|
8288
|
+
|
|
8289
|
+
// Every tab now carries its own deadline rather than sharing one active slot
|
|
8290
|
+
// and one rotating background slot. That is what lets the cadence differ per
|
|
8291
|
+
// resource: a quiet Security tab at 300s and an Actions tab watching a running
|
|
8292
|
+
// job at 5s cannot be expressed by two shared timers.
|
|
7823
8293
|
function pollSchedule({
|
|
7824
8294
|
nowMs,
|
|
7825
8295
|
floorMs,
|
|
7826
8296
|
activeKey,
|
|
7827
|
-
|
|
7828
|
-
backgroundAt = nowMs + 4 * floorMs,
|
|
8297
|
+
dueAt = {},
|
|
7829
8298
|
backgroundIndex = 0,
|
|
7830
8299
|
heldResources = {},
|
|
8300
|
+
background = "all",
|
|
8301
|
+
states = {},
|
|
7831
8302
|
}) {
|
|
7832
8303
|
const due = [];
|
|
7833
|
-
|
|
7834
|
-
|
|
8304
|
+
const next = {};
|
|
8305
|
+
const backgroundKeys = TAB_KEYS.filter((key) => key !== activeKey);
|
|
7835
8306
|
let nextBackgroundIndex = backgroundIndex;
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
|
|
7842
|
-
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
|
|
7848
|
-
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
7854
|
-
}
|
|
8307
|
+
// At most one inactive tab per wake, still round robin. Three inactive tabs
|
|
8308
|
+
// all becoming due together would otherwise open three concurrent panes of
|
|
8309
|
+
// demand against one lane slot.
|
|
8310
|
+
let backgroundTaken = false;
|
|
8311
|
+
|
|
8312
|
+
for (const key of [activeKey, ...rotateFrom(backgroundKeys, backgroundIndex)]) {
|
|
8313
|
+
const isActive = key === activeKey;
|
|
8314
|
+
const step = pollPolicyInterval({
|
|
8315
|
+
tab: key,
|
|
8316
|
+
floorMs,
|
|
8317
|
+
demand: isActive ? "active" : "inactive",
|
|
8318
|
+
unchangedCount: states[key]?.unchangedCount ?? 0,
|
|
8319
|
+
inProgressCI: states[key]?.inProgressCI === true,
|
|
8320
|
+
background,
|
|
8321
|
+
});
|
|
8322
|
+
if (!Number.isFinite(step)) {
|
|
8323
|
+
next[key] = Number.POSITIVE_INFINITY;
|
|
8324
|
+
continue;
|
|
7855
8325
|
}
|
|
7856
|
-
|
|
7857
|
-
|
|
7858
|
-
|
|
7859
|
-
|
|
7860
|
-
}
|
|
7861
|
-
|
|
7862
|
-
|
|
7863
|
-
|
|
7864
|
-
|
|
8326
|
+
const at = Number.isFinite(dueAt[key]) ? dueAt[key] : nowMs;
|
|
8327
|
+
if (at > nowMs) {
|
|
8328
|
+
next[key] = at;
|
|
8329
|
+
continue;
|
|
8330
|
+
}
|
|
8331
|
+
const hold = tabHold(key, heldResources);
|
|
8332
|
+
if (hold.held) {
|
|
8333
|
+
next[key] = hold.retryAt;
|
|
8334
|
+
continue;
|
|
8335
|
+
}
|
|
8336
|
+
if (!isActive) {
|
|
8337
|
+
if (backgroundTaken) {
|
|
8338
|
+
next[key] = at;
|
|
8339
|
+
continue;
|
|
8340
|
+
}
|
|
8341
|
+
backgroundTaken = true;
|
|
8342
|
+
nextBackgroundIndex = (backgroundKeys.indexOf(key) + 1) % backgroundKeys.length;
|
|
7865
8343
|
}
|
|
8344
|
+
due.push({ key, kind: isActive ? "active" : "background" });
|
|
8345
|
+
next[key] = nowMs + step;
|
|
7866
8346
|
}
|
|
7867
8347
|
return {
|
|
7868
8348
|
due,
|
|
7869
|
-
|
|
7870
|
-
backgroundAt: nextBackgroundAt,
|
|
8349
|
+
dueAt: next,
|
|
7871
8350
|
backgroundIndex: nextBackgroundIndex,
|
|
7872
|
-
nextAt: Math.min(
|
|
8351
|
+
nextAt: Math.min(...Object.values(next)),
|
|
7873
8352
|
};
|
|
7874
8353
|
}
|
|
7875
8354
|
|
|
7876
8355
|
function retryPollAfterAdmissionFailure({
|
|
8356
|
+
key,
|
|
7877
8357
|
kind,
|
|
7878
8358
|
retryAt,
|
|
7879
|
-
|
|
7880
|
-
backgroundAt,
|
|
8359
|
+
dueAt,
|
|
7881
8360
|
backgroundIndex,
|
|
7882
8361
|
previousBackgroundIndex,
|
|
7883
8362
|
}) {
|
|
7884
|
-
|
|
7885
|
-
|
|
7886
|
-
|
|
7887
|
-
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
}
|
|
7891
|
-
if (kind === "background") {
|
|
7892
|
-
return {
|
|
7893
|
-
activeAt,
|
|
7894
|
-
backgroundAt: Math.min(backgroundAt, retryAt),
|
|
7895
|
-
backgroundIndex: previousBackgroundIndex,
|
|
7896
|
-
};
|
|
7897
|
-
}
|
|
7898
|
-
return { activeAt, backgroundAt, backgroundIndex };
|
|
8363
|
+
const previous = Number.isFinite(dueAt?.[key]) ? dueAt[key] : Number.POSITIVE_INFINITY;
|
|
8364
|
+
return {
|
|
8365
|
+
dueAt: { ...dueAt, [key]: Math.min(previous, retryAt) },
|
|
8366
|
+
// A background tab that could not be admitted did not consume its turn.
|
|
8367
|
+
backgroundIndex: kind === "background" ? previousBackgroundIndex : backgroundIndex,
|
|
8368
|
+
};
|
|
7899
8369
|
}
|
|
7900
8370
|
|
|
7901
8371
|
function governorWakeTimes(state, nowMs, floorMs, leaseId = null) {
|
|
@@ -8175,12 +8645,55 @@ function rateLimitBlockProbeRecovered(pending, state, nowMs) {
|
|
|
8175
8645
|
});
|
|
8176
8646
|
}
|
|
8177
8647
|
|
|
8178
|
-
|
|
8179
|
-
|
|
8180
|
-
|
|
8181
|
-
|
|
8182
|
-
|
|
8648
|
+
// How long a separately admitted operation may wait for a lane slot the
|
|
8649
|
+
// governor has already granted it. Every request inside one tab fetch asks a
|
|
8650
|
+
// moment after the tab's own grant advanced the lane, so without this the second
|
|
8651
|
+
// request -- a later list page, or the workflow catalog -- is refused every
|
|
8652
|
+
// single time and the feature never runs at all. A named `notBefore` is a
|
|
8653
|
+
// schedule, not a refusal; anything past this bound still declines, and
|
|
8654
|
+
// startReservation revalidates the budget when the wait is over.
|
|
8655
|
+
const GOVERNOR_ADMISSION_WAIT_MS = 2_000;
|
|
8656
|
+
|
|
8657
|
+
async function runAdmittedOperation({
|
|
8658
|
+
scope,
|
|
8659
|
+
leaseId,
|
|
8660
|
+
operation,
|
|
8661
|
+
priority = "manual",
|
|
8662
|
+
signal,
|
|
8663
|
+
run,
|
|
8664
|
+
waitMs = 0,
|
|
8665
|
+
// One clock for the whole admission, so the wait below is measured against
|
|
8666
|
+
// the same time the governor scheduled the slot on.
|
|
8667
|
+
now = Date.now,
|
|
8668
|
+
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
8669
|
+
}) {
|
|
8670
|
+
let admitted = admitGovernorOperation(scope, leaseId, operation, priority, now());
|
|
8671
|
+
const scheduled = admitted.ok &&
|
|
8672
|
+
["scheduled", "waiting"].includes(admitted.value?.status) &&
|
|
8673
|
+
typeof admitted.value.reservationId === "string" &&
|
|
8674
|
+
Number.isFinite(admitted.value.notBefore)
|
|
8675
|
+
? admitted.value
|
|
8676
|
+
: null;
|
|
8677
|
+
if (waitMs > 0 && scheduled) {
|
|
8678
|
+
// The named slot can already have arrived while the intent was being
|
|
8679
|
+
// persisted, so a delay that is gone is retried at once rather than treated
|
|
8680
|
+
// as a refusal -- that race alone made this path intermittent.
|
|
8681
|
+
const delay = scheduled.notBefore - now();
|
|
8682
|
+
if (delay <= waitMs) {
|
|
8683
|
+
if (delay > 0) await wait(delay);
|
|
8684
|
+
if (!signal?.aborted) {
|
|
8685
|
+
admitted = startReservation(scope, scheduled.reservationId, now());
|
|
8686
|
+
}
|
|
8183
8687
|
}
|
|
8688
|
+
}
|
|
8689
|
+
if (!admitted.ok || admitted.value.status !== "started") {
|
|
8690
|
+
// The reservation is named by its intent, so a slot this call decided not
|
|
8691
|
+
// to take is released rather than left scheduled against the budget.
|
|
8692
|
+
const abandoned = admitted.value?.intentId ??
|
|
8693
|
+
(typeof admitted.value?.reservationId === "string"
|
|
8694
|
+
? admitted.value.reservationId.slice(12)
|
|
8695
|
+
: scheduled?.reservationId?.slice(12));
|
|
8696
|
+
if (validGovernorId(abandoned)) cancelIntent(scope, abandoned, now());
|
|
8184
8697
|
const detail = admitted.value?.resetMs
|
|
8185
8698
|
? ` until ${new Date(admitted.value.resetMs).toISOString()}`
|
|
8186
8699
|
: admitted.value?.notBefore ? ` until ${new Date(admitted.value.notBefore).toISOString()}` : "";
|
|
@@ -8194,11 +8707,11 @@ async function runAdmittedOperation({ scope, leaseId, operation, priority = "man
|
|
|
8194
8707
|
completeReservation(settlementScope, reservationId, {
|
|
8195
8708
|
outcome: "measured-success",
|
|
8196
8709
|
actualCost: costs,
|
|
8197
|
-
},
|
|
8710
|
+
}, now());
|
|
8198
8711
|
if (scope.accessKey && scope.accessKey !== runtimeIdentityCoordinator?.current()?.accessKey) return { ok: false, error: new Error("Credential changed"), reservationId };
|
|
8199
8712
|
return { ok: true, value, reservationId };
|
|
8200
8713
|
} catch (error) {
|
|
8201
|
-
completeReservation(settlementScope, reservationId, { outcome: governorOutcomeForError(error) },
|
|
8714
|
+
completeReservation(settlementScope, reservationId, { outcome: governorOutcomeForError(error) }, now());
|
|
8202
8715
|
return { ok: false, error, reservationId };
|
|
8203
8716
|
}
|
|
8204
8717
|
}
|
|
@@ -8884,6 +9397,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8884
9397
|
);
|
|
8885
9398
|
const dataRef = useRef(data);
|
|
8886
9399
|
dataRef.current = data;
|
|
9400
|
+
// The workflow catalog, kept across polls so a repository whose runs carry no
|
|
9401
|
+
// name of their own asks for it once every fifteen minutes rather than twice
|
|
9402
|
+
// per poll. A ref because it must not redraw anything by itself.
|
|
9403
|
+
const workflowCatalogRef = useRef(null);
|
|
9404
|
+
// What each paged tab has actually loaded, and whether more exists. Demand is
|
|
9405
|
+
// a page count so that holding `j` near the end asks for one more page rather
|
|
9406
|
+
// than one per keystroke.
|
|
9407
|
+
const pageStateRef = useRef({});
|
|
8887
9408
|
const [meta, setMeta] = useState(() =>
|
|
8888
9409
|
Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.meta ?? null])),
|
|
8889
9410
|
);
|
|
@@ -8969,8 +9490,6 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8969
9490
|
// Read at fetch time rather than being a hook dependency, so dragging the
|
|
8970
9491
|
// pane wider doesn't cancel and restart in-flight requests -- the next tick
|
|
8971
9492
|
// simply asks for the new size.
|
|
8972
|
-
const runLimitRef = useRef(0);
|
|
8973
|
-
runLimitRef.current = Math.min(Math.max(bodyRows + 1, MIN_RUN_LIMIT), MAX_RUN_LIMIT);
|
|
8974
9493
|
|
|
8975
9494
|
// Read inside the polling closure, which is created once on mount and must
|
|
8976
9495
|
// not be torn down and rebuilt every time you press a tab key.
|
|
@@ -9012,7 +9531,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9012
9531
|
|
|
9013
9532
|
// useInput's handler is created before the render body computes the visible
|
|
9014
9533
|
// slice, so the movement handlers read through a ref -- the same pattern the
|
|
9015
|
-
// poll loop uses for
|
|
9534
|
+
// poll loop uses for activeIndexRef, and for the same reason:
|
|
9016
9535
|
// the closure must see current values without being rebuilt on every change.
|
|
9017
9536
|
const navRef = useRef({ items: [], key: null, bodyRows: 1, tabKey: "actions", offset: 0 });
|
|
9018
9537
|
navRef.current = {
|
|
@@ -9234,6 +9753,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9234
9753
|
: Math.min(items.length - 1, start + rows_ - 1)
|
|
9235
9754
|
: Math.min(items.length - 1, Math.max(0, current + delta));
|
|
9236
9755
|
setSelected((s) => ({ ...s, [tabKey]: itemKey(items[next]) }));
|
|
9756
|
+
demandPages(tabKey, next, items.length);
|
|
9237
9757
|
// Keep the cursor on screen. Only the offset needed to reveal it changes,
|
|
9238
9758
|
// so scrolling never jumps further than it has to.
|
|
9239
9759
|
setOffset((o) => {
|
|
@@ -9246,6 +9766,24 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9246
9766
|
});
|
|
9247
9767
|
}
|
|
9248
9768
|
|
|
9769
|
+
// A cursor within PAGE_DEMAND_THRESHOLD rows of the end is the only thing
|
|
9770
|
+
// that asks for another page. Demand is a count rather than an event, so ten
|
|
9771
|
+
// keystrokes near the bottom acquire one page, and a tab nobody scrolled
|
|
9772
|
+
// never acquires a second one at all.
|
|
9773
|
+
function demandPages(tabKey, selectedIndex, loadedRows) {
|
|
9774
|
+
const state = pageStateRef.current[tabKey];
|
|
9775
|
+
if (!state) return;
|
|
9776
|
+
const wanted = demandedPageCount(state.pages, {
|
|
9777
|
+
selectedIndex,
|
|
9778
|
+
loadedRows,
|
|
9779
|
+
hasNextPage: state.hasNextPage,
|
|
9780
|
+
});
|
|
9781
|
+
if (wanted <= state.pages) return;
|
|
9782
|
+
state.pages = wanted;
|
|
9783
|
+
// Manual priority: the user is scrolling toward rows that are not loaded.
|
|
9784
|
+
fetchTabRef.current?.(tabKey, { kind: "manual" });
|
|
9785
|
+
}
|
|
9786
|
+
|
|
9249
9787
|
// Rearms on every call to moveSelection (it's the only thing that changes
|
|
9250
9788
|
// `selected`), so this fires exactly 60s after the *last* movement -- tab
|
|
9251
9789
|
// switches and Enter don't count as activity and don't push it back. Clears
|
|
@@ -9253,7 +9791,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9253
9791
|
// switch back to after being idle doesn't still show a stale row marked.
|
|
9254
9792
|
useEffect(() => {
|
|
9255
9793
|
if (Object.keys(selected).length === 0) return;
|
|
9256
|
-
const timer = setTimeout(() =>
|
|
9794
|
+
const timer = setTimeout(() => {
|
|
9795
|
+
setSelected({});
|
|
9796
|
+
// Extra pages are held for a cursor. With no cursor there is nothing to
|
|
9797
|
+
// scroll toward, so they stop being refreshed on the next poll.
|
|
9798
|
+
for (const state of Object.values(pageStateRef.current)) state.pages = 1;
|
|
9799
|
+
}, SELECTION_IDLE_MS);
|
|
9257
9800
|
return () => clearTimeout(timer);
|
|
9258
9801
|
}, [selected]);
|
|
9259
9802
|
|
|
@@ -9341,13 +9884,16 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9341
9884
|
} else if (key.return) {
|
|
9342
9885
|
if (remoteSetupRef.current) onCreateRemote(suspendTerminal);
|
|
9343
9886
|
else openSelected();
|
|
9344
|
-
} else if (input === "r") {
|
|
9887
|
+
} else if (input === "r" || input === "R") {
|
|
9345
9888
|
// Goes through the same per-tab in-flight guard as the poll loop, so
|
|
9346
|
-
// holding the key down cannot stack concurrent subprocesses.
|
|
9347
|
-
//
|
|
9348
|
-
// "try again now", and a refresh that silently declined to
|
|
9349
|
-
// be worse than no key at all.
|
|
9350
|
-
|
|
9889
|
+
// holding the key down cannot stack concurrent subprocesses. Both keys
|
|
9890
|
+
// bypass the scheduled deadline and the tab's failure ladder -- the user
|
|
9891
|
+
// is saying "try again now", and a refresh that silently declined to
|
|
9892
|
+
// refresh would be worse than no key at all. Only `R` also drops the
|
|
9893
|
+
// validators: `r` on a quiet repository answers 304 and costs nothing,
|
|
9894
|
+
// which is exactly when the key is most likely to be pressed.
|
|
9895
|
+
const request = manualRefreshRequest(refreshIntent(input));
|
|
9896
|
+
if (request) fetchTabRef.current?.(TABS[activeIndexRef.current].key, request);
|
|
9351
9897
|
} else if (input >= "1" && input <= String(TABS.length)) {
|
|
9352
9898
|
setActiveIndex(Number(input) - 1);
|
|
9353
9899
|
} else if (key.tab && key.shift) {
|
|
@@ -9428,8 +9974,6 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9428
9974
|
// a Promise.allSettled barrier. Actions is by far the slowest fetch, so
|
|
9429
9975
|
// barrelling everything together meant the three fast tabs sat invisible
|
|
9430
9976
|
// behind it and nothing at all appeared until the slowest call returned.
|
|
9431
|
-
let securityUnchangedPolls = 0;
|
|
9432
|
-
let securityNextPollAt = 0;
|
|
9433
9977
|
|
|
9434
9978
|
function setTabGovernorDecision(key, decision) {
|
|
9435
9979
|
setGovernorDecisions((current) => sameVisibleGovernorDecision(current[key], decision)
|
|
@@ -9480,6 +10024,18 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9480
10024
|
});
|
|
9481
10025
|
}
|
|
9482
10026
|
|
|
10027
|
+
// A folder with no repository target goes straight to setup. The no-remote
|
|
10028
|
+
// verdict is normally a fetch failure, but nothing can be fetched here --
|
|
10029
|
+
// there is no host to verify against -- so the same verdict is recorded on
|
|
10030
|
+
// every tab before coordination is attempted. remoteSetupRef then keeps the
|
|
10031
|
+
// scheduler out, and the Enter hint hands off to `gh repo create`.
|
|
10032
|
+
function enterRemoteSetup() {
|
|
10033
|
+
if (!runtimeHasNoRepositoryTarget()) return false;
|
|
10034
|
+
const failure = toTabError(new Error(NO_REMOTE_ERROR_TEXT));
|
|
10035
|
+
setErrors(Object.fromEntries(TABS.map((t) => [t.key, failure])));
|
|
10036
|
+
return true;
|
|
10037
|
+
}
|
|
10038
|
+
|
|
9483
10039
|
function publishControlStatus(key, refreshed, snapshot, nowMs) {
|
|
9484
10040
|
if (pendingBlockPublications.size > 0) {
|
|
9485
10041
|
pauseCoordination(key, "block-unpublished");
|
|
@@ -9542,6 +10098,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9542
10098
|
|
|
9543
10099
|
function commit(key, run, {
|
|
9544
10100
|
force = false,
|
|
10101
|
+
manual = false,
|
|
9545
10102
|
scope,
|
|
9546
10103
|
reservationId,
|
|
9547
10104
|
admittedAt,
|
|
@@ -9556,7 +10113,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9556
10113
|
const settlementScope = settlement.scope;
|
|
9557
10114
|
const currentAccess = settlement.isCurrent;
|
|
9558
10115
|
inFlightRef.current[key] = true;
|
|
9559
|
-
|
|
10116
|
+
inFlightStartedAt[key] = admittedAt;
|
|
10117
|
+
if (manual) manualInFlight.add(key);
|
|
9560
10118
|
admittedCadenceRef.current[key] = nextAdmittedCadence(
|
|
9561
10119
|
admittedCadenceRef.current[key],
|
|
9562
10120
|
admittedAt,
|
|
@@ -9564,7 +10122,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9564
10122
|
clearForcedBackoffAfterStart(key, force, "started");
|
|
9565
10123
|
const visibleLoading = shouldShowFetchLoading({
|
|
9566
10124
|
hasData: dataRef.current[key] !== null,
|
|
9567
|
-
|
|
10125
|
+
manual,
|
|
9568
10126
|
});
|
|
9569
10127
|
if (visibleLoading) setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
|
|
9570
10128
|
const retainDeferredHold = retainDeferredGovernorHold({
|
|
@@ -9632,15 +10190,21 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9632
10190
|
limit,
|
|
9633
10191
|
completedAt,
|
|
9634
10192
|
});
|
|
9635
|
-
|
|
9636
|
-
|
|
9637
|
-
|
|
9638
|
-
|
|
9639
|
-
|
|
9640
|
-
|
|
9641
|
-
|
|
9642
|
-
|
|
9643
|
-
|
|
10193
|
+
// The cadence follows the observation, not the tab: an error or an
|
|
10194
|
+
// unusable payload leaves the counter where it was, so a broken tab
|
|
10195
|
+
// is never slowed down for looking quiet.
|
|
10196
|
+
unchangedPolls[key] = advanceUnchangedCount(unchangedPolls[key], transition.kind);
|
|
10197
|
+
// Recomputed now that the outcome is known: the schedule chose this
|
|
10198
|
+
// tab's deadline before the observation that decides its cadence.
|
|
10199
|
+
rescheduleTab(key, admittedAt, {
|
|
10200
|
+
actionRows: key === "actions" && transition.kind === "changed" ? transition.data : undefined,
|
|
10201
|
+
});
|
|
10202
|
+
if (result?.catalog) workflowCatalogRef.current = result.catalog;
|
|
10203
|
+
if (Number.isFinite(result?.loadedPages)) {
|
|
10204
|
+
pageStateRef.current[key] = {
|
|
10205
|
+
pages: result.loadedPages,
|
|
10206
|
+
hasNextPage: result.hasNextPage === true,
|
|
10207
|
+
};
|
|
9644
10208
|
}
|
|
9645
10209
|
publishStagedEntities(entityRef.current, stagedEntities, transition.kind);
|
|
9646
10210
|
if (transition.kind === "unchanged") {
|
|
@@ -9674,7 +10238,6 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9674
10238
|
// so the next admitted tick parses its response instead of taking the
|
|
9675
10239
|
// identical-output fast path.
|
|
9676
10240
|
if (transition.kind === "unusable") {
|
|
9677
|
-
if (key === "security") securityNextPollAt = 0;
|
|
9678
10241
|
rawRef.current[key] = transition.nextRaw;
|
|
9679
10242
|
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
9680
10243
|
return;
|
|
@@ -9747,6 +10310,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9747
10310
|
})
|
|
9748
10311
|
.finally(() => {
|
|
9749
10312
|
inFlightRef.current[key] = false;
|
|
10313
|
+
delete inFlightStartedAt[key];
|
|
9750
10314
|
if (!currentAccess() && !Object.values(inFlightRef.current).some(Boolean) && runtimeIdentityCoordinator?.current()?.quotaKey !== settlementScope.quotaKey) releaseLease(settlementScope, leaseId);
|
|
9751
10315
|
manualInFlight.delete(key);
|
|
9752
10316
|
onSettled?.();
|
|
@@ -9764,19 +10328,27 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9764
10328
|
: current);
|
|
9765
10329
|
}
|
|
9766
10330
|
}
|
|
9767
|
-
|
|
9768
|
-
|
|
10331
|
+
const queued = queuedManual.get(key);
|
|
10332
|
+
if (!cancelled && queued) {
|
|
10333
|
+
queuedManual.delete(key);
|
|
10334
|
+
void requestTab(key, "manual", { force: queued.force });
|
|
9769
10335
|
}
|
|
9770
10336
|
});
|
|
9771
10337
|
}
|
|
9772
10338
|
|
|
9773
10339
|
const leaseId = governorId();
|
|
9774
10340
|
const pending = new Map();
|
|
9775
|
-
// One manual handoff per tab
|
|
9776
|
-
// not be lost, but repeated keypresses
|
|
9777
|
-
// must not create a trailing second batch.
|
|
9778
|
-
|
|
10341
|
+
// One manual handoff per tab, carrying which key produced it. A keypress
|
|
10342
|
+
// during an automatic request must not be lost, but repeated keypresses
|
|
10343
|
+
// during the manual request itself must not create a trailing second batch.
|
|
10344
|
+
// The intention has to travel with the handoff: replaying every queued press
|
|
10345
|
+
// as a resynchronization made `r` drop validators whenever it happened to
|
|
10346
|
+
// land while an automatic poll was in flight.
|
|
10347
|
+
const queuedManual = new Map();
|
|
9779
10348
|
const manualInFlight = new Set();
|
|
10349
|
+
// When the work currently in flight for each tab was admitted, so a press
|
|
10350
|
+
// can tell an acquisition that already answers it from one that predates it.
|
|
10351
|
+
const inFlightStartedAt = {};
|
|
9780
10352
|
const wakeScheduler = createWakeScheduler();
|
|
9781
10353
|
let scope = null;
|
|
9782
10354
|
let cleanupScope = null;
|
|
@@ -9788,11 +10360,66 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9788
10360
|
let remoteUrls = [];
|
|
9789
10361
|
let liveScheduling = false;
|
|
9790
10362
|
let controlEpochs = null;
|
|
9791
|
-
|
|
9792
|
-
|
|
10363
|
+
// One deadline per tab, not one active slot and one rotating background
|
|
10364
|
+
// slot. Two shared timers cannot express a Security tab quiet at 300s next
|
|
10365
|
+
// to an Actions tab watching a running job at 5s, which is the whole point
|
|
10366
|
+
// of the cadence table.
|
|
10367
|
+
const NEVER_DUE = Object.fromEntries(TAB_KEYS.map((key) => [key, Number.POSITIVE_INFINITY]));
|
|
10368
|
+
let pollDueAt = { ...NEVER_DUE };
|
|
9793
10369
|
let backgroundIndex = 0;
|
|
10370
|
+
// Only the unchanged run counts live here. Whether Actions has work in
|
|
10371
|
+
// flight is read from the rows themselves, so there is no second copy of it
|
|
10372
|
+
// to fall out of step with what is on screen.
|
|
10373
|
+
const unchangedPolls = Object.fromEntries(TAB_KEYS.map((key) => [key, 0]));
|
|
9794
10374
|
let pendingBlockPublications = new Map();
|
|
9795
10375
|
|
|
10376
|
+
// Rows are the only record of whether CI is busy, so this reads them rather
|
|
10377
|
+
// than keeping a second copy that can fall out of step with the screen. A
|
|
10378
|
+
// caller that has just parsed newer rows passes them, because dataRef only
|
|
10379
|
+
// catches up on the next render.
|
|
10380
|
+
function actionsInProgress(rows = dataRef.current.actions) {
|
|
10381
|
+
return Array.isArray(rows) && rows.some((row) => row.status !== "completed");
|
|
10382
|
+
}
|
|
10383
|
+
|
|
10384
|
+
function pollIntervalFor(key, activeKey, { actionRows } = {}) {
|
|
10385
|
+
return pollPolicyInterval({
|
|
10386
|
+
tab: key,
|
|
10387
|
+
floorMs: runtime.refreshMs,
|
|
10388
|
+
demand: key === activeKey ? "active" : "inactive",
|
|
10389
|
+
unchangedCount: unchangedPolls[key],
|
|
10390
|
+
inProgressCI: key === "actions" && actionsInProgress(actionRows),
|
|
10391
|
+
background: runtime.background,
|
|
10392
|
+
});
|
|
10393
|
+
}
|
|
10394
|
+
|
|
10395
|
+
function rescheduleTab(key, at, options = {}) {
|
|
10396
|
+
const step = pollIntervalFor(key, TABS[activeIndexRef.current].key, options);
|
|
10397
|
+
pollDueAt = {
|
|
10398
|
+
...pollDueAt,
|
|
10399
|
+
[key]: Number.isFinite(step) ? at + step : Number.POSITIVE_INFINITY,
|
|
10400
|
+
};
|
|
10401
|
+
}
|
|
10402
|
+
|
|
10403
|
+
// Polling opens with the active tab immediately and the inactive ones
|
|
10404
|
+
// staggered one background slot apart, exactly as the single rotating slot
|
|
10405
|
+
// used to place them. Opening them all at the same deadline would start
|
|
10406
|
+
// three fetches within one wake -- the herd the rotation exists to prevent,
|
|
10407
|
+
// and it only diverges again once each tab has taken its own interval.
|
|
10408
|
+
function openPollDeadlines(at) {
|
|
10409
|
+
const activeKey = TABS[activeIndexRef.current].key;
|
|
10410
|
+
const opened = { ...NEVER_DUE };
|
|
10411
|
+
opened[activeKey] = at + 1;
|
|
10412
|
+
let slot = 1;
|
|
10413
|
+
for (const key of TAB_KEYS) {
|
|
10414
|
+
if (key === activeKey) continue;
|
|
10415
|
+
opened[key] = Number.isFinite(pollIntervalFor(key, activeKey))
|
|
10416
|
+
? at + slot * 4 * runtime.refreshMs
|
|
10417
|
+
: Number.POSITIVE_INFINITY;
|
|
10418
|
+
slot += 1;
|
|
10419
|
+
}
|
|
10420
|
+
pollDueAt = opened;
|
|
10421
|
+
}
|
|
10422
|
+
|
|
9796
10423
|
function armWake(kind, at, run) {
|
|
9797
10424
|
if (!cancelled) wakeScheduler.arm(kind, at, run);
|
|
9798
10425
|
}
|
|
@@ -9800,8 +10427,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9800
10427
|
function failClosedRateLimit(key, reason) {
|
|
9801
10428
|
liveScheduling = false;
|
|
9802
10429
|
controlEpochs = null;
|
|
9803
|
-
|
|
9804
|
-
backgroundPollAt = Number.POSITIVE_INFINITY;
|
|
10430
|
+
pollDueAt = { ...NEVER_DUE };
|
|
9805
10431
|
wakeScheduler.clear("data");
|
|
9806
10432
|
pauseCoordination(key, reason);
|
|
9807
10433
|
armWake("control", governorControlRetryAt(Date.now(), runtime.refreshMs), controlWake);
|
|
@@ -9876,6 +10502,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9876
10502
|
rawRef.current = {};
|
|
9877
10503
|
entityRef.current.clear();
|
|
9878
10504
|
lastOkRef.current = {};
|
|
10505
|
+
// The workflow catalog is repository payload, not scheduling state, so
|
|
10506
|
+
// it is fenced with the rows rather than carried into whatever account
|
|
10507
|
+
// this pane now holds. The page counts go with it: demand measured
|
|
10508
|
+
// against a list that has been discarded describes nothing.
|
|
10509
|
+
workflowCatalogRef.current = null;
|
|
10510
|
+
pageStateRef.current = {};
|
|
9879
10511
|
setData(Object.fromEntries(TAB_KEYS.map((key) => [key, null])));
|
|
9880
10512
|
setMeta({});
|
|
9881
10513
|
setSecurityNotes([]);
|
|
@@ -9884,8 +10516,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9884
10516
|
}
|
|
9885
10517
|
pendingBlockPublications.clear();
|
|
9886
10518
|
liveScheduling = false;
|
|
9887
|
-
|
|
9888
|
-
backgroundPollAt = Number.POSITIVE_INFINITY;
|
|
10519
|
+
pollDueAt = { ...NEVER_DUE };
|
|
9889
10520
|
wakeScheduler.clear("data");
|
|
9890
10521
|
wakeScheduler.clear("heartbeat");
|
|
9891
10522
|
wakeScheduler.clear("control");
|
|
@@ -9993,7 +10624,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9993
10624
|
publishGovernorEpochs(snapshot);
|
|
9994
10625
|
const wakes = governorWakeTimes(snapshot.value, nowMs, runtime.refreshMs, leaseId);
|
|
9995
10626
|
armWake("control", wakes.controlAt, controlWake);
|
|
9996
|
-
const nextDataAt = Math.min(wakes.dataAt,
|
|
10627
|
+
const nextDataAt = Math.min(wakes.dataAt, ...Object.values(pollDueAt));
|
|
9997
10628
|
if (liveScheduling) armWake("data", nextDataAt, dataWake);
|
|
9998
10629
|
}
|
|
9999
10630
|
|
|
@@ -10002,39 +10633,55 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10002
10633
|
armFromState();
|
|
10003
10634
|
}
|
|
10004
10635
|
|
|
10636
|
+
// Manual and tab-switch work replaces the automatic check that would
|
|
10637
|
+
// otherwise be due, rather than being added to it.
|
|
10005
10638
|
function replaceActivePoll(kind, at) {
|
|
10006
10639
|
if (["manual", "tab-switch"].includes(kind)) {
|
|
10007
|
-
|
|
10640
|
+
rescheduleTab(TABS[activeIndexRef.current].key, at);
|
|
10008
10641
|
}
|
|
10009
10642
|
}
|
|
10010
10643
|
|
|
10644
|
+
// `manual` is the user asking now: it bypasses the scheduled deadline and
|
|
10645
|
+
// the tab's failure ladder. `force` is the separate, stronger request that
|
|
10646
|
+
// also drops validators -- the `R` key. `r` is manual and not forced, which
|
|
10647
|
+
// is what makes a quiet refresh cost nothing.
|
|
10011
10648
|
function requestTab(key, kind = "active", { force = false } = {}) {
|
|
10649
|
+
const manual = kind === "manual";
|
|
10012
10650
|
const signal = controller.signal;
|
|
10013
10651
|
const descriptor = tabForKey(key);
|
|
10014
10652
|
const monotonicNow = performance.now();
|
|
10015
10653
|
if (pendingBlockPublications.size > 0) {
|
|
10016
10654
|
pauseCoordination(key, "block-unpublished");
|
|
10017
|
-
return Promise.resolve({ persisted: false, retry: false, kind });
|
|
10655
|
+
return Promise.resolve({ persisted: false, retry: false, kind, key });
|
|
10018
10656
|
}
|
|
10019
10657
|
if (inFlightRef.current[key]) {
|
|
10020
|
-
|
|
10021
|
-
|
|
10022
|
-
|
|
10023
|
-
|
|
10024
|
-
|
|
10658
|
+
// A manual acquisition already in flight is work this user asked for,
|
|
10659
|
+
// so the press joins it. Automatic work that started before the press
|
|
10660
|
+
// cannot answer it, so schedule exactly one follow-up; further presses
|
|
10661
|
+
// coalesce into that one, and the stronger intention wins.
|
|
10662
|
+
if (manual && !manualInFlight.has(key)) {
|
|
10663
|
+
const plan = planManualRefresh({
|
|
10664
|
+
requestedAt: Date.now(),
|
|
10665
|
+
inFlightStartedAt: inFlightStartedAt[key] ?? null,
|
|
10666
|
+
followUpPending: queuedManual.has(key),
|
|
10667
|
+
});
|
|
10668
|
+
if (plan.followUp) queuedManual.set(key, { force });
|
|
10669
|
+
}
|
|
10670
|
+
if (force && queuedManual.has(key)) queuedManual.get(key).force = true;
|
|
10671
|
+
return Promise.resolve({ persisted: false, retry: false, kind, key });
|
|
10025
10672
|
}
|
|
10026
|
-
if (!
|
|
10027
|
-
return Promise.resolve({ persisted: false, retry: false, kind });
|
|
10673
|
+
if (!manual && backoffActive(`tab:${key}`, monotonicNow)) {
|
|
10674
|
+
return Promise.resolve({ persisted: false, retry: false, kind, key });
|
|
10028
10675
|
}
|
|
10029
10676
|
const nowMs = Date.now();
|
|
10030
10677
|
const currentScope = ensureScope(nowMs);
|
|
10031
10678
|
if (!currentScope) {
|
|
10032
10679
|
pauseCoordination(key, "unknown-scope");
|
|
10033
|
-
return Promise.resolve({ persisted: false, retry: true, kind });
|
|
10680
|
+
return Promise.resolve({ persisted: false, retry: true, kind, key });
|
|
10034
10681
|
}
|
|
10035
10682
|
let protocolReady = liveScheduling;
|
|
10036
10683
|
let resourceReady = liveScheduling;
|
|
10037
|
-
if (!liveScheduling ||
|
|
10684
|
+
if (!liveScheduling || manual) {
|
|
10038
10685
|
const snapshot = inspectGovernor(currentScope, nowMs);
|
|
10039
10686
|
protocolReady = governorProtocolReady(
|
|
10040
10687
|
{ ok: true, value: { status: "published" } },
|
|
@@ -10045,13 +10692,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10045
10692
|
{ ok: true, value: { status: "published" } }, snapshot, key, nowMs);
|
|
10046
10693
|
if (!liveScheduling && resourceReady) {
|
|
10047
10694
|
liveScheduling = true;
|
|
10048
|
-
|
|
10049
|
-
backgroundPollAt = nowMs + 4 * runtime.refreshMs;
|
|
10695
|
+
openPollDeadlines(nowMs - 1);
|
|
10050
10696
|
armWake("heartbeat", nowMs + GOVERNOR_HEARTBEAT_MS, heartbeatWake);
|
|
10051
10697
|
}
|
|
10052
10698
|
}
|
|
10053
|
-
const intentGate = runtimeIntentGate(liveScheduling, { force, protocolReady });
|
|
10054
|
-
if (intentGate.requestProbe ||
|
|
10699
|
+
const intentGate = runtimeIntentGate(liveScheduling, { force: manual, protocolReady });
|
|
10700
|
+
if (intentGate.requestProbe || manual && !resourceReady) {
|
|
10055
10701
|
const snapshot = inspectGovernor(currentScope, nowMs);
|
|
10056
10702
|
const costs = tabRequestCost(key);
|
|
10057
10703
|
for (const resource of RATE_RESOURCES) {
|
|
@@ -10065,12 +10711,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10065
10711
|
if (!intentGate.registerIntent) {
|
|
10066
10712
|
setTabGovernorDecision(key, { mode: "waiting", probing: true });
|
|
10067
10713
|
setTabWaiting(key, true);
|
|
10068
|
-
return Promise.resolve({ persisted: false, retry: false, kind });
|
|
10714
|
+
return Promise.resolve({ persisted: false, retry: false, kind, key });
|
|
10069
10715
|
}
|
|
10070
10716
|
const existing = pending.get(key);
|
|
10071
10717
|
if (existing) {
|
|
10072
|
-
if (!
|
|
10073
|
-
return Promise.resolve({ persisted: true, retry: false, kind });
|
|
10718
|
+
if (!manual || existing.kind === "manual") {
|
|
10719
|
+
return Promise.resolve({ persisted: true, retry: false, kind, key });
|
|
10074
10720
|
}
|
|
10075
10721
|
cancelIntent(currentScope, existing.intentId, nowMs);
|
|
10076
10722
|
pending.delete(key);
|
|
@@ -10088,13 +10734,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10088
10734
|
const registered = registerIntent(currentScope, request);
|
|
10089
10735
|
if (!registered.ok) {
|
|
10090
10736
|
pauseCoordination(key, registered.reason);
|
|
10091
|
-
return Promise.resolve({ persisted: false, retry: true, kind });
|
|
10737
|
+
return Promise.resolve({ persisted: false, retry: true, kind, key });
|
|
10092
10738
|
}
|
|
10093
10739
|
const decision = registered.value;
|
|
10094
10740
|
pending.set(key, {
|
|
10095
10741
|
intentId,
|
|
10096
10742
|
kind,
|
|
10097
10743
|
force,
|
|
10744
|
+
manual,
|
|
10098
10745
|
wasDeferred: false,
|
|
10099
10746
|
...sharedLaneEvidence(decision),
|
|
10100
10747
|
});
|
|
@@ -10103,20 +10750,20 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10103
10750
|
if (item) item.wasDeferred = true;
|
|
10104
10751
|
setTabGovernorDecision(key, visibleGovernorDecision(decision));
|
|
10105
10752
|
setTabWaiting(key, true);
|
|
10106
|
-
if (
|
|
10753
|
+
if (manual && decision.resource) {
|
|
10107
10754
|
const budget = inspectGovernor(currentScope, nowMs).value?.budgets?.[decision.resource];
|
|
10108
10755
|
if (budget) requestManualProbe(currentScope, leaseId, budget.epoch, budget.observedAt, nowMs);
|
|
10109
10756
|
}
|
|
10110
10757
|
armWake("data", decision.retryAt ?? decision.notBefore ?? nowMs + runtime.refreshMs, dataWake);
|
|
10111
10758
|
armFromState(nowMs);
|
|
10112
|
-
return Promise.resolve({ persisted: true, retry: false, kind });
|
|
10759
|
+
return Promise.resolve({ persisted: true, retry: false, kind, key });
|
|
10113
10760
|
}
|
|
10114
10761
|
const started = startReservation(currentScope, decision.reservationId, nowMs);
|
|
10115
10762
|
if (!started.ok) {
|
|
10116
10763
|
pauseCoordination(key, started.reason);
|
|
10117
10764
|
if (pendingFailureIsTerminal(started.reason)) finishPending(key, intentId);
|
|
10118
10765
|
else armWake("data", governorControlRetryAt(nowMs, runtime.refreshMs), dataWake);
|
|
10119
|
-
return Promise.resolve({ persisted: true, retry: false, kind });
|
|
10766
|
+
return Promise.resolve({ persisted: true, retry: false, kind, key });
|
|
10120
10767
|
}
|
|
10121
10768
|
if (started.value.status !== "started") {
|
|
10122
10769
|
const item = pending.get(key);
|
|
@@ -10126,7 +10773,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10126
10773
|
...started.value,
|
|
10127
10774
|
}));
|
|
10128
10775
|
armWake("data", started.value.notBefore ?? nowMs + runtime.refreshMs, dataWake);
|
|
10129
|
-
return Promise.resolve({ persisted: true, retry: false, kind });
|
|
10776
|
+
return Promise.resolve({ persisted: true, retry: false, kind, key });
|
|
10130
10777
|
}
|
|
10131
10778
|
setTabWaiting(key, false);
|
|
10132
10779
|
replaceActivePoll(kind, nowMs);
|
|
@@ -10135,23 +10782,31 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10135
10782
|
key,
|
|
10136
10783
|
() => descriptor.fetch({
|
|
10137
10784
|
signal,
|
|
10138
|
-
runLimit: runLimitRef.current,
|
|
10139
10785
|
entities: entityRef.current,
|
|
10140
10786
|
force,
|
|
10787
|
+
catalog: workflowCatalogRef.current,
|
|
10788
|
+
pages: pageStateRef.current[key]?.pages ?? 1,
|
|
10141
10789
|
governor: { scope: currentScope, leaseId },
|
|
10142
10790
|
previousRaw: rawRef.current[key] ?? null,
|
|
10143
10791
|
}),
|
|
10144
10792
|
{
|
|
10145
10793
|
force,
|
|
10794
|
+
manual,
|
|
10146
10795
|
scope: currentScope,
|
|
10147
10796
|
reservationId: decision.reservationId,
|
|
10148
10797
|
admittedAt: nowMs,
|
|
10149
10798
|
automaticStatusVisible: false,
|
|
10150
10799
|
onSettled: () => finishPending(key, intentId),
|
|
10151
10800
|
},
|
|
10152
|
-
).then(() => ({ persisted: true, retry: false, kind }));
|
|
10801
|
+
).then(() => ({ persisted: true, retry: false, kind, key }));
|
|
10153
10802
|
}
|
|
10154
10803
|
fetchTabRef.current = (key, { force = false, kind = force ? "manual" : "active" } = {}) => {
|
|
10804
|
+
if (kind === "tab-switch") {
|
|
10805
|
+
// A newly active subscription is not evidence of quiet: the tab you
|
|
10806
|
+
// just opened deserves a check at the floor, not at the interval it
|
|
10807
|
+
// earned while nobody was looking at it.
|
|
10808
|
+
unchangedPolls[key] = advanceUnchangedCount(unchangedPolls[key], "subscribed");
|
|
10809
|
+
}
|
|
10155
10810
|
const requestedAt = Date.now();
|
|
10156
10811
|
// Manual work replaces the active poll that would otherwise be due. Move
|
|
10157
10812
|
// that deadline before touching the governor so a data wake already in
|
|
@@ -10222,14 +10877,16 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10222
10877
|
key,
|
|
10223
10878
|
() => descriptor.fetch({
|
|
10224
10879
|
signal: controller.signal,
|
|
10225
|
-
runLimit: runLimitRef.current,
|
|
10226
10880
|
entities: entityRef.current,
|
|
10227
10881
|
force: item.force,
|
|
10882
|
+
catalog: workflowCatalogRef.current,
|
|
10883
|
+
pages: pageStateRef.current[key]?.pages ?? 1,
|
|
10228
10884
|
governor: { scope: currentScope, leaseId },
|
|
10229
10885
|
previousRaw: rawRef.current[key] ?? null,
|
|
10230
10886
|
}),
|
|
10231
10887
|
{
|
|
10232
10888
|
force: item.force,
|
|
10889
|
+
manual: item.manual === true,
|
|
10233
10890
|
scope: currentScope,
|
|
10234
10891
|
reservationId: decision.value.reservationId,
|
|
10235
10892
|
admittedAt: nowMs,
|
|
@@ -10266,13 +10923,16 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10266
10923
|
nowMs,
|
|
10267
10924
|
floorMs: runtime.refreshMs,
|
|
10268
10925
|
activeKey: active,
|
|
10269
|
-
|
|
10270
|
-
backgroundAt: backgroundPollAt,
|
|
10926
|
+
dueAt: pollDueAt,
|
|
10271
10927
|
backgroundIndex,
|
|
10272
10928
|
heldResources,
|
|
10929
|
+
background: runtime.background,
|
|
10930
|
+
states: Object.fromEntries(TAB_KEYS.map((key) => [key, {
|
|
10931
|
+
unchangedCount: unchangedPolls[key],
|
|
10932
|
+
inProgressCI: key === "actions" && actionsInProgress(),
|
|
10933
|
+
}])),
|
|
10273
10934
|
});
|
|
10274
|
-
|
|
10275
|
-
backgroundPollAt = planned.backgroundAt;
|
|
10935
|
+
pollDueAt = planned.dueAt;
|
|
10276
10936
|
backgroundIndex = planned.backgroundIndex;
|
|
10277
10937
|
const outcomes = await Promise.allSettled(
|
|
10278
10938
|
planned.due.map(({ key, kind }) => requestTab(key, kind)),
|
|
@@ -10281,15 +10941,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10281
10941
|
let retryNeeded = false;
|
|
10282
10942
|
for (const outcome of outcomes) {
|
|
10283
10943
|
if (outcome.status !== "fulfilled" || outcome.value?.retry !== true) continue;
|
|
10284
|
-
({
|
|
10285
|
-
|
|
10286
|
-
|
|
10287
|
-
|
|
10288
|
-
|
|
10289
|
-
|
|
10290
|
-
|
|
10291
|
-
|
|
10292
|
-
}));
|
|
10944
|
+
({ dueAt: pollDueAt, backgroundIndex } = retryPollAfterAdmissionFailure({
|
|
10945
|
+
key: outcome.value.key,
|
|
10946
|
+
kind: outcome.value.kind,
|
|
10947
|
+
retryAt,
|
|
10948
|
+
dueAt: pollDueAt,
|
|
10949
|
+
backgroundIndex,
|
|
10950
|
+
previousBackgroundIndex,
|
|
10951
|
+
}));
|
|
10293
10952
|
retryNeeded = true;
|
|
10294
10953
|
}
|
|
10295
10954
|
if (retryNeeded) armWake("data", retryAt, dataWake);
|
|
@@ -10346,10 +11005,15 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10346
11005
|
if (controlReady && (!liveScheduling || activeEpochChanged)) {
|
|
10347
11006
|
const starting = !liveScheduling;
|
|
10348
11007
|
liveScheduling = true;
|
|
10349
|
-
activePollAt = checkedAt + 1;
|
|
10350
11008
|
if (starting) {
|
|
10351
|
-
|
|
11009
|
+
openPollDeadlines(checkedAt);
|
|
10352
11010
|
armWake("heartbeat", checkedAt + GOVERNOR_HEARTBEAT_MS, heartbeatWake);
|
|
11011
|
+
} else {
|
|
11012
|
+
// A new accounting epoch for a resource the active tab spends: check
|
|
11013
|
+
// it now rather than at whatever cadence it had settled into. Set
|
|
11014
|
+
// directly rather than through rescheduleTab, whose whole job is to
|
|
11015
|
+
// apply that cadence.
|
|
11016
|
+
pollDueAt = { ...pollDueAt, [TABS[activeIndexRef.current].key]: checkedAt + 1 };
|
|
10353
11017
|
}
|
|
10354
11018
|
}
|
|
10355
11019
|
if (!refreshed.ok) {
|
|
@@ -10374,6 +11038,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10374
11038
|
async function bootstrap() {
|
|
10375
11039
|
remoteUrls = runtimeRemoteUrls.length > 0 ? runtimeRemoteUrls : await gitRemoteUrls();
|
|
10376
11040
|
runtimeRemoteUrls = remoteUrls;
|
|
11041
|
+
if (cancelled) return;
|
|
11042
|
+
if (enterRemoteSetup()) return;
|
|
10377
11043
|
await runtimeIdentityCoordinator.refresh();
|
|
10378
11044
|
if (cancelled) return;
|
|
10379
11045
|
const currentScope = ensureScope(Date.now());
|
|
@@ -10396,8 +11062,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10396
11062
|
if (controlReady) {
|
|
10397
11063
|
controlEpochs = { ...snapshot.value.epochs };
|
|
10398
11064
|
liveScheduling = true;
|
|
10399
|
-
|
|
10400
|
-
backgroundPollAt = checkedAt + 4 * runtime.refreshMs;
|
|
11065
|
+
openPollDeadlines(checkedAt);
|
|
10401
11066
|
armWake("heartbeat", checkedAt + GOVERNOR_HEARTBEAT_MS, heartbeatWake);
|
|
10402
11067
|
}
|
|
10403
11068
|
if (!refreshed.ok) {
|
|
@@ -11149,6 +11814,7 @@ export {
|
|
|
11149
11814
|
HOST_PATTERN,
|
|
11150
11815
|
MIN_REFRESH_SECONDS,
|
|
11151
11816
|
MAX_REFRESH_SECONDS,
|
|
11817
|
+
BACKGROUND_MODES,
|
|
11152
11818
|
TAB_KEYS,
|
|
11153
11819
|
ALERT_SOURCES,
|
|
11154
11820
|
REST_PER_FETCH,
|
|
@@ -11244,6 +11910,7 @@ export {
|
|
|
11244
11910
|
isRateLimited,
|
|
11245
11911
|
isAuthProblem,
|
|
11246
11912
|
isMissingRemote,
|
|
11913
|
+
noRepositoryTarget,
|
|
11247
11914
|
isUnusableOutput,
|
|
11248
11915
|
forwardSignalToChild,
|
|
11249
11916
|
toTabError,
|
|
@@ -11269,6 +11936,13 @@ export {
|
|
|
11269
11936
|
actionsRunsArgs,
|
|
11270
11937
|
actionsWorkflowsArgs,
|
|
11271
11938
|
parseActionsBodies,
|
|
11939
|
+
parseActionsRuns,
|
|
11940
|
+
parseWorkflowCatalog,
|
|
11941
|
+
resolveWorkflowNames,
|
|
11942
|
+
workflowCatalogDemand,
|
|
11943
|
+
fetchActions,
|
|
11944
|
+
ACTIONS_RUN_LIMIT,
|
|
11945
|
+
WORKFLOW_CATALOG_TTL_MS,
|
|
11272
11946
|
entityKey,
|
|
11273
11947
|
fetchConditionalEntity,
|
|
11274
11948
|
conditionalBatchResult,
|
|
@@ -11309,8 +11983,17 @@ export {
|
|
|
11309
11983
|
mergeDashboardCacheSnapshots,
|
|
11310
11984
|
nextSecurityRaw,
|
|
11311
11985
|
shouldCheckpointFreshness,
|
|
11312
|
-
|
|
11986
|
+
pollPolicyInterval,
|
|
11987
|
+
advanceUnchangedCount,
|
|
11988
|
+
POLL_ACTIVE_CI_MS,
|
|
11989
|
+
POLL_QUIET_AFTER,
|
|
11990
|
+
POLL_QUIET_MS,
|
|
11991
|
+
POLL_BACKGROUND_MS,
|
|
11313
11992
|
shouldShowFetchLoading,
|
|
11993
|
+
refreshIntent,
|
|
11994
|
+
manualRefreshRequest,
|
|
11995
|
+
planManualRefresh,
|
|
11996
|
+
conditionalRecoveryPlan,
|
|
11314
11997
|
pollSchedule,
|
|
11315
11998
|
retryPollAfterAdmissionFailure,
|
|
11316
11999
|
governorWakeTimes,
|
|
@@ -11330,6 +12013,7 @@ export {
|
|
|
11330
12013
|
rateLimitBlockProbeRecovered,
|
|
11331
12014
|
admitGovernorOperation,
|
|
11332
12015
|
runAdmittedOperation,
|
|
12016
|
+
GOVERNOR_ADMISSION_WAIT_MS,
|
|
11333
12017
|
pollResultTransition,
|
|
11334
12018
|
forcedBackoffKeys,
|
|
11335
12019
|
clearForcedBackoffAfterStart,
|
|
@@ -11345,6 +12029,11 @@ export {
|
|
|
11345
12029
|
graphqlArgs,
|
|
11346
12030
|
fetchGraphqlPage,
|
|
11347
12031
|
fetchGraphqlList,
|
|
12032
|
+
PAGE_DEMAND_THRESHOLD,
|
|
12033
|
+
pageGeneration,
|
|
12034
|
+
paginationDemand,
|
|
12035
|
+
demandedPageCount,
|
|
12036
|
+
mergeDemandedPages,
|
|
11348
12037
|
operationPausedUntil,
|
|
11349
12038
|
LIST_LIMIT,
|
|
11350
12039
|
readGraphqlObserver,
|