alissa-tools-github-genloop 0.1.0__py3-none-any.whl
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/genloop/__init__.py +14 -0
- alissa/tools/github/genloop/__main__.py +89 -0
- alissa/tools/github/genloop/alissa_cli.py +89 -0
- alissa/tools/github/genloop/alissa_client.py +183 -0
- alissa/tools/github/genloop/config.py +294 -0
- alissa/tools/github/genloop/github.py +286 -0
- alissa/tools/github/genloop/loop.py +971 -0
- alissa/tools/github/genloop/loop_events.py +115 -0
- alissa/tools/github/genloop/pass_directive.py +325 -0
- alissa/tools/github/genloop/plan.py +461 -0
- alissa/tools/github/genloop/proc.py +61 -0
- alissa/tools/github/genloop/state.py +113 -0
- alissa/tools/github/genloop/version +1 -0
- alissa/tools/github/genloop/version.py +46 -0
- alissa_tools_github_genloop-0.1.0.dist-info/METADATA +45 -0
- alissa_tools_github_genloop-0.1.0.dist-info/RECORD +21 -0
- alissa_tools_github_genloop-0.1.0.dist-info/WHEEL +5 -0
- alissa_tools_github_genloop-0.1.0.dist-info/entry_points.txt +2 -0
- alissa_tools_github_genloop-0.1.0.dist-info/licenses/LICENSE +202 -0
- alissa_tools_github_genloop-0.1.0.dist-info/licenses/NOTICE +5 -0
- alissa_tools_github_genloop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""genloop — the generator seat of the Alissa GitHub daemon family (design
|
|
2
|
+
`docs/design/genloop.md` v0.2 in `ali-fhr/studio.alissa.app`, lane L5).
|
|
3
|
+
|
|
4
|
+
Where orcloop releases committed work, devloop develops it and revloop
|
|
5
|
+
reviews it, genloop PROPOSES it: on a cadence it spawns one judgment session
|
|
6
|
+
(`genloop-<stamp>`) that reads the operator's direction and the loop's
|
|
7
|
+
exhaust and writes at most one plan on a `PLAN-` branch of the fleet's plans
|
|
8
|
+
repository; the seat then opens that plan's pull request, requests the
|
|
9
|
+
refuter, records the plan and emits the `plan.*` / `pass.*` loop events. The
|
|
10
|
+
reserved runtime env prefix is `ALISSA_GEN_*`; the seat is OFF unless
|
|
11
|
+
`ALISSA_GEN_ENABLED` is set.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""`alissa-genloop` — run the genloop seat.
|
|
2
|
+
|
|
3
|
+
alissa-genloop # poll forever (every ALISSA_GEN_POLL_INTERVAL seconds)
|
|
4
|
+
alissa-genloop --once # one poll, print its decision line, exit
|
|
5
|
+
alissa-genloop --dry-run # narrate every side effect instead of performing it
|
|
6
|
+
alissa-genloop --version
|
|
7
|
+
|
|
8
|
+
Configuration is the environment only (`ALISSA_GEN_*`, pass-through-when-unset,
|
|
9
|
+
see `config.py`); tokens are `GH_TOKEN` (read by `gh`) and `ALISSA_API_TOKEN`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import logging
|
|
16
|
+
import signal
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
from typing import Sequence
|
|
20
|
+
|
|
21
|
+
from .alissa_cli import AlissaCli
|
|
22
|
+
from .alissa_client import AlissaClient
|
|
23
|
+
from .config import Config, ConfigError
|
|
24
|
+
from .github import GitHub
|
|
25
|
+
from .loop import GenLoop
|
|
26
|
+
from .loop_events import LoopEventRecorder
|
|
27
|
+
from .state import State
|
|
28
|
+
from .version import version
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("alissa.tools.github.genloop")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_loop(config: Config) -> GenLoop:
|
|
34
|
+
alissa = AlissaClient(base=config.alissa_endpoint or None)
|
|
35
|
+
return GenLoop(
|
|
36
|
+
config,
|
|
37
|
+
state=State.in_dir(config.state_path),
|
|
38
|
+
alissa=alissa,
|
|
39
|
+
github=GitHub(),
|
|
40
|
+
cli=AlissaCli(),
|
|
41
|
+
events=LoopEventRecorder(alissa, dry_run=config.dry_run),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
46
|
+
parser = argparse.ArgumentParser(prog="alissa-genloop", description="The genloop seat (design lane L5).")
|
|
47
|
+
parser.add_argument("--once", action="store_true", help="run one poll and exit")
|
|
48
|
+
parser.add_argument("--dry-run", action="store_true", help="narrate side effects instead of performing them")
|
|
49
|
+
parser.add_argument("--log-level", default="INFO", help="logging level (default INFO)")
|
|
50
|
+
parser.add_argument("--version", action="version", version=f"{version.name} {version.value}")
|
|
51
|
+
args = parser.parse_args(argv)
|
|
52
|
+
|
|
53
|
+
logging.basicConfig(
|
|
54
|
+
level=getattr(logging, str(args.log_level).upper(), logging.INFO),
|
|
55
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
config = Config.from_env()
|
|
59
|
+
if args.dry_run:
|
|
60
|
+
config = config.replace(dry_run=True)
|
|
61
|
+
except ConfigError as exc:
|
|
62
|
+
log.error("genloop: configuration refused — %s", exc)
|
|
63
|
+
return 2
|
|
64
|
+
log.info("genloop %s starting: %s", version.value, config.summary())
|
|
65
|
+
loop = build_loop(config)
|
|
66
|
+
|
|
67
|
+
if args.once:
|
|
68
|
+
print(loop.poll())
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
stop = threading.Event()
|
|
72
|
+
|
|
73
|
+
def _stop(signum, _frame): # pragma: no cover - signal plumbing
|
|
74
|
+
log.info("genloop: signal %d — stopping after this poll", signum)
|
|
75
|
+
stop.set()
|
|
76
|
+
|
|
77
|
+
signal.signal(signal.SIGTERM, _stop)
|
|
78
|
+
signal.signal(signal.SIGINT, _stop)
|
|
79
|
+
while not stop.is_set():
|
|
80
|
+
try:
|
|
81
|
+
loop.poll()
|
|
82
|
+
except Exception: # noqa: BLE001 - one bad poll must not end the seat
|
|
83
|
+
log.exception("genloop: the poll raised — retrying next interval")
|
|
84
|
+
stop.wait(config.poll_interval)
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__": # pragma: no cover
|
|
89
|
+
sys.exit(main())
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""The `alissa` CLI surface the seat needs: enqueue one managed session, probe
|
|
2
|
+
the worker, list the managed tmux sessions, kill ONE of them.
|
|
3
|
+
|
|
4
|
+
Copy-adapted from orcloop's `alissa_cli.py` (itself devloop's), deliberately
|
|
5
|
+
NOT redesigned: the seats drive the same worker through the same four
|
|
6
|
+
commands, and a divergence here is a divergence in what "a live session"
|
|
7
|
+
means between the seats that count them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Sequence
|
|
16
|
+
|
|
17
|
+
from .proc import CommandError, run
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run(argv: Sequence[str], *, timeout: int, check: bool = True) -> str:
|
|
23
|
+
"""`proc.run`, with a CLI that could not be EXECUTED (missing binary,
|
|
24
|
+
unexecutable file) folded into the same `CommandError` as a CLI that
|
|
25
|
+
answered badly -- every caller treats "the CLI did not answer" as a hold."""
|
|
26
|
+
try:
|
|
27
|
+
return run(argv, timeout=timeout, check=check)
|
|
28
|
+
except OSError as exc:
|
|
29
|
+
raise CommandError(argv, -1, f"could not execute {argv[0]!r}: {exc}") from exc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AlissaCli:
|
|
33
|
+
def enqueue_session(self, *, session: str, directive: str, cwd: Path, agent: str) -> None:
|
|
34
|
+
"""`alissa tmux queue add <session> --agent <agent> --cwd <cwd>
|
|
35
|
+
<directive>`, then `queue set <session> respawn off` (best-effort): a
|
|
36
|
+
pass is one-shot, the seat's interval is the cadence."""
|
|
37
|
+
_run(
|
|
38
|
+
["alissa", "tmux", "queue", "add", session, "--agent", agent, "--cwd", str(cwd), directive],
|
|
39
|
+
timeout=60,
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
_run(["alissa", "tmux", "queue", "set", session, "respawn", "off"], timeout=30, check=False)
|
|
43
|
+
except CommandError: # pragma: no cover - defence in depth
|
|
44
|
+
log.warning("could not set respawn off for %s", session)
|
|
45
|
+
|
|
46
|
+
def list_sessions(self) -> "list[dict]":
|
|
47
|
+
"""`alissa tmux ls --json`. Raises CommandError whenever the CLI does
|
|
48
|
+
not answer USABLY (non-zero exit, empty or unparseable stdout, not a
|
|
49
|
+
list): a working CLI prints `[]` when there are no sessions, so
|
|
50
|
+
anything else is UNKNOWN -- and unknown holds the spawn (L2)."""
|
|
51
|
+
argv = ["alissa", "tmux", "ls", "--json"]
|
|
52
|
+
out = _run(argv, timeout=60)
|
|
53
|
+
if not out.strip():
|
|
54
|
+
raise CommandError(argv, 0, "`alissa tmux ls --json` printed nothing")
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(out)
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
raise CommandError(argv, 0, "`alissa tmux ls --json` returned unparseable output") from None
|
|
59
|
+
if not isinstance(data, list):
|
|
60
|
+
raise CommandError(argv, 0, f"`alissa tmux ls --json` returned {type(data).__name__}, not a list")
|
|
61
|
+
return [entry for entry in data if isinstance(entry, dict)]
|
|
62
|
+
|
|
63
|
+
def queued(self, name: str) -> bool:
|
|
64
|
+
"""True while `alissa tmux queue ls <name> --json` still holds a
|
|
65
|
+
pending item: the session was enqueued but not yet dispatched, so its
|
|
66
|
+
absence from `tmux ls` does not mean it ended. Raises CommandError
|
|
67
|
+
when the CLI does not answer usably (unknown, which holds)."""
|
|
68
|
+
argv = ["alissa", "tmux", "queue", "ls", name, "--json"]
|
|
69
|
+
out = _run(argv, timeout=60)
|
|
70
|
+
try:
|
|
71
|
+
data = json.loads(out)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
raise CommandError(argv, 0, "`alissa tmux queue ls --json` returned unparseable output") from None
|
|
74
|
+
if not isinstance(data, dict) or not isinstance(data.get("items"), list):
|
|
75
|
+
raise CommandError(argv, 0, "`alissa tmux queue ls --json` returned no `items` list")
|
|
76
|
+
return bool(data["items"])
|
|
77
|
+
|
|
78
|
+
def kill_session(self, name: str) -> None:
|
|
79
|
+
"""Kill ONE managed session by name -- never the tmux server."""
|
|
80
|
+
_run(["alissa", "tmux", "kill", name], timeout=60)
|
|
81
|
+
|
|
82
|
+
def worker_running(self) -> bool:
|
|
83
|
+
"""The queue only drains while `alissa worker` reconciles it."""
|
|
84
|
+
try:
|
|
85
|
+
out = _run(["alissa", "worker", "status"], timeout=30, check=False)
|
|
86
|
+
except CommandError:
|
|
87
|
+
return False
|
|
88
|
+
lowered = out.lower()
|
|
89
|
+
return "not running" not in lowered and "no worker" not in lowered
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Thin REST adapter over the Alissa routes the genloop seat calls.
|
|
2
|
+
|
|
3
|
+
Transport (`_request`, `_classify`) is copied from orcloop's client: stdlib
|
|
4
|
+
`urllib` over HTTPS, `ALISSA_API_TOKEN` / `ALISSA_BASE` from the environment,
|
|
5
|
+
every failure mapped onto a narrow taxonomy so callers never see a raw urllib
|
|
6
|
+
error or status.
|
|
7
|
+
|
|
8
|
+
The seat runs on the planner's own agent-bound token (D12). Studio answers
|
|
9
|
+
that token with its SPONSOR's rows on every read below; of the writes, the
|
|
10
|
+
seat's own two are `POST /v1/loop-events` and `POST /v1/loop/plans`. The
|
|
11
|
+
third, `PUT /v1/factory/proposal-rules` (§4.4's mechanical rule write), is
|
|
12
|
+
documented by Studio as a `403 FORBIDDEN` for an agent-bound token -- the seat
|
|
13
|
+
attempts it and records the refusal rather than retrying it every poll.
|
|
14
|
+
|
|
15
|
+
Routes (Studio `API.md`):
|
|
16
|
+
|
|
17
|
+
* `GET /v1/ping` -> who the token acts as.
|
|
18
|
+
* `GET /v1/factory/steering` -> `{records: [...]}`, fleet row first.
|
|
19
|
+
* `GET /v1/factory/proposal-rules` -> `{rules: [...]}`, unexpired only.
|
|
20
|
+
* `PUT /v1/factory/proposal-rules` -> one rule, `201` new / `200 renewed`.
|
|
21
|
+
* `GET /v1/code/budget?scope=&projectId=&window=` -> `{state, spentUsd?, capUsd?, remainingUsd?}`.
|
|
22
|
+
* `GET /v1/loop/generated` -> `{openLanes, plansToday, plansOpen}`.
|
|
23
|
+
* `POST /v1/loop/plans` -> idempotent on `(repo, prNumber)`.
|
|
24
|
+
* `POST /v1/loop-events` -> idempotent on each `dedupeKey`.
|
|
25
|
+
* `GET /v1/bodies-of-work?includeShared=true` -> `{bodiesOfWork: [...]}`.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import http.client
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import urllib.error
|
|
35
|
+
import urllib.parse
|
|
36
|
+
import urllib.request
|
|
37
|
+
from typing import Sequence
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
DEFAULT_BASE = "https://api.alissa.app"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AlissaError(RuntimeError):
|
|
45
|
+
"""Base class. `status` is the HTTP code (0 for transport failures); `code`
|
|
46
|
+
is the API's machine error string (`{"error": ...}`) when one was sent."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, status: int, payload: object, code: str | None = None):
|
|
49
|
+
self.status = status
|
|
50
|
+
self.payload = payload
|
|
51
|
+
self.code = code
|
|
52
|
+
super().__init__(f"HTTP {status}{f' {code}' if code else ''}: {payload}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AlissaAuthError(AlissaError):
|
|
56
|
+
"""401/403: no valid token, or the bound actor may not do this."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AlissaNotFound(AlissaError):
|
|
60
|
+
"""404."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AlissaTransient(AlissaError):
|
|
64
|
+
"""408/429/5xx or a transport failure: retry next poll."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _query(params: dict) -> str:
|
|
68
|
+
pairs = [(k, str(v)) for k, v in params.items() if v is not None and v != ""]
|
|
69
|
+
return "?" + urllib.parse.urlencode(pairs) if pairs else ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AlissaClient:
|
|
73
|
+
def __init__(self, token: str | None = None, base: str | None = None, *, timeout: int = 30):
|
|
74
|
+
self.base = (base or os.environ.get("ALISSA_BASE") or DEFAULT_BASE).rstrip("/")
|
|
75
|
+
self._token = token if token is not None else os.environ.get("ALISSA_API_TOKEN")
|
|
76
|
+
self._timeout = timeout
|
|
77
|
+
|
|
78
|
+
# --- transport ---------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def _request(self, method: str, path: str, body: dict | None = None) -> object:
|
|
81
|
+
if not self._token:
|
|
82
|
+
raise AlissaAuthError(0, "ALISSA_API_TOKEN is not set")
|
|
83
|
+
url = f"{self.base}{path}"
|
|
84
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
85
|
+
headers = {"Accept": "application/json", "Authorization": f"Bearer {self._token}"}
|
|
86
|
+
if data is not None:
|
|
87
|
+
headers["Content-Type"] = "application/json"
|
|
88
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
89
|
+
try:
|
|
90
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp: # noqa: S310
|
|
91
|
+
raw = resp.read().decode("utf-8")
|
|
92
|
+
except urllib.error.HTTPError as exc:
|
|
93
|
+
raise self._classify(exc) from None
|
|
94
|
+
except urllib.error.URLError as exc:
|
|
95
|
+
raise AlissaTransient(0, str(exc.reason)) from None
|
|
96
|
+
except (TimeoutError, OSError, http.client.HTTPException) as exc:
|
|
97
|
+
# A read timeout or reset after the connection is up is not a
|
|
98
|
+
# URLError; without this branch it would end the process.
|
|
99
|
+
raise AlissaTransient(0, str(exc) or type(exc).__name__) from None
|
|
100
|
+
return json.loads(raw) if raw.strip() else {}
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _classify(exc: "urllib.error.HTTPError") -> AlissaError:
|
|
104
|
+
try:
|
|
105
|
+
detail: object = exc.read().decode("utf-8", "replace")
|
|
106
|
+
except (TimeoutError, OSError, http.client.HTTPException):
|
|
107
|
+
detail = ""
|
|
108
|
+
code: str | None = None
|
|
109
|
+
try:
|
|
110
|
+
parsed = json.loads(detail) # type: ignore[arg-type]
|
|
111
|
+
if isinstance(parsed, dict):
|
|
112
|
+
detail = parsed
|
|
113
|
+
raw_code = parsed.get("error")
|
|
114
|
+
code = raw_code if isinstance(raw_code, str) else None
|
|
115
|
+
except (json.JSONDecodeError, TypeError):
|
|
116
|
+
pass
|
|
117
|
+
status = exc.code
|
|
118
|
+
if status in (401, 403):
|
|
119
|
+
return AlissaAuthError(status, detail, code)
|
|
120
|
+
if status == 404:
|
|
121
|
+
return AlissaNotFound(status, detail, code)
|
|
122
|
+
if status == 408 or status == 429 or 500 <= status <= 599:
|
|
123
|
+
return AlissaTransient(status, detail, code)
|
|
124
|
+
return AlissaError(status, detail, code)
|
|
125
|
+
|
|
126
|
+
# --- reads -------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def ping(self) -> dict:
|
|
129
|
+
payload = self._request("GET", "/v1/ping")
|
|
130
|
+
return payload if isinstance(payload, dict) else {}
|
|
131
|
+
|
|
132
|
+
def factory_steering(self) -> list[dict]:
|
|
133
|
+
"""The operator's steering rows (fleet first). Empty when nothing is
|
|
134
|
+
set -- which steers nothing and parks nothing."""
|
|
135
|
+
payload = self._request("GET", "/v1/factory/steering")
|
|
136
|
+
records = payload.get("records") if isinstance(payload, dict) else None
|
|
137
|
+
return [row for row in records if isinstance(row, dict)] if isinstance(records, list) else []
|
|
138
|
+
|
|
139
|
+
def proposal_rules(self) -> list[dict]:
|
|
140
|
+
payload = self._request("GET", "/v1/factory/proposal-rules")
|
|
141
|
+
rules = payload.get("rules") if isinstance(payload, dict) else None
|
|
142
|
+
return [row for row in rules if isinstance(row, dict)] if isinstance(rules, list) else []
|
|
143
|
+
|
|
144
|
+
def budget_status(self, scope: str, *, project_id: str | None = None, window: str = "30d") -> dict:
|
|
145
|
+
"""The cost meter's `budgetStatus`: `{state, spentUsd?, capUsd?,
|
|
146
|
+
remainingUsd?, ...}`, `state` one of ok / warn / exhausted / unmetered."""
|
|
147
|
+
payload = self._request(
|
|
148
|
+
"GET", "/v1/code/budget" + _query({"scope": scope, "projectId": project_id, "window": window}),
|
|
149
|
+
)
|
|
150
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("state"), str):
|
|
151
|
+
raise AlissaError(0, payload, "budget answered no state")
|
|
152
|
+
return payload
|
|
153
|
+
|
|
154
|
+
def loop_generated(self) -> dict:
|
|
155
|
+
"""`{openLanes, plansToday, plansOpen}`."""
|
|
156
|
+
payload = self._request("GET", "/v1/loop/generated")
|
|
157
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("openLanes"), int):
|
|
158
|
+
raise AlissaError(0, payload, "generated answered no openLanes")
|
|
159
|
+
return payload
|
|
160
|
+
|
|
161
|
+
def list_bodies_of_work(self) -> list[dict]:
|
|
162
|
+
payload = self._request("GET", "/v1/bodies-of-work?includeShared=true")
|
|
163
|
+
rows = payload.get("bodiesOfWork") if isinstance(payload, dict) else None
|
|
164
|
+
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
|
|
165
|
+
|
|
166
|
+
# --- writes ------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def post_loop_plan(self, record: dict) -> dict:
|
|
169
|
+
"""Record a plan or move its status forward. Idempotent on `(repo,
|
|
170
|
+
prNumber)`: the same status answers `200 {duplicate: true}`."""
|
|
171
|
+
payload = self._request("POST", "/v1/loop/plans", record)
|
|
172
|
+
return payload if isinstance(payload, dict) else {}
|
|
173
|
+
|
|
174
|
+
def post_loop_events(self, events: "Sequence[dict]") -> dict:
|
|
175
|
+
payload = self._request("POST", "/v1/loop-events", {"events": list(events)})
|
|
176
|
+
return payload if isinstance(payload, dict) else {}
|
|
177
|
+
|
|
178
|
+
def put_proposal_rule(self, *, pattern: str, scope: str, scope_key: str, reason: str, source_url: str) -> dict:
|
|
179
|
+
body: dict = {"pattern": pattern, "scope": scope, "reason": reason, "sourceUrl": source_url}
|
|
180
|
+
if scope_key:
|
|
181
|
+
body["scopeKey"] = scope_key
|
|
182
|
+
payload = self._request("PUT", "/v1/factory/proposal-rules", body)
|
|
183
|
+
return payload if isinstance(payload, dict) else {}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""The genloop seat's configuration: every `ALISSA_GEN_*` knob, its one
|
|
2
|
+
default, and the validation that runs before the first poll.
|
|
3
|
+
|
|
4
|
+
PASS-THROUGH-WHEN-UNSET (design L10, the three seats' rule)
|
|
5
|
+
=========================================================
|
|
6
|
+
`Config.from_env` reads a key ONLY when its `ALISSA_GEN_<KEY>` variable is set
|
|
7
|
+
and non-empty. A key the operator did not set is absent from the mapping
|
|
8
|
+
`Config.build` sees, so the default below is what takes effect -- one source
|
|
9
|
+
of truth for every default, this module. The container's ARG->ENV block bakes
|
|
10
|
+
every variable to the empty string, which therefore means "unset", never
|
|
11
|
+
"empty value".
|
|
12
|
+
|
|
13
|
+
THE PLANS REPOSITORY IS PER-FLEET CONFIGURATION
|
|
14
|
+
==============================================
|
|
15
|
+
The operator's decision on issue #1 overrides the design's literal: a fleet's
|
|
16
|
+
plans repository is `ALISSA_GEN_PLANS_REPO`, defaulting to
|
|
17
|
+
`<ALISSA_GEN_OWNER>/` + `PLANS_REPO_NAME`. `PLANS_REPO_NAME` below is the ONE
|
|
18
|
+
place the repository's name is written in this distribution, and
|
|
19
|
+
`test_no_plans_repo_literal` holds every other file of the repository to that.
|
|
20
|
+
|
|
21
|
+
FAIL CLOSED (design L9)
|
|
22
|
+
=======================
|
|
23
|
+
`enabled` defaults to OFF: an image deployed without `ALISSA_GEN_ENABLED`
|
|
24
|
+
idles and says why every poll. Once enabled, a seat that cannot name its plans
|
|
25
|
+
repository, its reviewer or (in `static` mode) one fed repo refuses to boot
|
|
26
|
+
rather than guess.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import dataclasses
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Mapping
|
|
37
|
+
|
|
38
|
+
ENV_PREFIX = "ALISSA_GEN_"
|
|
39
|
+
|
|
40
|
+
# The default plans repository's NAME (its owner is the fleet's, see above).
|
|
41
|
+
PLANS_REPO_NAME = "alissa-dark-factory-plans"
|
|
42
|
+
|
|
43
|
+
REPOS_SOURCE_BOWS = "bows"
|
|
44
|
+
REPOS_SOURCE_STATIC = "static"
|
|
45
|
+
REPOS_SOURCES = (REPOS_SOURCE_BOWS, REPOS_SOURCE_STATIC)
|
|
46
|
+
|
|
47
|
+
# The feed body-of-work title prefix every seat reads (`autodev: <owner>/<repo>`).
|
|
48
|
+
BOW_PREFIX = "autodev:"
|
|
49
|
+
|
|
50
|
+
DEFAULT_WORKSPACE_ROOT = "/workspace"
|
|
51
|
+
DEFAULT_STATE_DIRNAME = ".genloop"
|
|
52
|
+
|
|
53
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
54
|
+
_OWNER_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
55
|
+
_LOGIN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*(\[bot\])?$")
|
|
56
|
+
|
|
57
|
+
# Every key, with its library default. The env name is ENV_PREFIX + KEY.upper().
|
|
58
|
+
DEFAULTS: dict[str, object] = {
|
|
59
|
+
# The deploy-time switch (D11, L9): off unless the operator turns it on.
|
|
60
|
+
"enabled": False,
|
|
61
|
+
# Seconds between passes (D14: six hours).
|
|
62
|
+
"interval": 6 * 3600,
|
|
63
|
+
# Seconds between polls. A poll ingests a finished pass, opens its PR and
|
|
64
|
+
# walks the floors; the interval above is what spaces the SPAWNS.
|
|
65
|
+
"poll_interval": 300,
|
|
66
|
+
# The exhaust look-back the directive hands the pass (§3).
|
|
67
|
+
"window_days": 30,
|
|
68
|
+
# D10's two counters.
|
|
69
|
+
"plans_per_day": 2,
|
|
70
|
+
"open_lanes_max": 4,
|
|
71
|
+
# A live pass older than stale_intervals x interval is stalled (L2).
|
|
72
|
+
"stale_intervals": 2,
|
|
73
|
+
# GitHub logins whose closing comment on a plan PR writes a rule (§4.4).
|
|
74
|
+
"operators": (),
|
|
75
|
+
# The fleet's GitHub owner; the plans repository defaults under it.
|
|
76
|
+
"owner": "",
|
|
77
|
+
# `owner/name`; empty means `<owner>/` + PLANS_REPO_NAME.
|
|
78
|
+
"plans_repo": "",
|
|
79
|
+
# Where the fed-repo allowlist comes from: the `autodev:` feed bodies of
|
|
80
|
+
# work (`bows`, the fleet's mode) or `repos` (`static`).
|
|
81
|
+
"repos_source": REPOS_SOURCE_BOWS,
|
|
82
|
+
"repos": (),
|
|
83
|
+
# In `bows` mode, only feeds owned by these actor ids count (empty: any
|
|
84
|
+
# feed the token can read).
|
|
85
|
+
"bow_owners": (),
|
|
86
|
+
# Who reviews a plan PR (D12: the reviewer bot, never the seat).
|
|
87
|
+
"reviewers": ("alissa-app",),
|
|
88
|
+
# This seat's own GitHub login; empty means `gh api user`.
|
|
89
|
+
"login": "",
|
|
90
|
+
# The pass's skill and the worker agent profile that runs it.
|
|
91
|
+
"skill": "alissa-code-genloop",
|
|
92
|
+
"agent": "claude",
|
|
93
|
+
# Paths. Empty means "derive" (see the properties on Config).
|
|
94
|
+
"workspace_root": "",
|
|
95
|
+
"state_dir": "",
|
|
96
|
+
"plans_hub": "",
|
|
97
|
+
# Alissa's base URL; empty means the client's default (ALISSA_BASE).
|
|
98
|
+
"alissa_endpoint": "",
|
|
99
|
+
# Narrate every side effect instead of performing it.
|
|
100
|
+
"dry_run": False,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
CONFIG_KEYS = tuple(DEFAULTS)
|
|
104
|
+
_BOOL_KEYS = frozenset(k for k, v in DEFAULTS.items() if isinstance(v, bool))
|
|
105
|
+
_INT_KEYS = frozenset(k for k, v in DEFAULTS.items() if isinstance(v, int) and not isinstance(v, bool))
|
|
106
|
+
_LIST_KEYS = frozenset(k for k, v in DEFAULTS.items() if isinstance(v, tuple))
|
|
107
|
+
|
|
108
|
+
# Lower bounds, so a typo cannot make the seat spin.
|
|
109
|
+
_INT_MIN = {
|
|
110
|
+
"interval": 60,
|
|
111
|
+
"poll_interval": 10,
|
|
112
|
+
"window_days": 1,
|
|
113
|
+
"plans_per_day": 0,
|
|
114
|
+
"open_lanes_max": 0,
|
|
115
|
+
"stale_intervals": 1,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_TRUE = frozenset(("1", "true", "yes", "on"))
|
|
119
|
+
_FALSE = frozenset(("0", "false", "no", "off"))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ConfigError(ValueError):
|
|
123
|
+
"""The configuration cannot run; the message names the variable."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def env_name(key: str) -> str:
|
|
127
|
+
return ENV_PREFIX + key.upper()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def split_entries(raw: str) -> tuple[str, ...]:
|
|
131
|
+
"""The family's list syntax: `|`-separated, `,` also accepted, blanks dropped."""
|
|
132
|
+
return tuple(part.strip() for part in re.split(r"[|,]", raw) if part.strip())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _coerce(key: str, value: object) -> object:
|
|
136
|
+
if key in _BOOL_KEYS:
|
|
137
|
+
if isinstance(value, bool):
|
|
138
|
+
return value
|
|
139
|
+
text = str(value).strip().lower()
|
|
140
|
+
if text in _TRUE:
|
|
141
|
+
return True
|
|
142
|
+
if text in _FALSE:
|
|
143
|
+
return False
|
|
144
|
+
raise ConfigError(f"{env_name(key)}={value!r} is not a boolean (true/false)")
|
|
145
|
+
if key in _INT_KEYS:
|
|
146
|
+
try:
|
|
147
|
+
number = int(str(value).strip())
|
|
148
|
+
except ValueError:
|
|
149
|
+
raise ConfigError(f"{env_name(key)}={value!r} is not an integer") from None
|
|
150
|
+
floor = _INT_MIN.get(key)
|
|
151
|
+
if floor is not None and number < floor:
|
|
152
|
+
raise ConfigError(f"{env_name(key)}={number} is below its minimum {floor}")
|
|
153
|
+
return number
|
|
154
|
+
if key in _LIST_KEYS:
|
|
155
|
+
if isinstance(value, (list, tuple)):
|
|
156
|
+
return tuple(str(v).strip() for v in value if str(v).strip())
|
|
157
|
+
return split_entries(str(value))
|
|
158
|
+
return str(value).strip()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class Config:
|
|
163
|
+
enabled: bool = False
|
|
164
|
+
interval: int = 6 * 3600
|
|
165
|
+
poll_interval: int = 300
|
|
166
|
+
window_days: int = 30
|
|
167
|
+
plans_per_day: int = 2
|
|
168
|
+
open_lanes_max: int = 4
|
|
169
|
+
stale_intervals: int = 2
|
|
170
|
+
operators: tuple[str, ...] = ()
|
|
171
|
+
owner: str = ""
|
|
172
|
+
plans_repo: str = ""
|
|
173
|
+
repos_source: str = REPOS_SOURCE_BOWS
|
|
174
|
+
repos: tuple[str, ...] = ()
|
|
175
|
+
bow_owners: tuple[str, ...] = ()
|
|
176
|
+
reviewers: tuple[str, ...] = ("alissa-app",)
|
|
177
|
+
login: str = ""
|
|
178
|
+
skill: str = "alissa-code-genloop"
|
|
179
|
+
agent: str = "claude"
|
|
180
|
+
workspace_root: str = ""
|
|
181
|
+
state_dir: str = ""
|
|
182
|
+
plans_hub: str = ""
|
|
183
|
+
alissa_endpoint: str = ""
|
|
184
|
+
dry_run: bool = False
|
|
185
|
+
|
|
186
|
+
# -- construction ------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def build(cls, mapping: Mapping[str, object] | None = None) -> "Config":
|
|
190
|
+
"""A validated Config from `mapping` (key -> raw value); a key absent
|
|
191
|
+
from the mapping takes its default from `DEFAULTS`."""
|
|
192
|
+
mapping = dict(mapping or {})
|
|
193
|
+
unknown = sorted(set(mapping) - set(CONFIG_KEYS))
|
|
194
|
+
if unknown:
|
|
195
|
+
raise ConfigError(f"unknown config key(s): {', '.join(unknown)}")
|
|
196
|
+
values = {key: _coerce(key, mapping[key]) if key in mapping else DEFAULTS[key] for key in CONFIG_KEYS}
|
|
197
|
+
config = cls(**values) # type: ignore[arg-type]
|
|
198
|
+
config.validate()
|
|
199
|
+
return config
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def from_env(cls, environ: Mapping[str, str] | None = None) -> "Config":
|
|
203
|
+
"""Pass-through-when-unset: only the set, non-empty `ALISSA_GEN_*`
|
|
204
|
+
variables reach `build`. `workspace_root` also honours the image's
|
|
205
|
+
`ALISSA_WORKSPACE_ROOT`."""
|
|
206
|
+
env = os.environ if environ is None else environ
|
|
207
|
+
mapping: dict[str, object] = {}
|
|
208
|
+
for key in CONFIG_KEYS:
|
|
209
|
+
raw = env.get(env_name(key))
|
|
210
|
+
if raw is not None and raw.strip():
|
|
211
|
+
mapping[key] = raw
|
|
212
|
+
if "workspace_root" not in mapping and (env.get("ALISSA_WORKSPACE_ROOT") or "").strip():
|
|
213
|
+
mapping["workspace_root"] = env["ALISSA_WORKSPACE_ROOT"]
|
|
214
|
+
return cls.build(mapping)
|
|
215
|
+
|
|
216
|
+
def replace(self, **changes: object) -> "Config":
|
|
217
|
+
config = dataclasses.replace(self, **changes) # type: ignore[arg-type]
|
|
218
|
+
config.validate()
|
|
219
|
+
return config
|
|
220
|
+
|
|
221
|
+
# -- validation ----------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def validate(self) -> None:
|
|
224
|
+
if self.repos_source not in REPOS_SOURCES:
|
|
225
|
+
raise ConfigError(
|
|
226
|
+
f"{env_name('repos_source')}={self.repos_source!r} is not one of {', '.join(REPOS_SOURCES)}"
|
|
227
|
+
)
|
|
228
|
+
if self.owner and not _OWNER_RE.match(self.owner):
|
|
229
|
+
raise ConfigError(f"{env_name('owner')}={self.owner!r} is not a GitHub owner")
|
|
230
|
+
if self.plans_repo and not _REPO_RE.match(self.plans_repo):
|
|
231
|
+
raise ConfigError(f"{env_name('plans_repo')}={self.plans_repo!r} is not owner/name")
|
|
232
|
+
for repo in self.repos:
|
|
233
|
+
if not _REPO_RE.match(repo):
|
|
234
|
+
raise ConfigError(f"{env_name('repos')} entry {repo!r} is not owner/name")
|
|
235
|
+
for key in ("operators", "reviewers"):
|
|
236
|
+
for login in getattr(self, key):
|
|
237
|
+
if not _LOGIN_RE.match(login):
|
|
238
|
+
raise ConfigError(f"{env_name(key)} entry {login!r} is not a GitHub login")
|
|
239
|
+
if self.login and not _LOGIN_RE.match(self.login):
|
|
240
|
+
raise ConfigError(f"{env_name('login')}={self.login!r} is not a GitHub login")
|
|
241
|
+
if not self.enabled:
|
|
242
|
+
# An idle seat needs nothing else; it says why it idles every poll.
|
|
243
|
+
return
|
|
244
|
+
if not self.plans_repo and not self.owner:
|
|
245
|
+
raise ConfigError(
|
|
246
|
+
f"genloop is enabled but cannot name its plans repository: set {env_name('plans_repo')} "
|
|
247
|
+
f"(owner/name) or {env_name('owner')} (the default is <owner>/{PLANS_REPO_NAME})"
|
|
248
|
+
)
|
|
249
|
+
if not self.reviewers:
|
|
250
|
+
raise ConfigError(f"genloop is enabled with an empty {env_name('reviewers')} — a plan PR needs a refuter")
|
|
251
|
+
if self.repos_source == REPOS_SOURCE_STATIC and not self.repos:
|
|
252
|
+
raise ConfigError(
|
|
253
|
+
f"{env_name('repos_source')}=static with an empty {env_name('repos')} — the seat is fail-closed"
|
|
254
|
+
)
|
|
255
|
+
if self.login and self.login in self.reviewers:
|
|
256
|
+
raise ConfigError(
|
|
257
|
+
f"{env_name('login')}={self.login!r} is also a reviewer — the generator never reviews its "
|
|
258
|
+
"own plan (D12)"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# -- derived -------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def plans_repository(self) -> str:
|
|
265
|
+
"""`owner/name` of the fleet's plans repository."""
|
|
266
|
+
if self.plans_repo:
|
|
267
|
+
return self.plans_repo
|
|
268
|
+
return f"{self.owner}/{PLANS_REPO_NAME}" if self.owner else ""
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def workspace_path(self) -> Path:
|
|
272
|
+
return Path(self.workspace_root or DEFAULT_WORKSPACE_ROOT)
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def state_path(self) -> Path:
|
|
276
|
+
return Path(self.state_dir) if self.state_dir else self.workspace_path / DEFAULT_STATE_DIRNAME
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def plans_hub_path(self) -> Path:
|
|
280
|
+
"""The plans repository's worktree hub -- the one checkout the
|
|
281
|
+
container holds (D14). Defaults to `<workspace>/<repo name>`."""
|
|
282
|
+
if self.plans_hub:
|
|
283
|
+
return Path(self.plans_hub)
|
|
284
|
+
name = self.plans_repository.split("/", 1)[-1] if self.plans_repository else PLANS_REPO_NAME
|
|
285
|
+
return self.workspace_path / name
|
|
286
|
+
|
|
287
|
+
def summary(self) -> str:
|
|
288
|
+
"""One line for the boot log; no secrets live in the config."""
|
|
289
|
+
return (
|
|
290
|
+
f"enabled={self.enabled} interval={self.interval}s poll={self.poll_interval}s "
|
|
291
|
+
f"plans_repo={self.plans_repository or '<unset>'} repos_source={self.repos_source} "
|
|
292
|
+
f"plans_per_day={self.plans_per_day} open_lanes_max={self.open_lanes_max} "
|
|
293
|
+
f"reviewers={'|'.join(self.reviewers)} dry_run={self.dry_run}"
|
|
294
|
+
)
|