alissa-tools-github-devloop 0.8.14__tar.gz → 0.8.16__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 (35) hide show
  1. {alissa_tools_github_devloop-0.8.14/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.16}/PKG-INFO +1 -1
  2. alissa_tools_github_devloop-0.8.16/src/main/alissa/tools/github/devloop/version +1 -0
  3. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/webui/page.py +40 -17
  4. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/webui/sources.py +114 -7
  5. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_devloop-0.8.14/src/main/alissa/tools/github/devloop/version +0 -1
  7. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/LICENSE +0 -0
  8. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/MANIFEST.in +0 -0
  9. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/NOTICE +0 -0
  10. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/README.md +0 -0
  11. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/requirements.txt +0 -0
  12. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/setup.cfg +0 -0
  13. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/setup.py +0 -0
  14. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  15. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/__main__.py +0 -0
  16. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  17. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
  18. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  19. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/config.py +0 -0
  20. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  21. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  22. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/loop.py +0 -0
  23. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/loop_events.py +0 -0
  24. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  25. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/state.py +0 -0
  26. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/version.py +0 -0
  27. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  28. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  29. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  30. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  31. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
  32. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
  33. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  34. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  35. {alissa_tools_github_devloop-0.8.14 → alissa_tools_github_devloop-0.8.16}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.14
3
+ Version: 0.8.16
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -231,6 +231,11 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
231
231
  .inbox-item { display: flex; align-items: baseline; justify-content: space-between;
232
232
  gap: 0.75rem; padding: 0.55rem 0; border-top: 1px solid var(--surface-border); }
233
233
  .inbox-kind { font-family: var(--mono); font-size: 0.75rem; color: var(--status-blocked); }
