alissa-tools-github-devloop 0.8.20__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.20/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.20 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/__main__.py +29 -0
  3. {alissa_tools_github_devloop-0.8.20 → 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.20 → 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.20 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/loop.py +64 -12
  7. alissa_tools_github_devloop-0.8.21/src/main/alissa/tools/github/devloop/version +1 -0
  8. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/webui/sources.py +12 -2
  9. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +96 -0
  10. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  11. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
  12. alissa_tools_github_devloop-0.8.20/src/main/alissa/tools/github/devloop/version +0 -1
  13. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/LICENSE +0 -0
  14. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/MANIFEST.in +0 -0
  15. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/NOTICE +0 -0
  16. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/README.md +0 -0
  17. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/requirements.txt +0 -0
  18. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/setup.cfg +0 -0
  19. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/setup.py +0 -0
  20. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  21. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  22. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/bows.py +0 -0
  23. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/ghclient.py +0 -0
  24. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
  25. {alissa_tools_github_devloop-0.8.20 → 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.20 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  27. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/state.py +0 -0
  28. {alissa_tools_github_devloop-0.8.20 → alissa_tools_github_devloop-0.8.21}/src/main/alissa/tools/github/devloop/version.py +0 -0
  29. {alissa_tools_github_devloop-0.8.20 → 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.20 → 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.20 → 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.20 → 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.20 → 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.20 → 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.20 → 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.20 → 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.20
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
+ )
@@ -437,6 +437,7 @@ from pathlib import Path
437
437
  from typing import Callable
438
438
 
439
439
  from . import janitor
440
+ from . import fleet_vitals as fleet_vitals_mod
440
441
  from . import loop_events as loop_events_mod
441
442
  from .alissa import Alissa
442
443
  from .alissa_client import AlissaClient
@@ -1896,6 +1897,18 @@ class SessionLiveness:
1896
1897
  reaped: "set[str] | None" = None,
1897
1898
  ):
1898
1899
  self.listed = sessions is not None
1900
+ # The raw rows, kept for the fleet-vitals snapshot (issue #116) so
1901
+ # the pass's ONE listing is also the roster it reports -- minus the
1902
+ # names the sweep killed, which are not sessions any more. None when
1903
+ # the listing did not happen, which the snapshot reports as null
1904
+ # (never as an empty roster).
1905
+ self.entries: "list[dict] | None" = None
1906
+ if sessions is not None:
1907
+ killed = set(reaped or ())
1908
+ self.entries = [
1909
+ dict(entry) for entry in sessions
1910
+ if str(entry.get("name") or "") not in killed
1911
+ ]
1899
1912
  self._live: set[str] = set()
1900
1913
  for entry in sessions or ():
1901
1914
  name = str(entry.get("name") or "")
@@ -2605,6 +2618,7 @@ class DevWatcher:
2605
2618
  envelope_verdict: "Callable[[str, str, int], str | None] | None" = None,
2606
2619
  repo_source: "BowRepoSource | None" = None,
2607
2620
  loop_events: "loop_events_mod.LoopEventEmitter | None" = None,
2621
+ fleet_vitals: "fleet_vitals_mod.FleetVitalsPusher | None" = None,
2608
2622
  ):
2609
2623
  self.config = config
2610
2624
  self.github = github or GitHub(config.developer_login)
@@ -2635,6 +2649,17 @@ class DevWatcher:
2635
2649
  if config.loop_events_enabled
2636
2650
  else None
2637
2651
  )
2652
+ # The fleet-vitals push (issue #116), or None when
2653
+ # fleet_vitals_enabled is off -- the loop-events shape exactly: no
2654
+ # client built, no request ever made, same endpoint and token,
2655
+ # injectable so a test never opens a socket.
2656
+ self.fleet_vitals = fleet_vitals or (
2657
+ fleet_vitals_mod.FleetVitalsPusher(
2658
+ config, AlissaClient(base=config.alissa_endpoint)
2659
+ )
2660
+ if config.fleet_vitals_enabled
2661
+ else None
2662
+ )
2638
2663
  # The reaper's name-slug -> (owner, repo) index (see `_anchor_repo`),
2639
2664
  # rebuilt only when the allowlist itself changes rather than per
2640
2665
  # session per sweep.
@@ -7939,10 +7964,26 @@ class DevWatcher:
7939
7964
  self.run_janitor(roster)
7940
7965
 
7941
7966
  counts = Counter(decision.action for _, decision in results)
