alissa-tools-github-revloop 0.26.0__tar.gz → 0.27.0__tar.gz

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 (32) hide show
  1. {alissa_tools_github_revloop-0.26.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.27.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/loop.py +18 -7
  3. alissa_tools_github_revloop-0.27.0/src/main/alissa/tools/github/revloop/version +1 -0
  4. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +6 -4
  5. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/page.py +63 -11
  6. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/sources.py +181 -18
  7. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  8. alissa_tools_github_revloop-0.26.0/src/main/alissa/tools/github/revloop/version +0 -1
  9. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/LICENSE +0 -0
  10. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/MANIFEST.in +0 -0
  11. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/NOTICE +0 -0
  12. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/README.md +0 -0
  13. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/requirements.txt +0 -0
  14. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/setup.cfg +0 -0
  15. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/setup.py +0 -0
  16. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  17. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  18. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  19. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/config.py +0 -0
  20. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  21. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  22. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  23. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/state.py +0 -0
  24. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  25. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  26. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  27. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  28. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  30. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.27.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.26.0
3
+ Version: 0.27.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -1523,10 +1523,11 @@ class StabilityNotice:
1523
1523
  The bookkeeping travels WITH the text rather than being written when the
1524
1524
  gate decides, because the two are not the same event: the gate runs above
1525
1525
  the CI gate and `_ensure_hub`, either of which can still refuse the round.
1526
- Writing `lifts` there would spend an operator's re-entry grant on a round
1527
- that was never queued -- and the grant is the only thing that can lift the
1528
- hold, so spending one silently is the one bookkeeping error this guard
1529
- cannot afford.
1526
+ Writing `grants_seen` there would spend an operator's re-entry grant on a
1527
+ round that was never queued -- and the grant is the only thing that can lift
1528
+ the hold, so spending one silently is the one bookkeeping error this guard
1529
+ cannot afford. The seed strengthens that: a gate-time write would also seed
1530
+ a fresh episode's ledger off a round the reviewer never received.
1530
1531
  """
1531
1532
 
1532
1533
  text: str
@@ -2319,9 +2320,19 @@ class ReviewWatcher:
2319
2320
  # capped out at 10, was granted +5, and went tests-only at round 13
2320
2321
  # would get five "grace" rounds and no hold -- the guard disarmed on
2321
2322
  # exactly the shape issue #105 cites (PR #106 round 1, minor). After the
2322
- # seed the predicate means what it should: only an ack posted AFTER this
2323
- # guard last spoke can lift the hold. Either way the write happens only
2324
- # once `_spawn` has actually queued the round.
2323
+ # seed the predicate means what it should: only an ack THE LEDGER DID
2324
+ # NOT ALREADY KNOW ABOUT can lift the hold. Deliberately not "posted
2325
+ # after this guard last spoke", which claims more than the code
2326
+ # enforces -- the seed reads `granted_rounds`, i.e. acks already
2327
+ # collected into state, and `_collect_acks` runs from exactly two places
2328
+ # (the cap-out branch and the `graced` branch here). So an ack posted
2329
+ # while the loop is below its cap with no episode open is not recorded
2330
+ # when it is posted; it is first discovered by a later `graced` scan and
2331
+ # lifts the hold despite predating the notice. That is the doctrine
2332
+ # working -- nothing consumed that grant, so it is genuinely unspent,
2333
+ # and it errs toward extra rounds rather than a false hold (PR #106
2334
+ # round 2, nit). Either way the write happens only once `_spawn` has
2335
+ # actually queued the round.
2325
2336
  notice = STABILITY_NOTICE.format(
2326
2337
  base=base[:8],
2327
2338
  head=pr.head_sha[:8],
@@ -5,10 +5,12 @@ budget of its own beyond two cached checks.
5
5
  Ported from the devloop's worker console (`alissa.tools.github.devloop.webui`,
6
6
  its PR #38) and adapted to reviewer semantics: the unit of work is a PR round,
7
7
  not an issue attempt, so the pipeline board is PR-centric (PR ref → round k of
8
- the cap → session → stage), the operator inbox pages cap-outs and stalled
9
- deferrals, and there is no worker-tasks panel (reviewers create no tasks) and
10
- no maintenance edge. Module shapes are copied deliberately -- family precedent
11
- is copy-adapt per repo; a shared-webui package is a separate, deferred lane.
8
+ the cap → session → stage), the operator inbox pages cap-outs, stalled
9
+ deferrals and stability holds, split live from settled so a page whose PR has
10
+ left the newest poll's candidate set files itself away, and there is no
11
+ worker-tasks panel (reviewers create no tasks) and no maintenance edge. Module
12
+ shapes are copied deliberately -- family precedent is copy-adapt per repo; a
13
+ shared-webui package is a separate, deferred lane.
12
14
 
13
15
  The daemon (loop.py) already persists everything the console needs: every poll
14
16
  pass writes one `poll_snapshots` row (UI-1, PR #35) carrying the pass timing,
@@ -244,6 +244,13 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
244
244
  .inbox-kind { font-family: var(--mono); font-size: 0.75rem; color: var(--status-blocked); }
245
245
  .inbox-kind.cap-out { color: var(--status-cancelled); }
246
246
  .inbox-kind.stability-held { color: var(--status-cancelled); }
247
+ .inbox-settled > summary { cursor: pointer; list-style: none; padding: 0.55rem 0;
248
+ border-top: 1px solid var(--surface-border); color: var(--text-muted);
249
+ font-family: var(--mono); font-size: 0.75rem; }
250
+ .inbox-settled > summary::-webkit-details-marker { display: none; }
251
+ .inbox-settled .inbox-item { opacity: 0.55; }
252
+ .inbox-note { color: var(--text-muted); font-size: 0.75rem; font-family: var(--mono);
253
+ padding: 0.55rem 0; border-top: 1px solid var(--surface-border); }
247
254
  .log {
248
255
  font-family: var(--mono); font-size: 0.75rem; line-height: 1.55;
249
256
  color: var(--text-tertiary); background: var(--bg-primary);
@@ -504,16 +511,60 @@ _JS = r"""
504
511
  });