234
+ .inbox-settled { border-top: 1px solid var(--surface-border); margin-top: 0.55rem; }
235
+ .inbox-settled > summary { cursor: pointer; padding: 0.55rem 0; font-family: var(--mono);
236
+ font-size: 0.75rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
237
+ .inbox-settled .inbox-item { opacity: 0.55; }
238
+ .inbox-settled .inbox-item .inbox-kind { color: var(--text-muted); }
234
239
  .task-title { flex: 1; margin: 0 0.6rem; overflow: hidden;
235
240
  text-overflow: ellipsis; white-space: nowrap; }
236
241
  .log {
@@ -450,22 +455,40 @@ _JS = r"""
450
455
  el('pipeline').innerHTML = out;
451
456
  }
452
457
 
453
- function renderInbox(items) {
454
- if (!items.length) { el('inbox').innerHTML = '<div class="empty">Inbox clear.</div>'; return; }
455
- el('inbox').innerHTML = items.map(function (it) {
456
- // A row with no url has no GitHub artifact behind it (a foreign-session
457
- // page: the subject is a tmux session, and its repo_slug/number are
458
- // placeholders). Rendering the anchor anyway would link to
459
- // github.com/-/issues/0, so the kind — which already names the session —
460
- // stands alone.
461
- var target = it.url
462
- ? '<a href="' + esc(it.url) + '" target="_blank" rel="noopener">' +
463
- esc(it.repo_slug) + (it.is_pr ? '#pr' : '#') + esc(it.number) + '</a>'
464
- : '<span class="muted">no linked artifact</span>';
465
- return '<div class="inbox-item"><span>' +
466
- '<span class="inbox-kind">' + esc(it.kind) + '</span> ' + target +
467
- '</span><span class="muted num">' + dur(it.age_seconds) + '</span></div>';
468
- }).join('');
458
+ function inboxRow(it) {
459
+ // A row with no url has no GitHub artifact behind it (a foreign-session
460
+ // page: the subject is a tmux session, and its repo_slug/number are
461
+ // placeholders). Rendering the anchor anyway would link to
462
+ // github.com/-/issues/0, so the kind — which already names the session —
463
+ // stands alone.
464
+ var target = it.url
465
+ ? '<a href="' + esc(it.url) + '" target="_blank" rel="noopener">' +
466
+ esc(it.repo_slug) + (it.is_pr ? '#pr' : '#') + esc(it.number) + '</a>'
467
+ : '<span class="muted">no linked artifact</span>';
468
+ return '<div class="inbox-item"><span>' +
469
+ '<span class="inbox-kind">' + esc(it.kind) + '</span> ' + target +
470
+ '</span><span class="muted num">' + dur(it.age_seconds) + '</span></div>';
471
+ }
472
+
473
+ function renderInbox(items, settled, settledCount) {
474
+ // Live rows are the inbox; "Inbox clear." is their empty state alone.
475
+ // Settled rows (the artifact left the poll's candidate set -- exhaust,
476
+ // not backlog) sit behind a collapsed footer so nothing is lost, only
477
+ // demoted. The footer stays open across the 10s re-render once opened.
478
+ var wasOpen = false;
479
+ var prior = el('inbox').querySelector('details.inbox-settled');
480
+ if (prior && prior.open) { wasOpen = true; }
481
+ var out = items.length ? items.map(inboxRow).join('') : '<div class="empty">Inbox clear.</div>';
482
+ if (settledCount) {
483
+ out += '<details class="inbox-settled"' + (wasOpen ? ' open' : '') + '>' +
484
+ '<summary>' + esc(settledCount) + ' settled &mdash; show</summary>' +
485
+ settled.map(inboxRow).join('') +
486
+ (settledCount > settled.length
487
+ ? '<div class="empty">' + esc(settledCount - settled.length) + ' older settled pages not listed</div>'
488
+ : '') +
489
+ '</details>';
490
+ }
491
+ el('inbox').innerHTML = out;
469
492
  }
470
493
 
471
494
  function renderHoldouts(items) {
@@ -557,7 +580,7 @@ _JS = r"""
557
580
  sparkline(el('spark-duration'), d.sparklines.poll_duration_ms);
558
581
  sparkline(el('spark-active'), d.sparklines.active_sessions);
559
582
  renderPipeline(d.pipeline);
560
- renderInbox(d.inbox);
583
+ renderInbox(d.inbox, d.inbox_settled || [], d.inbox_settled_count || 0);
561
584
  renderHoldouts(d.holdouts || []);
562
585
  renderTasks(d.tasks);
563
586
  renderSessions(d.sessions);
@@ -61,6 +61,39 @@ ARCHIVE_FAILED_KIND = "janitor-archive-failed"
61
61
 
62
62
  # How many recent snapshots feed the sparklines / pipeline board.
63
63
  SPARK_POINTS = 60
64
+
65
+ # The operator inbox's settled-page demotion (issue #103). The `escalations`
66
+ # table is a dedupe key store -- each row is what stops a second page for the
67
+ # same episode -- so it is never pruned, and the console filters at READ time
68
+ # instead: an escalation row whose (repo_slug, number, is_pr) has left the
69
+ # latest poll snapshot's candidate set is SETTLED -- the trigger cleared (the
70
+ # issue closed, the PR merged or closed, the label came off) and the page is
71
+ # no longer actionable from this console. Settled rows are demoted, never
72
+ # dropped: the payload carries the newest INBOX_SETTLED_CAP of them plus the
73
+ # full count. No GitHub call is spent on the verdict -- the snapshot is the
74
+ # daemon's own exhaust.
75
+ INBOX_SETTLED_CAP = 50
76
+ # A page raised BETWEEN two polls is not in the latest snapshot yet, so a row
77
+ # escalated less than this many poll intervals ago stays live regardless of
78
+ # the candidate set -- it must not flicker out before the pass that lists its
79
+ # artifact lands.
80
+ INBOX_GRACE_POLLS = 2
81
+ # Kind prefixes whose rows store a PULL REQUEST number in the `issue` column.
82
+ # Every issue-edge kind (`cap`, `assignment`, `stalled:a<k>`,
83
+ # `live-stalled:a<k>`) stores an issue number; the PR edge's kinds (`fix-cap`,
84
+ # `fix-stalled`, `rerequest`, `rerequest-rejected`, the orphan sweep's three,
85
+ # `stacked-base`), the maintain edge's, and the janitor's archive page all
86
+ # store the PR's. The split drives both the link an operator follows and the
87
+ # settled verdict: a PR-keyed row is matched against the PR edges only, so a
88
+ # settled PR #905 is never revived by an open issue #905, nor the reverse.
89
+ # Agreement with `loop.ESCALATION_*` is pinned by a test.
90
+ PR_KINDS = ("fix-", "maintain", "orphan", "rerequest", "stacked-", ARCHIVE_FAILED_KIND)
91
+ # Kinds whose SUBJECT is not an issue or PR the poll evaluates: a foreign
92
+ # session (no artifact at all) and a janitor holdout that could not be
93
+ # archived (its anchor PR is terminal by construction, so it is never a
94
+ # candidate). The snapshot carries no evidence either way about these, and
95
+ # the rule is never to hide on missing evidence, so they are always live.
96
+ UNEVIDENCED_KINDS = (FOREIGN_SESSION_KIND, ARCHIVE_FAILED_KIND)
64
97
  # Cache lifetimes for the remote / shared-token checks (seconds).
65
98
  RATE_CACHE_TTL = 60.0
66
99
  VERSION_CACHE_TTL = 600.0
@@ -91,6 +124,36 @@ _EDGE_PREFIXES = (
91
124
  )
92
125
 
93
126
 
127
+ def is_pr_kind(kind: str) -> bool:
128
+ """Whether an escalation kind stores a PR number in the `issue` column
129
+ (see PR_KINDS). A foreign-session row is neither: it has no artifact."""
130
+ return kind.startswith(PR_KINDS)
131
+
132
+
133
+ def snapshot_candidates(snapshot: "dict | None") -> "set[tuple] | None":
134
+ """The poll snapshot's candidate set as `(repo_slug, number, is_pr)` keys,
135
+ or None when there is no snapshot to consult (a fresh boot, an empty
136
+ `poll_snapshots`) -- the caller then treats every inbox row as live:
137
+ never hide on missing evidence, the same rule the reaper applies to a
138
+ failed session listing.
139
+
140
+ The `issue` edge carries issue numbers; every other edge (fix, maintain,
141
+ and any PR-shaped edge a later daemon adds) carries PR numbers -- the
142
+ daemon's own vocabulary (`_reap_records`: issue or pr), and exactly the
143
+ split the inbox's `is_pr` flag encodes. A stage row missing its slug or
144
+ number contributes nothing rather than a degenerate key."""
145
+ if snapshot is None:
146
+ return None
147
+ keys: "set[tuple]" = set()
148
+ for stage in snapshot.get("stages") or []:
149
+ slug = (stage.get("slug") or "").split("#", 1)[0]
150
+ number = stage.get("number")
151
+ if not slug or number is None:
152
+ continue
153
+ keys.add((slug, int(number), stage.get("edge") != "issue"))
154
+ return keys
155
+
156
+
94
157
  def edge_of(name: "str | None") -> "str | None":
95
158
  if not name:
96
159
  return None
@@ -504,7 +567,9 @@ class Sources:
504
567
  else:
505
568
  sess["retry"] = None
506
569
 
507
- inbox = self._inbox(ledgers["escalations"])
570
+ live, settled = self._partition_inbox(ledgers["escalations"], latest)
571
+ inbox = self._inbox(live)
572
+ inbox_settled = self._inbox(settled[:INBOX_SETTLED_CAP])
508
573
  candidates = 0
509
574
  if latest:
510
575
  candidates = (latest["issue_candidates"] + latest["pr_candidates"]
@@ -537,24 +602,66 @@ class Sources:
537
602
  "edges": pipeline,
538
603
  },
539
604
  "inbox": inbox,
605
+ "inbox_settled": inbox_settled,
606
+ "inbox_settled_count": len(settled),
540
607
  "holdouts": holdouts,
541
608
  "sessions": sessions,
542
609
  "tasks": self.worker_tasks(),
543
610
  "log": self.log_tail(),
544
611
  }
545
612
 
613
+ def _partition_inbox(
614
+ self, escalations: "list[dict]", snapshot: "dict | None"
615
+ ) -> "tuple[list[dict], list[dict]]":
616
+ """Split the escalation ledger's rows into (live, settled) against the
617
+ latest poll snapshot (issue #103). Live rows keep the ledger's order;
618
+ settled rows come back newest-first. A row is live when any of:
619
+
620
+ - there is no snapshot to consult (fresh boot): every row is live;
621
+ - it was escalated less than INBOX_GRACE_POLLS poll intervals ago, or
622
+ after the snapshot was taken -- the pass that would list its
623
+ artifact has not landed yet (a long, rate-limited pass can outlast
624
+ the grace window, so the snapshot's own clock is checked too);
625
+ - its kind has no candidate to match (UNEVIDENCED_KINDS);
626
+ - its (repo_slug, number, is_pr) key is in the snapshot's candidate
627
+ set -- a PR-keyed row matches PR edges only, an issue-keyed row the
628
+ issue edge only.
629
+
630
+ Everything else is settled: the artifact left the poll's candidate
631
+ set, so the page is exhaust, not backlog. Pure ledger + snapshot
632
+ arithmetic -- no GitHub call, no write."""
633
+ candidates = snapshot_candidates(snapshot)
634
+ if candidates is None:
635
+ return list(escalations), []
636
+ horizon = int(self._wall()) - INBOX_GRACE_POLLS * self.config.poll_interval
637
+ snapshot_ts = int(snapshot.get("ts") or 0) if snapshot else 0
638
+ live: "list[dict]" = []
639
+ settled: "list[dict]" = []
640
+ for row in escalations:
641
+ kind = row["kind"]
642
+ escalated_at = int(row["escalated_at"])
643
+ key = (row["repo_slug"], int(row["issue"]), is_pr_kind(kind))
644
+ if (
645
+ escalated_at >= horizon
646
+ or escalated_at >= snapshot_ts
647
+ or kind.startswith(UNEVIDENCED_KINDS)
648
+ or key in candidates
649
+ ):
650
+ live.append(row)
651
+ else:
652
+ settled.append(row)
653
+ settled.sort(key=lambda r: int(r["escalated_at"]), reverse=True)
654
+ return live, settled
655
+
546
656
  def _inbox(self, escalations: "list[dict]") -> "list[dict]":
547
657
  now = int(self._wall())
548
658
  out = []
549
659
  for row in escalations:
550
660
  kind = row["kind"]
551
661
  number = row["issue"]
552
- # fix-*/maintain-*/orphan-*/janitor-archive-failed rows store a
553
- # PR number in the `issue` column; everything else is an issue
554
- # number.
555
- is_pr = kind.startswith(
556
- ("fix-cap", "maintain", "orphan", ARCHIVE_FAILED_KIND)
557
- )
662
+ # PR-side kinds (PR_KINDS) store a PR number in the `issue`
663
+ # column; everything else is an issue number.
664
+ is_pr = is_pr_kind(kind)
558
665
  leaf = "pull" if is_pr else "issues"
559
666
  # foreign-session rows have NO GitHub artifact behind them (issue
560
667
  # #95): the subject is a tmux session, and their repo_slug/issue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.14
3
+ Version: 0.8.16
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera