alissa-tools-github-revloop 0.28.0__tar.gz → 0.30.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {alissa_tools_github_revloop-0.28.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.30.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/__main__.py +119 -0
  3. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/alissa_client.py +158 -12
  4. alissa_tools_github_revloop-0.30.0/src/main/alissa/tools/github/revloop/bows.py +405 -0
  5. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/config.py +339 -18
  6. alissa_tools_github_revloop-0.30.0/src/main/alissa/tools/github/revloop/fleet_vitals.py +504 -0
  7. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/loop.py +243 -5
  8. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/state.py +69 -0
  9. alissa_tools_github_revloop-0.30.0/src/main/alissa/tools/github/revloop/version +1 -0
  10. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/sources.py +72 -9
  11. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +19 -0
  12. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  13. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +2 -0
  14. alissa_tools_github_revloop-0.28.0/src/main/alissa/tools/github/revloop/version +0 -1
  15. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/LICENSE +0 -0
  16. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/MANIFEST.in +0 -0
  17. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/NOTICE +0 -0
  18. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/README.md +0 -0
  19. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/requirements.txt +0 -0
  20. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/setup.cfg +0 -0
  21. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/setup.py +0 -0
  22. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  24. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  25. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  26. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  27. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  28. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  29. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  30. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  31. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  32. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  33. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  34. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  35. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  36. {alissa_tools_github_revloop-0.28.0 → alissa_tools_github_revloop-0.30.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.28.0
3
+ Version: 0.30.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -3,18 +3,26 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import dataclasses
6
7
  import logging
7
8
  import re
8
9
  import sys
9
10
  from pathlib import Path
10
11
 
12
+ from .alissa_client import AlissaClient, AlissaError
11
13
  from .config import (
14
+ BOW_OWNERS_ENV,
15
+ BOWS_REFRESH_POLLS_ENV,
16
+ FLEET_VITALS_ENV,
12
17
  HUB_ADD,
13
18
  HUB_SKIP,
14
19
  LOOP_EVENTS_ENV,
15
20
  ON_MISSING_CREATE,
16
21
  ON_MISSING_SKIP,
17
22
  ON_MISSING_SPAWN,
23
+ REPOS_BOWS,
24
+ REPOS_SOURCE_ENV,
25
+ REPOS_STATIC,
18
26
  TASK_LIST_BOW_ENV,
19
27
  Config,
20
28
  load_config_file,
@@ -82,6 +90,34 @@ def build_parser() -> argparse.ArgumentParser:
82
90
  metavar="OWNER/REPO",
83
91
  help="only watch this repo; repeatable. Replaces the config list entirely.",
84
92
  )
93
+ over.add_argument(
94
+ "--repos-source",
95
+ dest="repos_source",
96
+ choices=[REPOS_STATIC, REPOS_BOWS],
97
+ help="where the allowlist comes from: 'static' (the repos list alone, "
98
+ "the default) or 'bows' (that list unioned with the repos named by the "
99
+ "operator's active `autodev: <owner>/<repo>` bodies of work, "
100
+ "re-derived every --bows-refresh-polls passes). Under bows an EMPTY "
101
+ f"allowlist watches nothing. Overridden by ${REPOS_SOURCE_ENV}",
102
+ )
103
+ over.add_argument(
104
+ "--bows-refresh-polls",
105
+ dest="bows_refresh_polls",
106
+ type=int,
107
+ metavar="N",
108
+ help="bows mode: re-derive the allowlist every N poll passes (>= 1). "
109
+ f"Overridden by ${BOWS_REFRESH_POLLS_ENV}",
110
+ )
111
+ over.add_argument(
112
+ "--bow-owner",
113
+ dest="bow_owners",
114
+ action="append",
115
+ metavar="ACTOR_ID",
116
+ help="bows mode: an Alissa actor id whose bodies of work may enroll a "
117
+ "repo; repeatable, and one value may carry a `|`/`,`-separated list. "
118
+ "Replaces the config list entirely. Unset = this token's own actor, "
119
+ f"resolved at boot via GET /v1/ping. Overridden by ${BOW_OWNERS_ENV}",
120
+ )
85
121
  over.add_argument(
86
122
  "--author",
87
123
  dest="authors",
@@ -218,6 +254,23 @@ def build_parser() -> argparse.ArgumentParser:
218
254
  action="store_false",
219
255
  help="do not push loop telemetry even if the config enables it",
220
256
  )
257
+ vitals = over.add_mutually_exclusive_group()
258
+ vitals.add_argument(
259
+ "--fleet-vitals",
260
+ dest="fleet_vitals_enabled",
261
+ action="store_true",
262
+ default=None,
263
+ help="push one fleet-vitals snapshot (heartbeat, sessions, inbox, "
264
+ "rate, memory) to Studio's POST /v1/loop/fleet-vitals at the end of "
265
+ "every completed poll pass — best-effort, never fatal. "
266
+ f"Overridden by ${FLEET_VITALS_ENV}",
267
+ )
268
+ vitals.add_argument(
269
+ "--no-fleet-vitals",
270
+ dest="fleet_vitals_enabled",
271
+ action="store_false",
272
+ help="do not push fleet vitals even if the config enables it",
273
+ )
221
274
  over.add_argument(
222
275
  "--alissa-endpoint",
223
276
  dest="alissa_endpoint",
@@ -248,6 +301,9 @@ def overrides_from(args: argparse.Namespace) -> dict:
248
301
  through. `repos` becomes a tuple so it matches the config-file form."""
249
302
  return {
250
303
  "repos": tuple(args.repos) if args.repos else None,
304
+ "repos_source": args.repos_source,
305
+ "bows_refresh_polls": args.bows_refresh_polls,
306
+ "bow_owners": tuple(args.bow_owners) if args.bow_owners else None,
251
307
  "authors": tuple(args.authors) if args.authors else None,
252
308
  "operators": tuple(args.operators) if args.operators else None,
253
309
  "poll_interval": args.poll_interval,
@@ -269,6 +325,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
269
325
  "task_list_self_scope": args.task_list_self_scope,
270
326
  "task_list_bow_id": args.task_list_bow_id,
271
327
  "loop_events_enabled": args.loop_events_enabled,
328
+ "fleet_vitals_enabled": args.fleet_vitals_enabled,
272
329
  "alissa_endpoint": args.alissa_endpoint,
273
330
  "dry_run": args.dry_run,
274
331
  }
@@ -284,6 +341,54 @@ def resolve_config(args: argparse.Namespace) -> Config:
284
341
  return Config.build(workspace_root, file_data, overrides_from(args))
285
342
 
286
343
 
344
+ def resolve_feed_authority(
345
+ config: Config, client: "AlissaClient | None" = None
346
+ ) -> Config:
347
+ """Decide WHOSE bodies of work may enroll a repo, and say so out loud.
348
+
349
+ Two answers, one of them free:
350
+
351
+ * `bow_owners` **set** — the operator named the authority explicitly. That
352
+ is the multi-actor case: the feed containers belong to an actor this
353
+ token is not. Nothing is resolved, nothing is called.
354
+ * `bow_owners` **unset** — the authority is **self**. The token already
355
+ carries an identity, so `GET /v1/ping` answers the question the operator
356
+ would otherwise have hand-copied out of the API.
357
+
358
+ The whoami call is **fatal on failure**, deliberately. Carrying on with an
359
+ empty authority is technically safe (nothing would be trusted, so nothing
360
+ would enroll) but it is safe *silently*: the operator would see a daemon
361
+ that runs and watches only the static list, which is exactly what a
362
+ correctly configured daemon with no feeds yet looks like. Dying names the
363
+ reason while someone is watching. The property being protected is not
364
+ "self is trusted" but "only a KNOWN actor is trusted" — an unanswered
365
+ whoami leaves that unknown, and no default can fill it in. Never falling
366
+ back to trusting everything is the other half of the same rule.
367
+
368
+ Under `static` this is a no-op: that mode never reads a Body of Work, so
369
+ it never needs an identity, and a daemon that only watches its static
370
+ list must not fail to boot because an endpoint it does not use was
371
+ unreachable.
372
+ """
373
+ if config.repos_source != REPOS_BOWS:
374
+ return config
375
+
376
+ if config.bow_owners:
377
+ log.info("bow feed authority: explicit (%s)", ", ".join(config.bow_owners))
378
+ return config
379
+
380
+ identity = (client or AlissaClient(base=config.alissa_endpoint)).ping()
381
+ log.info("bow feed authority: self (%s)", identity.actor_id)
382
+ if identity.display_name:
383
+ # Logged as context, never compared: a display name is renameable,
384
+ # which is why `bow_owners` refuses to hold one.
385
+ log.debug("resolved from this token's identity: %r", identity.display_name)
386
+ # The id came from the API, so it is the authority on its own shape and is
387
+ # NOT put back through the config's actor-id check -- that check exists to
388
+ # catch an operator typing a username, not to second-guess the server.
389
+ return dataclasses.replace(config, bow_owners=(identity.actor_id,))
390
+
391
+
287
392
  def main(argv: list[str] | None = None) -> int:
288
393
  args = build_parser().parse_args(argv)
289
394
 
@@ -306,6 +411,8 @@ def main(argv: list[str] | None = None) -> int:
306
411
  try:
307
412
  config = resolve_config(args)
308
413
  log.info("workspace: %s", config.workspace_root)
414
+ log.info("repos_source: %s", config.repos_source)
415
+ config = resolve_feed_authority(config)
309
416
 
310
417
  watcher = ReviewWatcher(config)
311
418
  for warning in watcher.preflight():
@@ -349,6 +456,18 @@ def main(argv: list[str] | None = None) -> int:
349
456
  except (FileNotFoundError, ValueError) as exc:
350
457
  print(f"config error: {exc}", file=sys.stderr)
351
458
  return 2
459
+ except AlissaError as exc:
460
+ # Only reachable from resolve_feed_authority: an unanswered whoami
461
+ # leaves the feed authority unknown, and starting with it unknown is
462
+ # the one failure this mode must not have (the poll loop's own
463
+ # feed reads never raise -- see bows.BowRepoSource.refresh).
464
+ print(
465
+ f"alissa error: {exc} — repos_source='bows' could not resolve the "
466
+ f"feed authority (GET /v1/ping). Check ALISSA_API_TOKEN and "
467
+ f"alissa_endpoint, or set bow_owners explicitly",
468
+ file=sys.stderr,
469
+ )
470
+ return 2
352
471
  except CommandError as exc:
353
472
  print(f"error: {exc}", file=sys.stderr)
354
473
  return 1
@@ -1,4 +1,6 @@
1
- """Alissa REST access — the one write loop telemetry needs (issue #112).
1
+ """Alissa REST access — loop telemetry's write (issue #112), the fleet-vitals
2
+ write beside it (issue #126) and the two reads `repos_source: bows` needs
3
+ (issue #119).
2
4
 
3
5
  This is the SECOND Alissa adapter in the package, and the split is deliberate.
4
6
  `alissa.py` shells out to the `alissa` CLI, which is the daemon's established
@@ -20,6 +22,23 @@ The caller here is a BEST-EFFORT emitter (`loop_events`): its answer to every
20
22
  bucket is the same — warn once and let the pass complete — so the taxonomy
21
23
  exists for the log line, which should say "your token is wrong" (permanent,
22
24
  operator-fixable) differently from "the API blinked".
25
+
26
+ The two READS (issue #119) ride the same transport and the same taxonomy, and
27
+ they are the shape devloop's client already gives them, so the two daemons
28
+ agree about the wire:
29
+
30
+ ``GET /v1/ping``
31
+ ``{"pong", "timestamp", "userId", "actorId", "displayName"}`` — the
32
+ identity this token acts as. `actorId` is the same opaque id that appears
33
+ as `ownerActorId` on a Body of Work, which is what makes "trust my own
34
+ feeds" resolvable at boot without an operator hand-copying an id.
35
+
36
+ ``GET /v1/bodies-of-work?includeShared=true``
37
+ ``{"bodiesOfWork": [{"_id", "title", "status", "ownerActorId", ...}]}``.
38
+ `includeShared=true` is load-bearing: the endpoint defaults to OWNED-only,
39
+ and a feed container is the operator's with the daemon merely a
40
+ collaborator. It also widens the listing to whatever anyone shared, which
41
+ is why `bows.py` gates on the owner and never on the title alone.
23
42
  """
24
43
 
25
44
  from __future__ import annotations
@@ -29,6 +48,7 @@ import logging
29
48
  import os
30
49
  import urllib.error
31
50
  import urllib.request
51
+ from dataclasses import dataclass
32
52
 
33
53
  log = logging.getLogger(__name__)
34
54
 
@@ -43,6 +63,12 @@ ENV_TOKEN = "ALISSA_API_TOKEN"
43
63
  # rather than silently posting a request the API will 400.
44
64
  MAX_EVENTS_PER_POST = 200
45
65
 
66
+ # The `error` code a 403 carries when the token's user has not activated the
67
+ # stacked app that owns the loop ingest. Advisory (classification keys on the
68
+ # status), but the vitals pusher reads it to warn ONCE with the activate URL
69
+ # instead of once per pass.
70
+ NOT_ACTIVATED = "not_activated"
71
+
46
72
 
47
73
  class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
48
74
  """Refuse every redirect instead of following it (PR #113 round 1).
@@ -89,8 +115,38 @@ class AlissaTransient(AlissaError):
89
115
  deterministic dedupe keys are what make it harmless."""
90
116
 
91
117
 
118
+ @dataclass(frozen=True)
119
+ class Identity:
120
+ """Who this token acts as (`GET /v1/ping`).
121
+
122
+ `actor_id` is the field the feed-authority gate compares against a Body
123
+ of Work's `ownerActorId`. The other two are context for the log line —
124
+ a display name is renameable, so nothing is ever compared against it."""
125
+
126
+ actor_id: str
127
+ user_id: str = ""
128
+ display_name: str = ""
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class BodyOfWork:
133
+ """A Body of Work as the LIST endpoint reports it — only the four fields
134
+ the feed reads.
135
+
136
+ `owner_id` is the container's provenance and the only field here that is
137
+ not operator-authored text: the listing includes containers merely SHARED
138
+ with this actor, so the title alone cannot say whether a feed is one the
139
+ operator set up. Empty when the payload omits it, which the authority gate
140
+ treats as untrusted rather than unknown."""
141
+
142
+ id: str
143
+ title: str
144
+ status: str = ""
145
+ owner_id: str = ""
146
+
147
+
92
148
  class AlissaClient:
93
- """The one write, with the transport hidden behind the taxonomy.
149
+ """One write and two reads, with the transport hidden behind the taxonomy.
94
150
 
95
151
  Reads `ALISSA_API_TOKEN` from the environment; `base` defaults to the
96
152
  public API. Both are constructor arguments so a test never needs the
@@ -108,24 +164,31 @@ class AlissaClient:
108
164
  self._token = token if token is not None else os.environ.get(ENV_TOKEN)
109
165
  self._timeout = timeout
110
166
 
111
- def _request(self, path: str, payload: dict) -> object:
112
- """One POST. Every failure leaves as a taxonomy exception — the caller
113
- never sees a raw urllib error or an HTTP status."""
167
+ def _request(self, path: str, payload: "dict | None" = None) -> object:
168
+ """One call: a POST when `payload` is given, a GET otherwise. Every
169
+ failure leaves as a taxonomy exception the caller never sees a raw
170
+ urllib error or an HTTP status. Both verbs share the redirect-refusing
171
+ opener: a 30x on a GET would carry the bearer token across hosts just
172
+ as it would on the POST."""
114
173
  if not self._token:
115
174
  # No token at all is an auth condition, not a transport one: the
116
175
  # operator must set the env var. Fail the way a 401 would, so the
117
176
  # emitter's warning reads as permanent rather than transient.
118
177
  raise AlissaAuthError(0, f"{ENV_TOKEN} is not set")
119
178
 
179
+ headers = {
180
+ "Accept": "application/json",
181
+ "Authorization": f"Bearer {self._token}",
182
+ }
183
+ data = None
184
+ if payload is not None:
185
+ headers["Content-Type"] = "application/json"
186
+ data = json.dumps(payload).encode("utf-8")
120
187
  req = urllib.request.Request(
121
188
  f"{self.base}{path}",
122
- method="POST",
123
- data=json.dumps(payload).encode("utf-8"),
124
- headers={
125
- "Accept": "application/json",
126
- "Content-Type": "application/json",
127
- "Authorization": f"Bearer {self._token}",
128
- },
189
+ method="POST" if payload is not None else "GET",
190
+ data=data,
191
+ headers=headers,
129
192
  )
130
193
  try:
131
194
  with _opener.open(req, timeout=self._timeout) as resp:
@@ -189,3 +252,86 @@ class AlissaClient:
189
252
  )
190
253
  payload = self._request("/v1/loop-events", {"events": events})
191
254
  return payload if isinstance(payload, dict) else {}
255
+
256
+ def post_fleet_vitals(self, snapshot: dict) -> dict:
257
+ """Replace this seat's fleet-vitals snapshot (`POST
258
+ /v1/loop/fleet-vitals`, issue #126).
259
+
260
+ One body, one seat, replaced on every call — there is no batching
261
+ and no dedupe key, because the API keeps only the LATEST snapshot per
262
+ user×seat (loop events are the history). Returns the API's
263
+ `{"seat", "receivedAt", "replaced"}` payload (empty dict when the
264
+ body was empty), for the caller's debug line. The API is strict —
265
+ unknown keys 400, lists cap at 50, the body at 64 KB — and the
266
+ BUILDER (`fleet_vitals`) owns fitting the snapshot to that, so this
267
+ method sends exactly what it is handed.
268
+ """
269
+ payload = self._request("/v1/loop/fleet-vitals", snapshot)
270
+ return payload if isinstance(payload, dict) else {}
271
+
272
+ def ping(self) -> Identity:
273
+ """The identity this token acts as (`GET /v1/ping`).
274
+
275
+ On the BOOT path of `repos_source: bows`, and fatal there — see
276
+ `__main__.resolve_feed_authority` for why an unanswered whoami must
277
+ stop the daemon rather than soften into an empty authority."""
278
+ payload = self._request("/v1/ping")
279
+ row = payload if isinstance(payload, dict) else {}
280
+ actor_id = row.get("actorId")
281
+ if not isinstance(actor_id, str) or not actor_id.strip():
282
+ # A 2xx with no actorId is a contract violation, not a missing
283
+ # resource: name it as such rather than letting an empty authority
284
+ # travel onwards looking like a configuration choice.
285
+ raise AlissaError(
286
+ 200,
287
+ f"GET /v1/ping returned no actorId (got {payload!r}) — the "
288
+ f"token's acting identity could not be determined",
289
+ )
290
+ return Identity(
291
+ actor_id=actor_id.strip(),
292
+ user_id=str(row.get("userId") or ""),
293
+ display_name=str(row.get("displayName") or ""),
294
+ )
295
+
296
+ def list_bodies_of_work(self) -> "list[BodyOfWork]":
297
+ """Every Body of Work this actor owns OR collaborates on — ONE call.
298
+
299
+ There is no server-side title or status filter, so the caller narrows.
300
+ Rows without an `_id` are dropped: a container that cannot be
301
+ addressed cannot be reported on either.
302
+
303
+ A payload with no `bodiesOfWork` LIST is a contract violation, not an
304
+ empty listing, and is raised as such -- `ping`'s missing-`actorId`
305
+ reasoning applied to the other read. Degrading it to `[]` would report
306
+ SUCCESS to the refresh, which would then derive nothing and drop every
307
+ feed-enrolled repo on a routine INFO line: a response-shape change or
308
+ an `alissa_endpoint` answering 200 with something else would silently
309
+ un-enroll everything, which is precisely what the never-shrink rule
310
+ promises cannot happen. Raising routes it to the refresh's failure
311
+ path instead, which keeps the last good set. `{"bodiesOfWork": []}`
312
+ is untouched -- present-and-empty is a legitimate empty listing, and
313
+ the documented way an operator un-enrolls the last repo."""
314
+ payload = self._request("/v1/bodies-of-work?includeShared=true")
315
+ rows = payload.get("bodiesOfWork") if isinstance(payload, dict) else None
316
+ if not isinstance(rows, list):
317
+ raise AlissaError(
318
+ 200,
319
+ f"GET /v1/bodies-of-work returned no bodiesOfWork list "
320
+ f"(got {payload!r})",
321
+ )
322
+ out: "list[BodyOfWork]" = []
323
+ for row in rows:
324
+ if not isinstance(row, dict):
325
+ continue
326
+ bow_id = row.get("_id")
327
+ if not isinstance(bow_id, str) or not bow_id:
328
+ continue
329
+ out.append(
330
+ BodyOfWork(
331
+ id=bow_id,
332
+ title=str(row.get("title") or ""),
333
+ status=str(row.get("status") or ""),
334
+ owner_id=str(row.get("ownerActorId") or ""),
335
+ )
336
+ )
337
+ return out