505
512
  }
506
513
 
507
- function renderInbox(items) {
508
- if (!items.length) { el('inbox').innerHTML = '<div class="empty">Inbox clear.</div>'; return; }
509
- el('inbox').innerHTML = items.map(function (it) {
510
- return '<div class="inbox-item"><span>' +
511
- '<span class="inbox-kind ' + esc(it.kind) + '">' + esc(it.kind) + '</span> ' +
512
- '<a href="' + esc(it.url) + '" target="_blank" rel="noopener">' +
513
- esc(it.repo_slug) + '#' + esc(it.number) + '</a>' +
514
- (it.detail ? ' <span class="muted mono">' + esc(it.detail) + '</span>' : '') +
515
- '</span><span class="muted num">' + dur(it.age_seconds) + '</span></div>';
516
- }).join('');
514
+ function inboxRow(it) {
515
+ return '<div class="inbox-item"><span>' +
516
+ '<span class="inbox-kind ' + esc(it.kind) + '">' + esc(it.kind) + '</span> ' +
517
+ '<a href="' + esc(it.url) + '" target="_blank" rel="noopener">' +
518
+ esc(it.repo_slug) + '#' + esc(it.number) + '</a>' +
519
+ (it.detail ? ' <span class="muted mono">' + esc(it.detail) + '</span>' : '') +
520
+ '</span><span class="muted num">' + dur(it.age_seconds) + '</span></div>';
521
+ }
522
+
523
+ // Two lists, one panel. `items` is what the operator still owes; `settled`
524
+ // is pages whose PR has left the poll's candidate set (merged, closed, or
525
+ // the review request withdrawn) -- exhaust, so it rides behind a collapsed
526
+ // <details> and never displaces the empty state. An inbox whose live half
527
+ // is empty says "Inbox clear." even with a hundred settled rows behind it:
528
+ // that is the whole point of the split.
529
+ function renderInbox(items, settled, truncated, dropped) {
530
+ settled = settled || [];
531
+ dropped = dropped || 0;
532
+ // The panel re-renders every poll and the innerHTML assignment below
533
+ // destroys this subtree, taking the <details> node with it -- and its
534
+ // `open` is the OPERATOR's state, not the payload's. So read it off the
535
+ // live node first and fold it back into the string being built, rather
536
+ // than re-applying it after the write (which would flash the footer shut
537
+ // for a frame on every poll). No prior node -- the first render, or an
538
+ // inbox that had nothing settled last time -- reads false, which is how
539
+ // collapsed-by-default survives this: the `open` here is never
540
+ // unconditional. Fifty rows are not readable in ten seconds.
541
+ var prior = el('inbox').querySelector('details.inbox-settled');
542
+ var open = !!(prior && prior.open);
543
+ // `Inbox clear.` is a positive claim that nothing is owed, so it may only
544
+ // be made off a COMPLETE read. When a ledger read came back at its bound
545
+ // there are older rows nobody looked at, and the panel says that instead.
546
+ var empty = truncated
547
+ ? 'No live pages in the ledger window read — it was truncated, so an older page may still be outstanding.'
548
+ : 'Inbox clear.';
549
+ var body = items.length ? items.map(inboxRow).join('')
550
+ : '<div class="empty">' + empty + '</div>';
551
+ // A truncated read qualifies the LIST as much as its absence: a partial
552
+ // list of live pages reads as complete unless the panel says otherwise,
553
+ // so the qualifier rides with the rows and not only with the empty state.
554
+ if (items.length && truncated) {
555
+ body += '<div class="inbox-note">ledger window truncated — older pages were not read</div>';
556
+ }
557
+ if (settled.length) {
558
+ // Two true numbers, not one: `settled.length` is what expanding the
559
+ // footer actually reveals, and `dropped` is what the per-half cap left
560
+ // out of the payload -- a drop that happens well inside a read that
561
+ // never hit its bound, so `truncated` does not cover it.
562
+ body += '<details class="inbox-settled"' + (open ? ' open' : '') + '><summary>' +
563
+ settled.length + ' settled' +
564
+ (dropped ? ' (+' + dropped + ' not shown)' : '') + ' — show</summary>' +
565
+ settled.map(inboxRow).join('') + '</details>';
566
+ }
567
+ el('inbox').innerHTML = body;
517
568
  }
518
569
 
519
570
  function act(url, body, btn) {
@@ -614,7 +665,8 @@ _JS = r"""
614
665
  sparkline(el('spark-duration'), d.sparklines.poll_duration_ms);
615
666
  sparkline(el('spark-active'), d.sparklines.active_sessions);
616
667
  renderPipeline(d.pipeline);
617
- renderInbox(d.inbox);
668
+ renderInbox(d.inbox, d.inbox_settled, d.inbox_truncated,
669
+ d.inbox_settled_dropped);
618
670
  renderSessions(d.sessions);
619
671
  renderTopProcs(d.top_procs);
620
672
  renderLog(d.log);
@@ -103,6 +103,28 @@ INBOX_STABILITY = "stability-held"
103
103
  # clears stops being an inbox. The ping read applies the kind filter in SQL, so
104
104
  # this counts pages and not the telemetry rows interleaved with them.
105
105
  INBOX_LIMIT = 50
106
+ # How many rows each ledger read materialises for the live/settled split to
107
+ # choose from. It has to be WIDER than what is rendered. The split picks from
108
+ # whatever the read returned, so reading exactly INBOX_LIMIT rows would make
109
+ # the live half not "every live page, capped at INBOX_LIMIT" but "the live
110
+ # pages that happen to be among the newest INBOX_LIMIT rows of a never-pruned
111
+ # store" -- and a full window of settled rows would empty it while a page is
112
+ # outstanding, which is the failure this whole split exists to prevent, reached
113
+ # from the other side. Four windows deep gives the live half room to fill past
114
+ # a long run of settled rows (`escalations` is keyed per head, so one PR that
115
+ # caps out across five pushes spends five slots). The read is still bounded, so
116
+ # the never-pruned tables still cannot grow an unbounded payload, and what
117
+ # reaches the page is capped at INBOX_LIMIT per half exactly as before. A wider
118
+ # window is still a window, so `inbox_truncated` covers the residual case.
119
+ INBOX_READ_LIMIT = INBOX_LIMIT * 4
120
+ # How many poll intervals a freshly raised page is live for regardless of the
121
+ # snapshot. The liveness test reads the LATEST snapshot, so a page raised
122
+ # between two passes has no snapshot to appear in yet and would flicker into
123
+ # `settled` for one refresh; two intervals covers the raise-to-next-pass gap
124
+ # with a pass to spare. Erring long is the safe direction -- the failure this
125
+ # split exists to prevent is an operator skipping the inbox, and a settled row
126
+ # shown one refresh too long costs nothing.
127
+ INBOX_LIVE_GRACE_INTERVALS = 2
106
128
  # The kind prefix that makes a ping row an operator page. `read_pings` matches
107
129
  # it in SQL; `_inbox` re-checks it to split the session out of the kind.
108
130
  PING_STALLED_PREFIX = f"{ESCALATION_STALLED}:"
@@ -116,6 +138,23 @@ RETRY_NO_ROW = "no ledger row"
116
138
  RETRY_UNAVAILABLE = "state unavailable"
117
139
 
118
140
 
141
+ def _pr_key(repo: object, number: object) -> "tuple[str, int] | None":
142
+ """The identity the inbox and the pipeline board compare PRs by.
143
+
144
+ The two sides reach this from different stores -- an inbox row's `number`
145
+ comes out of sqlite, a board row's out of a snapshot's JSON -- so the key
146
+ is normalised rather than compared as-is, and a row too malformed to key
147
+ (a null number) returns None instead of raising: every read path here
148
+ degrades, it never blanks the dashboard.
149
+ """
150
+ if not isinstance(number, (int, str)):
151
+ return None
152
+ try:
153
+ return (str(repo), int(number))
154
+ except ValueError:
155
+ return None
156
+
157
+
119
158
  def is_managed(name: "str | None") -> bool:
120
159
  """Whether a tmux session belongs to this daemon's reviewer namespace.
121
160
 
@@ -257,24 +296,27 @@ class Sources:
257
296
  return self._read_state([], lambda st: st.read_snapshots(limit))
258
297
 
259
298
  def ledgers(self) -> dict:
260
- """The two INBOX tables, each bounded to INBOX_LIMIT rows of the kind
261
- the console pages on. The spawn ledger is deliberately not here: it is
262
- a lookup table read by key, not a display list bounded by recency --
263
- `sessions` reads it for exactly the session names it renders."""
299
+ """The two INBOX tables, each bounded to INBOX_READ_LIMIT rows of the
300
+ kind the console pages on -- deliberately wider than the INBOX_LIMIT
301
+ the payload renders, because `_inbox` splits live from settled inside
302
+ whatever this returns and a window of settled rows must not be able to
303
+ squeeze the live half out. The spawn ledger is deliberately not here:
304
+ it is a lookup table read by key, not a display list bounded by recency
305
+ -- `sessions` reads it for exactly the session names it renders."""
264
306
  empty: "dict[str, list]" = {
265
307
  "escalations": [], "pings": [], "stability_pings": []
266
308
  }
267
309
  return self._read_state(empty, lambda st: {
268
- "escalations": st.read_escalations(INBOX_LIMIT),
310
+ "escalations": st.read_escalations(INBOX_READ_LIMIT),
269
311
  "pings": st.read_pings(
270
- INBOX_LIMIT, kind_prefix=PING_STALLED_PREFIX
312
+ INBOX_READ_LIMIT, kind_prefix=PING_STALLED_PREFIX
271
313
  ),
272
314
  # A SECOND bounded read rather than one unfiltered one: `read_pings`
273
315
  # narrows to a single prefix in SQL so its limit bounds the rows the
274
316
  # console actually renders, and the telemetry kinds interleaved with
275
317
  # both pages would otherwise evict them.
276
318
  "stability_pings": st.read_pings(
277
- INBOX_LIMIT, kind_prefix=PING_STABILITY_PREFIX
319
+ INBOX_READ_LIMIT, kind_prefix=PING_STABILITY_PREFIX
278
320
  ),
279
321
  })
280
322
 
@@ -542,6 +584,16 @@ class Sources:
542
584
  # and still working (in_flight) and one whose respawn is deferred
543
585
  # behind a session that still shows life (deferred).
544
586
  chrono = list(reversed(snaps))
587
+ # The board rows and the inbox liveness test read the SAME item set --
588
+ # built once, so the two panels can never disagree about which PRs the
589
+ # newest pass still had in hand.
590
+ items = self._pipeline(latest)
591
+ inbox = self._inbox(
592
+ ledgers["escalations"],
593
+ ledgers["pings"],
594
+ ledgers.get("stability_pings", []),
595
+ live_prs=self._live_prs(latest, items),
596
+ )
545
597
  sparklines = {
546
598
  "poll_duration_ms": [s["duration_ms"] for s in chrono],
547
599
  "active_sessions": [s["in_flight"] + s["deferred"] for s in chrono],
@@ -588,13 +640,23 @@ class Sources:
588
640
  "snapshot_ts": latest["ts"] if latest else None,
589
641
  "duration_ms": latest["duration_ms"] if latest else None,
590
642
  "round_cap": self.config.round_cap,
591
- "items": self._pipeline(latest),
643
+ "items": items,
592
644
  },
593
- "inbox": self._inbox(
594
- ledgers["escalations"],
595
- ledgers["pings"],
596
- ledgers.get("stability_pings", []),
597
- ),
645
+ "inbox": inbox["live"],
646
+ # Exhaust, not backlog: pages whose PR has left the poll's
647
+ # candidate set. Kept in the payload (the operator can still audit
648
+ # what was raised) but out of the list that means "you owe this".
649
+ "inbox_settled": inbox["settled"],
650
+ # Post-cap on purpose: the counter has to match the rows expanding
651
+ # the footer actually reveals. What the cap dropped rides beside
652
+ # it rather than inside it, so both numbers are true of the thing
653
+ # they label.
654
+ "inbox_settled_count": len(inbox["settled"]),
655
+ "inbox_settled_dropped": inbox["settled_dropped"],
656
+ # At least one ledger read came back at its bound, so there are
657
+ # older rows this payload never looked at. The panel refuses to
658
+ # claim `Inbox clear.` on a window it knows was truncated.
659
+ "inbox_truncated": inbox["truncated"],
598
660
  "sessions": sessions,
599
661
  # Host-wide, not per session: when the memory tile says the charge
600
662
  # IS resident, this is what names the holder.
@@ -631,12 +693,51 @@ class Sources:
631
693
  items.append(item)
632
694
  return items
633
695
 
696
+ def _live_prs(
697
+ self, latest: "dict | None", items: "list[dict]"
698
+ ) -> "set[tuple[str, int]] | None":
699
+ """The `(repo, number)` set an inbox page must still be in to be worth
700
+ an operator's attention, or None when there is no evidence either way.
701
+
702
+ The daemon's newest poll pass is the oracle: every PR with a review
703
+ pending from the reviewer identity is a candidate, so a page whose PR
704
+ is absent from the latest snapshot is one that pass did not list.
705
+ Ordinarily that means the trigger cleared -- the PR merged, closed, or
706
+ the review request was withdrawn -- and nothing the console offers can
707
+ act on it any more. Those three are the ordinary causes, NOT the only
708
+ ones: `review_requests` issues a single unpaginated `search/issues`
709
+ call, so past its page size a pass sees an arbitrary subset, and one
710
+ ranked by relevance rather than age, so the subset is not even stable
711
+ between calls (TASK-1796886433 covers closing that ceiling), and a
712
+ repo dropped from `config.watches` stops being walked at all, which
713
+ settles its outstanding pages by operator action rather than by
714
+ anything the PR did. A capped
715
+ (or stability-held) PR that is still open keeps its review request and
716
+ so stays in the set: its page is exactly the one that needs a re-entry
717
+ ack, and it must not be filed away.
718
+
719
+ Derived from the rendered board rows rather than the raw stages, so
720
+ the inbox and the pipeline panel cannot disagree about what the pass
721
+ had in hand. None (no snapshot at all -- a fresh boot, an unreadable
722
+ state.db) means "no evidence", and the caller treats every row as
723
+ live: the same rule the daemon's liveness oracle uses for a failed
724
+ listing, because hiding a page on missing evidence is the one
725
+ unrecoverable direction.
726
+ """
727
+ if not latest:
728
+ return None
729
+ keys = (
730
+ _pr_key(item.get("repo_slug"), item.get("number")) for item in items
731
+ )
732
+ return {key for key in keys if key is not None}
733
+
634
734
  def _inbox(
635
735
  self,
636
736
  escalations: "list[dict]",
637
737
  pings: "list[dict]",
638
738
  stability_pings: "list[dict] | None" = None,
639
- ) -> "list[dict]":
739
+ live_prs: "set[tuple[str, int]] | None" = None,
740
+ ) -> dict:
640
741
  """The operator inbox: everything the daemon paged a human about, in
641
742
  one list, newest first.
642
743
 
@@ -653,11 +754,44 @@ class Sources:
653
754
  process -- a caller that passed unfiltered rows still gets an inbox of
654
755
  pages only, just a shorter one.
655
756
 
656
- Bounded twice over: each reader is already capped at INBOX_LIMIT rows
657
- (newest first), and the merged list is capped again, so the payload
658
- cannot grow without bound as the two never-pruned tables accumulate.
757
+ Bounded twice over: each reader is already capped at INBOX_READ_LIMIT
758
+ rows (newest first), and each returned list is capped again at
759
+ INBOX_LIMIT, so the payload cannot grow without bound as the
760
+ never-pruned tables accumulate.
761
+
762
+ Returns the rows split two ways -- `live` (what the operator still
763
+ owes) and `settled` (the PR has left the poll's candidate set, so the
764
+ page is exhaust). `escalations` and `pings` are dedupe key stores and
765
+ must never be pruned, so this read-time split is the only place the
766
+ distinction can be made, and it is made from the local snapshot alone:
767
+ a page load still costs the GitHub API nothing. A row raised less than
768
+ INBOX_LIVE_GRACE_INTERVALS poll intervals ago is live whatever the
769
+ snapshot says, and `live_prs` of None (no snapshot) means every row is
770
+ live -- see `_live_prs`.
771
+
772
+ `truncated` reports that at least one of the reads came back AT its
773
+ bound, so there are older rows the split never saw. The reason it
774
+ exists is the empty case: an empty `live` half off a truncated window
775
+ means "no live pages among the rows I read", which is not what `Inbox
776
+ clear.` claims. It qualifies the non-empty case too, though -- a
777
+ partial list of live pages reads as complete unless the panel says
778
+ otherwise -- so the page renders it both ways. Widening the read (see
779
+ INBOX_READ_LIMIT) makes truncation rare; it cannot make it impossible,
780
+ and a panel that positively asserts nothing is owed had better be
781
+ right.
782
+
783
+ `settled_dropped` is the settled rows the INBOX_LIMIT cap left out of
784
+ the payload. It is a second, quieter truncation than `truncated`: it
785
+ happens inside a read that may never have reached its bound, so the
786
+ flag says nothing about it, and `len(settled)` alone would understate
787
+ a real backlog with no signal anywhere.
659
788
  """
660
789
  now = int(self._wall())
790
+ grace = INBOX_LIVE_GRACE_INTERVALS * self.config.poll_interval
791
+ truncated = any(
792
+ len(rows) >= INBOX_READ_LIMIT
793
+ for rows in (escalations, pings, stability_pings or [])
794
+ )
661
795
  out: list[dict] = []
662
796
  for row in escalations:
663
797
  out.append(
@@ -707,4 +841,33 @@ class Sources:
707
841
  }
708
842
  )