7967
+ self._write_snapshot(
7968
+ issue_results,
7969
+ pr_results,
7970
+ maintain_results,
7971
+ sweep,
7972
+ counts,
7973
+ duration_ms=int((time.monotonic() - started) * 1000),
7974
+ )
7975
+ # The two Studio pushes close the pass, loop events FIRST (issue
7976
+ # #116 fixes the order: the vitals snapshot reads the ledgers the
7977
+ # events were derived from, and the summary line quotes its
7978
+ # outcome). Both are best-effort and neither can raise.
7979
+ self._emit_loop_events(
7980
+ issue_results, pr_results, maintain_results, sweep
7981
+ )
7982
+ vitals = self._push_fleet_vitals(roster)
7942
7983
  log.info(
7943
7984
  "poll summary: %d candidate(s) — %d spawned, %d in-flight, "
7944
7985
  "%d skipped, %d escalated, %d capped, %d deferred; "
7945
- "%d worker session(s) reaped, %d foreign",
7986
+ "%d worker session(s) reaped, %d foreign; vitals: %s",
7946
7987
  len(results),
7947
7988
  counts[Action.SPAWNED],
7948
7989
  counts[Action.IN_FLIGHT],
@@ -7952,20 +7993,31 @@ class DevWatcher:
7952
7993
  counts[Action.DEFERRED],
7953
7994
  sweep.reaped,
7954
7995
  sweep.foreign_reaped,
7955
- )
7956
- self._write_snapshot(
7957
- issue_results,
7958
- pr_results,
7959
- maintain_results,
7960
- sweep,
7961
- counts,
7962
- duration_ms=int((time.monotonic() - started) * 1000),
7963
- )
7964
- self._emit_loop_events(
7965
- issue_results, pr_results, maintain_results, sweep
7996
+ vitals,
7966
7997
  )
7967
7998
  return results
7968
7999
 
