alissa-tools-github-revloop 0.31.27__tar.gz → 0.31.29__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 (41) hide show
  1. {alissa_tools_github_revloop-0.31.27/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.29}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/alissa.py +10 -0
  3. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/fleet_vitals.py +57 -5
  4. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/loop.py +58 -2
  5. alissa_tools_github_revloop-0.31.29/src/main/alissa/tools/github/revloop/self_upgrade.py +623 -0
  6. alissa_tools_github_revloop-0.31.29/src/main/alissa/tools/github/revloop/version +1 -0
  7. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  8. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
  9. alissa_tools_github_revloop-0.31.27/src/main/alissa/tools/github/revloop/version +0 -1
  10. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/LICENSE +0 -0
  11. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/MANIFEST.in +0 -0
  12. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/NOTICE +0 -0
  13. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/README.md +0 -0
  14. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/requirements.txt +0 -0
  15. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/setup.cfg +0 -0
  16. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/setup.py +0 -0
  17. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  18. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  19. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  20. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  21. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/config.py +0 -0
  22. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  23. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/janitor.py +0 -0
  24. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  25. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/parking.py +0 -0
  26. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  27. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/prompts.py +0 -0
  28. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  29. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/state.py +0 -0
  30. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/trust.py +0 -0
  31. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/version.py +0 -0
  32. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  33. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  34. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  35. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  36. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  37. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  38. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  39. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  40. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  41. {alissa_tools_github_revloop-0.31.27 → alissa_tools_github_revloop-0.31.29}/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.31.27
3
+ Version: 0.31.29
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -1576,6 +1576,16 @@ class Alissa:
1576
1576
  )
1577
1577
  return sessions
1578
1578
 
1579
+ def list_queues(self) -> object:
1580
+ """`alissa tmux queue ls --json`, parsed, or None when the CLI does
1581
+ not answer (`parking.queued_items` reads None as unknown, never
1582
+ empty). Read by the self-upgrade idle check only (issue #193)."""
1583
+ try:
1584
+ return run_json(["alissa", "tmux", "queue", "ls", "--json"], timeout=60)
1585
+ except (CommandError, OSError, ValueError) as exc:
1586
+ log.debug("could not list the alissa tmux queue (%s)", exc)
1587
+ return None
1588
+
1579
1589
  def kill_session(self, session: str) -> None:
1580
1590
  """Kill ONE finished reviewer's managed session to free its worker slot.
1581
1591
 
@@ -65,6 +65,16 @@ out-of-pass push leaves it out (that process never resolves the feed
65
65
  authority nor refreshes the allowlist, so its answer would be a different
66
66
  one). A Studio whose strict schema predates the block gets the `idle`
67
67
  treatment: resent without it, offered again after BLOCK_REPROBE_S.
68
+
69
+ Fleet auto-upgrade (issue #193, Studio AU-1) makes the ANSWER matter: every
70
+ answer carries an `upgrade` block (`self_upgrade.parse_upgrade`; absent on an
71
+ older Studio). The pusher keeps the last one read (`FleetVitalsPusher.upgrade`)
72
+ and hands each answer to the daemon's `SelfUpgrader`, which drains and
73
+ upgrades on it. The request side gains `upgradeFailed: {version, reason}`,
74
+ the seat's report that a version would not start and was rolled back: sent
75
+ once, on the next snapshot after the rollback, and treated like the other
76
+ optional blocks by a Studio that refuses it. The console's pusher has no
77
+ upgrader: it reads the answer and acts on nothing.
68
78
  """
69
79
 
70
80
  from __future__ import annotations
@@ -78,6 +88,7 @@ from datetime import datetime, timezone
78
88
  from typing import TYPE_CHECKING, Any, Callable, Mapping, Sequence
79
89
 
80
90
  from . import parking
91
+ from . import self_upgrade as self_upgrade_mod
81
92
  from .alissa import parse_session_name, session_repo_slug
82
93
  from .alissa_client import (
83
94
  NOT_ACTIVATED,
@@ -120,8 +131,9 @@ VITALS_FAILED = "failed"
120
131
  # naming one of them is an old Studio, not a bad snapshot.
121
132
  #
122
133
  # `diskUsedPct` (issue #167) joins them: the workspace volume's used percent,
123
- # which Studio's schema may not carry yet.
124
- OPTIONAL_BLOCKS = ("idle", "config", "diskUsedPct")
134
+ # which Studio's schema may not carry yet. `upgradeFailed` (issue #193, the
135
+ # fleet auto-upgrade report) joins them for the same reason.
136
+ OPTIONAL_BLOCKS = ("idle", "config", "diskUsedPct", "upgradeFailed")
125
137
 
126
138
  # How long a snapshot leaves an optional block out after Studio refused it as
127
139
  # an unknown key, before it offers the block again. An hour: one extra POST
@@ -501,6 +513,7 @@ class FleetVitalsPusher:
501
513
  *,
502
514
  clock: Callable[[], float] = time.time,
503
515
  config_reader: "Callable[[], Mapping | None] | None" = None,
516
+ upgrader: "self_upgrade_mod.SelfUpgrader | None" = None,
504
517
  ):
505
518
  self.config = config
506
519
  self._sources = sources
@@ -517,6 +530,12 @@ class FleetVitalsPusher:
517
530
  # Per optional block: until when it is left out (Studio refused it
518
531
  # as an unknown key). Absent = offer it.
519
532
  self._refused_until: "dict[str, float]" = {}
533
+ # The daemon's self-upgrade state (issue #193), or None -- the
534
+ # console's pusher -- to read the answer and act on nothing.
535
+ self.upgrader = upgrader
536
+ # The last `upgrade` block a vitals answer carried (issue #193), kept
537
+ # across answers that lack one.
538
+ self.upgrade: "self_upgrade_mod.UpgradeAnswer | None" = None
520
539
 
521
540
  def build(self, *, heartbeat_at: "int | float", queue_depth: "int | None") -> dict:
522
541
  """This pass's contract body, fitted to the API's caps."""
@@ -568,6 +587,9 @@ class FleetVitalsPusher:
568
587
  type(exc).__name__, exc,
569
588
  )
570
589
  return VITALS_FAILED
590
+ report = self._upgrade_report()
591
+ if report is not None:
592
+ snapshot["upgradeFailed"] = report
571
593
  now = self._clock()
572
594
  for key, until in self._refused_until.items():
573
595
  if now < until:
@@ -588,6 +610,34 @@ class FleetVitalsPusher:
588
610
  heartbeat = snaps[0]["ts"] if snaps else self._clock()
589
611
  return self.push_once(heartbeat_at=heartbeat, queue_depth=None)
590
612
 
613
+ def _upgrade_report(self) -> "dict | None":
614
+ """The `upgradeFailed` report owed to Studio, or None. A reader that
615
+ fails costs the report this pass, never the snapshot."""
616
+ if self.upgrader is None:
617
+ return None
618
+ try:
619
+ return self.upgrader.report()
620
+ except Exception as exc:
621
+ log.warning("fleet-vitals: could not read the upgrade report (%s)", exc)
622
+ return None
623
+
624
+ def _answered(self, answer: object, snapshot: "Mapping") -> None:
625
+ """Read one successful answer: keep its `upgrade` block and hand it
626
+ to the upgrader, then mark a carried `upgradeFailed` delivered.
627
+ Never raises: the push already succeeded."""
628
+ try:
629
+ parsed = self_upgrade_mod.parse_upgrade(answer)
630
+ if parsed is not None:
631
+ self.upgrade = parsed
632
+ if self.upgrader is None:
633
+ return
634
+ self.upgrader.observe(answer)
635
+ sent = snapshot.get("upgradeFailed")
636
+ if isinstance(sent, Mapping):
637
+ self.upgrader.reported(sent)
638
+ except Exception as exc:
639
+ log.warning("fleet-vitals: could not read the upgrade answer (%s)", exc)
640
+
591
641
  @staticmethod
592
642
  def _refused_blocks(exc: AlissaError, snapshot: dict) -> "list[str]":
593
643
  """The optional blocks of `snapshot` a 400 names: a Studio whose
@@ -630,6 +680,7 @@ class FleetVitalsPusher:
630
680
  return self._post(trimmed)
631
681
  self._warn_failed(exc)
632
682
  return VITALS_FAILED
683
+ self._answered(result, snapshot)
633
684
  log.info(
634
685
  "fleet-vitals: pushed (%s; replaced=%s)",
635
686
  describe(snapshot), result.get("replaced"),
@@ -698,6 +749,7 @@ def build_pusher(
698
749
  github: object = None,
699
750
  endpoint: "str | None" = None,
700
751
  config_reader: "Callable[[], Mapping | None] | None" = None,
752
+ upgrader: "self_upgrade_mod.SelfUpgrader | None" = None,
701
753
  ) -> FleetVitalsPusher:
702
754
  """The pusher the watcher wires in when `fleet_vitals_enabled` is on.
703
755
 
