alissa-tools-github-orcloop 0.1.0__tar.gz → 0.2.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.1.0/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.2.0}/PKG-INFO +10 -7
- {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/README.md +10 -7
- {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/src/main/alissa/tools/github/orcloop/__init__.py +3 -3
- alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/__main__.py +209 -0
- alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/alissa_client.py +393 -0
- alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/config.py +333 -0
- alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/ghclient.py +372 -0
- alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/loop.py +915 -0
- alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/markers.py +73 -0
- alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/proc.py +62 -0
- alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/state.py +166 -0
- alissa_tools_github_orcloop-0.2.0/src/main/alissa/tools/github/orcloop/version +1 -0
- {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +10 -7
- {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +7 -0
- alissa_tools_github_orcloop-0.1.0/src/main/alissa/tools/github/orcloop/__main__.py +0 -45
- alissa_tools_github_orcloop-0.1.0/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/setup.py +0 -0
- {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_orcloop-0.1.0 → alissa_tools_github_orcloop-0.2.0}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: alissa-tools-github-orcloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: ALISSA-TOOLS-GITHUB-ORCLOOP
|
|
5
5
|
Home-page: https://alissa.app
|
|
6
6
|
Author: Fahera
|
|
@@ -46,13 +46,16 @@ The rule: a distribution owns a leaf package and declares only that subtree
|
|
|
46
46
|
`__init__.py` at any namespace level would claim it for one distribution and
|
|
47
47
|
shadow the others.
|
|
48
48
|
|
|
49
|
-
## Status
|
|
49
|
+
## Status
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
The daemon is live: the config surface (O1), the Alissa/GitHub clients and the
|
|
52
|
+
release ledger (O2), and the decision loop — both the release and close edges
|
|
53
|
+
(O3) — are all in place, driven by the `alissa-orcloop` CLI. See the repo
|
|
54
|
+
[README](../README.md) for the end-to-end documentation: the pipeline role, the
|
|
55
|
+
`Autonomous-Dev` / `Autonomous-Scope` marker contract, both decision matrices,
|
|
56
|
+
the full `ALISSA_ORC_*` configuration surface, and the escalation catalog.
|
|
57
|
+
Packaging the daemon as a container image (O5) is the remaining lane. The
|
|
58
|
+
reserved runtime env prefix is `ALISSA_ORC_*`.
|
|
56
59
|
|
|
57
60
|
## Install
|
|
58
61
|
|
|
@@ -29,13 +29,16 @@ The rule: a distribution owns a leaf package and declares only that subtree
|
|
|
29
29
|
`__init__.py` at any namespace level would claim it for one distribution and
|
|
30
30
|
shadow the others.
|
|
31
31
|
|
|
32
|
-
## Status
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
## Status
|
|
33
|
+
|
|
34
|
+
The daemon is live: the config surface (O1), the Alissa/GitHub clients and the
|
|
35
|
+
release ledger (O2), and the decision loop — both the release and close edges
|
|
36
|
+
(O3) — are all in place, driven by the `alissa-orcloop` CLI. See the repo
|
|
37
|
+
[README](../README.md) for the end-to-end documentation: the pipeline role, the
|
|
38
|
+
`Autonomous-Dev` / `Autonomous-Scope` marker contract, both decision matrices,
|
|
39
|
+
the full `ALISSA_ORC_*` configuration surface, and the escalation catalog.
|
|
40
|
+
Packaging the daemon as a container image (O5) is the remaining lane. The
|
|
41
|
+
reserved runtime env prefix is `ALISSA_ORC_*`.
|
|
39
42
|
|
|
40
43
|
## Install
|
|
41
44
|
|
|
@@ -6,9 +6,9 @@ releases tasks marked ready as `alissa:develop` issues, and closes the loop when
|
|
|
6
6
|
the resulting PR merges. It is the mechanical driver that turns committed work in
|
|
7
7
|
Alissa into GitHub issues the develop/review daemons can pick up.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
clients, the decision loop, the Docker image) land across
|
|
9
|
+
O1 landed the config surface (`config.py`), the marker contract (`markers.py`),
|
|
10
|
+
and the real `alissa-orcloop` CLI. The remaining moving parts (Alissa/GitHub
|
|
11
|
+
clients, the decision loop, the Docker image) land across O2–O5; see the repo
|
|
12
12
|
README's lane map. The reserved runtime env prefix is `ALISSA_ORC_*`.
|
|
13
13
|
"""
|
|
14
14
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""CLI entry point: alissa-orcloop (or python -m alissa.tools.github.orcloop).
|
|
2
|
+
|
|
3
|
+
O1 wired the config surface and the argument parser; O3 wires the decision
|
|
4
|
+
loop behind it. The CLI resolves and validates the full configuration
|
|
5
|
+
(four-layer precedence, fail-closed `repos` guard), reports the effective
|
|
6
|
+
settings, then runs the orchestrator: `--once` for a single sweep (pair it
|
|
7
|
+
with `--dry-run -v` to print every decision and its reasons without touching
|
|
8
|
+
GitHub or the task graph), otherwise `run_forever`. Config errors still surface
|
|
9
|
+
here, at startup, with a clean exit code.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import logging
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .alissa_client import AlissaError
|
|
20
|
+
from .config import (
|
|
21
|
+
Config,
|
|
22
|
+
load_config_file,
|
|
23
|
+
resolve_config_path,
|
|
24
|
+
)
|
|
25
|
+
from .ghclient import RateLimited
|
|
26
|
+
from .loop import Orchestrator
|
|
27
|
+
from .proc import CommandError
|
|
28
|
+
from .version import version
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
34
|
+
p = argparse.ArgumentParser(
|
|
35
|
+
prog="alissa-orcloop",
|
|
36
|
+
description="Orchestrate the Alissa task graph into alissa:develop "
|
|
37
|
+
"issues and close the loop on merge — the orchestrator daemon of the "
|
|
38
|
+
"Alissa GitHub daemon family.",
|
|
39
|
+
epilog="Every setting below can also live in the config file; a CLI "
|
|
40
|
+
"flag wins over the file, and an ALISSA_ORC_* environment variable "
|
|
41
|
+
"wins over the flag. workspace_root is CLI-only, so one config can "
|
|
42
|
+
"drive several orchestrators over different workspaces.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
p.add_argument(
|
|
46
|
+
"--version", action="version", version=f"%(prog)s {version.value}"
|
|
47
|
+
)
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"--workspace-root",
|
|
50
|
+
type=Path,
|
|
51
|
+
default=None,
|
|
52
|
+
metavar="PATH",
|
|
53
|
+
help="the workspace this orchestrator runs against (default: current "
|
|
54
|
+
"directory); also where the ledger defaults",
|
|
55
|
+
)
|
|
56
|
+
p.add_argument(
|
|
57
|
+
"-c",
|
|
58
|
+
"--config-path",
|
|
59
|
+
"--config",
|
|
60
|
+
dest="config_path",
|
|
61
|
+
type=Path,
|
|
62
|
+
default=None,
|
|
63
|
+
metavar="PATH",
|
|
64
|
+
help="config file; without it, ./orcloop.config.json then "
|
|
65
|
+
"<workspace-root>/orcloop.config.json, else defaults only",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
mode = p.add_argument_group("mode")
|
|
69
|
+
mode.add_argument(
|
|
70
|
+
"--once", action="store_true", help="run a single pass and exit"
|
|
71
|
+
)
|
|
72
|
+
mode.add_argument("-v", "--verbose", action="store_true")
|
|
73
|
+
|
|
74
|
+
over = p.add_argument_group(
|
|
75
|
+
"config overrides (win over the config file; ALISSA_ORC_* wins over these)"
|
|
76
|
+
)
|
|
77
|
+
over.add_argument(
|
|
78
|
+
"--repo",
|
|
79
|
+
dest="repos",
|
|
80
|
+
action="append",
|
|
81
|
+
metavar="OWNER/REPO",
|
|
82
|
+
help="watch this repo; repeatable. When given, REPLACES the config "
|
|
83
|
+
"`repos` list. The allowlist is fail-closed: an empty allowlist is a "
|
|
84
|
+
"fatal startup error, not a no-op.",
|
|
85
|
+
)
|
|
86
|
+
over.add_argument("--label", metavar="LABEL", help="issue label released work carries")
|
|
87
|
+
over.add_argument("--poll-interval", type=int, metavar="SECONDS")
|
|
88
|
+
over.add_argument("--repo-parallelism", type=int, metavar="N", help="repos advanced per pass")
|
|
89
|
+
over.add_argument("--release-blocked-floor-days", type=int, metavar="DAYS")
|
|
90
|
+
over.add_argument("--marker-key", metavar="KEY", help="Autonomous-Dev trailer key")
|
|
91
|
+
over.add_argument("--scope-key", metavar="KEY", help="Autonomous-Scope trailer key")
|
|
92
|
+
over.add_argument("--alissa-endpoint", metavar="URL")
|
|
93
|
+
over.add_argument("--ledger-path", type=Path, metavar="PATH")
|
|
94
|
+
|
|
95
|
+
close = over.add_mutually_exclusive_group()
|
|
96
|
+
close.add_argument(
|
|
97
|
+
"--close-loop",
|
|
98
|
+
dest="close_loop_enabled",
|
|
99
|
+
action="store_true",
|
|
100
|
+
default=None,
|
|
101
|
+
help="drive the origin task forward when a released PR merges (the default)",
|
|
102
|
+
)
|
|
103
|
+
close.add_argument(
|
|
104
|
+
"--no-close-loop",
|
|
105
|
+
dest="close_loop_enabled",
|
|
106
|
+
action="store_false",
|
|
107
|
+
help="release-only: never act on merges, even if the config enables it",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
dry = over.add_mutually_exclusive_group()
|
|
111
|
+
dry.add_argument(
|
|
112
|
+
"--dry-run",
|
|
113
|
+
dest="dry_run",
|
|
114
|
+
action="store_true",
|
|
115
|
+
default=None,
|
|
116
|
+
help="decide and log, but never release an issue or touch task/GitHub state",
|
|
117
|
+
)
|
|
118
|
+
dry.add_argument(
|
|
119
|
+
"--no-dry-run",
|
|
120
|
+
dest="dry_run",
|
|
121
|
+
action="store_false",
|
|
122
|
+
help="act for real even if the config sets dry_run",
|
|
123
|
+
)
|
|
124
|
+
return p
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def overrides_from(args: argparse.Namespace) -> dict:
|
|
128
|
+
"""CLI values, with None meaning 'not specified' so the config file shows
|
|
129
|
+
through. `repos` becomes a tuple so it matches the file form."""
|
|
130
|
+
return {
|
|
131
|
+
"repos": tuple(args.repos) if args.repos else None,
|
|
132
|
+
"label": args.label,
|
|
133
|
+
"poll_interval": args.poll_interval,
|
|
134
|
+
"repo_parallelism": args.repo_parallelism,
|
|
135
|
+
"release_blocked_floor_days": args.release_blocked_floor_days,
|
|
136
|
+
"marker_key": args.marker_key,
|
|
137
|
+
"scope_key": args.scope_key,
|
|
138
|
+
"alissa_endpoint": args.alissa_endpoint,
|
|
139
|
+
"ledger_path": args.ledger_path,
|
|
140
|
+
"close_loop_enabled": args.close_loop_enabled,
|
|
141
|
+
"dry_run": args.dry_run,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def resolve_config(args: argparse.Namespace) -> Config:
|
|
146
|
+
workspace_root = args.workspace_root or Path.cwd()
|
|
147
|
+
path = resolve_config_path(args.config_path, workspace_root)
|
|
148
|
+
|
|
149
|
+
file_data = load_config_file(path) if path else {}
|
|
150
|
+
log.info("config: %s", path or "none found — defaults + CLI + env only")
|
|
151
|
+
|
|
152
|
+
return Config.build(workspace_root, file_data, overrides_from(args))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def log_effective_config(config: Config) -> None:
|
|
156
|
+
"""The resolved settings, defaults and all. INFO carries the decisions an
|
|
157
|
+
operator needs; -v (DEBUG) shows the full surface."""
|
|
158
|
+
log.info("watching %s for label %r", ", ".join(config.repos), config.label)
|
|
159
|
+
log.info(
|
|
160
|
+
"poll every %ss; repo_parallelism=%s; dry_run=%s; close_loop=%s",
|
|
161
|
+
config.poll_interval, config.repo_parallelism, config.dry_run,
|
|
162
|
+
config.close_loop_enabled,
|
|
163
|
+
)
|
|
164
|
+
log.debug("marker_key: %s", config.marker_key)
|
|
165
|
+
log.debug("scope_key: %s", config.scope_key)
|
|
166
|
+
log.debug("release_blocked_floor_days: %s", config.release_blocked_floor_days)
|
|
167
|
+
log.debug("alissa_endpoint: %s", config.alissa_endpoint)
|
|
168
|
+
log.debug("ledger_db: %s", config.ledger_db)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main(argv: "list[str] | None" = None) -> int:
|
|
172
|
+
args = build_parser().parse_args(argv)
|
|
173
|
+
|
|
174
|
+
logging.basicConfig(
|
|
175
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
176
|
+
format="%(asctime)s %(levelname)-7s %(message)s",
|
|
177
|
+
datefmt="%H:%M:%S",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
config = resolve_config(args)
|
|
182
|
+
log.info("workspace: %s", config.workspace_root)
|
|
183
|
+
log_effective_config(config)
|
|
184
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
185
|
+
print(f"config error: {exc}", file=sys.stderr)
|
|
186
|
+
return 2
|
|
187
|
+
except KeyboardInterrupt:
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
orchestrator = Orchestrator(config)
|
|
191
|
+
try:
|
|
192
|
+
if args.once:
|
|
193
|
+
orchestrator.poll_once()
|
|
194
|
+
else:
|
|
195
|
+
log.info("starting orchestrator; polling every %ss", config.poll_interval)
|
|
196
|
+
orchestrator.run_forever()
|
|
197
|
+
except KeyboardInterrupt:
|
|
198
|
+
return 0
|
|
199
|
+
except (AlissaError, CommandError, RateLimited) as exc:
|
|
200
|
+
# A single --once pass surfaces a transport/adapter failure as a non-zero
|
|
201
|
+
# exit for the operator; run_forever swallows and backs off instead of
|
|
202
|
+
# exiting, so this only fires under --once.
|
|
203
|
+
print(f"orchestrator error: {exc}", file=sys.stderr)
|
|
204
|
+
return 1
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
if __name__ == "__main__":
|
|
209
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Alissa task-graph adapter for the orchestrator.
|
|
2
|
+
|
|
3
|
+
TRANSPORT DECISION (the O2 spike, spec v1 §1)
|
|
4
|
+
=============================================
|
|
5
|
+
orcloop talks to Alissa as the operator actor over the **Alissa REST API**
|
|
6
|
+
(`https://api.alissa.app`, versioned under `/v1`), using the Python **stdlib
|
|
7
|
+
only** (`urllib`) and a personal access token from `ALISSA_API_TOKEN`. No third
|
|
8
|
+
-party HTTP library, no subprocess: the transport is in-process `urllib`, hidden
|
|
9
|
+
entirely behind this adapter.
|
|
10
|
+
|
|
11
|
+
Rationale, and why the two options the issue named were rejected:
|
|
12
|
+
|
|
13
|
+
* **REST over stdlib HTTP (CHOSEN).** The token authenticates every `/v1`
|
|
14
|
+
endpoint with a plain `Authorization: Bearer` header -- no browser/OAuth flow
|
|
15
|
+
-- and the REST surface exposes exactly the operations the loop needs
|
|
16
|
+
(filtered task list, task read, evidence, criteria, status, comments). Every
|
|
17
|
+
failure arrives as an HTTP status code, which maps cleanly onto the narrow
|
|
18
|
+
error taxonomy below (auth vs transient vs not-found). Stdlib `urllib` keeps
|
|
19
|
+
the distribution dependency-free, matching the family posture (devloop and
|
|
20
|
+
reviewloop ship no third-party runtime deps either). This is the same
|
|
21
|
+
transport the `alissa-api` skill's bundled `scripts/alissa_api.py` client
|
|
22
|
+
uses, so the shape is proven against the live endpoint.
|
|
23
|
+
|
|
24
|
+
* **JSON-RPC against the Alissa MCP HTTP endpoint (REJECTED -- no reachable
|
|
25
|
+
endpoint).** The spike found no MCP server in the daemon/worker environment
|
|
26
|
+
(`claude mcp list` is empty), so there is no MCP HTTP endpoint to speak
|
|
27
|
+
JSON-RPC to; the token authenticates the REST API, not an MCP transport. The
|
|
28
|
+
MCP surface is an editor/agent integration, not an operator daemon one --
|
|
29
|
+
building a JSON-RPC client against an endpoint that does not exist here was a
|
|
30
|
+
dead end. REST reaches the identical task-graph operations directly.
|
|
31
|
+
|
|
32
|
+
* **Shelling to the `alissa` CLI (REJECTED -- taxonomy + capability).** The CLI
|
|
33
|
+
can read/write tasks, but (a) its `task list` takes no `status`/marker filter
|
|
34
|
+
(per the `alissa-api` skill, filtered listing and ranking are REST/MCP only),
|
|
35
|
+
so the loop's `list_marked_committed_tasks` would have to over-fetch and
|
|
36
|
+
filter client-side, and (b) subprocess failures surface as an exit code plus
|
|
37
|
+
free-text stderr, which cannot be classified into auth/transient/not-found
|
|
38
|
+
as reliably as an HTTP status. REST gives both the server-side `status=`
|
|
39
|
+
filter and clean status codes.
|
|
40
|
+
|
|
41
|
+
Live smoke run confirming the choice is documented in the PR body.
|
|
42
|
+
|
|
43
|
+
ERROR TAXONOMY
|
|
44
|
+
==============
|
|
45
|
+
Three narrow, actionable buckets so the loop can react without string-matching:
|
|
46
|
+
|
|
47
|
+
* ``AlissaAuthError`` -- 401/403 (missing/invalid token, or no role on the
|
|
48
|
+
task, e.g. the ``NOT_OWNER`` code). Permanent, operator-fixable; do not retry.
|
|
49
|
+
* ``AlissaNotFound`` -- 404 (task/evidence/criterion absent or not visible to
|
|
50
|
+
the bound actor). Permanent for this ref.
|
|
51
|
+
* ``AlissaTransient`` -- 408/429/5xx and every transport failure (DNS, refused
|
|
52
|
+
connection, timeout). Retry-later signals.
|
|
53
|
+
|
|
54
|
+
Anything else (a 400 validation error, say) surfaces as the base
|
|
55
|
+
``AlissaError`` -- a bug to fix, not a condition to retry blindly.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import json
|
|
61
|
+
import logging
|
|
62
|
+
import os
|
|
63
|
+
import urllib.error
|
|
64
|
+
import urllib.parse
|
|
65
|
+
import urllib.request
|
|
66
|
+
from dataclasses import dataclass
|
|
67
|
+
|
|
68
|
+
log = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
DEFAULT_BASE = "https://api.alissa.app"
|
|
71
|
+
|
|
72
|
+
# The readiness marker: an operator "releases" a committed task to the develop
|
|
73
|
+
# pipeline by placing this token in the task's description. It is deliberately
|
|
74
|
+
# the SAME string as the GitHub label orcloop stamps on the issue it creates --
|
|
75
|
+
# one token means both "ready to release" (on the Alissa task) and "for the
|
|
76
|
+
# develop daemon" (on the GitHub issue). The value is overridable per call so
|
|
77
|
+
# the O1 config surface / O3 loop can supply a configured marker; this constant
|
|
78
|
+
# is the documented default. See the repo README's pipeline description.
|
|
79
|
+
RELEASE_MARKER = "alissa:develop"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AlissaError(RuntimeError):
|
|
83
|
+
"""Base class. `status` is the HTTP code (0 for transport failures); `code`
|
|
84
|
+
is the API's machine error string (`{"error": ...}`) when one was sent."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, status: int, payload: object, code: str | None = None):
|
|
87
|
+
self.status = status
|
|
88
|
+
self.payload = payload
|
|
89
|
+
self.code = code
|
|
90
|
+
super().__init__(f"HTTP {status}{f' {code}' if code else ''}: {payload}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AlissaAuthError(AlissaError):
|
|
94
|
+
"""401/403: no valid token, or the bound actor has no role on the task.
|
|
95
|
+
Permanent and operator-fixable -- never retried."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AlissaNotFound(AlissaError):
|
|
99
|
+
"""404: the task/evidence/criterion is absent or invisible to this actor."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class AlissaTransient(AlissaError):
|
|
103
|
+
"""408/429/5xx or a transport failure -- a retry-later signal."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class MarkedTask:
|
|
108
|
+
"""A release candidate as the LIST endpoint reports it. `description` is the
|
|
109
|
+
list payload's `descriptionPreview` -- possibly truncated, enough to filter
|
|
110
|
+
on; the loop calls `get_task` for the full body before writing the issue."""
|
|
111
|
+
|
|
112
|
+
ref: str # TASK-<taskNumber>
|
|
113
|
+
task_number: int
|
|
114
|
+
title: str
|
|
115
|
+
status: str
|
|
116
|
+
description: str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Criterion:
|
|
121
|
+
id: str
|
|
122
|
+
description: str
|
|
123
|
+
satisfied: bool
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class Dependency:
|
|
128
|
+
"""One upstream (depends-on) task, as the DETAIL endpoint reports it under
|
|
129
|
+
`upstreamTasks`. `ref` is `TASK-<n>` when the upstream row carries a task
|
|
130
|
+
number, else its opaque id (the upstream preview omits taskNumber for some
|
|
131
|
+
actors); `status` is what the O3 dependsOn gate reads."""
|
|
132
|
+
|
|
133
|
+
ref: str
|
|
134
|
+
status: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class Task:
|
|
139
|
+
"""A task as the DETAIL endpoint reports it -- full description and DoD,
|
|
140
|
+
plus the validation criteria the loop satisfies on close.
|
|
141
|
+
|
|
142
|
+
O3 added the release-queue fields: `priority` and `created_at` drive the
|
|
143
|
+
priority-then-age ordering, and `dependencies` (with `depends_on_count`)
|
|
144
|
+
drives the dependsOn gate. They default to the "unset" shape so a detail
|
|
145
|
+
payload that omits them -- and any caller constructing a Task by hand --
|
|
146
|
+
reads as no-priority / age-zero / no-deps rather than raising."""
|
|
147
|
+
|
|
148
|
+
ref: str # TASK-<taskNumber>
|
|
149
|
+
task_number: int
|
|
150
|
+
title: str
|
|
151
|
+
status: str
|
|
152
|
+
description: str
|
|
153
|
+
definition_of_done: str
|
|
154
|
+
criteria: tuple[Criterion, ...]
|
|
155
|
+
priority: str = ""
|
|
156
|
+
created_at: int = 0 # ms since epoch; 0 when the payload omits createdAt
|
|
157
|
+
# The resolved upstream tasks (statuses the dependsOn gate reads).
|
|
158
|
+
dependencies: tuple[Dependency, ...] = ()
|
|
159
|
+
# len(dependsOn): may EXCEED len(dependencies) when an upstream is invisible
|
|
160
|
+
# to this actor -- the gate treats that mismatch as "not provably clear".
|
|
161
|
+
depends_on_count: int = 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _query(params: dict) -> str:
|
|
165
|
+
"""`?a=b&…` from params: skip None, comma-join lists (any-of), bools as
|
|
166
|
+
true/false. Empty → ''. Mirrors the bundled alissa_api.py client."""
|
|
167
|
+
pairs: list[tuple[str, str]] = []
|
|
168
|
+
for key, val in params.items():
|
|
169
|
+
if val is None:
|
|
170
|
+
continue
|
|
171
|
+
if isinstance(val, bool):
|
|
172
|
+
pairs.append((key, "true" if val else "false"))
|
|
173
|
+
elif isinstance(val, (list, tuple)):
|
|
174
|
+
if val:
|
|
175
|
+
pairs.append((key, ",".join(str(v) for v in val)))
|
|
176
|
+
else:
|
|
177
|
+
pairs.append((key, str(val)))
|
|
178
|
+
return ("?" + urllib.parse.urlencode(pairs)) if pairs else ""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class AlissaClient:
|
|
182
|
+
"""Thin REST adapter over the task-graph operations orcloop needs.
|
|
183
|
+
|
|
184
|
+
Transport is hidden: callers see `MarkedTask`/`Task` and the taxonomy
|
|
185
|
+
exceptions, never HTTP. Reads `ALISSA_API_TOKEN` and `ALISSA_BASE` from the
|
|
186
|
+
environment (both overridable in the constructor for tests)."""
|
|
187
|
+
|
|
188
|
+
def __init__(self, token: str | None = None, base: str | None = None, *, timeout: int = 30):
|
|
189
|
+
self.base = (base or os.environ.get("ALISSA_BASE") or DEFAULT_BASE).rstrip("/")
|
|
190
|
+
self._token = token if token is not None else os.environ.get("ALISSA_API_TOKEN")
|
|
191
|
+
self._timeout = timeout
|
|
192
|
+
|
|
193
|
+
# --- transport ---------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def _request(self, method: str, path: str, body: dict | None = None) -> object:
|
|
196
|
+
"""One REST call. Translates every failure into the narrow taxonomy;
|
|
197
|
+
the caller never sees a raw urllib error or HTTP status."""
|
|
198
|
+
if not self._token:
|
|
199
|
+
# No token at all is an auth condition, not a transport one: the
|
|
200
|
+
# operator must set ALISSA_API_TOKEN. Fail the same way a 401 would.
|
|
201
|
+
raise AlissaAuthError(0, "ALISSA_API_TOKEN is not set")
|
|
202
|
+
|
|
203
|
+
url = f"{self.base}{path}"
|
|
204
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
205
|
+
headers = {"Accept": "application/json", "Authorization": f"Bearer {self._token}"}
|
|
206
|
+
if data is not None:
|
|
207
|
+
headers["Content-Type"] = "application/json"
|
|
208
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
209
|
+
try:
|
|
210
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp: # noqa: S310
|
|
211
|
+
raw = resp.read().decode("utf-8")
|
|
212
|
+
except urllib.error.HTTPError as exc:
|
|
213
|
+
raise self._classify(exc) from None
|
|
214
|
+
except urllib.error.URLError as exc:
|
|
215
|
+
# DNS failure, refused connection, timeout: all retry-later.
|
|
216
|
+
raise AlissaTransient(0, str(exc.reason)) from None
|
|
217
|
+
return json.loads(raw) if raw.strip() else {}
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _classify(exc: "urllib.error.HTTPError") -> AlissaError:
|
|
221
|
+
"""Map an HTTP error onto the taxonomy. The API sends JSON error bodies
|
|
222
|
+
(`{"error": CODE, "message": ...}`); the code rides along on the
|
|
223
|
+
exception when present, but classification keys on the STATUS -- codes
|
|
224
|
+
are advisory, statuses are the contract."""
|
|
225
|
+
detail: object = exc.read().decode("utf-8", "replace")
|
|
226
|
+
code: str | None = None
|
|
227
|
+
try:
|
|
228
|
+
parsed = json.loads(detail) # type: ignore[arg-type]
|
|
229
|
+
if isinstance(parsed, dict):
|
|
230
|
+
detail = parsed
|
|
231
|
+
raw_code = parsed.get("error")
|
|
232
|
+
code = raw_code if isinstance(raw_code, str) else None
|
|
233
|
+
except (json.JSONDecodeError, TypeError):
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
status = exc.code
|
|
237
|
+
if status in (401, 403):
|
|
238
|
+
return AlissaAuthError(status, detail, code)
|
|
239
|
+
if status == 404:
|
|
240
|
+
return AlissaNotFound(status, detail, code)
|
|
241
|
+
if status == 408 or status == 429 or 500 <= status <= 599:
|
|
242
|
+
return AlissaTransient(status, detail, code)
|
|
243
|
+
return AlissaError(status, detail, code)
|
|
244
|
+
|
|
245
|
+
# --- adapter surface ---------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def list_marked_committed_tasks(self, marker: str = RELEASE_MARKER) -> list[MarkedTask]:
|
|
248
|
+
"""Committed tasks carrying the release `marker`, with descriptions.
|
|
249
|
+
|
|
250
|
+
The status filter runs server-side (`GET /v1/tasks?status=committed`);
|
|
251
|
+
the marker filter runs here against the fields the LIST endpoint returns
|
|
252
|
+
-- `title`, `descriptionPreview`, `dodPreview`. A marker buried past the
|
|
253
|
+
preview truncation window will be missed, which is why the operator
|
|
254
|
+
convention is to place it in the task description where it leads the
|
|
255
|
+
preview; the loop re-reads the full body via `get_task` before writing
|
|
256
|
+
the issue anyway.
|
|
257
|
+
|
|
258
|
+
An empty/whitespace marker returns [] rather than matching every task:
|
|
259
|
+
a blank marker must mean "release nothing", never "release everything"
|
|
260
|
+
-- the same fail-closed spirit the GitHub search carries.
|
|
261
|
+
"""
|
|
262
|
+
marker = marker.strip()
|
|
263
|
+
if not marker:
|
|
264
|
+
log.warning(
|
|
265
|
+
"list_marked_committed_tasks: empty release marker -- nothing is "
|
|
266
|
+
"released. A blank marker would match every committed task, so "
|
|
267
|
+
"this fails closed; configure a marker to release something."
|
|
268
|
+
)
|
|
269
|
+
return []
|
|
270
|
+
|
|
271
|
+
payload = self._request("GET", "/v1/tasks" + _query({"status": "committed"}))
|
|
272
|
+
tasks = payload.get("tasks") if isinstance(payload, dict) else None
|
|
273
|
+
if not isinstance(tasks, list):
|
|
274
|
+
return []
|
|
275
|
+
|
|
276
|
+
out: list[MarkedTask] = []
|
|
277
|
+
for row in tasks:
|
|
278
|
+
if not isinstance(row, dict):
|
|
279
|
+
continue
|
|
280
|
+
number = row.get("taskNumber")
|
|
281
|
+
if not isinstance(number, int):
|
|
282
|
+
continue
|
|
283
|
+
title = row.get("title") or ""
|
|
284
|
+
description = row.get("descriptionPreview") or ""
|
|
285
|
+
dod = row.get("dodPreview") or ""
|
|
286
|
+
if marker not in f"{title}\n{description}\n{dod}":
|
|
287
|
+
continue
|
|
288
|
+
out.append(
|
|
289
|
+
MarkedTask(
|
|
290
|
+
ref=f"TASK-{number}",
|
|
291
|
+
task_number=number,
|
|
292
|
+
title=title,
|
|
293
|
+
status=row.get("status") or "",
|
|
294
|
+
description=description,
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
return out
|
|
298
|
+
|
|
299
|
+
def get_task(self, ref: str) -> Task:
|
|
300
|
+
"""Full detail for one task. `ref` is any reference form the API accepts
|
|
301
|
+
(`TASK-<n>`, public id, url). Raises `AlissaNotFound` when the bound
|
|
302
|
+
actor has no role on it."""
|
|
303
|
+
payload = self._request("GET", f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}")
|
|
304
|
+
task = payload.get("task") if isinstance(payload, dict) else None
|
|
305
|
+
if not isinstance(task, dict):
|
|
306
|
+
# A 2xx with no task object is a contract violation, not a 404 --
|
|
307
|
+
# surface it as the base error rather than fabricating a Task.
|
|
308
|
+
raise AlissaError(200, f"no task object in response for {ref!r}")
|
|
309
|
+
|
|
310
|
+
criteria: list[Criterion] = []
|
|
311
|
+
for crit in task.get("validationCriteria") or []:
|
|
312
|
+
if not isinstance(crit, dict):
|
|
313
|
+
continue
|
|
314
|
+
criteria.append(
|
|
315
|
+
Criterion(
|
|
316
|
+
id=str(crit.get("id") or ""),
|
|
317
|
+
description=crit.get("description") or "",
|
|
318
|
+
satisfied=bool(crit.get("satisfied")),
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# The dependsOn gate reads upstream statuses. `dependsOn` is the id
|
|
323
|
+
# list; `upstreamTasks` is the resolved view (id/title/status). We key
|
|
324
|
+
# the gate on the resolved statuses but remember the raw dependsOn
|
|
325
|
+
# count so an upstream this actor cannot see (present in dependsOn,
|
|
326
|
+
# absent from upstreamTasks) reads as "not provably clear".
|
|
327
|
+
dependencies: list[Dependency] = []
|
|
328
|
+
for up in task.get("upstreamTasks") or []:
|
|
329
|
+
if not isinstance(up, dict):
|
|
330
|
+
continue
|
|
331
|
+
up_number = up.get("taskNumber")
|
|
332
|
+
up_ref = (
|
|
333
|
+
f"TASK-{up_number}"
|
|
334
|
+
if isinstance(up_number, int)
|
|
335
|
+
else str(up.get("_id") or "")
|
|
336
|
+
)
|
|
337
|
+
dependencies.append(Dependency(ref=up_ref, status=up.get("status") or ""))
|
|
338
|
+
depends_on = task.get("dependsOn")
|
|
339
|
+
depends_on_count = len(depends_on) if isinstance(depends_on, list) else 0
|
|
340
|
+
|
|
341
|
+
created_raw = task.get("createdAt")
|
|
342
|
+
created_at = int(created_raw) if isinstance(created_raw, (int, float)) else 0
|
|
343
|
+
|
|
344
|
+
number = task.get("taskNumber")
|
|
345
|
+
return Task(
|
|
346
|
+
ref=f"TASK-{number}" if isinstance(number, int) else str(ref),
|
|
347
|
+
task_number=number if isinstance(number, int) else 0,
|
|
348
|
+
title=task.get("title") or "",
|
|
349
|
+
status=task.get("status") or "",
|
|
350
|
+
description=task.get("description") or "",
|
|
351
|
+
definition_of_done=task.get("definitionOfDone") or "",
|
|
352
|
+
criteria=tuple(criteria),
|
|
353
|
+
priority=task.get("priority") or "",
|
|
354
|
+
created_at=created_at,
|
|
355
|
+
dependencies=tuple(dependencies),
|
|
356
|
+
depends_on_count=depends_on_count,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def add_evidence(self, ref: str, title: str, markdown: str, *, item_type: str = "evidence") -> None:
|
|
360
|
+
"""Attach an evidence/deliverable item (outputs surface, not context).
|
|
361
|
+
orcloop uses this to record loop-closure evidence on the origin task."""
|
|
362
|
+
self._request(
|
|
363
|
+
"POST",
|
|
364
|
+
f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}/evidence",
|
|
365
|
+
{"title": title, "markdownContent": markdown, "itemType": item_type},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def satisfy_criterion(self, ref: str, criterion_id: str) -> None:
|
|
369
|
+
"""Mark one validation criterion satisfied. No partial-credit shortcut:
|
|
370
|
+
every criterion is satisfied individually before a task can validate."""
|
|
371
|
+
self._request(
|
|
372
|
+
"POST",
|
|
373
|
+
f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}"
|
|
374
|
+
f"/criteria/{urllib.parse.quote(str(criterion_id), safe='')}/satisfy",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def set_status(self, ref: str, status: str) -> None:
|
|
378
|
+
"""Move a task along the transition matrix (draft → committed →
|
|
379
|
+
in_progress → pending_validation → validated, with blocked/cancelled
|
|
380
|
+
side moves). Illegal transitions come back as the base `AlissaError`."""
|
|
381
|
+
self._request(
|
|
382
|
+
"POST",
|
|
383
|
+
f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}/status",
|
|
384
|
+
{"status": status},
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def add_comment(self, ref: str, body: str) -> None:
|
|
388
|
+
"""Post a comment. The endpoint field is `content`, not `body`."""
|
|
389
|
+
self._request(
|
|
390
|
+
"POST",
|
|
391
|
+
f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}/comments",
|
|
392
|
+
{"content": body},
|
|
393
|
+
)
|