709
843
  out.sort(key=lambda item: item["age_seconds"])
710
- return out[:INBOX_LIMIT]
844
+ live: list[dict] = []
845
+ settled: list[dict] = []
846
+ for item in out:
847
+ key = _pr_key(item["repo_slug"], item["number"])
848
+ if (
849
+ live_prs is None
850
+ or item["age_seconds"] < grace
851
+ # A row this cannot key is missing evidence ABOUT THE ROW, and
852
+ # missing evidence never hides a page -- the same rule the
853
+ # None snapshot gets one clause up. Filing an unkeyable row
854
+ # away would be the console asserting something it does not
855
+ # know. Low reachability (both `number` columns are INTEGER
856
+ # NOT NULL), so this rests on caller discipline rather than on
857
+ # the schema, which is exactly why it is written down here.
858
+ or key is None
859
+ or key in live_prs
860
+ ):
861
+ live.append(item)
862
+ else:
863
+ settled.append(item)
864
+ return {
865
+ "live": live[:INBOX_LIMIT],
866
+ "settled": settled[:INBOX_LIMIT],
867
+ # The settled rows the per-half cap left out. A SECOND truncation,
868
+ # and a quieter one: it happens inside a read that may never have
869
+ # hit its bound, so `truncated` says nothing about it and the
870
+ # count alone would silently understate.
871
+ "settled_dropped": max(0, len(settled) - INBOX_LIMIT),
872
+ "truncated": truncated,
873
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.26.0
3
+ Version: 0.27.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera