alissa-tools-github-revloop 0.27.0__tar.gz → 0.28.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 (34) hide show
  1. {alissa_tools_github_revloop-0.27.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.28.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/__main__.py +28 -0
  3. alissa_tools_github_revloop-0.28.0/src/main/alissa/tools/github/revloop/alissa_client.py +191 -0
  4. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/config.py +123 -6
  5. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/loop.py +38 -1
  6. alissa_tools_github_revloop-0.28.0/src/main/alissa/tools/github/revloop/loop_events.py +518 -0
  7. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/state.py +92 -17
  8. alissa_tools_github_revloop-0.28.0/src/main/alissa/tools/github/revloop/version +1 -0
  9. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  10. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +2 -0
  11. alissa_tools_github_revloop-0.27.0/src/main/alissa/tools/github/revloop/version +0 -1
  12. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/LICENSE +0 -0
  13. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/MANIFEST.in +0 -0
  14. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/NOTICE +0 -0
  15. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/README.md +0 -0
  16. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/requirements.txt +0 -0
  17. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/setup.cfg +0 -0
  18. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/setup.py +0 -0
  19. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  20. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  21. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  22. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  23. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  24. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  25. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  26. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  27. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  28. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  29. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  30. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  31. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  32. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  33. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  34. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.28.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.27.0
3
+ Version: 0.28.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -11,6 +11,7 @@ from pathlib import Path
11
11
  from .config import (
12
12
  HUB_ADD,
13
13
  HUB_SKIP,
14
+ LOOP_EVENTS_ENV,
14
15
  ON_MISSING_CREATE,
15
16
  ON_MISSING_SKIP,
16
17
  ON_MISSING_SPAWN,
@@ -200,6 +201,31 @@ def build_parser() -> argparse.ArgumentParser:
200
201
  f"invisible to this daemon. Overridden by ${TASK_LIST_BOW_ENV}",
201
202
  )
202
203
 
204
+ events = over.add_mutually_exclusive_group()
205
+ events.add_argument(
206
+ "--loop-events",
207
+ dest="loop_events_enabled",
208
+ action="store_true",
209
+ default=None,
210
+ help="push loop telemetry (rounds spawned, verdicts, cap-outs, "
211
+ "stability holds, stalls, checks holds) to Studio's POST "
212
+ "/v1/loop-events once per poll pass — best-effort, never fatal. "
213
+ f"Overridden by ${LOOP_EVENTS_ENV}",
214
+ )
215
+ events.add_argument(
216
+ "--no-loop-events",
217
+ dest="loop_events_enabled",
218
+ action="store_false",
219
+ help="do not push loop telemetry even if the config enables it",
220
+ )
221
+ over.add_argument(
222
+ "--alissa-endpoint",
223
+ dest="alissa_endpoint",
224
+ metavar="URL",
225
+ help="the Alissa API base the loop-events client posts to "
226
+ "(default: https://api.alissa.app)",
227
+ )
228
+
203
229
  dry = over.add_mutually_exclusive_group()
204
230
  dry.add_argument(
205
231
  "--dry-run",
@@ -242,6 +268,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
242
268
  "review_task_miss_ttl_polls": args.review_task_miss_ttl_polls,
243
269
  "task_list_self_scope": args.task_list_self_scope,
244
270
  "task_list_bow_id": args.task_list_bow_id,
271
+ "loop_events_enabled": args.loop_events_enabled,
272
+ "alissa_endpoint": args.alissa_endpoint,
245
273
  "dry_run": args.dry_run,
246
274
  }
247
275
 
@@ -0,0 +1,191 @@
1
+ """Alissa REST access — the one write loop telemetry needs (issue #112).
2
+
3
+ This is the SECOND Alissa adapter in the package, and the split is deliberate.
4
+ `alissa.py` shells out to the `alissa` CLI, which is the daemon's established
5
+ Alissa idiom for everything it does today (the review-task search, the tmux
6
+ queue, the CR6 envelope reads). Loop telemetry needs a thing that idiom cannot
7
+ supply: the CLI has no loop-events command, and it cannot attach the actor
8
+ identity `POST /v1/loop-events` keys its rows by — the API stores events under
9
+ the token's principal user, which is exactly the credential this daemon's
10
+ `ALISSA_API_TOKEN` already carries.
11
+
12
+ So the one write goes over the REST API directly, in the shape devloop's
13
+ `alissa_client.py` adopted (PR #91 there): stdlib `urllib` only — the
14
+ distribution ships no third-party runtime dependency and this must not be the
15
+ change that adds one — a bearer token from the environment, bounded timeouts,
16
+ and errors classified into a small taxonomy instead of leaking raw urllib
17
+ exceptions. The CLI adapter stays untouched for everything else.
18
+
19
+ The caller here is a BEST-EFFORT emitter (`loop_events`): its answer to every
20
+ bucket is the same — warn once and let the pass complete — so the taxonomy
21
+ exists for the log line, which should say "your token is wrong" (permanent,
22
+ operator-fixable) differently from "the API blinked".
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import os
30
+ import urllib.error
31
+ import urllib.request
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+ DEFAULT_ENDPOINT = "https://api.alissa.app"
36
+
37
+ # The env var the `alissa` CLI itself reads, so a daemon whose CLI is already
38
+ # authenticated needs no second secret.
39
+ ENV_TOKEN = "ALISSA_API_TOKEN"
40
+
41
+ # The ingest cap: POST /v1/loop-events takes 1-200 events per call. The
42
+ # EMITTER splits batches at this bound; the client refuses an oversized one
43
+ # rather than silently posting a request the API will 400.
44
+ MAX_EVENTS_PER_POST = 200
45
+
46
+
47
+ class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
48
+ """Refuse every redirect instead of following it (PR #113 round 1).
49
+
50
+ The default handler copies the request's headers — `Authorization`
51
+ included — onto the redirected request, ACROSS HOSTS, so a 30x from
52
+ whatever `alissa_endpoint` names would hand the bearer token to the
53
+ redirect target (and 301/302/303 would downgrade the POST to a GET that
54
+ ingests nothing while reading as success). A redirected ingest cannot
55
+ succeed anyway, so refusal beats strip-and-follow: returning None makes
56
+ urllib raise the 30x as an HTTPError, which the taxonomy reports like any
57
+ other unexpected status.
58
+ """
59
+
60
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
61
+ return None
62
+
63
+
64
+ # One opener for the module: default handlers with only the redirect
65
+ # behaviour replaced, built once because handler construction is not free
66
+ # and every client shares the same policy.
67
+ _opener = urllib.request.build_opener(_RefuseRedirects())
68
+
69
+
70
+ class AlissaError(Exception):
71
+ """Base of the taxonomy. `status` is 0 for a transport failure (there was
72
+ no HTTP response to carry one)."""
73
+
74
+ def __init__(self, status: int, detail: object, code: "str | None" = None):
75
+ super().__init__(f"HTTP {status}: {detail}" if status else str(detail))
76
+ self.status = status
77
+ self.detail = detail
78
+ self.code = code
79
+
80
+
81
+ class AlissaAuthError(AlissaError):
82
+ """401/403, or no token at all. Permanent and operator-fixable — retrying
83
+ it every pass only writes the same warning again."""
84
+
85
+
86
+ class AlissaTransient(AlissaError):
87
+ """408/429/5xx and every transport failure (DNS, refused, timeout). The
88
+ 'the API blinked' bucket — re-emission next pass is the retry, and the
89
+ deterministic dedupe keys are what make it harmless."""
90
+
91
+
92
+ class AlissaClient:
93
+ """The one write, with the transport hidden behind the taxonomy.
94
+
95
+ Reads `ALISSA_API_TOKEN` from the environment; `base` defaults to the
96
+ public API. Both are constructor arguments so a test never needs the
97
+ network and an operator can point a daemon at another deployment
98
+ (`alissa_endpoint` in the config)."""
99
+
100
+ def __init__(
101
+ self,
102
+ token: "str | None" = None,
103
+ base: "str | None" = None,
104
+ *,
105
+ timeout: int = 30,
106
+ ):
107
+ self.base = (base or DEFAULT_ENDPOINT).rstrip("/")
108
+ self._token = token if token is not None else os.environ.get(ENV_TOKEN)
109
+ self._timeout = timeout
110
+
111
+ def _request(self, path: str, payload: dict) -> object:
112
+ """One POST. Every failure leaves as a taxonomy exception — the caller
113
+ never sees a raw urllib error or an HTTP status."""
114
+ if not self._token:
115
+ # No token at all is an auth condition, not a transport one: the
116
+ # operator must set the env var. Fail the way a 401 would, so the
117
+ # emitter's warning reads as permanent rather than transient.
118
+ raise AlissaAuthError(0, f"{ENV_TOKEN} is not set")
119
+
120
+ req = urllib.request.Request(
121
+ f"{self.base}{path}",
122
+ method="POST",
123
+ data=json.dumps(payload).encode("utf-8"),
124
+ headers={
125
+ "Accept": "application/json",
126
+ "Content-Type": "application/json",
127
+ "Authorization": f"Bearer {self._token}",
128
+ },
129
+ )
130
+ try:
131
+ with _opener.open(req, timeout=self._timeout) as resp:
132
+ raw = resp.read().decode("utf-8")
133
+ except urllib.error.HTTPError as exc:
134
+ raise self._classify(exc) from None
135
+ except urllib.error.URLError as exc:
136
+ raise AlissaTransient(0, str(exc.reason)) from None
137
+ except (TimeoutError, OSError) as exc: # pragma: no cover - defence
138
+ # A socket timeout on the READ does not arrive as URLError.
139
+ raise AlissaTransient(0, str(exc)) from None
140
+ try:
141
+ return json.loads(raw) if raw.strip() else {}
142
+ except json.JSONDecodeError as exc:
143
+ # A 2xx that is not JSON is a contract violation, not a retry
144
+ # signal -- but it must not escape as a bare ValueError either,
145
+ # because the emitter catches AlissaError and nothing else.
146
+ raise AlissaError(200, f"response was not JSON ({exc})") from None
147
+
148
+ @staticmethod
149
+ def _classify(exc: "urllib.error.HTTPError") -> AlissaError:
150
+ """Map an HTTP error onto the taxonomy. The API sends JSON error
151
+ bodies (`{"error": CODE, "message": ...}`); the code rides along when
152
+ present, but classification keys on the STATUS — codes are advisory,
153
+ statuses are the contract."""
154
+ detail: object = exc.read().decode("utf-8", "replace")
155
+ code: "str | None" = None
156
+ try:
157
+ parsed = json.loads(detail) # type: ignore[arg-type]
158
+ except (json.JSONDecodeError, TypeError):
159
+ parsed = None
160
+ if isinstance(parsed, dict):
161
+ detail = parsed
162
+ raw_code = parsed.get("error")
163
+ code = raw_code if isinstance(raw_code, str) else None
164
+
165
+ status = exc.code
166
+ if status in (401, 403):
167
+ return AlissaAuthError(status, detail, code)
168
+ if status in (408, 429) or 500 <= status <= 599:
169
+ return AlissaTransient(status, detail, code)
170
+ return AlissaError(status, detail, code)
171
+
172
+ def post_loop_events(self, events: "list[dict]") -> dict:
173
+ """Ingest one batch of loop events (`POST /v1/loop-events`).
174
+
175
+ The API is idempotent on `(user, dedupeKey)`, so re-posting a batch —
176
+ which is exactly what the emitter does after a failed pass — lands as
177
+ silent duplicates, never as errors or overwrites. Returns the API's
178
+ `{"accepted": N, "duplicates": M}` payload (empty dict when the body
179
+ was empty), for the caller's debug line.
180
+
181
+ An oversized batch is refused HERE, loudly: the API fails the whole
182
+ call at >200 events, and the emitter owns the splitting, so reaching
183
+ this guard is a code defect rather than an operational condition.
184
+ """
185
+ if len(events) > MAX_EVENTS_PER_POST:
186
+ raise ValueError(
187
+ f"post_loop_events takes at most {MAX_EVENTS_PER_POST} events "
188
+ f"per call, got {len(events)} — the emitter must split"
189
+ )
190
+ payload = self._request("/v1/loop-events", {"events": events})
191
+ return payload if isinstance(payload, dict) else {}
@@ -6,12 +6,15 @@ Settings come from three layers, later winning over earlier:
6
6
  2. a JSON config file (see `resolve_config_path`)
7
7
  3. CLI arguments
8
8
 
9
- `task_list_bow_id` adds a fourth above them all — the environment
10
- (`ALISSA_REVIEW_TASK_BOW`, see `env_task_list_bow_id`). It is the only key that
11
- does, and the reason is that it is the only one a second entry point needs:
12
- `alissa-pr-review` builds its own `Alissa` client with no config file and no
13
- argv of the daemon's, so an id that lives only in the file or the flags reaches
14
- the poll loop and silently misses that call site.
9
+ Two keys add a fourth layer above them all — the environment. `task_list_bow_id`
10
+ (`ALISSA_REVIEW_TASK_BOW`, see `env_task_list_bow_id`) has it because it is the
11
+ one key a second entry point needs: `alissa-pr-review` builds its own `Alissa`
12
+ client with no config file and no argv of the daemon's, so an id that lives only
13
+ in the file or the flags reaches the poll loop and silently misses that call
14
+ site. `loop_events_enabled` (`ALISSA_REV_LOOP_EVENTS_ENABLED`, see
15
+ `env_loop_events_enabled`) has it because a container deployment toggles
16
+ telemetry with one variable and no config-file edit (issue #112); the env wins
17
+ over both other layers so the two env-backed keys share one precedence story.
15
18
 
16
19
  `workspace_root` is deliberately **not** a config key — it is a property of the
17
20
  running process, not of the settings. That lets one config file drive several
@@ -28,6 +31,7 @@ import re
28
31
  from dataclasses import dataclass
29
32
  from pathlib import Path
30
33
  from typing import Any, Mapping
34
+ from urllib.parse import urlsplit
31
35
 
32
36
  # A POSIX-ish environment variable name -- what `reviewer_token_env` must be.
33
37
  _ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
@@ -147,6 +151,8 @@ CONFIG_KEYS = (
147
151
  "review_task_miss_ttl_polls",
148
152
  "task_list_self_scope",
149
153
  "task_list_bow_id",
154
+ "loop_events_enabled",
155
+ "alissa_endpoint",
150
156
  "dry_run",
151
157
  )
152
158
 
@@ -156,6 +162,81 @@ CONFIG_KEYS = (
156
162
  TASK_LIST_BOW_ENV = "ALISSA_REVIEW_TASK_BOW"
157
163
 
158
164
 
165
+ # The environment variable toggling loop telemetry (issue #112). Like
166
+ # TASK_LIST_BOW_ENV it outranks both the config file and the CLI flag — see the
167
+ # module docstring for the shared precedence story.
168
+ LOOP_EVENTS_ENV = "ALISSA_REV_LOOP_EVENTS_ENABLED"
169
+
170
+ # The default Studio API base the loop-events client posts to. Mirrors
171
+ # alissa_client.DEFAULT_ENDPOINT (a test pins the two together); defined here
172
+ # too so config stays importable without the client module.
173
+ DEFAULT_ALISSA_ENDPOINT = "https://api.alissa.app"
174
+
175
+ # The boolean spellings the env layer accepts, matching the container
176
+ # renderer's contract for its other boolean (ALISSA_TASK_LIST_SELF_SCOPE):
177
+ # anything else is REFUSED rather than read as false, because a silently-false
178
+ # typo is indistinguishable from the default it was trying to change.
179
+ _ENV_TRUE = frozenset({"1", "true", "yes", "on"})
180
+ _ENV_FALSE = frozenset({"0", "false", "no", "off"})
181
+
182
+ # Hosts a cleartext `alissa_endpoint` is allowed to name. The loop-events
183
+ # client sends a bearer token with every POST, so a non-https endpoint puts
184
+ # that token on the wire — refused at load, except toward the machine itself
185
+ # (a local stub or port-forward, which is how the client is tested against a
186
+ # fake ingest).
187
+ _LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
188
+
189
+
190
+ def _validate_alissa_endpoint(endpoint: str) -> str:
191
+ """`endpoint`, or a ValueError naming why it cannot carry a credential.
192
+
193
+ https is the rule; http is allowed only toward loopback. Anything else —
194
+ a bare host, another scheme, an unparsable value — is refused at load,
195
+ where the operator reads `config error`, rather than discovered as a
196
+ token on a cleartext hop (PR #113 round 1, minor).
197
+ """
198
+ parts = urlsplit(endpoint)
199
+ # A host is required on BOTH branches (PR #113 round 2, nit): a bare
200
+ # "https://" parses with the right scheme and no host, and would then
201
+ # fail on the wire as a per-pass transient WARN — the exact symptom this
202
+ # load-time check exists to prevent.
203
+ if parts.scheme == "https" and parts.hostname:
204
+ return endpoint
205
+ if parts.scheme == "http" and parts.hostname in _LOOPBACK_HOSTS:
206
+ return endpoint
207
+ raise ValueError(
208
+ f"alissa_endpoint must be an https:// URL with a host (or http:// "
209
+ f"toward loopback — localhost, 127.0.0.1, ::1 — for a local stub): "
210
+ f"the loop-events client sends a bearer token with every request, "
211
+ f"and a cleartext endpoint puts it on the wire. Got {endpoint!r}"
212
+ )
213
+
214
+
215
+ def env_loop_events_enabled(
216
+ environ: "Mapping[str, str] | None" = None,
217
+ ) -> "bool | None":
218
+ """The loop-events toggle from the environment, or None when unset.
219
+
220
+ None and empty are the SAME answer — an exported-but-empty variable is how
221
+ a container renders "unset" (the Dockerfile bakes empty ENV defaults), and
222
+ it must fall through to the file/CLI layers rather than read as false. A
223
+ non-boolean spelling raises: the startup phase turns a ValueError into
224
+ `config error` + exit 2, which is where a typo belongs.
225
+ """
226
+ raw = (os.environ if environ is None else environ).get(LOOP_EVENTS_ENV)
227
+ value = (raw or "").strip().lower()
228
+ if not value:
229
+ return None
230
+ if value in _ENV_TRUE:
231
+ return True
232
+ if value in _ENV_FALSE:
233
+ return False
234
+ raise ValueError(
235
+ f"{LOOP_EVENTS_ENV} must be a boolean "
236
+ f"(1/0, true/false, yes/no, on/off), got {raw!r}"
237
+ )
238
+
239
+
159
240
  def env_task_list_bow_id(environ: "Mapping[str, str] | None" = None) -> "str | None":
160
241
  """The review BOW id from the environment, or None when it is not set.
161
242
 
@@ -496,6 +577,20 @@ class Config:
496
577
  # get the id wrong (a repo's `autodev:` feed BOW; a `mirrorInstanceId`).
497
578
  task_list_bow_id: str | None = None
498
579
 
580
+ # Whether the loop pushes its telemetry — rounds spawned, verdicts posted,
581
+ # cap-outs, stability holds, stalls, checks holds, grants, reaps — to
582
+ # Studio's `POST /v1/loop-events` once per poll pass (issue #112). OFF by
583
+ # default: telemetry is an outbound write to an external service, and an
584
+ # existing deployment must not start posting because it upgraded. Toggled
585
+ # by the env var LOOP_EVENTS_ENV above the file and the CLI. Best-effort
586
+ # when on: a failed push is one WARN and the pass completes.
587
+ loop_events_enabled: bool = False
588
+
589
+ # The Alissa/Studio API base the loop-events client posts to. One knob so
590
+ # a staging deployment can point telemetry somewhere else; everything else
591
+ # about the client (its bearer token) comes from the environment.
592
+ alissa_endpoint: str = DEFAULT_ALISSA_ENDPOINT
593
+
499
594
  dry_run: bool = False
500
595
 
501
596
  def __post_init__(self) -> None:
@@ -603,6 +698,26 @@ class Config:
603
698
  # takes and answers with nobody's tasks.
604
699
  bow_id = (bow_id or "").strip() or None
605
700
 
701
+ # The loop-events toggle's env layer, applied after the CLI overrides
702
+ # like the BOW id's above and for the same reason: the variable is how
703
+ # a container flips telemetry without editing a file on its volume.
704
+ env_events = env_loop_events_enabled(environ)
705
+ if env_events is not None:
706
+ raw["loop_events_enabled"] = env_events
707
+
708
+ endpoint = raw.get("alissa_endpoint", cls.alissa_endpoint)
709
+ if not isinstance(endpoint, str):
710
+ raise ValueError(
711
+ f"alissa_endpoint must be a URL string, got a "
712
+ f"{type(endpoint).__name__}"
713
+ )
714
+ # "" falls back to the default rather than building a client with an
715
+ # empty base — the same unset-means-default reading every optional
716
+ # string key here has.
717
+ endpoint = _validate_alissa_endpoint(
718
+ endpoint.strip() or cls.alissa_endpoint
719
+ )
720
+
606
721
  mode = raw.get("on_missing_review_task", ON_MISSING_SPAWN)
607
722
  if mode not in _MISSING_MODES:
608
723
  raise ValueError(
@@ -781,6 +896,8 @@ class Config:
781
896
  review_task_miss_ttl_polls=miss_ttl,
782
897
  task_list_self_scope=bool(raw.get("task_list_self_scope", False)),
783
898
  task_list_bow_id=bow_id,
899
+ loop_events_enabled=bool(raw.get("loop_events_enabled", False)),
900
+ alissa_endpoint=endpoint,
784
901
  dry_run=bool(raw.get("dry_run", False)),
785
902
  )
786
903
 
@@ -56,6 +56,7 @@ from .ghclient import (
56
56
  countable_rounds,
57
57
  verdict_marker,
58
58
  )
59
+ from .loop_events import LoopEventsEmitter, build_emitter
59
60
  from .proc import CommandError
60
61
  from .state import State
61
62
 
@@ -1604,6 +1605,14 @@ class ReviewWatcher:
1604
1605
  task_list_bow_id=config.task_list_bow_id,
1605
1606
  )
1606
1607
  self.state = state or State(config.state_db)
1608
+ # The Studio loop-telemetry push (issue #112), or None when the knob
1609
+ # is off — the disabled default costs nothing: no client, no read, no
1610
+ # log line. Built through the seam so tests swap in a fake client.
1611
+ self._loop_events: LoopEventsEmitter | None = (
1612
+ build_emitter(self.state, endpoint=config.alissa_endpoint)
1613
+ if config.loop_events_enabled
1614
+ else None
1615
+ )
1607
1616
  # (repo, number, comment id) of every re-entry directive already
1608
1617
  # refused in this process -- see _log_ignored_ack.
1609
1618
  self._ignored_acks: set[tuple[str, int, int]] = set()
@@ -2896,7 +2905,9 @@ class ReviewWatcher:
2896
2905
  else f" — CI gate: the rollup at {judged[:8]} is {gate.state}, so the "
2897
2906
  f"{verdict} envelope did not post as an APPROVE"
2898
2907
  )
2899
- self.state.record_verdict_post(pr.full_name, pr.number, round_, url)
2908
+ self.state.record_verdict_post(
2909
+ pr.full_name, pr.number, round_, url, verdict=verdict
2910
+ )
2900
2911
  log.info(
2901
2912
  "%s round %d closed: native %s review submitted as %s (%s)%s",
2902
2913
  pr.slug, round_, event, self.github.login, url or "no url", gate_note,
@@ -4810,8 +4821,34 @@ class ReviewWatcher:
4810
4821
  self._write_snapshot(
4811
4822
  results, reaped, duration_ms=int((time.monotonic() - started) * 1000)
4812
4823
  )
4824
+ self._emit_loop_events()
4813
4825
  return results
4814
4826
 
4827
+ def _emit_loop_events(self) -> None:
4828
+ """Push this pass's loop telemetry to Studio, when enabled (issue
4829
+ #112). Last thing in the pass, after the snapshot, so the batch sees
4830
+ every ledger row the pass wrote.
4831
+
4832
+ Skipped in dry-run even when enabled: the ledger holds no new rows
4833
+ from a dry-run pass, and while re-emitting old ones would be harmless
4834
+ (idempotent keys), an outbound POST is still an act — and dry-run's
4835
+ contract is decide-and-log only.
4836
+
4837
+ The emitter itself never raises for an API condition (one WARN, the
4838
+ pass completes); the guard here is the same never-fatal promise held
4839
+ against a code defect, so telemetry can never take down a poll.
4840
+ """
4841
+ if self._loop_events is None or self.config.dry_run:
4842
+ return
4843
+ try:
4844
+ self._loop_events.emit_once()
4845
+ except Exception as exc:
4846
+ log.warning(
4847
+ "loop-events: emitter failed unexpectedly (%s: %s) — "
4848
+ "telemetry is best-effort, the pass completes",
4849
+ type(exc).__name__, exc,
4850
+ )
4851
+
4815
4852
  def _note_ledger_unwritable(self) -> None:
4816
4853
  """Report a pass refused because the ledger cannot record it.
4817
4854
 
@@ -0,0 +1,518 @@
1
+ """Loop telemetry: derive Studio loop events from the ledger, once per pass.
2
+
3
+ The per-round record this daemon keeps — `verdict_posts` (first seen, attempts,
4
+ posted/abandoned, checks-held state, review URL), `spawns`, `escalations`,
5
+ `pings`, `grants`, `stability_notices`, `spawn_checks_holds`, `reaps` — lives
6
+ only in `.revloop/state.db` on the deployment's volume. The Factory console
7
+ needs rounds-to-approve, verdict mix, cap-outs and stability holds over time,
8
+ and Studio now ingests exactly that (`POST /v1/loop-events`, issue #112). This
9
+ module is the writer: at the end of each poll pass it derives events from the
10
+ ledger and posts ONE batch (split at 200, the ingest cap).
11
+
12
+ Design rules, all load-bearing:
13
+
14
+ * **The ledger is the source, not the pass.** Events carry the LEDGER's
15
+ timestamps (`at` = the row's stamp, in epoch ms) and dedupe keys built from
16
+ the LEDGER's own keys, so however many times a row is read, it derives the
17
+ same event. That is what lets everything else be simple.
18
+
19
+ * **Best-effort, never fatal.** A failed post is one WARN and the pass
20
+ completes; there is no retry queue, because there does not need to be one —
21
+ the watermark below does not advance on failure, so the next pass re-derives
22
+ and re-sends, and the deterministic keys make the re-emission land as silent
23
+ duplicates server-side (idempotent on `(user, dedupeKey)`).
24
+
25
+ * **The watermark is in-memory and starts at zero.** Each successful emission
26
+ advances it to the newest ledger stamp sent. The boundary is INCLUSIVE (two
27
+ rows can share a second, and a strict `>` advanced to the first one's stamp
28
+ would lose the second forever); the standing re-post that inclusion would
29
+ otherwise cause is closed by remembering the dedupe keys already sent at the
30
+ watermark second and dropping them from the next derivation — so a quiet
31
+ ledger derives an EMPTY batch and no request is made. A daemon restart
32
+ resets both and the first pass re-sends the WHOLE ledger, batched: that is
33
+ the backfill, not a bug — Studio dedupes every previously-seen key and
34
+ keeps the history a fresh console needs. Be honest about the bound: NONE of
35
+ the seven tables this module reads is pruned (`poll_snapshots` is the
36
+ ledger's only self-bounding table, and this module does not read it), so
37
+ the backfill — and the per-pass re-derivation the watermark then filters —
38
+ is bounded only by the deployment's actual row counts. At today's volumes
39
+ (hundreds of rows over a deployment's life) that is a fine trade for
40
+ stateless simplicity; if a ledger ever outgrows it, the upgrade path is
41
+ pushing the `since` bound into the readers' SQL, or persisting the
42
+ watermark in the ledger so a restart does not backfill at all.
43
+
44
+ * **Derivation is pure reads.** No GitHub call, no ledger write, no new state
45
+ table. A row the ledger deletes (a spawn-side CI hold whose wait ended
46
+ between passes) is simply not observed; a row enriched from another table
47
+ (`stability_notices` onto a `stability:` ping) reads whatever that table
48
+ says NOW, which is also what the guard itself decides from.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import logging
54
+ from typing import Any
55
+
56
+ from .alissa_client import (
57
+ AlissaAuthError,
58
+ AlissaClient,
59
+ AlissaError,
60
+ MAX_EVENTS_PER_POST,
61
+ )
62
+ from .state import State
63
+
64
+ log = logging.getLogger(__name__)
65
+
66
+ SEAT = "revloop"
67
+
68
+ # Ping-kind prefixes this module parses back out of the ledger. Kept as local
69
+ # constants rather than imported from `loop` because loop imports THIS module
70
+ # (the emitter is wired into the watcher), and a cycle is a worse trade than a
71
+ # pinned duplicate: a test asserts each against loop's own constant, so a
72
+ # renamed kind fails the suite instead of silently deriving nothing.
73
+ STALLED_PREFIX = "stalled:"
74
+ STABILITY_PREFIX = "stability:"
75
+ CHECKS_UNSETTLED_PREFIX = "checks-unsettled:"
76
+
77
+
78
+ def _ms(seconds: "int | float") -> int:
79
+ """A ledger stamp (epoch seconds) as the API's epoch-ms `at`."""
80
+ return int(seconds) * 1000
81
+
82
+
83
+ def _event(
84
+ kind: str,
85
+ at_s: int,
86
+ dedupe_key: str,
87
+ *,
88
+ repo: "str | None" = None,
89
+ pr: "int | None" = None,
90
+ round_: "int | None" = None,
91
+ session: "str | None" = None,
92
+ reason: "str | None" = None,
93
+ data: "dict[str, Any] | None" = None,
94
+ ) -> "tuple[int, dict]":
95
+ """One (ledger stamp, event payload) pair, with absent fields omitted
96
+ rather than sent as null — the API validates shape per field."""
97
+ payload: dict[str, Any] = {
98
+ "seat": SEAT,
99
+ "kind": kind,
100
+ "at": _ms(at_s),
101
+ "dedupeKey": dedupe_key,
102
+ }
103
+ if repo:
104
+ payload["repo"] = repo
105
+ if pr is not None:
106
+ payload["prNumber"] = int(pr)
107
+ if round_ is not None:
108
+ payload["round"] = int(round_)
109
+ if session:
110
+ payload["session"] = session
111
+ if reason:
112
+ # The API caps `reason` at 2000 chars and refuses (never trims) an
113
+ # over-cap value, which would fail the WHOLE batch — so the trim
114
+ # happens here, where it costs one event's tail instead.
115
+ payload["reason"] = reason[:2000]
116
+ if data:
117
+ payload["data"] = data
118
+ return int(at_s), payload
119
+
120
+
121
+ def _spawn_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
122
+ out = []
123
+ for row in rows:
124
+ repo, number = row["repo"], int(row["number"])
125
+ head = row["head_sha"] or ""
126
+ data: dict[str, Any] = {"headSha": head}
127
+ if row.get("task_ref"):
128
+ data["taskRef"] = row["task_ref"]
129
+ out.append(_event(
130
+ "round.spawned",
131
+ int(row["spawned_at"]),
132
+ f"revloop:round.spawned:{repo}:{number}:{row['round']}:{head}",
133
+ repo=repo,
134
+ pr=number,
135
+ round_=int(row["round"]),
136
+ session=row["session"],
137
+ data=data,
138
+ ))
139
+ return out
140
+
141
+
142
+ def _verdict_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
143
+ """`round.verdict` for posted rows, `round.abandoned` for abandoned ones.
144
+
145
+ A row with neither `posted_at` nor `abandoned_at` is an OPEN obligation —
146
+ the round's native verdict has not landed — and emits nothing until it
147
+ closes one way or the other. `data.verdict` is omitted (never invented)
148
+ on rows that predate the ledger's verdict column.
149
+
150
+ COVERAGE BOUND (PR #113 round 2, major): `verdict_posts` is the
151
+ per-round OBLIGATION record, not the per-round record — a row exists
152
+ only when the reviewer session defaulted and the daemon posted the
153
+ native fallback verdict itself. On a fleet whose sessions post every
154
+ review, these two kinds are expected to be EMPTY; issue #112 maps them
155
+ to this table and puts the round bookkeeping a session-covering source
156
+ needs out of scope, so the widening is TASK-1086576582, and the README's
157
+ telemetry table states the bound where an operator will read it."""
158
+ out = []
159
+ for row in rows:
160
+ repo, number = row["repo"], int(row["number"])
161
+ round_, head = int(row["round"]), row["head_sha"] or ""
162
+ if row.get("posted_at"):
163
+ posted = int(row["posted_at"])
164
+ data: dict[str, Any] = {"headSha": head}
165
+ if row.get("verdict"):
166
+ data["verdict"] = row["verdict"]
167
+ if row.get("review_url"):
168
+ data["reviewUrl"] = row["review_url"]
169
+ data["attempts"] = int(row.get("attempts") or 0)
170
+ if row.get("checks_held_at"):
171
+ held = posted - int(row["checks_held_at"])
172
+ data["checksHeldMs"] = max(held, 0) * 1000
173
+ out.append(_event(
174
+ "round.verdict",
175
+ posted,
176
+ f"revloop:round.verdict:{repo}:{number}:{round_}:{head}",
177
+ repo=repo,
178
+ pr=number,
179
+ round_=round_,
180
+ data=data,
181
+ ))
182
+ elif row.get("abandoned_at"):
183
+ out.append(_event(
184
+ "round.abandoned",
185
+ int(row["abandoned_at"]),
186
+ f"revloop:round.abandoned:{repo}:{number}:{round_}:{head}",
187
+ repo=repo,
188
+ pr=number,
189
+ round_=round_,
190
+ reason=row.get("last_error") or None,
191
+ data={"headSha": head},
192
+ ))
193
+ return out
194
+
195
+
196
+ def _capped_events(
197
+ rows: "list[dict]", spawns: "list[dict]"
198
+ ) -> "list[tuple[int, dict]]":
199
+ """`round.capped` from the escalation table (CR9 cap-outs).
200
+
201
+ The dedupe key folds the row's own `escalated_at` (PR #113 round 1,
202
+ major): `escalations` is keyed per head and REPLACEd in place, and a
203
+ re-cap-out on the SAME head is a designed path — an operator-granted
204
+ round consumed without an approve is a new decision, which is the whole
205
+ reason `loop.capout_kind` folds the granted total into its ping key. A
206
+ key without the stamp would make Studio swallow the re-cap-out as a
207
+ duplicate and under-report exactly the re-entry case. The stamp, not the
208
+ granted total, because the stamp is ON the row: re-deriving the same row
209
+ always yields the same key, while a granted total read at derive time
210
+ would re-key an old row during a backfill.
211
+
212
+ `escalations` carries no round of its own, so the round is read from the
213
+ newest spawn at or before `escalated_at` — the round in flight when THIS
214
+ cap-out was recorded, which stays true for a row backfilled long after
215
+ the PR moved on — and omitted when no spawn row qualifies."""
216
+ out = []
217
+ for row in rows:
218
+ repo, number = row["repo"], int(row["number"])
219
+ head = row["head_sha"] or ""
220
+ at = int(row["escalated_at"])
221
+ rounds = [
222
+ int(s["round"]) for s in spawns
223
+ if s["repo"] == repo and int(s["number"]) == number
224
+ and int(s["spawned_at"]) <= at
225
+ ]
226
+ out.append(_event(
227
+ "round.capped",
228
+ at,
229
+ f"revloop:round.capped:{repo}:{number}:{head}:{at}",
230
+ repo=repo,
231
+ pr=number,
232
+ round_=max(rounds) if rounds else None,
233
+ data={"headSha": head},
234
+ ))
235
+ return out
236
+
237
+
238
+ def _ping_events(
239
+ rows: "list[dict]", notices: "dict[tuple[str, int], dict]"
240
+ ) -> "list[tuple[int, dict]]":
241
+ """`stalled`, `stability.hold` and `checks.held` out of the ping ledger.
242
+
243
+ `kind` is free-form text carrying the episode identity, so it is parsed
244
+ by prefix; kinds this module does not report (`activity-deferred:`,
245
+ `capout:`, `checks-hold:`, `verdict-post-failed:` — each the dedupe of a
246
+ GitHub-side comment, not a fact of its own) derive nothing.
247
+
248
+ A stability event's payload is split by provenance, deliberately
249
+ (PR #113 round 1, minor). `data.headSha` and `data.grantsSeen` come from
250
+ the ping KIND itself (`stability:<head>:<base>:<granted>`, where the
251
+ granted total is what the guard had accounted for when it held), so they
252
+ are episode-correct even for a row backfilled long after. `data.rcRounds`
253
+ and `round` come from the `stability_notices` join, and that table is
254
+ REPLACEd per episode — so those two are CURRENT-AT-DERIVATION: a
255
+ backfilled episode-1 ping carries the newest episode's numbers, because
256
+ the ledger keeps nothing episode-scoped for them. Stated here so no
257
+ reader mistakes the join for an episode guarantee."""
258
+ out = []
259
+ for row in rows:
260
+ repo, number = row["repo"], int(row["number"])
261
+ kind, at = str(row["kind"]), int(row["pinged_at"])
262
+ if kind.startswith(STALLED_PREFIX):
263
+ session = kind[len(STALLED_PREFIX):]
264
+ out.append(_event(
265
+ "stalled",
266
+ at,
267
+ f"revloop:stalled:{repo}:{number}:{session}",
268
+ repo=repo,
269
+ pr=number,
270
+ session=session,
271
+ ))
272
+ elif kind.startswith(STABILITY_PREFIX):
273
+ tail = kind[len(STABILITY_PREFIX):]
274
+ data: dict[str, Any] = {}
275
+ parts = tail.split(":")
276
+ head = parts[0]
277
+ if head:
278
+ data["headSha"] = head
279
+ # The kind's own granted total — episode-correct, see above.
280
+ if len(parts) == 3 and parts[2].isdigit():
281
+ data["grantsSeen"] = int(parts[2])
282
+ notice = notices.get((repo, number))
283
+ if notice is not None:
284
+ data["rcRounds"] = int(notice["rc_rounds"])
285
+ out.append(_event(
286
+ "stability.hold",
287
+ at,
288
+ f"revloop:stability.hold:{repo}:{number}:{tail}",
289
+ repo=repo,
290
+ pr=number,
291
+ round_=(int(notice["round"]) if notice is not None else None),
292
+ data=data or None,
293
+ ))
294
+ elif kind.startswith(CHECKS_UNSETTLED_PREFIX):
295
+ tail = kind[len(CHECKS_UNSETTLED_PREFIX):]
296
+ round_str, _, head = tail.partition(":")
297
+ round_ = int(round_str) if round_str.isdigit() else None
298
+ out.append(_event(
299
+ "checks.held",
300
+ at,
301
+ f"revloop:checks.held:{repo}:{number}:{round_str}:{head}",
302
+ repo=repo,
303
+ pr=number,
304
+ round_=round_,
305
+ data={"headSha": head, "gate": "verdict"},
306
+ ))
307
+ return out
308
+
309
+
310
+ def _spawn_hold_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
311
+ """`checks.held` from the pre-spawn CI gate's in-flight holds.
312
+
313
+ Same kind as the verdict-side hold above — both mean "this round is
314
+ waiting on this head's checks" — but a distinct dedupe key (`:spawn`
315
+ suffix) and `data.gate`, because the two waits are different facts about
316
+ the same round and one must not swallow the other's event."""
317
+ out = []
318
+ for row in rows:
319
+ repo, number = row["repo"], int(row["number"])
320
+ round_, head = int(row["round"]), row["head_sha"] or ""
321
+ out.append(_event(
322
+ "checks.held",
323
+ int(row["first_at"]),
324
+ f"revloop:checks.held:{repo}:{number}:{round_}:{head}:spawn",
325
+ repo=repo,
326
+ pr=number,
327
+ round_=round_,
328
+ data={"headSha": head, "gate": "spawn"},
329
+ ))
330
+ return out
331
+
332
+
333
+ def _grant_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
334
+ return [
335
+ _event(
336
+ "grant",
337
+ int(row["granted_at"]),
338
+ f"revloop:grant:{row['repo']}:{row['number']}:{row['comment_id']}",
339
+ repo=row["repo"],
340
+ pr=int(row["number"]),
341
+ data={"author": row["author"], "rounds": int(row["rounds"])},
342
+ )
343
+ for row in rows
344
+ ]
345
+
346
+
347
+ def _reap_events(rows: "list[dict]") -> "list[tuple[int, dict]]":
348
+ # `reaped_at` folds into the key (PR #113 round 1, nit): `reaps` is
349
+ # REPLACEd per session name, and while names are nonce-unique per spawn
350
+ # today, the key should not depend on that holding forever — a re-reaped
351
+ # name is a new decision, and the stamp is on the row, so re-derivation
352
+ # stays deterministic.
353
+ return [
354
+ _event(
355
+ "reap",
356
+ int(row["reaped_at"]),
357
+ f"revloop:reap:{row['session']}:{int(row['reaped_at'])}",
358
+ session=row["session"],
359
+ )
360
+ for row in rows
361
+ ]
362
+
363
+
364
+ def derive_events(state: State, *, since: int = 0) -> "list[dict]":
365
+ """Every loop event the ledger implies whose stamp is >= `since`,
366
+ oldest first.
367
+
368
+ Inclusive on the boundary on purpose: two rows can share a second, and a
369
+ strictly-greater filter advanced to the first one's stamp would lose the
370
+ second forever, while inclusion merely re-sends a key the API dedupes.
371
+ """
372
+ spawns = state.read_spawns()
373
+ notices = {
374
+ (row["repo"], int(row["number"])): row
375
+ for row in state.read_stability_notices()
376
+ }
377
+
378
+ stamped: "list[tuple[int, dict]]" = []
379
+ stamped += _spawn_events(spawns)
380
+ stamped += _verdict_events(state.read_verdict_posts())
381
+ stamped += _capped_events(state.read_escalations(), spawns)
382
+ stamped += _ping_events(state.read_pings(), notices)
383
+ stamped += _spawn_hold_events(state.read_spawn_checks_holds())
384
+ stamped += _grant_events(state.read_grants())
385
+ stamped += _reap_events(state.read_reaps())
386
+
387
+ stamped = [(at, event) for at, event in stamped if at >= since]
388
+ stamped.sort(key=lambda pair: pair[0])
389
+ return [event for _, event in stamped]
390
+
391
+
392
+ class LoopEventsEmitter:
393
+ """The once-per-pass push, watermarked and best-effort.
394
+
395
+ Owned by the watcher when `loop_events_enabled` is on; `emit_once` is
396
+ called at the end of every poll pass and NEVER raises for an API or
397
+ transport condition — one WARN, the pass completes, and the un-advanced
398
+ watermark is the whole retry story (see the module docstring).
399
+ """
400
+
401
+ def __init__(self, state: State, client: AlissaClient):
402
+ self._state = state
403
+ self._client = client
404
+ # Epoch seconds of the newest ledger stamp successfully emitted.
405
+ # Zero until the first success, so a fresh process backfills.
406
+ self._since = 0
407
+ # The dedupe keys already sent at exactly the watermark second. The
408
+ # `since` filter is inclusive so a second row written in the boundary
409
+ # second can never be lost — this set is what stops the OTHER edge of
410
+ # that choice, the boundary row being re-posted every pass forever on
411
+ # a ledger that has stopped changing (PR #113 round 1, minor).
412
+ self._sent_at_watermark: "set[str]" = set()
413
+ # Latched True by a permanent auth failure — see emit_once. A
414
+ # process-lifetime latch on purpose: a restart is both how a fixed
415
+ # token takes effect and how the operator re-arms the emitter.
416
+ self._auth_failed = False
417
+
418
+ def emit_once(self) -> bool:
419
+ """Derive and post this pass's batch. True when everything landed
420
+ (an empty derivation is vacuous success).
421
+
422
+ An `AlissaAuthError` LATCHES the emitter off for the life of the
423
+ process (PR #113 round 1, minor): the taxonomy calls it permanent and
424
+ operator-fixable — a missing `ALISSA_API_TOKEN`, a token the ingest
425
+ 403s — so re-trying it would re-derive the whole ledger and write an
426
+ identical WARN every poll interval for as long as the daemon runs.
427
+ One WARN names the fix; later passes return at a DEBUG line, and the
428
+ restart that installs a corrected credential also re-arms this.
429
+ Transient errors keep the re-send behaviour — the watermark stays
430
+ put and the next pass retries.
431
+ """
432
+ if self._auth_failed:
433
+ log.debug(
434
+ "loop-events: disabled since an authentication failure — "
435
+ "restart the daemon after fixing the credential",
436
+ )
437
+ return False
438
+ try:
439
+ events = derive_events(self._state, since=self._since)
440
+ except Exception as exc:
441
+ # A derivation failure is a ledger read gone wrong — the same
442
+ # best-effort telemetry classification the snapshot writer has:
443
+ # warn and let the pass complete, never kill the poll over it.
444
+ log.warning(
445
+ "loop-events: derivation failed (%s: %s) — skipping this "
446
+ "pass's telemetry; the loop keeps polling",
447
+ type(exc).__name__, exc,
448
+ )
449
+ return False
450
+ events = [
451
+ e for e in events
452
+ if not (
453
+ int(e["at"]) // 1000 == self._since
454
+ and e["dedupeKey"] in self._sent_at_watermark
455
+ )
456
+ ]
457
+ if not events:
458
+ return True
459
+ sent = 0
460
+ try:
461
+ for start in range(0, len(events), MAX_EVENTS_PER_POST):
462
+ chunk = events[start:start + MAX_EVENTS_PER_POST]
463
+ result = self._client.post_loop_events(chunk)
464
+ sent += len(chunk)
465
+ log.debug(
466
+ "loop-events: posted %d event(s) (accepted=%s, "
467
+ "duplicates=%s)",
468
+ len(chunk), result.get("accepted"),
469
+ result.get("duplicates"),
470
+ )
471
+ except AlissaAuthError as exc:
472
+ self._auth_failed = True
473
+ log.warning(
474
+ "loop-events: authentication failed (%s) — this is permanent "
475
+ "and operator-fixable (set/rotate the token the emitter's "
476
+ "client reads, then restart the daemon); loop telemetry is "
477
+ "now off for this process, the loop keeps polling",
478
+ exc,
479
+ )
480
+ return False
481
+ except AlissaError as exc:
482
+ # ONE warn per failed pass, naming how far it got; the watermark
483
+ # stays put so the next pass re-derives and re-sends, and the
484
+ # deterministic keys make the overlap land as duplicates.
485
+ log.warning(
486
+ "loop-events: post failed after %d of %d event(s) (%s) — "
487
+ "telemetry is best-effort, the pass completes; the next "
488
+ "pass re-sends (idempotent keys)",
489
+ sent, len(events), exc,
490
+ )
491
+ return False
492
+ newest = max(int(event["at"]) // 1000 for event in events)
493
+ boundary_keys = {
494
+ e["dedupeKey"] for e in events if int(e["at"]) // 1000 == newest
495
+ }
496
+ if newest == self._since:
497
+ # The watermark did not move (a same-second newcomer): the set
498
+ # GROWS, because the earlier boundary sends are still boundary.
499
+ self._sent_at_watermark |= boundary_keys
500
+ else:
501
+ self._since = newest
502
+ self._sent_at_watermark = boundary_keys
503
+ log.info("loop-events: %d event(s) pushed", len(events))
504
+ return True
505
+
506
+
507
+ def build_emitter(
508
+ state: State, *, endpoint: "str | None" = None
509
+ ) -> LoopEventsEmitter:
510
+ """The emitter the watcher wires in when `loop_events_enabled` is on.
511
+
512
+ A seam, so tests build emitters over fake clients while the watcher's
513
+ call stays one line. The token comes from the environment inside
514
+ `AlissaClient` (the CLI's own `ALISSA_API_TOKEN`); its absence surfaces
515
+ as the emitter's WARN, never at construction — a daemon must boot and
516
+ poll whether or not telemetry can authenticate.
517
+ """
518
+ return LoopEventsEmitter(state, AlissaClient(base=endpoint))
@@ -144,6 +144,13 @@ CREATE TABLE IF NOT EXISTS verdict_posts (
144
144
  -- waited one bound when it waited two. NULL until (and unless) the promotion
145
145
  -- happens; the bound then reads `checks_pending_at or checks_held_at`.
146
146
  checks_pending_at INTEGER,
147
+ -- WHAT the round's verdict was ('approve' | 'request_changes'), stamped
148
+ -- when the post lands (issue #112). The ledger did not need it -- GitHub's
149
+ -- reviews list answers "what was decided" -- but the loop-events emitter
150
+ -- derives `round.verdict` from THIS table at end of pass, long after the
151
+ -- envelope read that knew the answer. NULL on rows that predate the
152
+ -- column; the emitter omits the field rather than inventing one.
153
+ verdict TEXT,
147
154
  review_url TEXT,
148
155
  last_error TEXT,
149
156
  PRIMARY KEY (repo, number, round)
@@ -308,6 +315,7 @@ _ADDED_COLUMNS = {
308
315
  ("checks_held_at", "INTEGER"),
309
316
  ("checks_held_state", "TEXT"),
310
317
  ("checks_pending_at", "INTEGER"),
318
+ ("verdict", "TEXT"),
311
319
  ),
312
320
  }
313
321
 
@@ -779,21 +787,79 @@ class State:
779
787
  (repo, number),
780
788
  ).fetchone()
781
789
 
782
- def read_grants(self, repo: str, number: int) -> list[dict]:
783
- """One PR's grant rows, newest first (like every reader here).
784
-
785
- Deliberately narrow: no unfiltered form and no `limit` until something
786
- needs them. The console does not read this table yet -- showing "this
787
- PR was re-entered by @x" in the operator inbox wants rendering as well
788
- as data, so it lands as its own change rather than as unused
789
- parameters here.
790
+ def read_grants(
791
+ self, repo: "str | None" = None, number: "int | None" = None
792
+ ) -> list[dict]:
793
+ """Grant rows, newest first (like every reader here) one PR's when
794
+ `repo`/`number` are given, every PR's when they are not.
795
+
796
+ The unfiltered form exists for the loop-events emitter (issue #112),
797
+ which derives one `grant` event per honoured ack: grants are the
798
+ rarest rows in this ledger (one per operator comment, ever), so the
799
+ full read is bounded by how often a human types the ack grammar. The
800
+ per-PR form keeps its original callers unchanged.
801
+
802
+ A PARTIAL filter is refused (PR #113 round 2, nit): `repo` without
803
+ `number` would otherwise fall through to the unfiltered read and
804
+ answer with every PR's grants — a superset, not an error, which is
805
+ the quiet kind of wrong. The paired and unfiltered forms are the
806
+ only two forms.
790
807
  """
791
- return self._read_rows(
808
+ if (repo is None) != (number is None):
809
+ raise ValueError(
810
+ "read_grants takes repo AND number (one PR) or neither "
811
+ "(every PR) — a partial filter would silently read every PR"
812
+ )
813
+ sql = (
792
814
  "SELECT repo, number, comment_id, author, rounds, granted_at "
793
- "FROM grants WHERE repo=? AND number=? "
794
- "ORDER BY granted_at DESC, rowid DESC",
815
+ "FROM grants"
816
+ )
817
+ params: tuple = ()
818
+ if repo is not None and number is not None:
819
+ sql += " WHERE repo=? AND number=?"
820
+ params = (repo, number)
821
+ sql += " ORDER BY granted_at DESC, rowid DESC"
822
+ return self._read_rows(sql, None, params)
823
+
824
+ # -- loop-events readers (issue #112) ----------------------------------
825
+ #
826
+ # Full-table reads for the telemetry emitter, which derives events from
827
+ # the ledger at end of pass and filters by timestamp itself. Each is a
828
+ # plain projection, newest first like every reader here; none takes a
829
+ # `limit`, because the emitter's bound is its own watermark and a SQL
830
+ # truncation would silently drop exactly the oldest rows a fresh daemon's
831
+ # backfill exists to send.
832
+
833
+ def read_reaps(self) -> list[dict]:
834
+ """Reap rows (one per session the sweep killed), newest first."""
835
+ return self._read_rows(
836
+ "SELECT session, reaped_at FROM reaps "
837
+ "ORDER BY reaped_at DESC, rowid DESC",
838
+ None,
839
+ )
840
+
841
+ def read_stability_notices(self) -> list[dict]:
842
+ """Stability-notice rows (one per PR, the guard's memory), newest
843
+ first. The emitter joins these onto `stability:` pings for the
844
+ rcRounds/grantsSeen payload; the row is REPLACEd per episode, so the
845
+ join reads the newest episode's numbers — which is also what the
846
+ guard itself decides from."""
847
+ return self._read_rows(
848
+ "SELECT repo, number, round, rc_rounds, grants_seen, noticed_at "
849
+ "FROM stability_notices ORDER BY noticed_at DESC, number DESC",
850
+ None,
851
+ )
852
+
853
+ def read_spawn_checks_holds(self) -> list[dict]:
854
+ """Pre-spawn CI holds currently in the ledger, newest first. Rows are
855
+ deleted when their wait ends (see `clear_spawn_checks_hold`), so this
856
+ is the waits in flight plus the residue of rounds that never started
857
+ — the emitter reports a hold while it exists, and a hold that begins
858
+ and ends between two passes is simply not observed (best-effort)."""
859
+ return self._read_rows(
860
+ "SELECT repo, number, round, head_sha, first_at "
861
+ "FROM spawn_checks_holds ORDER BY first_at DESC, number DESC",
795
862
  None,
796
- (repo, number),
797
863
  )
798
864
 
799
865
  # -- native verdict posts ----------------------------------------------
@@ -1034,16 +1100,24 @@ class State:
1034
1100
  return int(row["n"]) if row else 0
1035
1101
 
1036
1102
  def record_verdict_post(
1037
- self, repo: str, number: int, round_: int, review_url: str
1103
+ self, repo: str, number: int, round_: int, review_url: str,
1104
+ verdict: "str | None" = None,
1038
1105
  ) -> None:
1039
1106
  """Mark the round's native verdict as landed. Bookkeeping and evidence
1040
1107
  only: GitHub's reviews list stays the authority on whether a native
1041
1108
  verdict exists, so a row lost behind the daemon's back costs one
1042
- duplicate post, never a round that silently counts as closed."""
1109
+ duplicate post, never a round that silently counts as closed.
1110
+
1111
+ `verdict` is the round's parsed envelope verdict ('approve' or
1112
+ 'request_changes'), remembered for the loop-events emitter (issue
1113
+ #112), which reports rounds long after the envelope read that knew the
1114
+ answer. Optional so existing callers and tests keep their shape; None
1115
+ leaves the column NULL and the emitter omits the field."""
1043
1116
  self._db.execute(
1044
- "UPDATE verdict_posts SET posted_at = ?, review_url = ?, last_error = NULL "
1117
+ "UPDATE verdict_posts SET posted_at = ?, review_url = ?, "
1118
+ "verdict = ?, last_error = NULL "
1045
1119
  "WHERE repo=? AND number=? AND round=?",
1046
- (int(time.time()), review_url, repo, number, round_),
1120
+ (int(time.time()), review_url, verdict, repo, number, round_),
1047
1121
  )
1048
1122
  self._db.commit()
1049
1123
 
@@ -1052,7 +1126,8 @@ class State:
1052
1126
  return self._read_rows(
1053
1127
  "SELECT repo, number, round, first_seen_at, head_sha, attempts, "
1054
1128
  "last_attempt_at, posted_at, abandoned_at, checks_held_at, "
1055
- "checks_held_state, checks_pending_at, review_url, last_error "
1129
+ "checks_held_state, checks_pending_at, verdict, review_url, "
1130
+ "last_error "
1056
1131
  "FROM verdict_posts "
1057
1132
  "ORDER BY first_seen_at DESC, number DESC",
1058
1133
  limit,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.27.0
3
+ Version: 0.28.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -7,9 +7,11 @@ setup.py
7
7
  src/main/alissa/tools/github/revloop/__init__.py
8
8
  src/main/alissa/tools/github/revloop/__main__.py
9
9
  src/main/alissa/tools/github/revloop/alissa.py
10
+ src/main/alissa/tools/github/revloop/alissa_client.py
10
11
  src/main/alissa/tools/github/revloop/config.py
11
12
  src/main/alissa/tools/github/revloop/ghclient.py
12
13
  src/main/alissa/tools/github/revloop/loop.py
14
+ src/main/alissa/tools/github/revloop/loop_events.py
13
15
  src/main/alissa/tools/github/revloop/proc.py
14
16
  src/main/alissa/tools/github/revloop/prreview.py
15
17
  src/main/alissa/tools/github/revloop/state.py