alissa-tools-github-orcloop 0.3.0__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {alissa_tools_github_orcloop-0.3.0/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.2}/PKG-INFO +1 -1
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/__main__.py +112 -12
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/alissa_client.py +58 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/config.py +281 -35
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/loop.py +24 -3
- alissa_tools_github_orcloop-0.3.2/src/main/alissa/tools/github/orcloop/version +1 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_orcloop-0.3.0/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/README.md +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/setup.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/state.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_orcloop-0.3.0 → alissa_tools_github_orcloop-0.3.2}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
O1 wired the config surface and the argument parser; O3 wires the decision
|
|
4
4
|
loop behind it; O6 adds the release-feed selector (`--release-feed bow` and its
|
|
5
|
-
`--bow-prefix` / `--bow-owner` / `--scan-fallback-interval` knobs)
|
|
5
|
+
`--bow-prefix` / `--bow-owner` / `--scan-fallback-interval` knobs); O8 makes the
|
|
6
|
+
feed's authority resolve itself (see `resolve_feed_authority`).
|
|
6
7
|
|
|
7
8
|
The CLI resolves and validates the full configuration
|
|
8
9
|
(four-layer precedence, fail-closed `repos` guard), reports the effective
|
|
@@ -10,6 +11,12 @@ settings, then runs the orchestrator: `--once` for a single sweep (pair it
|
|
|
10
11
|
with `--dry-run -v` to print every decision and its reasons without touching
|
|
11
12
|
GitHub or the task graph), otherwise `run_forever`. Config errors still surface
|
|
12
13
|
here, at startup, with a clean exit code.
|
|
14
|
+
|
|
15
|
+
Startup is the ONE place that may do I/O before the loop begins, and it does
|
|
16
|
+
exactly one call: under `release_feed: bow` with no explicit `bow_owners`, the
|
|
17
|
+
daemon asks Alissa who its token is and makes itself the feed authority. That
|
|
18
|
+
call failing is fatal — the daemon exits rather than starting up unable to tell
|
|
19
|
+
its own containers from a stranger's.
|
|
13
20
|
"""
|
|
14
21
|
|
|
15
22
|
from __future__ import annotations
|
|
@@ -17,9 +24,10 @@ from __future__ import annotations
|
|
|
17
24
|
import argparse
|
|
18
25
|
import logging
|
|
19
26
|
import sys
|
|
27
|
+
from dataclasses import replace
|
|
20
28
|
from pathlib import Path
|
|
21
29
|
|
|
22
|
-
from .alissa_client import AlissaError
|
|
30
|
+
from .alissa_client import AlissaClient, AlissaError
|
|
23
31
|
from .config import (
|
|
24
32
|
FEED_BOW,
|
|
25
33
|
RELEASE_FEEDS,
|
|
@@ -84,9 +92,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
84
92
|
dest="repos",
|
|
85
93
|
action="append",
|
|
86
94
|
metavar="OWNER/REPO",
|
|
87
|
-
help="watch this repo; repeatable
|
|
88
|
-
"
|
|
89
|
-
"
|
|
95
|
+
help="watch this repo; repeatable, and one flag may carry several "
|
|
96
|
+
"entries separated by `|` (the daemon-family convention) or `,`. When "
|
|
97
|
+
"given, REPLACES the config `repos` list. The allowlist is fail-closed: "
|
|
98
|
+
"an empty allowlist is a fatal startup error, not a no-op.",
|
|
90
99
|
)
|
|
91
100
|
over.add_argument("--label", metavar="LABEL", help="issue label released work carries")
|
|
92
101
|
over.add_argument("--poll-interval", type=int, metavar="SECONDS")
|
|
@@ -118,10 +127,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
118
127
|
metavar="ACTOR_ID",
|
|
119
128
|
help="bow feed only: trust bodies of work OWNED by this Alissa actor "
|
|
120
129
|
"as release feeds; repeatable. When given, REPLACES the config "
|
|
121
|
-
"`bow_owners` list.
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"
|
|
130
|
+
"`bow_owners` list. OPTIONAL — left unset, the daemon resolves its own "
|
|
131
|
+
"token's actor at boot and trusts only its own containers; give this "
|
|
132
|
+
"flag only when the containers belong to someone else. Being SHARED a "
|
|
133
|
+
"body of work is never a claim to drive this daemon either way. Must be "
|
|
134
|
+
"an actor ID (the `ownerActorId` on the container's row in "
|
|
135
|
+
"GET /v1/bodies-of-work) — a username or display name is refused.",
|
|
125
136
|
)
|
|
126
137
|
over.add_argument(
|
|
127
138
|
"--scan-fallback-interval",
|
|
@@ -196,6 +207,57 @@ def resolve_config(args: argparse.Namespace) -> Config:
|
|
|
196
207
|
return Config.build(workspace_root, file_data, overrides_from(args))
|
|
197
208
|
|
|
198
209
|
|
|
210
|
+
def resolve_feed_authority(config: Config, alissa: AlissaClient) -> Config:
|
|
211
|
+
"""Decide WHOSE bodies of work drive this daemon, and say so out loud.
|
|
212
|
+
|
|
213
|
+
Two answers, one of them free:
|
|
214
|
+
|
|
215
|
+
* `bow_owners` **set** — the operator named the authority explicitly. That
|
|
216
|
+
is the multi-operator case: the containers belong to an actor this token
|
|
217
|
+
is not. Nothing is resolved, nothing is called.
|
|
218
|
+
* `bow_owners` **unset** — the authority is **self**. The token already
|
|
219
|
+
carries an identity, so `GET /v1/ping` answers the question the operator
|
|
220
|
+
would otherwise have hand-copied out of the API, and the daemon trusts
|
|
221
|
+
exactly its own containers.
|
|
222
|
+
|
|
223
|
+
The whoami call is **fatal on failure**, deliberately, and this is the whole
|
|
224
|
+
fail-closed argument in one place. The alternative — carry on with an empty
|
|
225
|
+
authority — is technically safe (nothing would be trusted, so nothing would
|
|
226
|
+
release) but it is safe *silently*: the operator sees a daemon that runs and
|
|
227
|
+
releases nothing, which is exactly what a correctly configured daemon with
|
|
228
|
+
an empty queue looks like. Dying names the reason while someone is watching.
|
|
229
|
+
Note the property being protected is not "self is trusted" but "only a
|
|
230
|
+
KNOWN actor is trusted" — an unanswered whoami leaves that unknown, and no
|
|
231
|
+
default can fill it in.
|
|
232
|
+
|
|
233
|
+
Under `scan` this is a no-op: that feed never reads a Body of Work, so it
|
|
234
|
+
never needs an identity, and a daemon that only scans must not fail to boot
|
|
235
|
+
because an endpoint it does not use was unreachable.
|
|
236
|
+
"""
|
|
237
|
+
if config.release_feed != FEED_BOW:
|
|
238
|
+
return config
|
|
239
|
+
|
|
240
|
+
if config.bow_owners:
|
|
241
|
+
log.info(
|
|
242
|
+
"bow feed authority: explicit (%s)", ", ".join(config.bow_owners)
|
|
243
|
+
)
|
|
244
|
+
return config
|
|
245
|
+
|
|
246
|
+
identity = alissa.whoami() # fatal on failure — see main()
|
|
247
|
+
log.info("bow feed authority: self (%s)", identity.actor_id)
|
|
248
|
+
if identity.display_name:
|
|
249
|
+
# Logged as context, never compared: a display name is renameable, which
|
|
250
|
+
# is why `config.bow_owners` refuses to hold one.
|
|
251
|
+
log.debug(
|
|
252
|
+
"resolved from this token's identity: %r", identity.display_name
|
|
253
|
+
)
|
|
254
|
+
# The id comes from the API, so it is the authority on its own shape and is
|
|
255
|
+
# NOT put through `config`'s actor-id check — that check exists to catch a
|
|
256
|
+
# human typing a username, and applying it here would let a future id format
|
|
257
|
+
# break a daemon that had been given no configuration to be wrong about.
|
|
258
|
+
return replace(config, bow_owners=(identity.actor_id,))
|
|
259
|
+
|
|
260
|
+
|
|
199
261
|
def log_effective_config(config: Config) -> None:
|
|
200
262
|
"""The resolved settings, defaults and all. INFO carries the decisions an
|
|
201
263
|
operator needs; -v (DEBUG) shows the full surface."""
|
|
@@ -240,14 +302,52 @@ def main(argv: "list[str] | None" = None) -> int:
|
|
|
240
302
|
try:
|
|
241
303
|
config = resolve_config(args)
|
|
242
304
|
log.info("workspace: %s", config.workspace_root)
|
|
243
|
-
|
|
244
|
-
|
|
305
|
+
# One client, shared with the orchestrator: the boot-time whoami and
|
|
306
|
+
# every later task-graph call must speak as the same actor, or the
|
|
307
|
+
# authority resolved here would not be the one doing the reading.
|
|
308
|
+
alissa = AlissaClient(base=config.alissa_endpoint)
|
|
309
|
+
except (FileNotFoundError, ValueError, AlissaError) as exc:
|
|
310
|
+
# `AlissaError` is here to keep main()'s exit contract whole, not because
|
|
311
|
+
# anything in this block raises one today (the client's constructor only
|
|
312
|
+
# reads ALISSA_BASE / ALISSA_API_TOKEN). Exit 2 means "never started" and
|
|
313
|
+
# every other way of never starting prints one line; a lone path that
|
|
314
|
+
# answers with a traceback and exit 1 — the code that means "started,
|
|
315
|
+
# then a pass failed" — would misreport itself the day the constructor
|
|
316
|
+
# grows any validation. Plain text, deliberately: the bow-specific advice
|
|
317
|
+
# belongs only to the call that can actually earn it, below.
|
|
245
318
|
print(f"config error: {exc}", file=sys.stderr)
|
|
246
319
|
return 2
|
|
247
320
|
except KeyboardInterrupt:
|
|
248
321
|
return 0
|
|
249
322
|
|
|
250
|
-
|
|
323
|
+
# Deliberately its OWN try, holding exactly one call. The handler below
|
|
324
|
+
# names the bow feed, and `resolve_feed_authority` is the only thing here
|
|
325
|
+
# that can fail for that reason — under `scan` it returns before making any
|
|
326
|
+
# call at all. A wider block would let some unrelated Alissa failure be
|
|
327
|
+
# reported as an authority problem, pointing a scan-only operator at a knob
|
|
328
|
+
# that does nothing in their feed mode.
|
|
329
|
+
try:
|
|
330
|
+
config = resolve_feed_authority(config, alissa)
|
|
331
|
+
except AlissaError as exc:
|
|
332
|
+
# The bow feed could not learn who this token is. Fatal by design (see
|
|
333
|
+
# resolve_feed_authority): booting on an unknown identity would mean a
|
|
334
|
+
# daemon that looks healthy and silently releases nothing. Same exit
|
|
335
|
+
# code as a config error — it is the same class of failure, a daemon
|
|
336
|
+
# that never started, and the operator's fix is a token or an explicit
|
|
337
|
+
# bow_owners rather than anything the loop could retry.
|
|
338
|
+
print(
|
|
339
|
+
f"config error: could not resolve the bow feed authority — {exc}. "
|
|
340
|
+
f"Check ALISSA_API_TOKEN and the endpoint, or set bow_owners "
|
|
341
|
+
f"explicitly to name the actor(s) that own the feed bodies of work.",
|
|
342
|
+
file=sys.stderr,
|
|
343
|
+
)
|
|
344
|
+
return 2
|
|
345
|
+
except KeyboardInterrupt:
|
|
346
|
+
return 0
|
|
347
|
+
|
|
348
|
+
log_effective_config(config)
|
|
349
|
+
|
|
350
|
+
orchestrator = Orchestrator(config, alissa=alissa)
|
|
251
351
|
try:
|
|
252
352
|
if args.once:
|
|
253
353
|
orchestrator.poll_once()
|
|
@@ -94,6 +94,18 @@ Both BOW endpoints require the daemon actor to be a **collaborator** on the
|
|
|
94
94
|
Body of Work (otherwise 403 ``FORBIDDEN`` on attach/detach) -- an operator setup
|
|
95
95
|
step, documented in the README. orcloop never attaches: attaching is the
|
|
96
96
|
operator's trigger, detaching is orcloop's terminal release act.
|
|
97
|
+
|
|
98
|
+
WHO AM I (O8)
|
|
99
|
+
=============
|
|
100
|
+
``GET /v1/ping`` -- ``{"pong", "timestamp", "userId", "actorId",
|
|
101
|
+
"displayName"}`` -- is the token's identity endpoint, and it is the ONLY one:
|
|
102
|
+
``/v1/me``, ``/v1/whoami``, ``/v1/actors/me``, ``/v1/auth/me``, ``/v1/user(s)/me``,
|
|
103
|
+
``/v1/identity``, ``/v1/actor``, ``/v1/session``, ``/v1/profile`` all answer 404
|
|
104
|
+
``"The requested endpoint does not exist."``. Its OpenAPI summary states the
|
|
105
|
+
contract outright: *"Verify the token and return the acting identity."* That is
|
|
106
|
+
what lets ``bow_owners`` default to the daemon's own actor instead of making the
|
|
107
|
+
operator hand-copy an id -- see ``whoami`` and the boot resolution in
|
|
108
|
+
``__main__.resolve_feed_authority``.
|
|
97
109
|
"""
|
|
98
110
|
|
|
99
111
|
from __future__ import annotations
|
|
@@ -189,6 +201,21 @@ class BowMember:
|
|
|
189
201
|
status: str
|
|
190
202
|
|
|
191
203
|
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class Identity:
|
|
206
|
+
"""Who this token acts as, from ``GET /v1/ping``.
|
|
207
|
+
|
|
208
|
+
`actor_id` is the load-bearing field: it is the same opaque id that appears
|
|
209
|
+
as `ownerActorId` on a Body of Work, so it can be compared directly against
|
|
210
|
+
a container's owner. `user_id` and `display_name` are carried for logging
|
|
211
|
+
only -- a display name is renameable and must never reach an authority
|
|
212
|
+
check (see `config.bow_owners`)."""
|
|
213
|
+
|
|
214
|
+
actor_id: str
|
|
215
|
+
user_id: str = ""
|
|
216
|
+
display_name: str = ""
|
|
217
|
+
|
|
218
|
+
|
|
192
219
|
@dataclass(frozen=True)
|
|
193
220
|
class Criterion:
|
|
194
221
|
id: str
|
|
@@ -317,6 +344,37 @@ class AlissaClient:
|
|
|
317
344
|
|
|
318
345
|
# --- adapter surface ---------------------------------------------------
|
|
319
346
|
|
|
347
|
+
def whoami(self) -> Identity:
|
|
348
|
+
"""The identity this token acts as (`GET /v1/ping`).
|
|
349
|
+
|
|
350
|
+
The `bow` feed's authority defaults to this actor, so the call is on the
|
|
351
|
+
BOOT path and its failure is fatal there -- never softened into a
|
|
352
|
+
warning. A daemon that could not learn who it is cannot tell its own
|
|
353
|
+
containers from a stranger's, and the only safe answers are "refuse to
|
|
354
|
+
start" or "trust nobody"; refusing is the loud one, so that is what
|
|
355
|
+
`__main__.resolve_feed_authority` does with what this raises.
|
|
356
|
+
|
|
357
|
+
A 2xx with no `actorId` is a contract violation rather than a missing
|
|
358
|
+
resource, so it surfaces as the base `AlissaError` -- the same way
|
|
359
|
+
`get_task` treats a response with no task object. `AlissaAuthError`
|
|
360
|
+
(a bad or unset token) and `AlissaTransient` (the endpoint was
|
|
361
|
+
unreachable) come through the shared taxonomy unchanged.
|
|
362
|
+
"""
|
|
363
|
+
payload = self._request("GET", "/v1/ping")
|
|
364
|
+
row = payload if isinstance(payload, dict) else {}
|
|
365
|
+
actor_id = row.get("actorId")
|
|
366
|
+
if not isinstance(actor_id, str) or not actor_id.strip():
|
|
367
|
+
raise AlissaError(
|
|
368
|
+
200,
|
|
369
|
+
f"GET /v1/ping returned no actorId (got {payload!r}) — the "
|
|
370
|
+
f"token's acting identity could not be determined",
|
|
371
|
+
)
|
|
372
|
+
return Identity(
|
|
373
|
+
actor_id=actor_id.strip(),
|
|
374
|
+
user_id=str(row.get("userId") or ""),
|
|
375
|
+
display_name=str(row.get("displayName") or ""),
|
|
376
|
+
)
|
|
377
|
+
|
|
320
378
|
def list_marked_committed_tasks(self, marker: str = RELEASE_MARKER) -> list[MarkedTask]:
|
|
321
379
|
"""Committed tasks carrying the release `marker`, with descriptions.
|
|
322
380
|
|
|
@@ -21,6 +21,20 @@ watch nothing, but boot anyway), an empty orcloop allowlist is **fatal at
|
|
|
21
21
|
startup** — the orchestrator releases work into `alissa:develop` issues and
|
|
22
22
|
closes the loop on merge, so it never runs against an unbounded repo set.
|
|
23
23
|
|
|
24
|
+
REPOS SEPARATOR (O7). A repo list written as one string — `ALISSA_ORC_REPOS`,
|
|
25
|
+
a `--repo a/b|c/d` flag, or a single-element list in the config file — is split
|
|
26
|
+
on **both `|` and `,`**, mixed freely. `|` is the daemon **family convention**
|
|
27
|
+
(devloop's `ALISSA_DEV_REPOS` has always been pipe-separated); comma stays
|
|
28
|
+
accepted for backward compatibility with every orcloop release up to 0.3.0.
|
|
29
|
+
The convention matters because the operator points several Railway services at
|
|
30
|
+
ONE list through a variable reference — `ALISSA_ORC_REPOS=${{dev.ALISSA_DEV_REPOS}}`
|
|
31
|
+
— which only works if both daemons read the same separator. Entries are
|
|
32
|
+
whitespace-stripped, empties from splitting are dropped (so a trailing
|
|
33
|
+
separator is harmless), duplicates collapse, and each surviving entry must look
|
|
34
|
+
like `owner/repo` (a bare word or a pasted URL is a fatal startup error naming
|
|
35
|
+
the offending entry). An allowlist that is empty *after* parsing stays fatal —
|
|
36
|
+
fail-closed is unchanged.
|
|
37
|
+
|
|
24
38
|
The marker contract (`Autonomous-Dev` / `Autonomous-Scope` issue-body
|
|
25
39
|
trailers) is parsed in `markers.py`; the keys that name those trailers live
|
|
26
40
|
here (`marker_key`, `scope_key`) so an operator can rename them per-deployment.
|
|
@@ -36,21 +50,35 @@ RELEASE FEED (O6). `release_feed` selects where release candidates come from:
|
|
|
36
50
|
O(total BOW membership). `scan_fallback_interval` keeps a low-frequency full
|
|
37
51
|
trailer scan running underneath as a visibility safety net.
|
|
38
52
|
|
|
39
|
-
The `bow` feed has a **second**
|
|
40
|
-
|
|
41
|
-
|
|
53
|
+
The `bow` feed has a **second** allowlist, `bow_owners`, and the two answer
|
|
54
|
+
different questions. A container's title says which repo it targets, so `repos`
|
|
55
|
+
bounds *where* a release lands. Nothing in a title says who created the
|
|
42
56
|
container — and the daemon lists Bodies of Work it merely **collaborates** on,
|
|
43
57
|
which any actor in the tenant can arrange without an acceptance step on this
|
|
44
58
|
side. `bow_owners` names the actor id(s) whose containers may drive the daemon,
|
|
45
|
-
so "whose work reaches an autonomous pipeline" is
|
|
46
|
-
than a consequence of being shared something.
|
|
47
|
-
|
|
59
|
+
so "whose work reaches an autonomous pipeline" is a decided question rather
|
|
60
|
+
than a consequence of being shared something.
|
|
61
|
+
|
|
62
|
+
FEED AUTHORITY (O8). `bow_owners` is an OPTIONAL OVERRIDE, not required setup.
|
|
63
|
+
Left unset it resolves at boot to the **token's own actor** — the daemon asks
|
|
64
|
+
`GET /v1/ping` who it is and trusts only its own containers (see
|
|
65
|
+
`__main__.resolve_feed_authority`, which is also where a failed whoami is
|
|
66
|
+
fatal). Set it only for a multi-operator deployment, where the containers are
|
|
67
|
+
owned by someone other than the actor the daemon's token acts as. That the
|
|
68
|
+
default is self does not soften the gate: an unresolvable identity refuses to
|
|
69
|
+
boot rather than trusting everything, so the fail-closed property `bow_owners`
|
|
70
|
+
was introduced for is unchanged — only the hand-copying is gone.
|
|
71
|
+
|
|
72
|
+
Entries are actor **ids** and nothing else; a username or display name is
|
|
73
|
+
rejected here, naming it (see `_validate_actor_id` for why a name is both a
|
|
74
|
+
spoof surface and a silent one).
|
|
48
75
|
"""
|
|
49
76
|
|
|
50
77
|
from __future__ import annotations
|
|
51
78
|
|
|
52
79
|
import json
|
|
53
80
|
import os
|
|
81
|
+
import re
|
|
54
82
|
from dataclasses import dataclass, field
|
|
55
83
|
from pathlib import Path
|
|
56
84
|
from typing import Any, Mapping
|
|
@@ -91,9 +119,52 @@ RELEASE_FEEDS = (FEED_SCAN, FEED_BOW)
|
|
|
91
119
|
# Keys carrying a boolean; env strings for these need real parsing (bool("false")
|
|
92
120
|
# is True, which would silently invert the setting).
|
|
93
121
|
_BOOL_KEYS = frozenset({"dry_run", "close_loop_enabled"})
|
|
94
|
-
# Keys carrying a list of strings
|
|
122
|
+
# Keys carrying a list of strings. In an env value they are separator-delimited;
|
|
123
|
+
# `repos` accepts the family's `|` as well as `,` (see REPO_SEPARATORS), every
|
|
124
|
+
# other list key is comma-separated.
|
|
95
125
|
_LIST_KEYS = frozenset({"repos", "bow_owners"})
|
|
96
126
|
|
|
127
|
+
# The separators a one-string repo list may use, in preference order. `|` is the
|
|
128
|
+
# daemon-family convention (devloop's ALISSA_DEV_REPOS), which is what lets one
|
|
129
|
+
# Railway variable reference feed both daemons; `,` is what orcloop shipped
|
|
130
|
+
# through 0.3.0 and stays accepted. Mixing them in one value is fine.
|
|
131
|
+
REPO_SEPARATORS = ("|", ",")
|
|
132
|
+
_REPO_SPLIT_RE = re.compile("[" + re.escape("".join(REPO_SEPARATORS)) + "]")
|
|
133
|
+
|
|
134
|
+
# What an allowlist entry must look like once split: exactly one `/`, a GitHub
|
|
135
|
+
# owner (alphanumerics and hyphens, not leading/trailing) and a repo name
|
|
136
|
+
# (alphanumerics, `.`, `_`, `-`) that is neither all dots nor `.git`-suffixed
|
|
137
|
+
# (in any casing, matching the hint branch below — spelled as a character class
|
|
138
|
+
# rather than re.IGNORECASE, which would also relax the two strict classes) —
|
|
139
|
+
# both are names GitHub itself refuses. Deliberately strict enough to reject
|
|
140
|
+
# the mistakes a separator change invites — a bare word (`widgets`, which names
|
|
141
|
+
# no owner) and a pasted URL or git remote (`https://github.com/acme/widgets`,
|
|
142
|
+
# `git@github.com:acme/widgets.git`), each of which would otherwise become an
|
|
143
|
+
# allowlist row the daemon silently never matches.
|
|
144
|
+
#
|
|
145
|
+
# Every exclusion here is one spelling GitHub refuses that the character class
|
|
146
|
+
# would otherwise accept, so this is the line new ones get added to; the hint
|
|
147
|
+
# table in `_validate_repo_entry` is where each earns its own message.
|
|
148
|
+
_REPO_NAME_RE = re.compile(
|
|
149
|
+
r"^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?"
|
|
150
|
+
r"/(?!\.+$)(?!.*\.[Gg][Ii][Tt]$)[A-Za-z0-9._-]+$"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# What an Alissa actor id looks like: a 32-character opaque handle of lower-case
|
|
154
|
+
# letters and digits (`j5706fv7xe5jy1k5wdwzacab9s8axcd2`). Every id the API mints
|
|
155
|
+
# — actors, users, tasks, bodies of work — has this shape, and `bow_owners` is
|
|
156
|
+
# checked against it so a username or display name cannot be mistaken for one.
|
|
157
|
+
# The check is SHAPE only: it says "this could be an id", never "this actor
|
|
158
|
+
# exists" (nothing short of a lookup could, and a wrong-but-well-formed id
|
|
159
|
+
# already fails loudly as a feed nobody owns). If the API ever mints a different
|
|
160
|
+
# shape, this constant and the pattern below are the two lines to widen.
|
|
161
|
+
ACTOR_ID_LENGTH = 32
|
|
162
|
+
_ACTOR_ID_ALPHABET = frozenset("abcdefghijklmnopqrstuvwxyz0123456789")
|
|
163
|
+
# `\Z`, not `$`: `$` also matches just before a trailing newline, so
|
|
164
|
+
# `"<id>\n"` would pass. Callers strip, but a gate that only holds because its
|
|
165
|
+
# caller normalised is not a gate.
|
|
166
|
+
_ACTOR_ID_RE = re.compile(rf"^[a-z0-9]{{{ACTOR_ID_LENGTH}}}\Z")
|
|
167
|
+
|
|
97
168
|
MIN_POLL_INTERVAL = 10 # the GitHub search API allows 30 req/min
|
|
98
169
|
|
|
99
170
|
DEFAULT_ALISSA_ENDPOINT = "https://api.alissa.app"
|
|
@@ -134,6 +205,168 @@ def _string_list(value: Any, key: str) -> tuple[str, ...]:
|
|
|
134
205
|
return tuple(value)
|
|
135
206
|
|
|
136
207
|
|
|
208
|
+
def split_repo_entries(raw: str) -> list[str]:
|
|
209
|
+
"""Split one repo-list string into entries on `|` and/or `,`.
|
|
210
|
+
|
|
211
|
+
Whitespace around an entry is stripped and empties are dropped, so a
|
|
212
|
+
trailing separator, a doubled one, and `a/b | c/d` all behave. This is the
|
|
213
|
+
ONE place the separator set is applied: the library's env layer, the CLI
|
|
214
|
+
flag, the config file, and the container's `render_config.py` all route
|
|
215
|
+
through it, so no surface can end up accepting a different syntax.
|
|
216
|
+
|
|
217
|
+
Splitting only — shape validation is `normalize_repos`, because the config
|
|
218
|
+
file may also hand over an already-split JSON list.
|
|
219
|
+
"""
|
|
220
|
+
return [entry for part in _REPO_SPLIT_RE.split(raw) if (entry := part.strip())]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def normalize_repos(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
|
|
224
|
+
"""Turn the raw `repos` layer value into the validated allowlist.
|
|
225
|
+
|
|
226
|
+
Every element is itself split (an entry may carry a whole separated list —
|
|
227
|
+
that is exactly what `ALISSA_ORC_REPOS` and a `--repo a/b|c/d` flag look
|
|
228
|
+
like), duplicates collapse case-insensitively keeping the operator's first
|
|
229
|
+
spelling (GitHub names are case-insensitive, and `watches` matches them
|
|
230
|
+
that way — a repeat is a duplicated GitHub search term, not a second lane),
|
|
231
|
+
and each survivor is validated as `owner/repo`.
|
|
232
|
+
|
|
233
|
+
Emptiness is NOT judged here: `Config.build` owns the fail-closed guard so
|
|
234
|
+
the operator gets its one canonical message.
|
|
235
|
+
"""
|
|
236
|
+
out: list[str] = []
|
|
237
|
+
seen: set[str] = set()
|
|
238
|
+
for value in values:
|
|
239
|
+
for entry in split_repo_entries(value):
|
|
240
|
+
_validate_repo_entry(entry)
|
|
241
|
+
if (key := entry.casefold()) not in seen:
|
|
242
|
+
seen.add(key)
|
|
243
|
+
out.append(entry)
|
|
244
|
+
return tuple(out)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _validate_repo_entry(entry: str) -> None:
|
|
248
|
+
"""Reject anything that is not an `owner/repo` name, naming the entry.
|
|
249
|
+
|
|
250
|
+
Fail loudly rather than carry it: an unmatchable allowlist entry is not
|
|
251
|
+
inert, it is a repo the operator believes is watched and is not.
|
|
252
|
+
"""
|
|
253
|
+
if _REPO_NAME_RE.match(entry):
|
|
254
|
+
return
|
|
255
|
+
if (
|
|
256
|
+
"://" in entry
|
|
257
|
+
or "@" in entry
|
|
258
|
+
or ":" in entry
|
|
259
|
+
or entry.casefold().startswith(("github.com/", "www."))
|
|
260
|
+
):
|
|
261
|
+
# Covers a pasted browser URL, a scheme-less `github.com/...`, and an
|
|
262
|
+
# SSH remote (`git@github.com:acme/widgets.git`) — which has no `://`
|
|
263
|
+
# and only one `/`, so without the `@`/`:` tests it would fall through
|
|
264
|
+
# to the bare message.
|
|
265
|
+
hint = " — that looks like a URL or a git remote; use only the " \
|
|
266
|
+
"`owner/repo` part"
|
|
267
|
+
elif entry.casefold().endswith(".git") and "/" in entry:
|
|
268
|
+
# Ordered AFTER the URL/remote branch so the two messages COMPOSE: a
|
|
269
|
+
# full remote keeps "use only the `owner/repo` part", and the
|
|
270
|
+
# `acme/widgets.git` that following that advice yields gets the
|
|
271
|
+
# remaining half of the fix here. Without this branch, the first
|
|
272
|
+
# message walks the operator from a loud abort into a silent no-match.
|
|
273
|
+
hint = (" — drop the trailing `.git`; that is the clone URL's suffix, "
|
|
274
|
+
"not part of the repo name")
|
|
275
|
+
elif "/" not in entry:
|
|
276
|
+
hint = " — no `/`, so it names no owner"
|
|
277
|
+
elif entry.count("/") > 1:
|
|
278
|
+
hint = " — too many `/`; an entry is one owner and one repo"
|
|
279
|
+
else:
|
|
280
|
+
hint = ""
|
|
281
|
+
raise ValueError(
|
|
282
|
+
f"repos entry {entry!r} is not an owner/repo name{hint}. Entries look "
|
|
283
|
+
f"like `acme/widgets` and are separated by `|` (the daemon-family "
|
|
284
|
+
f"convention) or `,`, e.g. \"acme/widgets|acme/gadgets\"."
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def normalize_bow_owners(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
|
|
289
|
+
"""Turn the raw `bow_owners` layer value into the validated authority list.
|
|
290
|
+
|
|
291
|
+
Entries are whitespace-stripped, empties dropped (so a trailing comma is
|
|
292
|
+
harmless, matching `repos`), duplicates collapsed — EXACTLY, with no
|
|
293
|
+
casefolding, because actor ids are opaque: two spellings that differ in case
|
|
294
|
+
are two different strings on the wire, and folding them would silently widen
|
|
295
|
+
the match. Every survivor must look like an actor id.
|
|
296
|
+
|
|
297
|
+
Emptiness is fine here and means "not overridden": the boot path resolves
|
|
298
|
+
the authority to this token's own actor (see the module docstring). There is
|
|
299
|
+
no fail-closed guard left in `build` for it — an empty list still admits
|
|
300
|
+
nothing (`trusts_feed_owner` returns False for every owner), so the property
|
|
301
|
+
is preserved by the comparison itself rather than by refusing to start.
|
|
302
|
+
"""
|
|
303
|
+
out: list[str] = []
|
|
304
|
+
seen: set[str] = set()
|
|
305
|
+
for value in values:
|
|
306
|
+
entry = value.strip()
|
|
307
|
+
if not entry:
|
|
308
|
+
continue
|
|
309
|
+
_validate_actor_id(entry)
|
|
310
|
+
if entry not in seen:
|
|
311
|
+
seen.add(entry)
|
|
312
|
+
out.append(entry)
|
|
313
|
+
return tuple(out)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _validate_actor_id(entry: str) -> None:
|
|
317
|
+
"""Reject anything that is not an Alissa actor id, naming the entry.
|
|
318
|
+
|
|
319
|
+
Ownership is checked against a container's `ownerActorId`, which is opaque
|
|
320
|
+
and immutable. A username or display name is neither: it is operator-facing
|
|
321
|
+
text the owning account can CHANGE, so an allowlist written in those terms
|
|
322
|
+
grants authority to whoever holds the name when the check runs rather than
|
|
323
|
+
to the actor the operator meant — the reason ids are the only accepted
|
|
324
|
+
spelling.
|
|
325
|
+
|
|
326
|
+
Worse, getting it wrong would not even be loud. Nothing on the wire ever
|
|
327
|
+
equals a name, so a name in this list simply matches no container, and the
|
|
328
|
+
`bow` feed's symptom is an empty queue — indistinguishable from "no work
|
|
329
|
+
attached yet". Failing here, at boot, with the offending entry named, is the
|
|
330
|
+
only point where the mistake is visible.
|
|
331
|
+
"""
|
|
332
|
+
if _ACTOR_ID_RE.match(entry):
|
|
333
|
+
return
|
|
334
|
+
lowered = entry.lower()
|
|
335
|
+
if _ACTOR_ID_RE.match(lowered):
|
|
336
|
+
# The one near-miss with a mechanical fix, so it gets its own message
|
|
337
|
+
# instead of being described. NOT lowered silently: ids are opaque, and
|
|
338
|
+
# quietly rewriting one makes a config stop meaning what it says.
|
|
339
|
+
hint = f" — actor ids are lower-case; did you mean {lowered!r}?"
|
|
340
|
+
else:
|
|
341
|
+
# LENGTH FIRST. Both problems are reported when both are present, but a
|
|
342
|
+
# short name is a length problem before it is an alphabet one: telling
|
|
343
|
+
# someone their 8-character username "contains 'ADHMORTZ'" buries the
|
|
344
|
+
# useful observation under a rendering of its own character set.
|
|
345
|
+
problems = []
|
|
346
|
+
if len(entry) != ACTOR_ID_LENGTH:
|
|
347
|
+
problems.append(
|
|
348
|
+
f"it is {len(entry)} characters, not {ACTOR_ID_LENGTH}"
|
|
349
|
+
)
|
|
350
|
+
offending = "".join(sorted(set(entry) - _ACTOR_ID_ALPHABET))
|
|
351
|
+
if offending:
|
|
352
|
+
problems.append(
|
|
353
|
+
f"it contains {offending!r}, which no actor id does"
|
|
354
|
+
)
|
|
355
|
+
hint = " — " + " and ".join(problems)
|
|
356
|
+
if offending:
|
|
357
|
+
hint += "; that looks like a username or a display name"
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"bow_owners entry {entry!r} is not an Alissa actor id{hint}. Use the "
|
|
360
|
+
f"opaque id — the `ownerActorId` on the container's row in "
|
|
361
|
+
f"GET /v1/bodies-of-work, or `actorId` from GET /v1/ping — never a "
|
|
362
|
+
f"username or display name: names are renameable, so an ownership "
|
|
363
|
+
f"check against one is a spoof surface, and since no id on the wire "
|
|
364
|
+
f"ever equals a name it would fail as a silently empty feed rather "
|
|
365
|
+
f"than an error. Leave bow_owners unset to trust this token's own "
|
|
366
|
+
f"actor, which needs no id at all."
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
137
370
|
def _env_bool(env_name: str, raw: str) -> bool:
|
|
138
371
|
value = raw.strip().lower()
|
|
139
372
|
if value in _ENV_TRUE:
|
|
@@ -164,6 +397,11 @@ def env_overrides(environ: Mapping[str, str] | None = None) -> dict[str, Any]:
|
|
|
164
397
|
raw = env[env_name]
|
|
165
398
|
if key in _BOOL_KEYS:
|
|
166
399
|
out[key] = _env_bool(env_name, raw)
|
|
400
|
+
elif key == "repos":
|
|
401
|
+
# Separator-tolerant (`|` and `,`); shape-validated in `build`
|
|
402
|
+
# alongside the file and CLI layers, so one bad entry reports the
|
|
403
|
+
# same way wherever it came from.
|
|
404
|
+
out[key] = tuple(split_repo_entries(raw))
|
|
167
405
|
elif key in _LIST_KEYS:
|
|
168
406
|
out[key] = tuple(
|
|
169
407
|
item for part in raw.split(",") if (item := part.strip())
|
|
@@ -181,7 +419,9 @@ class Config:
|
|
|
181
419
|
# Fail-closed allowlist of owner/repo. Empty is REJECTED at build (see
|
|
182
420
|
# build's guard) — the orchestrator never runs against an unbounded set.
|
|
183
421
|
# The dataclass default is () only to mark "unset"; a real Config always
|
|
184
|
-
# carries at least one repo.
|
|
422
|
+
# carries at least one repo. Entries reach `build` as `|`- and/or
|
|
423
|
+
# `,`-separated text from any layer and land here split, validated,
|
|
424
|
+
# de-duplicated, and in the operator's own casing (see `normalize_repos`).
|
|
185
425
|
repos: tuple[str, ...] = ()
|
|
186
426
|
|
|
187
427
|
label: str = "alissa:develop"
|
|
@@ -222,13 +462,16 @@ class Config:
|
|
|
222
462
|
bow_prefix: str = "autodev: "
|
|
223
463
|
|
|
224
464
|
# WHOSE Bodies of Work may act as a release feed: the Alissa actor id(s)
|
|
225
|
-
# allowed to OWN a feed container.
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
# collaborates on, which anyone in the tenant can arrange unilaterally.
|
|
230
|
-
# So provenance is an operator decision, made here. Ids are opaque and
|
|
465
|
+
# allowed to OWN a feed container. The title tells the daemon which repo a
|
|
466
|
+
# container targets; it says nothing about who created it, and the daemon
|
|
467
|
+
# lists containers it merely collaborates on, which anyone in the tenant can
|
|
468
|
+
# arrange unilaterally. So provenance is decided here. Ids are opaque and
|
|
231
469
|
# compared EXACTLY (unlike `repos`, which casefolds GitHub names).
|
|
470
|
+
#
|
|
471
|
+
# Empty means "not overridden", NOT "trust everyone": the boot path resolves
|
|
472
|
+
# it to this token's own actor and dies if it cannot (O8), and until it does
|
|
473
|
+
# an empty list matches no owner at all. An explicit list is the
|
|
474
|
+
# multi-operator case — containers owned by an actor this token is not.
|
|
232
475
|
bow_owners: tuple[str, ...] = ()
|
|
233
476
|
|
|
234
477
|
# The `bow`-feed safety net: every this-many seconds, ALSO run the full
|
|
@@ -270,6 +513,13 @@ class Config:
|
|
|
270
513
|
payload does not state cannot be attributed, and an unattributable feed
|
|
271
514
|
is exactly what `bow_owners` exists to refuse. Exact comparison — Alissa
|
|
272
515
|
actor ids are opaque, so normalising them would only widen the match.
|
|
516
|
+
|
|
517
|
+
Fail-closed on an EMPTY authority as well, which is what makes the O8
|
|
518
|
+
default safe to resolve outside this class: before boot fills the list
|
|
519
|
+
in (or when something skipped that step), every owner is untrusted and
|
|
520
|
+
nothing is a feed. The failure mode of a missing authority is therefore
|
|
521
|
+
an inert daemon, never an open one — boot's job is only to make that
|
|
522
|
+
state loud instead of quiet.
|
|
273
523
|
"""
|
|
274
524
|
return bool(actor_id) and actor_id in self.bow_owners
|
|
275
525
|
|
|
@@ -313,7 +563,12 @@ class Config:
|
|
|
313
563
|
# The env layer wins over everything, including a value set on the CLI.
|
|
314
564
|
raw.update(env_overrides(environ))
|
|
315
565
|
|
|
316
|
-
|
|
566
|
+
# Split-then-validate: whichever layer won, an entry may still be a
|
|
567
|
+
# whole `a/b|c/d` list (that is what one Railway variable reference
|
|
568
|
+
# looks like), so normalize before the fail-closed check — a value of
|
|
569
|
+
# only separators parses to nothing and must read as "empty", not as
|
|
570
|
+
# one bogus repo.
|
|
571
|
+
repos = normalize_repos(_string_list(raw.get("repos", cls.repos), "repos"))
|
|
317
572
|
if not repos:
|
|
318
573
|
# Fail-closed: the orchestrator spawns develop work and closes the
|
|
319
574
|
# loop on merge. An empty allowlist would let anyone who can label
|
|
@@ -321,7 +576,8 @@ class Config:
|
|
|
321
576
|
raise ValueError(
|
|
322
577
|
"repos allowlist is empty — the orchestrator is fail-closed and "
|
|
323
578
|
"refuses to start with no repos. Set `repos` in the config file, "
|
|
324
|
-
"pass one or more --repo flags, or set ALISSA_ORC_REPOS
|
|
579
|
+
"pass one or more --repo flags, or set ALISSA_ORC_REPOS "
|
|
580
|
+
"(entries separated by `|`, or `,`)."
|
|
325
581
|
)
|
|
326
582
|
|
|
327
583
|
interval = int(raw.get("poll_interval", cls.poll_interval))
|
|
@@ -365,25 +621,15 @@ class Config:
|
|
|
365
621
|
"turn every Body of Work into a release feed"
|
|
366
622
|
)
|
|
367
623
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
# `bow` feed: `scan` never reads a Body of Work at all.
|
|
378
|
-
raise ValueError(
|
|
379
|
-
"bow_owners is empty and release_feed is 'bow' — the release "
|
|
380
|
-
"feed is fail-closed and refuses to start without the actor "
|
|
381
|
-
"id(s) allowed to own a feed Body of Work (being shared one is "
|
|
382
|
-
"not a claim to drive this daemon). Set `bow_owners` in the "
|
|
383
|
-
"config file, pass one or more --bow-owner flags, or set "
|
|
384
|
-
"ALISSA_ORC_BOW_OWNERS. An actor id is the `ownerActorId` on "
|
|
385
|
-
"the container's row in GET /v1/bodies-of-work."
|
|
386
|
-
)
|
|
624
|
+
# Shape-validated in EVERY feed mode, not just `bow`. The value is inert
|
|
625
|
+
# under `scan`, but a malformed id that boots quietly today is a daemon
|
|
626
|
+
# that refuses to start on the unrelated day the operator flips the feed
|
|
627
|
+
# over — and this is the layer that knows the entry came from a human.
|
|
628
|
+
# Empty stays legal: the boot path resolves the authority to this
|
|
629
|
+
# token's own actor (O8), so `bow_owners` is an override, not setup.
|
|
630
|
+
bow_owners = normalize_bow_owners(
|
|
631
|
+
_string_list(raw.get("bow_owners", cls.bow_owners), "bow_owners")
|
|
632
|
+
)
|
|
387
633
|
|
|
388
634
|
fallback = int(raw.get("scan_fallback_interval", cls.scan_fallback_interval))
|
|
389
635
|
if fallback < 0:
|
|
@@ -67,9 +67,10 @@ titled `autodev: <owner>/<repo>` (`bow_prefix`).
|
|
|
67
67
|
trailer logs a warning and the BOW wins (never a silent retarget).
|
|
68
68
|
`Autonomous-Scope` is unchanged: still read from the task body, still the
|
|
69
69
|
intra-repo parallel-release glob list.
|
|
70
|
-
* **Only an ALLOWLISTED OWNER's Body of Work is a feed** (`bow_owners`,
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
* **Only an ALLOWLISTED OWNER's Body of Work is a feed** (`bow_owners`, which
|
|
71
|
+
defaults at boot to the daemon's OWN actor and is fatal if that cannot be
|
|
72
|
+
resolved -- O8). The listing includes containers merely SHARED with the
|
|
73
|
+
daemon, and sharing is unilateral -- so a feed-shaped title is a target
|
|
73
74
|
declaration, never a provenance claim. `bow_prefix` and `repos` bound which
|
|
74
75
|
repo a release lands in; they say nothing about whose task gets there, and an
|
|
75
76
|
allowlisted repo is where the daemon's writes are most trusted. A prefix-
|
|
@@ -609,6 +610,26 @@ class Orchestrator:
|
|
|
609
610
|
surfaced: set[int] = set()
|
|
610
611
|
unresolved: set[str] = set()
|
|
611
612
|
|
|
613
|
+
if not self.config.bow_owners:
|
|
614
|
+
# Not reachable through the CLI: boot resolves the authority (the
|
|
615
|
+
# token's own actor, or the operator's explicit list) and refuses to
|
|
616
|
+
# start if it cannot. Kept because it is the feed's central property
|
|
617
|
+
# and it must hold for an Orchestrator built any other way -- a
|
|
618
|
+
# test, an embedder, a future entry point. `trusts_feed_owner`
|
|
619
|
+
# already refuses every owner on an empty list, so the release
|
|
620
|
+
# behavior here is unchanged; what this adds is the LOUDNESS boot
|
|
621
|
+
# would have provided. The pass is INCOMPLETE, so the fallback sweep
|
|
622
|
+
# does not go on to report every marked task as "the feed did not
|
|
623
|
+
# surface this" -- the feed did not run.
|
|
624
|
+
log.error(
|
|
625
|
+
"release_feed is 'bow' but no feed authority is set — no body "
|
|
626
|
+
"of work can be authoritative, so nothing is released this "
|
|
627
|
+
"pass. Startup normally resolves this to the token's own actor; "
|
|
628
|
+
"set bow_owners explicitly if the feed containers are owned by "
|
|
629
|
+
"another actor."
|
|
630
|
+
)
|
|
631
|
+
return FeedPass(results, surfaced, unresolved, complete=False)
|
|
632
|
+
|
|
612
633
|
try:
|
|
613
634
|
bows = self.alissa.list_bodies_of_work()
|
|
614
635
|
except AlissaError as exc:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.2
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|