alissa-tools-github-devloop 0.8.19__tar.gz → 0.8.21__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_devloop-0.8.19/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.21}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/__main__.py +29 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/alissa_client.py +26 -4
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/config.py +57 -17
- alissa_tools_github_devloop-0.8.21/src/main/alissa/tools/github/devloop/fleet_vitals.py +390 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/janitor.py +73 -24
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/loop.py +300 -62
- alissa_tools_github_devloop-0.8.21/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/webui/sources.py +12 -2
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +96 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
- alissa_tools_github_devloop-0.8.19/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/loop_events.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -312,6 +312,27 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
312
312
|
"(the default: the push is opt-in)",
|
|
313
313
|
)
|
|
314
314
|
|
|
315
|
+
fleet_vitals = over.add_mutually_exclusive_group()
|
|
316
|
+
fleet_vitals.add_argument(
|
|
317
|
+
"--fleet-vitals",
|
|
318
|
+
dest="fleet_vitals_enabled",
|
|
319
|
+
action="store_true",
|
|
320
|
+
default=None,
|
|
321
|
+
help="push one console snapshot (version/drift, pass durations, the "
|
|
322
|
+
"tmux roster with CPU/RSS, GitHub rate, memory, queue depth, inbox) "
|
|
323
|
+
"per poll pass to Studio's POST /v1/loop/fleet-vitals on the alissa "
|
|
324
|
+
"endpoint, after the loop-events push — best-effort, never fatal; "
|
|
325
|
+
"the console sidecar need not run. ALISSA_DEV_FLEET_VITALS_ENABLED "
|
|
326
|
+
"(non-blank) overrides even this flag",
|
|
327
|
+
)
|
|
328
|
+
fleet_vitals.add_argument(
|
|
329
|
+
"--no-fleet-vitals",
|
|
330
|
+
dest="fleet_vitals_enabled",
|
|
331
|
+
action="store_false",
|
|
332
|
+
help="never push fleet vitals, even if the config enables it "
|
|
333
|
+
"(the default: the push is opt-in)",
|
|
334
|
+
)
|
|
335
|
+
|
|
315
336
|
janitor = over.add_mutually_exclusive_group()
|
|
316
337
|
janitor.add_argument(
|
|
317
338
|
"--janitor",
|
|
@@ -418,6 +439,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
418
439
|
"orphan_grace_minutes": args.orphan_grace_minutes,
|
|
419
440
|
"activity_claims_enabled": args.activity_claims_enabled,
|
|
420
441
|
"loop_events_enabled": args.loop_events_enabled,
|
|
442
|
+
"fleet_vitals_enabled": args.fleet_vitals_enabled,
|
|
421
443
|
"janitor_enabled": args.janitor_enabled,
|
|
422
444
|
"janitor_grace_minutes": args.janitor_grace_minutes,
|
|
423
445
|
"janitor_interval_minutes": args.janitor_interval_minutes,
|
|
@@ -539,6 +561,13 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
539
561
|
f"/v1/loop-events, best-effort)"
|
|
540
562
|
if config.loop_events_enabled else "",
|
|
541
563
|
)
|
|
564
|
+
log.info(
|
|
565
|
+
"fleet_vitals=%s%s",
|
|
566
|
+
config.fleet_vitals_enabled,
|
|
567
|
+
f" (one snapshot per pass → POST {config.alissa_endpoint}"
|
|
568
|
+
f"/v1/loop/fleet-vitals, best-effort)"
|
|
569
|
+
if config.fleet_vitals_enabled else "",
|
|
570
|
+
)
|
|
542
571
|
log.debug("hub_template: %s", config.hub_template)
|
|
543
572
|
log.debug(
|
|
544
573
|
"cache_paths: %s",
|
|
@@ -22,10 +22,11 @@ already authenticates — the same transport, endpoints and taxonomy orcloop's
|
|
|
22
22
|
`urllib` only: the distribution ships no third-party runtime dependency, and
|
|
23
23
|
this must not be the change that adds one.
|
|
24
24
|
|
|
25
|
-
Since issue #101 the adapter also carries the daemon's
|
|
26
|
-
``POST /v1/loop-events`` (`post_loop_events`), the loop-telemetry push
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
Since issue #101 the adapter also carries the daemon's writes —
|
|
26
|
+
``POST /v1/loop-events`` (`post_loop_events`), the loop-telemetry push, and
|
|
27
|
+
since issue #116 ``POST /v1/loop/fleet-vitals`` (`post_fleet_vitals`), the
|
|
28
|
+
per-pass vitals snapshot — over the same transport, token and taxonomy, so
|
|
29
|
+
the daemon still reads and writes Alissa through a single REST idiom.
|
|
29
30
|
|
|
30
31
|
Endpoints, the reads both verified against the live API:
|
|
31
32
|
|
|
@@ -62,6 +63,7 @@ import os
|
|
|
62
63
|
import urllib.error
|
|
63
64
|
import urllib.request
|
|
64
65
|
from dataclasses import dataclass
|
|
66
|
+
from typing import Mapping
|
|
65
67
|
|
|
66
68
|
log = logging.getLogger(__name__)
|
|
67
69
|
|
|
@@ -262,6 +264,26 @@ class AlissaClient:
|
|
|
262
264
|
)
|
|
263
265
|
return payload if isinstance(payload, dict) else {}
|
|
264
266
|
|
|
267
|
+
def post_fleet_vitals(self, snapshot: "Mapping[str, object]") -> "dict":
|
|
268
|
+
"""Push one fleet-vitals snapshot (issue #116).
|
|
269
|
+
|
|
270
|
+
``POST /v1/loop/fleet-vitals`` (fahera-mx/studio.alissa.app,
|
|
271
|
+
TASK-182095034): the body is ONE snapshot for ONE seat, and the
|
|
272
|
+
server REPLACES the caller's previous snapshot for that seat — no
|
|
273
|
+
history, no dedupe key. The shape, the 50-entry list caps and the
|
|
274
|
+
64 KB body cap are the CALLER's contract (`fleet_vitals.py` builds
|
|
275
|
+
and trims); this is the transport, returning the server's
|
|
276
|
+
``{"seat", "receivedAt", "replaced"}`` verbatim.
|
|
277
|
+
|
|
278
|
+
Failures leave as the taxonomy like every other call. The soft
|
|
279
|
+
activation gate answers ``403 {"error": "not_activated",
|
|
280
|
+
"activateUrl": …}`` — an `AlissaAuthError` whose `code` the caller
|
|
281
|
+
reads to warn once per boot rather than once per pass."""
|
|
282
|
+
payload = self._request(
|
|
283
|
+
"/v1/loop/fleet-vitals", method="POST", body=dict(snapshot)
|
|
284
|
+
)
|
|
285
|
+
return payload if isinstance(payload, dict) else {}
|
|
286
|
+
|
|
265
287
|
def list_bodies_of_work(self) -> "list[BodyOfWork]":
|
|
266
288
|
"""Every Body of Work this actor owns OR collaborates on — ONE call.
|
|
267
289
|
|
|
@@ -22,7 +22,8 @@ var > `--repos-source` > the config key. A BLANK env var means "not set" here
|
|
|
22
22
|
platform variable reference renders as the empty string, and that must fall
|
|
23
23
|
through to the config rather than fail the daemon's boot on a mode of `''`.
|
|
24
24
|
`loop_events_enabled` rides the same shape of rail through
|
|
25
|
-
`ALISSA_DEV_LOOP_EVENTS_ENABLED`, with the same blank-falls-through rule
|
|
25
|
+
`ALISSA_DEV_LOOP_EVENTS_ENABLED`, with the same blank-falls-through rule, and
|
|
26
|
+
`fleet_vitals_enabled` through `ALISSA_DEV_FLEET_VITALS_ENABLED` likewise.
|
|
26
27
|
"""
|
|
27
28
|
|
|
28
29
|
from __future__ import annotations
|
|
@@ -102,9 +103,38 @@ DEFAULT_ALISSA_ENDPOINT = "https://api.alissa.app"
|
|
|
102
103
|
# staring at a variable that does nothing.
|
|
103
104
|
ENV_LOOP_EVENTS = "ALISSA_DEV_LOOP_EVENTS_ENABLED"
|
|
104
105
|
|
|
106
|
+
# The fleet-vitals rail (issue #116): `fleet_vitals_enabled` ridden exactly
|
|
107
|
+
# the way ENV_LOOP_EVENTS rides loop_events_enabled — same spellings, same
|
|
108
|
+
# non-blank-wins / blank-falls-through rule, same config error on a typo.
|
|
109
|
+
ENV_FLEET_VITALS = "ALISSA_DEV_FLEET_VITALS_ENABLED"
|
|
110
|
+
|
|
105
111
|
_ENV_BOOL_TRUE = frozenset(("1", "true"))
|
|
106
112
|
_ENV_BOOL_FALSE = frozenset(("0", "false"))
|
|
107
113
|
|
|
114
|
+
|
|
115
|
+
def _env_bool_rail(
|
|
116
|
+
environ: "Mapping[str, str]", name: str, value: bool
|
|
117
|
+
) -> bool:
|
|
118
|
+
"""`value` unless env var `name` is set NON-BLANK, in which case the
|
|
119
|
+
variable decides: 1/true or 0/false, case-insensitive, anything else a
|
|
120
|
+
config error naming the variable (a typo silently read as false would
|
|
121
|
+
leave an operator staring at a variable that does nothing). Blank falls
|
|
122
|
+
through — an unset platform variable reference renders as "" and must not
|
|
123
|
+
decide anything. The one rule behind ENV_LOOP_EVENTS and
|
|
124
|
+
ENV_FLEET_VITALS, stated once."""
|
|
125
|
+
raw = environ.get(name, "").strip()
|
|
126
|
+
if not raw:
|
|
127
|
+
return value
|
|
128
|
+
lowered = raw.casefold()
|
|
129
|
+
if lowered in _ENV_BOOL_TRUE:
|
|
130
|
+
return True
|
|
131
|
+
if lowered in _ENV_BOOL_FALSE:
|
|
132
|
+
return False
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"{name} must be one of 1/true/0/false (case-insensitive), got {raw!r}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
108
138
|
# What an Alissa actor id looks like: a 32-character opaque handle of
|
|
109
139
|
# lower-case letters and digits (`j5706fv7xe5jy1k5wdwzacab9s8axcd2`). Every id
|
|
110
140
|
# the API mints has this shape, and `bow_owners` is checked against it so a
|
|
@@ -157,6 +187,7 @@ CONFIG_KEYS = (
|
|
|
157
187
|
"orphan_grace_minutes",
|
|
158
188
|
"activity_claims_enabled",
|
|
159
189
|
"loop_events_enabled",
|
|
190
|
+
"fleet_vitals_enabled",
|
|
160
191
|
"janitor_enabled",
|
|
161
192
|
"janitor_grace_minutes",
|
|
162
193
|
"janitor_interval_minutes",
|
|
@@ -557,6 +588,21 @@ class Config:
|
|
|
557
588
|
# wins over the CLI and the file, repos_source's rail exactly.
|
|
558
589
|
loop_events_enabled: bool = False
|
|
559
590
|
|
|
591
|
+
# The fleet-vitals push (issue #116): at the end of each poll pass, AFTER
|
|
592
|
+
# the loop-events push, POST one console snapshot — version/drift, recent
|
|
593
|
+
# pass durations, the tmux roster with per-session CPU/RSS, the cached
|
|
594
|
+
# GitHub rate, container memory, queue depth and the operator inbox — to
|
|
595
|
+
# Studio's /v1/loop/fleet-vitals on `alissa_endpoint`, over the same REST
|
|
596
|
+
# client and token. The Factory's /fleet renders it for a deployment it
|
|
597
|
+
# cannot pull from. Built in-process from the worker console's own
|
|
598
|
+
# builders, so the console sidecar need not run. Same posture as the
|
|
599
|
+
# loop events: best-effort, never fatal (one WARNING per failed pass, no
|
|
600
|
+
# retry queue — the next pass sends a fresher snapshot), nothing sent
|
|
601
|
+
# under dry_run. Default OFF; disabled, no request is ever made.
|
|
602
|
+
# ALISSA_DEV_FLEET_VITALS_ENABLED (non-blank) wins over the CLI and the
|
|
603
|
+
# file, loop_events_enabled's rail exactly.
|
|
604
|
+
fleet_vitals_enabled: bool = False
|
|
605
|
+
|
|
560
606
|
# The workspace janitor (issue #74): the filesystem sibling of the session
|
|
561
607
|
# reaper. Every worker session materializes a per-task workspace under its
|
|
562
608
|
# repo's hub (a `TASK-<n>-<DESC>` worktree plus whatever its lane installs
|
|
@@ -837,23 +883,16 @@ class Config:
|
|
|
837
883
|
# ALISSA_DEV_LOOP_EVENTS_ENABLED wins over the CLI and the file, and
|
|
838
884
|
# blank falls through. Validated by NAME like the enums — a typo read
|
|
839
885
|
# as false would silently disable a push the operator asked for.
|
|
840
|
-
|
|
841
|
-
|
|
886
|
+
env_map = os.environ if environ is None else environ
|
|
887
|
+
loop_events = _env_bool_rail(
|
|
888
|
+
env_map, ENV_LOOP_EVENTS,
|
|
889
|
+
bool(raw.get("loop_events_enabled", cls.loop_events_enabled)),
|
|
890
|
+
)
|
|
891
|
+
# The fleet-vitals rail (issue #116), the same shape again.
|
|
892
|
+
fleet_vitals = _env_bool_rail(
|
|
893
|
+
env_map, ENV_FLEET_VITALS,
|
|
894
|
+
bool(raw.get("fleet_vitals_enabled", cls.fleet_vitals_enabled)),
|
|
842
895
|
)
|
|
843
|
-
env_loop_events = (os.environ if environ is None else environ).get(
|
|
844
|
-
ENV_LOOP_EVENTS, ""
|
|
845
|
-
).strip()
|
|
846
|
-
if env_loop_events:
|
|
847
|
-
lowered = env_loop_events.casefold()
|
|
848
|
-
if lowered in _ENV_BOOL_TRUE:
|
|
849
|
-
loop_events = True
|
|
850
|
-
elif lowered in _ENV_BOOL_FALSE:
|
|
851
|
-
loop_events = False
|
|
852
|
-
else:
|
|
853
|
-
raise ValueError(
|
|
854
|
-
f"{ENV_LOOP_EVENTS} must be one of 1/true/0/false "
|
|
855
|
-
f"(case-insensitive), got {env_loop_events!r}"
|
|
856
|
-
)
|
|
857
896
|
|
|
858
897
|
bow_owners = normalize_bow_owners(raw.get("bow_owners", cls.bow_owners))
|
|
859
898
|
|
|
@@ -1083,6 +1122,7 @@ class Config:
|
|
|
1083
1122
|
raw.get("activity_claims_enabled", cls.activity_claims_enabled)
|
|
1084
1123
|
),
|
|
1085
1124
|
loop_events_enabled=loop_events,
|
|
1125
|
+
fleet_vitals_enabled=fleet_vitals,
|
|
1086
1126
|
janitor_enabled=bool(raw.get("janitor_enabled", cls.janitor_enabled)),
|
|
1087
1127
|
janitor_grace_minutes=janitor_grace,
|
|
1088
1128
|
janitor_interval_minutes=janitor_interval,
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Fleet vitals — the Studio push of one console snapshot per pass (issue #116).
|
|
2
|
+
|
|
3
|
+
The Factory's `/fleet` screen PULLS each daemon console's `/api/state` over
|
|
4
|
+
private networking with passcodes held on the hosted Factory — per-deployment
|
|
5
|
+
by construction, so a Dark Factory customer running their own devloop sees
|
|
6
|
+
`not_configured`. Studio's ``POST /v1/loop/fleet-vitals`` (fahera-mx/
|
|
7
|
+
studio.alissa.app, TASK-182095034) turns that into a PUSH: one snapshot per
|
|
8
|
+
user×seat, replaced on every POST, rendered by the Factory whenever it has no
|
|
9
|
+
console URL for the seat. This module is the devloop half: at the end of every
|
|
10
|
+
completed poll pass, build the snapshot and POST it once.
|
|
11
|
+
|
|
12
|
+
Three rules, all downstream of "the console sidecar does not need to run":
|
|
13
|
+
|
|
14
|
+
* **Built in-process from the console's own builders.** The snapshot is the
|
|
15
|
+
worker console's dashboard payload, re-shaped to the wire contract:
|
|
16
|
+
`webui.sources.Sources` (ledgers, roster, cached GitHub rate, drift) and
|
|
17
|
+
`webui.sysinfo` (per-session CPU/RSS, memory) do the reading. Nothing is
|
|
18
|
+
measured twice — the pass's own ``alissa tmux ls`` listing (the reaper's,
|
|
19
|
+
see `DevWatcher._sweep_and_budget`) is the roster, handed in rather than
|
|
20
|
+
re-listed — and the GitHub rate read keeps the console's 60 s cache, so an
|
|
21
|
+
enabled push adds no ``gh api rate_limit`` per pass.
|
|
22
|
+
* **Same posture as loop events.** Opt-in (`fleet_vitals_enabled`, default
|
|
23
|
+
off), best-effort, never fatal: a failed POST is ONE WARNING naming the
|
|
24
|
+
status and the pass completes. No retry queue — the next pass sends a
|
|
25
|
+
fresher snapshot, which is the whole point of a snapshot. `403
|
|
26
|
+
not_activated` (the operator has not activated the stacked app) warns once
|
|
27
|
+
per boot with the activate URL and then drops to DEBUG. Under `dry_run`
|
|
28
|
+
nothing is sent.
|
|
29
|
+
* **Built to the contract exactly.** The API is STRICT (an unknown top-level
|
|
30
|
+
key is a 400) and caps `sessionList` and `inbox` at LIST_CAP entries and the
|
|
31
|
+
body at BODY_MAX bytes, so the caps are applied HERE, before sending.
|
|
32
|
+
Optional fields ride as explicit nulls, never as missing keys, so the body
|
|
33
|
+
shape is the same every pass.
|
|
34
|
+
|
|
35
|
+
The roster rule the contract cares most about: `sessions` and `sessionList`
|
|
36
|
+
are `null` — not `{live: 0, managed: 0}` — when the listing could not be
|
|
37
|
+
read. A machine whose tmux blinked is not an idle machine, and the Factory
|
|
38
|
+
must be able to tell the two apart.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
import time
|
|
46
|
+
from datetime import datetime, timezone
|
|
47
|
+
from typing import Callable, Mapping, Sequence
|
|
48
|
+
|
|
49
|
+
from .alissa_client import AlissaAuthError, AlissaClient, AlissaError
|
|
50
|
+
from .config import Config
|
|
51
|
+
from .version import version as running_version
|
|
52
|
+
from .webui import sources as sources_mod
|
|
53
|
+
from .webui import sysinfo
|
|
54
|
+
from .webui.sources import Sources
|
|
55
|
+
|
|
56
|
+
log = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
SEAT = "devloop"
|
|
59
|
+
SCHEMA_VERSION = 1
|
|
60
|
+
ENDPOINT_PATH = "/v1/loop/fleet-vitals"
|
|
61
|
+
|
|
62
|
+
# The ingest contract's caps: 50 entries per list (a 51st is a 400 naming the
|
|
63
|
+
# path) and 64 KB for the whole body. Mirrors Studio's FLEET_VITALS_LIST_CAP.
|
|
64
|
+
LIST_CAP = 50
|
|
65
|
+
BODY_MAX = 64 * 1024
|
|
66
|
+
|
|
67
|
+
# The error code Studio's soft gate answers a POST with when the operator has
|
|
68
|
+
# not activated the stacked app. Permanent until the operator acts, so it is
|
|
69
|
+
# warned once per boot (with the URL the answer carries) and DEBUG after.
|
|
70
|
+
NOT_ACTIVATED = "not_activated"
|
|
71
|
+
|
|
72
|
+
# The pass-outcome tokens the poll summary quotes (`vitals: …`).
|
|
73
|
+
PUSHED = "pushed"
|
|
74
|
+
SKIPPED = "skipped"
|
|
75
|
+
FAILED = "failed"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _session_grammar() -> "Callable[[str], object]":
|
|
79
|
+
"""The daemon's own session-name parser, resolved late.
|
|
80
|
+
|
|
81
|
+
`loop` imports this module (it owns the pass hook), so a top-level import
|
|
82
|
+
of `loop.parse_worker_session` here would be a cycle. The grammar stays
|
|
83
|
+
stated ONCE, in `loop`, and is read at first use."""
|
|
84
|
+
from .loop import parse_worker_session
|
|
85
|
+
return parse_worker_session
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def iso(ts: "float | int") -> str:
|
|
89
|
+
"""A unix timestamp as the contract's ISO-8601 UTC instant (whole
|
|
90
|
+
seconds, `Z` suffix)."""
|
|
91
|
+
return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime(
|
|
92
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# -- the snapshot ---------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _session_row(
|
|
100
|
+
entry: "Mapping",
|
|
101
|
+
*,
|
|
102
|
+
repo_by_session: "Mapping[str, str]",
|
|
103
|
+
parse_session: "Callable[[str], object]",
|
|
104
|
+
) -> "dict":
|
|
105
|
+
"""One `sessionList` row from one console session record
|
|
106
|
+
(`Sources.sessions` shape). The name is the FULL tmux session (the
|
|
107
|
+
managed name under its `ali-` prefix — what an operator would `tmux
|
|
108
|
+
attach` to); edge, number and attempt come from devloop's own grammar
|
|
109
|
+
(a name that does not parse is a stranger's: edge/number/attempt null);
|
|
110
|
+
the repo comes from the ledger row behind the name, never from the name
|
|
111
|
+
slug (which is lossy), and the URL follows the repo."""
|
|
112
|
+
managed_name = str(entry.get("name") or "")
|
|
113
|
+
worker = parse_session(managed_name) if managed_name else None
|
|
114
|
+
edge = number = attempt = None
|
|
115
|
+
if worker is not None:
|
|
116
|
+
edge = str(worker.edge) # type: ignore[attr-defined]
|
|
117
|
+
number = int(worker.number) # type: ignore[attr-defined]
|
|
118
|
+
attempt = int(worker.attempt) # type: ignore[attr-defined]
|
|
119
|
+
repo = repo_by_session.get(managed_name)
|
|
120
|
+
url = None
|
|
121
|
+
if repo and number is not None:
|
|
122
|
+
leaf = "issues" if edge == "develop" else "pull"
|
|
123
|
+
url = f"https://github.com/{repo}/{leaf}/{number}"
|
|
124
|
+
return {
|
|
125
|
+
"name": str(entry.get("session") or managed_name),
|
|
126
|
+
"edge": edge,
|
|
127
|
+
"repo": repo,
|
|
128
|
+
"number": number,
|
|
129
|
+
"round": None,
|
|
130
|
+
"attempt": attempt,
|
|
131
|
+
"ageS": entry.get("age_seconds"),
|
|
132
|
+
"cpu": entry.get("cpu_percent"),
|
|
133
|
+
"rssBytes": entry.get("rss_bytes"),
|
|
134
|
+
"live": bool(entry.get("live")),
|
|
135
|
+
"managed": True,
|
|
136
|
+
"url": url,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _inbox_row(row: "Mapping") -> "dict":
|
|
141
|
+
"""One `inbox` row from one console inbox record (`Sources._inbox`
|
|
142
|
+
shape). A foreign-session page has no artifact: its subject is the kind
|
|
143
|
+
(which names the session), repo and number null."""
|
|
144
|
+
kind = str(row.get("kind") or "")
|
|
145
|
+
foreign = kind.startswith(sources_mod.FOREIGN_SESSION_KIND)
|
|
146
|
+
repo = None if foreign else row.get("repo_slug")
|
|
147
|
+
number = None if foreign else row.get("number")
|
|
148
|
+
subject = kind if foreign else f"{repo}#{number}"
|
|
149
|
+
return {
|
|
150
|
+
"kind": kind,
|
|
151
|
+
"subject": subject,
|
|
152
|
+
"repo": repo,
|
|
153
|
+
"number": number,
|
|
154
|
+
"isPr": bool(row.get("is_pr")),
|
|
155
|
+
"ageS": row.get("age_seconds"),
|
|
156
|
+
"url": row.get("url"),
|
|
157
|
+
# The escalations ledger carries no lever column today; the field is
|
|
158
|
+
# on the contract for the seats that do, so it rides as null.
|
|
159
|
+
"lever": None,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _rate(rate: "Mapping | None") -> "dict | None":
|
|
164
|
+
if not rate:
|
|
165
|
+
return None
|
|
166
|
+
reset = rate.get("reset")
|
|
167
|
+
return {
|
|
168
|
+
"remaining": rate.get("remaining"),
|
|
169
|
+
"limit": rate.get("limit"),
|
|
170
|
+
"resetAt": iso(reset) if isinstance(reset, (int, float)) else None,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _memory(mem: "Mapping | None") -> "dict | None":
|
|
175
|
+
if not mem:
|
|
176
|
+
return None
|
|
177
|
+
return {
|
|
178
|
+
"residentBytes": mem.get("resident_bytes"),
|
|
179
|
+
"reclaimableBytes": mem.get("reclaimable_bytes"),
|
|
180
|
+
"limitBytes": mem.get("limit_bytes"),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def build_snapshot(
|
|
185
|
+
sources: Sources,
|
|
186
|
+
*,
|
|
187
|
+
roster: "Sequence[Mapping] | None",
|
|
188
|
+
heartbeat_at: "float | int",
|
|
189
|
+
now: "float | int | None" = None,
|
|
190
|
+
memory: "Mapping | None" = None,
|
|
191
|
+
parse_session: "Callable[[str], object] | None" = None,
|
|
192
|
+
) -> "dict":
|
|
193
|
+
"""The contract body for one pass, from the console's builders.
|
|
194
|
+
|
|
195
|
+
`roster` is the pass's own ``alissa tmux ls`` listing (raw rows), or None
|
|
196
|
+
when the listing failed — which is the ONE input that makes `sessions`
|
|
197
|
+
and `sessionList` null. `heartbeat_at` is the pass's completion time;
|
|
198
|
+
`now` (default: the same instant) stamps `asOf` and the ages. `memory` is
|
|
199
|
+
the `sysinfo.memory_usage` reading, taken by the caller so a test can
|
|
200
|
+
hand in a fixture. Pure assembly over the sources: the only IO is what
|
|
201
|
+
`Sources` does (state.db, `tmux list-panes` + `/proc` per live session,
|
|
202
|
+
the cached rate and PyPI reads)."""
|
|
203
|
+
parse = parse_session or _session_grammar()
|
|
204
|
+
now_ts = heartbeat_at if now is None else now
|
|
205
|
+
|
|
206
|
+
snaps = sources.snapshots(sources_mod.SPARK_POINTS)
|
|
207
|
+
latest = snaps[0] if snaps else None
|
|
208
|
+
ledgers = sources.ledgers()
|
|
209
|
+
|
|
210
|
+
repo_by_session: "dict[str, str]" = {}
|
|
211
|
+
for table in ("spawns", "fix_spawns", "maintain_spawns"):
|
|
212
|
+
for row in ledgers[table]:
|
|
213
|
+
session = row.get("session")
|
|
214
|
+
if session and session not in repo_by_session:
|
|
215
|
+
repo_by_session[str(session)] = str(row["repo_slug"])
|
|
216
|
+
|
|
217
|
+
sessions_block: "dict | None" = None
|
|
218
|
+
session_list: "list[dict] | None" = None
|
|
219
|
+
if roster is not None:
|
|
220
|
+
records = sources.sessions(listing=[dict(r) for r in roster])
|
|
221
|
+
sessions_block = {
|
|
222
|
+
"live": sum(1 for r in records if r.get("live")),
|
|
223
|
+
"managed": len(records),
|
|
224
|
+
}
|
|
225
|
+
session_list = [
|
|
226
|
+
_session_row(r, repo_by_session=repo_by_session, parse_session=parse)
|
|
227
|
+
for r in records[:LIST_CAP]
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
live, _settled = sources._partition_inbox(ledgers["escalations"], latest)
|
|
231
|
+
inbox_rows = sources._inbox(live)
|
|
232
|
+
# Newest first: the ledger reads newest-first already, but the order is
|
|
233
|
+
# part of the contract ("newest first, ≤ 50"), so it is enforced rather
|
|
234
|
+
# than inherited.
|
|
235
|
+
inbox_rows.sort(key=lambda r: r.get("age_seconds") or 0)
|
|
236
|
+
inbox = [_inbox_row(r) for r in inbox_rows[:LIST_CAP]]
|
|
237
|
+
|
|
238
|
+
durations = [s["duration_ms"] for s in reversed(snaps)] if snaps else None
|
|
239
|
+
|
|
240
|
+
queue_depth = None
|
|
241
|
+
if latest:
|
|
242
|
+
queue_depth = (
|
|
243
|
+
int(latest.get("issue_candidates") or 0)
|
|
244
|
+
+ int(latest.get("pr_candidates") or 0)
|
|
245
|
+
+ int(latest.get("maintain_candidates") or 0)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
drift = sources.drift()
|
|
249
|
+
return {
|
|
250
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
251
|
+
"seat": SEAT,
|
|
252
|
+
"asOf": iso(now_ts),
|
|
253
|
+
"heartbeatAt": iso(heartbeat_at),
|
|
254
|
+
"pollIntervalS": int(sources.config.poll_interval),
|
|
255
|
+
"version": drift["running"],
|
|
256
|
+
"drift": drift["latest"] if drift["state"] == "behind" else None,
|
|
257
|
+
"pollDurationsMs": durations,
|
|
258
|
+
"sessions": sessions_block,
|
|
259
|
+
"sessionList": session_list,
|
|
260
|
+
"rate": _rate(sources.rate_limit()),
|
|
261
|
+
"memory": _memory(memory),
|
|
262
|
+
"queueDepth": queue_depth,
|
|
263
|
+
"kpis": None,
|
|
264
|
+
"inbox": inbox,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def encoded_size(snapshot: "Mapping") -> int:
|
|
269
|
+
"""The body's wire size, as the client serialises it."""
|
|
270
|
+
return len(json.dumps(snapshot).encode("utf-8"))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def fit_to_body_cap(snapshot: "dict", cap: int = BODY_MAX) -> "dict":
|
|
274
|
+
"""The snapshot trimmed until it fits the body cap.
|
|
275
|
+
|
|
276
|
+
Fifty sessions and fifty inbox rows are ~20 KB, so this is a backstop,
|
|
277
|
+
not the normal path. The lists shrink first (halving `sessionList`, then
|
|
278
|
+
`inbox`, alternately) and the durations series last; the scalar fields
|
|
279
|
+
are never touched. The counts in `sessions` stay whole — they describe
|
|
280
|
+
the roster, not the list."""
|
|
281
|
+
out = dict(snapshot)
|
|
282
|
+
while encoded_size(out) > cap:
|
|
283
|
+
for key in ("sessionList", "inbox", "pollDurationsMs"):
|
|
284
|
+
rows = out.get(key)
|
|
285
|
+
if rows:
|
|
286
|
+
keep = len(rows) // 2
|
|
287
|
+
out[key] = rows[-keep:] if key == "pollDurationsMs" else rows[:keep]
|
|
288
|
+
break
|
|
289
|
+
else:
|
|
290
|
+
break # nothing left to trim: the scalars alone must do
|
|
291
|
+
return out
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# -- the pusher ---------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class FleetVitalsPusher:
|
|
298
|
+
"""One snapshot per completed pass, best-effort.
|
|
299
|
+
|
|
300
|
+
Owns the long-lived `Sources` (so the console's caches — the 60 s rate
|
|
301
|
+
read, the 10 min PyPI read — persist across passes exactly as they do in
|
|
302
|
+
the sidecar) and the once-per-boot `not_activated` latch. Never raises
|
|
303
|
+
from `push_pass`: the daemon's job is spawning sessions, and a snapshot
|
|
304
|
+
must never cost a poll pass."""
|
|
305
|
+
|
|
306
|
+
def __init__(
|
|
307
|
+
self,
|
|
308
|
+
config: Config,
|
|
309
|
+
client: AlissaClient,
|
|
310
|
+
*,
|
|
311
|
+
sources: "Sources | None" = None,
|
|
312
|
+
memory_reader: "Callable[[], Mapping | None]" = sysinfo.memory_usage,
|
|
313
|
+
clock: "Callable[[], float]" = time.time,
|
|
314
|
+
):
|
|
315
|
+
self.config = config
|
|
316
|
+
self.client = client
|
|
317
|
+
self.sources = sources or Sources(
|
|
318
|
+
config=config, running_version=running_version.value
|
|
319
|
+
)
|
|
320
|
+
self._memory = memory_reader
|
|
321
|
+
self._clock = clock
|
|
322
|
+
self._not_activated_warned = False
|
|
323
|
+
self.last: "dict | None" = None
|
|
324
|
+
|
|
325
|
+
def push_pass(self, roster: "Sequence[Mapping] | None") -> str:
|
|
326
|
+
"""Build this pass's snapshot and POST it once. Returns PUSHED or
|
|
327
|
+
FAILED (the caller quotes it in the poll summary). A snapshot that
|
|
328
|
+
cannot be BUILT is a failure too — one WARNING, nothing sent."""
|
|
329
|
+
heartbeat = self._clock()
|
|
330
|
+
try:
|
|
331
|
+
snapshot = fit_to_body_cap(build_snapshot(
|
|
332
|
+
self.sources, roster=roster, heartbeat_at=heartbeat,
|
|
333
|
+
memory=self._memory(),
|
|
334
|
+
))
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
log.warning(
|
|
337
|
+
"fleet vitals: could not build the snapshot (%s) — nothing "
|
|
338
|
+
"pushed this pass; the next pass builds a fresh one", exc,
|
|
339
|
+
)
|
|
340
|
+
return FAILED
|
|
341
|
+
self.last = snapshot
|
|
342
|
+
return self._post(snapshot)
|
|
343
|
+
|
|
344
|
+
def _post(self, snapshot: "dict") -> str:
|
|
345
|
+
try:
|
|
346
|
+
self.client.post_fleet_vitals(snapshot)
|
|
347
|
+
except AlissaAuthError as exc:
|
|
348
|
+
if exc.code == NOT_ACTIVATED:
|
|
349
|
+
self._warn_not_activated(exc)
|
|
350
|
+
return FAILED
|
|
351
|
+
self._warn_failed(exc)
|
|
352
|
+
return FAILED
|
|
353
|
+
except AlissaError as exc:
|
|
354
|
+
self._warn_failed(exc)
|
|
355
|
+
return FAILED
|
|
356
|
+
sessions = snapshot.get("sessions") or {}
|
|
357
|
+
log.debug(
|
|
358
|
+
"fleet vitals: pushed (%s live / %s managed session(s), %d inbox "
|
|
359
|
+
"row(s), %d bytes)",
|
|
360
|
+
sessions.get("live", "?"), sessions.get("managed", "?"),
|
|
361
|
+
len(snapshot.get("inbox") or []), encoded_size(snapshot),
|
|
362
|
+
)
|
|
363
|
+
return PUSHED
|
|
364
|
+
|
|
365
|
+
def _warn_failed(self, exc: AlissaError) -> None:
|
|
366
|
+
log.warning(
|
|
367
|
+
"fleet vitals: POST %s failed (status %s: %s) — the pass "
|
|
368
|
+
"completes; the next pass sends a fresher snapshot",
|
|
369
|
+
ENDPOINT_PATH, exc.status, exc.detail,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def _warn_not_activated(self, exc: AlissaAuthError) -> None:
|
|
373
|
+
"""The soft gate's answer is permanent until the operator acts, so it
|
|
374
|
+
is one WARNING per boot — with the activate URL the answer carries —
|
|
375
|
+
and DEBUG on every pass after."""
|
|
376
|
+
detail = exc.detail if isinstance(exc.detail, dict) else {}
|
|
377
|
+
url = detail.get("activateUrl") or "(no activate URL in the answer)"
|
|
378
|
+
if self._not_activated_warned:
|
|
379
|
+
log.debug(
|
|
380
|
+
"fleet vitals: still not_activated (activate at %s)", url
|
|
381
|
+
)
|
|
382
|
+
return
|
|
383
|
+
self._not_activated_warned = True
|
|
384
|
+
log.warning(
|
|
385
|
+
"fleet vitals: POST %s refused with 403 %s — the stacked app is "
|
|
386
|
+
"not activated for this operator; activate it at %s (this is "
|
|
387
|
+
"logged once per boot; every later pass logs at DEBUG until it "
|
|
388
|
+
"succeeds)",
|
|
389
|
+
ENDPOINT_PATH, NOT_ACTIVATED, url,
|
|
390
|
+
)
|