alissa-tools-github-revloop 0.29.0__tar.gz → 0.30.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.29.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.30.0}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/__main__.py +19 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/alissa_client.py +25 -2
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/config.py +56 -13
- alissa_tools_github_revloop-0.30.0/src/main/alissa/tools/github/revloop/fleet_vitals.py +504 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/loop.py +57 -3
- alissa_tools_github_revloop-0.30.0/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/sources.py +58 -9
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +19 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +1 -0
- alissa_tools_github_revloop-0.29.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/README.md +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/setup.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/state.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.29.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -13,6 +13,7 @@ from .alissa_client import AlissaClient, AlissaError
|
|
|
13
13
|
from .config import (
|
|
14
14
|
BOW_OWNERS_ENV,
|
|
15
15
|
BOWS_REFRESH_POLLS_ENV,
|
|
16
|
+
FLEET_VITALS_ENV,
|
|
16
17
|
HUB_ADD,
|
|
17
18
|
HUB_SKIP,
|
|
18
19
|
LOOP_EVENTS_ENV,
|
|
@@ -253,6 +254,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
253
254
|
action="store_false",
|
|
254
255
|
help="do not push loop telemetry even if the config enables it",
|
|
255
256
|
)
|
|
257
|
+
vitals = over.add_mutually_exclusive_group()
|
|
258
|
+
vitals.add_argument(
|
|
259
|
+
"--fleet-vitals",
|
|
260
|
+
dest="fleet_vitals_enabled",
|
|
261
|
+
action="store_true",
|
|
262
|
+
default=None,
|
|
263
|
+
help="push one fleet-vitals snapshot (heartbeat, sessions, inbox, "
|
|
264
|
+
"rate, memory) to Studio's POST /v1/loop/fleet-vitals at the end of "
|
|
265
|
+
"every completed poll pass — best-effort, never fatal. "
|
|
266
|
+
f"Overridden by ${FLEET_VITALS_ENV}",
|
|
267
|
+
)
|
|
268
|
+
vitals.add_argument(
|
|
269
|
+
"--no-fleet-vitals",
|
|
270
|
+
dest="fleet_vitals_enabled",
|
|
271
|
+
action="store_false",
|
|
272
|
+
help="do not push fleet vitals even if the config enables it",
|
|
273
|
+
)
|
|
256
274
|
over.add_argument(
|
|
257
275
|
"--alissa-endpoint",
|
|
258
276
|
dest="alissa_endpoint",
|
|
@@ -307,6 +325,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
307
325
|
"task_list_self_scope": args.task_list_self_scope,
|
|
308
326
|
"task_list_bow_id": args.task_list_bow_id,
|
|
309
327
|
"loop_events_enabled": args.loop_events_enabled,
|
|
328
|
+
"fleet_vitals_enabled": args.fleet_vitals_enabled,
|
|
310
329
|
"alissa_endpoint": args.alissa_endpoint,
|
|
311
330
|
"dry_run": args.dry_run,
|
|
312
331
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
"""Alissa REST access — loop telemetry's
|
|
2
|
-
reads `repos_source: bows` needs
|
|
1
|
+
"""Alissa REST access — loop telemetry's write (issue #112), the fleet-vitals
|
|
2
|
+
write beside it (issue #126) and the two reads `repos_source: bows` needs
|
|
3
|
+
(issue #119).
|
|
3
4
|
|
|
4
5
|
This is the SECOND Alissa adapter in the package, and the split is deliberate.
|
|
5
6
|
`alissa.py` shells out to the `alissa` CLI, which is the daemon's established
|
|
@@ -62,6 +63,12 @@ ENV_TOKEN = "ALISSA_API_TOKEN"
|
|
|
62
63
|
# rather than silently posting a request the API will 400.
|
|
63
64
|
MAX_EVENTS_PER_POST = 200
|
|
64
65
|
|
|
66
|
+
# The `error` code a 403 carries when the token's user has not activated the
|
|
67
|
+
# stacked app that owns the loop ingest. Advisory (classification keys on the
|
|
68
|
+
# status), but the vitals pusher reads it to warn ONCE with the activate URL
|
|
69
|
+
# instead of once per pass.
|
|
70
|
+
NOT_ACTIVATED = "not_activated"
|
|
71
|
+
|
|
65
72
|
|
|
66
73
|
class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
|
|
67
74
|
"""Refuse every redirect instead of following it (PR #113 round 1).
|
|
@@ -246,6 +253,22 @@ class AlissaClient:
|
|
|
246
253
|
payload = self._request("/v1/loop-events", {"events": events})
|
|
247
254
|
return payload if isinstance(payload, dict) else {}
|
|
248
255
|
|
|
256
|
+
def post_fleet_vitals(self, snapshot: dict) -> dict:
|
|
257
|
+
"""Replace this seat's fleet-vitals snapshot (`POST
|
|
258
|
+
/v1/loop/fleet-vitals`, issue #126).
|
|
259
|
+
|
|
260
|
+
One body, one seat, replaced on every call — there is no batching
|
|
261
|
+
and no dedupe key, because the API keeps only the LATEST snapshot per
|
|
262
|
+
user×seat (loop events are the history). Returns the API's
|
|
263
|
+
`{"seat", "receivedAt", "replaced"}` payload (empty dict when the
|
|
264
|
+
body was empty), for the caller's debug line. The API is strict —
|
|
265
|
+
unknown keys 400, lists cap at 50, the body at 64 KB — and the
|
|
266
|
+
BUILDER (`fleet_vitals`) owns fitting the snapshot to that, so this
|
|
267
|
+
method sends exactly what it is handed.
|
|
268
|
+
"""
|
|
269
|
+
payload = self._request("/v1/loop/fleet-vitals", snapshot)
|
|
270
|
+
return payload if isinstance(payload, dict) else {}
|
|
271
|
+
|
|
249
272
|
def ping(self) -> Identity:
|
|
250
273
|
"""The identity this token acts as (`GET /v1/ping`).
|
|
251
274
|
|
|
@@ -15,6 +15,9 @@ site. `loop_events_enabled` (`ALISSA_REV_LOOP_EVENTS_ENABLED`, see
|
|
|
15
15
|
`env_loop_events_enabled`) has it because a container deployment toggles
|
|
16
16
|
telemetry with one variable and no config-file edit (issue #112); the env wins
|
|
17
17
|
over both other layers so the two env-backed keys share one precedence story.
|
|
18
|
+
`fleet_vitals_enabled` (`ALISSA_REV_FLEET_VITALS_ENABLED`, see
|
|
19
|
+
`env_fleet_vitals_enabled`) is the second Studio push and rides the same rail
|
|
20
|
+
through the same boolean reader (issue #126).
|
|
18
21
|
The three `repos_source: bows` keys (issue #119) ride the same rail for the
|
|
19
22
|
same container reason: `repos_source` (`ALISSA_REVIEW_REPOS_SOURCE`),
|
|
20
23
|
`bows_refresh_polls` (`ALISSA_REVIEW_BOWS_REFRESH_POLLS`) and `bow_owners`
|
|
@@ -191,6 +194,7 @@ CONFIG_KEYS = (
|
|
|
191
194
|
"task_list_self_scope",
|
|
192
195
|
"task_list_bow_id",
|
|
193
196
|
"loop_events_enabled",
|
|
197
|
+
"fleet_vitals_enabled",
|
|
194
198
|
"alissa_endpoint",
|
|
195
199
|
"dry_run",
|
|
196
200
|
)
|
|
@@ -206,6 +210,11 @@ TASK_LIST_BOW_ENV = "ALISSA_REVIEW_TASK_BOW"
|
|
|
206
210
|
# module docstring for the shared precedence story.
|
|
207
211
|
LOOP_EVENTS_ENV = "ALISSA_REV_LOOP_EVENTS_ENABLED"
|
|
208
212
|
|
|
213
|
+
# The environment variable toggling the fleet-vitals push (issue #126): one
|
|
214
|
+
# snapshot of this daemon's live state per completed pass, to Studio's
|
|
215
|
+
# `POST /v1/loop/fleet-vitals`. Same rail and same reader as LOOP_EVENTS_ENV.
|
|
216
|
+
FLEET_VITALS_ENV = "ALISSA_REV_FLEET_VITALS_ENABLED"
|
|
217
|
+
|
|
209
218
|
# The three `repos_source: bows` rails (issue #119). Same precedence as the two
|
|
210
219
|
# above -- env > file > flag -- and the same blank-falls-through rule, because
|
|
211
220
|
# the container bakes every knob as an ENV that renders `""` when unset.
|
|
@@ -258,18 +267,21 @@ def _validate_alissa_endpoint(endpoint: str) -> str:
|
|
|
258
267
|
)
|
|
259
268
|
|
|
260
269
|
|
|
261
|
-
def
|
|
262
|
-
environ: "Mapping[str, str] | None" = None,
|
|
270
|
+
def _env_bool(
|
|
271
|
+
name: str, environ: "Mapping[str, str] | None" = None,
|
|
263
272
|
) -> "bool | None":
|
|
264
|
-
"""
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
273
|
+
"""One boolean env rail: True/False, or None when unset.
|
|
274
|
+
|
|
275
|
+
Shared by every boolean the environment can set (`LOOP_EVENTS_ENV`,
|
|
276
|
+
`FLEET_VITALS_ENV`) so the accepted spellings and the refusal are one
|
|
277
|
+
rule, not two copies that drift (issue #126). None and empty are the SAME
|
|
278
|
+
answer — an exported-but-empty variable is how a container renders
|
|
279
|
+
"unset" (the Dockerfile bakes empty ENV defaults), and it must fall
|
|
280
|
+
through to the file/CLI layers rather than read as false. A non-boolean
|
|
281
|
+
spelling raises, naming the variable: the startup phase turns a
|
|
282
|
+
ValueError into `config error` + exit 2, which is where a typo belongs.
|
|
271
283
|
"""
|
|
272
|
-
raw = (os.environ if environ is None else environ).get(
|
|
284
|
+
raw = (os.environ if environ is None else environ).get(name)
|
|
273
285
|
value = (raw or "").strip().lower()
|
|
274
286
|
if not value:
|
|
275
287
|
return None
|
|
@@ -278,11 +290,27 @@ def env_loop_events_enabled(
|
|
|
278
290
|
if value in _ENV_FALSE:
|
|
279
291
|
return False
|
|
280
292
|
raise ValueError(
|
|
281
|
-
f"{
|
|
293
|
+
f"{name} must be a boolean "
|
|
282
294
|
f"(1/0, true/false, yes/no, on/off), got {raw!r}"
|
|
283
295
|
)
|
|
284
296
|
|
|
285
297
|
|
|
298
|
+
def env_loop_events_enabled(
|
|
299
|
+
environ: "Mapping[str, str] | None" = None,
|
|
300
|
+
) -> "bool | None":
|
|
301
|
+
"""The loop-events toggle from the environment, or None when unset —
|
|
302
|
+
`_env_bool` over LOOP_EVENTS_ENV."""
|
|
303
|
+
return _env_bool(LOOP_EVENTS_ENV, environ)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def env_fleet_vitals_enabled(
|
|
307
|
+
environ: "Mapping[str, str] | None" = None,
|
|
308
|
+
) -> "bool | None":
|
|
309
|
+
"""The fleet-vitals toggle from the environment, or None when unset —
|
|
310
|
+
`_env_bool` over FLEET_VITALS_ENV (issue #126)."""
|
|
311
|
+
return _env_bool(FLEET_VITALS_ENV, environ)
|
|
312
|
+
|
|
313
|
+
|
|
286
314
|
def env_task_list_bow_id(environ: "Mapping[str, str] | None" = None) -> "str | None":
|
|
287
315
|
"""The review BOW id from the environment, or None when it is not set.
|
|
288
316
|
|
|
@@ -786,6 +814,15 @@ class Config:
|
|
|
786
814
|
# when on: a failed push is one WARN and the pass completes.
|
|
787
815
|
loop_events_enabled: bool = False
|
|
788
816
|
|
|
817
|
+
# Whether the loop pushes one FLEET-VITALS snapshot — heartbeat, poll
|
|
818
|
+
# durations, the reviewer-session roster, rate, memory, queue depth, the
|
|
819
|
+
# operator inbox — to Studio's `POST /v1/loop/fleet-vitals` at the end
|
|
820
|
+
# of every completed pass (issue #126), so a Factory with no console URL
|
|
821
|
+
# for this seat can still render its card. OFF by default for the same
|
|
822
|
+
# reason `loop_events_enabled` is; toggled by FLEET_VITALS_ENV above the
|
|
823
|
+
# file and the CLI; best-effort when on (one WARN per failed pass).
|
|
824
|
+
fleet_vitals_enabled: bool = False
|
|
825
|
+
|
|
789
826
|
# The Alissa/Studio API base the loop-events client posts to. One knob so
|
|
790
827
|
# a staging deployment can point telemetry somewhere else; everything else
|
|
791
828
|
# about the client (its bearer token) comes from the environment.
|
|
@@ -884,8 +921,8 @@ class Config:
|
|
|
884
921
|
"not specified on the CLI" and fall through to the file / defaults.
|
|
885
922
|
|
|
886
923
|
`environ` is the fourth layer and applies to the env-backed keys
|
|
887
|
-
(`task_list_bow_id`, `loop_events_enabled`, `
|
|
888
|
-
`bows_refresh_polls`, `bow_owners`); it wins over both of the others
|
|
924
|
+
(`task_list_bow_id`, `loop_events_enabled`, `fleet_vitals_enabled`,
|
|
925
|
+
`repos_source`, `bows_refresh_polls`, `bow_owners`); it wins over both of the others
|
|
889
926
|
(see the module docstring). Defaults to the real environment, so
|
|
890
927
|
callers that do not care pass nothing.
|
|
891
928
|
"""
|
|
@@ -941,6 +978,11 @@ class Config:
|
|
|
941
978
|
env_events = env_loop_events_enabled(environ)
|
|
942
979
|
if env_events is not None:
|
|
943
980
|
raw["loop_events_enabled"] = env_events
|
|
981
|
+
# ...and the fleet-vitals toggle (issue #126), the same rail through
|
|
982
|
+
# the same reader.
|
|
983
|
+
env_vitals = env_fleet_vitals_enabled(environ)
|
|
984
|
+
if env_vitals is not None:
|
|
985
|
+
raw["fleet_vitals_enabled"] = env_vitals
|
|
944
986
|
|
|
945
987
|
# The three bows rails (issue #119), same layer and same reason: the
|
|
946
988
|
# container hands the daemon its mode with one variable. Each is
|
|
@@ -1175,6 +1217,7 @@ class Config:
|
|
|
1175
1217
|
task_list_self_scope=bool(raw.get("task_list_self_scope", False)),
|
|
1176
1218
|
task_list_bow_id=bow_id,
|
|
1177
1219
|
loop_events_enabled=bool(raw.get("loop_events_enabled", False)),
|
|
1220
|
+
fleet_vitals_enabled=bool(raw.get("fleet_vitals_enabled", False)),
|
|
1178
1221
|
alissa_endpoint=endpoint,
|
|
1179
1222
|
dry_run=bool(raw.get("dry_run", False)),
|
|
1180
1223
|
)
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Fleet vitals: one snapshot of this daemon's live state per completed poll
|
|
2
|
+
pass, pushed to Studio's `POST /v1/loop/fleet-vitals` (issue #126).
|
|
3
|
+
|
|
4
|
+
The Factory's `/fleet` screen PULLS each daemon console's `/api/state` over
|
|
5
|
+
private networking, with passcodes held on the hosted Factory -- which is
|
|
6
|
+
per-deployment by construction, so a Dark Factory customer running their own
|
|
7
|
+
revloop sees `not_configured`. Studio (TASK-182095034) now also accepts one
|
|
8
|
+
PUSHED snapshot per user×seat, replaced on every POST and rendered whenever
|
|
9
|
+
the Factory has no console URL for the seat. This module is the revloop half
|
|
10
|
+
of that: opt-in (`fleet_vitals_enabled` / `ALISSA_REV_FLEET_VITALS_ENABLED`),
|
|
11
|
+
in the same posture as loop events -- best-effort, never fatal, honours
|
|
12
|
+
dry-run -- and pushed at the end of every completed pass, AFTER the
|
|
13
|
+
loop-events batch.
|
|
14
|
+
|
|
15
|
+
Design rules, all load-bearing:
|
|
16
|
+
|
|
17
|
+
* **Built in-process from the console's own builders.** The snapshot is the
|
|
18
|
+
reviewer console's data layer (`webui.sources.Sources` -- the LIVE half of
|
|
19
|
+
the inbox, the session roster, the cached rate and drift reads -- and
|
|
20
|
+
`webui.sysinfo` for the cgroup memory split) mapped onto the wire
|
|
21
|
+
vocabulary, so the Factory card and the console can never disagree about
|
|
22
|
+
what the operator owes. The console SIDECAR is not required to be running:
|
|
23
|
+
the daemon holds its own `Sources` over its own config.
|
|
24
|
+
|
|
25
|
+
* **Built to the contract EXACTLY.** The API is strict -- an unknown key is a
|
|
26
|
+
400, `sessionList` and `inbox` cap at 50 entries, the whole body at 64 KB --
|
|
27
|
+
so the builder emits only the contract's keys, caps every list here, and
|
|
28
|
+
trims the two lists until the body fits (`fit_body`). A snapshot the API
|
|
29
|
+
refuses is worth nothing, so fitting is the builder's job, not the API's.
|
|
30
|
+
|
|
31
|
+
* **`null` means "could not read", never `{0, 0}`.** A roster `alissa tmux
|
|
32
|
+
ls` could not list is `sessions: null` / `sessionList: null`; a memory
|
|
33
|
+
split off a host without cgroup v2 is `memory: null`. A Factory card that
|
|
34
|
+
read "0 live" over a broken tmux would be the reassuring-direction error,
|
|
35
|
+
and every other read here follows the same rule.
|
|
36
|
+
|
|
37
|
+
* **One push, no retry queue.** The API keeps only the latest snapshot per
|
|
38
|
+
seat, so a failed push is simply replaced by the next pass's -- there is
|
|
39
|
+
nothing to re-send. A failure is ONE WARNING naming the status and error;
|
|
40
|
+
the pass completes. The one refinement: a `403 not_activated` (the token's
|
|
41
|
+
user has not activated the loop app) warns ONCE per boot with the activate
|
|
42
|
+
URL and logs at DEBUG after, because it is a fixed condition the operator
|
|
43
|
+
resolves out of band, and the next pass after activation lands on its own.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import json
|
|
49
|
+
import logging
|
|
50
|
+
import time
|
|
51
|
+
from datetime import datetime, timezone
|
|
52
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
53
|
+
|
|
54
|
+
from .alissa import parse_session_name, session_repo_slug
|
|
55
|
+
from .alissa_client import (
|
|
56
|
+
NOT_ACTIVATED,
|
|
57
|
+
AlissaAuthError,
|
|
58
|
+
AlissaClient,
|
|
59
|
+
AlissaError,
|
|
60
|
+
)
|
|
61
|
+
from .config import Config
|
|
62
|
+
from .proc import CommandError, run as proc_run
|
|
63
|
+
|
|
64
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
65
|
+
from .webui.sources import Sources
|
|
66
|
+
|
|
67
|
+
log = logging.getLogger(__name__)
|
|
68
|
+
|
|
69
|
+
SEAT = "revloop"
|
|
70
|
+
SCHEMA_VERSION = 1
|
|
71
|
+
# The edge this daemon's sessions run: the Factory's vocabulary for "a
|
|
72
|
+
# reviewer round" (devloop's is `develop`).
|
|
73
|
+
EDGE = "review"
|
|
74
|
+
# The API's per-list cap (`sessionList`, `inbox`) and its body cap. Mirrors
|
|
75
|
+
# Studio's FLEET_VITALS_LIST_CAP / FLEET_VITALS_BODY_MAX; both are the wire
|
|
76
|
+
# contract, so the builder enforces them before sending rather than learning
|
|
77
|
+
# them from a 400.
|
|
78
|
+
LIST_CAP = 50
|
|
79
|
+
BODY_MAX_BYTES = 64 * 1024
|
|
80
|
+
# How many recent pass durations ride along -- the console's sparkline
|
|
81
|
+
# window (`sources.SPARK_POINTS`), restated here because this module cannot
|
|
82
|
+
# import `sources` at module level (sources imports loop, loop imports this).
|
|
83
|
+
DURATION_POINTS = 60
|
|
84
|
+
|
|
85
|
+
# The three outcomes the poll summary reports.
|
|
86
|
+
VITALS_PUSHED = "pushed"
|
|
87
|
+
VITALS_SKIPPED = "skipped"
|
|
88
|
+
VITALS_FAILED = "failed"
|
|
89
|
+
|
|
90
|
+
# The operator's lever per inbox kind, in the Factory's `lever` slot. The
|
|
91
|
+
# cap-out and the stability hold are both lifted by the same re-entry ack;
|
|
92
|
+
# a stalled episode is the console's retry-now (or a reap).
|
|
93
|
+
LEVER_REENTER = "comment `alissa-review: re-enter +N` on the PR"
|
|
94
|
+
LEVER_STALLED = "retry-now in the reviewer console, or reap the session"
|
|
95
|
+
_LEVERS = {
|
|
96
|
+
"cap-out": LEVER_REENTER,
|
|
97
|
+
"stability-held": LEVER_REENTER,
|
|
98
|
+
"stalled": LEVER_STALLED,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def iso_utc(seconds: "int | float") -> str:
|
|
103
|
+
"""An epoch stamp as the contract's ISO-8601 UTC string (whole seconds,
|
|
104
|
+
`Z` suffix)."""
|
|
105
|
+
return datetime.fromtimestamp(int(seconds), tz=timezone.utc).strftime(
|
|
106
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _int_or_none(value: object) -> "int | None":
|
|
111
|
+
if isinstance(value, bool):
|
|
112
|
+
return None
|
|
113
|
+
if isinstance(value, (int, float)):
|
|
114
|
+
return int(value)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _resolve_repo(slug: "str | None", repos: "tuple[str, ...]") -> "str | None":
|
|
119
|
+
"""The `owner/repo` a session name's repo component denotes, or None.
|
|
120
|
+
|
|
121
|
+
A session name carries the sanitized REPO component only (no owner), so
|
|
122
|
+
it resolves through the allowlist exactly the way the sweep's own name
|
|
123
|
+
resolution does: the one `owner/repo` whose slug matches. Two matches --
|
|
124
|
+
two owners with the same repo name -- is no answer, not a guess."""
|
|
125
|
+
if not slug:
|
|
126
|
+
return None
|
|
127
|
+
matches = [
|
|
128
|
+
full for full in repos
|
|
129
|
+
if session_repo_slug(full.split("/", 1)[-1]) == slug
|
|
130
|
+
]
|
|
131
|
+
return matches[0] if len(matches) == 1 else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _session_row(row: dict, repos: "tuple[str, ...]") -> dict:
|
|
135
|
+
"""One console session row -> one contract `sessionList` entry.
|
|
136
|
+
|
|
137
|
+
The PR and round come from the spawn ledger when the console paired the
|
|
138
|
+
session with a row (the authoritative record); a session with no ledger
|
|
139
|
+
row -- a hand-spawned `review-pr-<n>` from the skill, or one whose ledger
|
|
140
|
+
was lost -- falls back to what its NAME says (`parse_session_name`), the
|
|
141
|
+
same grammar the reaper trusts. `attempt` is always null: the review
|
|
142
|
+
loop is round-based and has no attempt dimension."""
|
|
143
|
+
name = row.get("name")
|
|
144
|
+
ref = parse_session_name(name)
|
|
145
|
+
repo: "str | None" = None
|
|
146
|
+
number: "int | None" = None
|
|
147
|
+
round_: "int | None" = None
|
|
148
|
+
pr = row.get("pr")
|
|
149
|
+
if isinstance(pr, str) and "#" in pr:
|
|
150
|
+
head, _, tail = pr.rpartition("#")
|
|
151
|
+
if tail.isdigit():
|
|
152
|
+
repo, number = head, int(tail)
|
|
153
|
+
round_ = _int_or_none(row.get("round"))
|
|
154
|
+
if ref is not None:
|
|
155
|
+
if number is None:
|
|
156
|
+
number = ref.number
|
|
157
|
+
if round_ is None:
|
|
158
|
+
round_ = ref.round
|
|
159
|
+
if repo is None:
|
|
160
|
+
repo = _resolve_repo(ref.repo, repos)
|
|
161
|
+
url = (
|
|
162
|
+
f"https://github.com/{repo}/pull/{number}"
|
|
163
|
+
if repo and number is not None
|
|
164
|
+
else None
|
|
165
|
+
)
|
|
166
|
+
cpu = row.get("cpu_percent")
|
|
167
|
+
return {
|
|
168
|
+
"name": str(name) if name is not None else "",
|
|
169
|
+
"edge": EDGE,
|
|
170
|
+
"repo": repo,
|
|
171
|
+
"number": number,
|
|
172
|
+
"round": round_,
|
|
173
|
+
"attempt": None,
|
|
174
|
+
"ageS": _int_or_none(row.get("age_seconds")),
|
|
175
|
+
"cpu": float(cpu) if isinstance(cpu, (int, float)) else None,
|
|
176
|
+
"rssBytes": _int_or_none(row.get("rss_bytes")),
|
|
177
|
+
"live": bool(row.get("live")),
|
|
178
|
+
"managed": bool(row.get("managed")),
|
|
179
|
+
"url": url,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _inbox_row(item: dict) -> dict:
|
|
184
|
+
"""One console inbox item (the LIVE half) -> one contract `inbox` entry.
|
|
185
|
+
Every reviewer page is a PR reference, so `isPr` is always true."""
|
|
186
|
+
kind = str(item.get("kind"))
|
|
187
|
+
repo = item.get("repo_slug")
|
|
188
|
+
number = _int_or_none(item.get("number"))
|
|
189
|
+
return {
|
|
190
|
+
"kind": kind,
|
|
191
|
+
"subject": (
|
|
192
|
+
f"{repo}#{number}" if repo and number is not None else str(repo or "")
|
|
193
|
+
),
|
|
194
|
+
"repo": str(repo) if repo else None,
|
|
195
|
+
"number": number,
|
|
196
|
+
"isPr": True,
|
|
197
|
+
"ageS": _int_or_none(item.get("age_seconds")),
|
|
198
|
+
"url": item.get("url") or None,
|
|
199
|
+
"lever": _LEVERS.get(kind),
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _rate(raw: "dict | None") -> "dict | None":
|
|
204
|
+
"""The console's cached `gh api rate_limit` read in the contract's shape,
|
|
205
|
+
or None when the read failed or came back without the two numbers the
|
|
206
|
+
Factory meter needs."""
|
|
207
|
+
if not isinstance(raw, dict):
|
|
208
|
+
return None
|
|
209
|
+
remaining = _int_or_none(raw.get("remaining"))
|
|
210
|
+
limit = _int_or_none(raw.get("limit"))
|
|
211
|
+
if remaining is None or limit is None:
|
|
212
|
+
return None
|
|
213
|
+
reset = _int_or_none(raw.get("reset"))
|
|
214
|
+
return {
|
|
215
|
+
"remaining": remaining,
|
|
216
|
+
"limit": limit,
|
|
217
|
+
"resetAt": iso_utc(reset) if reset is not None else None,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _memory(raw: "dict | None") -> "dict | None":
|
|
222
|
+
"""The cgroup split in the contract's shape. `resident` is the one field
|
|
223
|
+
the Factory cannot draw without, so its absence (no cgroup v2) makes the
|
|
224
|
+
whole object null rather than a meter over nothing."""
|
|
225
|
+
if not isinstance(raw, dict):
|
|
226
|
+
return None
|
|
227
|
+
resident = _int_or_none(raw.get("resident"))
|
|
228
|
+
if resident is None:
|
|
229
|
+
return None
|
|
230
|
+
return {
|
|
231
|
+
"residentBytes": resident,
|
|
232
|
+
"reclaimableBytes": _int_or_none(raw.get("reclaimable")),
|
|
233
|
+
"limitBytes": _int_or_none(raw.get("limit")),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def build_snapshot(
|
|
238
|
+
sources: "Sources",
|
|
239
|
+
*,
|
|
240
|
+
heartbeat_at: "int | float",
|
|
241
|
+
poll_interval: int,
|
|
242
|
+
queue_depth: "int | None",
|
|
243
|
+
repos: "tuple[str, ...]" = (),
|
|
244
|
+
now: "int | float | None" = None,
|
|
245
|
+
) -> dict:
|
|
246
|
+
"""The contract body for this pass, off the console's builders.
|
|
247
|
+
|
|
248
|
+
`heartbeat_at` is the completion time of the pass that produced the
|
|
249
|
+
snapshot (the Factory derives `polling` / `stalled` from its age against
|
|
250
|
+
`poll_interval`); `queue_depth` is the watcher's count of owed rounds
|
|
251
|
+
waiting on the spawn gate, or None when the caller cannot say. Every
|
|
252
|
+
optional field degrades to null on its own -- a broken tmux does not
|
|
253
|
+
blank the memory split, and vice versa -- and `kpis` is always null (an
|
|
254
|
+
orcloop-only section).
|
|
255
|
+
"""
|
|
256
|
+
stamp = time.time() if now is None else now
|
|
257
|
+
snaps = sources.snapshots(DURATION_POINTS)
|
|
258
|
+
durations = [
|
|
259
|
+
int(s["duration_ms"]) for s in reversed(snaps)
|
|
260
|
+
if _int_or_none(s.get("duration_ms")) is not None
|
|
261
|
+
]
|
|
262
|
+
drift = sources.drift()
|
|
263
|
+
rows = sources.session_rows()
|
|
264
|
+
sessions: "dict | None"
|
|
265
|
+
session_list: "list[dict] | None"
|
|
266
|
+
if rows is None:
|
|
267
|
+
sessions = session_list = None
|
|
268
|
+
else:
|
|
269
|
+
sessions = {
|
|
270
|
+
"live": sum(1 for r in rows if r.get("live")),
|
|
271
|
+
"managed": sum(1 for r in rows if r.get("managed")),
|
|
272
|
+
}
|
|
273
|
+
# Live, own-grammar sessions first, so the cap drops the rows an
|
|
274
|
+
# operator cares least about (a gone session, another lane's).
|
|
275
|
+
ordered = sorted(
|
|
276
|
+
rows, key=lambda r: (not r.get("live"), not r.get("managed"))
|
|
277
|
+
)
|
|
278
|
+
session_list = [_session_row(r, repos) for r in ordered[:LIST_CAP]]
|
|
279
|
+
inbox = [_inbox_row(item) for item in sources.inbox()["live"][:LIST_CAP]]
|
|
280
|
+
return {
|
|
281
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
282
|
+
"seat": SEAT,
|
|
283
|
+
"asOf": iso_utc(stamp),
|
|
284
|
+
"heartbeatAt": iso_utc(heartbeat_at),
|
|
285
|
+
"pollIntervalS": int(poll_interval),
|
|
286
|
+
"version": str(drift.get("running") or ""),
|
|
287
|
+
"drift": (
|
|
288
|
+
str(drift["latest"])
|
|
289
|
+
if drift.get("state") == "behind" and drift.get("latest")
|
|
290
|
+
else None
|
|
291
|
+
),
|
|
292
|
+
"pollDurationsMs": durations or None,
|
|
293
|
+
"sessions": sessions,
|
|
294
|
+
"sessionList": session_list,
|
|
295
|
+
"rate": _rate(sources.rate_limit()),
|
|
296
|
+
"memory": _memory(sources.memory()),
|
|
297
|
+
"queueDepth": _int_or_none(queue_depth),
|
|
298
|
+
"kpis": None,
|
|
299
|
+
"inbox": inbox,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def body_bytes(snapshot: dict) -> int:
|
|
304
|
+
"""The wire size of `snapshot`, encoded exactly as the client sends it."""
|
|
305
|
+
return len(json.dumps(snapshot).encode("utf-8"))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def fit_body(snapshot: dict) -> dict:
|
|
309
|
+
"""`snapshot`, with `sessionList` and `inbox` trimmed until the encoded
|
|
310
|
+
body is within BODY_MAX_BYTES. The longer list is halved first, so the
|
|
311
|
+
two shrink together rather than one vanishing while the other stays
|
|
312
|
+
whole; the counts in `sessions` are left as the whole roster's, because
|
|
313
|
+
they describe the fleet, not the list. Everything else is bounded by
|
|
314
|
+
construction and is never touched."""
|
|
315
|
+
body = dict(snapshot)
|
|
316
|
+
while body_bytes(body) > BODY_MAX_BYTES:
|
|
317
|
+
lists = [
|
|
318
|
+
key for key in ("sessionList", "inbox")
|
|
319
|
+
if isinstance(body.get(key), list) and body[key]
|
|
320
|
+
]
|
|
321
|
+
if not lists:
|
|
322
|
+
break
|
|
323
|
+
key = max(lists, key=lambda k: len(body[k]))
|
|
324
|
+
body[key] = body[key][: len(body[key]) // 2]
|
|
325
|
+
return body
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def describe(snapshot: dict) -> str:
|
|
329
|
+
"""The one-line summary the log carries for a snapshot."""
|
|
330
|
+
sessions = snapshot.get("sessions")
|
|
331
|
+
roster = (
|
|
332
|
+
f"sessions={sessions.get('live')}/{sessions.get('managed')} live/managed"
|
|
333
|
+
if isinstance(sessions, dict)
|
|
334
|
+
else "sessions=unlistable"
|
|
335
|
+
)
|
|
336
|
+
inbox = snapshot.get("inbox")
|
|
337
|
+
return (
|
|
338
|
+
f"{roster}, inbox={len(inbox) if isinstance(inbox, list) else 0}, "
|
|
339
|
+
f"queue={snapshot.get('queueDepth')}, {body_bytes(snapshot)} bytes"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class FleetVitalsPusher:
|
|
344
|
+
"""The once-per-pass push, best-effort.
|
|
345
|
+
|
|
346
|
+
Owned by the watcher when `fleet_vitals_enabled` is on; `push_once` is
|
|
347
|
+
called at the end of every completed pass, after the loop-events batch,
|
|
348
|
+
and NEVER raises for an API or transport condition -- one WARNING, the
|
|
349
|
+
pass completes, and the next pass's snapshot is the whole retry story.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
def __init__(
|
|
353
|
+
self,
|
|
354
|
+
config: Config,
|
|
355
|
+
sources: "Sources",
|
|
356
|
+
client: AlissaClient,
|
|
357
|
+
*,
|
|
358
|
+
clock: Callable[[], float] = time.time,
|
|
359
|
+
):
|
|
360
|
+
self.config = config
|
|
361
|
+
self._sources = sources
|
|
362
|
+
self._client = client
|
|
363
|
+
self._clock = clock
|
|
364
|
+
# Latched by the first `403 not_activated`: the operator resolves it
|
|
365
|
+
# out of band, so one WARNING per boot names the activate URL and
|
|
366
|
+
# every later refusal is a DEBUG line. The push itself is still
|
|
367
|
+
# attempted every pass -- the pass after activation lands on its own.
|
|
368
|
+
self._not_activated_warned = False
|
|
369
|
+
|
|
370
|
+
def build(self, *, heartbeat_at: "int | float", queue_depth: "int | None") -> dict:
|
|
371
|
+
"""This pass's contract body, fitted to the API's caps."""
|
|
372
|
+
return fit_body(build_snapshot(
|
|
373
|
+
self._sources,
|
|
374
|
+
heartbeat_at=heartbeat_at,
|
|
375
|
+
poll_interval=self.config.poll_interval,
|
|
376
|
+
queue_depth=queue_depth,
|
|
377
|
+
repos=self.config.repos,
|
|
378
|
+
now=self._clock(),
|
|
379
|
+
))
|
|
380
|
+
|
|
381
|
+
def push_once(
|
|
382
|
+
self, *, heartbeat_at: "int | float", queue_depth: "int | None" = None
|
|
383
|
+
) -> str:
|
|
384
|
+
"""Build and push this pass's snapshot. Returns one of VITALS_PUSHED,
|
|
385
|
+
VITALS_SKIPPED (dry-run: logged, nothing sent) or VITALS_FAILED.
|
|
386
|
+
|
|
387
|
+
A builder failure is a console read gone wrong -- the same
|
|
388
|
+
best-effort classification the loop-events derivation has: warn and
|
|
389
|
+
let the pass complete. In dry-run the snapshot is still BUILT (reads
|
|
390
|
+
only: the ledger, tmux, /proc, the two cached checks) and described,
|
|
391
|
+
so the operator sees what would be sent; the POST is the act dry-run
|
|
392
|
+
suppresses.
|
|
393
|
+
"""
|
|
394
|
+
try:
|
|
395
|
+
snapshot = self.build(heartbeat_at=heartbeat_at, queue_depth=queue_depth)
|
|
396
|
+
except Exception as exc:
|
|
397
|
+
log.warning(
|
|
398
|
+
"fleet-vitals: snapshot build failed (%s: %s) — skipping this "
|
|
399
|
+
"pass's vitals; the loop keeps polling",
|
|
400
|
+
type(exc).__name__, exc,
|
|
401
|
+
)
|
|
402
|
+
return VITALS_FAILED
|
|
403
|
+
if self.config.dry_run:
|
|
404
|
+
log.info("[dry-run] would push fleet vitals (%s)", describe(snapshot))
|
|
405
|
+
return VITALS_SKIPPED
|
|
406
|
+
try:
|
|
407
|
+
result = self._client.post_fleet_vitals(snapshot)
|
|
408
|
+
except AlissaAuthError as exc:
|
|
409
|
+
if exc.code == NOT_ACTIVATED:
|
|
410
|
+
self._note_not_activated(exc)
|
|
411
|
+
return VITALS_FAILED
|
|
412
|
+
self._warn_failed(exc)
|
|
413
|
+
return VITALS_FAILED
|
|
414
|
+
except AlissaError as exc:
|
|
415
|
+
self._warn_failed(exc)
|
|
416
|
+
return VITALS_FAILED
|
|
417
|
+
log.info(
|
|
418
|
+
"fleet-vitals: pushed (%s; replaced=%s)",
|
|
419
|
+
describe(snapshot), result.get("replaced"),
|
|
420
|
+
)
|
|
421
|
+
return VITALS_PUSHED
|
|
422
|
+
|
|
423
|
+
@staticmethod
|
|
424
|
+
def _warn_failed(exc: AlissaError) -> None:
|
|
425
|
+
# ONE warn per failed pass, naming the status and the error; the API
|
|
426
|
+
# keeps only the latest snapshot, so the next pass's push IS the
|
|
427
|
+
# retry and nothing is queued.
|
|
428
|
+
log.warning(
|
|
429
|
+
"fleet-vitals: push failed (status %s%s: %s) — vitals are "
|
|
430
|
+
"best-effort, the pass completes; the next pass sends a fresh "
|
|
431
|
+
"snapshot",
|
|
432
|
+
exc.status or "transport",
|
|
433
|
+
f" {exc.code}" if exc.code else "",
|
|
434
|
+
exc.detail,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
def _note_not_activated(self, exc: AlissaAuthError) -> None:
|
|
438
|
+
detail = exc.detail if isinstance(exc.detail, dict) else {}
|
|
439
|
+
activate = detail.get("activateUrl") or "the activate URL in the API's response"
|
|
440
|
+
if self._not_activated_warned:
|
|
441
|
+
log.debug(
|
|
442
|
+
"fleet-vitals: still refused — the loop app is not activated "
|
|
443
|
+
"for this token's user (403 not_activated); activate at %s",
|
|
444
|
+
activate,
|
|
445
|
+
)
|
|
446
|
+
return
|
|
447
|
+
self._not_activated_warned = True
|
|
448
|
+
log.warning(
|
|
449
|
+
"fleet-vitals: Studio refused the push — the loop app is not "
|
|
450
|
+
"activated for this token's user (HTTP 403 not_activated). "
|
|
451
|
+
"Activate it at %s and the next pass lands on its own. Reported "
|
|
452
|
+
"once per boot; later refusals log at DEBUG",
|
|
453
|
+
activate,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _console_runner(github: object) -> Callable[..., str]:
|
|
458
|
+
"""The subprocess runner the in-daemon `Sources` uses: `proc.run`, with
|
|
459
|
+
every `gh` call placed under the REVIEWER identity's credential the way
|
|
460
|
+
the daemon's own GitHub client does (`GitHub._env`), so the rate read is
|
|
461
|
+
the reviewer's bucket and not whatever the container inherited. A
|
|
462
|
+
reviewer token the client cannot resolve surfaces as a CommandError,
|
|
463
|
+
which `Sources` degrades to a null rate -- honest, rather than a read
|
|
464
|
+
under the wrong login."""
|
|
465
|
+
env_of = getattr(github, "_env", None)
|
|
466
|
+
|
|
467
|
+
def run(argv: "list[str]", *, timeout: int = 60, **kwargs: Any) -> str:
|
|
468
|
+
env = None
|
|
469
|
+
if argv and argv[0] == "gh" and callable(env_of):
|
|
470
|
+
try:
|
|
471
|
+
env = env_of()
|
|
472
|
+
except Exception as exc:
|
|
473
|
+
raise CommandError(argv, -1, str(exc)) from None
|
|
474
|
+
return proc_run(argv, timeout=timeout, env=env, **kwargs)
|
|
475
|
+
|
|
476
|
+
return run
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def build_pusher(
|
|
480
|
+
config: Config,
|
|
481
|
+
*,
|
|
482
|
+
github: object = None,
|
|
483
|
+
endpoint: "str | None" = None,
|
|
484
|
+
) -> FleetVitalsPusher:
|
|
485
|
+
"""The pusher the watcher wires in when `fleet_vitals_enabled` is on.
|
|
486
|
+
|
|
487
|
+
A seam, so tests build pushers over fake sources and clients while the
|
|
488
|
+
watcher's call stays one line. The `Sources` is the console's own data
|
|
489
|
+
layer over the daemon's config -- the sidecar need not be running -- and
|
|
490
|
+
the token comes from the environment inside `AlissaClient` (the CLI's
|
|
491
|
+
own `ALISSA_API_TOKEN`); its absence surfaces as the pusher's WARN, never
|
|
492
|
+
at construction, because a daemon must boot and poll whether or not
|
|
493
|
+
vitals can authenticate. The import is local because `sources` imports
|
|
494
|
+
`loop`, which imports this module.
|
|
495
|
+
"""
|
|
496
|
+
from .version import version
|
|
497
|
+
from .webui.sources import Sources
|
|
498
|
+
|
|
499
|
+
sources = Sources(
|
|
500
|
+
config=config,
|
|
501
|
+
running_version=version.value,
|
|
502
|
+
run=_console_runner(github),
|
|
503
|
+
)
|
|
504
|
+
return FleetVitalsPusher(config, sources, AlissaClient(base=endpoint))
|
|
@@ -59,6 +59,12 @@ from .ghclient import (
|
|
|
59
59
|
countable_rounds,
|
|
60
60
|
verdict_marker,
|
|
61
61
|
)
|
|
62
|
+
from .fleet_vitals import (
|
|
63
|
+
VITALS_FAILED,
|
|
64
|
+
VITALS_SKIPPED,
|
|
65
|
+
FleetVitalsPusher,
|
|
66
|
+
build_pusher,
|
|
67
|
+
)
|
|
62
68
|
from .loop_events import LoopEventsEmitter, build_emitter
|
|
63
69
|
from .proc import CommandError
|
|
64
70
|
from .state import State
|
|
@@ -1630,6 +1636,18 @@ class ReviewWatcher:
|
|
|
1630
1636
|
if config.loop_events_enabled
|
|
1631
1637
|
else None
|
|
1632
1638
|
)
|
|
1639
|
+
# The Studio fleet-vitals push (issue #126), or None when the knob
|
|
1640
|
+
# is off — same posture as the loop-events emitter above: nothing is
|
|
1641
|
+
# built, read or logged for the disabled default. The pusher owns
|
|
1642
|
+
# its own in-process `Sources` (the console's data layer), so the
|
|
1643
|
+
# console sidecar need not be running.
|
|
1644
|
+
self._fleet_vitals: FleetVitalsPusher | None = (
|
|
1645
|
+
build_pusher(
|
|
1646
|
+
config, github=self.github, endpoint=config.alissa_endpoint
|
|
1647
|
+
)
|
|
1648
|
+
if config.fleet_vitals_enabled
|
|
1649
|
+
else None
|
|
1650
|
+
)
|
|
1633
1651
|
# (repo, number, comment id) of every re-entry directive already
|
|
1634
1652
|
# refused in this process -- see _log_ignored_ack.
|
|
1635
1653
|
self._ignored_acks: set[tuple[str, int, int]] = set()
|
|
@@ -5002,10 +5020,17 @@ class ReviewWatcher:
|
|
|
5002
5020
|
# calls. Written in dry-run too: a snapshot OBSERVES the pass, it is
|
|
5003
5021
|
# not a side effect the daemon takes, so a future console sees dry-run
|
|
5004
5022
|
# passes as well as live ones.
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
)
|
|
5023
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
5024
|
+
self._write_snapshot(results, reaped, duration_ms=duration_ms)
|
|
5008
5025
|
self._emit_loop_events()
|
|
5026
|
+
# AFTER the loop-events push, and after the snapshot, so the vitals
|
|
5027
|
+
# heartbeat is the completion time of a pass whose exhaust is already
|
|
5028
|
+
# written and whose events are already on their way.
|
|
5029
|
+
vitals = self._push_fleet_vitals(completed_at=time.time())
|
|
5030
|
+
log.info(
|
|
5031
|
+
"poll summary: %d candidate(s), %d reaped, %d ms, vitals: %s",
|
|
5032
|
+
len(results), reaped, duration_ms, vitals,
|
|
5033
|
+
)
|
|
5009
5034
|
return results
|
|
5010
5035
|
|
|
5011
5036
|
def _emit_loop_events(self) -> None:
|
|
@@ -5033,6 +5058,35 @@ class ReviewWatcher:
|
|
|
5033
5058
|
type(exc).__name__, exc,
|
|
5034
5059
|
)
|
|
5035
5060
|
|
|
5061
|
+
def _push_fleet_vitals(self, *, completed_at: float) -> str:
|
|
5062
|
+
"""Push this pass's fleet-vitals snapshot to Studio, when enabled
|
|
5063
|
+
(issue #126). Returns the outcome word the poll summary prints:
|
|
5064
|
+
`pushed`, `skipped` (disabled, or dry-run -- the pusher logs what it
|
|
5065
|
+
WOULD send and sends nothing) or `failed`.
|
|
5066
|
+
|
|
5067
|
+
`queue_depth` is the spawn gate's waiting set at the end of the pass:
|
|
5068
|
+
every round this pass deferred for a slot, pruned to live candidates
|
|
5069
|
+
at the pass's start and drained as slots free -- the "owed rounds
|
|
5070
|
+
waiting on `max_concurrent_sessions`" the Factory card shows.
|
|
5071
|
+
|
|
5072
|
+
The pusher itself never raises for an API condition (one WARNING, the
|
|
5073
|
+
pass completes); the guard here is the same never-fatal promise held
|
|
5074
|
+
against a code defect, so vitals can never take down a poll.
|
|
5075
|
+
"""
|
|
5076
|
+
if self._fleet_vitals is None:
|
|
5077
|
+
return VITALS_SKIPPED
|
|
5078
|
+
try:
|
|
5079
|
+
return self._fleet_vitals.push_once(
|
|
5080
|
+
heartbeat_at=completed_at, queue_depth=len(self._waiting)
|
|
5081
|
+
)
|
|
5082
|
+
except Exception as exc:
|
|
5083
|
+
log.warning(
|
|
5084
|
+
"fleet-vitals: pusher failed unexpectedly (%s: %s) — vitals "
|
|
5085
|
+
"are best-effort, the pass completes",
|
|
5086
|
+
type(exc).__name__, exc,
|
|
5087
|
+
)
|
|
5088
|
+
return VITALS_FAILED
|
|
5089
|
+
|
|
5036
5090
|
def _note_ledger_unwritable(self) -> None:
|
|
5037
5091
|
"""Report a pass refused because the ledger cannot record it.
|
|
5038
5092
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.30.0
|
|
@@ -360,8 +360,27 @@ class Sources:
|
|
|
360
360
|
spawns: "list[dict] | None" = None,
|
|
361
361
|
index: "tuple[dict[int, list[int]], dict[int, dict]] | None" = None,
|
|
362
362
|
) -> "list[dict]":
|
|
363
|
+
"""The managed-session table -- `session_rows`, with an unlistable
|
|
364
|
+
roster degraded to the empty table the dashboard has always rendered
|
|
365
|
+
(a panel cannot draw None). A caller that must tell "nobody is live"
|
|
366
|
+
from "could not list" reads `session_rows` directly."""
|
|
367
|
+
return self.session_rows(spawns, index) or []
|
|
368
|
+
|
|
369
|
+
def session_rows(
|
|
370
|
+
self,
|
|
371
|
+
spawns: "list[dict] | None" = None,
|
|
372
|
+
index: "tuple[dict[int, list[int]], dict[int, dict]] | None" = None,
|
|
373
|
+
) -> "list[dict] | None":
|
|
363
374
|
"""The managed-session table: liveness from `alissa tmux ls`, footprint
|
|
364
|
-
from /proc, and the PR round each session is reviewing
|
|
375
|
+
from /proc, and the PR round each session is reviewing -- or None
|
|
376
|
+
when the roster could not be LISTED at all.
|
|
377
|
+
|
|
378
|
+
None is a distinct answer from `[]`, and the fleet-vitals snapshot
|
|
379
|
+
(issue #126) depends on the distinction: it reports `sessions: null`
|
|
380
|
+
for a roster it could not read and must never report `{0, 0}` for
|
|
381
|
+
it, because a Factory card reading "0 live" over a broken tmux would
|
|
382
|
+
be the reassuring-direction error. A listing that returned anything
|
|
383
|
+
other than a JSON list is "could not list" too.
|
|
365
384
|
|
|
366
385
|
The round comes from the spawn ledger (session name is its primary
|
|
367
386
|
key), not from the session name itself: the name carries a nonce and
|
|
@@ -379,9 +398,9 @@ class Sources:
|
|
|
379
398
|
table) the old lazy build is unchanged and a table with no live pane
|
|
380
399
|
still never scans `/proc`.
|
|
381
400
|
"""
|
|
382
|
-
raw = self._safe_json(["alissa", "tmux", "ls", "--json"])
|
|
401
|
+
raw = self._safe_json(["alissa", "tmux", "ls", "--json"])
|
|
383
402
|
if not isinstance(raw, list):
|
|
384
|
-
return
|
|
403
|
+
return None
|
|
385
404
|
if spawns is not None:
|
|
386
405
|
by_session = {row["session"]: row for row in spawns}
|
|
387
406
|
else:
|
|
@@ -443,6 +462,16 @@ class Sources:
|
|
|
443
462
|
)
|
|
444
463
|
return out
|
|
445
464
|
|
|
465
|
+
def memory(self) -> dict:
|
|
466
|
+
"""The container's cgroup memory split (`sysinfo.cgroup_memory`) plus
|
|
467
|
+
its hard `limit` (`sysinfo.cgroup_memory_limit`), every field None
|
|
468
|
+
off a host without cgroup v2. The fleet-vitals builder's read (issue
|
|
469
|
+
#126); the dashboard tile keeps reading `cgroup_memory` directly, so
|
|
470
|
+
its payload is untouched."""
|
|
471
|
+
out = dict(sysinfo.cgroup_memory(self._cgroup_root))
|
|
472
|
+
out["limit"] = sysinfo.cgroup_memory_limit(self._cgroup_root)
|
|
473
|
+
return out
|
|
474
|
+
|
|
446
475
|
# -- two cached remote checks ------------------------------------------
|
|
447
476
|
|
|
448
477
|
def rate_limit(self) -> "dict | None":
|
|
@@ -602,12 +631,7 @@ class Sources:
|
|
|
602
631
|
# built once, so the two panels can never disagree about which PRs the
|
|
603
632
|
# newest pass still had in hand.
|
|
604
633
|
items = self._pipeline(latest)
|
|
605
|
-
inbox = self.
|
|
606
|
-
ledgers["escalations"],
|
|
607
|
-
ledgers["pings"],
|
|
608
|
-
ledgers.get("stability_pings", []),
|
|
609
|
-
live_prs=self._live_prs(latest, items),
|
|
610
|
-
)
|
|
634
|
+
inbox = self._inbox_for(latest, items, ledgers)
|
|
611
635
|
sparklines = {
|
|
612
636
|
"poll_duration_ms": [s["duration_ms"] for s in chrono],
|
|
613
637
|
"active_sessions": [s["in_flight"] + s["deferred"] for s in chrono],
|
|
@@ -678,6 +702,31 @@ class Sources:
|
|
|
678
702
|
"log": self.log_tail(),
|
|
679
703
|
}
|
|
680
704
|
|
|
705
|
+
def inbox(self) -> dict:
|
|
706
|
+
"""The operator inbox on its own -- the `_inbox` split (`live`,
|
|
707
|
+
`settled`, `settled_dropped`, `truncated`) built exactly as the
|
|
708
|
+
dashboard builds it, off the newest snapshot and the two inbox
|
|
709
|
+
ledgers, for a caller that wants no other panel. The fleet-vitals
|
|
710
|
+
builder's read (issue #126): the snapshot carries the LIVE half, so
|
|
711
|
+
the Factory card and this console can never disagree about what the
|
|
712
|
+
operator still owes."""
|
|
713
|
+
snaps = self.snapshots(1)
|
|
714
|
+
latest = snaps[0] if snaps else None
|
|
715
|
+
return self._inbox_for(latest, self._pipeline(latest), self.ledgers())
|
|
716
|
+
|
|
717
|
+
def _inbox_for(
|
|
718
|
+
self, latest: "dict | None", items: "list[dict]", ledgers: dict
|
|
719
|
+
) -> dict:
|
|
720
|
+
"""`_inbox` over the two ledgers, with the liveness oracle derived
|
|
721
|
+
from `items` -- the one place the dashboard and `inbox()` share, so
|
|
722
|
+
the two cannot drift on which rows are live."""
|
|
723
|
+
return self._inbox(
|
|
724
|
+
ledgers["escalations"],
|
|
725
|
+
ledgers["pings"],
|
|
726
|
+
ledgers.get("stability_pings", []),
|
|
727
|
+
live_prs=self._live_prs(latest, items),
|
|
728
|
+
)
|
|
729
|
+
|
|
681
730
|
def _pipeline(self, latest: "dict | None") -> "list[dict]":
|
|
682
731
|
"""The PR-centric board: one row per PR the newest poll pass saw.
|
|
683
732
|
|
|
@@ -62,6 +62,10 @@ _TAIL_MIN_LEN = 22
|
|
|
62
62
|
# real footprint and a charge the kernel would give back under pressure.
|
|
63
63
|
_CGROUP_CURRENT = "memory.current"
|
|
64
64
|
_CGROUP_STAT = "memory.stat"
|
|
65
|
+
# The container's hard memory limit. cgroup v2 writes the literal `max` when
|
|
66
|
+
# there is none, which `_read_int_file` reports as None -- the right answer
|
|
67
|
+
# for "no limit", and the same answer as "no cgroup v2 at all".
|
|
68
|
+
_CGROUP_MAX = "memory.max"
|
|
65
69
|
_CGROUP_STAT_KEYS = (
|
|
66
70
|
"anon",
|
|
67
71
|
"file",
|
|
@@ -340,6 +344,21 @@ def cgroup_memory(
|
|
|
340
344
|
return out
|
|
341
345
|
|
|
342
346
|
|
|
347
|
+
def cgroup_memory_limit(
|
|
348
|
+
cgroup_root: "str | os.PathLike[str]" = "/sys/fs/cgroup",
|
|
349
|
+
) -> "int | None":
|
|
350
|
+
"""The container's hard memory limit in bytes (`memory.max`), or None
|
|
351
|
+
when it is unlimited (`max`), unreadable, or there is no cgroup v2.
|
|
352
|
+
|
|
353
|
+
Separate from `cgroup_memory` on purpose: that payload is the console
|
|
354
|
+
tile's, pinned key-for-key by its tests and rendered by the page, and
|
|
355
|
+
the fleet-vitals snapshot (issue #126) is the only reader that wants
|
|
356
|
+
the limit -- the Factory draws a resident-over-limit meter from it.
|
|
357
|
+
Never raises, like every read here.
|
|
358
|
+
"""
|
|
359
|
+
return _read_int_file(Path(cgroup_root) / _CGROUP_MAX)
|
|
360
|
+
|
|
361
|
+
|
|
343
362
|
def disk_usage(path: "str | os.PathLike[str]") -> "dict | None":
|
|
344
363
|
"""Workspace volume usage for the stat tile, or None if the path is
|
|
345
364
|
unreadable. Percent is used/total, rounded -- the meter the console fills."""
|
|
@@ -10,6 +10,7 @@ src/main/alissa/tools/github/revloop/alissa.py
|
|
|
10
10
|
src/main/alissa/tools/github/revloop/alissa_client.py
|
|
11
11
|
src/main/alissa/tools/github/revloop/bows.py
|
|
12
12
|
src/main/alissa/tools/github/revloop/config.py
|
|
13
|
+
src/main/alissa/tools/github/revloop/fleet_vitals.py
|
|
13
14
|
src/main/alissa/tools/github/revloop/ghclient.py
|
|
14
15
|
src/main/alissa/tools/github/revloop/loop.py
|
|
15
16
|
src/main/alissa/tools/github/revloop/loop_events.py
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.29.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|