@@ -709,8 +761,8 @@ def build_pusher(
709
761
  at construction, because a daemon must boot and poll whether or not
710
762
  vitals can authenticate. The import is local because `sources` imports
711
763
  `loop`, which imports this module. `config_reader` is the daemon's
712
- `config` block source (issue #159); the console builds its own pusher
713
- without one.
764
+ `config` block source (issue #159), and `upgrader` its self-upgrade
765
+ state (issue #193); the console builds its own pusher without either.
714
766
  """
715
767
  from .version import version
716
768
  from .webui.sources import Sources
@@ -722,5 +774,5 @@ def build_pusher(
722
774
  )
723
775
  return FleetVitalsPusher(
724
776
  config, sources, AlissaClient(base=endpoint),
725
- config_reader=config_reader,
777
+ config_reader=config_reader, upgrader=upgrader,
726
778
  )
@@ -27,6 +27,7 @@ from typing import Callable
27
27
  from . import janitor as janitor_mod
28
28
  from . import parking as parking_mod
29
29
  from . import prompts as prompts_mod
30
+ from . import self_upgrade as self_upgrade_mod
30
31
  from .alissa import (
31
32
  COMMIT_READINESS_TRAILER_LABEL,
32
33
  MANAGED_PREFIX,
@@ -113,6 +114,7 @@ from .loop_events import (
113
114
  )
114
115
  from .proc import CommandError
115
116
  from .state import State
117
+ from .version import version as running_version
116
118
 
117
119
  log = logging.getLogger(__name__)
118
120
 
@@ -2730,6 +2732,7 @@ class ReviewWatcher:
2730
2732
  alissa: Alissa | None = None,
2731
2733
  state: State | None = None,
2732
2734
  bow_client: "AlissaClient | None" = None,
2735
+ self_upgrade: "self_upgrade_mod.SelfUpgrader | None" = None,
2733
2736
  ):
2734
2737
  self.config = config
2735
2738
  # The BOW-derived allowlist (`repos_source: "bows"`, issue #119), or
@@ -2766,10 +2769,18 @@ class ReviewWatcher:
2766
2769
  # built, read or logged for the disabled default. The pusher owns
2767
2770
  # its own in-process `Sources` (the console's data layer), so the
2768
2771
  # console sidecar need not be running.
2772
+ #
2773
+ # Fleet auto-upgrade (issue #193): the drain and self-upgrade state,
2774
+ # fed by the vitals answer. Built always: it reads nothing until an
2775
+ # answer arrives, so a seat without vitals never drains on it.
2776
+ self.self_upgrade = self_upgrade or self_upgrade_mod.SelfUpgrader(
2777
+ running_version.value, dry_run=config.dry_run,
2778
+ )
2769
2779
  self._fleet_vitals: FleetVitalsPusher | None = (
2770
2780
  build_pusher(
2771
2781
  config, github=self.github, endpoint=config.alissa_endpoint,
2772
2782
  config_reader=self._vitals_config,
2783
+ upgrader=self.self_upgrade,
2773
2784
  )
2774
2785
  if config.fleet_vitals_enabled
2775
2786
  else None
@@ -6745,8 +6756,26 @@ class ReviewWatcher:
6745
6756
  """Claim (or, `late`, re-claim) the spawn of `name` against the drain
6746
6757
  flag: None when the spawn may proceed, else the QUEUED `drained`
6747
6758
  decision that stands the round down. Dry-run reads the flag and
6748
- claims nothing. See `_drain_gated`."""
6759
+ claims nothing. See `_drain_gated`.
6760
+
6761
+ The fleet auto-upgrade hold (issue #193) is read FIRST, on both
6762
+ claims: while Studio's vitals answer says drain, or a self-hosted
6763
+ seat is draining to upgrade, the round stands down the same QUEUED
6764
+ `drained` way a parking drain does -- owed, no spawn row, no round
6765
+ burned. Running reviews are not touched. The upgrader logs the state
6766
+ change once; each held round is a DEBUG line here."""
6749
6767
  where = " after its hub was provisioned" if late else ""
6768
+ hold = self.self_upgrade.hold()
6769
+ if hold is not None:
6770
+ detail = self.self_upgrade.hold_detail()
6771
+ log.debug("%s round %d: not spawning%s — %s", pr.slug, round_, where, detail)
6772
+ return Decision(
6773
+ Action.QUEUED,
6774
+ f"round {round_} held{where} — {detail}; running reviews finish "
6775
+ f"normally, retrying once the drain lifts",
6776
+ round_,
6777
+ drained=True,
6778
+ )
6750
6779
  try:
6751
6780
  if self.config.dry_run:
6752
6781
  drain = self.state.drain()
@@ -8278,19 +8307,40 @@ class ReviewWatcher:
8278
8307
  # heartbeat is the completion time of a pass whose exhaust is already
8279
8308
  # written and whose events are already on their way.
8280
8309
  vitals = self._push_fleet_vitals(completed_at=time.time())
8310
+ # The self-upgrade step (issue #193), right after the push that read
8311
+ # this pass's answer, on the sweep's own roster.
8312
+ upgrade = self._after_pass_upgrade()
8281
8313
  # The workspace janitor (issue #167), last: it is the one part of the
8282
8314
  # pass that is pure housekeeping, and it never raises.
8283
8315
  self._run_janitor()
8284
8316
  log.info(
8285
8317
  "poll summary: %d candidate(s), %d reaped, %d parked on a prompt, "
8286
- "%d answered%s, %d ms, vitals: %s",
8318
+ "%d answered%s, %d ms, vitals: %s%s",
8287
8319
  len(results), reaped, len(self._prompt_sweep.waiting),
8288
8320
  self._prompt_sweep.answered,
8289
8321
  " (spawns HELD)" if self._prompt_sweep.hold else "",
8290
8322
  duration_ms, vitals,
8323
+ f"; upgrade: {upgrade}" if upgrade != self_upgrade_mod.NONE else "",
8291
8324
  )
8292
8325
  return results
8293
8326
 
8327
+ def _after_pass_upgrade(self) -> str:
8328
+ """The pass's self-upgrade step (issue #193): `exit` once the seat
8329
+ has been idle for two polls (no live reviewer session, no
8330
+ undispatched queue item) with a self-hosted upgrade wanted -- the
8331
+ request file is then written and `run_forever` returns -- `wait`
8332
+ while draining towards one, `none` otherwise. The roster is the reap
8333
+ sweep's (`_pass_roster`, None when it could not list: never idle).
8334
+ Never raises."""
8335
+ try:
8336
+ return self.self_upgrade.after_pass(
8337
+ self._pass_roster,
8338
+ queued=lambda: parking_mod.queued_items(self.alissa.list_queues()),
8339
+ )
8340
+ except Exception as exc:
8341
+ log.warning("self-upgrade: the after-pass check failed (%s)", exc)
8342
+ return self_upgrade_mod.NONE
8343
+
8294
8344
  def _emit_loop_events(self) -> None:
8295
8345
  """Push this pass's loop telemetry to Studio, when enabled (issue
8296
8346
  #112). Last thing in the pass, after the snapshot, so the batch sees
@@ -8768,6 +8818,12 @@ class ReviewWatcher:
8768
8818
  self.poll_once()
8769
8819
  backoff = self.config.poll_interval
8770
8820
  self._note_poll_recovered(failures)
8821
+ if self.self_upgrade.exit_requested:
8822
+ # Issue #193: return, so the CLI exits 0 and the
8823
+ # container restarts; the entrypoint installs the
8824
+ # target at boot.
8825
+ log.info("stopping for the self-upgrade")
8826
+ return
8771
8827
  except LedgerUnwritable:
8772
8828
  # Already logged, streak-limited and escalating, by the
8773
8829
  # gate itself. What matters HERE is what is NOT done: the
@@ -0,0 +1,623 @@
1
+ """Self-upgrade -- the revloop half of fleet auto-upgrade (MDF M6, AU-6,
2
+ issue #193; Studio's contract: studio.alissa.app docs/design/
3
+ fleet-auto-upgrade.md, AU-1). A port of devloop's module (AU-4, devloop
4
+ issue #206) and orcloop's (AU-5): the three seats behave the same way, and
5
+ only what makes a seat busy differs (here: a live reviewer session).
6
+
7
+ Every ``POST /v1/loop/fleet-vitals`` answer carries an ``upgrade`` block::
8
+
9
+ {"mode": "managed" | "self_hosted",
10
+ "target": {"version", "digest"} | null,
11
+ "autoUpgrade": bool, "paused": bool, "drain": bool}
12
+
13
+ This module reads it and acts on it in four steps:
14
+
15
+ 1. **Read.** `parse_upgrade` turns the answer into an `UpgradeAnswer`, or
16
+ None when the block is absent or malformed (an older Studio). The
17
+ `SelfUpgrader` keeps the last good answer in memory. An answer without
18
+ the block leaves the last one in place.
19
+ 2. **Drain.** `SelfUpgrader.hold` names why no new reviewer session may start:
20
+ ``drain`` is true (either mode), or a self-hosted seat has a target to
21
+ move to. A seat has a target to move to when `autoUpgrade` is on, the
22
+ pause switch is off, `target.version` is strictly newer than the running
23
+ version (numeric ``X.Y.Z``; an older or equal pin, or a version that does
24
+ not parse, is never a target), and that version is not in the skip
25
+ list. The loop reads the hold in its seat-drain gate
26
+ (`ReviewWatcher._claim_spawn`), so a held round is the same QUEUED
27
+ ``drained`` stage a parking drain gives. Running sessions are never
28
+ touched. One INFO line per change of state, not per poll.
29
+ 3. **Exit at idle.** Self-hosted only (a managed seat is swapped by the
30
+ backend, AU-2). After two consecutive polls with no live reviewer
31
+ session (`alissa.parse_session_name`) and no undispatched item in the
32
+ alissa tmux queue, `after_pass` writes the
33
+ request file (`REQUEST_FILE` under `upgrade_dir()`) and asks the daemon
34
+ to exit with code 0. The container's restart policy brings it back, and
35
+ the entrypoint installs the target at boot.
36
+ 4. **Confirm.** The first vitals POST answered on the new version marks the
37
+ request confirmed. The entrypoint rolls back an unconfirmed upgrade. It
38
+ reinstalls `previous`, adds the target to the file's skip list and leaves
39
+ an ``upgradeFailed`` report. `report` hands that report to the next
40
+ snapshot, and `reported` marks it sent once a POST carrying it succeeds.
41
+ 5. **Unserved.** A process that finds an unconfirmed request for a target
42
+ it does not run, written by this same version (``previous``), knows its
43
+ predecessor's exit produced no upgrade: the boot step never ran (a
44
+ non-container or non-root start, a boot block that failed before its
45
+ first write) or installed somewhere the daemon does not import from.
46
+ It skips the target and reports it, so the seat never loops drain →
47
+ exit → restart.
48
+
49
+ Never while paused, never in managed mode, never with a live session: those
50
+ three are checked here, at the moment of the exit, not only when the hold
51
+ starts.
52
+
53
+ The request file is shared with ``docker/claude/entrypoint.sh`` (the
54
+ `self_upgrade_boot` block). Both sides keep every key they do not own, so
55
+ neither can drop the other's state. Its keys:
56
+
57
+ * ``target`` -- ``{"version", "digest"}``, written by the daemon.
58
+ * ``previous`` -- the version the daemon ran when it asked.
59
+ * ``requestedAt`` -- ISO-8601 UTC.
60
+ * ``confirmed`` / ``confirmedAt`` -- written by the daemon after the first
61
+ answered poll on the target.
62
+ * ``boots`` / ``firstBootAt`` / ``firstBootTs`` / ``lastError`` -- the
63
+ entrypoint's attempt record for this target. ``firstBootTs`` is the epoch
64
+ seconds its 10-minute window reads; ``firstBootAt`` the same as ISO-8601.
65
+ * ``rollbackReason`` -- why the entrypoint rolled back, kept across the
66
+ retries of a ``rollback_failed`` reinstall.
67
+ * ``state`` -- ``requested`` (daemon), then ``installed``,
68
+ ``install_failed``, ``rollback_failed`` (retried at the next boot),
69
+ ``rolled_back`` or ``not_newer`` (a target not newer than the installed
70
+ version, never installed) (entrypoint), then ``confirmed`` or
71
+ ``unserved`` (daemon). A ``not_newer`` request this version wrote is
72
+ unserved too: the daemon runs an older copy than the boot step sees.
73
+ * ``skip`` -- versions never to move to again. A newer target clears it.
74
+ * ``upgradeFailed`` -- ``{"version", "reason"}``, the report owed to Studio.
75
+ ``upgradeFailedReported`` is set once a POST carried it.
76
+ """
77
+
78
+ from __future__ import annotations
79
+
80
+ import json
81
+ import logging
82
+ import os
83
+ import re
84
+ import tempfile
85
+ import time
86
+ from dataclasses import dataclass
87
+ from datetime import datetime, timezone
88
+ from typing import Callable, Mapping, Sequence
89
+
90
+ from .alissa import parse_session_name
91
+
92
+ log = logging.getLogger(__name__)
93
+
94
+ SEAT = "revloop"
95
+ MANAGED = "managed"
96
+ SELF_HOSTED = "self_hosted"
97
+ MODES = (MANAGED, SELF_HOSTED)
98
+
99
+ # The shared request file. The directory follows ALISSA_UPGRADE_DIR so a
100
+ # test or a non-container run can point it elsewhere. The entrypoint reads
101
+ # the same variable.
102
+ UPGRADE_DIR_ENV = "ALISSA_UPGRADE_DIR"
103
+ DEFAULT_UPGRADE_DIR = "/workspace/.alissa-upgrade"
104
+ REQUEST_FILE = f"{SEAT}.json"
105
+
106
+ # How many consecutive polls with no live reviewer session count as idle.
107
+ IDLE_POLLS = 2
108
+
109
+ # The hold reasons `hold` returns, and the poll summary quotes.
110
+ HOLD_DRAIN = "drain"
111
+ HOLD_UPGRADE = "upgrade"
112
+
113
+ # The request states in which an upgrade is still on its way: the only ones
114
+ # the daemon confirms or calls unserved. Every other state is final, or a
115
+ # failure the entrypoint owns.
116
+ PENDING_STATES = ("requested", "installed")
117
+
118
+ # The states in which a request this version wrote left the seat where it
119
+ # was: still pending, or refused at boot as not newer than the installed
120
+ # version (the daemon imports an older copy than the root pass sees). Any
121
+ # of them on a later process means the exit did not move the seat.
122
+ UNSERVED_STATES = PENDING_STATES + ("not_newer",)
123
+
124
+ # `after_pass` outcomes.
125
+ EXIT = "exit"
126
+ WAIT = "wait"
127
+ NONE = "none"
128
+
129
+
130
+ def upgrade_dir(environ: "Mapping[str, str] | None" = None) -> str:
131
+ env = os.environ if environ is None else environ
132
+ return (env.get(UPGRADE_DIR_ENV) or "").strip() or DEFAULT_UPGRADE_DIR
133
+
134
+
135
+ def iso(ts: float) -> str:
136
+ return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime(
137
+ "%Y-%m-%dT%H:%M:%SZ"
138
+ )
139
+
140
+
141
+ def version_key(version: str) -> "tuple[int, ...] | None":
142
+ """`X.Y.Z` as a comparable tuple, or None for anything else."""
143
+ parts = str(version).strip().split(".")
144
+ if len(parts) != 3 or not all(p.isdigit() for p in parts):
145
+ return None
146
+ return tuple(int(p) for p in parts)
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class UpgradeAnswer:
151
+ """One vitals answer's ``upgrade`` block."""
152
+
153
+ mode: str
154
+ target_version: "str | None"
155
+ target_digest: "str | None"
156
+ auto_upgrade: bool
157
+ paused: bool
158
+ drain: bool
159
+
160
+ @property
161
+ def target(self) -> "dict | None":
162
+ if self.target_version is None:
163
+ return None
164
+ return {"version": self.target_version, "digest": self.target_digest}
165
+
166
+
167
+ def parse_upgrade(payload: object) -> "UpgradeAnswer | None":
168
+ """The ``upgrade`` block of a vitals answer, or None.
169
+
170
+ None when the answer is not an object or carries no ``upgrade`` (a Studio
171
+ older than AU-1), or when the block is not one this daemon can act on
172
+ (an unknown `mode`, a non-boolean flag). A malformed block is never read
173
+ as a partial one: a guessed `paused: false` would let a seat upgrade
174
+ through the operator's switch. A `target` without a string version is
175
+ read as no target."""
176
+ if not isinstance(payload, Mapping):
177
+ return None
178
+ block = payload.get("upgrade")
179
+ if not isinstance(block, Mapping):
180
+ return None
181
+ mode = block.get("mode")
182
+ flags = [block.get(k) for k in ("autoUpgrade", "paused", "drain")]
183
+ if mode not in MODES or not all(isinstance(f, bool) for f in flags):
184
+ return None
185
+ version = digest = None
186
+ target = block.get("target")
187
+ if isinstance(target, Mapping):
188
+ raw = target.get("version")
189
+ if isinstance(raw, str) and raw.strip():
190
+ version = raw.strip()
191
+ raw_digest = target.get("digest")
192
+ digest = raw_digest if isinstance(raw_digest, str) else None
193
+ auto_upgrade, paused, drain = flags
194
+ return UpgradeAnswer(
195
+ mode=str(mode), target_version=version, target_digest=digest,
196
+ auto_upgrade=bool(auto_upgrade), paused=bool(paused), drain=bool(drain),
197
+ )
198
+
199
+
200
+ def live_reviewer_sessions(roster: "Sequence[object] | None") -> "list[str] | None":
201
+ """The live reviewer sessions in the pass's roster, or None when the
202
+ listing failed (unknown is never idle).
203
+
204
+ The roster is the reap sweep's own (`ReviewWatcher._pass_roster`): the
205
+ ``alissa tmux ls --live`` rows whose name parses as a reviewer session,
206
+ minus the ones the sweep just killed. Each entry is a `ManagedSession`
207
+ or a bare name. The reviewer grammar is re-checked here, so a session
208
+ another daemon owns in a shared container never holds this seat."""
209
+ if roster is None:
210
+ return None
211
+ names: "list[str]" = []
212
+ for entry in roster:
213
+ name = entry if isinstance(entry, str) else getattr(entry, "name", None)
214
+ if isinstance(name, str) and parse_session_name(name) is not None:
215
+ names.append(name)
216
+ return names
217
+
218
+
219
+ # -- the request file ---------------------------------------------------------
220
+
221
+
222
+ def read_request(path: str) -> "dict | None":
223
+ """The request file, or None when it is absent. An unreadable or
224
+ non-object file is None too, with a WARNING: the daemon must not crash
225
+ on a file the entrypoint half-wrote."""
226
+ try:
227
+ with open(path, encoding="utf-8") as fh:
228
+ data = json.load(fh)
229
+ except FileNotFoundError:
230
+ return None
231
+ except (OSError, ValueError) as exc:
232
+ log.warning("self-upgrade: could not read %s (%s) — ignoring it", path, exc)
233
+ return None
234
+ if not isinstance(data, dict):
235
+ log.warning("self-upgrade: %s is not a JSON object — ignoring it", path)
236
+ return None
237
+ return data
238
+
239
+
240
+ def write_request(path: str, data: "Mapping") -> None:
241
+ """Write the request file atomically (a temp file in the same directory,
242
+ then a rename), so the entrypoint never reads half a file. Raises
243
+ OSError."""
244
+ directory = os.path.dirname(path) or "."
245
+ os.makedirs(directory, exist_ok=True)
246
+ fd, tmp = tempfile.mkstemp(prefix=".revloop-", suffix=".json", dir=directory)
247
+ try:
248
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
249
+ json.dump(dict(data), fh, indent=2, sort_keys=True)
250
+ fh.write("\n")
251
+ os.replace(tmp, path)
252
+ except BaseException:
253
+ try:
254
+ os.unlink(tmp)
255
+ except OSError:
256
+ pass
257
+ raise
258
+
259
+
260
+ def _target_version(data: "Mapping") -> "str | None":
261
+ target = data.get("target")
262
+ version = target.get("version") if isinstance(target, Mapping) else None
263
+ return version if isinstance(version, str) else None
264
+
265
+
266
+ def _count(queued: "Callable[[], int | None]") -> "int | None":
267
+ """`queued()`, with a failure read as unknown (never idle)."""
268
+ try:
269
+ return queued()
270
+ except Exception as exc:
271
+ log.warning("self-upgrade: could not count the alissa queue (%s)", exc)
272
+ return None
273
+
274
+
275
+ def skip_list(data: "Mapping | None") -> "list[str]":
276
+ if not data:
277
+ return []
278
+ skip = data.get("skip")
279
+ if not isinstance(skip, list):
280
+ return []
281
+ return [str(v) for v in skip if isinstance(v, str)]
282
+
283
+
284
+ # -- the upgrader -------------------------------------------------------------
285
+
286
+
287
+ class SelfUpgrader:
288
+ """The daemon's self-upgrade state: the last answer, the hold, the idle
289
+ streak and the request file.
290
+
291
+ Every method is best-effort and never raises. The daemon's job is
292
+ spawning reviewer sessions, and an upgrade that cannot be done must
293
+ leave the seat running."""
294
+
295
+ def __init__(
296
+ self,
297
+ running: str,
298
+ *,
299
+ directory: "str | None" = None,
300
+ clock: "Callable[[], float]" = time.time,
301
+ dry_run: bool = False,
302
+ ):
303
+ self.running = running
304
+ self.path = os.path.join(directory or upgrade_dir(), REQUEST_FILE)
305
+ self._clock = clock
306
+ self.dry_run = dry_run
307
+ self.answer: "UpgradeAnswer | None" = None
308
+ self._hold: "str | None" = None
309
+ self._idle_polls = 0
310
+ # Targets this process could not request (the file would not
311
+ # write): never held for again in this process, or the seat would
312
+ # drain for ever without ever exiting.
313
+ self._unwritable: "set[str]" = set()
314
+ self.exit_requested = False
315
+
316
+ # -- reading the answer --
317
+
318
+ def observe(self, payload: object) -> "UpgradeAnswer | None":
319
+ """Read one vitals answer. Keeps the last good block (an answer
320
+ without one changes nothing), confirms a pending upgrade the first
321
+ time an answer arrives on the target version, and clears the skip
322
+ list when a newer target arrives. Returns the block read, or None."""
323
+ answer = parse_upgrade(payload)
324
+ if answer is None:
325
+ if payload is not None:
326
+ log.debug("self-upgrade: the vitals answer carries no upgrade block")
327
+ return None
328
+ self.answer = answer
329
+ try:
330
+ self._confirm()
331
+ self._unserved()
332
+ self._clear_skip(answer)
333
+ except OSError as exc:
334
+ log.warning("self-upgrade: could not update %s (%s)", self.path, exc)
335
+ self._update_hold()
336
+ return answer
337
+
338
+ def _confirm(self) -> None:
339
+ data = read_request(self.path)
340
+ if not data or data.get("confirmed"):
341
+ return
342
+ if data.get("state") not in PENDING_STATES:
343
+ # A failed or rolled-back target is never confirmed, even when
344
+ # a rollback that could not reinstall left the daemon on it.
345
+ return
346
+ version = _target_version(data)
347
+ if version != self.running or version in skip_list(data):
348
+ return
349
+ data["confirmed"] = True
350
+ data["confirmedAt"] = iso(self._clock())
351
+ data["state"] = "confirmed"
352
+ write_request(self.path, data)
353
+ log.info(
354
+ "self-upgrade: confirmed — this daemon runs %s and Studio answered "
355
+ "its first poll (upgraded from %s)", self.running,
356
+ data.get("previous") or "unknown",
357
+ )
358
+
359
+ def _unserved(self) -> None:
360
+ """Skip and report a target whose last request never moved this
361
+ seat: the file still holds an unconfirmed, pending request that
362
+ THIS version wrote (`previous == running`) for another version,
363
+ or a request of this version's that the boot step refused as
364
+ `not_newer`. Asking again would only repeat the drain, the exit and
365
+ the restart for ever. A file that cannot be written puts the target out of
366
+ reach for this process instead."""
367
+ data = read_request(self.path)
368
+ if not data or data.get("confirmed"):
369
+ return
370
+ state = data.get("state")
371
+ version = _target_version(data)
372
+ if state not in UNSERVED_STATES or not version or version == self.running:
373
+ return
374
+ if data.get("previous") != self.running:
375
+ return
376
+ skip = skip_list(data)
377
+ if version in skip:
378
+ return
379
+ if state == "requested":
380
+ reason = "the boot step never acted on the request (no install was tried)"
381
+ elif state == "not_newer":
382
+ reason = (
383
+ "the boot step refused it: not newer than the installed "
384
+ f"version, but the daemon still runs {self.running}"
385
+ )
386
+ else:
387
+ reason = f"installed at boot, but the daemon still runs {self.running}"
388
+ data.update({
389
+ "state": "unserved", "skip": skip + [version], "lastError": reason,
390
+ "upgradeFailed": {"version": version, "reason": reason},
391
+ "upgradeFailedReported": False,
392
+ })
393
+ try:
394
+ write_request(self.path, data)
395
+ except OSError as exc:
396
+ self._unwritable.add(version)
397
+ log.warning(
398
+ "self-upgrade: the request for %s was never served (%s) and "
399
+ "%s could not be updated (%s) — this process will not try %s "
400
+ "again", version, reason, self.path, exc, version,
401
+ )
402
+ return
403
+ log.warning(
404
+ "self-upgrade: the request for %s → %s was never served: %s — "
405
+ "%s is now on the skip list and reported to Studio; this seat "
406
+ "keeps running %s", self.running, version, reason, version,
407
+ self.running,
408
+ )
409
+
410
+ def _clear_skip(self, answer: UpgradeAnswer) -> None:
411
+ version = answer.target_version
412
+ new = version_key(version or "")
413
+ if new is None:
414
+ return
415
+ data = read_request(self.path)
416
+ skip = skip_list(data)
417
+ if not skip or version in skip:
418
+ return
419
+ keys = [k for k in (version_key(v) for v in skip) if k is not None]
420
+ if keys and new <= max(keys):
421
+ return
422
+ assert data is not None
423
+ data["skip"] = []
424
+ write_request(self.path, data)
425
+ log.info(
426
+ "self-upgrade: target %s is newer than every skipped version "
427
+ "(%s) — skip list cleared", version, ", ".join(skip),
428
+ )
429
+
430
+ # -- the hold --
431
+
432
+ def _skipped(self, version: str) -> bool:
433
+ return version in skip_list(read_request(self.path))
434
+
435
+ def wanted(self) -> "str | None":
436
+ """The version a self-hosted seat should move to now, or None."""
437
+ a = self.answer
438
+ if a is None or a.mode != SELF_HOSTED:
439
+ return None
440
+ if not a.auto_upgrade or a.paused:
441
+ return None
442
+ version = a.target_version
443
+ if not version:
444
+ return None
445
+ # Only a strictly newer target. A pin briefly older than what runs
446
+ # (a merge rolls the seat before the platform pin moves) must never
447
+ # drain and downgrade the seat. Unparseable on either side: never.
448
+ new, cur = version_key(version), version_key(self.running)
449
+ if new is None or cur is None or new <= cur:
450
+ return None
451
+ if version in self._unwritable or self._skipped(version):
452
+ return None
453
+ return version
454
+
455
+ def hold(self) -> "str | None":
456
+ """Why no new reviewer session may start now (`HOLD_DRAIN`,
457
+ `HOLD_UPGRADE`), or None. Read by every spawn path."""
458
+ return self._hold
459
+
460
+ def hold_detail(self) -> str:
461
+ """The hold in words, for a deferral's reason."""
462
+ if self._hold == HOLD_DRAIN:
463
+ return "Studio asked this seat to drain for a managed upgrade"
464
+ if self._hold == HOLD_UPGRADE:
465
+ return (
466
+ f"draining to self-upgrade {self.running} → "
467
+ f"{self.answer.target_version if self.answer else '?'}"
468
+ )
469
+ return ""
470
+
471
+ def _update_hold(self) -> None:
472
+ if self.answer is not None and self.answer.drain:
473
+ new = HOLD_DRAIN
474
+ elif self.wanted() is not None:
475
+ new = HOLD_UPGRADE
476
+ else:
477
+ new = None
478
+ if new == self._hold:
479
+ return
480
+ old, self._hold = self._hold, new
481
+ if new is None:
482
+ self._idle_polls = 0
483
+ log.info(
484
+ "self-upgrade: drain lifted (was: %s) — new reviewer sessions may "
485
+ "start again", old,
486
+ )
487
+ else:
488
+ log.info(
489
+ "self-upgrade: draining — %s; no new reviewer session starts, "
490
+ "running ones finish normally", self.hold_detail(),
491
+ )
492
+
493
+ # -- the exit --
494
+
495
+ def after_pass(
496
+ self,
497
+ roster: "Sequence[object] | None",
498
+ queued: "Callable[[], int | None] | None" = None,
499
+ ) -> str:
500
+ """Called once per completed pass, after the vitals push. Returns
501
+ EXIT when the daemon should exit now for the upgrade (the request is
502
+ written), WAIT while it is draining towards one, NONE otherwise.
503
+
504
+ `roster` is the pass's live reviewer sessions (the reap sweep's
505
+ ``alissa tmux ls --live`` listing, None when it failed; see
506
+ `live_reviewer_sessions`). `queued` counts
507
+ the undispatched items in the alissa tmux queue (None: unknown). It
508
+ is read only when the roster is idle: an item queued before the
509
+ hold came on is in neither listing yet, and a restart would kill the
510
+ session it dispatches to."""
511
+ # Re-checked here, not only at the hold's start: a skip list the
512
+ # entrypoint just wrote, or a target that moved, must stop the exit.
513
+ self._update_hold()
514
+ version = self.wanted()
515
+ if version is None or self.answer is None:
516
+ self._idle_polls = 0
517
+ return NONE
518
+ live = live_reviewer_sessions(roster)
519
+ if live is None or live:
520
+ if self._idle_polls:
521
+ log.info(
522
+ "self-upgrade: not idle any more (%s) — idle count reset",
523
+ "the session listing failed" if live is None
524
+ else ", ".join(live),
525
+ )
526
+ self._idle_polls = 0
527
+ return WAIT
528
+ waiting = 0 if queued is None else _count(queued)
529
+ if waiting is None or waiting:
530
+ if self._idle_polls:
531
+ log.info(
532
+ "self-upgrade: not idle any more (%s) — idle count reset",
533
+ "the alissa queue listing failed" if waiting is None
534
+ else f"{waiting} undispatched item(s) in the alissa queue",
535
+ )
536
+ self._idle_polls = 0
537
+ return WAIT
538
+ self._idle_polls += 1
539
+ if self._idle_polls < IDLE_POLLS:
540
+ log.info(
541
+ "self-upgrade: idle on poll %d of %d before the exit to %s",
542
+ self._idle_polls, IDLE_POLLS, version,
543
+ )
544
+ return WAIT
545
+ if self.dry_run:
546
+ log.info(
547
+ "[dry-run] self-upgrade: idle — would write %s and exit to "
548
+ "upgrade %s → %s", self.path, self.running, version,
549
+ )
550
+ return WAIT
551
+ try:
552
+ self._request(self.answer)
553
+ except OSError as exc:
554
+ self._unwritable.add(version)
555
+ log.warning(
556
+ "self-upgrade: could not write %s (%s) — NOT upgrading to %s; "
557
+ "the drain is lifted and this process will not try %s again",
558
+ self.path, exc, version, version,
559
+ )
560
+ self._update_hold()
561
+ return NONE
562
+ self.exit_requested = True
563
+ log.info(
564
+ "self-upgrade: idle on %d consecutive polls — wrote %s; exiting "
565
+ "with code 0 so the container restarts and installs %s (from %s)",
566
+ IDLE_POLLS, self.path, version, self.running,
567
+ )
568
+ return EXIT
569
+
570
+ def _request(self, answer: UpgradeAnswer) -> None:
571
+ old = read_request(self.path) or {}
572
+ data: dict = {
573
+ "target": answer.target,
574
+ "previous": self.running,
575
+ "requestedAt": iso(self._clock()),
576
+ "state": "requested",
577
+ "confirmed": False,
578
+ "boots": 0,
579
+ "skip": skip_list(old),
580
+ }
581
+ # A report not yet delivered survives a new request.
582
+ if old.get("upgradeFailed") and not old.get("upgradeFailedReported"):
583
+ data["upgradeFailed"] = old["upgradeFailed"]
584
+ data["upgradeFailedReported"] = False
585
+ write_request(self.path, data)
586
+
587
+ # -- the failure report --
588
+
589
+ def report(self) -> "dict | None":
590
+ """The ``upgradeFailed`` report the next snapshot owes Studio, or
591
+ None. Only a well-formed `{version, reason}` is offered: the request
592
+ schema is strict."""
593
+ data = read_request(self.path)
594
+ if not data or data.get("upgradeFailedReported"):
595
+ return None
596
+ failed = data.get("upgradeFailed")
597
+ if not isinstance(failed, Mapping):
598
+ return None
599
+ version, reason = failed.get("version"), failed.get("reason")
600
+ if not isinstance(version, str) or not re.fullmatch(r"[A-Za-z0-9._-]{1,128}", version):
601
+ return None
602
+ reason = " ".join(str(reason or "rolled back").split())[:300] or "rolled back"
603
+ return {"version": version, "reason": reason}
604
+
605
+ def reported(self, sent: "Mapping") -> None:
606
+ """Mark the report delivered, after a POST carrying `sent`
607
+ succeeded."""
608
+ data = read_request(self.path)
609
+ if not data:
610
+ return
611
+ failed = data.get("upgradeFailed")
612
+ if not isinstance(failed, Mapping) or failed.get("version") != sent.get("version"):
613
+ return
614
+ data["upgradeFailedReported"] = True
615
+ try:
616
+ write_request(self.path, data)
617
+ except OSError as exc:
618
+ log.warning("self-upgrade: could not update %s (%s)", self.path, exc)
619
+ return
620
+ log.info(
621
+ "self-upgrade: reported to Studio that %s would not start "
622
+ "(rolled back to %s)", sent.get("version"), self.running,
623
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.31.27
3
+ Version: 0.31.29
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -19,6 +19,7 @@ src/main/alissa/tools/github/revloop/parking.py
19
19
  src/main/alissa/tools/github/revloop/proc.py
20
20
  src/main/alissa/tools/github/revloop/prompts.py
21
21
  src/main/alissa/tools/github/revloop/prreview.py
22
+ src/main/alissa/tools/github/revloop/self_upgrade.py
22
23
  src/main/alissa/tools/github/revloop/state.py
23
24
  src/main/alissa/tools/github/revloop/trust.py
24
25
  src/main/alissa/tools/github/revloop/version