gh-glance 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +92 -1
- package/README.md +48 -19
- package/index.mjs +915 -271
- 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
|
};
|
|
@@ -850,7 +882,7 @@ async function ghApi(args, { operation, signal, etag = null, run = runGh } = {})
|
|
|
850
882
|
|
|
851
883
|
// The version travels in the query key, so a changed document invalidates its
|
|
852
884
|
// cursors instead of paging a new shape with an old cursor.
|
|
853
|
-
const GRAPHQL_QUERY_VERSION =
|
|
885
|
+
const GRAPHQL_QUERY_VERSION = 2;
|
|
854
886
|
// GitHub prices a connection by the pages it could return, and the row's single
|
|
855
887
|
// label is a nested connection of its own. Two points is the conservative bound
|
|
856
888
|
// for one page; the observer selects nothing but the meter and costs one.
|
|
@@ -1243,9 +1275,9 @@ function createFailureContextCoordinator({ resolve, commit, fallback }) {
|
|
|
1243
1275
|
return { ensure, invalidate };
|
|
1244
1276
|
}
|
|
1245
1277
|
|
|
1246
|
-
function actionsRunsArgs(
|
|
1278
|
+
function actionsRunsArgs() {
|
|
1247
1279
|
return [
|
|
1248
|
-
apiPath(`repos/{owner}/{repo}/actions/runs?exclude_pull_requests=true&per_page=${
|
|
1280
|
+
apiPath(`repos/{owner}/{repo}/actions/runs?exclude_pull_requests=true&per_page=${ACTIONS_RUN_LIMIT}`),
|
|
1249
1281
|
...apiHostArgs(),
|
|
1250
1282
|
"--jq",
|
|
1251
1283
|
// html_url is projected because a run's page must come from the row itself.
|
|
@@ -1253,7 +1285,11 @@ function actionsRunsArgs(limit) {
|
|
|
1253
1285
|
// with an inferred repository the app never learns the slug, and the row is
|
|
1254
1286
|
// the one place the answer is always present. It costs nothing extra: the
|
|
1255
1287
|
// field is already in the response being parsed.
|
|
1256
|
-
|
|
1288
|
+
//
|
|
1289
|
+
// `name` is the workflow's name as of the run. Selecting it here is what
|
|
1290
|
+
// turns the workflow catalog from an unconditional second request into a
|
|
1291
|
+
// fallback for the rows that lack one.
|
|
1292
|
+
"[.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
1293
|
];
|
|
1258
1294
|
}
|
|
1259
1295
|
|
|
@@ -1266,15 +1302,11 @@ function actionsWorkflowsArgs() {
|
|
|
1266
1302
|
];
|
|
1267
1303
|
}
|
|
1268
1304
|
|
|
1269
|
-
function
|
|
1270
|
-
const workflows = new Map(parseJsonOutput(workflowsBody).map((workflow) => [
|
|
1271
|
-
workflow.id,
|
|
1272
|
-
safe(workflow.name),
|
|
1273
|
-
]));
|
|
1305
|
+
function parseActionsRuns(runsBody) {
|
|
1274
1306
|
return parseJsonOutput(runsBody).map((run) => ({
|
|
1275
1307
|
databaseId: run.databaseId,
|
|
1276
1308
|
displayTitle: safe(run.displayTitle),
|
|
1277
|
-
workflowName:
|
|
1309
|
+
workflowName: safe(run.workflowName ?? ""),
|
|
1278
1310
|
number: run.number,
|
|
1279
1311
|
headBranch: safe(run.headBranch),
|
|
1280
1312
|
status: run.status,
|
|
@@ -1282,9 +1314,56 @@ function parseActionsBodies(runsBody, workflowsBody) {
|
|
|
1282
1314
|
startedAt: run.startedAt,
|
|
1283
1315
|
updatedAt: run.updatedAt,
|
|
1284
1316
|
url: safe(run.url ?? ""),
|
|
1317
|
+
workflowId: run.workflowId,
|
|
1318
|
+
}));
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function parseWorkflowCatalog(workflowsBody, at, previous = null) {
|
|
1322
|
+
// An attempt that produced no usable body still closes the TTL window, and
|
|
1323
|
+
// keeps whatever names were already known. Re-asking once per poll for a
|
|
1324
|
+
// catalog the server will not serve is the failure mode this replaces.
|
|
1325
|
+
if (typeof workflowsBody !== "string" || workflowsBody.length === 0) {
|
|
1326
|
+
return { at, names: previous?.names instanceof Map ? previous.names : new Map() };
|
|
1327
|
+
}
|
|
1328
|
+
return {
|
|
1329
|
+
at,
|
|
1330
|
+
names: new Map(parseJsonOutput(workflowsBody).map((workflow) => [workflow.id, safe(workflow.name)])),
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function catalogNames(catalog) {
|
|
1335
|
+
return catalog?.names instanceof Map ? catalog.names : new Map();
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// The catalog is worth a request only when a run carries no name of its own and
|
|
1339
|
+
// its workflow id is not already known. A server that answers with names -- the
|
|
1340
|
+
// normal case -- never asks for it at all.
|
|
1341
|
+
function workflowCatalogDemand({ runs = [], catalog = null, nowMs = Date.now() }) {
|
|
1342
|
+
const fresh = catalog && Number.isFinite(catalog.at) &&
|
|
1343
|
+
nowMs - catalog.at < WORKFLOW_CATALOG_TTL_MS;
|
|
1344
|
+
if (fresh) return false;
|
|
1345
|
+
const names = catalogNames(catalog);
|
|
1346
|
+
return runs.some((run) => !run.workflowName && !names.has(run.workflowId));
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// A missing name renders empty rather than blocking the row. Everything else a
|
|
1350
|
+
// run carries -- its status, conclusion and title -- is exactly as useful
|
|
1351
|
+
// without the workflow's name, so an unreadable catalog must not cost CI status.
|
|
1352
|
+
function resolveWorkflowNames(runs, catalog) {
|
|
1353
|
+
const names = catalogNames(catalog);
|
|
1354
|
+
return runs.map(({ workflowId, ...run }) => ({
|
|
1355
|
+
...run,
|
|
1356
|
+
workflowName: run.workflowName || names.get(workflowId) || "",
|
|
1285
1357
|
}));
|
|
1286
1358
|
}
|
|
1287
1359
|
|
|
1360
|
+
function parseActionsBodies(runsBody, workflowsBody) {
|
|
1361
|
+
return resolveWorkflowNames(
|
|
1362
|
+
parseActionsRuns(runsBody),
|
|
1363
|
+
parseWorkflowCatalog(workflowsBody, 0),
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1288
1367
|
function entityKey(tab, path) {
|
|
1289
1368
|
return `${tab}\0${path}`;
|
|
1290
1369
|
}
|
|
@@ -1315,12 +1394,24 @@ async function fetchConditionalEntity({
|
|
|
1315
1394
|
response.status === 200 ? 1 : 0,
|
|
1316
1395
|
);
|
|
1317
1396
|
if (response.status === 304) {
|
|
1397
|
+
// An entity that is present but empty is not an answer either: it parses to
|
|
1398
|
+
// nothing, and the validator that named it is never refreshed, so the tab
|
|
1399
|
+
// asks the same unanswerable question at every poll forever.
|
|
1400
|
+
const recovery = conditionalRecoveryPlan({ status: 304, entity: cached?.body });
|
|
1401
|
+
// A validator naming an entity this pane no longer holds describes a
|
|
1402
|
+
// question with no possible answer: the server will keep saying "unchanged"
|
|
1403
|
+
// and there is nothing here for that to mean. Drop the validator now --
|
|
1404
|
+
// directly, not through the staged publication, which only runs on a usable
|
|
1405
|
+
// transition -- so the next admitted check is unconditional. One recovery,
|
|
1406
|
+
// and the tab reports unusable in the meantime rather than looping.
|
|
1407
|
+
if (recovery.recover && entities instanceof Map) entities.delete(entityKey(tab, path));
|
|
1318
1408
|
return {
|
|
1319
1409
|
path,
|
|
1320
1410
|
status: 304,
|
|
1321
|
-
body:
|
|
1411
|
+
body: recovery.recover ? null : cached.body,
|
|
1322
1412
|
staged: null,
|
|
1323
1413
|
observations: observation ? [observation] : [],
|
|
1414
|
+
recovered: recovery.recover,
|
|
1324
1415
|
};
|
|
1325
1416
|
}
|
|
1326
1417
|
return {
|
|
@@ -1371,39 +1462,92 @@ function publishStagedEntities(entities, staged, transitionKind) {
|
|
|
1371
1462
|
return true;
|
|
1372
1463
|
}
|
|
1373
1464
|
|
|
1374
|
-
|
|
1465
|
+
const ACTIONS_QUERY_VERSION = 2;
|
|
1466
|
+
|
|
1467
|
+
async function fetchActions(signal, {
|
|
1375
1468
|
entities = new Map(),
|
|
1376
1469
|
force = false,
|
|
1377
1470
|
previousRaw = null,
|
|
1471
|
+
catalog = null,
|
|
1472
|
+
governor = null,
|
|
1473
|
+
// Seams, so the conditional catalog fallback can be exercised without a live
|
|
1474
|
+
// governor or a subprocess.
|
|
1475
|
+
request = ghApi,
|
|
1476
|
+
admit = runAdmittedOperation,
|
|
1378
1477
|
} = {}) {
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1478
|
+
const runsResponse = await fetchConditionalEntity({
|
|
1479
|
+
tab: "actions",
|
|
1480
|
+
args: actionsRunsArgs(),
|
|
1481
|
+
operation: "tab:actions-runs",
|
|
1482
|
+
signal,
|
|
1483
|
+
force,
|
|
1484
|
+
entities,
|
|
1485
|
+
request,
|
|
1486
|
+
});
|
|
1487
|
+
if (typeof runsResponse.body !== "string") {
|
|
1488
|
+
// The broken validator has already been dropped above, so the next admitted
|
|
1489
|
+
// check is unconditional. This poll reports unusable, which keeps the
|
|
1490
|
+
// last-good rows and their freshness clock rather than blanking the tab.
|
|
1491
|
+
return {
|
|
1492
|
+
raw: null,
|
|
1493
|
+
limit: ACTIONS_RUN_LIMIT,
|
|
1494
|
+
restSpent: 0,
|
|
1495
|
+
graphqlSpent: GRAPHQL_PER_FETCH.actions,
|
|
1496
|
+
stagedEntities: null,
|
|
1497
|
+
observations: runsResponse.observations,
|
|
1498
|
+
catalog,
|
|
1499
|
+
parse: () => parseActionsRuns(runsResponse.body ?? ""),
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
const responses = [runsResponse];
|
|
1503
|
+
const runs = parseActionsRuns(runsResponse.body);
|
|
1504
|
+
const startedAt = Date.now();
|
|
1505
|
+
let nextCatalog = catalog;
|
|
1506
|
+
if (governor && workflowCatalogDemand({ runs, catalog, nowMs: startedAt })) {
|
|
1507
|
+
// Separately admitted, like a later GraphQL page: the catalog is a fallback,
|
|
1508
|
+
// so a budget decision about it settles against its own reservation and can
|
|
1509
|
+
// never take the run list -- or the tab's own settlement -- down with it.
|
|
1510
|
+
const admitted = await admit({
|
|
1511
|
+
...governor,
|
|
1512
|
+
operation: "catalog:actions-workflows",
|
|
1513
|
+
priority: "background",
|
|
1392
1514
|
signal,
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1515
|
+
waitMs: GOVERNOR_ADMISSION_WAIT_MS,
|
|
1516
|
+
run: (admittedSignal) => fetchConditionalEntity({
|
|
1517
|
+
tab: "actions",
|
|
1518
|
+
args: actionsWorkflowsArgs(),
|
|
1519
|
+
operation: "catalog:actions-workflows",
|
|
1520
|
+
signal: admittedSignal,
|
|
1521
|
+
force,
|
|
1522
|
+
entities,
|
|
1523
|
+
request,
|
|
1524
|
+
}),
|
|
1525
|
+
});
|
|
1526
|
+
if (admitted.ok && admitted.value) {
|
|
1527
|
+
responses.push(admitted.value);
|
|
1528
|
+
nextCatalog = parseWorkflowCatalog(admitted.value.body, startedAt, catalog);
|
|
1529
|
+
}
|
|
1530
|
+
// A refusal leaves the catalog exactly as it was. Closing the TTL on a
|
|
1531
|
+
// scheduling outcome would hide missing names for fifteen minutes over a
|
|
1532
|
+
// moment of budget pressure.
|
|
1533
|
+
}
|
|
1534
|
+
const rows = resolveWorkflowNames(runs, nextCatalog);
|
|
1535
|
+
// The payload identity is what is rendered, not the concatenated bodies. The
|
|
1536
|
+
// catalog is fetched on some polls and not others, and joining raw bodies
|
|
1537
|
+
// made that alternation look like a content change every time.
|
|
1538
|
+
const raw = JSON.stringify({ v: ACTIONS_QUERY_VERSION, rows });
|
|
1539
|
+
const batch = conditionalBatchResult(responses, previousRaw, raw);
|
|
1399
1540
|
return {
|
|
1400
1541
|
raw: batch.raw,
|
|
1401
|
-
limit,
|
|
1402
|
-
|
|
1542
|
+
limit: ACTIONS_RUN_LIMIT,
|
|
1543
|
+
// The tab settles for its own request. The catalog settled against the
|
|
1544
|
+
// reservation it opened for itself.
|
|
1545
|
+
restSpent: runsResponse.status === 200 ? 1 : 0,
|
|
1403
1546
|
graphqlSpent: GRAPHQL_PER_FETCH.actions,
|
|
1404
1547
|
stagedEntities: batch.stagedEntities,
|
|
1405
1548
|
observations: batch.observations,
|
|
1406
|
-
|
|
1549
|
+
catalog: nextCatalog,
|
|
1550
|
+
parse: () => rows,
|
|
1407
1551
|
};
|
|
1408
1552
|
}
|
|
1409
1553
|
|
|
@@ -1487,15 +1631,101 @@ const PR_ROW = (node) => ({
|
|
|
1487
1631
|
url: safe(node.url ?? ""),
|
|
1488
1632
|
});
|
|
1489
1633
|
|
|
1490
|
-
//
|
|
1491
|
-
// is
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1494
|
-
|
|
1634
|
+
// How close to the end of the loaded rows the cursor must come before another
|
|
1635
|
+
// page is worth acquiring. Ten rows is roughly half a pane, so the next page
|
|
1636
|
+
// arrives before the user reaches the bottom without being fetched for a list
|
|
1637
|
+
// nobody scrolled.
|
|
1638
|
+
const PAGE_DEMAND_THRESHOLD = 10;
|
|
1639
|
+
|
|
1640
|
+
// A traversal's identity is the identity of its first page. A cursor is only
|
|
1641
|
+
// meaningful against the ordering that produced it, so when the first page
|
|
1642
|
+
// changes underneath an in-flight later page, that page describes a list that
|
|
1643
|
+
// no longer exists.
|
|
1644
|
+
function pageGeneration(rows) {
|
|
1645
|
+
return (Array.isArray(rows) ? rows : [])
|
|
1646
|
+
.map((row) => String(itemKey(row)))
|
|
1647
|
+
.join("\u0000");
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function paginationDemand({
|
|
1651
|
+
selectedIndex,
|
|
1652
|
+
loadedRows,
|
|
1653
|
+
hasNextPage,
|
|
1654
|
+
cap = LIST_LIMIT,
|
|
1655
|
+
threshold = PAGE_DEMAND_THRESHOLD,
|
|
1656
|
+
}) {
|
|
1657
|
+
if (hasNextPage !== true) return false;
|
|
1658
|
+
if (loadedRows >= cap) return false;
|
|
1659
|
+
return Number.isSafeInteger(selectedIndex) && selectedIndex >= loadedRows - threshold;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// Demand is a page count, not an event, which is what makes it coalesce:
|
|
1663
|
+
// holding `j` near the end asks for one more page however many times it fires.
|
|
1664
|
+
function demandedPageCount(previous, {
|
|
1665
|
+
selectedIndex,
|
|
1666
|
+
loadedRows,
|
|
1667
|
+
hasNextPage,
|
|
1668
|
+
cap = LIST_LIMIT,
|
|
1669
|
+
pageSize = GRAPHQL_PAGE_SIZE,
|
|
1670
|
+
}) {
|
|
1671
|
+
const current = Number.isSafeInteger(previous) && previous > 0 ? previous : 1;
|
|
1672
|
+
const max = Math.max(1, Math.ceil(cap / pageSize));
|
|
1673
|
+
if (!paginationDemand({ selectedIndex, loadedRows, hasNextPage, cap })) {
|
|
1674
|
+
return Math.min(current, max);
|
|
1675
|
+
}
|
|
1676
|
+
return Math.min(Math.max(current, Math.ceil(loadedRows / pageSize) + 1), max);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function mergeDemandedPages({ pages = [], cap = LIST_LIMIT, keyOf = itemKey }) {
|
|
1680
|
+
const list = (Array.isArray(pages) ? pages : []).filter(Boolean);
|
|
1681
|
+
const base = list[0] ?? null;
|
|
1682
|
+
const rows = [];
|
|
1683
|
+
const seen = new Set();
|
|
1684
|
+
let accepted = 0;
|
|
1685
|
+
let hasNextPage = false;
|
|
1686
|
+
let capped = false;
|
|
1687
|
+
for (const value of list) {
|
|
1688
|
+
if (value.generation !== base.generation) break;
|
|
1689
|
+
accepted += 1;
|
|
1690
|
+
hasNextPage = value.pageInfo?.hasNextPage === true;
|
|
1691
|
+
for (const row of value.rows ?? []) {
|
|
1692
|
+
const key = keyOf(row);
|
|
1693
|
+
// A node can legitimately appear on two pages when the list shifted
|
|
1694
|
+
// between them. Publishing it twice is a rendering bug, not extra data.
|
|
1695
|
+
if (key != null) {
|
|
1696
|
+
if (seen.has(key)) continue;
|
|
1697
|
+
seen.add(key);
|
|
1698
|
+
}
|
|
1699
|
+
if (rows.length >= cap) { capped = true; break; }
|
|
1700
|
+
rows.push(row);
|
|
1701
|
+
}
|
|
1702
|
+
if (capped) break;
|
|
1703
|
+
}
|
|
1704
|
+
return {
|
|
1705
|
+
rows,
|
|
1706
|
+
pages: accepted,
|
|
1707
|
+
// A capped or short merge is not a complete list, and must never render as
|
|
1708
|
+
// one: "50 of 999" and "50 open issues" are different claims.
|
|
1709
|
+
hasNextPage: hasNextPage || capped,
|
|
1710
|
+
incomplete: hasNextPage || capped || accepted < list.length,
|
|
1711
|
+
generation: base?.generation ?? null,
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// Acquires exactly the pages demand asks for, one explicitly admitted page at a
|
|
1716
|
+
// time. The first page is covered by the tab's own reservation; each later page
|
|
1717
|
+
// reserves its own envelope, so a denial there is an ordinary scheduling
|
|
1718
|
+
// outcome rather than a hidden overspend. Rows already collected survive that
|
|
1719
|
+
// denial and the result is marked incomplete rather than published as complete.
|
|
1495
1720
|
async function fetchGraphqlList(kind, mapRow, {
|
|
1496
1721
|
signal,
|
|
1497
1722
|
governor = null,
|
|
1498
1723
|
previousRaw = null,
|
|
1724
|
+
// How many pages the viewport has actually asked for. One, until a cursor
|
|
1725
|
+
// comes within PAGE_DEMAND_THRESHOLD rows of the end. The walk used to run to
|
|
1726
|
+
// LIST_LIMIT on the very first paint -- three requests for a pane showing
|
|
1727
|
+
// about twenty rows, on every tab, forever.
|
|
1728
|
+
pages = 1,
|
|
1499
1729
|
// Seams, so the paging loop can be exercised without a live governor: the
|
|
1500
1730
|
// settlement figure it produces was wrong for months of repositories with
|
|
1501
1731
|
// more than one page, and nothing could see it.
|
|
@@ -1510,7 +1740,6 @@ async function fetchGraphqlList(kind, mapRow, {
|
|
|
1510
1740
|
error.budgetObservations = first.observations;
|
|
1511
1741
|
throw error;
|
|
1512
1742
|
}
|
|
1513
|
-
const rows = [];
|
|
1514
1743
|
const observations = [...first.observations];
|
|
1515
1744
|
// Only the first page. Pages past it opened their own reservation inside
|
|
1516
1745
|
// runAdmittedOperation and settle against it, so adding them here charges the
|
|
@@ -1519,20 +1748,32 @@ async function fetchGraphqlList(kind, mapRow, {
|
|
|
1519
1748
|
// budget observation the fetch gathered.
|
|
1520
1749
|
const envelopeSpent = first.observedCost;
|
|
1521
1750
|
let spent = first.observedCost;
|
|
1522
|
-
let page = graphqlConnection(first, connection);
|
|
1523
|
-
let incomplete = false;
|
|
1524
1751
|
let overrun = first.overrun;
|
|
1525
|
-
let
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1752
|
+
let current = graphqlConnection(first, connection);
|
|
1753
|
+
const totalCount = current?.totalCount ?? null;
|
|
1754
|
+
const generation = pageGeneration((current?.nodes ?? []).map(mapRow));
|
|
1755
|
+
const wanted = Math.min(
|
|
1756
|
+
Math.max(1, Number.isSafeInteger(pages) ? pages : 1),
|
|
1757
|
+
Math.max(1, Math.ceil(LIST_LIMIT / GRAPHQL_PAGE_SIZE)),
|
|
1758
|
+
);
|
|
1759
|
+
const acquired = [];
|
|
1760
|
+
const collect = (value) => acquired.push({
|
|
1761
|
+
generation,
|
|
1762
|
+
rows: (value?.nodes ?? []).map(mapRow),
|
|
1763
|
+
pageInfo: value?.pageInfo ?? null,
|
|
1764
|
+
});
|
|
1765
|
+
let truncatedWalk = false;
|
|
1766
|
+
if (current) collect(current); else truncatedWalk = true;
|
|
1767
|
+
while (acquired.length < wanted && current?.pageInfo?.hasNextPage) {
|
|
1768
|
+
const cursor = current.pageInfo.endCursor;
|
|
1769
|
+
if (typeof cursor !== "string" || cursor.length === 0) { truncatedWalk = true; break; }
|
|
1770
|
+
if (!governor) { truncatedWalk = true; break; }
|
|
1531
1771
|
const admitted = await admit({
|
|
1532
1772
|
...governor,
|
|
1533
1773
|
operation: `page:${kind}`,
|
|
1534
1774
|
priority: "background",
|
|
1535
1775
|
signal,
|
|
1776
|
+
waitMs: GOVERNOR_ADMISSION_WAIT_MS,
|
|
1536
1777
|
run: (admittedSignal) => fetchPage(kind, { signal: admittedSignal, after: cursor, operation: `page:${kind}` }),
|
|
1537
1778
|
});
|
|
1538
1779
|
// A later page that is denied, fails, or returns errors leaves the rows
|
|
@@ -1548,47 +1789,57 @@ async function fetchGraphqlList(kind, mapRow, {
|
|
|
1548
1789
|
// it will not do.
|
|
1549
1790
|
if (admitted.value?.observations) observations.push(...admitted.value.observations);
|
|
1550
1791
|
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;
|
|
1792
|
+
if (!admitted.ok || !admitted.value?.ok) { truncatedWalk = true; break; }
|
|
1793
|
+
spent += admitted.value.observedCost;
|
|
1794
|
+
current = graphqlConnection(admitted.value, connection);
|
|
1795
|
+
if (!current) { truncatedWalk = true; break; }
|
|
1796
|
+
collect(current);
|
|
1797
|
+
}
|
|
1559
1798
|
if (overrun) {
|
|
1560
1799
|
// Recorded at its real cost above; suspended here until the resource resets,
|
|
1561
1800
|
// which is the soonest a corrected bound could be trusted again.
|
|
1562
1801
|
const reset = observations.at(-1)?.resetMs;
|
|
1563
1802
|
pauseOperation(operation, Number.isFinite(reset) ? reset + BUDGET_RESET_GRACE_MS : Date.now() + BUDGET_PROBE_MS);
|
|
1564
1803
|
}
|
|
1565
|
-
const
|
|
1804
|
+
const merged = mergeDemandedPages({ pages: acquired, cap: LIST_LIMIT });
|
|
1805
|
+
const incomplete = merged.incomplete || truncatedWalk;
|
|
1566
1806
|
// 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
|
-
|
|
1807
|
+
// and the cache both compare on, so it must cover exactly what is rendered --
|
|
1808
|
+
// including the markers that say the list is not all of it.
|
|
1809
|
+
const raw = JSON.stringify({
|
|
1810
|
+
v: GRAPHQL_QUERY_VERSION,
|
|
1811
|
+
totalCount,
|
|
1812
|
+
incomplete,
|
|
1813
|
+
hasNextPage: merged.hasNextPage,
|
|
1814
|
+
rows: merged.rows,
|
|
1815
|
+
});
|
|
1569
1816
|
return {
|
|
1570
1817
|
raw: raw === previousRaw ? previousRaw : raw,
|
|
1571
1818
|
// An incomplete walk reports the rows it has as its own limit, so the
|
|
1572
1819
|
// existing truncation indicator fires. "More rows exist than are shown" is
|
|
1573
1820
|
// exactly what incomplete means, and publishing 50 of 150 rows as a
|
|
1574
1821
|
// complete tab is indistinguishable from a repository with 50 open issues.
|
|
1575
|
-
limit: incomplete ?
|
|
1822
|
+
limit: incomplete ? merged.rows.length : LIST_LIMIT,
|
|
1576
1823
|
restSpent: 0,
|
|
1577
1824
|
graphqlSpent: envelopeSpent,
|
|
1578
1825
|
// What the whole walk cost, for reporting. Never the settlement figure.
|
|
1579
1826
|
graphqlSpentTotal: spent,
|
|
1580
1827
|
observations,
|
|
1581
1828
|
incomplete,
|
|
1582
|
-
|
|
1829
|
+
totalCount,
|
|
1830
|
+
hasNextPage: merged.hasNextPage,
|
|
1831
|
+
generation: merged.generation,
|
|
1832
|
+
loadedPages: merged.pages,
|
|
1833
|
+
parse: () => merged.rows,
|
|
1583
1834
|
};
|
|
1584
1835
|
}
|
|
1585
1836
|
|
|
1586
|
-
async function fetchIssues(signal, { governor = null, previousRaw = null } = {}) {
|
|
1587
|
-
return fetchGraphqlList("issues", ISSUE_ROW, { signal, governor, previousRaw });
|
|
1837
|
+
async function fetchIssues(signal, { governor = null, previousRaw = null, pages = 1 } = {}) {
|
|
1838
|
+
return fetchGraphqlList("issues", ISSUE_ROW, { signal, governor, previousRaw, pages });
|
|
1588
1839
|
}
|
|
1589
1840
|
|
|
1590
|
-
async function fetchPRs(signal, { governor = null, previousRaw = null } = {}) {
|
|
1591
|
-
return fetchGraphqlList("prs", PR_ROW, { signal, governor, previousRaw });
|
|
1841
|
+
async function fetchPRs(signal, { governor = null, previousRaw = null, pages = 1 } = {}) {
|
|
1842
|
+
return fetchGraphqlList("prs", PR_ROW, { signal, governor, previousRaw, pages });
|
|
1592
1843
|
}
|
|
1593
1844
|
|
|
1594
1845
|
// The three alert endpoints were three near-identical 26-line blocks that had
|
|
@@ -1662,10 +1913,10 @@ const ALERT_SOURCES = [
|
|
|
1662
1913
|
// `projectedHourlyCost` derives the hourly figure from it and the spend meter
|
|
1663
1914
|
// bills against it, so a corrected number cannot reach one and miss the other.
|
|
1664
1915
|
//
|
|
1665
|
-
// actions
|
|
1666
|
-
//
|
|
1667
|
-
//
|
|
1668
|
-
//
|
|
1916
|
+
// actions was 2 for as long as every fetch made both calls: measured
|
|
1917
|
+
// 2026-08-10 with `GH_DEBUG=api`, `gh run list` issued GET /actions/runs *and*
|
|
1918
|
+
// GET /actions/workflows. The catalog is now a conditional fallback with its
|
|
1919
|
+
// own reservation, so the tab reserves for the one call it always makes.
|
|
1669
1920
|
//
|
|
1670
1921
|
// issues and prs are 0 REST because SORT_RECENT's --search routes both through
|
|
1671
1922
|
// GraphQL entirely (2 POSTs each, confirmed by the same measurement).
|
|
@@ -1673,7 +1924,10 @@ const SECURITY_REQUESTS_PER_FETCH = ALERT_SOURCES.reduce(
|
|
|
1673
1924
|
(total, source) => total + 1 + source.priorityQueries.length,
|
|
1674
1925
|
0,
|
|
1675
1926
|
);
|
|
1676
|
-
|
|
1927
|
+
// Actions is one request now: the workflow catalog became a conditional
|
|
1928
|
+
// fallback that opens its own reservation, so the tab no longer reserves for
|
|
1929
|
+
// a second call it usually does not make.
|
|
1930
|
+
const REST_PER_FETCH = { actions: 1, issues: 0, prs: 0, security: SECURITY_REQUESTS_PER_FETCH };
|
|
1677
1931
|
const GRAPHQL_PER_FETCH = { actions: 0, issues: 2, prs: 2, security: 0 };
|
|
1678
1932
|
|
|
1679
1933
|
function tabRequestCost(tab) {
|
|
@@ -1688,7 +1942,9 @@ function tabRequestCost(tab) {
|
|
|
1688
1942
|
const OPERATION_COSTS = Object.freeze({
|
|
1689
1943
|
...Object.fromEntries(TAB_KEYS.map((tab) => [`tab:${tab}`, tabRequestCost(tab)])),
|
|
1690
1944
|
"tab:actions-runs": { core: 1, graphql: 0 },
|
|
1691
|
-
|
|
1945
|
+
// Separately admitted, like a later list page: only fetched when a run has
|
|
1946
|
+
// no name of its own, and settled against its own reservation.
|
|
1947
|
+
"catalog:actions-workflows": { core: 1, graphql: 0 },
|
|
1692
1948
|
"tab:security-endpoint": { core: 1, graphql: 0 },
|
|
1693
1949
|
"failure-context:repository": { core: 0, graphql: GRAPHQL_OBSERVER_POINTS },
|
|
1694
1950
|
// Each page past the first reserves its own envelope, so a denial there is a
|
|
@@ -3072,7 +3328,14 @@ function retryThrottledTransport(root, host, { now = Date.now } = {}) {
|
|
|
3072
3328
|
|
|
3073
3329
|
function identityCoordinationMessage(reason) {
|
|
3074
3330
|
if (reason === "restart-required") return "Restart required: close older gh-glance panes";
|
|
3075
|
-
|
|
3331
|
+
// These two were one message for a long time, and the difference between them
|
|
3332
|
+
// is the difference between waiting and being stuck. A migration hold has a
|
|
3333
|
+
// deadline and ends by itself. An unresolved legacy ledger has no deadline at
|
|
3334
|
+
// all -- it holds unsettled spend with no reset recorded to wait for -- so the
|
|
3335
|
+
// same wording had people waiting weeks for something that was never going to
|
|
3336
|
+
// arrive. Say which one it is.
|
|
3337
|
+
if (reason === "migration-hold") return "Upgrade waiting for legacy quota reset";
|
|
3338
|
+
if (reason === "legacy-unresolved") return "Upgrade blocked: older spend cannot be settled or waited out";
|
|
3076
3339
|
if (["legacy-corrupt", "corrupt"].includes(reason)) return "Coordination state unavailable; evidence preserved";
|
|
3077
3340
|
if (reason === "transport-busy") return "Shared HTTP request or cooldown in progress";
|
|
3078
3341
|
if (reason === "throttle-paused") return "Paused after repeated rate limits; refresh to retry";
|
|
@@ -3098,17 +3361,23 @@ const GOVERNOR_SCOPE_VERSION = 1;
|
|
|
3098
3361
|
// 5: scheduling fairness is persisted, because the starvation it prevents
|
|
3099
3362
|
// happens *between* planning passes -- each manual refresh is its own pass, so
|
|
3100
3363
|
// a counter that lives only inside one cannot see a run of them.
|
|
3101
|
-
const GOVERNOR_STATE_VERSION =
|
|
3364
|
+
const GOVERNOR_STATE_VERSION = 6;
|
|
3102
3365
|
// Readable-as-evidence, never written: the shapes a still-running older pane
|
|
3103
3366
|
// holds. Recognising that such a pane owns a live lease is what the restart
|
|
3104
3367
|
// boundary depends on.
|
|
3105
|
-
const LEGACY_GOVERNOR_VERSIONS = [GOVERNOR_STATE_VERSION, 4, 3, 2];
|
|
3368
|
+
const LEGACY_GOVERNOR_VERSIONS = [GOVERNOR_STATE_VERSION, 5, 4, 3, 2];
|
|
3106
3369
|
// The first version whose file is already in the per-resource observer shape.
|
|
3107
3370
|
// Anything below it is adapted before it can be read; see readLegacyGovernorState.
|
|
3108
3371
|
const GOVERNOR_OBSERVER_SPLIT_VERSION = 4;
|
|
3109
3372
|
// The first version carrying persisted scheduling fairness. A version 4 file is
|
|
3110
3373
|
// otherwise in the current shape and only wants the field defaulted.
|
|
3111
3374
|
const GOVERNOR_FAIRNESS_VERSION = 5;
|
|
3375
|
+
// The first version whose intents are priced by this build's cost table. An
|
|
3376
|
+
// intent must declare exactly what its tab costs, so a build that re-prices a
|
|
3377
|
+
// tab cannot read a live older pane's pending intents -- and that pane's file is
|
|
3378
|
+
// precisely the one carrying the leases and uncertain debts this build must see.
|
|
3379
|
+
// Bumping the version is what turns "corrupt" back into "older, and adaptable".
|
|
3380
|
+
const GOVERNOR_TAB_COST_VERSION = 6;
|
|
3112
3381
|
// Manual work outranks active work, which is right until it means "always". A
|
|
3113
3382
|
// user holding the refresh key issues one manual intent per planning pass, and
|
|
3114
3383
|
// without a bound the active owner behind them never gets a turn at all.
|
|
@@ -3474,14 +3743,41 @@ function normalizeGovernorState(raw, nowMs, { prune = true, acceptVersion = GOVE
|
|
|
3474
3743
|
function readLegacyGovernorState(raw, nowMs, version) {
|
|
3475
3744
|
if (!isRecord(raw) || raw.version !== version) return null;
|
|
3476
3745
|
const options = { prune: false, acceptVersion: version };
|
|
3477
|
-
if (version >=
|
|
3478
|
-
// A version
|
|
3479
|
-
//
|
|
3480
|
-
//
|
|
3481
|
-
const shaped = version >= GOVERNOR_OBSERVER_SPLIT_VERSION
|
|
3746
|
+
if (version >= GOVERNOR_TAB_COST_VERSION) return normalizeGovernorState(raw, nowMs, options);
|
|
3747
|
+
// A version 5 file is in the current shape and differs only in what it
|
|
3748
|
+
// believes a tab costs. Version 4 additionally wants the fairness field
|
|
3749
|
+
// defaulted; anything older needs its observer shape rewritten first.
|
|
3750
|
+
const shaped = version >= GOVERNOR_OBSERVER_SPLIT_VERSION
|
|
3751
|
+
? { ...raw }
|
|
3752
|
+
: adaptPreSplitGovernorShape(raw);
|
|
3482
3753
|
if (!shaped) return null;
|
|
3483
|
-
shaped.fairness = { manualStreak: 0 };
|
|
3484
|
-
return normalizeGovernorState(shaped, nowMs, options);
|
|
3754
|
+
if (version < GOVERNOR_FAIRNESS_VERSION) shaped.fairness = { manualStreak: 0 };
|
|
3755
|
+
return normalizeGovernorState(dropSupersededTabCosts(shaped), nowMs, options);
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
// An intent priced by a build that costed its tab differently. It is a pending
|
|
3759
|
+
// request and nothing more: an intent still in the file has not been granted,
|
|
3760
|
+
// because scheduleGovernorState deletes each one as it creates its reservation.
|
|
3761
|
+
// So dropping it releases nothing and forgives nothing -- the pane that
|
|
3762
|
+
// registered it re-registers at the current price on its next wake. Re-pricing
|
|
3763
|
+
// it here would be the one unsafe option, because the older pane will still make
|
|
3764
|
+
// the number of calls its own build declares.
|
|
3765
|
+
function dropSupersededTabCosts(raw) {
|
|
3766
|
+
if (!isRecord(raw.intents)) return raw;
|
|
3767
|
+
const intents = {};
|
|
3768
|
+
for (const [id, intent] of Object.entries(raw.intents)) {
|
|
3769
|
+
const expected = TAB_KEYS.includes(intent?.tab)
|
|
3770
|
+
? tabRequestCost(intent.tab)
|
|
3771
|
+
: operationCost(intent?.tab);
|
|
3772
|
+
const declared = exactResourceCosts(intent?.costs);
|
|
3773
|
+
// Anything unrecognisable is left exactly as it is, so normalization still
|
|
3774
|
+
// rejects a genuinely corrupt document rather than having it quietly pruned.
|
|
3775
|
+
if (!expected || !declared ||
|
|
3776
|
+
RATE_RESOURCES.every((resource) => expected[resource] === declared[resource])) {
|
|
3777
|
+
intents[id] = intent;
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
return { ...raw, intents };
|
|
3485
3781
|
}
|
|
3486
3782
|
|
|
3487
3783
|
// Versions 2 and 3 held one observer for core, a differently shaped
|
|
@@ -3548,7 +3844,7 @@ function migrateGovernorState(raw, nowMs) {
|
|
|
3548
3844
|
if (!shaped) return null;
|
|
3549
3845
|
if (!isRecord(shaped.fairness)) shaped.fairness = { manualStreak: 0 };
|
|
3550
3846
|
shaped.version = GOVERNOR_STATE_VERSION;
|
|
3551
|
-
return normalizeGovernorState(shaped, nowMs);
|
|
3847
|
+
return normalizeGovernorState(dropSupersededTabCosts(shaped), nowMs);
|
|
3552
3848
|
}
|
|
3553
3849
|
|
|
3554
3850
|
function migrateV1GovernorState(raw, nowMs) {
|
|
@@ -4890,14 +5186,6 @@ function externalSampleIsUsable(sample) {
|
|
|
4890
5186
|
);
|
|
4891
5187
|
}
|
|
4892
5188
|
|
|
4893
|
-
function resourcePerTick(table, activeKey) {
|
|
4894
|
-
let cost = table[activeKey] ?? 0;
|
|
4895
|
-
for (const key of TAB_KEYS) {
|
|
4896
|
-
if (key !== activeKey) cost += (table[key] ?? 0) / BACKGROUND_EVERY;
|
|
4897
|
-
}
|
|
4898
|
-
return cost;
|
|
4899
|
-
}
|
|
4900
|
-
|
|
4901
5189
|
function alertArgs(source, path = source.path) {
|
|
4902
5190
|
return [apiPath(path), ...apiHostArgs(), "--jq", source.jq];
|
|
4903
5191
|
}
|
|
@@ -5651,14 +5939,38 @@ async function readSharedBudgetSources(signal, host = effectiveRuntimeHost(), {
|
|
|
5651
5939
|
// and left to rot. The governor can admit less demand; this projection explains
|
|
5652
5940
|
// pressure, not actual granted spend. The per-fetch prices come from
|
|
5653
5941
|
// REST_PER_FETCH and GRAPHQL_PER_FETCH, which also feed the operation registry.
|
|
5654
|
-
function projectedHourlyCost(activeKey
|
|
5655
|
-
|
|
5942
|
+
function projectedHourlyCost(activeKey, {
|
|
5943
|
+
floorMs = runtime.refreshMs,
|
|
5944
|
+
background = runtime.background,
|
|
5945
|
+
} = {}) {
|
|
5946
|
+
const perHour = (intervalMs) =>
|
|
5947
|
+
Number.isFinite(intervalMs) && intervalMs > 0 ? 3_600_000 / intervalMs : 0;
|
|
5948
|
+
const totals = { rest: { min: 0, max: 0 }, graphql: { min: 0, max: 0 } };
|
|
5949
|
+
for (const tab of TAB_KEYS) {
|
|
5950
|
+
const demand = tab === activeKey ? "active" : "inactive";
|
|
5951
|
+
// Fastest: nothing has been observed unchanged yet, and Actions has work in
|
|
5952
|
+
// flight. Slowest: the quiet cadence this tab settles into.
|
|
5953
|
+
const fastest = pollPolicyInterval({
|
|
5954
|
+
tab, floorMs, demand, unchangedCount: 0, inProgressCI: true, background,
|
|
5955
|
+
});
|
|
5956
|
+
const slowest = pollPolicyInterval({
|
|
5957
|
+
tab, floorMs, demand, unchangedCount: POLL_QUIET_AFTER, background,
|
|
5958
|
+
});
|
|
5959
|
+
totals.rest.min += REST_PER_FETCH[tab] * perHour(slowest);
|
|
5960
|
+
totals.rest.max += REST_PER_FETCH[tab] * perHour(fastest);
|
|
5961
|
+
totals.graphql.min += GRAPHQL_PER_FETCH[tab] * perHour(slowest);
|
|
5962
|
+
totals.graphql.max += GRAPHQL_PER_FETCH[tab] * perHour(fastest);
|
|
5963
|
+
}
|
|
5656
5964
|
return {
|
|
5657
|
-
rest: Math.round(
|
|
5658
|
-
graphql: Math.round(
|
|
5965
|
+
rest: { min: Math.round(totals.rest.min), max: Math.round(totals.rest.max) },
|
|
5966
|
+
graphql: { min: Math.round(totals.graphql.min), max: Math.round(totals.graphql.max) },
|
|
5659
5967
|
};
|
|
5660
5968
|
}
|
|
5661
5969
|
|
|
5970
|
+
function formatProjectedRange({ min, max }) {
|
|
5971
|
+
return min === max ? `~${max}` : `~${min}-${max}`;
|
|
5972
|
+
}
|
|
5973
|
+
|
|
5662
5974
|
function doctorProbePlan() {
|
|
5663
5975
|
// GraphQL cannot infer a repository the way `gh` does, so a report run from a
|
|
5664
5976
|
// directory with no unambiguous remote says so per probe instead of sending a
|
|
@@ -5671,7 +5983,7 @@ function doctorProbePlan() {
|
|
|
5671
5983
|
// Issues/PRs entries ran `gh issue list`/`gh pr list`, whose `--search`
|
|
5672
5984
|
// routed through GraphQL at a price the report could not name.
|
|
5673
5985
|
["Repository access", graphqlArgs(), "doctor:repository", document("repository", graphqlRepositoryVariables(target)), "repository"],
|
|
5674
|
-
["Actions runs", ghApiArgs(actionsRunsArgs(
|
|
5986
|
+
["Actions runs", ghApiArgs(actionsRunsArgs()), "doctor:actions-runs"],
|
|
5675
5987
|
["Actions workflows", ghApiArgs(actionsWorkflowsArgs()), "doctor:actions-workflows"],
|
|
5676
5988
|
["Issues (first page)", graphqlArgs(), "doctor:issues", document("issues", graphqlPageVariables(null, target)), "issues"],
|
|
5677
5989
|
["Pull requests (first page)", graphqlArgs(), "doctor:prs", document("prs", graphqlPageVariables(null, target)), "prs"],
|
|
@@ -5823,11 +6135,16 @@ async function runDoctor() {
|
|
|
5823
6135
|
field("REST core", budget.core),
|
|
5824
6136
|
field("GraphQL", budget.graphql),
|
|
5825
6137
|
field(
|
|
5826
|
-
"
|
|
6138
|
+
"projected demand",
|
|
5827
6139
|
(() => {
|
|
5828
6140
|
const active = TAB_KEYS[runtime.initialTabIndex] ?? TAB_KEYS[0];
|
|
5829
6141
|
const { rest, graphql } = projectedHourlyCost(active);
|
|
5830
|
-
|
|
6142
|
+
// A range, because the cadence now depends on what the repository is
|
|
6143
|
+
// doing. Labelled "projected" rather than "spends": the governor admits
|
|
6144
|
+
// less than this, and the API governor section below carries what was
|
|
6145
|
+
// actually charged.
|
|
6146
|
+
return `${formatProjectedRange(rest)} REST + ${formatProjectedRange(graphql)} GraphQL per hour ` +
|
|
6147
|
+
`(floor ${runtime.refreshMs / 1000}s, background ${runtime.background}, "${active}" active)`;
|
|
5831
6148
|
})(),
|
|
5832
6149
|
),
|
|
5833
6150
|
"",
|
|
@@ -5936,6 +6253,7 @@ function parseRepoTarget(value) {
|
|
|
5936
6253
|
// in-flight guard absorbs every other one and the effective rate is whatever
|
|
5937
6254
|
// `gh` can sustain -- the requested interval silently stops being real. Clamping
|
|
5938
6255
|
// with a stated minimum is honest where silently accepting it would not be.
|
|
6256
|
+
const BACKGROUND_MODES = ["all", "off"];
|
|
5939
6257
|
const MIN_REFRESH_SECONDS = 2;
|
|
5940
6258
|
const MAX_REFRESH_SECONDS = 3600;
|
|
5941
6259
|
|
|
@@ -5956,6 +6274,10 @@ function parseArgs(argv) {
|
|
|
5956
6274
|
// parseArgs returns stays stated in one place.
|
|
5957
6275
|
refreshSource: null,
|
|
5958
6276
|
tab: null,
|
|
6277
|
+
// Whether inactive tabs are polled at all. `off` never requests data for a
|
|
6278
|
+
// tab you are not looking at; its counts stay at whatever was last known,
|
|
6279
|
+
// visibly aged.
|
|
6280
|
+
background: null,
|
|
5959
6281
|
verbose: false,
|
|
5960
6282
|
};
|
|
5961
6283
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -5983,6 +6305,8 @@ function parseArgs(argv) {
|
|
|
5983
6305
|
opts.refresh = takeValue("--refresh");
|
|
5984
6306
|
} else if (arg === "--tab" || arg.startsWith("--tab=")) {
|
|
5985
6307
|
opts.tab = takeValue("--tab");
|
|
6308
|
+
} else if (arg === "--background" || arg.startsWith("--background=")) {
|
|
6309
|
+
opts.background = takeValue("--background");
|
|
5986
6310
|
} else {
|
|
5987
6311
|
throw new Error(`unknown argument: ${arg}`);
|
|
5988
6312
|
}
|
|
@@ -5990,7 +6314,7 @@ function parseArgs(argv) {
|
|
|
5990
6314
|
return opts;
|
|
5991
6315
|
}
|
|
5992
6316
|
|
|
5993
|
-
// Returns { repo, refreshMs, tabKey, verbose } or throws with a message that
|
|
6317
|
+
// Returns { repo, refreshMs, tabKey, background, verbose } or throws with a message that
|
|
5994
6318
|
// says what to do about it.
|
|
5995
6319
|
function validateArgs(opts, tabKeys) {
|
|
5996
6320
|
const { host, slug } = opts.repo !== null ? parseRepoTarget(opts.repo) : { host: null, slug: null };
|
|
@@ -6016,6 +6340,10 @@ function validateArgs(opts, tabKeys) {
|
|
|
6016
6340
|
throw new Error(`--tab must be one of ${tabKeys.join(", ")}, got: ${opts.tab}`);
|
|
6017
6341
|
}
|
|
6018
6342
|
|
|
6343
|
+
if (opts.background !== null && !BACKGROUND_MODES.includes(opts.background)) {
|
|
6344
|
+
throw new Error(`--background must be one of ${BACKGROUND_MODES.join(", ")}, got: ${opts.background}`);
|
|
6345
|
+
}
|
|
6346
|
+
|
|
6019
6347
|
return {
|
|
6020
6348
|
help: opts.help,
|
|
6021
6349
|
showVersion: opts.showVersion,
|
|
@@ -6024,6 +6352,7 @@ function validateArgs(opts, tabKeys) {
|
|
|
6024
6352
|
host,
|
|
6025
6353
|
refreshMs,
|
|
6026
6354
|
tabKey: opts.tab,
|
|
6355
|
+
background: opts.background,
|
|
6027
6356
|
verbose: opts.verbose,
|
|
6028
6357
|
};
|
|
6029
6358
|
}
|
|
@@ -6044,6 +6373,7 @@ const KEY_TABLE = [
|
|
|
6044
6373
|
["PgUp / PgDn", "Move a page at a time"],
|
|
6045
6374
|
["Enter", "Open the selected item or accept a prompt"],
|
|
6046
6375
|
["r", "Refresh the current tab when a safe grant is available"],
|
|
6376
|
+
["R", "Resynchronize the current tab, ignoring cached validators and backoff"],
|
|
6047
6377
|
["w", "Adjust table column widths"],
|
|
6048
6378
|
["?", "Show the keys (any key closes it)"],
|
|
6049
6379
|
["q / Esc / Ctrl+C", "Quit (Esc leaves width mode)"],
|
|
@@ -6051,22 +6381,39 @@ const KEY_TABLE = [
|
|
|
6051
6381
|
const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
|
|
6052
6382
|
const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
|
|
6053
6383
|
|
|
6384
|
+
// Which bindings survive a short pane, most important first. Named rather than
|
|
6385
|
+
// indexed into KEY_TABLE: the order used to be a list of positions, so adding a
|
|
6386
|
+
// key silently reassigned every entry after it -- which is exactly how `R`
|
|
6387
|
+
// pushed Quit out of the four-row overlay.
|
|
6388
|
+
const HELP_PRIORITY = [
|
|
6389
|
+
"q / Esc / Ctrl+C",
|
|
6390
|
+
"r",
|
|
6391
|
+
"Enter",
|
|
6392
|
+
"Up / Down, j / k",
|
|
6393
|
+
"1 2 3 4",
|
|
6394
|
+
"w",
|
|
6395
|
+
"?",
|
|
6396
|
+
"Left / Right",
|
|
6397
|
+
"Tab / Shift+Tab",
|
|
6398
|
+
"PgUp / PgDn",
|
|
6399
|
+
"R",
|
|
6400
|
+
];
|
|
6401
|
+
|
|
6054
6402
|
function helpLines(maxRows) {
|
|
6055
6403
|
const rows = Math.max(1, Number.isSafeInteger(maxRows) ? maxRows : 1);
|
|
6056
6404
|
const all = keyTableLines();
|
|
6405
|
+
const lineFor = (binding) => all[KEY_TABLE.findIndex(([key]) => key === binding)];
|
|
6057
6406
|
if (all.length <= rows) return all;
|
|
6058
6407
|
if (rows === 1) return [`… ${all.length} keys: gh-glance --help`];
|
|
6059
6408
|
if (rows === 4) {
|
|
6060
6409
|
return [
|
|
6061
|
-
|
|
6062
|
-
|
|
6410
|
+
lineFor("q / Esc / Ctrl+C"),
|
|
6411
|
+
lineFor("r"),
|
|
6063
6412
|
"Ent/jk Open the selected/Move the cursor",
|
|
6064
6413
|
`… ${all.length - 4} more: gh-glance --help`,
|
|
6065
6414
|
];
|
|
6066
6415
|
}
|
|
6067
|
-
const priority =
|
|
6068
|
-
.slice(0, rows - 1)
|
|
6069
|
-
.map((index) => all[index]);
|
|
6416
|
+
const priority = HELP_PRIORITY.slice(0, rows - 1).map(lineFor);
|
|
6070
6417
|
return [...priority, `… ${all.length - priority.length} more: gh-glance --help`];
|
|
6071
6418
|
}
|
|
6072
6419
|
|
|
@@ -6077,6 +6424,7 @@ Usage:
|
|
|
6077
6424
|
gh-glance --repo owner/name Watch a specific repository instead
|
|
6078
6425
|
gh-glance --refresh 15 Set a 15-second active-tab poll floor
|
|
6079
6426
|
gh-glance --tab security Start on a specific tab
|
|
6427
|
+
gh-glance --background off Poll only the tab you are looking at
|
|
6080
6428
|
gh-glance --verbose 2>log Log every gh call to a file (see below)
|
|
6081
6429
|
gh-glance --doctor Print a diagnostic report and exit
|
|
6082
6430
|
gh-glance --help Show this help
|
|
@@ -6091,10 +6439,14 @@ Options:
|
|
|
6091
6439
|
and is unnecessary when running inside a clone of
|
|
6092
6440
|
that repository.
|
|
6093
6441
|
--refresh <seconds> Minimum active-tab poll interval (${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS},
|
|
6094
|
-
default ${REFRESH_MS / 1000}).
|
|
6095
|
-
|
|
6096
|
-
every ${
|
|
6442
|
+
default ${REFRESH_MS / 1000}). A floor, never a ceiling:
|
|
6443
|
+
a quiet tab slows to ${POLL_QUIET_MS.actions / 1000}s (${POLL_QUIET_MS.security / 1000}s for Security),
|
|
6444
|
+
and running Actions are checked every ${POLL_ACTIVE_CI_MS / 1000}s.
|
|
6445
|
+
Safe shared grants may run later.
|
|
6097
6446
|
--tab <name> Tab to start on: ${TAB_KEYS.join(", ")}.
|
|
6447
|
+
--background <mode> ${BACKGROUND_MODES.join(" or ")} (default all). \`off\` never requests data
|
|
6448
|
+
for a tab you are not looking at; its count stays at
|
|
6449
|
+
the last known value and visibly ages.
|
|
6098
6450
|
--verbose Write one line per gh invocation to stderr. stderr
|
|
6099
6451
|
must be redirected -- writing it to the terminal
|
|
6100
6452
|
would draw over the dashboard, so this refuses to
|
|
@@ -6110,10 +6462,12 @@ Run it from inside a locally cloned GitHub repository; the repo is inferred
|
|
|
6110
6462
|
from the git remote, the same way \`gh\` does it. Requires the \`gh\` CLI
|
|
6111
6463
|
(2.20 or newer), authenticated via \`gh auth login\`.
|
|
6112
6464
|
|
|
6113
|
-
On a healthy single pane, the active tab is considered every ${REFRESH_MS / 1000}s
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6465
|
+
On a healthy single pane, the active tab is considered every ${REFRESH_MS / 1000}s while its
|
|
6466
|
+
content is changing, then every ${POLL_QUIET_MS.actions / 1000}s once two checks in a row come back
|
|
6467
|
+
unchanged; each background tab is considered about every ${Math.max(REFRESH_MS * BACKGROUND_EVERY, POLL_BACKGROUND_MS.actions) / 1000}s. Quota-consuming
|
|
6468
|
+
calls still need a shared, resource-specific grant that preserves a hard reserve.
|
|
6469
|
+
Scheduled checks and manual refresh do not bypass that safety check: neither
|
|
6470
|
+
refresh key can spend past the reserve.
|
|
6117
6471
|
|
|
6118
6472
|
Row icons are GitHub Octicons and need a Nerd Font. Without one, set
|
|
6119
6473
|
GH_GLANCE_ICONS=unicode for Unicode status glyphs and text row substitutes, or
|
|
@@ -6188,6 +6542,7 @@ if (IS_MAIN) {
|
|
|
6188
6542
|
runtime.verbose = opts.verbose;
|
|
6189
6543
|
if (opts.refreshMs !== null) runtime.refreshMs = opts.refreshMs;
|
|
6190
6544
|
if (opts.tabKey !== null) runtime.initialTabIndex = TAB_KEYS.indexOf(opts.tabKey);
|
|
6545
|
+
if (opts.background !== null) runtime.background = opts.background;
|
|
6191
6546
|
|
|
6192
6547
|
// Checked after parsing rather than before, so `gh-glance --repo` with no
|
|
6193
6548
|
// value reports the missing value rather than silently printing help.
|
|
@@ -6926,7 +7281,7 @@ const MemoSecurityRow = React.memo(SecurityRow);
|
|
|
6926
7281
|
const TABS = [
|
|
6927
7282
|
{
|
|
6928
7283
|
key: "actions",
|
|
6929
|
-
fetch: ({ signal,
|
|
7284
|
+
fetch: ({ signal, ...options }) => fetchActions(signal, options),
|
|
6930
7285
|
label: "Actions",
|
|
6931
7286
|
short: "Actions",
|
|
6932
7287
|
header: ACTIONS_HEADER,
|
|
@@ -6936,7 +7291,8 @@ const TABS = [
|
|
|
6936
7291
|
},
|
|
6937
7292
|
{
|
|
6938
7293
|
key: "issues",
|
|
6939
|
-
fetch: ({ signal, governor, previousRaw }) =>
|
|
7294
|
+
fetch: ({ signal, governor, previousRaw, pages }) =>
|
|
7295
|
+
fetchIssues(signal, { governor, previousRaw, pages }),
|
|
6940
7296
|
label: "Issues",
|
|
6941
7297
|
short: "Issues",
|
|
6942
7298
|
header: ISSUES_HEADER,
|
|
@@ -6946,7 +7302,8 @@ const TABS = [
|
|
|
6946
7302
|
},
|
|
6947
7303
|
{
|
|
6948
7304
|
key: "prs",
|
|
6949
|
-
fetch: ({ signal, governor, previousRaw }) =>
|
|
7305
|
+
fetch: ({ signal, governor, previousRaw, pages }) =>
|
|
7306
|
+
fetchPRs(signal, { governor, previousRaw, pages }),
|
|
6950
7307
|
label: "Pull requests",
|
|
6951
7308
|
short: "PRs",
|
|
6952
7309
|
header: PRS_HEADER,
|
|
@@ -7781,19 +8138,92 @@ function nextSecurityRaw(previousRaw, raw, blind) {
|
|
|
7781
8138
|
}
|
|
7782
8139
|
|
|
7783
8140
|
const CACHE_FRESHNESS_CHECKPOINT_MS = 60_000;
|
|
7784
|
-
const SECURITY_UNCHANGED_POLL_MS = 60_000;
|
|
7785
8141
|
|
|
7786
8142
|
function shouldCheckpointFreshness({ persistedAt, completedAt }) {
|
|
7787
8143
|
return !Number.isFinite(persistedAt) || completedAt - persistedAt >= CACHE_FRESHNESS_CHECKPOINT_MS;
|
|
7788
8144
|
}
|
|
7789
8145
|
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
8146
|
+
// The one place the cadence table is decided. It used to be spread across a
|
|
8147
|
+
// fixed active floor, a fixed four-floor background slot, and a Security-only
|
|
8148
|
+
// unchanged rule -- three policies that could disagree, and did: Security
|
|
8149
|
+
// slowed after a single unchanged poll while every other tab never slowed at
|
|
8150
|
+
// all. Everything now derives from this function, so a resource's cadence is
|
|
8151
|
+
// stated once and read everywhere.
|
|
8152
|
+
function pollPolicyInterval({
|
|
8153
|
+
tab,
|
|
8154
|
+
floorMs,
|
|
8155
|
+
demand = "active",
|
|
8156
|
+
unchangedCount = 0,
|
|
8157
|
+
inProgressCI = false,
|
|
8158
|
+
background = "all",
|
|
8159
|
+
}) {
|
|
8160
|
+
if (demand === "none") return Number.POSITIVE_INFINITY;
|
|
8161
|
+
if (demand === "inactive") {
|
|
8162
|
+
if (background === "off") return Number.POSITIVE_INFINITY;
|
|
8163
|
+
return Math.max(BACKGROUND_EVERY * floorMs, pick(POLL_BACKGROUND_MS, tab, 0));
|
|
8164
|
+
}
|
|
8165
|
+
// Work in flight outranks the quiet counter: a repository whose list has not
|
|
8166
|
+
// changed in an hour still has a run finishing right now.
|
|
8167
|
+
if (tab === "actions" && inProgressCI === true) return Math.max(floorMs, POLL_ACTIVE_CI_MS);
|
|
8168
|
+
if (unchangedCount >= POLL_QUIET_AFTER) return Math.max(floorMs, pick(POLL_QUIET_MS, tab, 0));
|
|
8169
|
+
return floorMs;
|
|
8170
|
+
}
|
|
8171
|
+
|
|
8172
|
+
// Only a validated observation moves this counter. An error, a blind Security
|
|
8173
|
+
// source, or an unusable payload is the absence of evidence, not evidence of
|
|
8174
|
+
// quiet -- counting them would slow a tab precisely because it is broken.
|
|
8175
|
+
function advanceUnchangedCount(previous, outcome) {
|
|
8176
|
+
const current = Number.isSafeInteger(previous) && previous > 0 ? previous : 0;
|
|
8177
|
+
if (outcome === "unchanged") return current + 1;
|
|
8178
|
+
if (outcome === "changed" || outcome === "subscribed") return 0;
|
|
8179
|
+
return current;
|
|
8180
|
+
}
|
|
8181
|
+
|
|
8182
|
+
// Two manual intentions, deliberately separated. `r` used to mean both "check
|
|
8183
|
+
// now" and "throw away every validator", so the key that a user presses on a
|
|
8184
|
+
// quiet repository -- the one case where a conditional check is free -- was the
|
|
8185
|
+
// key guaranteed to spend.
|
|
8186
|
+
function refreshIntent(input, { widthMode = false } = {}) {
|
|
8187
|
+
// Width mode owns both keys: `r` resets the selected column and `R` resets
|
|
8188
|
+
// the tab's widths. Leaking a refresh into either would make a layout action
|
|
8189
|
+
// spend quota.
|
|
8190
|
+
if (widthMode === true) return null;
|
|
8191
|
+
if (input === "r") return "conditional";
|
|
8192
|
+
if (input === "R") return "resync";
|
|
8193
|
+
return null;
|
|
8194
|
+
}
|
|
8195
|
+
|
|
8196
|
+
function manualRefreshRequest(intent) {
|
|
8197
|
+
if (intent === "conditional") {
|
|
8198
|
+
return { kind: "manual", force: false, dropValidators: false, clearCapabilityBackoff: false };
|
|
8199
|
+
}
|
|
8200
|
+
if (intent === "resync") {
|
|
8201
|
+
return { kind: "manual", force: true, dropValidators: true, clearCapabilityBackoff: true };
|
|
8202
|
+
}
|
|
8203
|
+
return null;
|
|
8204
|
+
}
|
|
8205
|
+
|
|
8206
|
+
// A press during automatic work either rides that work or schedules exactly one
|
|
8207
|
+
// follow-up. Without the follow-up, pressing `r` while a poll that started
|
|
8208
|
+
// before the press was still running silently did nothing.
|
|
8209
|
+
function planManualRefresh({ requestedAt, inFlightStartedAt = null, followUpPending = false }) {
|
|
8210
|
+
if (!Number.isFinite(inFlightStartedAt)) return { join: false, start: true, followUp: false };
|
|
8211
|
+
if (inFlightStartedAt >= requestedAt) return { join: true, start: false, followUp: false };
|
|
8212
|
+
return { join: followUpPending, start: false, followUp: !followUpPending };
|
|
7793
8213
|
}
|
|
7794
8214
|
|
|
7795
|
-
|
|
7796
|
-
|
|
8215
|
+
// A 304 whose entity is gone is a broken pair, not data. One unconditional
|
|
8216
|
+
// recovery, separately admitted, and then the tab says so -- because the
|
|
8217
|
+
// alternative is a conditional request that can never be satisfied, retried at
|
|
8218
|
+
// the poll cadence forever.
|
|
8219
|
+
function conditionalRecoveryPlan({ status, entity, hasEntity = typeof entity === "string" && entity.length > 0, attempts = 0 }) {
|
|
8220
|
+
if (status !== 304 || hasEntity === true) return { recover: false, unusable: false };
|
|
8221
|
+
if (!Number.isSafeInteger(attempts) || attempts <= 0) return { recover: true, unusable: false };
|
|
8222
|
+
return { recover: false, unusable: true };
|
|
8223
|
+
}
|
|
8224
|
+
|
|
8225
|
+
function shouldShowFetchLoading({ hasData, manual }) {
|
|
8226
|
+
return !hasData || manual === true;
|
|
7797
8227
|
}
|
|
7798
8228
|
|
|
7799
8229
|
function tabHold(tab, heldResources = {}) {
|
|
@@ -7813,82 +8243,92 @@ function tabHold(tab, heldResources = {}) {
|
|
|
7813
8243
|
};
|
|
7814
8244
|
}
|
|
7815
8245
|
|
|
8246
|
+
function rotateFrom(keys, index) {
|
|
8247
|
+
if (keys.length === 0) return keys;
|
|
8248
|
+
const start = ((Number.isSafeInteger(index) ? index : 0) % keys.length + keys.length) % keys.length;
|
|
8249
|
+
return keys.map((_, offset) => keys[(start + offset) % keys.length]);
|
|
8250
|
+
}
|
|
8251
|
+
|
|
8252
|
+
// Every tab now carries its own deadline rather than sharing one active slot
|
|
8253
|
+
// and one rotating background slot. That is what lets the cadence differ per
|
|
8254
|
+
// resource: a quiet Security tab at 300s and an Actions tab watching a running
|
|
8255
|
+
// job at 5s cannot be expressed by two shared timers.
|
|
7816
8256
|
function pollSchedule({
|
|
7817
8257
|
nowMs,
|
|
7818
8258
|
floorMs,
|
|
7819
8259
|
activeKey,
|
|
7820
|
-
|
|
7821
|
-
backgroundAt = nowMs + 4 * floorMs,
|
|
8260
|
+
dueAt = {},
|
|
7822
8261
|
backgroundIndex = 0,
|
|
7823
8262
|
heldResources = {},
|
|
8263
|
+
background = "all",
|
|
8264
|
+
states = {},
|
|
7824
8265
|
}) {
|
|
7825
8266
|
const due = [];
|
|
7826
|
-
|
|
7827
|
-
|
|
8267
|
+
const next = {};
|
|
8268
|
+
const backgroundKeys = TAB_KEYS.filter((key) => key !== activeKey);
|
|
7828
8269
|
let nextBackgroundIndex = backgroundIndex;
|
|
7829
|
-
|
|
7830
|
-
|
|
7831
|
-
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
|
|
7842
|
-
|
|
7843
|
-
|
|
7844
|
-
|
|
7845
|
-
|
|
7846
|
-
|
|
7847
|
-
}
|
|
8270
|
+
// At most one inactive tab per wake, still round robin. Three inactive tabs
|
|
8271
|
+
// all becoming due together would otherwise open three concurrent panes of
|
|
8272
|
+
// demand against one lane slot.
|
|
8273
|
+
let backgroundTaken = false;
|
|
8274
|
+
|
|
8275
|
+
for (const key of [activeKey, ...rotateFrom(backgroundKeys, backgroundIndex)]) {
|
|
8276
|
+
const isActive = key === activeKey;
|
|
8277
|
+
const step = pollPolicyInterval({
|
|
8278
|
+
tab: key,
|
|
8279
|
+
floorMs,
|
|
8280
|
+
demand: isActive ? "active" : "inactive",
|
|
8281
|
+
unchangedCount: states[key]?.unchangedCount ?? 0,
|
|
8282
|
+
inProgressCI: states[key]?.inProgressCI === true,
|
|
8283
|
+
background,
|
|
8284
|
+
});
|
|
8285
|
+
if (!Number.isFinite(step)) {
|
|
8286
|
+
next[key] = Number.POSITIVE_INFINITY;
|
|
8287
|
+
continue;
|
|
7848
8288
|
}
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
}
|
|
7854
|
-
|
|
7855
|
-
|
|
7856
|
-
|
|
7857
|
-
|
|
8289
|
+
const at = Number.isFinite(dueAt[key]) ? dueAt[key] : nowMs;
|
|
8290
|
+
if (at > nowMs) {
|
|
8291
|
+
next[key] = at;
|
|
8292
|
+
continue;
|
|
8293
|
+
}
|
|
8294
|
+
const hold = tabHold(key, heldResources);
|
|
8295
|
+
if (hold.held) {
|
|
8296
|
+
next[key] = hold.retryAt;
|
|
8297
|
+
continue;
|
|
7858
8298
|
}
|
|
8299
|
+
if (!isActive) {
|
|
8300
|
+
if (backgroundTaken) {
|
|
8301
|
+
next[key] = at;
|
|
8302
|
+
continue;
|
|
8303
|
+
}
|
|
8304
|
+
backgroundTaken = true;
|
|
8305
|
+
nextBackgroundIndex = (backgroundKeys.indexOf(key) + 1) % backgroundKeys.length;
|
|
8306
|
+
}
|
|
8307
|
+
due.push({ key, kind: isActive ? "active" : "background" });
|
|
8308
|
+
next[key] = nowMs + step;
|
|
7859
8309
|
}
|
|
7860
8310
|
return {
|
|
7861
8311
|
due,
|
|
7862
|
-
|
|
7863
|
-
backgroundAt: nextBackgroundAt,
|
|
8312
|
+
dueAt: next,
|
|
7864
8313
|
backgroundIndex: nextBackgroundIndex,
|
|
7865
|
-
nextAt: Math.min(
|
|
8314
|
+
nextAt: Math.min(...Object.values(next)),
|
|
7866
8315
|
};
|
|
7867
8316
|
}
|
|
7868
8317
|
|
|
7869
8318
|
function retryPollAfterAdmissionFailure({
|
|
8319
|
+
key,
|
|
7870
8320
|
kind,
|
|
7871
8321
|
retryAt,
|
|
7872
|
-
|
|
7873
|
-
backgroundAt,
|
|
8322
|
+
dueAt,
|
|
7874
8323
|
backgroundIndex,
|
|
7875
8324
|
previousBackgroundIndex,
|
|
7876
8325
|
}) {
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
|
|
7881
|
-
|
|
7882
|
-
|
|
7883
|
-
}
|
|
7884
|
-
if (kind === "background") {
|
|
7885
|
-
return {
|
|
7886
|
-
activeAt,
|
|
7887
|
-
backgroundAt: Math.min(backgroundAt, retryAt),
|
|
7888
|
-
backgroundIndex: previousBackgroundIndex,
|
|
7889
|
-
};
|
|
7890
|
-
}
|
|
7891
|
-
return { activeAt, backgroundAt, backgroundIndex };
|
|
8326
|
+
const previous = Number.isFinite(dueAt?.[key]) ? dueAt[key] : Number.POSITIVE_INFINITY;
|
|
8327
|
+
return {
|
|
8328
|
+
dueAt: { ...dueAt, [key]: Math.min(previous, retryAt) },
|
|
8329
|
+
// A background tab that could not be admitted did not consume its turn.
|
|
8330
|
+
backgroundIndex: kind === "background" ? previousBackgroundIndex : backgroundIndex,
|
|
8331
|
+
};
|
|
7892
8332
|
}
|
|
7893
8333
|
|
|
7894
8334
|
function governorWakeTimes(state, nowMs, floorMs, leaseId = null) {
|
|
@@ -8168,12 +8608,55 @@ function rateLimitBlockProbeRecovered(pending, state, nowMs) {
|
|
|
8168
8608
|
});
|
|
8169
8609
|
}
|
|
8170
8610
|
|
|
8171
|
-
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8175
|
-
|
|
8611
|
+
// How long a separately admitted operation may wait for a lane slot the
|
|
8612
|
+
// governor has already granted it. Every request inside one tab fetch asks a
|
|
8613
|
+
// moment after the tab's own grant advanced the lane, so without this the second
|
|
8614
|
+
// request -- a later list page, or the workflow catalog -- is refused every
|
|
8615
|
+
// single time and the feature never runs at all. A named `notBefore` is a
|
|
8616
|
+
// schedule, not a refusal; anything past this bound still declines, and
|
|
8617
|
+
// startReservation revalidates the budget when the wait is over.
|
|
8618
|
+
const GOVERNOR_ADMISSION_WAIT_MS = 2_000;
|
|
8619
|
+
|
|
8620
|
+
async function runAdmittedOperation({
|
|
8621
|
+
scope,
|
|
8622
|
+
leaseId,
|
|
8623
|
+
operation,
|
|
8624
|
+
priority = "manual",
|
|
8625
|
+
signal,
|
|
8626
|
+
run,
|
|
8627
|
+
waitMs = 0,
|
|
8628
|
+
// One clock for the whole admission, so the wait below is measured against
|
|
8629
|
+
// the same time the governor scheduled the slot on.
|
|
8630
|
+
now = Date.now,
|
|
8631
|
+
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
8632
|
+
}) {
|
|
8633
|
+
let admitted = admitGovernorOperation(scope, leaseId, operation, priority, now());
|
|
8634
|
+
const scheduled = admitted.ok &&
|
|
8635
|
+
["scheduled", "waiting"].includes(admitted.value?.status) &&
|
|
8636
|
+
typeof admitted.value.reservationId === "string" &&
|
|
8637
|
+
Number.isFinite(admitted.value.notBefore)
|
|
8638
|
+
? admitted.value
|
|
8639
|
+
: null;
|
|
8640
|
+
if (waitMs > 0 && scheduled) {
|
|
8641
|
+
// The named slot can already have arrived while the intent was being
|
|
8642
|
+
// persisted, so a delay that is gone is retried at once rather than treated
|
|
8643
|
+
// as a refusal -- that race alone made this path intermittent.
|
|
8644
|
+
const delay = scheduled.notBefore - now();
|
|
8645
|
+
if (delay <= waitMs) {
|
|
8646
|
+
if (delay > 0) await wait(delay);
|
|
8647
|
+
if (!signal?.aborted) {
|
|
8648
|
+
admitted = startReservation(scope, scheduled.reservationId, now());
|
|
8649
|
+
}
|
|
8176
8650
|
}
|
|
8651
|
+
}
|
|
8652
|
+
if (!admitted.ok || admitted.value.status !== "started") {
|
|
8653
|
+
// The reservation is named by its intent, so a slot this call decided not
|
|
8654
|
+
// to take is released rather than left scheduled against the budget.
|
|
8655
|
+
const abandoned = admitted.value?.intentId ??
|
|
8656
|
+
(typeof admitted.value?.reservationId === "string"
|
|
8657
|
+
? admitted.value.reservationId.slice(12)
|
|
8658
|
+
: scheduled?.reservationId?.slice(12));
|
|
8659
|
+
if (validGovernorId(abandoned)) cancelIntent(scope, abandoned, now());
|
|
8177
8660
|
const detail = admitted.value?.resetMs
|
|
8178
8661
|
? ` until ${new Date(admitted.value.resetMs).toISOString()}`
|
|
8179
8662
|
: admitted.value?.notBefore ? ` until ${new Date(admitted.value.notBefore).toISOString()}` : "";
|
|
@@ -8187,11 +8670,11 @@ async function runAdmittedOperation({ scope, leaseId, operation, priority = "man
|
|
|
8187
8670
|
completeReservation(settlementScope, reservationId, {
|
|
8188
8671
|
outcome: "measured-success",
|
|
8189
8672
|
actualCost: costs,
|
|
8190
|
-
},
|
|
8673
|
+
}, now());
|
|
8191
8674
|
if (scope.accessKey && scope.accessKey !== runtimeIdentityCoordinator?.current()?.accessKey) return { ok: false, error: new Error("Credential changed"), reservationId };
|
|
8192
8675
|
return { ok: true, value, reservationId };
|
|
8193
8676
|
} catch (error) {
|
|
8194
|
-
completeReservation(settlementScope, reservationId, { outcome: governorOutcomeForError(error) },
|
|
8677
|
+
completeReservation(settlementScope, reservationId, { outcome: governorOutcomeForError(error) }, now());
|
|
8195
8678
|
return { ok: false, error, reservationId };
|
|
8196
8679
|
}
|
|
8197
8680
|
}
|
|
@@ -8877,6 +9360,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8877
9360
|
);
|
|
8878
9361
|
const dataRef = useRef(data);
|
|
8879
9362
|
dataRef.current = data;
|
|
9363
|
+
// The workflow catalog, kept across polls so a repository whose runs carry no
|
|
9364
|
+
// name of their own asks for it once every fifteen minutes rather than twice
|
|
9365
|
+
// per poll. A ref because it must not redraw anything by itself.
|
|
9366
|
+
const workflowCatalogRef = useRef(null);
|
|
9367
|
+
// What each paged tab has actually loaded, and whether more exists. Demand is
|
|
9368
|
+
// a page count so that holding `j` near the end asks for one more page rather
|
|
9369
|
+
// than one per keystroke.
|
|
9370
|
+
const pageStateRef = useRef({});
|
|
8880
9371
|
const [meta, setMeta] = useState(() =>
|
|
8881
9372
|
Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.meta ?? null])),
|
|
8882
9373
|
);
|
|
@@ -8962,8 +9453,6 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
8962
9453
|
// Read at fetch time rather than being a hook dependency, so dragging the
|
|
8963
9454
|
// pane wider doesn't cancel and restart in-flight requests -- the next tick
|
|
8964
9455
|
// simply asks for the new size.
|
|
8965
|
-
const runLimitRef = useRef(0);
|
|
8966
|
-
runLimitRef.current = Math.min(Math.max(bodyRows + 1, MIN_RUN_LIMIT), MAX_RUN_LIMIT);
|
|
8967
9456
|
|
|
8968
9457
|
// Read inside the polling closure, which is created once on mount and must
|
|
8969
9458
|
// not be torn down and rebuilt every time you press a tab key.
|
|
@@ -9005,7 +9494,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9005
9494
|
|
|
9006
9495
|
// useInput's handler is created before the render body computes the visible
|
|
9007
9496
|
// slice, so the movement handlers read through a ref -- the same pattern the
|
|
9008
|
-
// poll loop uses for
|
|
9497
|
+
// poll loop uses for activeIndexRef, and for the same reason:
|
|
9009
9498
|
// the closure must see current values without being rebuilt on every change.
|
|
9010
9499
|
const navRef = useRef({ items: [], key: null, bodyRows: 1, tabKey: "actions", offset: 0 });
|
|
9011
9500
|
navRef.current = {
|
|
@@ -9227,6 +9716,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9227
9716
|
: Math.min(items.length - 1, start + rows_ - 1)
|
|
9228
9717
|
: Math.min(items.length - 1, Math.max(0, current + delta));
|
|
9229
9718
|
setSelected((s) => ({ ...s, [tabKey]: itemKey(items[next]) }));
|
|
9719
|
+
demandPages(tabKey, next, items.length);
|
|
9230
9720
|
// Keep the cursor on screen. Only the offset needed to reveal it changes,
|
|
9231
9721
|
// so scrolling never jumps further than it has to.
|
|
9232
9722
|
setOffset((o) => {
|
|
@@ -9239,6 +9729,24 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9239
9729
|
});
|
|
9240
9730
|
}
|
|
9241
9731
|
|
|
9732
|
+
// A cursor within PAGE_DEMAND_THRESHOLD rows of the end is the only thing
|
|
9733
|
+
// that asks for another page. Demand is a count rather than an event, so ten
|
|
9734
|
+
// keystrokes near the bottom acquire one page, and a tab nobody scrolled
|
|
9735
|
+
// never acquires a second one at all.
|
|
9736
|
+
function demandPages(tabKey, selectedIndex, loadedRows) {
|
|
9737
|
+
const state = pageStateRef.current[tabKey];
|
|
9738
|
+
if (!state) return;
|
|
9739
|
+
const wanted = demandedPageCount(state.pages, {
|
|
9740
|
+
selectedIndex,
|
|
9741
|
+
loadedRows,
|
|
9742
|
+
hasNextPage: state.hasNextPage,
|
|
9743
|
+
});
|
|
9744
|
+
if (wanted <= state.pages) return;
|
|
9745
|
+
state.pages = wanted;
|
|
9746
|
+
// Manual priority: the user is scrolling toward rows that are not loaded.
|
|
9747
|
+
fetchTabRef.current?.(tabKey, { kind: "manual" });
|
|
9748
|
+
}
|
|
9749
|
+
|
|
9242
9750
|
// Rearms on every call to moveSelection (it's the only thing that changes
|
|
9243
9751
|
// `selected`), so this fires exactly 60s after the *last* movement -- tab
|
|
9244
9752
|
// switches and Enter don't count as activity and don't push it back. Clears
|
|
@@ -9246,7 +9754,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9246
9754
|
// switch back to after being idle doesn't still show a stale row marked.
|
|
9247
9755
|
useEffect(() => {
|
|
9248
9756
|
if (Object.keys(selected).length === 0) return;
|
|
9249
|
-
const timer = setTimeout(() =>
|
|
9757
|
+
const timer = setTimeout(() => {
|
|
9758
|
+
setSelected({});
|
|
9759
|
+
// Extra pages are held for a cursor. With no cursor there is nothing to
|
|
9760
|
+
// scroll toward, so they stop being refreshed on the next poll.
|
|
9761
|
+
for (const state of Object.values(pageStateRef.current)) state.pages = 1;
|
|
9762
|
+
}, SELECTION_IDLE_MS);
|
|
9250
9763
|
return () => clearTimeout(timer);
|
|
9251
9764
|
}, [selected]);
|
|
9252
9765
|
|
|
@@ -9334,13 +9847,16 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9334
9847
|
} else if (key.return) {
|
|
9335
9848
|
if (remoteSetupRef.current) onCreateRemote(suspendTerminal);
|
|
9336
9849
|
else openSelected();
|
|
9337
|
-
} else if (input === "r") {
|
|
9850
|
+
} else if (input === "r" || input === "R") {
|
|
9338
9851
|
// Goes through the same per-tab in-flight guard as the poll loop, so
|
|
9339
|
-
// holding the key down cannot stack concurrent subprocesses.
|
|
9340
|
-
//
|
|
9341
|
-
// "try again now", and a refresh that silently declined to
|
|
9342
|
-
// be worse than no key at all.
|
|
9343
|
-
|
|
9852
|
+
// holding the key down cannot stack concurrent subprocesses. Both keys
|
|
9853
|
+
// bypass the scheduled deadline and the tab's failure ladder -- the user
|
|
9854
|
+
// is saying "try again now", and a refresh that silently declined to
|
|
9855
|
+
// refresh would be worse than no key at all. Only `R` also drops the
|
|
9856
|
+
// validators: `r` on a quiet repository answers 304 and costs nothing,
|
|
9857
|
+
// which is exactly when the key is most likely to be pressed.
|
|
9858
|
+
const request = manualRefreshRequest(refreshIntent(input));
|
|
9859
|
+
if (request) fetchTabRef.current?.(TABS[activeIndexRef.current].key, request);
|
|
9344
9860
|
} else if (input >= "1" && input <= String(TABS.length)) {
|
|
9345
9861
|
setActiveIndex(Number(input) - 1);
|
|
9346
9862
|
} else if (key.tab && key.shift) {
|
|
@@ -9421,8 +9937,6 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9421
9937
|
// a Promise.allSettled barrier. Actions is by far the slowest fetch, so
|
|
9422
9938
|
// barrelling everything together meant the three fast tabs sat invisible
|
|
9423
9939
|
// behind it and nothing at all appeared until the slowest call returned.
|
|
9424
|
-
let securityUnchangedPolls = 0;
|
|
9425
|
-
let securityNextPollAt = 0;
|
|
9426
9940
|
|
|
9427
9941
|
function setTabGovernorDecision(key, decision) {
|
|
9428
9942
|
setGovernorDecisions((current) => sameVisibleGovernorDecision(current[key], decision)
|
|
@@ -9535,6 +10049,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9535
10049
|
|
|
9536
10050
|
function commit(key, run, {
|
|
9537
10051
|
force = false,
|
|
10052
|
+
manual = false,
|
|
9538
10053
|
scope,
|
|
9539
10054
|
reservationId,
|
|
9540
10055
|
admittedAt,
|
|
@@ -9549,7 +10064,8 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9549
10064
|
const settlementScope = settlement.scope;
|
|
9550
10065
|
const currentAccess = settlement.isCurrent;
|
|
9551
10066
|
inFlightRef.current[key] = true;
|
|
9552
|
-
|
|
10067
|
+
inFlightStartedAt[key] = admittedAt;
|
|
10068
|
+
if (manual) manualInFlight.add(key);
|
|
9553
10069
|
admittedCadenceRef.current[key] = nextAdmittedCadence(
|
|
9554
10070
|
admittedCadenceRef.current[key],
|
|
9555
10071
|
admittedAt,
|
|
@@ -9557,7 +10073,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9557
10073
|
clearForcedBackoffAfterStart(key, force, "started");
|
|
9558
10074
|
const visibleLoading = shouldShowFetchLoading({
|
|
9559
10075
|
hasData: dataRef.current[key] !== null,
|
|
9560
|
-
|
|
10076
|
+
manual,
|
|
9561
10077
|
});
|
|
9562
10078
|
if (visibleLoading) setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
|
|
9563
10079
|
const retainDeferredHold = retainDeferredGovernorHold({
|
|
@@ -9625,15 +10141,21 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9625
10141
|
limit,
|
|
9626
10142
|
completedAt,
|
|
9627
10143
|
});
|
|
9628
|
-
|
|
9629
|
-
|
|
9630
|
-
|
|
9631
|
-
|
|
9632
|
-
|
|
9633
|
-
|
|
9634
|
-
|
|
9635
|
-
|
|
9636
|
-
|
|
10144
|
+
// The cadence follows the observation, not the tab: an error or an
|
|
10145
|
+
// unusable payload leaves the counter where it was, so a broken tab
|
|
10146
|
+
// is never slowed down for looking quiet.
|
|
10147
|
+
unchangedPolls[key] = advanceUnchangedCount(unchangedPolls[key], transition.kind);
|
|
10148
|
+
// Recomputed now that the outcome is known: the schedule chose this
|
|
10149
|
+
// tab's deadline before the observation that decides its cadence.
|
|
10150
|
+
rescheduleTab(key, admittedAt, {
|
|
10151
|
+
actionRows: key === "actions" && transition.kind === "changed" ? transition.data : undefined,
|
|
10152
|
+
});
|
|
10153
|
+
if (result?.catalog) workflowCatalogRef.current = result.catalog;
|
|
10154
|
+
if (Number.isFinite(result?.loadedPages)) {
|
|
10155
|
+
pageStateRef.current[key] = {
|
|
10156
|
+
pages: result.loadedPages,
|
|
10157
|
+
hasNextPage: result.hasNextPage === true,
|
|
10158
|
+
};
|
|
9637
10159
|
}
|
|
9638
10160
|
publishStagedEntities(entityRef.current, stagedEntities, transition.kind);
|
|
9639
10161
|
if (transition.kind === "unchanged") {
|
|
@@ -9667,7 +10189,6 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9667
10189
|
// so the next admitted tick parses its response instead of taking the
|
|
9668
10190
|
// identical-output fast path.
|
|
9669
10191
|
if (transition.kind === "unusable") {
|
|
9670
|
-
if (key === "security") securityNextPollAt = 0;
|
|
9671
10192
|
rawRef.current[key] = transition.nextRaw;
|
|
9672
10193
|
setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
|
|
9673
10194
|
return;
|
|
@@ -9740,6 +10261,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9740
10261
|
})
|
|
9741
10262
|
.finally(() => {
|
|
9742
10263
|
inFlightRef.current[key] = false;
|
|
10264
|
+
delete inFlightStartedAt[key];
|
|
9743
10265
|
if (!currentAccess() && !Object.values(inFlightRef.current).some(Boolean) && runtimeIdentityCoordinator?.current()?.quotaKey !== settlementScope.quotaKey) releaseLease(settlementScope, leaseId);
|
|
9744
10266
|
manualInFlight.delete(key);
|
|
9745
10267
|
onSettled?.();
|
|
@@ -9757,19 +10279,27 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9757
10279
|
: current);
|
|
9758
10280
|
}
|
|
9759
10281
|
}
|
|
9760
|
-
|
|
9761
|
-
|
|
10282
|
+
const queued = queuedManual.get(key);
|
|
10283
|
+
if (!cancelled && queued) {
|
|
10284
|
+
queuedManual.delete(key);
|
|
10285
|
+
void requestTab(key, "manual", { force: queued.force });
|
|
9762
10286
|
}
|
|
9763
10287
|
});
|
|
9764
10288
|
}
|
|
9765
10289
|
|
|
9766
10290
|
const leaseId = governorId();
|
|
9767
10291
|
const pending = new Map();
|
|
9768
|
-
// One manual handoff per tab
|
|
9769
|
-
// not be lost, but repeated keypresses
|
|
9770
|
-
// must not create a trailing second batch.
|
|
9771
|
-
|
|
10292
|
+
// One manual handoff per tab, carrying which key produced it. A keypress
|
|
10293
|
+
// during an automatic request must not be lost, but repeated keypresses
|
|
10294
|
+
// during the manual request itself must not create a trailing second batch.
|
|
10295
|
+
// The intention has to travel with the handoff: replaying every queued press
|
|
10296
|
+
// as a resynchronization made `r` drop validators whenever it happened to
|
|
10297
|
+
// land while an automatic poll was in flight.
|
|
10298
|
+
const queuedManual = new Map();
|
|
9772
10299
|
const manualInFlight = new Set();
|
|
10300
|
+
// When the work currently in flight for each tab was admitted, so a press
|
|
10301
|
+
// can tell an acquisition that already answers it from one that predates it.
|
|
10302
|
+
const inFlightStartedAt = {};
|
|
9773
10303
|
const wakeScheduler = createWakeScheduler();
|
|
9774
10304
|
let scope = null;
|
|
9775
10305
|
let cleanupScope = null;
|
|
@@ -9781,11 +10311,66 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9781
10311
|
let remoteUrls = [];
|
|
9782
10312
|
let liveScheduling = false;
|
|
9783
10313
|
let controlEpochs = null;
|
|
9784
|
-
|
|
9785
|
-
|
|
10314
|
+
// One deadline per tab, not one active slot and one rotating background
|
|
10315
|
+
// slot. Two shared timers cannot express a Security tab quiet at 300s next
|
|
10316
|
+
// to an Actions tab watching a running job at 5s, which is the whole point
|
|
10317
|
+
// of the cadence table.
|
|
10318
|
+
const NEVER_DUE = Object.fromEntries(TAB_KEYS.map((key) => [key, Number.POSITIVE_INFINITY]));
|
|
10319
|
+
let pollDueAt = { ...NEVER_DUE };
|
|
9786
10320
|
let backgroundIndex = 0;
|
|
10321
|
+
// Only the unchanged run counts live here. Whether Actions has work in
|
|
10322
|
+
// flight is read from the rows themselves, so there is no second copy of it
|
|
10323
|
+
// to fall out of step with what is on screen.
|
|
10324
|
+
const unchangedPolls = Object.fromEntries(TAB_KEYS.map((key) => [key, 0]));
|
|
9787
10325
|
let pendingBlockPublications = new Map();
|
|
9788
10326
|
|
|
10327
|
+
// Rows are the only record of whether CI is busy, so this reads them rather
|
|
10328
|
+
// than keeping a second copy that can fall out of step with the screen. A
|
|
10329
|
+
// caller that has just parsed newer rows passes them, because dataRef only
|
|
10330
|
+
// catches up on the next render.
|
|
10331
|
+
function actionsInProgress(rows = dataRef.current.actions) {
|
|
10332
|
+
return Array.isArray(rows) && rows.some((row) => row.status !== "completed");
|
|
10333
|
+
}
|
|
10334
|
+
|
|
10335
|
+
function pollIntervalFor(key, activeKey, { actionRows } = {}) {
|
|
10336
|
+
return pollPolicyInterval({
|
|
10337
|
+
tab: key,
|
|
10338
|
+
floorMs: runtime.refreshMs,
|
|
10339
|
+
demand: key === activeKey ? "active" : "inactive",
|
|
10340
|
+
unchangedCount: unchangedPolls[key],
|
|
10341
|
+
inProgressCI: key === "actions" && actionsInProgress(actionRows),
|
|
10342
|
+
background: runtime.background,
|
|
10343
|
+
});
|
|
10344
|
+
}
|
|
10345
|
+
|
|
10346
|
+
function rescheduleTab(key, at, options = {}) {
|
|
10347
|
+
const step = pollIntervalFor(key, TABS[activeIndexRef.current].key, options);
|
|
10348
|
+
pollDueAt = {
|
|
10349
|
+
...pollDueAt,
|
|
10350
|
+
[key]: Number.isFinite(step) ? at + step : Number.POSITIVE_INFINITY,
|
|
10351
|
+
};
|
|
10352
|
+
}
|
|
10353
|
+
|
|
10354
|
+
// Polling opens with the active tab immediately and the inactive ones
|
|
10355
|
+
// staggered one background slot apart, exactly as the single rotating slot
|
|
10356
|
+
// used to place them. Opening them all at the same deadline would start
|
|
10357
|
+
// three fetches within one wake -- the herd the rotation exists to prevent,
|
|
10358
|
+
// and it only diverges again once each tab has taken its own interval.
|
|
10359
|
+
function openPollDeadlines(at) {
|
|
10360
|
+
const activeKey = TABS[activeIndexRef.current].key;
|
|
10361
|
+
const opened = { ...NEVER_DUE };
|
|
10362
|
+
opened[activeKey] = at + 1;
|
|
10363
|
+
let slot = 1;
|
|
10364
|
+
for (const key of TAB_KEYS) {
|
|
10365
|
+
if (key === activeKey) continue;
|
|
10366
|
+
opened[key] = Number.isFinite(pollIntervalFor(key, activeKey))
|
|
10367
|
+
? at + slot * 4 * runtime.refreshMs
|
|
10368
|
+
: Number.POSITIVE_INFINITY;
|
|
10369
|
+
slot += 1;
|
|
10370
|
+
}
|
|
10371
|
+
pollDueAt = opened;
|
|
10372
|
+
}
|
|
10373
|
+
|
|
9789
10374
|
function armWake(kind, at, run) {
|
|
9790
10375
|
if (!cancelled) wakeScheduler.arm(kind, at, run);
|
|
9791
10376
|
}
|
|
@@ -9793,8 +10378,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9793
10378
|
function failClosedRateLimit(key, reason) {
|
|
9794
10379
|
liveScheduling = false;
|
|
9795
10380
|
controlEpochs = null;
|
|
9796
|
-
|
|
9797
|
-
backgroundPollAt = Number.POSITIVE_INFINITY;
|
|
10381
|
+
pollDueAt = { ...NEVER_DUE };
|
|
9798
10382
|
wakeScheduler.clear("data");
|
|
9799
10383
|
pauseCoordination(key, reason);
|
|
9800
10384
|
armWake("control", governorControlRetryAt(Date.now(), runtime.refreshMs), controlWake);
|
|
@@ -9869,6 +10453,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9869
10453
|
rawRef.current = {};
|
|
9870
10454
|
entityRef.current.clear();
|
|
9871
10455
|
lastOkRef.current = {};
|
|
10456
|
+
// The workflow catalog is repository payload, not scheduling state, so
|
|
10457
|
+
// it is fenced with the rows rather than carried into whatever account
|
|
10458
|
+
// this pane now holds. The page counts go with it: demand measured
|
|
10459
|
+
// against a list that has been discarded describes nothing.
|
|
10460
|
+
workflowCatalogRef.current = null;
|
|
10461
|
+
pageStateRef.current = {};
|
|
9872
10462
|
setData(Object.fromEntries(TAB_KEYS.map((key) => [key, null])));
|
|
9873
10463
|
setMeta({});
|
|
9874
10464
|
setSecurityNotes([]);
|
|
@@ -9877,8 +10467,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9877
10467
|
}
|
|
9878
10468
|
pendingBlockPublications.clear();
|
|
9879
10469
|
liveScheduling = false;
|
|
9880
|
-
|
|
9881
|
-
backgroundPollAt = Number.POSITIVE_INFINITY;
|
|
10470
|
+
pollDueAt = { ...NEVER_DUE };
|
|
9882
10471
|
wakeScheduler.clear("data");
|
|
9883
10472
|
wakeScheduler.clear("heartbeat");
|
|
9884
10473
|
wakeScheduler.clear("control");
|
|
@@ -9986,7 +10575,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9986
10575
|
publishGovernorEpochs(snapshot);
|
|
9987
10576
|
const wakes = governorWakeTimes(snapshot.value, nowMs, runtime.refreshMs, leaseId);
|
|
9988
10577
|
armWake("control", wakes.controlAt, controlWake);
|
|
9989
|
-
const nextDataAt = Math.min(wakes.dataAt,
|
|
10578
|
+
const nextDataAt = Math.min(wakes.dataAt, ...Object.values(pollDueAt));
|
|
9990
10579
|
if (liveScheduling) armWake("data", nextDataAt, dataWake);
|
|
9991
10580
|
}
|
|
9992
10581
|
|
|
@@ -9995,39 +10584,55 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
9995
10584
|
armFromState();
|
|
9996
10585
|
}
|
|
9997
10586
|
|
|
10587
|
+
// Manual and tab-switch work replaces the automatic check that would
|
|
10588
|
+
// otherwise be due, rather than being added to it.
|
|
9998
10589
|
function replaceActivePoll(kind, at) {
|
|
9999
10590
|
if (["manual", "tab-switch"].includes(kind)) {
|
|
10000
|
-
|
|
10591
|
+
rescheduleTab(TABS[activeIndexRef.current].key, at);
|
|
10001
10592
|
}
|
|
10002
10593
|
}
|
|
10003
10594
|
|
|
10595
|
+
// `manual` is the user asking now: it bypasses the scheduled deadline and
|
|
10596
|
+
// the tab's failure ladder. `force` is the separate, stronger request that
|
|
10597
|
+
// also drops validators -- the `R` key. `r` is manual and not forced, which
|
|
10598
|
+
// is what makes a quiet refresh cost nothing.
|
|
10004
10599
|
function requestTab(key, kind = "active", { force = false } = {}) {
|
|
10600
|
+
const manual = kind === "manual";
|
|
10005
10601
|
const signal = controller.signal;
|
|
10006
10602
|
const descriptor = tabForKey(key);
|
|
10007
10603
|
const monotonicNow = performance.now();
|
|
10008
10604
|
if (pendingBlockPublications.size > 0) {
|
|
10009
10605
|
pauseCoordination(key, "block-unpublished");
|
|
10010
|
-
return Promise.resolve({ persisted: false, retry: false, kind });
|
|
10606
|
+
return Promise.resolve({ persisted: false, retry: false, kind, key });
|
|
10011
10607
|
}
|
|
10012
10608
|
if (inFlightRef.current[key]) {
|
|
10013
|
-
|
|
10014
|
-
|
|
10015
|
-
|
|
10016
|
-
|
|
10017
|
-
|
|
10609
|
+
// A manual acquisition already in flight is work this user asked for,
|
|
10610
|
+
// so the press joins it. Automatic work that started before the press
|
|
10611
|
+
// cannot answer it, so schedule exactly one follow-up; further presses
|
|
10612
|
+
// coalesce into that one, and the stronger intention wins.
|
|
10613
|
+
if (manual && !manualInFlight.has(key)) {
|
|
10614
|
+
const plan = planManualRefresh({
|
|
10615
|
+
requestedAt: Date.now(),
|
|
10616
|
+
inFlightStartedAt: inFlightStartedAt[key] ?? null,
|
|
10617
|
+
followUpPending: queuedManual.has(key),
|
|
10618
|
+
});
|
|
10619
|
+
if (plan.followUp) queuedManual.set(key, { force });
|
|
10620
|
+
}
|
|
10621
|
+
if (force && queuedManual.has(key)) queuedManual.get(key).force = true;
|
|
10622
|
+
return Promise.resolve({ persisted: false, retry: false, kind, key });
|
|
10018
10623
|
}
|
|
10019
|
-
if (!
|
|
10020
|
-
return Promise.resolve({ persisted: false, retry: false, kind });
|
|
10624
|
+
if (!manual && backoffActive(`tab:${key}`, monotonicNow)) {
|
|
10625
|
+
return Promise.resolve({ persisted: false, retry: false, kind, key });
|
|
10021
10626
|
}
|
|
10022
10627
|
const nowMs = Date.now();
|
|
10023
10628
|
const currentScope = ensureScope(nowMs);
|
|
10024
10629
|
if (!currentScope) {
|
|
10025
10630
|
pauseCoordination(key, "unknown-scope");
|
|
10026
|
-
return Promise.resolve({ persisted: false, retry: true, kind });
|
|
10631
|
+
return Promise.resolve({ persisted: false, retry: true, kind, key });
|
|
10027
10632
|
}
|
|
10028
10633
|
let protocolReady = liveScheduling;
|
|
10029
10634
|
let resourceReady = liveScheduling;
|
|
10030
|
-
if (!liveScheduling ||
|
|
10635
|
+
if (!liveScheduling || manual) {
|
|
10031
10636
|
const snapshot = inspectGovernor(currentScope, nowMs);
|
|
10032
10637
|
protocolReady = governorProtocolReady(
|
|
10033
10638
|
{ ok: true, value: { status: "published" } },
|
|
@@ -10038,13 +10643,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10038
10643
|
{ ok: true, value: { status: "published" } }, snapshot, key, nowMs);
|
|
10039
10644
|
if (!liveScheduling && resourceReady) {
|
|
10040
10645
|
liveScheduling = true;
|
|
10041
|
-
|
|
10042
|
-
backgroundPollAt = nowMs + 4 * runtime.refreshMs;
|
|
10646
|
+
openPollDeadlines(nowMs - 1);
|
|
10043
10647
|
armWake("heartbeat", nowMs + GOVERNOR_HEARTBEAT_MS, heartbeatWake);
|
|
10044
10648
|
}
|
|
10045
10649
|
}
|
|
10046
|
-
const intentGate = runtimeIntentGate(liveScheduling, { force, protocolReady });
|
|
10047
|
-
if (intentGate.requestProbe ||
|
|
10650
|
+
const intentGate = runtimeIntentGate(liveScheduling, { force: manual, protocolReady });
|
|
10651
|
+
if (intentGate.requestProbe || manual && !resourceReady) {
|
|
10048
10652
|
const snapshot = inspectGovernor(currentScope, nowMs);
|
|
10049
10653
|
const costs = tabRequestCost(key);
|
|
10050
10654
|
for (const resource of RATE_RESOURCES) {
|
|
@@ -10058,12 +10662,12 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10058
10662
|
if (!intentGate.registerIntent) {
|
|
10059
10663
|
setTabGovernorDecision(key, { mode: "waiting", probing: true });
|
|
10060
10664
|
setTabWaiting(key, true);
|
|
10061
|
-
return Promise.resolve({ persisted: false, retry: false, kind });
|
|
10665
|
+
return Promise.resolve({ persisted: false, retry: false, kind, key });
|
|
10062
10666
|
}
|
|
10063
10667
|
const existing = pending.get(key);
|
|
10064
10668
|
if (existing) {
|
|
10065
|
-
if (!
|
|
10066
|
-
return Promise.resolve({ persisted: true, retry: false, kind });
|
|
10669
|
+
if (!manual || existing.kind === "manual") {
|
|
10670
|
+
return Promise.resolve({ persisted: true, retry: false, kind, key });
|
|
10067
10671
|
}
|
|
10068
10672
|
cancelIntent(currentScope, existing.intentId, nowMs);
|
|
10069
10673
|
pending.delete(key);
|
|
@@ -10081,13 +10685,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10081
10685
|
const registered = registerIntent(currentScope, request);
|
|
10082
10686
|
if (!registered.ok) {
|
|
10083
10687
|
pauseCoordination(key, registered.reason);
|
|
10084
|
-
return Promise.resolve({ persisted: false, retry: true, kind });
|
|
10688
|
+
return Promise.resolve({ persisted: false, retry: true, kind, key });
|
|
10085
10689
|
}
|
|
10086
10690
|
const decision = registered.value;
|
|
10087
10691
|
pending.set(key, {
|
|
10088
10692
|
intentId,
|
|
10089
10693
|
kind,
|
|
10090
10694
|
force,
|
|
10695
|
+
manual,
|
|
10091
10696
|
wasDeferred: false,
|
|
10092
10697
|
...sharedLaneEvidence(decision),
|
|
10093
10698
|
});
|
|
@@ -10096,20 +10701,20 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10096
10701
|
if (item) item.wasDeferred = true;
|
|
10097
10702
|
setTabGovernorDecision(key, visibleGovernorDecision(decision));
|
|
10098
10703
|
setTabWaiting(key, true);
|
|
10099
|
-
if (
|
|
10704
|
+
if (manual && decision.resource) {
|
|
10100
10705
|
const budget = inspectGovernor(currentScope, nowMs).value?.budgets?.[decision.resource];
|
|
10101
10706
|
if (budget) requestManualProbe(currentScope, leaseId, budget.epoch, budget.observedAt, nowMs);
|
|
10102
10707
|
}
|
|
10103
10708
|
armWake("data", decision.retryAt ?? decision.notBefore ?? nowMs + runtime.refreshMs, dataWake);
|
|
10104
10709
|
armFromState(nowMs);
|
|
10105
|
-
return Promise.resolve({ persisted: true, retry: false, kind });
|
|
10710
|
+
return Promise.resolve({ persisted: true, retry: false, kind, key });
|
|
10106
10711
|
}
|
|
10107
10712
|
const started = startReservation(currentScope, decision.reservationId, nowMs);
|
|
10108
10713
|
if (!started.ok) {
|
|
10109
10714
|
pauseCoordination(key, started.reason);
|
|
10110
10715
|
if (pendingFailureIsTerminal(started.reason)) finishPending(key, intentId);
|
|
10111
10716
|
else armWake("data", governorControlRetryAt(nowMs, runtime.refreshMs), dataWake);
|
|
10112
|
-
return Promise.resolve({ persisted: true, retry: false, kind });
|
|
10717
|
+
return Promise.resolve({ persisted: true, retry: false, kind, key });
|
|
10113
10718
|
}
|
|
10114
10719
|
if (started.value.status !== "started") {
|
|
10115
10720
|
const item = pending.get(key);
|
|
@@ -10119,7 +10724,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10119
10724
|
...started.value,
|
|
10120
10725
|
}));
|
|
10121
10726
|
armWake("data", started.value.notBefore ?? nowMs + runtime.refreshMs, dataWake);
|
|
10122
|
-
return Promise.resolve({ persisted: true, retry: false, kind });
|
|
10727
|
+
return Promise.resolve({ persisted: true, retry: false, kind, key });
|
|
10123
10728
|
}
|
|
10124
10729
|
setTabWaiting(key, false);
|
|
10125
10730
|
replaceActivePoll(kind, nowMs);
|
|
@@ -10128,23 +10733,31 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10128
10733
|
key,
|
|
10129
10734
|
() => descriptor.fetch({
|
|
10130
10735
|
signal,
|
|
10131
|
-
runLimit: runLimitRef.current,
|
|
10132
10736
|
entities: entityRef.current,
|
|
10133
10737
|
force,
|
|
10738
|
+
catalog: workflowCatalogRef.current,
|
|
10739
|
+
pages: pageStateRef.current[key]?.pages ?? 1,
|
|
10134
10740
|
governor: { scope: currentScope, leaseId },
|
|
10135
10741
|
previousRaw: rawRef.current[key] ?? null,
|
|
10136
10742
|
}),
|
|
10137
10743
|
{
|
|
10138
10744
|
force,
|
|
10745
|
+
manual,
|
|
10139
10746
|
scope: currentScope,
|
|
10140
10747
|
reservationId: decision.reservationId,
|
|
10141
10748
|
admittedAt: nowMs,
|
|
10142
10749
|
automaticStatusVisible: false,
|
|
10143
10750
|
onSettled: () => finishPending(key, intentId),
|
|
10144
10751
|
},
|
|
10145
|
-
).then(() => ({ persisted: true, retry: false, kind }));
|
|
10752
|
+
).then(() => ({ persisted: true, retry: false, kind, key }));
|
|
10146
10753
|
}
|
|
10147
10754
|
fetchTabRef.current = (key, { force = false, kind = force ? "manual" : "active" } = {}) => {
|
|
10755
|
+
if (kind === "tab-switch") {
|
|
10756
|
+
// A newly active subscription is not evidence of quiet: the tab you
|
|
10757
|
+
// just opened deserves a check at the floor, not at the interval it
|
|
10758
|
+
// earned while nobody was looking at it.
|
|
10759
|
+
unchangedPolls[key] = advanceUnchangedCount(unchangedPolls[key], "subscribed");
|
|
10760
|
+
}
|
|
10148
10761
|
const requestedAt = Date.now();
|
|
10149
10762
|
// Manual work replaces the active poll that would otherwise be due. Move
|
|
10150
10763
|
// that deadline before touching the governor so a data wake already in
|
|
@@ -10215,14 +10828,16 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10215
10828
|
key,
|
|
10216
10829
|
() => descriptor.fetch({
|
|
10217
10830
|
signal: controller.signal,
|
|
10218
|
-
runLimit: runLimitRef.current,
|
|
10219
10831
|
entities: entityRef.current,
|
|
10220
10832
|
force: item.force,
|
|
10833
|
+
catalog: workflowCatalogRef.current,
|
|
10834
|
+
pages: pageStateRef.current[key]?.pages ?? 1,
|
|
10221
10835
|
governor: { scope: currentScope, leaseId },
|
|
10222
10836
|
previousRaw: rawRef.current[key] ?? null,
|
|
10223
10837
|
}),
|
|
10224
10838
|
{
|
|
10225
10839
|
force: item.force,
|
|
10840
|
+
manual: item.manual === true,
|
|
10226
10841
|
scope: currentScope,
|
|
10227
10842
|
reservationId: decision.value.reservationId,
|
|
10228
10843
|
admittedAt: nowMs,
|
|
@@ -10259,13 +10874,16 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10259
10874
|
nowMs,
|
|
10260
10875
|
floorMs: runtime.refreshMs,
|
|
10261
10876
|
activeKey: active,
|
|
10262
|
-
|
|
10263
|
-
backgroundAt: backgroundPollAt,
|
|
10877
|
+
dueAt: pollDueAt,
|
|
10264
10878
|
backgroundIndex,
|
|
10265
10879
|
heldResources,
|
|
10880
|
+
background: runtime.background,
|
|
10881
|
+
states: Object.fromEntries(TAB_KEYS.map((key) => [key, {
|
|
10882
|
+
unchangedCount: unchangedPolls[key],
|
|
10883
|
+
inProgressCI: key === "actions" && actionsInProgress(),
|
|
10884
|
+
}])),
|
|
10266
10885
|
});
|
|
10267
|
-
|
|
10268
|
-
backgroundPollAt = planned.backgroundAt;
|
|
10886
|
+
pollDueAt = planned.dueAt;
|
|
10269
10887
|
backgroundIndex = planned.backgroundIndex;
|
|
10270
10888
|
const outcomes = await Promise.allSettled(
|
|
10271
10889
|
planned.due.map(({ key, kind }) => requestTab(key, kind)),
|
|
@@ -10274,15 +10892,14 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10274
10892
|
let retryNeeded = false;
|
|
10275
10893
|
for (const outcome of outcomes) {
|
|
10276
10894
|
if (outcome.status !== "fulfilled" || outcome.value?.retry !== true) continue;
|
|
10277
|
-
({
|
|
10278
|
-
|
|
10279
|
-
|
|
10280
|
-
|
|
10281
|
-
|
|
10282
|
-
|
|
10283
|
-
|
|
10284
|
-
|
|
10285
|
-
}));
|
|
10895
|
+
({ dueAt: pollDueAt, backgroundIndex } = retryPollAfterAdmissionFailure({
|
|
10896
|
+
key: outcome.value.key,
|
|
10897
|
+
kind: outcome.value.kind,
|
|
10898
|
+
retryAt,
|
|
10899
|
+
dueAt: pollDueAt,
|
|
10900
|
+
backgroundIndex,
|
|
10901
|
+
previousBackgroundIndex,
|
|
10902
|
+
}));
|
|
10286
10903
|
retryNeeded = true;
|
|
10287
10904
|
}
|
|
10288
10905
|
if (retryNeeded) armWake("data", retryAt, dataWake);
|
|
@@ -10339,10 +10956,15 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10339
10956
|
if (controlReady && (!liveScheduling || activeEpochChanged)) {
|
|
10340
10957
|
const starting = !liveScheduling;
|
|
10341
10958
|
liveScheduling = true;
|
|
10342
|
-
activePollAt = checkedAt + 1;
|
|
10343
10959
|
if (starting) {
|
|
10344
|
-
|
|
10960
|
+
openPollDeadlines(checkedAt);
|
|
10345
10961
|
armWake("heartbeat", checkedAt + GOVERNOR_HEARTBEAT_MS, heartbeatWake);
|
|
10962
|
+
} else {
|
|
10963
|
+
// A new accounting epoch for a resource the active tab spends: check
|
|
10964
|
+
// it now rather than at whatever cadence it had settled into. Set
|
|
10965
|
+
// directly rather than through rescheduleTab, whose whole job is to
|
|
10966
|
+
// apply that cadence.
|
|
10967
|
+
pollDueAt = { ...pollDueAt, [TABS[activeIndexRef.current].key]: checkedAt + 1 };
|
|
10346
10968
|
}
|
|
10347
10969
|
}
|
|
10348
10970
|
if (!refreshed.ok) {
|
|
@@ -10389,8 +11011,7 @@ function App({ onCreateRemote = () => {} } = {}) {
|
|
|
10389
11011
|
if (controlReady) {
|
|
10390
11012
|
controlEpochs = { ...snapshot.value.epochs };
|
|
10391
11013
|
liveScheduling = true;
|
|
10392
|
-
|
|
10393
|
-
backgroundPollAt = checkedAt + 4 * runtime.refreshMs;
|
|
11014
|
+
openPollDeadlines(checkedAt);
|
|
10394
11015
|
armWake("heartbeat", checkedAt + GOVERNOR_HEARTBEAT_MS, heartbeatWake);
|
|
10395
11016
|
}
|
|
10396
11017
|
if (!refreshed.ok) {
|
|
@@ -11142,6 +11763,7 @@ export {
|
|
|
11142
11763
|
HOST_PATTERN,
|
|
11143
11764
|
MIN_REFRESH_SECONDS,
|
|
11144
11765
|
MAX_REFRESH_SECONDS,
|
|
11766
|
+
BACKGROUND_MODES,
|
|
11145
11767
|
TAB_KEYS,
|
|
11146
11768
|
ALERT_SOURCES,
|
|
11147
11769
|
REST_PER_FETCH,
|
|
@@ -11262,6 +11884,13 @@ export {
|
|
|
11262
11884
|
actionsRunsArgs,
|
|
11263
11885
|
actionsWorkflowsArgs,
|
|
11264
11886
|
parseActionsBodies,
|
|
11887
|
+
parseActionsRuns,
|
|
11888
|
+
parseWorkflowCatalog,
|
|
11889
|
+
resolveWorkflowNames,
|
|
11890
|
+
workflowCatalogDemand,
|
|
11891
|
+
fetchActions,
|
|
11892
|
+
ACTIONS_RUN_LIMIT,
|
|
11893
|
+
WORKFLOW_CATALOG_TTL_MS,
|
|
11265
11894
|
entityKey,
|
|
11266
11895
|
fetchConditionalEntity,
|
|
11267
11896
|
conditionalBatchResult,
|
|
@@ -11302,8 +11931,17 @@ export {
|
|
|
11302
11931
|
mergeDashboardCacheSnapshots,
|
|
11303
11932
|
nextSecurityRaw,
|
|
11304
11933
|
shouldCheckpointFreshness,
|
|
11305
|
-
|
|
11934
|
+
pollPolicyInterval,
|
|
11935
|
+
advanceUnchangedCount,
|
|
11936
|
+
POLL_ACTIVE_CI_MS,
|
|
11937
|
+
POLL_QUIET_AFTER,
|
|
11938
|
+
POLL_QUIET_MS,
|
|
11939
|
+
POLL_BACKGROUND_MS,
|
|
11306
11940
|
shouldShowFetchLoading,
|
|
11941
|
+
refreshIntent,
|
|
11942
|
+
manualRefreshRequest,
|
|
11943
|
+
planManualRefresh,
|
|
11944
|
+
conditionalRecoveryPlan,
|
|
11307
11945
|
pollSchedule,
|
|
11308
11946
|
retryPollAfterAdmissionFailure,
|
|
11309
11947
|
governorWakeTimes,
|
|
@@ -11323,6 +11961,7 @@ export {
|
|
|
11323
11961
|
rateLimitBlockProbeRecovered,
|
|
11324
11962
|
admitGovernorOperation,
|
|
11325
11963
|
runAdmittedOperation,
|
|
11964
|
+
GOVERNOR_ADMISSION_WAIT_MS,
|
|
11326
11965
|
pollResultTransition,
|
|
11327
11966
|
forcedBackoffKeys,
|
|
11328
11967
|
clearForcedBackoffAfterStart,
|
|
@@ -11338,6 +11977,11 @@ export {
|
|
|
11338
11977
|
graphqlArgs,
|
|
11339
11978
|
fetchGraphqlPage,
|
|
11340
11979
|
fetchGraphqlList,
|
|
11980
|
+
PAGE_DEMAND_THRESHOLD,
|
|
11981
|
+
pageGeneration,
|
|
11982
|
+
paginationDemand,
|
|
11983
|
+
demandedPageCount,
|
|
11984
|
+
mergeDemandedPages,
|
|
11341
11985
|
operationPausedUntil,
|
|
11342
11986
|
LIST_LIMIT,
|
|
11343
11987
|
readGraphqlObserver,
|