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.
- {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
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/__main__.py +28 -0
- alissa_tools_github_revloop-0.28.0/src/main/alissa/tools/github/revloop/alissa_client.py +191 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/config.py +123 -6
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/loop.py +56 -8
- alissa_tools_github_revloop-0.28.0/src/main/alissa/tools/github/revloop/loop_events.py +518 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/state.py +92 -17
- alissa_tools_github_revloop-0.28.0/src/main/alissa/tools/github/revloop/version +1 -0
- {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
- {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
- {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
- {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
- {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
- alissa_tools_github_revloop-0.26.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/README.md +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/setup.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.26.0 → alissa_tools_github_revloop-0.28.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
|
@@ -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
|
-
|
|
10
|
-
(`ALISSA_REVIEW_TASK_BOW`, see `env_task_list_bow_id`)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 `
|
|
1527
|
-
that was never queued -- and the grant is the only thing that can lift
|
|
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
|
|
2323
|
-
#
|
|
2324
|
-
#
|
|
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(
|
|
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
|
|