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.
Files changed (36) hide show
  1. {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
  2. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/__main__.py +29 -0
  3. {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
  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
  5. alissa_tools_github_devloop-0.8.21/src/main/alissa/tools/github/devloop/fleet_vitals.py +390 -0
  6. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/janitor.py +73 -24
  7. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/loop.py +300 -62
  8. alissa_tools_github_devloop-0.8.21/src/main/alissa/tools/github/devloop/version +1 -0
  9. {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
  10. {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
  11. {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
  12. {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
  13. alissa_tools_github_devloop-0.8.19/src/main/alissa/tools/github/devloop/version +0 -1
  14. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/LICENSE +0 -0
  15. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/MANIFEST.in +0 -0
  16. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/NOTICE +0 -0
  17. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/README.md +0 -0
  18. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/requirements.txt +0 -0
  19. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/setup.cfg +0 -0
  20. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/setup.py +0 -0
  21. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  22. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  23. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  24. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  25. {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
  26. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  27. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/state.py +0 -0
  28. {alissa_tools_github_devloop-0.8.19 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/version.py +0 -0
  29. {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
  30. {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
  31. {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
  32. {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
  33. {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
  34. {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
  35. {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
  36. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.19
3
+ Version: 0.8.21
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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 ONE write —
26
- ``POST /v1/loop-events`` (`post_loop_events`), the loop-telemetry push — over
27
- the same transport, token and taxonomy, so the daemon still reads and writes
28
- Alissa through a single REST idiom.
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
- loop_events = bool(
841
- raw.get("loop_events_enabled", cls.loop_events_enabled)
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
+ )