alissa-tools-github-orcloop 0.2.0__tar.gz → 0.3.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.
- {alissa_tools_github_orcloop-0.2.0/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.3.0}/PKG-INFO +1 -1
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/__main__.py +61 -1
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/alissa_client.py +165 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/config.py +132 -1
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/loop.py +728 -38
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/markers.py +49 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/state.py +35 -1
- alissa_tools_github_orcloop-0.3.0/src/main/alissa/tools/github/orcloop/version +1 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/README.md +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/setup.py +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/ghclient.py +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_orcloop-0.2.0 → alissa_tools_github_orcloop-0.3.0}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"""CLI entry point: alissa-orcloop (or python -m alissa.tools.github.orcloop).
|
|
2
2
|
|
|
3
3
|
O1 wired the config surface and the argument parser; O3 wires the decision
|
|
4
|
-
loop behind it
|
|
4
|
+
loop behind it; O6 adds the release-feed selector (`--release-feed bow` and its
|
|
5
|
+
`--bow-prefix` / `--bow-owner` / `--scan-fallback-interval` knobs).
|
|
6
|
+
|
|
7
|
+
The CLI resolves and validates the full configuration
|
|
5
8
|
(four-layer precedence, fail-closed `repos` guard), reports the effective
|
|
6
9
|
settings, then runs the orchestrator: `--once` for a single sweep (pair it
|
|
7
10
|
with `--dry-run -v` to print every decision and its reasons without touching
|
|
@@ -18,6 +21,8 @@ from pathlib import Path
|
|
|
18
21
|
|
|
19
22
|
from .alissa_client import AlissaError
|
|
20
23
|
from .config import (
|
|
24
|
+
FEED_BOW,
|
|
25
|
+
RELEASE_FEEDS,
|
|
21
26
|
Config,
|
|
22
27
|
load_config_file,
|
|
23
28
|
resolve_config_path,
|
|
@@ -91,6 +96,41 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
91
96
|
over.add_argument("--scope-key", metavar="KEY", help="Autonomous-Scope trailer key")
|
|
92
97
|
over.add_argument("--alissa-endpoint", metavar="URL")
|
|
93
98
|
over.add_argument("--ledger-path", type=Path, metavar="PATH")
|
|
99
|
+
over.add_argument(
|
|
100
|
+
"--release-feed",
|
|
101
|
+
choices=RELEASE_FEEDS,
|
|
102
|
+
metavar="{" + ",".join(RELEASE_FEEDS) + "}",
|
|
103
|
+
help="where release candidates come from: 'scan' (the default) lists "
|
|
104
|
+
"every committed task and filters it on the release marker; 'bow' "
|
|
105
|
+
"reads the per-repo Bodies of Work named <bow-prefix><owner>/<repo>, "
|
|
106
|
+
"where attaching a task is the release trigger AND the repo declaration",
|
|
107
|
+
)
|
|
108
|
+
over.add_argument(
|
|
109
|
+
"--bow-prefix",
|
|
110
|
+
metavar="PREFIX",
|
|
111
|
+
help="title prefix that marks a Body of Work as a release feed "
|
|
112
|
+
"(default 'autodev: '); the rest of the title is the target owner/repo",
|
|
113
|
+
)
|
|
114
|
+
over.add_argument(
|
|
115
|
+
"--bow-owner",
|
|
116
|
+
dest="bow_owners",
|
|
117
|
+
action="append",
|
|
118
|
+
metavar="ACTOR_ID",
|
|
119
|
+
help="bow feed only: trust bodies of work OWNED by this Alissa actor "
|
|
120
|
+
"as release feeds; repeatable. When given, REPLACES the config "
|
|
121
|
+
"`bow_owners` list. Fail-closed like --repo: `--release-feed bow` with "
|
|
122
|
+
"an empty owner list is a fatal startup error, because being SHARED a "
|
|
123
|
+
"body of work is not a claim to drive this daemon. An actor id is the "
|
|
124
|
+
"`ownerActorId` on the container's row in GET /v1/bodies-of-work.",
|
|
125
|
+
)
|
|
126
|
+
over.add_argument(
|
|
127
|
+
"--scan-fallback-interval",
|
|
128
|
+
type=int,
|
|
129
|
+
metavar="SECONDS",
|
|
130
|
+
help="bow feed only: how often to ALSO run the full marker scan and "
|
|
131
|
+
"log every marked task the feed did not surface (default 3600; 0 "
|
|
132
|
+
"disables it). The sweep reports, it never releases.",
|
|
133
|
+
)
|
|
94
134
|
|
|
95
135
|
close = over.add_mutually_exclusive_group()
|
|
96
136
|
close.add_argument(
|
|
@@ -139,6 +179,10 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
139
179
|
"ledger_path": args.ledger_path,
|
|
140
180
|
"close_loop_enabled": args.close_loop_enabled,
|
|
141
181
|
"dry_run": args.dry_run,
|
|
182
|
+
"release_feed": args.release_feed,
|
|
183
|
+
"bow_prefix": args.bow_prefix,
|
|
184
|
+
"bow_owners": tuple(args.bow_owners) if args.bow_owners else None,
|
|
185
|
+
"scan_fallback_interval": args.scan_fallback_interval,
|
|
142
186
|
}
|
|
143
187
|
|
|
144
188
|
|
|
@@ -161,6 +205,22 @@ def log_effective_config(config: Config) -> None:
|
|
|
161
205
|
config.poll_interval, config.repo_parallelism, config.dry_run,
|
|
162
206
|
config.close_loop_enabled,
|
|
163
207
|
)
|
|
208
|
+
if config.release_feed == FEED_BOW:
|
|
209
|
+
log.info(
|
|
210
|
+
"release feed: bow — candidates are the tasks attached to the "
|
|
211
|
+
"%r bodies of work OWNED by %s; scan fallback sweep %s",
|
|
212
|
+
config.bow_prefix,
|
|
213
|
+
", ".join(config.bow_owners),
|
|
214
|
+
f"every {config.scan_fallback_interval}s"
|
|
215
|
+
if config.scan_fallback_interval
|
|
216
|
+
else "DISABLED",
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
log.info(
|
|
220
|
+
"release feed: scan — candidates are committed tasks carrying %r, "
|
|
221
|
+
"targeted by their %s trailer",
|
|
222
|
+
config.label, config.marker_key,
|
|
223
|
+
)
|
|
164
224
|
log.debug("marker_key: %s", config.marker_key)
|
|
165
225
|
log.debug("scope_key: %s", config.scope_key)
|
|
166
226
|
log.debug("release_blocked_floor_days: %s", config.release_blocked_floor_days)
|
|
@@ -53,6 +53,47 @@ Three narrow, actionable buckets so the loop can react without string-matching:
|
|
|
53
53
|
|
|
54
54
|
Anything else (a 400 validation error, say) surfaces as the base
|
|
55
55
|
``AlissaError`` -- a bug to fix, not a condition to retry blindly.
|
|
56
|
+
|
|
57
|
+
THE BODY-OF-WORK SURFACE (O6)
|
|
58
|
+
=============================
|
|
59
|
+
The `bow` release feed reads candidates out of per-repo Bodies of Work instead
|
|
60
|
+
of scanning every committed task. Three endpoints, all verified live against
|
|
61
|
+
``api.alissa.app`` while implementing O6:
|
|
62
|
+
|
|
63
|
+
* ``GET /v1/bodies-of-work?includeShared=true`` -> ``{"bodiesOfWork": [...]}``.
|
|
64
|
+
**One call, no server-side title/status filter** (``?status=``/``?q=`` are
|
|
65
|
+
silently ignored), so the ``bow_prefix`` match runs here. The endpoint does,
|
|
66
|
+
however, have a SCOPE parameter, and it is not optional for a daemon: the
|
|
67
|
+
default list is what the actor **owns**, and ``includeShared=true`` adds the
|
|
68
|
+
ones it merely collaborates on. Measured live: 14 owned, 19 with the flag.
|
|
69
|
+
Feed containers are operator-created (there is no create endpoint) and the
|
|
70
|
+
daemon is a collaborator, so every one of them lives in that difference --
|
|
71
|
+
omitting the flag makes the ``bow`` feed discover nothing, silently. A
|
|
72
|
+
collaborator BOW is otherwise perfectly visible: ``GET
|
|
73
|
+
/v1/bodies-of-work/<id>`` returns it and membership reads work; it is only
|
|
74
|
+
absent from the DEFAULT listing. This is the whole discovery cost: the number
|
|
75
|
+
of Bodies of Work an org has is a small constant, not a function of task
|
|
76
|
+
count. The flag also widens the listing to containers ANY actor shared with
|
|
77
|
+
the daemon, so each row carries its ``ownerActorId`` and the loop admits only
|
|
78
|
+
the owners the operator allowlisted (``bow_owners``) -- see
|
|
79
|
+
``list_bodies_of_work``.
|
|
80
|
+
* ``GET /v1/bodies-of-work/<id>`` -> ``{"bodyOfWork": {..., "tasks": [{"_id",
|
|
81
|
+
"status", "title"}]}}``. The membership list is EMBEDDED in the BOW detail;
|
|
82
|
+
there is no ``/bodies-of-work/<id>/tasks`` route, and ``GET /v1/tasks`` has no
|
|
83
|
+
``bodyOfWorkId`` filter (an unknown query param is silently ignored, which
|
|
84
|
+
would have read as "every task is a member"). Member rows carry no
|
|
85
|
+
``taskNumber``, so the loop resolves each committed member through
|
|
86
|
+
``get_task`` -- which accepts the opaque ``_id`` as a ref.
|
|
87
|
+
* ``DELETE /v1/bodies-of-work/<id>/tasks/<ref>`` -> ``{"removed": bool}``. The
|
|
88
|
+
detach that terminates a successful release. **Idempotent**: detaching a task
|
|
89
|
+
that is not attached returns ``removed: false`` rather than an error, so a
|
|
90
|
+
crash-resumed detach is safe. The ``<ref>`` accepts both ``TASK-<n>`` and the
|
|
91
|
+
opaque id. A task id that does not exist at all is a 404 (``TASK_NOT_FOUND``).
|
|
92
|
+
|
|
93
|
+
Both BOW endpoints require the daemon actor to be a **collaborator** on the
|
|
94
|
+
Body of Work (otherwise 403 ``FORBIDDEN`` on attach/detach) -- an operator setup
|
|
95
|
+
step, documented in the README. orcloop never attaches: attaching is the
|
|
96
|
+
operator's trigger, detaching is orcloop's terminal release act.
|
|
56
97
|
"""
|
|
57
98
|
|
|
58
99
|
from __future__ import annotations
|
|
@@ -116,6 +157,38 @@ class MarkedTask:
|
|
|
116
157
|
description: str
|
|
117
158
|
|
|
118
159
|
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class BodyOfWork:
|
|
162
|
+
"""A Body of Work as the LIST endpoint reports it. Only the fields the feed
|
|
163
|
+
needs: the id to read membership with, the title carrying the
|
|
164
|
+
`<prefix><owner>/<repo>` naming convention, the BOW's own status, and
|
|
165
|
+
`owner_id` -- the actor that OWNS the container (`ownerActorId`).
|
|
166
|
+
|
|
167
|
+
`owner_id` is the container's provenance and the only thing here that is
|
|
168
|
+
not operator-authored text: the listing includes containers this actor was
|
|
169
|
+
merely SHARED, so the title alone cannot say whether a feed is one the
|
|
170
|
+
operator set up (see `Config.bow_owners`). Empty when the payload omits it,
|
|
171
|
+
which the caller treats as untrusted rather than unknown."""
|
|
172
|
+
|
|
173
|
+
id: str
|
|
174
|
+
title: str
|
|
175
|
+
status: str = ""
|
|
176
|
+
owner_id: str = ""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class BowMember:
|
|
181
|
+
"""One task attached to a Body of Work, as the BOW DETAIL endpoint reports
|
|
182
|
+
it. Deliberately thin: the membership rows carry `_id`, `status` and
|
|
183
|
+
`title` and NOTHING else -- no taskNumber, no description. `status` is
|
|
184
|
+
enough to hold an uncommitted member without a detail round-trip; a
|
|
185
|
+
committed member is resolved through `get_task(id)`."""
|
|
186
|
+
|
|
187
|
+
id: str
|
|
188
|
+
title: str
|
|
189
|
+
status: str
|
|
190
|
+
|
|
191
|
+
|
|
119
192
|
@dataclass(frozen=True)
|
|
120
193
|
class Criterion:
|
|
121
194
|
id: str
|
|
@@ -296,6 +369,98 @@ class AlissaClient:
|
|
|
296
369
|
)
|
|
297
370
|
return out
|
|
298
371
|
|
|
372
|
+
# --- the Body-of-Work feed (O6) ----------------------------------------
|
|
373
|
+
|
|
374
|
+
def list_bodies_of_work(self) -> list[BodyOfWork]:
|
|
375
|
+
"""Every Body of Work this actor owns OR collaborates on -- ONE call.
|
|
376
|
+
|
|
377
|
+
`includeShared=true` is load-bearing, not decoration: the endpoint
|
|
378
|
+
defaults to OWNED-only, and a feed container is created by the operator
|
|
379
|
+
in Studio (there is no create endpoint) with the daemon added as a
|
|
380
|
+
COLLABORATOR. Without the flag the feed's only discovery path returns
|
|
381
|
+
nothing and `release_feed: bow` is silently inert. There is still no
|
|
382
|
+
server-side title/status filter (see the module docstring), so the
|
|
383
|
+
caller narrows by prefix. Rows without an `_id` are dropped: a BOW that
|
|
384
|
+
cannot be addressed cannot be read for membership.
|
|
385
|
+
|
|
386
|
+
THE TRUST BOUNDARY THE FLAG MOVES. `includeShared=true` is necessary
|
|
387
|
+
(above) but it widens this listing to every container ANY actor in the
|
|
388
|
+
tenant has shared with the daemon -- unilaterally, with no acceptance
|
|
389
|
+
step on this side. So membership of this list is NOT a claim to be a
|
|
390
|
+
release feed: it means "visible", not "authoritative". The title-derived
|
|
391
|
+
gates downstream (`parse_bow_repo`, `Config.watches`) bound which repo a
|
|
392
|
+
release lands in, not whose task reaches it, and an allowlisted repo is
|
|
393
|
+
precisely where the daemon's writes are most trusted. Provenance is
|
|
394
|
+
therefore carried out of here as `BodyOfWork.owner_id` and checked
|
|
395
|
+
against the operator's `bow_owners` allowlist before a container is
|
|
396
|
+
treated as a feed. Collaboration makes a container READABLE (and is
|
|
397
|
+
still required, for detach); `bow_owners` makes it AUTHORITATIVE.
|
|
398
|
+
"""
|
|
399
|
+
payload = self._request("GET", "/v1/bodies-of-work?includeShared=true")
|
|
400
|
+
rows = payload.get("bodiesOfWork") if isinstance(payload, dict) else None
|
|
401
|
+
if not isinstance(rows, list):
|
|
402
|
+
return []
|
|
403
|
+
out: list[BodyOfWork] = []
|
|
404
|
+
for row in rows:
|
|
405
|
+
if not isinstance(row, dict):
|
|
406
|
+
continue
|
|
407
|
+
bow_id = row.get("_id")
|
|
408
|
+
if not isinstance(bow_id, str) or not bow_id:
|
|
409
|
+
continue
|
|
410
|
+
out.append(
|
|
411
|
+
BodyOfWork(
|
|
412
|
+
id=bow_id,
|
|
413
|
+
title=row.get("title") or "",
|
|
414
|
+
status=row.get("status") or "",
|
|
415
|
+
owner_id=row.get("ownerActorId") or "",
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
return out
|
|
419
|
+
|
|
420
|
+
def body_of_work_tasks(self, bow_id: str) -> list[BowMember]:
|
|
421
|
+
"""The tasks attached to one Body of Work, in the BOW's own order.
|
|
422
|
+
|
|
423
|
+
Membership is embedded in the BOW detail payload; there is no separate
|
|
424
|
+
listing route. Rows without an `_id` are dropped for the same reason as
|
|
425
|
+
above -- an unaddressable member cannot be resolved or detached.
|
|
426
|
+
"""
|
|
427
|
+
payload = self._request(
|
|
428
|
+
"GET", f"/v1/bodies-of-work/{urllib.parse.quote(str(bow_id), safe='')}"
|
|
429
|
+
)
|
|
430
|
+
bow = payload.get("bodyOfWork") if isinstance(payload, dict) else None
|
|
431
|
+
if not isinstance(bow, dict):
|
|
432
|
+
raise AlissaError(200, f"no bodyOfWork object in response for {bow_id!r}")
|
|
433
|
+
out: list[BowMember] = []
|
|
434
|
+
for row in bow.get("tasks") or []:
|
|
435
|
+
if not isinstance(row, dict):
|
|
436
|
+
continue
|
|
437
|
+
task_id = row.get("_id")
|
|
438
|
+
if not isinstance(task_id, str) or not task_id:
|
|
439
|
+
continue
|
|
440
|
+
out.append(
|
|
441
|
+
BowMember(
|
|
442
|
+
id=task_id,
|
|
443
|
+
title=row.get("title") or "",
|
|
444
|
+
status=row.get("status") or "",
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
return out
|
|
448
|
+
|
|
449
|
+
def detach_task(self, bow_id: str, ref: str) -> bool:
|
|
450
|
+
"""Detach a task from a Body of Work; True when this call removed it.
|
|
451
|
+
|
|
452
|
+
Idempotent by contract: detaching an already-detached task answers
|
|
453
|
+
`removed: false` with a 2xx, so the terminal step of a release can be
|
|
454
|
+
retried after a crash without a "was it already done?" probe. `ref`
|
|
455
|
+
accepts `TASK-<n>` or the opaque task id.
|
|
456
|
+
"""
|
|
457
|
+
payload = self._request(
|
|
458
|
+
"DELETE",
|
|
459
|
+
f"/v1/bodies-of-work/{urllib.parse.quote(str(bow_id), safe='')}"
|
|
460
|
+
f"/tasks/{urllib.parse.quote(str(ref), safe='')}",
|
|
461
|
+
)
|
|
462
|
+
return bool(payload.get("removed")) if isinstance(payload, dict) else False
|
|
463
|
+
|
|
299
464
|
def get_task(self, ref: str) -> Task:
|
|
300
465
|
"""Full detail for one task. `ref` is any reference form the API accepts
|
|
301
466
|
(`TASK-<n>`, public id, url). Raises `AlissaNotFound` when the bound
|
|
@@ -24,6 +24,27 @@ closes the loop on merge, so it never runs against an unbounded repo set.
|
|
|
24
24
|
The marker contract (`Autonomous-Dev` / `Autonomous-Scope` issue-body
|
|
25
25
|
trailers) is parsed in `markers.py`; the keys that name those trailers live
|
|
26
26
|
here (`marker_key`, `scope_key`) so an operator can rename them per-deployment.
|
|
27
|
+
|
|
28
|
+
RELEASE FEED (O6). `release_feed` selects where release candidates come from:
|
|
29
|
+
|
|
30
|
+
* `scan` (the DEFAULT, for backward compatibility) — list every committed task
|
|
31
|
+
and filter it on the release marker. Simple, but the read is O(all committed
|
|
32
|
+
tasks) every poll, which is the DB-load concern that motivated O6.
|
|
33
|
+
* `bow` — read them out of the per-repo **Bodies of Work** named
|
|
34
|
+
`<bow_prefix><owner>/<repo>` (default prefix `autodev: `). Attaching a task to
|
|
35
|
+
such a BOW is both the release trigger and the repo declaration; the read is
|
|
36
|
+
O(total BOW membership). `scan_fallback_interval` keeps a low-frequency full
|
|
37
|
+
trailer scan running underneath as a visibility safety net.
|
|
38
|
+
|
|
39
|
+
The `bow` feed has a **second** fail-closed allowlist, `bow_owners`, and the two
|
|
40
|
+
answer different questions. A container's title says which repo it targets, so
|
|
41
|
+
`repos` bounds *where* a release lands. Nothing in a title says who created the
|
|
42
|
+
container — and the daemon lists Bodies of Work it merely **collaborates** on,
|
|
43
|
+
which any actor in the tenant can arrange without an acceptance step on this
|
|
44
|
+
side. `bow_owners` names the actor id(s) whose containers may drive the daemon,
|
|
45
|
+
so "whose work reaches an autonomous pipeline" is an operator decision rather
|
|
46
|
+
than a consequence of being shared something. Empty is fatal under
|
|
47
|
+
`release_feed: bow`, exactly as an empty `repos` is fatal always.
|
|
27
48
|
"""
|
|
28
49
|
|
|
29
50
|
from __future__ import annotations
|
|
@@ -55,13 +76,23 @@ CONFIG_KEYS = (
|
|
|
55
76
|
"dry_run",
|
|
56
77
|
"ledger_path",
|
|
57
78
|
"close_loop_enabled",
|
|
79
|
+
"release_feed",
|
|
80
|
+
"bow_prefix",
|
|
81
|
+
"bow_owners",
|
|
82
|
+
"scan_fallback_interval",
|
|
58
83
|
)
|
|
59
84
|
|
|
85
|
+
# `release_feed` values. `scan` is the O0-O5 behavior and stays the default so
|
|
86
|
+
# an existing deployment upgrades without a config change; `bow` is the O6 feed.
|
|
87
|
+
FEED_SCAN = "scan"
|
|
88
|
+
FEED_BOW = "bow"
|
|
89
|
+
RELEASE_FEEDS = (FEED_SCAN, FEED_BOW)
|
|
90
|
+
|
|
60
91
|
# Keys carrying a boolean; env strings for these need real parsing (bool("false")
|
|
61
92
|
# is True, which would silently invert the setting).
|
|
62
93
|
_BOOL_KEYS = frozenset({"dry_run", "close_loop_enabled"})
|
|
63
94
|
# Keys carrying a list of strings; an env value is comma-separated.
|
|
64
|
-
_LIST_KEYS = frozenset({"repos"})
|
|
95
|
+
_LIST_KEYS = frozenset({"repos", "bow_owners"})
|
|
65
96
|
|
|
66
97
|
MIN_POLL_INTERVAL = 10 # the GitHub search API allows 30 req/min
|
|
67
98
|
|
|
@@ -180,6 +211,33 @@ class Config:
|
|
|
180
211
|
|
|
181
212
|
dry_run: bool = False
|
|
182
213
|
|
|
214
|
+
# Where release candidates come from: `scan` (all committed tasks, filtered
|
|
215
|
+
# on the marker) or `bow` (the per-repo Bodies of Work). Default `scan` —
|
|
216
|
+
# an existing deployment must not change behavior on upgrade.
|
|
217
|
+
release_feed: str = FEED_SCAN
|
|
218
|
+
|
|
219
|
+
# The naming convention that makes a Body of Work a release feed: its title
|
|
220
|
+
# is `<bow_prefix><owner>/<repo>`. The trailing space is part of the default
|
|
221
|
+
# (`autodev: fahera-mx/widgets`); prefix matching is case-insensitive.
|
|
222
|
+
bow_prefix: str = "autodev: "
|
|
223
|
+
|
|
224
|
+
# WHOSE Bodies of Work may act as a release feed: the Alissa actor id(s)
|
|
225
|
+
# allowed to OWN a feed container. Fail-closed like `repos` — empty is
|
|
226
|
+
# REJECTED at build when `release_feed` is `bow` (see build's guard). The
|
|
227
|
+
# title tells the daemon which repo a container targets; it says nothing
|
|
228
|
+
# about who created it, and the daemon lists containers it merely
|
|
229
|
+
# collaborates on, which anyone in the tenant can arrange unilaterally.
|
|
230
|
+
# So provenance is an operator decision, made here. Ids are opaque and
|
|
231
|
+
# compared EXACTLY (unlike `repos`, which casefolds GitHub names).
|
|
232
|
+
bow_owners: tuple[str, ...] = ()
|
|
233
|
+
|
|
234
|
+
# The `bow`-feed safety net: every this-many seconds, ALSO run the full
|
|
235
|
+
# marker scan and LOG every marked committed task the BOW feed did not
|
|
236
|
+
# surface, so a feed bug can never make work invisible. Purely level-based
|
|
237
|
+
# visibility — the sweep never releases (attachment is the only trigger).
|
|
238
|
+
# 0 disables it. Ignored entirely in `scan` mode (the scan IS the feed).
|
|
239
|
+
scan_fallback_interval: int = 3600
|
|
240
|
+
|
|
183
241
|
# Derived at build time: `repos` casefolded for matching. GitHub names are
|
|
184
242
|
# case-insensitive, but `repos` keeps the operator's casing for logs/repr.
|
|
185
243
|
_repo_match: frozenset[str] = field(
|
|
@@ -205,6 +263,16 @@ class Config:
|
|
|
205
263
|
GitHub owner/repo names."""
|
|
206
264
|
return full_name.casefold() in self._repo_match
|
|
207
265
|
|
|
266
|
+
def trusts_feed_owner(self, actor_id: str | None) -> bool:
|
|
267
|
+
"""Whether a Body of Work owned by this actor may act as a release feed.
|
|
268
|
+
|
|
269
|
+
Fail-closed on a MISSING owner too: a container whose provenance the
|
|
270
|
+
payload does not state cannot be attributed, and an unattributable feed
|
|
271
|
+
is exactly what `bow_owners` exists to refuse. Exact comparison — Alissa
|
|
272
|
+
actor ids are opaque, so normalising them would only widen the match.
|
|
273
|
+
"""
|
|
274
|
+
return bool(actor_id) and actor_id in self.bow_owners
|
|
275
|
+
|
|
208
276
|
@classmethod
|
|
209
277
|
def build(
|
|
210
278
|
cls,
|
|
@@ -280,6 +348,65 @@ class Config:
|
|
|
280
348
|
if not value.strip():
|
|
281
349
|
raise ValueError(f"{name} must be a non-empty string")
|
|
282
350
|
|
|
351
|
+
release_feed = str(raw.get("release_feed", cls.release_feed)).strip().lower()
|
|
352
|
+
if release_feed not in RELEASE_FEEDS:
|
|
353
|
+
raise ValueError(
|
|
354
|
+
f"release_feed must be one of {', '.join(RELEASE_FEEDS)}, got "
|
|
355
|
+
f"{raw.get('release_feed')!r}"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# NOT stripped: the convention's trailing space (`autodev: `) is load-
|
|
359
|
+
# bearing, so only an all-whitespace prefix is rejected. An empty prefix
|
|
360
|
+
# would make EVERY Body of Work a release feed.
|
|
361
|
+
bow_prefix = str(raw.get("bow_prefix", cls.bow_prefix))
|
|
362
|
+
if not bow_prefix.strip():
|
|
363
|
+
raise ValueError(
|
|
364
|
+
"bow_prefix must be a non-empty string — an empty prefix would "
|
|
365
|
+
"turn every Body of Work into a release feed"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
bow_owners = _string_list(raw.get("bow_owners", cls.bow_owners), "bow_owners")
|
|
369
|
+
if release_feed == FEED_BOW and not bow_owners:
|
|
370
|
+
# Fail-closed, the `repos` guard's twin. `repos` bounds WHERE the
|
|
371
|
+
# orchestrator writes; this bounds WHOSE work drives it. The feed
|
|
372
|
+
# lists Bodies of Work the daemon merely collaborates on, and being
|
|
373
|
+
# made a collaborator takes no acceptance step on this side, so
|
|
374
|
+
# without an owner allowlist "shared with the daemon and titled for
|
|
375
|
+
# a watched repo" is enough to get a task rendered into an issue in
|
|
376
|
+
# that repo and handed to the develop pipeline. Only fatal for the
|
|
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
|
+
)
|
|
387
|
+
|
|
388
|
+
fallback = int(raw.get("scan_fallback_interval", cls.scan_fallback_interval))
|
|
389
|
+
if fallback < 0:
|
|
390
|
+
raise ValueError(
|
|
391
|
+
f"scan_fallback_interval must be >= 0 (0 disables the sweep), "
|
|
392
|
+
f"got {fallback}"
|
|
393
|
+
)
|
|
394
|
+
if 0 < fallback < MIN_POLL_INTERVAL:
|
|
395
|
+
# A floor on absurd values, nothing more. The sweep is driven from
|
|
396
|
+
# the poll loop, so it can never run more often than `poll_interval`
|
|
397
|
+
# anyway -- what this rejects is a value so small it reads as "off"
|
|
398
|
+
# to a human but is not (`1`), and it shares MIN_POLL_INTERVAL
|
|
399
|
+
# rather than inventing a second floor. The real contract -- that
|
|
400
|
+
# the sweep is LOW-frequency relative to the poll, so the `bow` feed
|
|
401
|
+
# keeps saving the full scan it exists to avoid -- is the operator's
|
|
402
|
+
# to set: any `scan_fallback_interval <= poll_interval` sweeps on
|
|
403
|
+
# every poll, which is legitimate on a slow poll and wasteful on a
|
|
404
|
+
# fast one, so it is documented (README) rather than enforced here.
|
|
405
|
+
raise ValueError(
|
|
406
|
+
f"scan_fallback_interval must be 0 (off) or >= {MIN_POLL_INTERVAL} "
|
|
407
|
+
f"seconds, got {fallback}"
|
|
408
|
+
)
|
|
409
|
+
|
|
283
410
|
ledger_path = raw.get("ledger_path", cls.ledger_path)
|
|
284
411
|
return cls(
|
|
285
412
|
workspace_root=Path(workspace_root),
|
|
@@ -296,6 +423,10 @@ class Config:
|
|
|
296
423
|
raw.get("close_loop_enabled", cls.close_loop_enabled)
|
|
297
424
|
),
|
|
298
425
|
dry_run=bool(raw.get("dry_run", cls.dry_run)),
|
|
426
|
+
release_feed=release_feed,
|
|
427
|
+
bow_prefix=bow_prefix,
|
|
428
|
+
bow_owners=bow_owners,
|
|
429
|
+
scan_fallback_interval=fallback,
|
|
299
430
|
)
|
|
300
431
|
|
|
301
432
|
|