gh-glance 0.13.3 → 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.
Files changed (4) hide show
  1. package/CHANGELOG.md +78 -1
  2. package/README.md +46 -18
  3. package/index.mjs +907 -270
  4. 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
- const MIN_RUN_LIMIT = 20;
96
-
97
- // Ceiling on the pane-height-derived run limit. Without it a very tall terminal
98
- // asks for enough runs that the fetch outlasts REFRESH_MS, at which point ticks
99
- // are permanently absorbed by the in-flight guard and the effective refresh rate
100
- // silently becomes whatever `gh` can sustain.
101
- const MAX_RUN_LIMIT = 60;
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 every four active floors, so each inactive tab is considered
121
- // about every Nth floor instead of all three starting together. The shared
122
- // governor can schedule either active or background demand later when that is
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
- // MIN_RUN_LIMIT note above measures ~4.9s at 150 runs -- so it sits far above
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 = 1;
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(limit) {
1278
+ function actionsRunsArgs() {
1247
1279
  return [
1248
- apiPath(`repos/{owner}/{repo}/actions/runs?exclude_pull_requests=true&per_page=${limit}`),
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
- "[.workflow_runs[] | {databaseId: .id, displayTitle: .display_title, number: .run_number, headBranch: .head_branch, status, conclusion, startedAt: .run_started_at, updatedAt: .updated_at, workflowId: .workflow_id, url: .html_url}]",
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 parseActionsBodies(runsBody, workflowsBody) {
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: workflows.get(run.workflowId) ?? "",
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: cached?.body ?? null,
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
- async function fetchActions(limit, signal, {
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 responses = await Promise.all([
1380
- fetchConditionalEntity({
1381
- tab: "actions",
1382
- args: actionsRunsArgs(limit),
1383
- operation: "tab:actions-runs",
1384
- signal,
1385
- force,
1386
- entities,
1387
- }),
1388
- fetchConditionalEntity({
1389
- tab: "actions",
1390
- args: actionsWorkflowsArgs(),
1391
- operation: "tab:actions-workflows",
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
- force,
1394
- entities,
1395
- }),
1396
- ]);
1397
- const [runs, workflows] = responses;
1398
- const batch = conditionalBatchResult(responses, previousRaw);
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
- restSpent: batch.restSpent,
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
- parse: () => parseActionsBodies(runs.body ?? "", workflows.body ?? ""),
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
- // Walks the connection one explicitly admitted page at a time. The first page
1491
- // is covered by the tab's own reservation; each later page reserves its own
1492
- // envelope, so a denial there is an ordinary scheduling outcome rather than a
1493
- // hidden overspend. Rows already collected survive that denial and the result
1494
- // is marked incomplete rather than being published as a complete tab.
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 totalCount = page?.totalCount ?? null;
1526
- for (const node of page?.nodes ?? []) rows.push(mapRow(node));
1527
- while (page?.pageInfo?.hasNextPage && rows.length < LIST_LIMIT) {
1528
- const cursor = page.pageInfo.endCursor;
1529
- if (typeof cursor !== "string" || cursor.length === 0) { incomplete = true; break; }
1530
- if (!governor) { incomplete = true; break; }
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) { incomplete = true; break; }
1552
- const next = admitted.value;
1553
- spent += next.observedCost;
1554
- page = graphqlConnection(next, connection);
1555
- if (!page) { incomplete = true; break; }
1556
- for (const node of page.nodes) rows.push(mapRow(node));
1557
- }
1558
- if (page?.pageInfo?.hasNextPage && rows.length >= LIST_LIMIT) incomplete = true;
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 limited = rows.slice(0, LIST_LIMIT);
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
- const raw = JSON.stringify({ v: GRAPHQL_QUERY_VERSION, totalCount, incomplete, rows: limited });
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 ? limited.length : LIST_LIMIT,
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
- parse: () => limited,
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 is 2, not 1: measured 2026-08-10 with `GH_DEBUG=api`, `gh run list`
1666
- // issues GET /actions/runs *and* GET /actions/workflows. The 1 that stood here
1667
- // understated the default tab -- the tab every pane starts on, since
1668
- // initialTabIndex defaults to 0 -- by half.
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
- const REST_PER_FETCH = { actions: 2, issues: 0, prs: 0, security: SECURITY_REQUESTS_PER_FETCH };
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
- "tab:actions-workflows": { core: 1, graphql: 0 },
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
@@ -3105,17 +3361,23 @@ const GOVERNOR_SCOPE_VERSION = 1;
3105
3361
  // 5: scheduling fairness is persisted, because the starvation it prevents
3106
3362
  // happens *between* planning passes -- each manual refresh is its own pass, so
3107
3363
  // a counter that lives only inside one cannot see a run of them.
3108
- const GOVERNOR_STATE_VERSION = 5;
3364
+ const GOVERNOR_STATE_VERSION = 6;
3109
3365
  // Readable-as-evidence, never written: the shapes a still-running older pane
3110
3366
  // holds. Recognising that such a pane owns a live lease is what the restart
3111
3367
  // boundary depends on.
3112
- const LEGACY_GOVERNOR_VERSIONS = [GOVERNOR_STATE_VERSION, 4, 3, 2];
3368
+ const LEGACY_GOVERNOR_VERSIONS = [GOVERNOR_STATE_VERSION, 5, 4, 3, 2];
3113
3369
  // The first version whose file is already in the per-resource observer shape.
3114
3370
  // Anything below it is adapted before it can be read; see readLegacyGovernorState.
3115
3371
  const GOVERNOR_OBSERVER_SPLIT_VERSION = 4;
3116
3372
  // The first version carrying persisted scheduling fairness. A version 4 file is
3117
3373
  // otherwise in the current shape and only wants the field defaulted.
3118
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;
3119
3381
  // Manual work outranks active work, which is right until it means "always". A
3120
3382
  // user holding the refresh key issues one manual intent per planning pass, and
3121
3383
  // without a bound the active owner behind them never gets a turn at all.
@@ -3481,14 +3743,41 @@ function normalizeGovernorState(raw, nowMs, { prune = true, acceptVersion = GOVE
3481
3743
  function readLegacyGovernorState(raw, nowMs, version) {
3482
3744
  if (!isRecord(raw) || raw.version !== version) return null;
3483
3745
  const options = { prune: false, acceptVersion: version };
3484
- if (version >= GOVERNOR_FAIRNESS_VERSION) return normalizeGovernorState(raw, nowMs, options);
3485
- // A version 4 file is already in the per-resource observer shape and differs
3486
- // only by the field this version added, so defaulting it is the whole
3487
- // adaptation. Older files need their observer shape rewritten first.
3488
- const shaped = version >= GOVERNOR_OBSERVER_SPLIT_VERSION ? { ...raw } : adaptPreSplitGovernorShape(raw);
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);
3489
3753
  if (!shaped) return null;
3490
- shaped.fairness = { manualStreak: 0 };
3491
- 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 };
3492
3781
  }
3493
3782
 
3494
3783
  // Versions 2 and 3 held one observer for core, a differently shaped
@@ -3555,7 +3844,7 @@ function migrateGovernorState(raw, nowMs) {
3555
3844
  if (!shaped) return null;
3556
3845
  if (!isRecord(shaped.fairness)) shaped.fairness = { manualStreak: 0 };
3557
3846
  shaped.version = GOVERNOR_STATE_VERSION;
3558
- return normalizeGovernorState(shaped, nowMs);
3847
+ return normalizeGovernorState(dropSupersededTabCosts(shaped), nowMs);
3559
3848
  }
3560
3849
 
3561
3850
  function migrateV1GovernorState(raw, nowMs) {
@@ -4897,14 +5186,6 @@ function externalSampleIsUsable(sample) {
4897
5186
  );
4898
5187
  }
4899
5188
 
4900
- function resourcePerTick(table, activeKey) {
4901
- let cost = table[activeKey] ?? 0;
4902
- for (const key of TAB_KEYS) {
4903
- if (key !== activeKey) cost += (table[key] ?? 0) / BACKGROUND_EVERY;
4904
- }
4905
- return cost;
4906
- }
4907
-
4908
5189
  function alertArgs(source, path = source.path) {
4909
5190
  return [apiPath(path), ...apiHostArgs(), "--jq", source.jq];
4910
5191
  }
@@ -5658,14 +5939,38 @@ async function readSharedBudgetSources(signal, host = effectiveRuntimeHost(), {
5658
5939
  // and left to rot. The governor can admit less demand; this projection explains
5659
5940
  // pressure, not actual granted spend. The per-fetch prices come from
5660
5941
  // REST_PER_FETCH and GRAPHQL_PER_FETCH, which also feed the operation registry.
5661
- function projectedHourlyCost(activeKey) {
5662
- const perHour = 3_600_000 / runtime.refreshMs;
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
+ }
5663
5964
  return {
5664
- rest: Math.round(resourcePerTick(REST_PER_FETCH, activeKey) * perHour),
5665
- graphql: Math.round(resourcePerTick(GRAPHQL_PER_FETCH, activeKey) * perHour),
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) },
5666
5967
  };
5667
5968
  }
5668
5969
 
5970
+ function formatProjectedRange({ min, max }) {
5971
+ return min === max ? `~${max}` : `~${min}-${max}`;
5972
+ }
5973
+
5669
5974
  function doctorProbePlan() {
5670
5975
  // GraphQL cannot infer a repository the way `gh` does, so a report run from a
5671
5976
  // directory with no unambiguous remote says so per probe instead of sending a
@@ -5678,7 +5983,7 @@ function doctorProbePlan() {
5678
5983
  // Issues/PRs entries ran `gh issue list`/`gh pr list`, whose `--search`
5679
5984
  // routed through GraphQL at a price the report could not name.
5680
5985
  ["Repository access", graphqlArgs(), "doctor:repository", document("repository", graphqlRepositoryVariables(target)), "repository"],
5681
- ["Actions runs", ghApiArgs(actionsRunsArgs(MIN_RUN_LIMIT)), "doctor:actions-runs"],
5986
+ ["Actions runs", ghApiArgs(actionsRunsArgs()), "doctor:actions-runs"],
5682
5987
  ["Actions workflows", ghApiArgs(actionsWorkflowsArgs()), "doctor:actions-workflows"],
5683
5988
  ["Issues (first page)", graphqlArgs(), "doctor:issues", document("issues", graphqlPageVariables(null, target)), "issues"],
5684
5989
  ["Pull requests (first page)", graphqlArgs(), "doctor:prs", document("prs", graphqlPageVariables(null, target)), "prs"],
@@ -5830,11 +6135,16 @@ async function runDoctor() {
5830
6135
  field("REST core", budget.core),
5831
6136
  field("GraphQL", budget.graphql),
5832
6137
  field(
5833
- "this config spends",
6138
+ "projected demand",
5834
6139
  (() => {
5835
6140
  const active = TAB_KEYS[runtime.initialTabIndex] ?? TAB_KEYS[0];
5836
6141
  const { rest, graphql } = projectedHourlyCost(active);
5837
- return `~${rest} REST + ~${graphql} GraphQL per hour (refresh ${runtime.refreshMs / 1000}s, "${active}" active)`;
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)`;
5838
6148
  })(),
5839
6149
  ),
5840
6150
  "",
@@ -5943,6 +6253,7 @@ function parseRepoTarget(value) {
5943
6253
  // in-flight guard absorbs every other one and the effective rate is whatever
5944
6254
  // `gh` can sustain -- the requested interval silently stops being real. Clamping
5945
6255
  // with a stated minimum is honest where silently accepting it would not be.
6256
+ const BACKGROUND_MODES = ["all", "off"];
5946
6257
  const MIN_REFRESH_SECONDS = 2;
5947
6258
  const MAX_REFRESH_SECONDS = 3600;
5948
6259
 
@@ -5963,6 +6274,10 @@ function parseArgs(argv) {
5963
6274
  // parseArgs returns stays stated in one place.
5964
6275
  refreshSource: null,
5965
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,
5966
6281
  verbose: false,
5967
6282
  };
5968
6283
  for (let i = 0; i < argv.length; i += 1) {
@@ -5990,6 +6305,8 @@ function parseArgs(argv) {
5990
6305
  opts.refresh = takeValue("--refresh");
5991
6306
  } else if (arg === "--tab" || arg.startsWith("--tab=")) {
5992
6307
  opts.tab = takeValue("--tab");
6308
+ } else if (arg === "--background" || arg.startsWith("--background=")) {
6309
+ opts.background = takeValue("--background");
5993
6310
  } else {
5994
6311
  throw new Error(`unknown argument: ${arg}`);
5995
6312
  }
@@ -5997,7 +6314,7 @@ function parseArgs(argv) {
5997
6314
  return opts;
5998
6315
  }
5999
6316
 
6000
- // Returns { repo, refreshMs, tabKey, verbose } or throws with a message that
6317
+ // Returns { repo, refreshMs, tabKey, background, verbose } or throws with a message that
6001
6318
  // says what to do about it.
6002
6319
  function validateArgs(opts, tabKeys) {
6003
6320
  const { host, slug } = opts.repo !== null ? parseRepoTarget(opts.repo) : { host: null, slug: null };
@@ -6023,6 +6340,10 @@ function validateArgs(opts, tabKeys) {
6023
6340
  throw new Error(`--tab must be one of ${tabKeys.join(", ")}, got: ${opts.tab}`);
6024
6341
  }
6025
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
+
6026
6347
  return {
6027
6348
  help: opts.help,
6028
6349
  showVersion: opts.showVersion,
@@ -6031,6 +6352,7 @@ function validateArgs(opts, tabKeys) {
6031
6352
  host,
6032
6353
  refreshMs,
6033
6354
  tabKey: opts.tab,
6355
+ background: opts.background,
6034
6356
  verbose: opts.verbose,
6035
6357
  };
6036
6358
  }
@@ -6051,6 +6373,7 @@ const KEY_TABLE = [
6051
6373
  ["PgUp / PgDn", "Move a page at a time"],
6052
6374
  ["Enter", "Open the selected item or accept a prompt"],
6053
6375
  ["r", "Refresh the current tab when a safe grant is available"],
6376
+ ["R", "Resynchronize the current tab, ignoring cached validators and backoff"],
6054
6377
  ["w", "Adjust table column widths"],
6055
6378
  ["?", "Show the keys (any key closes it)"],
6056
6379
  ["q / Esc / Ctrl+C", "Quit (Esc leaves width mode)"],
@@ -6058,22 +6381,39 @@ const KEY_TABLE = [
6058
6381
  const KEY_COL = Math.max(...KEY_TABLE.map(([k]) => k.length)) + 3;
6059
6382
  const keyTableLines = () => KEY_TABLE.map(([k, d]) => `${k.padEnd(KEY_COL)}${d}`);
6060
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
+
6061
6402
  function helpLines(maxRows) {
6062
6403
  const rows = Math.max(1, Number.isSafeInteger(maxRows) ? maxRows : 1);
6063
6404
  const all = keyTableLines();
6405
+ const lineFor = (binding) => all[KEY_TABLE.findIndex(([key]) => key === binding)];
6064
6406
  if (all.length <= rows) return all;
6065
6407
  if (rows === 1) return [`… ${all.length} keys: gh-glance --help`];
6066
6408
  if (rows === 4) {
6067
6409
  return [
6068
- all[9],
6069
- all[6],
6410
+ lineFor("q / Esc / Ctrl+C"),
6411
+ lineFor("r"),
6070
6412
  "Ent/jk Open the selected/Move the cursor",
6071
6413
  `… ${all.length - 4} more: gh-glance --help`,
6072
6414
  ];
6073
6415
  }
6074
- const priority = [9, 6, 5, 3, 0, 7, 8, 1, 2, 4]
6075
- .slice(0, rows - 1)
6076
- .map((index) => all[index]);
6416
+ const priority = HELP_PRIORITY.slice(0, rows - 1).map(lineFor);
6077
6417
  return [...priority, `… ${all.length - priority.length} more: gh-glance --help`];
6078
6418
  }
6079
6419
 
@@ -6084,6 +6424,7 @@ Usage:
6084
6424
  gh-glance --repo owner/name Watch a specific repository instead
6085
6425
  gh-glance --refresh 15 Set a 15-second active-tab poll floor
6086
6426
  gh-glance --tab security Start on a specific tab
6427
+ gh-glance --background off Poll only the tab you are looking at
6087
6428
  gh-glance --verbose 2>log Log every gh call to a file (see below)
6088
6429
  gh-glance --doctor Print a diagnostic report and exit
6089
6430
  gh-glance --help Show this help
@@ -6098,10 +6439,14 @@ Options:
6098
6439
  and is unnecessary when running inside a clone of
6099
6440
  that repository.
6100
6441
  --refresh <seconds> Minimum active-tab poll interval (${MIN_REFRESH_SECONDS}-${MAX_REFRESH_SECONDS},
6101
- default ${REFRESH_MS / 1000}). Safe shared grants may
6102
- run later; each background tab is considered about
6103
- every ${BACKGROUND_EVERY} floors.
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.
6104
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.
6105
6450
  --verbose Write one line per gh invocation to stderr. stderr
6106
6451
  must be redirected -- writing it to the terminal
6107
6452
  would draw over the dashboard, so this refuses to
@@ -6117,10 +6462,12 @@ Run it from inside a locally cloned GitHub repository; the repo is inferred
6117
6462
  from the git remote, the same way \`gh\` does it. Requires the \`gh\` CLI
6118
6463
  (2.20 or newer), authenticated via \`gh auth login\`.
6119
6464
 
6120
- On a healthy single pane, the active tab is considered every ${REFRESH_MS / 1000}s and each
6121
- background tab about every ${(REFRESH_MS * BACKGROUND_EVERY) / 1000}s. Quota-consuming calls still
6122
- need a shared, resource-specific grant that preserves a hard reserve. Scheduled
6123
- checks and manual refresh do not bypass that safety check.
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.
6124
6471
 
6125
6472
  Row icons are GitHub Octicons and need a Nerd Font. Without one, set
6126
6473
  GH_GLANCE_ICONS=unicode for Unicode status glyphs and text row substitutes, or
@@ -6195,6 +6542,7 @@ if (IS_MAIN) {
6195
6542
  runtime.verbose = opts.verbose;
6196
6543
  if (opts.refreshMs !== null) runtime.refreshMs = opts.refreshMs;
6197
6544
  if (opts.tabKey !== null) runtime.initialTabIndex = TAB_KEYS.indexOf(opts.tabKey);
6545
+ if (opts.background !== null) runtime.background = opts.background;
6198
6546
 
6199
6547
  // Checked after parsing rather than before, so `gh-glance --repo` with no
6200
6548
  // value reports the missing value rather than silently printing help.
@@ -6933,7 +7281,7 @@ const MemoSecurityRow = React.memo(SecurityRow);
6933
7281
  const TABS = [
6934
7282
  {
6935
7283
  key: "actions",
6936
- fetch: ({ signal, runLimit, ...options }) => fetchActions(runLimit, signal, options),
7284
+ fetch: ({ signal, ...options }) => fetchActions(signal, options),
6937
7285
  label: "Actions",
6938
7286
  short: "Actions",
6939
7287
  header: ACTIONS_HEADER,
@@ -6943,7 +7291,8 @@ const TABS = [
6943
7291
  },
6944
7292
  {
6945
7293
  key: "issues",
6946
- fetch: ({ signal, governor, previousRaw }) => fetchIssues(signal, { governor, previousRaw }),
7294
+ fetch: ({ signal, governor, previousRaw, pages }) =>
7295
+ fetchIssues(signal, { governor, previousRaw, pages }),
6947
7296
  label: "Issues",
6948
7297
  short: "Issues",
6949
7298
  header: ISSUES_HEADER,
@@ -6953,7 +7302,8 @@ const TABS = [
6953
7302
  },
6954
7303
  {
6955
7304
  key: "prs",
6956
- fetch: ({ signal, governor, previousRaw }) => fetchPRs(signal, { governor, previousRaw }),
7305
+ fetch: ({ signal, governor, previousRaw, pages }) =>
7306
+ fetchPRs(signal, { governor, previousRaw, pages }),
6957
7307
  label: "Pull requests",
6958
7308
  short: "PRs",
6959
7309
  header: PRS_HEADER,
@@ -7788,19 +8138,92 @@ function nextSecurityRaw(previousRaw, raw, blind) {
7788
8138
  }
7789
8139
 
7790
8140
  const CACHE_FRESHNESS_CHECKPOINT_MS = 60_000;
7791
- const SECURITY_UNCHANGED_POLL_MS = 60_000;
7792
8141
 
7793
8142
  function shouldCheckpointFreshness({ persistedAt, completedAt }) {
7794
8143
  return !Number.isFinite(persistedAt) || completedAt - persistedAt >= CACHE_FRESHNESS_CHECKPOINT_MS;
7795
8144
  }
7796
8145
 
7797
- function securityPollDelay({ unchangedPolls, floorMs, force = false }) {
7798
- if (force) return 0;
7799
- return unchangedPolls > 0 ? Math.max(floorMs, SECURITY_UNCHANGED_POLL_MS) : floorMs;
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 };
7800
8213
  }
7801
8214
 
7802
- function shouldShowFetchLoading({ hasData, force }) {
7803
- return !hasData || force === true;
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;
7804
8227
  }
7805
8228
 
7806
8229
  function tabHold(tab, heldResources = {}) {
@@ -7820,82 +8243,92 @@ function tabHold(tab, heldResources = {}) {
7820
8243
  };
7821
8244
  }
7822
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.
7823
8256
  function pollSchedule({
7824
8257
  nowMs,
7825
8258
  floorMs,
7826
8259
  activeKey,
7827
- activeAt = nowMs,
7828
- backgroundAt = nowMs + 4 * floorMs,
8260
+ dueAt = {},
7829
8261
  backgroundIndex = 0,
7830
8262
  heldResources = {},
8263
+ background = "all",
8264
+ states = {},
7831
8265
  }) {
7832
8266
  const due = [];
7833
- let nextActiveAt = activeAt;
7834
- let nextBackgroundAt = backgroundAt;
8267
+ const next = {};
8268
+ const backgroundKeys = TAB_KEYS.filter((key) => key !== activeKey);
7835
8269
  let nextBackgroundIndex = backgroundIndex;
7836
- if (activeAt <= nowMs) {
7837
- const activeHold = tabHold(activeKey, heldResources);
7838
- if (activeHold.held) nextActiveAt = activeHold.retryAt;
7839
- else {
7840
- due.push({ key: activeKey, kind: "active" });
7841
- nextActiveAt = nowMs + floorMs;
7842
- }
7843
- }
7844
- // Three inactive tabs share one background slot every four active periods.
7845
- // Select at most one usable tab and retain the cursor for the next slot.
7846
- if (backgroundAt <= nowMs) {
7847
- const background = TAB_KEYS.filter((key) => key !== activeKey);
7848
- let selected = -1;
7849
- for (let offset = 0; offset < background.length; offset += 1) {
7850
- const index = (backgroundIndex + offset) % background.length;
7851
- if (!tabHold(background[index], heldResources).held) {
7852
- selected = index;
7853
- break;
7854
- }
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;
7855
8288
  }
7856
- if (selected >= 0) {
7857
- due.push({ key: background[selected], kind: "background" });
7858
- nextBackgroundIndex = (selected + 1) % background.length;
7859
- nextBackgroundAt = nowMs + 4 * floorMs;
7860
- } else {
7861
- nextBackgroundAt = background.reduce(
7862
- (earliest, key) => Math.min(earliest, tabHold(key, heldResources).retryAt),
7863
- Number.POSITIVE_INFINITY,
7864
- );
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;
7865
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;
7866
8309
  }
7867
8310
  return {
7868
8311
  due,
7869
- activeAt: nextActiveAt,
7870
- backgroundAt: nextBackgroundAt,
8312
+ dueAt: next,
7871
8313
  backgroundIndex: nextBackgroundIndex,
7872
- nextAt: Math.min(nextActiveAt, nextBackgroundAt),
8314
+ nextAt: Math.min(...Object.values(next)),
7873
8315
  };
7874
8316
  }
7875
8317
 
7876
8318
  function retryPollAfterAdmissionFailure({
8319
+ key,
7877
8320
  kind,
7878
8321
  retryAt,
7879
- activeAt,
7880
- backgroundAt,
8322
+ dueAt,
7881
8323
  backgroundIndex,
7882
8324
  previousBackgroundIndex,
7883
8325
  }) {
7884
- if (kind === "active") {
7885
- return {
7886
- activeAt: Math.min(activeAt, retryAt),
7887
- backgroundAt,
7888
- backgroundIndex,
7889
- };
7890
- }
7891
- if (kind === "background") {
7892
- return {
7893
- activeAt,
7894
- backgroundAt: Math.min(backgroundAt, retryAt),
7895
- backgroundIndex: previousBackgroundIndex,
7896
- };
7897
- }
7898
- return { activeAt, backgroundAt, backgroundIndex };
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
+ };
7899
8332
  }
7900
8333
 
7901
8334
  function governorWakeTimes(state, nowMs, floorMs, leaseId = null) {
@@ -8175,12 +8608,55 @@ function rateLimitBlockProbeRecovered(pending, state, nowMs) {
8175
8608
  });
8176
8609
  }
8177
8610
 
8178
- async function runAdmittedOperation({ scope, leaseId, operation, priority = "manual", signal, run }) {
8179
- const admitted = admitGovernorOperation(scope, leaseId, operation, priority, Date.now());
8180
- if (!admitted.ok || admitted.value.status !== "started") {
8181
- if (validGovernorId(admitted.value?.intentId)) {
8182
- cancelIntent(scope, admitted.value.intentId, Date.now());
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
+ }
8183
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());
8184
8660
  const detail = admitted.value?.resetMs
8185
8661
  ? ` until ${new Date(admitted.value.resetMs).toISOString()}`
8186
8662
  : admitted.value?.notBefore ? ` until ${new Date(admitted.value.notBefore).toISOString()}` : "";
@@ -8194,11 +8670,11 @@ async function runAdmittedOperation({ scope, leaseId, operation, priority = "man
8194
8670
  completeReservation(settlementScope, reservationId, {
8195
8671
  outcome: "measured-success",
8196
8672
  actualCost: costs,
8197
- }, Date.now());
8673
+ }, now());
8198
8674
  if (scope.accessKey && scope.accessKey !== runtimeIdentityCoordinator?.current()?.accessKey) return { ok: false, error: new Error("Credential changed"), reservationId };
8199
8675
  return { ok: true, value, reservationId };
8200
8676
  } catch (error) {
8201
- completeReservation(settlementScope, reservationId, { outcome: governorOutcomeForError(error) }, Date.now());
8677
+ completeReservation(settlementScope, reservationId, { outcome: governorOutcomeForError(error) }, now());
8202
8678
  return { ok: false, error, reservationId };
8203
8679
  }
8204
8680
  }
@@ -8884,6 +9360,14 @@ function App({ onCreateRemote = () => {} } = {}) {
8884
9360
  );
8885
9361
  const dataRef = useRef(data);
8886
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({});
8887
9371
  const [meta, setMeta] = useState(() =>
8888
9372
  Object.fromEntries(TABS.map((candidate) => [candidate.key, cachedEntry?.tabs[candidate.key]?.meta ?? null])),
8889
9373
  );
@@ -8969,8 +9453,6 @@ function App({ onCreateRemote = () => {} } = {}) {
8969
9453
  // Read at fetch time rather than being a hook dependency, so dragging the
8970
9454
  // pane wider doesn't cancel and restart in-flight requests -- the next tick
8971
9455
  // simply asks for the new size.
8972
- const runLimitRef = useRef(0);
8973
- runLimitRef.current = Math.min(Math.max(bodyRows + 1, MIN_RUN_LIMIT), MAX_RUN_LIMIT);
8974
9456
 
8975
9457
  // Read inside the polling closure, which is created once on mount and must
8976
9458
  // not be torn down and rebuilt every time you press a tab key.
@@ -9012,7 +9494,7 @@ function App({ onCreateRemote = () => {} } = {}) {
9012
9494
 
9013
9495
  // useInput's handler is created before the render body computes the visible
9014
9496
  // slice, so the movement handlers read through a ref -- the same pattern the
9015
- // poll loop uses for runLimitRef and activeIndexRef, and for the same reason:
9497
+ // poll loop uses for activeIndexRef, and for the same reason:
9016
9498
  // the closure must see current values without being rebuilt on every change.
9017
9499
  const navRef = useRef({ items: [], key: null, bodyRows: 1, tabKey: "actions", offset: 0 });
9018
9500
  navRef.current = {
@@ -9234,6 +9716,7 @@ function App({ onCreateRemote = () => {} } = {}) {
9234
9716
  : Math.min(items.length - 1, start + rows_ - 1)
9235
9717
  : Math.min(items.length - 1, Math.max(0, current + delta));
9236
9718
  setSelected((s) => ({ ...s, [tabKey]: itemKey(items[next]) }));
9719
+ demandPages(tabKey, next, items.length);
9237
9720
  // Keep the cursor on screen. Only the offset needed to reveal it changes,
9238
9721
  // so scrolling never jumps further than it has to.
9239
9722
  setOffset((o) => {
@@ -9246,6 +9729,24 @@ function App({ onCreateRemote = () => {} } = {}) {
9246
9729
  });
9247
9730
  }
9248
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
+
9249
9750
  // Rearms on every call to moveSelection (it's the only thing that changes
9250
9751
  // `selected`), so this fires exactly 60s after the *last* movement -- tab
9251
9752
  // switches and Enter don't count as activity and don't push it back. Clears
@@ -9253,7 +9754,12 @@ function App({ onCreateRemote = () => {} } = {}) {
9253
9754
  // switch back to after being idle doesn't still show a stale row marked.
9254
9755
  useEffect(() => {
9255
9756
  if (Object.keys(selected).length === 0) return;
9256
- const timer = setTimeout(() => setSelected({}), SELECTION_IDLE_MS);
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);
9257
9763
  return () => clearTimeout(timer);
9258
9764
  }, [selected]);
9259
9765
 
@@ -9341,13 +9847,16 @@ function App({ onCreateRemote = () => {} } = {}) {
9341
9847
  } else if (key.return) {
9342
9848
  if (remoteSetupRef.current) onCreateRemote(suspendTerminal);
9343
9849
  else openSelected();
9344
- } else if (input === "r") {
9850
+ } else if (input === "r" || input === "R") {
9345
9851
  // Goes through the same per-tab in-flight guard as the poll loop, so
9346
- // holding the key down cannot stack concurrent subprocesses. `force`
9347
- // bypasses (and clears) any failure backoff: this key is the user saying
9348
- // "try again now", and a refresh that silently declined to refresh would
9349
- // be worse than no key at all.
9350
- fetchTabRef.current?.(TABS[activeIndexRef.current].key, { force: true });
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);
9351
9860
  } else if (input >= "1" && input <= String(TABS.length)) {
9352
9861
  setActiveIndex(Number(input) - 1);
9353
9862
  } else if (key.tab && key.shift) {
@@ -9428,8 +9937,6 @@ function App({ onCreateRemote = () => {} } = {}) {
9428
9937
  // a Promise.allSettled barrier. Actions is by far the slowest fetch, so
9429
9938
  // barrelling everything together meant the three fast tabs sat invisible
9430
9939
  // behind it and nothing at all appeared until the slowest call returned.
9431
- let securityUnchangedPolls = 0;
9432
- let securityNextPollAt = 0;
9433
9940
 
9434
9941
  function setTabGovernorDecision(key, decision) {
9435
9942
  setGovernorDecisions((current) => sameVisibleGovernorDecision(current[key], decision)
@@ -9542,6 +10049,7 @@ function App({ onCreateRemote = () => {} } = {}) {
9542
10049
 
9543
10050
  function commit(key, run, {
9544
10051
  force = false,
10052
+ manual = false,
9545
10053
  scope,
9546
10054
  reservationId,
9547
10055
  admittedAt,
@@ -9556,7 +10064,8 @@ function App({ onCreateRemote = () => {} } = {}) {
9556
10064
  const settlementScope = settlement.scope;
9557
10065
  const currentAccess = settlement.isCurrent;
9558
10066
  inFlightRef.current[key] = true;
9559
- if (force) manualInFlight.add(key);
10067
+ inFlightStartedAt[key] = admittedAt;
10068
+ if (manual) manualInFlight.add(key);
9560
10069
  admittedCadenceRef.current[key] = nextAdmittedCadence(
9561
10070
  admittedCadenceRef.current[key],
9562
10071
  admittedAt,
@@ -9564,7 +10073,7 @@ function App({ onCreateRemote = () => {} } = {}) {
9564
10073
  clearForcedBackoffAfterStart(key, force, "started");
9565
10074
  const visibleLoading = shouldShowFetchLoading({
9566
10075
  hasData: dataRef.current[key] !== null,
9567
- force,
10076
+ manual,
9568
10077
  });
9569
10078
  if (visibleLoading) setLoading((l) => (l[key] ? l : { ...l, [key]: true }));
9570
10079
  const retainDeferredHold = retainDeferredGovernorHold({
@@ -9632,15 +10141,21 @@ function App({ onCreateRemote = () => {} } = {}) {
9632
10141
  limit,
9633
10142
  completedAt,
9634
10143
  });
9635
- if (key === "security") {
9636
- securityUnchangedPolls = transition.kind === "unchanged" ? securityUnchangedPolls + 1 : 0;
9637
- securityNextPollAt =
9638
- performance.now() +
9639
- securityPollDelay({
9640
- unchangedPolls: securityUnchangedPolls,
9641
- floorMs: runtime.refreshMs,
9642
- force,
9643
- });
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
+ };
9644
10159
  }
9645
10160
  publishStagedEntities(entityRef.current, stagedEntities, transition.kind);
9646
10161
  if (transition.kind === "unchanged") {
@@ -9674,7 +10189,6 @@ function App({ onCreateRemote = () => {} } = {}) {
9674
10189
  // so the next admitted tick parses its response instead of taking the
9675
10190
  // identical-output fast path.
9676
10191
  if (transition.kind === "unusable") {
9677
- if (key === "security") securityNextPollAt = 0;
9678
10192
  rawRef.current[key] = transition.nextRaw;
9679
10193
  setErrors((x) => (x[key] === null ? x : { ...x, [key]: null }));
9680
10194
  return;
@@ -9747,6 +10261,7 @@ function App({ onCreateRemote = () => {} } = {}) {
9747
10261
  })
9748
10262
  .finally(() => {
9749
10263
  inFlightRef.current[key] = false;
10264
+ delete inFlightStartedAt[key];
9750
10265
  if (!currentAccess() && !Object.values(inFlightRef.current).some(Boolean) && runtimeIdentityCoordinator?.current()?.quotaKey !== settlementScope.quotaKey) releaseLease(settlementScope, leaseId);
9751
10266
  manualInFlight.delete(key);
9752
10267
  onSettled?.();
@@ -9764,19 +10279,27 @@ function App({ onCreateRemote = () => {} } = {}) {
9764
10279
  : current);
9765
10280
  }
9766
10281
  }
9767
- if (!cancelled && queuedManual.delete(key)) {
9768
- void requestTab(key, "manual", { force: true });
10282
+ const queued = queuedManual.get(key);
10283
+ if (!cancelled && queued) {
10284
+ queuedManual.delete(key);
10285
+ void requestTab(key, "manual", { force: queued.force });
9769
10286
  }
9770
10287
  });
9771
10288
  }
9772
10289
 
9773
10290
  const leaseId = governorId();
9774
10291
  const pending = new Map();
9775
- // One manual handoff per tab. A keypress during an automatic request must
9776
- // not be lost, but repeated keypresses during the forced request itself
9777
- // must not create a trailing second batch.
9778
- const queuedManual = new Set();
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();
9779
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 = {};
9780
10303
  const wakeScheduler = createWakeScheduler();
9781
10304
  let scope = null;
9782
10305
  let cleanupScope = null;
@@ -9788,11 +10311,66 @@ function App({ onCreateRemote = () => {} } = {}) {
9788
10311
  let remoteUrls = [];
9789
10312
  let liveScheduling = false;
9790
10313
  let controlEpochs = null;
9791
- let activePollAt = Number.POSITIVE_INFINITY;
9792
- let backgroundPollAt = Number.POSITIVE_INFINITY;
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 };
9793
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]));
9794
10325
  let pendingBlockPublications = new Map();
9795
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
+
9796
10374
  function armWake(kind, at, run) {
9797
10375
  if (!cancelled) wakeScheduler.arm(kind, at, run);
9798
10376
  }
@@ -9800,8 +10378,7 @@ function App({ onCreateRemote = () => {} } = {}) {
9800
10378
  function failClosedRateLimit(key, reason) {
9801
10379
  liveScheduling = false;
9802
10380
  controlEpochs = null;
9803
- activePollAt = Number.POSITIVE_INFINITY;
9804
- backgroundPollAt = Number.POSITIVE_INFINITY;
10381
+ pollDueAt = { ...NEVER_DUE };
9805
10382
  wakeScheduler.clear("data");
9806
10383
  pauseCoordination(key, reason);
9807
10384
  armWake("control", governorControlRetryAt(Date.now(), runtime.refreshMs), controlWake);
@@ -9876,6 +10453,12 @@ function App({ onCreateRemote = () => {} } = {}) {
9876
10453
  rawRef.current = {};
9877
10454
  entityRef.current.clear();
9878
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 = {};
9879
10462
  setData(Object.fromEntries(TAB_KEYS.map((key) => [key, null])));
9880
10463
  setMeta({});
9881
10464
  setSecurityNotes([]);
@@ -9884,8 +10467,7 @@ function App({ onCreateRemote = () => {} } = {}) {
9884
10467
  }
9885
10468
  pendingBlockPublications.clear();
9886
10469
  liveScheduling = false;
9887
- activePollAt = Number.POSITIVE_INFINITY;
9888
- backgroundPollAt = Number.POSITIVE_INFINITY;
10470
+ pollDueAt = { ...NEVER_DUE };
9889
10471
  wakeScheduler.clear("data");
9890
10472
  wakeScheduler.clear("heartbeat");
9891
10473
  wakeScheduler.clear("control");
@@ -9993,7 +10575,7 @@ function App({ onCreateRemote = () => {} } = {}) {
9993
10575
  publishGovernorEpochs(snapshot);
9994
10576
  const wakes = governorWakeTimes(snapshot.value, nowMs, runtime.refreshMs, leaseId);
9995
10577
  armWake("control", wakes.controlAt, controlWake);
9996
- const nextDataAt = Math.min(wakes.dataAt, activePollAt, backgroundPollAt);
10578
+ const nextDataAt = Math.min(wakes.dataAt, ...Object.values(pollDueAt));
9997
10579
  if (liveScheduling) armWake("data", nextDataAt, dataWake);
9998
10580
  }
9999
10581
 
@@ -10002,39 +10584,55 @@ function App({ onCreateRemote = () => {} } = {}) {
10002
10584
  armFromState();
10003
10585
  }
10004
10586
 
10587
+ // Manual and tab-switch work replaces the automatic check that would
10588
+ // otherwise be due, rather than being added to it.
10005
10589
  function replaceActivePoll(kind, at) {
10006
10590
  if (["manual", "tab-switch"].includes(kind)) {
10007
- activePollAt = at + runtime.refreshMs;
10591
+ rescheduleTab(TABS[activeIndexRef.current].key, at);
10008
10592
  }
10009
10593
  }
10010
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.
10011
10599
  function requestTab(key, kind = "active", { force = false } = {}) {
10600
+ const manual = kind === "manual";
10012
10601
  const signal = controller.signal;
10013
10602
  const descriptor = tabForKey(key);
10014
10603
  const monotonicNow = performance.now();
10015
10604
  if (pendingBlockPublications.size > 0) {
10016
10605
  pauseCoordination(key, "block-unpublished");
10017
- return Promise.resolve({ persisted: false, retry: false, kind });
10606
+ return Promise.resolve({ persisted: false, retry: false, kind, key });
10018
10607
  }
10019
10608
  if (inFlightRef.current[key]) {
10020
- if (force && !manualInFlight.has(key)) queuedManual.add(key);
10021
- return Promise.resolve({ persisted: false, retry: false, kind });
10022
- }
10023
- if (!force && backoffActive(`tab:${key}`, monotonicNow)) {
10024
- return Promise.resolve({ persisted: false, retry: false, kind });
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 });
10025
10623
  }
10026
- if (!force && key === "security" && monotonicNow < securityNextPollAt) {
10027
- 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 });
10028
10626
  }
10029
10627
  const nowMs = Date.now();
10030
10628
  const currentScope = ensureScope(nowMs);
10031
10629
  if (!currentScope) {
10032
10630
  pauseCoordination(key, "unknown-scope");
10033
- return Promise.resolve({ persisted: false, retry: true, kind });
10631
+ return Promise.resolve({ persisted: false, retry: true, kind, key });
10034
10632
  }
10035
10633
  let protocolReady = liveScheduling;
10036
10634
  let resourceReady = liveScheduling;
10037
- if (!liveScheduling || force) {
10635
+ if (!liveScheduling || manual) {
10038
10636
  const snapshot = inspectGovernor(currentScope, nowMs);
10039
10637
  protocolReady = governorProtocolReady(
10040
10638
  { ok: true, value: { status: "published" } },
@@ -10045,13 +10643,12 @@ function App({ onCreateRemote = () => {} } = {}) {
10045
10643
  { ok: true, value: { status: "published" } }, snapshot, key, nowMs);
10046
10644
  if (!liveScheduling && resourceReady) {
10047
10645
  liveScheduling = true;
10048
- activePollAt = nowMs;
10049
- backgroundPollAt = nowMs + 4 * runtime.refreshMs;
10646
+ openPollDeadlines(nowMs - 1);
10050
10647
  armWake("heartbeat", nowMs + GOVERNOR_HEARTBEAT_MS, heartbeatWake);
10051
10648
  }
10052
10649
  }
10053
- const intentGate = runtimeIntentGate(liveScheduling, { force, protocolReady });
10054
- if (intentGate.requestProbe || force && !resourceReady) {
10650
+ const intentGate = runtimeIntentGate(liveScheduling, { force: manual, protocolReady });
10651
+ if (intentGate.requestProbe || manual && !resourceReady) {
10055
10652
  const snapshot = inspectGovernor(currentScope, nowMs);
10056
10653
  const costs = tabRequestCost(key);
10057
10654
  for (const resource of RATE_RESOURCES) {
@@ -10065,12 +10662,12 @@ function App({ onCreateRemote = () => {} } = {}) {
10065
10662
  if (!intentGate.registerIntent) {
10066
10663
  setTabGovernorDecision(key, { mode: "waiting", probing: true });
10067
10664
  setTabWaiting(key, true);
10068
- return Promise.resolve({ persisted: false, retry: false, kind });
10665
+ return Promise.resolve({ persisted: false, retry: false, kind, key });
10069
10666
  }
10070
10667
  const existing = pending.get(key);
10071
10668
  if (existing) {
10072
- if (!force || existing.kind === "manual") {
10073
- return Promise.resolve({ persisted: true, retry: false, kind });
10669
+ if (!manual || existing.kind === "manual") {
10670
+ return Promise.resolve({ persisted: true, retry: false, kind, key });
10074
10671
  }
10075
10672
  cancelIntent(currentScope, existing.intentId, nowMs);
10076
10673
  pending.delete(key);
@@ -10088,13 +10685,14 @@ function App({ onCreateRemote = () => {} } = {}) {
10088
10685
  const registered = registerIntent(currentScope, request);
10089
10686
  if (!registered.ok) {
10090
10687
  pauseCoordination(key, registered.reason);
10091
- return Promise.resolve({ persisted: false, retry: true, kind });
10688
+ return Promise.resolve({ persisted: false, retry: true, kind, key });
10092
10689
  }
10093
10690
  const decision = registered.value;
10094
10691
  pending.set(key, {
10095
10692
  intentId,
10096
10693
  kind,
10097
10694
  force,
10695
+ manual,
10098
10696
  wasDeferred: false,
10099
10697
  ...sharedLaneEvidence(decision),
10100
10698
  });
@@ -10103,20 +10701,20 @@ function App({ onCreateRemote = () => {} } = {}) {
10103
10701
  if (item) item.wasDeferred = true;
10104
10702
  setTabGovernorDecision(key, visibleGovernorDecision(decision));
10105
10703
  setTabWaiting(key, true);
10106
- if (force && decision.resource) {
10704
+ if (manual && decision.resource) {
10107
10705
  const budget = inspectGovernor(currentScope, nowMs).value?.budgets?.[decision.resource];
10108
10706
  if (budget) requestManualProbe(currentScope, leaseId, budget.epoch, budget.observedAt, nowMs);
10109
10707
  }
10110
10708
  armWake("data", decision.retryAt ?? decision.notBefore ?? nowMs + runtime.refreshMs, dataWake);
10111
10709
  armFromState(nowMs);
10112
- return Promise.resolve({ persisted: true, retry: false, kind });
10710
+ return Promise.resolve({ persisted: true, retry: false, kind, key });
10113
10711
  }
10114
10712
  const started = startReservation(currentScope, decision.reservationId, nowMs);
10115
10713
  if (!started.ok) {
10116
10714
  pauseCoordination(key, started.reason);
10117
10715
  if (pendingFailureIsTerminal(started.reason)) finishPending(key, intentId);
10118
10716
  else armWake("data", governorControlRetryAt(nowMs, runtime.refreshMs), dataWake);
10119
- return Promise.resolve({ persisted: true, retry: false, kind });
10717
+ return Promise.resolve({ persisted: true, retry: false, kind, key });
10120
10718
  }
10121
10719
  if (started.value.status !== "started") {
10122
10720
  const item = pending.get(key);
@@ -10126,7 +10724,7 @@ function App({ onCreateRemote = () => {} } = {}) {
10126
10724
  ...started.value,
10127
10725
  }));
10128
10726
  armWake("data", started.value.notBefore ?? nowMs + runtime.refreshMs, dataWake);
10129
- return Promise.resolve({ persisted: true, retry: false, kind });
10727
+ return Promise.resolve({ persisted: true, retry: false, kind, key });
10130
10728
  }
10131
10729
  setTabWaiting(key, false);
10132
10730
  replaceActivePoll(kind, nowMs);
@@ -10135,23 +10733,31 @@ function App({ onCreateRemote = () => {} } = {}) {
10135
10733
  key,
10136
10734
  () => descriptor.fetch({
10137
10735
  signal,
10138
- runLimit: runLimitRef.current,
10139
10736
  entities: entityRef.current,
10140
10737
  force,
10738
+ catalog: workflowCatalogRef.current,
10739
+ pages: pageStateRef.current[key]?.pages ?? 1,
10141
10740
  governor: { scope: currentScope, leaseId },
10142
10741
  previousRaw: rawRef.current[key] ?? null,
10143
10742
  }),
10144
10743
  {
10145
10744
  force,
10745
+ manual,
10146
10746
  scope: currentScope,
10147
10747
  reservationId: decision.reservationId,
10148
10748
  admittedAt: nowMs,
10149
10749
  automaticStatusVisible: false,
10150
10750
  onSettled: () => finishPending(key, intentId),
10151
10751
  },
10152
- ).then(() => ({ persisted: true, retry: false, kind }));
10752
+ ).then(() => ({ persisted: true, retry: false, kind, key }));
10153
10753
  }
10154
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
+ }
10155
10761
  const requestedAt = Date.now();
10156
10762
  // Manual work replaces the active poll that would otherwise be due. Move
10157
10763
  // that deadline before touching the governor so a data wake already in
@@ -10222,14 +10828,16 @@ function App({ onCreateRemote = () => {} } = {}) {
10222
10828
  key,
10223
10829
  () => descriptor.fetch({
10224
10830
  signal: controller.signal,
10225
- runLimit: runLimitRef.current,
10226
10831
  entities: entityRef.current,
10227
10832
  force: item.force,
10833
+ catalog: workflowCatalogRef.current,
10834
+ pages: pageStateRef.current[key]?.pages ?? 1,
10228
10835
  governor: { scope: currentScope, leaseId },
10229
10836
  previousRaw: rawRef.current[key] ?? null,
10230
10837
  }),
10231
10838
  {
10232
10839
  force: item.force,
10840
+ manual: item.manual === true,
10233
10841
  scope: currentScope,
10234
10842
  reservationId: decision.value.reservationId,
10235
10843
  admittedAt: nowMs,
@@ -10266,13 +10874,16 @@ function App({ onCreateRemote = () => {} } = {}) {
10266
10874
  nowMs,
10267
10875
  floorMs: runtime.refreshMs,
10268
10876
  activeKey: active,
10269
- activeAt: activePollAt,
10270
- backgroundAt: backgroundPollAt,
10877
+ dueAt: pollDueAt,
10271
10878
  backgroundIndex,
10272
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
+ }])),
10273
10885
  });
10274
- activePollAt = planned.activeAt;
10275
- backgroundPollAt = planned.backgroundAt;
10886
+ pollDueAt = planned.dueAt;
10276
10887
  backgroundIndex = planned.backgroundIndex;
10277
10888
  const outcomes = await Promise.allSettled(
10278
10889
  planned.due.map(({ key, kind }) => requestTab(key, kind)),
@@ -10281,15 +10892,14 @@ function App({ onCreateRemote = () => {} } = {}) {
10281
10892
  let retryNeeded = false;
10282
10893
  for (const outcome of outcomes) {
10283
10894
  if (outcome.status !== "fulfilled" || outcome.value?.retry !== true) continue;
10284
- ({ activeAt: activePollAt, backgroundAt: backgroundPollAt, backgroundIndex } =
10285
- retryPollAfterAdmissionFailure({
10286
- kind: outcome.value.kind,
10287
- retryAt,
10288
- activeAt: activePollAt,
10289
- backgroundAt: backgroundPollAt,
10290
- backgroundIndex,
10291
- previousBackgroundIndex,
10292
- }));
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
+ }));
10293
10903
  retryNeeded = true;
10294
10904
  }
10295
10905
  if (retryNeeded) armWake("data", retryAt, dataWake);
@@ -10346,10 +10956,15 @@ function App({ onCreateRemote = () => {} } = {}) {
10346
10956
  if (controlReady && (!liveScheduling || activeEpochChanged)) {
10347
10957
  const starting = !liveScheduling;
10348
10958
  liveScheduling = true;
10349
- activePollAt = checkedAt + 1;
10350
10959
  if (starting) {
10351
- backgroundPollAt = checkedAt + 4 * runtime.refreshMs;
10960
+ openPollDeadlines(checkedAt);
10352
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 };
10353
10968
  }
10354
10969
  }
10355
10970
  if (!refreshed.ok) {
@@ -10396,8 +11011,7 @@ function App({ onCreateRemote = () => {} } = {}) {
10396
11011
  if (controlReady) {
10397
11012
  controlEpochs = { ...snapshot.value.epochs };
10398
11013
  liveScheduling = true;
10399
- activePollAt = checkedAt + 1;
10400
- backgroundPollAt = checkedAt + 4 * runtime.refreshMs;
11014
+ openPollDeadlines(checkedAt);
10401
11015
  armWake("heartbeat", checkedAt + GOVERNOR_HEARTBEAT_MS, heartbeatWake);
10402
11016
  }
10403
11017
  if (!refreshed.ok) {
@@ -11149,6 +11763,7 @@ export {
11149
11763
  HOST_PATTERN,
11150
11764
  MIN_REFRESH_SECONDS,
11151
11765
  MAX_REFRESH_SECONDS,
11766
+ BACKGROUND_MODES,
11152
11767
  TAB_KEYS,
11153
11768
  ALERT_SOURCES,
11154
11769
  REST_PER_FETCH,
@@ -11269,6 +11884,13 @@ export {
11269
11884
  actionsRunsArgs,
11270
11885
  actionsWorkflowsArgs,
11271
11886
  parseActionsBodies,
11887
+ parseActionsRuns,
11888
+ parseWorkflowCatalog,
11889
+ resolveWorkflowNames,
11890
+ workflowCatalogDemand,
11891
+ fetchActions,
11892
+ ACTIONS_RUN_LIMIT,
11893
+ WORKFLOW_CATALOG_TTL_MS,
11272
11894
  entityKey,
11273
11895
  fetchConditionalEntity,
11274
11896
  conditionalBatchResult,
@@ -11309,8 +11931,17 @@ export {
11309
11931
  mergeDashboardCacheSnapshots,
11310
11932
  nextSecurityRaw,
11311
11933
  shouldCheckpointFreshness,
11312
- securityPollDelay,
11934
+ pollPolicyInterval,
11935
+ advanceUnchangedCount,
11936
+ POLL_ACTIVE_CI_MS,
11937
+ POLL_QUIET_AFTER,
11938
+ POLL_QUIET_MS,
11939
+ POLL_BACKGROUND_MS,
11313
11940
  shouldShowFetchLoading,
11941
+ refreshIntent,
11942
+ manualRefreshRequest,
11943
+ planManualRefresh,
11944
+ conditionalRecoveryPlan,
11314
11945
  pollSchedule,
11315
11946
  retryPollAfterAdmissionFailure,
11316
11947
  governorWakeTimes,
@@ -11330,6 +11961,7 @@ export {
11330
11961
  rateLimitBlockProbeRecovered,
11331
11962
  admitGovernorOperation,
11332
11963
  runAdmittedOperation,
11964
+ GOVERNOR_ADMISSION_WAIT_MS,
11333
11965
  pollResultTransition,
11334
11966
  forcedBackoffKeys,
11335
11967
  clearForcedBackoffAfterStart,
@@ -11345,6 +11977,11 @@ export {
11345
11977
  graphqlArgs,
11346
11978
  fetchGraphqlPage,
11347
11979
  fetchGraphqlList,
11980
+ PAGE_DEMAND_THRESHOLD,
11981
+ pageGeneration,
11982
+ paginationDemand,
11983
+ demandedPageCount,
11984
+ mergeDemandedPages,
11348
11985
  operationPausedUntil,
11349
11986
  LIST_LIMIT,
11350
11987
  readGraphqlObserver,