alissa-tools-github-revloop 0.26.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.26.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.26.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.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/config.py +123 -6
  5. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/loop.py +56 -8
  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.26.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.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +6 -4
  10. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/page.py +63 -11
  11. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/sources.py +181 -18
  12. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  13. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +2 -0
  14. alissa_tools_github_revloop-0.26.0/src/main/alissa/tools/github/revloop/version +0 -1
  15. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/LICENSE +0 -0
  16. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/MANIFEST.in +0 -0
  17. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/NOTICE +0 -0
  18. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/README.md +0 -0
  19. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/requirements.txt +0 -0
  20. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/setup.cfg +0 -0
  21. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/setup.py +0 -0
  22. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  24. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  25. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  26. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  27. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  28. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  29. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  30. {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  31. {alissa_tools_github_revloop-0.26.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.26.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.26.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.26.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.26.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
 
@@ -1523,10 +1524,11 @@ class StabilityNotice:
1523
1524
  The bookkeeping travels WITH the text rather than being written when the
1524
1525
  gate decides, because the two are not the same event: the gate runs above
1525
1526
  the CI gate and `_ensure_hub`, either of which can still refuse the round.
1526
- Writing `lifts` there would spend an operator's re-entry grant on a round
1527
- that was never queued -- and the grant is the only thing that can lift the
1528
- hold, so spending one silently is the one bookkeeping error this guard
1529
- cannot afford.
1527
+ Writing `grants_seen` there would spend an operator's re-entry grant on a
1528
+ round that was never queued -- and the grant is the only thing that can lift
1529
+ the hold, so spending one silently is the one bookkeeping error this guard
1530
+ cannot afford. The seed strengthens that: a gate-time write would also seed
1531
+ a fresh episode's ledger off a round the reviewer never received.
1530
1532
  """
1531
1533
 
1532
1534
  text: str
@@ -1603,6 +1605,14 @@ class ReviewWatcher:
1603
1605
  task_list_bow_id=config.task_list_bow_id,
1604
1606
  )
1605
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
+ )
1606
1616
  # (repo, number, comment id) of every re-entry directive already
1607
1617
  # refused in this process -- see _log_ignored_ack.
1608
1618
  self._ignored_acks: set[tuple[str, int, int]] = set()
@@ -2319,9 +2329,19 @@ class ReviewWatcher:
2319
2329
  # capped out at 10, was granted +5, and went tests-only at round 13
2320
2330
  # would get five "grace" rounds and no hold -- the guard disarmed on
2321
2331
  # exactly the shape issue #105 cites (PR #106 round 1, minor). After the
2322
- # seed the predicate means what it should: only an ack posted AFTER this
2323
- # guard last spoke can lift the hold. Either way the write happens only
2324
- # once `_spawn` has actually queued the round.
2332
+ # seed the predicate means what it should: only an ack THE LEDGER DID
2333
+ # NOT ALREADY KNOW ABOUT can lift the hold. Deliberately not "posted
2334
+ # after this guard last spoke", which claims more than the code
2335
+ # enforces -- the seed reads `granted_rounds`, i.e. acks already
2336
+ # collected into state, and `_collect_acks` runs from exactly two places
2337
+ # (the cap-out branch and the `graced` branch here). So an ack posted
2338
+ # while the loop is below its cap with no episode open is not recorded
2339
+ # when it is posted; it is first discovered by a later `graced` scan and
2340
+ # lifts the hold despite predating the notice. That is the doctrine
2341
+ # working -- nothing consumed that grant, so it is genuinely unspent,
2342
+ # and it errs toward extra rounds rather than a false hold (PR #106
2343
+ # round 2, nit). Either way the write happens only once `_spawn` has
2344
+ # actually queued the round.
2325
2345
  notice = STABILITY_NOTICE.format(
2326
2346
  base=base[:8],
2327
2347
  head=pr.head_sha[:8],
@@ -2885,7 +2905,9 @@ class ReviewWatcher:
2885
2905
  else f" — CI gate: the rollup at {judged[:8]} is {gate.state}, so the "
2886
2906
  f"{verdict} envelope did not post as an APPROVE"
2887
2907
  )
2888
- 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
+ )
2889
2911
  log.info(
2890
2912
  "%s round %d closed: native %s review submitted as %s (%s)%s",
2891
2913
  pr.slug, round_, event, self.github.login, url or "no url", gate_note,
@@ -4799,8 +4821,34 @@ class ReviewWatcher:
4799
4821
  self._write_snapshot(
4800
4822
  results, reaped, duration_ms=int((time.monotonic() - started) * 1000)
4801
4823
  )
4824
+ self._emit_loop_events()
4802
4825
  return results
4803
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
+
4804
4852
  def _note_ledger_unwritable(self) -> None:
4805
4853
  """Report a pass refused because the ledger cannot record it.
4806
4854