alissa-tools-github-revloop 0.27.0__tar.gz → 0.29.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 (35) hide show
  1. {alissa_tools_github_revloop-0.27.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.29.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/__main__.py +128 -0
  3. alissa_tools_github_revloop-0.29.0/src/main/alissa/tools/github/revloop/alissa_client.py +314 -0
  4. alissa_tools_github_revloop-0.29.0/src/main/alissa/tools/github/revloop/bows.py +405 -0
  5. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/config.py +408 -13
  6. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/loop.py +224 -3
  7. alissa_tools_github_revloop-0.29.0/src/main/alissa/tools/github/revloop/loop_events.py +518 -0
  8. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/state.py +160 -16
  9. alissa_tools_github_revloop-0.29.0/src/main/alissa/tools/github/revloop/version +1 -0
  10. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/sources.py +14 -0
  11. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  12. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +3 -0
  13. alissa_tools_github_revloop-0.27.0/src/main/alissa/tools/github/revloop/version +0 -1
  14. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/LICENSE +0 -0
  15. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/MANIFEST.in +0 -0
  16. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/NOTICE +0 -0
  17. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/README.md +0 -0
  18. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/requirements.txt +0 -0
  19. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/setup.cfg +0 -0
  20. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/setup.py +0 -0
  21. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  22. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  23. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  24. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  25. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  26. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  27. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  28. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  29. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  30. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  31. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  32. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  33. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  34. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  35. {alissa_tools_github_revloop-0.27.0 → alissa_tools_github_revloop-0.29.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.27.0
3
+ Version: 0.29.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -3,17 +3,25 @@
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,
12
16
  HUB_ADD,
13
17
  HUB_SKIP,
18
+ LOOP_EVENTS_ENV,
14
19
  ON_MISSING_CREATE,
15
20
  ON_MISSING_SKIP,
16
21
  ON_MISSING_SPAWN,
22
+ REPOS_BOWS,
23
+ REPOS_SOURCE_ENV,
24
+ REPOS_STATIC,
17
25
  TASK_LIST_BOW_ENV,
18
26
  Config,
19
27
  load_config_file,
@@ -81,6 +89,34 @@ def build_parser() -> argparse.ArgumentParser:
81
89
  metavar="OWNER/REPO",
82
90
  help="only watch this repo; repeatable. Replaces the config list entirely.",
83
91
  )
92
+ over.add_argument(
93
+ "--repos-source",
94
+ dest="repos_source",
95
+ choices=[REPOS_STATIC, REPOS_BOWS],
96
+ help="where the allowlist comes from: 'static' (the repos list alone, "
97
+ "the default) or 'bows' (that list unioned with the repos named by the "
98
+ "operator's active `autodev: <owner>/<repo>` bodies of work, "
99
+ "re-derived every --bows-refresh-polls passes). Under bows an EMPTY "
100
+ f"allowlist watches nothing. Overridden by ${REPOS_SOURCE_ENV}",
101
+ )
102
+ over.add_argument(
103
+ "--bows-refresh-polls",
104
+ dest="bows_refresh_polls",
105
+ type=int,
106
+ metavar="N",
107
+ help="bows mode: re-derive the allowlist every N poll passes (>= 1). "
108
+ f"Overridden by ${BOWS_REFRESH_POLLS_ENV}",
109
+ )
110
+ over.add_argument(
111
+ "--bow-owner",
112
+ dest="bow_owners",
113
+ action="append",
114
+ metavar="ACTOR_ID",
115
+ help="bows mode: an Alissa actor id whose bodies of work may enroll a "
116
+ "repo; repeatable, and one value may carry a `|`/`,`-separated list. "
117
+ "Replaces the config list entirely. Unset = this token's own actor, "
118
+ f"resolved at boot via GET /v1/ping. Overridden by ${BOW_OWNERS_ENV}",
119
+ )
84
120
  over.add_argument(
85
121
  "--author",
86
122
  dest="authors",
@@ -200,6 +236,31 @@ def build_parser() -> argparse.ArgumentParser:
200
236
  f"invisible to this daemon. Overridden by ${TASK_LIST_BOW_ENV}",
201
237
  )
202
238
 
239
+ events = over.add_mutually_exclusive_group()
240
+ events.add_argument(
241
+ "--loop-events",
242
+ dest="loop_events_enabled",
243
+ action="store_true",
244
+ default=None,
245
+ help="push loop telemetry (rounds spawned, verdicts, cap-outs, "
246
+ "stability holds, stalls, checks holds) to Studio's POST "
247
+ "/v1/loop-events once per poll pass — best-effort, never fatal. "
248
+ f"Overridden by ${LOOP_EVENTS_ENV}",
249
+ )
250
+ events.add_argument(
251
+ "--no-loop-events",
252
+ dest="loop_events_enabled",
253
+ action="store_false",
254
+ help="do not push loop telemetry even if the config enables it",
255
+ )
256
+ over.add_argument(
257
+ "--alissa-endpoint",
258
+ dest="alissa_endpoint",
259
+ metavar="URL",
260
+ help="the Alissa API base the loop-events client posts to "
261
+ "(default: https://api.alissa.app)",
262
+ )
263
+
203
264
  dry = over.add_mutually_exclusive_group()
204
265
  dry.add_argument(
205
266
  "--dry-run",
@@ -222,6 +283,9 @@ def overrides_from(args: argparse.Namespace) -> dict:
222
283
  through. `repos` becomes a tuple so it matches the config-file form."""
223
284
  return {
224
285
  "repos": tuple(args.repos) if args.repos else None,
286
+ "repos_source": args.repos_source,
287
+ "bows_refresh_polls": args.bows_refresh_polls,
288
+ "bow_owners": tuple(args.bow_owners) if args.bow_owners else None,
225
289
  "authors": tuple(args.authors) if args.authors else None,
226
290
  "operators": tuple(args.operators) if args.operators else None,
227
291
  "poll_interval": args.poll_interval,
@@ -242,6 +306,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
242
306
  "review_task_miss_ttl_polls": args.review_task_miss_ttl_polls,
243
307
  "task_list_self_scope": args.task_list_self_scope,
244
308
  "task_list_bow_id": args.task_list_bow_id,
309
+ "loop_events_enabled": args.loop_events_enabled,
310
+ "alissa_endpoint": args.alissa_endpoint,
245
311
  "dry_run": args.dry_run,
246
312
  }
247
313
 
@@ -256,6 +322,54 @@ def resolve_config(args: argparse.Namespace) -> Config:
256
322
  return Config.build(workspace_root, file_data, overrides_from(args))
257
323
 
258
324
 
325
+ def resolve_feed_authority(
326
+ config: Config, client: "AlissaClient | None" = None
327
+ ) -> Config:
328
+ """Decide WHOSE bodies of work may enroll a repo, and say so out loud.
329
+
330
+ Two answers, one of them free:
331
+
332
+ * `bow_owners` **set** — the operator named the authority explicitly. That
333
+ is the multi-actor case: the feed containers belong to an actor this
334
+ token is not. Nothing is resolved, nothing is called.
335
+ * `bow_owners` **unset** — the authority is **self**. The token already
336
+ carries an identity, so `GET /v1/ping` answers the question the operator
337
+ would otherwise have hand-copied out of the API.
338
+
339
+ The whoami call is **fatal on failure**, deliberately. Carrying on with an
340
+ empty authority is technically safe (nothing would be trusted, so nothing
341
+ would enroll) but it is safe *silently*: the operator would see a daemon
342
+ that runs and watches only the static list, which is exactly what a
343
+ correctly configured daemon with no feeds yet looks like. Dying names the
344
+ reason while someone is watching. The property being protected is not
345
+ "self is trusted" but "only a KNOWN actor is trusted" — an unanswered
346
+ whoami leaves that unknown, and no default can fill it in. Never falling
347
+ back to trusting everything is the other half of the same rule.
348
+
349
+ Under `static` this is a no-op: that mode never reads a Body of Work, so
350
+ it never needs an identity, and a daemon that only watches its static
351
+ list must not fail to boot because an endpoint it does not use was
352
+ unreachable.
353
+ """
354
+ if config.repos_source != REPOS_BOWS:
355
+ return config
356
+
357
+ if config.bow_owners:
358
+ log.info("bow feed authority: explicit (%s)", ", ".join(config.bow_owners))
359
+ return config
360
+
361
+ identity = (client or AlissaClient(base=config.alissa_endpoint)).ping()
362
+ log.info("bow feed authority: self (%s)", identity.actor_id)
363
+ if identity.display_name:
364
+ # Logged as context, never compared: a display name is renameable,
365
+ # which is why `bow_owners` refuses to hold one.
366
+ log.debug("resolved from this token's identity: %r", identity.display_name)
367
+ # The id came from the API, so it is the authority on its own shape and is
368
+ # NOT put back through the config's actor-id check -- that check exists to
369
+ # catch an operator typing a username, not to second-guess the server.
370
+ return dataclasses.replace(config, bow_owners=(identity.actor_id,))
371
+
372
+
259
373
  def main(argv: list[str] | None = None) -> int:
260
374
  args = build_parser().parse_args(argv)
261
375
 
@@ -278,6 +392,8 @@ def main(argv: list[str] | None = None) -> int:
278
392
  try:
279
393
  config = resolve_config(args)
280
394
  log.info("workspace: %s", config.workspace_root)
395
+ log.info("repos_source: %s", config.repos_source)
396
+ config = resolve_feed_authority(config)
281
397
 
282
398
  watcher = ReviewWatcher(config)
283
399
  for warning in watcher.preflight():
@@ -321,6 +437,18 @@ def main(argv: list[str] | None = None) -> int:
321
437
  except (FileNotFoundError, ValueError) as exc:
322
438
  print(f"config error: {exc}", file=sys.stderr)
323
439
  return 2
440
+ except AlissaError as exc:
441
+ # Only reachable from resolve_feed_authority: an unanswered whoami
442
+ # leaves the feed authority unknown, and starting with it unknown is
443
+ # the one failure this mode must not have (the poll loop's own
444
+ # feed reads never raise -- see bows.BowRepoSource.refresh).
445
+ print(
446
+ f"alissa error: {exc} — repos_source='bows' could not resolve the "
447
+ f"feed authority (GET /v1/ping). Check ALISSA_API_TOKEN and "
448
+ f"alissa_endpoint, or set bow_owners explicitly",
449
+ file=sys.stderr,
450
+ )
451
+ return 2
324
452
  except CommandError as exc:
325
453
  print(f"error: {exc}", file=sys.stderr)
326
454
  return 1
@@ -0,0 +1,314 @@
1
+ """Alissa REST access — loop telemetry's one write (issue #112) and the two
2
+ reads `repos_source: bows` needs (issue #119).
3
+
4
+ This is the SECOND Alissa adapter in the package, and the split is deliberate.
5
+ `alissa.py` shells out to the `alissa` CLI, which is the daemon's established
6
+ Alissa idiom for everything it does today (the review-task search, the tmux
7
+ queue, the CR6 envelope reads). Loop telemetry needs a thing that idiom cannot
8
+ supply: the CLI has no loop-events command, and it cannot attach the actor
9
+ identity `POST /v1/loop-events` keys its rows by — the API stores events under
10
+ the token's principal user, which is exactly the credential this daemon's
11
+ `ALISSA_API_TOKEN` already carries.
12
+
13
+ So the one write goes over the REST API directly, in the shape devloop's
14
+ `alissa_client.py` adopted (PR #91 there): stdlib `urllib` only — the
15
+ distribution ships no third-party runtime dependency and this must not be the
16
+ change that adds one — a bearer token from the environment, bounded timeouts,
17
+ and errors classified into a small taxonomy instead of leaking raw urllib
18
+ exceptions. The CLI adapter stays untouched for everything else.
19
+
20
+ The caller here is a BEST-EFFORT emitter (`loop_events`): its answer to every
21
+ bucket is the same — warn once and let the pass complete — so the taxonomy
22
+ exists for the log line, which should say "your token is wrong" (permanent,
23
+ operator-fixable) differently from "the API blinked".
24
+
25
+ The two READS (issue #119) ride the same transport and the same taxonomy, and
26
+ they are the shape devloop's client already gives them, so the two daemons
27
+ agree about the wire:
28
+
29
+ ``GET /v1/ping``
30
+ ``{"pong", "timestamp", "userId", "actorId", "displayName"}`` — the
31
+ identity this token acts as. `actorId` is the same opaque id that appears
32
+ as `ownerActorId` on a Body of Work, which is what makes "trust my own
33
+ feeds" resolvable at boot without an operator hand-copying an id.
34
+
35
+ ``GET /v1/bodies-of-work?includeShared=true``
36
+ ``{"bodiesOfWork": [{"_id", "title", "status", "ownerActorId", ...}]}``.
37
+ `includeShared=true` is load-bearing: the endpoint defaults to OWNED-only,
38
+ and a feed container is the operator's with the daemon merely a
39
+ collaborator. It also widens the listing to whatever anyone shared, which
40
+ is why `bows.py` gates on the owner and never on the title alone.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import json
46
+ import logging
47
+ import os
48
+ import urllib.error
49
+ import urllib.request
50
+ from dataclasses import dataclass
51
+
52
+ log = logging.getLogger(__name__)
53
+
54
+ DEFAULT_ENDPOINT = "https://api.alissa.app"
55
+
56
+ # The env var the `alissa` CLI itself reads, so a daemon whose CLI is already
57
+ # authenticated needs no second secret.
58
+ ENV_TOKEN = "ALISSA_API_TOKEN"
59
+
60
+ # The ingest cap: POST /v1/loop-events takes 1-200 events per call. The
61
+ # EMITTER splits batches at this bound; the client refuses an oversized one
62
+ # rather than silently posting a request the API will 400.
63
+ MAX_EVENTS_PER_POST = 200
64
+
65
+
66
+ class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
67
+ """Refuse every redirect instead of following it (PR #113 round 1).
68
+
69
+ The default handler copies the request's headers — `Authorization`
70
+ included — onto the redirected request, ACROSS HOSTS, so a 30x from
71
+ whatever `alissa_endpoint` names would hand the bearer token to the
72
+ redirect target (and 301/302/303 would downgrade the POST to a GET that
73
+ ingests nothing while reading as success). A redirected ingest cannot
74
+ succeed anyway, so refusal beats strip-and-follow: returning None makes
75
+ urllib raise the 30x as an HTTPError, which the taxonomy reports like any
76
+ other unexpected status.
77
+ """
78
+
79
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
80
+ return None
81
+
82
+
83
+ # One opener for the module: default handlers with only the redirect
84
+ # behaviour replaced, built once because handler construction is not free
85
+ # and every client shares the same policy.
86
+ _opener = urllib.request.build_opener(_RefuseRedirects())
87
+
88
+
89
+ class AlissaError(Exception):
90
+ """Base of the taxonomy. `status` is 0 for a transport failure (there was
91
+ no HTTP response to carry one)."""
92
+
93
+ def __init__(self, status: int, detail: object, code: "str | None" = None):
94
+ super().__init__(f"HTTP {status}: {detail}" if status else str(detail))
95
+ self.status = status
96
+ self.detail = detail
97
+ self.code = code
98
+
99
+
100
+ class AlissaAuthError(AlissaError):
101
+ """401/403, or no token at all. Permanent and operator-fixable — retrying
102
+ it every pass only writes the same warning again."""
103
+
104
+
105
+ class AlissaTransient(AlissaError):
106
+ """408/429/5xx and every transport failure (DNS, refused, timeout). The
107
+ 'the API blinked' bucket — re-emission next pass is the retry, and the
108
+ deterministic dedupe keys are what make it harmless."""
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class Identity:
113
+ """Who this token acts as (`GET /v1/ping`).
114
+
115
+ `actor_id` is the field the feed-authority gate compares against a Body
116
+ of Work's `ownerActorId`. The other two are context for the log line —
117
+ a display name is renameable, so nothing is ever compared against it."""
118
+
119
+ actor_id: str
120
+ user_id: str = ""
121
+ display_name: str = ""
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class BodyOfWork:
126
+ """A Body of Work as the LIST endpoint reports it — only the four fields
127
+ the feed reads.
128
+
129
+ `owner_id` is the container's provenance and the only field here that is
130
+ not operator-authored text: the listing includes containers merely SHARED
131
+ with this actor, so the title alone cannot say whether a feed is one the
132
+ operator set up. Empty when the payload omits it, which the authority gate
133
+ treats as untrusted rather than unknown."""
134
+
135
+ id: str
136
+ title: str
137
+ status: str = ""
138
+ owner_id: str = ""
139
+
140
+
141
+ class AlissaClient:
142
+ """One write and two reads, with the transport hidden behind the taxonomy.
143
+
144
+ Reads `ALISSA_API_TOKEN` from the environment; `base` defaults to the
145
+ public API. Both are constructor arguments so a test never needs the
146
+ network and an operator can point a daemon at another deployment
147
+ (`alissa_endpoint` in the config)."""
148
+
149
+ def __init__(
150
+ self,
151
+ token: "str | None" = None,
152
+ base: "str | None" = None,
153
+ *,
154
+ timeout: int = 30,
155
+ ):
156
+ self.base = (base or DEFAULT_ENDPOINT).rstrip("/")
157
+ self._token = token if token is not None else os.environ.get(ENV_TOKEN)
158
+ self._timeout = timeout
159
+
160
+ def _request(self, path: str, payload: "dict | None" = None) -> object:
161
+ """One call: a POST when `payload` is given, a GET otherwise. Every
162
+ failure leaves as a taxonomy exception — the caller never sees a raw
163
+ urllib error or an HTTP status. Both verbs share the redirect-refusing
164
+ opener: a 30x on a GET would carry the bearer token across hosts just
165
+ as it would on the POST."""
166
+ if not self._token:
167
+ # No token at all is an auth condition, not a transport one: the
168
+ # operator must set the env var. Fail the way a 401 would, so the
169
+ # emitter's warning reads as permanent rather than transient.
170
+ raise AlissaAuthError(0, f"{ENV_TOKEN} is not set")
171
+
172
+ headers = {
173
+ "Accept": "application/json",
174
+ "Authorization": f"Bearer {self._token}",
175
+ }
176
+ data = None
177
+ if payload is not None:
178
+ headers["Content-Type"] = "application/json"
179
+ data = json.dumps(payload).encode("utf-8")
180
+ req = urllib.request.Request(
181
+ f"{self.base}{path}",
182
+ method="POST" if payload is not None else "GET",
183
+ data=data,
184
+ headers=headers,
185
+ )
186
+ try:
187
+ with _opener.open(req, timeout=self._timeout) as resp:
188
+ raw = resp.read().decode("utf-8")
189
+ except urllib.error.HTTPError as exc:
190
+ raise self._classify(exc) from None
191
+ except urllib.error.URLError as exc:
192
+ raise AlissaTransient(0, str(exc.reason)) from None
193
+ except (TimeoutError, OSError) as exc: # pragma: no cover - defence
194
+ # A socket timeout on the READ does not arrive as URLError.
195
+ raise AlissaTransient(0, str(exc)) from None
196
+ try:
197
+ return json.loads(raw) if raw.strip() else {}
198
+ except json.JSONDecodeError as exc:
199
+ # A 2xx that is not JSON is a contract violation, not a retry
200
+ # signal -- but it must not escape as a bare ValueError either,
201
+ # because the emitter catches AlissaError and nothing else.
202
+ raise AlissaError(200, f"response was not JSON ({exc})") from None
203
+
204
+ @staticmethod
205
+ def _classify(exc: "urllib.error.HTTPError") -> AlissaError:
206
+ """Map an HTTP error onto the taxonomy. The API sends JSON error
207
+ bodies (`{"error": CODE, "message": ...}`); the code rides along when
208
+ present, but classification keys on the STATUS — codes are advisory,
209
+ statuses are the contract."""
210
+ detail: object = exc.read().decode("utf-8", "replace")
211
+ code: "str | None" = None
212
+ try:
213
+ parsed = json.loads(detail) # type: ignore[arg-type]
214
+ except (json.JSONDecodeError, TypeError):
215
+ parsed = None
216
+ if isinstance(parsed, dict):
217
+ detail = parsed
218
+ raw_code = parsed.get("error")
219
+ code = raw_code if isinstance(raw_code, str) else None
220
+
221
+ status = exc.code
222
+ if status in (401, 403):
223
+ return AlissaAuthError(status, detail, code)
224
+ if status in (408, 429) or 500 <= status <= 599:
225
+ return AlissaTransient(status, detail, code)
226
+ return AlissaError(status, detail, code)
227
+
228
+ def post_loop_events(self, events: "list[dict]") -> dict:
229
+ """Ingest one batch of loop events (`POST /v1/loop-events`).
230
+
231
+ The API is idempotent on `(user, dedupeKey)`, so re-posting a batch —
232
+ which is exactly what the emitter does after a failed pass — lands as
233
+ silent duplicates, never as errors or overwrites. Returns the API's
234
+ `{"accepted": N, "duplicates": M}` payload (empty dict when the body
235
+ was empty), for the caller's debug line.
236
+
237
+ An oversized batch is refused HERE, loudly: the API fails the whole
238
+ call at >200 events, and the emitter owns the splitting, so reaching
239
+ this guard is a code defect rather than an operational condition.
240
+ """
241
+ if len(events) > MAX_EVENTS_PER_POST:
242
+ raise ValueError(
243
+ f"post_loop_events takes at most {MAX_EVENTS_PER_POST} events "
244
+ f"per call, got {len(events)} — the emitter must split"
245
+ )
246
+ payload = self._request("/v1/loop-events", {"events": events})
247
+ return payload if isinstance(payload, dict) else {}
248
+
249
+ def ping(self) -> Identity:
250
+ """The identity this token acts as (`GET /v1/ping`).
251
+
252
+ On the BOOT path of `repos_source: bows`, and fatal there — see
253
+ `__main__.resolve_feed_authority` for why an unanswered whoami must
254
+ stop the daemon rather than soften into an empty authority."""
255
+ payload = self._request("/v1/ping")
256
+ row = payload if isinstance(payload, dict) else {}
257
+ actor_id = row.get("actorId")
258
+ if not isinstance(actor_id, str) or not actor_id.strip():
259
+ # A 2xx with no actorId is a contract violation, not a missing
260
+ # resource: name it as such rather than letting an empty authority
261
+ # travel onwards looking like a configuration choice.
262
+ raise AlissaError(
263
+ 200,
264
+ f"GET /v1/ping returned no actorId (got {payload!r}) — the "
265
+ f"token's acting identity could not be determined",
266
+ )
267
+ return Identity(
268
+ actor_id=actor_id.strip(),
269
+ user_id=str(row.get("userId") or ""),
270
+ display_name=str(row.get("displayName") or ""),
271
+ )
272
+
273
+ def list_bodies_of_work(self) -> "list[BodyOfWork]":
274
+ """Every Body of Work this actor owns OR collaborates on — ONE call.
275
+
276
+ There is no server-side title or status filter, so the caller narrows.
277
+ Rows without an `_id` are dropped: a container that cannot be
278
+ addressed cannot be reported on either.
279
+
280
+ A payload with no `bodiesOfWork` LIST is a contract violation, not an
281
+ empty listing, and is raised as such -- `ping`'s missing-`actorId`
282
+ reasoning applied to the other read. Degrading it to `[]` would report
283
+ SUCCESS to the refresh, which would then derive nothing and drop every
284
+ feed-enrolled repo on a routine INFO line: a response-shape change or
285
+ an `alissa_endpoint` answering 200 with something else would silently
286
+ un-enroll everything, which is precisely what the never-shrink rule
287
+ promises cannot happen. Raising routes it to the refresh's failure
288
+ path instead, which keeps the last good set. `{"bodiesOfWork": []}`
289
+ is untouched -- present-and-empty is a legitimate empty listing, and
290
+ the documented way an operator un-enrolls the last repo."""
291
+ payload = self._request("/v1/bodies-of-work?includeShared=true")
292
+ rows = payload.get("bodiesOfWork") if isinstance(payload, dict) else None
293
+ if not isinstance(rows, list):
294
+ raise AlissaError(
295
+ 200,
296
+ f"GET /v1/bodies-of-work returned no bodiesOfWork list "
297
+ f"(got {payload!r})",
298
+ )
299
+ out: "list[BodyOfWork]" = []
300
+ for row in rows:
301
+ if not isinstance(row, dict):
302
+ continue
303
+ bow_id = row.get("_id")
304
+ if not isinstance(bow_id, str) or not bow_id:
305
+ continue
306
+ out.append(
307
+ BodyOfWork(
308
+ id=bow_id,
309
+ title=str(row.get("title") or ""),
310
+ status=str(row.get("status") or ""),
311
+ owner_id=str(row.get("ownerActorId") or ""),
312
+ )
313
+ )
314
+ return out