8000
+ def _push_fleet_vitals(self, roster: SessionLiveness) -> str:
8001
+ """The pass's fleet-vitals push (issue #116): one console snapshot
8002
+ to Studio, built from the pass's own roster and the ledgers the pass
8003
+ just wrote. Returns the outcome token the summary quotes --
8004
+ `skipped` when the feature is off or under dry_run (the push writes
8005
+ to an external service, and a dry pass takes no side effects: it
8006
+ logs what it would have sent instead), else the pusher's `pushed` /
8007
+ `failed`. Runs AFTER the loop-events push and never raises."""
8008
+ if self.fleet_vitals is None:
8009
+ return fleet_vitals_mod.SKIPPED
8010
+ if self.config.dry_run:
8011
+ log.info(
8012
+ "[dry-run] would push fleet vitals (seat %s, %s roster "
8013
+ "session(s) → POST %s%s)",
8014
+ fleet_vitals_mod.SEAT,
8015
+ len(roster.entries) if roster.entries is not None else "unlisted",
8016
+ self.config.alissa_endpoint, fleet_vitals_mod.ENDPOINT_PATH,
8017
+ )
8018
+ return fleet_vitals_mod.SKIPPED
8019
+ return self.fleet_vitals.push_pass(roster.entries)
8020
+
7969
8021
  def _emit_loop_events(
7970
8022
  self,
7971
8023
  issue_results: "list[tuple[str, Decision]]",
@@ -312,8 +312,18 @@ class Sources:
312
312
  except ValueError:
313
313
  return None
314
314
 
315
- def sessions(self) -> "list[dict]":
316
- raw = self._safe_json(["alissa", "tmux", "ls", "--json"]) or []
315
+ def sessions(self, listing: "list[dict] | None" = None) -> "list[dict]":
316
+ """The managed sessions with their edge, age and `/proc` footprint.
317
+
318
+ `listing` is an ``alissa tmux ls --json`` result already in hand: the
319
+ daemon's fleet-vitals push (issue #116) hands in the pass's own
320
+ reaper listing so the roster is read ONCE per pass, while the
321
+ console (no listing) lists for itself. Either way the rows go
322
+ through the same shaping, so the two never disagree about what a
323
+ session record looks like."""
324
+ raw = listing if listing is not None else (
325
+ self._safe_json(["alissa", "tmux", "ls", "--json"]) or []
326
+ )
317
327
  if not isinstance(raw, list):
318
328
  return []
319
329
  now = int(self._wall())
@@ -148,6 +148,102 @@ def tree_usage(
148
148
  }
149
149
 
150
150
 
151
+ def _read_int(path: Path) -> "int | None":
152
+ """One integer file (`memory.current`, `memory.max`, …), or None when
153
+ it is absent, unreadable, or not a number (`max` for an unlimited
154
+ cgroup reads as None — no limit)."""
155
+ try:
156
+ return int(path.read_text().split()[0])
157
+ except (OSError, ValueError, IndexError):
158
+ return None
159
+
160
+
161
+ def _read_stat_field(path: Path, field: str) -> "int | None":
162
+ """One `key value` line of a cgroup `memory.stat`, or None."""
163
+ try:
164
+ for line in path.read_text().splitlines():
165
+ key, _, value = line.partition(" ")
166
+ if key == field:
167
+ return int(value.strip())
168
+ except (OSError, ValueError):
169
+ return None
170
+ return None
171
+
172
+
173
+ def _read_meminfo(path: Path) -> "dict[str, int]":
174
+ """`/proc/meminfo` as `{key: kB}`, empty when unreadable."""
175
+ out: "dict[str, int]" = {}
176
+ try:
177
+ for line in path.read_text().splitlines():
178
+ key, _, rest = line.partition(":")
179
+ parts = rest.split()
180
+ if key and parts and parts[0].isdigit():
181
+ out[key.strip()] = int(parts[0])
182
+ except OSError:
183
+ return {}
184
+ return out
185
+
186
+
187
+ def memory_usage(
188
+ *,
189
+ cgroup_root: "str | os.PathLike[str]" = "/sys/fs/cgroup",
190
+ proc_root: "str | os.PathLike[str]" = "/proc",
191
+ ) -> "dict | None":
192
+ """The container's memory footprint for the fleet-vitals snapshot
193
+ (issue #116): `resident_bytes` (what is charged right now),
194
+ `reclaimable_bytes` (the inactive page cache the kernel would drop under
195
+ pressure — the part of "resident" that is not really the workers'), and
196
+ `limit_bytes` (the cgroup cap, or None when unlimited). Or None when no
197
+ accounting can be read at all.
198
+
199
+ Three sources, in the order a container is likeliest to expose them:
200
+ cgroup v2 (`memory.current` / `memory.stat inactive_file` / `memory.max`,
201
+ where `max` means no limit), cgroup v1 (`memory/memory.usage_in_bytes` /
202
+ `memory.stat total_inactive_file` / `memory.limit_in_bytes`, where the
203
+ kernel's "no limit" is a near-2^63 sentinel), and `/proc/meminfo` for a
204
+ bare host (resident = MemTotal − MemAvailable, reclaimable = MemAvailable
205
+ − MemFree, limit = MemTotal). Every read is failure-tolerant like the
206
+ rest of this module: a field that cannot be read is None, never an
207
+ exception."""
208
+ cg = Path(cgroup_root)
209
+ current = _read_int(cg / "memory.current")
210
+ if current is not None:
211
+ limit = _read_int(cg / "memory.max")
212
+ return {
213
+ "resident_bytes": current,
214
+ "reclaimable_bytes": _read_stat_field(cg / "memory.stat", "inactive_file"),
215
+ "limit_bytes": limit,
216
+ }
217
+ v1 = cg / "memory"
218
+ usage = _read_int(v1 / "memory.usage_in_bytes")
219
+ if usage is not None:
220
+ limit = _read_int(v1 / "memory.limit_in_bytes")
221
+ # cgroup v1 reports "unlimited" as PAGE_COUNTER_MAX (≈ 2^63 rounded
222
+ # to a page); anything past a petabyte is that sentinel, not a cap.
223
+ if limit is not None and limit >= 1 << 50:
224
+ limit = None
225
+ return {
226
+ "resident_bytes": usage,
227
+ "reclaimable_bytes": _read_stat_field(
228
+ v1 / "memory.stat", "total_inactive_file"
229
+ ),
230
+ "limit_bytes": limit,
231
+ }
232
+ info = _read_meminfo(Path(proc_root) / "meminfo")
233
+ total = info.get("MemTotal")
234
+ available = info.get("MemAvailable")
235
+ if total is None or available is None:
236
+ return None
237
+ free = info.get("MemFree")
238
+ return {
239
+ "resident_bytes": max(0, total - available) * 1024,
240
+ "reclaimable_bytes": (
241
+ max(0, available - free) * 1024 if free is not None else None
242
+ ),
243
+ "limit_bytes": total * 1024,
244
+ }
245
+
246
+
151
247
  def disk_usage(path: "str | os.PathLike[str]") -> "dict | None":
152
248
  """Workspace volume usage for the stat tile, or None if the path is
153
249
  unreadable. Percent is used/total, rounded -- the meter the console fills.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.8.20
3
+ Version: 0.8.21
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -10,6 +10,7 @@ src/main/alissa/tools/github/devloop/alissa.py
10
10
  src/main/alissa/tools/github/devloop/alissa_client.py
11
11
  src/main/alissa/tools/github/devloop/bows.py
12
12
  src/main/alissa/tools/github/devloop/config.py
13
+ src/main/alissa/tools/github/devloop/fleet_vitals.py
13
14
  src/main/alissa/tools/github/devloop/ghclient.py
14
15
  src/main/alissa/tools/github/devloop/janitor.py
15
16
  src/main/alissa/tools/github/devloop/loop.py