sdxloop 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.
- sdxloop/__init__.py +26 -0
- sdxloop/_vendor/sdxloop_worker-0.1.0-py3-none-any.whl +0 -0
- sdxloop/cli/__init__.py +1 -0
- sdxloop/cli/app.py +359 -0
- sdxloop/cli/doctor.py +145 -0
- sdxloop/cli/tui.py +129 -0
- sdxloop/config.py +180 -0
- sdxloop/engine/__init__.py +26 -0
- sdxloop/engine/engine.py +380 -0
- sdxloop/engine/model.py +151 -0
- sdxloop/engine/phases.py +223 -0
- sdxloop/engine/prompts/__init__.py +31 -0
- sdxloop/engine/prompts/decompose.md +41 -0
- sdxloop/engine/prompts/execute.md +31 -0
- sdxloop/engine/prompts/plan.md +35 -0
- sdxloop/engine/prompts/scrutinize.md +43 -0
- sdxloop/engine/prompts/validate.md +38 -0
- sdxloop/engine/store.py +257 -0
- sdxloop/errors.py +73 -0
- sdxloop/events.py +87 -0
- sdxloop/gh/__init__.py +6 -0
- sdxloop/gh/ops.py +140 -0
- sdxloop/gh/reporter.py +75 -0
- sdxloop/ids.py +35 -0
- sdxloop/py.typed +0 -0
- sdxloop/sbx/__init__.py +6 -0
- sdxloop/sbx/cli.py +210 -0
- sdxloop/sbx/models.py +83 -0
- sdxloop/sbx/pair.py +125 -0
- sdxloop/sbx/parse.py +67 -0
- sdxloop/sbx/provision.py +209 -0
- sdxloop/sbx/sandbox.py +70 -0
- sdxloop/worker/__init__.py +6 -0
- sdxloop/worker/client.py +260 -0
- sdxloop/worker/wheel.py +73 -0
- sdxloop-0.1.0.dist-info/METADATA +32 -0
- sdxloop-0.1.0.dist-info/RECORD +39 -0
- sdxloop-0.1.0.dist-info/WHEEL +4 -0
- sdxloop-0.1.0.dist-info/entry_points.txt +2 -0
sdxloop/engine/store.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""SQLite state store: runs, tasks, phase attempts, events.
|
|
2
|
+
|
|
3
|
+
One WAL-mode database at ``<state_dir>/state.db``. A checkpoint row is
|
|
4
|
+
written after every state transition, which is what makes ``resume`` safe:
|
|
5
|
+
a phase whose result was never committed is simply re-run from its start.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from sdxloop.engine.model import (
|
|
17
|
+
PlanModel,
|
|
18
|
+
RunRecord,
|
|
19
|
+
RunState,
|
|
20
|
+
TaskRecord,
|
|
21
|
+
TaskSpec,
|
|
22
|
+
TaskState,
|
|
23
|
+
)
|
|
24
|
+
from sdxloop.errors import StateError
|
|
25
|
+
from sdxloop_worker.protocol import Event
|
|
26
|
+
|
|
27
|
+
_SCHEMA = """
|
|
28
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
29
|
+
run_id TEXT PRIMARY KEY,
|
|
30
|
+
outcome TEXT NOT NULL,
|
|
31
|
+
state TEXT NOT NULL,
|
|
32
|
+
config_json TEXT NOT NULL DEFAULT '{}',
|
|
33
|
+
created_at REAL NOT NULL,
|
|
34
|
+
updated_at REAL NOT NULL
|
|
35
|
+
);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
37
|
+
run_id TEXT NOT NULL,
|
|
38
|
+
task_id TEXT NOT NULL,
|
|
39
|
+
order_idx INTEGER NOT NULL,
|
|
40
|
+
state TEXT NOT NULL,
|
|
41
|
+
spec_json TEXT NOT NULL,
|
|
42
|
+
plan_json TEXT,
|
|
43
|
+
revisions INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
replans INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
last_feedback TEXT NOT NULL DEFAULT '',
|
|
46
|
+
session_id TEXT,
|
|
47
|
+
PRIMARY KEY (run_id, task_id)
|
|
48
|
+
);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS phase_attempts (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
run_id TEXT NOT NULL,
|
|
52
|
+
task_id TEXT,
|
|
53
|
+
phase TEXT NOT NULL,
|
|
54
|
+
attempt INTEGER NOT NULL,
|
|
55
|
+
status TEXT NOT NULL,
|
|
56
|
+
output_json TEXT,
|
|
57
|
+
started_at REAL NOT NULL,
|
|
58
|
+
ended_at REAL NOT NULL
|
|
59
|
+
);
|
|
60
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
61
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
62
|
+
run_id TEXT NOT NULL,
|
|
63
|
+
ts REAL NOT NULL,
|
|
64
|
+
type TEXT NOT NULL,
|
|
65
|
+
job_id TEXT,
|
|
66
|
+
data_json TEXT NOT NULL DEFAULT '{}'
|
|
67
|
+
);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_events_run ON events (run_id, seq);
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class StateStore:
|
|
73
|
+
def __init__(self, path: Path) -> None:
|
|
74
|
+
self.path = path
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
self._conn = sqlite3.connect(path, check_same_thread=False)
|
|
77
|
+
self._conn.row_factory = sqlite3.Row
|
|
78
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
79
|
+
self._conn.executescript(_SCHEMA)
|
|
80
|
+
self._conn.commit()
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
self._conn.close()
|
|
84
|
+
|
|
85
|
+
# -- runs --------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def create_run(self, run_id: str, outcome: str, config_json: str = "{}") -> RunRecord:
|
|
88
|
+
now = time.time()
|
|
89
|
+
try:
|
|
90
|
+
self._conn.execute(
|
|
91
|
+
"INSERT INTO runs (run_id, outcome, state, config_json, created_at, updated_at)"
|
|
92
|
+
" VALUES (?, ?, 'created', ?, ?, ?)",
|
|
93
|
+
(run_id, outcome, config_json, now, now),
|
|
94
|
+
)
|
|
95
|
+
except sqlite3.IntegrityError as exc:
|
|
96
|
+
raise StateError(f"run {run_id} already exists") from exc
|
|
97
|
+
self._conn.commit()
|
|
98
|
+
return RunRecord(
|
|
99
|
+
run_id=run_id, outcome=outcome, state="created", created_at=now, updated_at=now
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def set_run_state(self, run_id: str, state: RunState) -> None:
|
|
103
|
+
cursor = self._conn.execute(
|
|
104
|
+
"UPDATE runs SET state = ?, updated_at = ? WHERE run_id = ?",
|
|
105
|
+
(state, time.time(), run_id),
|
|
106
|
+
)
|
|
107
|
+
if cursor.rowcount == 0:
|
|
108
|
+
raise StateError(f"unknown run {run_id}")
|
|
109
|
+
self._conn.commit()
|
|
110
|
+
|
|
111
|
+
def get_run(self, run_id: str) -> RunRecord:
|
|
112
|
+
row = self._conn.execute("SELECT * FROM runs WHERE run_id = ?", (run_id,)).fetchone()
|
|
113
|
+
if row is None:
|
|
114
|
+
raise StateError(f"unknown run {run_id}")
|
|
115
|
+
return RunRecord(
|
|
116
|
+
run_id=row["run_id"],
|
|
117
|
+
outcome=row["outcome"],
|
|
118
|
+
state=row["state"],
|
|
119
|
+
created_at=row["created_at"],
|
|
120
|
+
updated_at=row["updated_at"],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def list_runs(self) -> list[RunRecord]:
|
|
124
|
+
rows = self._conn.execute("SELECT * FROM runs ORDER BY created_at DESC").fetchall()
|
|
125
|
+
return [
|
|
126
|
+
RunRecord(
|
|
127
|
+
run_id=row["run_id"],
|
|
128
|
+
outcome=row["outcome"],
|
|
129
|
+
state=row["state"],
|
|
130
|
+
created_at=row["created_at"],
|
|
131
|
+
updated_at=row["updated_at"],
|
|
132
|
+
)
|
|
133
|
+
for row in rows
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
# -- tasks -------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def save_tasks(self, run_id: str, specs: list[TaskSpec]) -> None:
|
|
139
|
+
for index, spec in enumerate(specs):
|
|
140
|
+
self._conn.execute(
|
|
141
|
+
"INSERT OR REPLACE INTO tasks (run_id, task_id, order_idx, state, spec_json)"
|
|
142
|
+
" VALUES (?, ?, ?, 'pending', ?)",
|
|
143
|
+
(run_id, spec.id, index, spec.model_dump_json()),
|
|
144
|
+
)
|
|
145
|
+
self._conn.commit()
|
|
146
|
+
|
|
147
|
+
def update_task(self, run_id: str, task: TaskRecord) -> None:
|
|
148
|
+
cursor = self._conn.execute(
|
|
149
|
+
"UPDATE tasks SET state = ?, plan_json = ?, revisions = ?, replans = ?,"
|
|
150
|
+
" last_feedback = ?, session_id = ? WHERE run_id = ? AND task_id = ?",
|
|
151
|
+
(
|
|
152
|
+
task.state,
|
|
153
|
+
task.plan.model_dump_json() if task.plan else None,
|
|
154
|
+
task.revisions,
|
|
155
|
+
task.replans,
|
|
156
|
+
task.last_feedback,
|
|
157
|
+
task.session_id,
|
|
158
|
+
run_id,
|
|
159
|
+
task.spec.id,
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
if cursor.rowcount == 0:
|
|
163
|
+
raise StateError(f"unknown task {task.spec.id} in run {run_id}")
|
|
164
|
+
self._conn.commit()
|
|
165
|
+
|
|
166
|
+
def get_tasks(self, run_id: str) -> list[TaskRecord]:
|
|
167
|
+
rows = self._conn.execute(
|
|
168
|
+
"SELECT * FROM tasks WHERE run_id = ? ORDER BY order_idx", (run_id,)
|
|
169
|
+
).fetchall()
|
|
170
|
+
records: list[TaskRecord] = []
|
|
171
|
+
for row in rows:
|
|
172
|
+
state: TaskState = row["state"]
|
|
173
|
+
records.append(
|
|
174
|
+
TaskRecord(
|
|
175
|
+
spec=TaskSpec.model_validate_json(row["spec_json"]),
|
|
176
|
+
state=state,
|
|
177
|
+
revisions=row["revisions"],
|
|
178
|
+
replans=row["replans"],
|
|
179
|
+
last_feedback=row["last_feedback"],
|
|
180
|
+
session_id=row["session_id"],
|
|
181
|
+
plan=(
|
|
182
|
+
PlanModel.model_validate_json(row["plan_json"])
|
|
183
|
+
if row["plan_json"]
|
|
184
|
+
else None
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
return records
|
|
189
|
+
|
|
190
|
+
# -- phase attempts ----------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def record_phase(
|
|
193
|
+
self,
|
|
194
|
+
run_id: str,
|
|
195
|
+
phase: str,
|
|
196
|
+
*,
|
|
197
|
+
task_id: str | None,
|
|
198
|
+
attempt: int,
|
|
199
|
+
status: str,
|
|
200
|
+
output_json: str | None,
|
|
201
|
+
started_at: float,
|
|
202
|
+
) -> None:
|
|
203
|
+
self._conn.execute(
|
|
204
|
+
"INSERT INTO phase_attempts"
|
|
205
|
+
" (run_id, task_id, phase, attempt, status, output_json, started_at, ended_at)"
|
|
206
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
207
|
+
(run_id, task_id, phase, attempt, status, output_json, started_at, time.time()),
|
|
208
|
+
)
|
|
209
|
+
self._conn.commit()
|
|
210
|
+
|
|
211
|
+
def phase_attempts(self, run_id: str, task_id: str | None = None) -> list[sqlite3.Row]:
|
|
212
|
+
if task_id is None:
|
|
213
|
+
return list(
|
|
214
|
+
self._conn.execute(
|
|
215
|
+
"SELECT * FROM phase_attempts WHERE run_id = ? ORDER BY id", (run_id,)
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
return list(
|
|
219
|
+
self._conn.execute(
|
|
220
|
+
"SELECT * FROM phase_attempts WHERE run_id = ? AND task_id = ? ORDER BY id",
|
|
221
|
+
(run_id, task_id),
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# -- events ------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def append_event(self, event: Event) -> None:
|
|
228
|
+
self._conn.execute(
|
|
229
|
+
"INSERT INTO events (run_id, ts, type, job_id, data_json) VALUES (?, ?, ?, ?, ?)",
|
|
230
|
+
(event.run_id, event.ts, event.type, event.job_id, json.dumps(event.data)),
|
|
231
|
+
)
|
|
232
|
+
self._conn.commit()
|
|
233
|
+
|
|
234
|
+
def events(
|
|
235
|
+
self,
|
|
236
|
+
run_id: str,
|
|
237
|
+
*,
|
|
238
|
+
after_seq: int = 0,
|
|
239
|
+
type_prefix: str | None = None,
|
|
240
|
+
) -> Iterator[tuple[int, Event]]:
|
|
241
|
+
query = "SELECT * FROM events WHERE run_id = ? AND seq > ?"
|
|
242
|
+
params: list[object] = [run_id, after_seq]
|
|
243
|
+
if type_prefix:
|
|
244
|
+
query += " AND type LIKE ?"
|
|
245
|
+
params.append(type_prefix + "%")
|
|
246
|
+
query += " ORDER BY seq"
|
|
247
|
+
for row in self._conn.execute(query, params):
|
|
248
|
+
yield (
|
|
249
|
+
row["seq"],
|
|
250
|
+
Event(
|
|
251
|
+
ts=row["ts"],
|
|
252
|
+
run_id=row["run_id"],
|
|
253
|
+
job_id=row["job_id"],
|
|
254
|
+
type=row["type"],
|
|
255
|
+
data=json.loads(row["data_json"]),
|
|
256
|
+
),
|
|
257
|
+
)
|
sdxloop/errors.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""sdxloop exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SdxloopError(Exception):
|
|
9
|
+
"""Base class for all sdxloop errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigError(SdxloopError):
|
|
13
|
+
"""Invalid or unloadable configuration."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SbxError(SdxloopError):
|
|
17
|
+
"""An sbx CLI invocation failed."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
argv: Sequence[str] = (),
|
|
24
|
+
returncode: int | None = None,
|
|
25
|
+
stderr: str = "",
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.argv = list(argv)
|
|
29
|
+
self.returncode = returncode
|
|
30
|
+
self.stderr = stderr
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
base = super().__str__()
|
|
34
|
+
parts = [base]
|
|
35
|
+
if self.argv:
|
|
36
|
+
parts.append(f"argv={' '.join(self.argv)}")
|
|
37
|
+
if self.returncode is not None:
|
|
38
|
+
parts.append(f"rc={self.returncode}")
|
|
39
|
+
if self.stderr.strip():
|
|
40
|
+
parts.append(f"stderr={self.stderr.strip()}")
|
|
41
|
+
return " | ".join(parts)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SbxNotFoundError(SbxError):
|
|
45
|
+
"""The sbx binary is missing, or a referenced sandbox does not exist."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProvisionError(SdxloopError):
|
|
49
|
+
"""Sandbox pair provisioning failed."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WorkerError(SdxloopError):
|
|
53
|
+
"""The in-sandbox worker failed or returned an invalid result."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WorkerTimeoutError(WorkerError):
|
|
57
|
+
"""The worker exceeded its job timeout and was killed."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ProtocolError(SdxloopError):
|
|
61
|
+
"""Host/worker protocol violation (bad event line, missing result, ...)."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GithubOpsError(SdxloopError):
|
|
65
|
+
"""A GitHub operation failed in the github-ops sandbox."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BudgetExceededError(SdxloopError):
|
|
69
|
+
"""A run or task exhausted one of its budgets."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class StateError(SdxloopError):
|
|
73
|
+
"""Invalid state transition or corrupted persisted state."""
|
sdxloop/events.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Host-side event plumbing: a synchronous pub/sub bus over protocol Events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
from sdxloop_worker.protocol import Event
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
Subscriber = Callable[[Event], None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@runtime_checkable
|
|
18
|
+
class Hook(Protocol):
|
|
19
|
+
"""Extension point: receives every event published on the bus.
|
|
20
|
+
|
|
21
|
+
Implementations must be fast and must not raise; exceptions are caught
|
|
22
|
+
and logged so one misbehaving hook cannot break a run.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def on_event(self, event: Event) -> None: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EventBus:
|
|
29
|
+
"""Synchronous fan-out of protocol events to subscribers.
|
|
30
|
+
|
|
31
|
+
Subscriber exceptions are isolated: they are logged and swallowed so
|
|
32
|
+
telemetry consumers can never fail a run.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self._subscribers: list[Subscriber] = []
|
|
37
|
+
|
|
38
|
+
def subscribe(self, fn: Subscriber) -> Callable[[], None]:
|
|
39
|
+
"""Register a subscriber; returns an unsubscribe callable."""
|
|
40
|
+
self._subscribers.append(fn)
|
|
41
|
+
|
|
42
|
+
def unsubscribe() -> None:
|
|
43
|
+
with contextlib.suppress(ValueError):
|
|
44
|
+
self._subscribers.remove(fn)
|
|
45
|
+
|
|
46
|
+
return unsubscribe
|
|
47
|
+
|
|
48
|
+
def attach_hook(self, hook: Hook) -> Callable[[], None]:
|
|
49
|
+
return self.subscribe(hook.on_event)
|
|
50
|
+
|
|
51
|
+
def publish(self, event: Event) -> None:
|
|
52
|
+
for fn in list(self._subscribers):
|
|
53
|
+
try:
|
|
54
|
+
fn(event)
|
|
55
|
+
except Exception:
|
|
56
|
+
logger.exception("event subscriber %r failed for %s", fn, event.type)
|
|
57
|
+
|
|
58
|
+
def emit(
|
|
59
|
+
self,
|
|
60
|
+
type: str,
|
|
61
|
+
run_id: str,
|
|
62
|
+
job_id: str | None = None,
|
|
63
|
+
**data: Any,
|
|
64
|
+
) -> Event:
|
|
65
|
+
"""Construct an Event stamped now, publish it, and return it."""
|
|
66
|
+
event = Event.now(type, run_id, job_id=job_id, **data)
|
|
67
|
+
self.publish(event)
|
|
68
|
+
return event
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class HostEventTypes:
|
|
72
|
+
"""Host-emitted event types (the worker's live in protocol.EventTypes)."""
|
|
73
|
+
|
|
74
|
+
RUN_START = "run.start"
|
|
75
|
+
RUN_STATE = "run.state"
|
|
76
|
+
RUN_END = "run.end"
|
|
77
|
+
TASK_START = "task.start"
|
|
78
|
+
TASK_STATE = "task.state"
|
|
79
|
+
TASK_END = "task.end"
|
|
80
|
+
PHASE_START = "phase.start"
|
|
81
|
+
PHASE_END = "phase.end"
|
|
82
|
+
SANDBOX_PROVISION_START = "sandbox.provision_start"
|
|
83
|
+
SANDBOX_READY = "sandbox.ready"
|
|
84
|
+
SANDBOX_CLEANUP = "sandbox.cleanup"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = ["Event", "EventBus", "Hook", "HostEventTypes", "Subscriber"]
|
sdxloop/gh/__init__.py
ADDED
sdxloop/gh/ops.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Typed facade over github.op jobs running in the github-ops sandbox.
|
|
2
|
+
|
|
3
|
+
The host never talks to GitHub with the user PAT directly — every operation
|
|
4
|
+
becomes a ``github.op`` JobRequest submitted to the github sandbox, which is
|
|
5
|
+
the only environment holding ``GH_TOKEN``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from sdxloop.errors import GithubOpsError
|
|
15
|
+
from sdxloop.ids import new_job_id
|
|
16
|
+
from sdxloop.worker.client import WorkerClient
|
|
17
|
+
from sdxloop_worker.protocol import JobRequest
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IssueRef(BaseModel):
|
|
21
|
+
number: int
|
|
22
|
+
url: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PrRef(BaseModel):
|
|
26
|
+
number: int
|
|
27
|
+
url: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GithubOps:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
client: WorkerClient,
|
|
34
|
+
run_id: str,
|
|
35
|
+
*,
|
|
36
|
+
timeout_s: float = 120.0,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.client = client
|
|
39
|
+
self.run_id = run_id
|
|
40
|
+
self.timeout_s = timeout_s
|
|
41
|
+
|
|
42
|
+
def _op(self, op: str, params: dict[str, Any]) -> Any:
|
|
43
|
+
job = JobRequest(
|
|
44
|
+
job_id=new_job_id(),
|
|
45
|
+
run_id=self.run_id,
|
|
46
|
+
kind="github.op",
|
|
47
|
+
op=op,
|
|
48
|
+
params=params,
|
|
49
|
+
timeout_s=self.timeout_s,
|
|
50
|
+
)
|
|
51
|
+
result = self.client.submit(job)
|
|
52
|
+
if result.status != "ok":
|
|
53
|
+
assert result.error is not None
|
|
54
|
+
raise GithubOpsError(
|
|
55
|
+
f"github op {op} failed: {result.error.type}: {result.error.message}"
|
|
56
|
+
)
|
|
57
|
+
return result.output_json
|
|
58
|
+
|
|
59
|
+
def issue_create(
|
|
60
|
+
self,
|
|
61
|
+
repo: str,
|
|
62
|
+
title: str,
|
|
63
|
+
body: str = "",
|
|
64
|
+
labels: list[str] | None = None,
|
|
65
|
+
) -> IssueRef:
|
|
66
|
+
params: dict[str, Any] = {"repo": repo, "title": title, "body": body}
|
|
67
|
+
if labels:
|
|
68
|
+
params["labels"] = labels
|
|
69
|
+
return IssueRef.model_validate(self._op("issue.create", params))
|
|
70
|
+
|
|
71
|
+
def issue_comment(self, repo: str, number: int, body: str) -> str:
|
|
72
|
+
data = self._op("issue.comment", {"repo": repo, "number": number, "body": body})
|
|
73
|
+
return str(data.get("url", ""))
|
|
74
|
+
|
|
75
|
+
def pr_create(
|
|
76
|
+
self,
|
|
77
|
+
repo: str,
|
|
78
|
+
base: str,
|
|
79
|
+
head: str,
|
|
80
|
+
title: str,
|
|
81
|
+
body: str = "",
|
|
82
|
+
*,
|
|
83
|
+
draft: bool = False,
|
|
84
|
+
) -> PrRef:
|
|
85
|
+
return PrRef.model_validate(
|
|
86
|
+
self._op(
|
|
87
|
+
"pr.create",
|
|
88
|
+
{
|
|
89
|
+
"repo": repo,
|
|
90
|
+
"base": base,
|
|
91
|
+
"head": head,
|
|
92
|
+
"title": title,
|
|
93
|
+
"body": body,
|
|
94
|
+
"draft": draft,
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def pr_comment(self, repo: str, number: int, body: str) -> str:
|
|
100
|
+
data = self._op("pr.comment", {"repo": repo, "number": number, "body": body})
|
|
101
|
+
return str(data.get("url", ""))
|
|
102
|
+
|
|
103
|
+
def contents_read(self, repo: str, path: str, ref: str | None = None) -> str:
|
|
104
|
+
params: dict[str, Any] = {"repo": repo, "path": path}
|
|
105
|
+
if ref:
|
|
106
|
+
params["ref"] = ref
|
|
107
|
+
data = self._op("contents.read", params)
|
|
108
|
+
return str(data.get("content", ""))
|
|
109
|
+
|
|
110
|
+
def status_create(
|
|
111
|
+
self,
|
|
112
|
+
repo: str,
|
|
113
|
+
sha: str,
|
|
114
|
+
state: str,
|
|
115
|
+
*,
|
|
116
|
+
context: str = "sdxloop",
|
|
117
|
+
description: str = "",
|
|
118
|
+
target_url: str = "",
|
|
119
|
+
) -> None:
|
|
120
|
+
params: dict[str, Any] = {"repo": repo, "sha": sha, "state": state, "context": context}
|
|
121
|
+
if description:
|
|
122
|
+
params["description"] = description
|
|
123
|
+
if target_url:
|
|
124
|
+
params["target_url"] = target_url
|
|
125
|
+
self._op("status.create", params)
|
|
126
|
+
|
|
127
|
+
def repo_get(self, repo: str) -> dict[str, Any]:
|
|
128
|
+
data = self._op("repo.get", {"repo": repo})
|
|
129
|
+
assert isinstance(data, dict)
|
|
130
|
+
return data
|
|
131
|
+
|
|
132
|
+
def search_issues(self, query: str, per_page: int = 30) -> list[dict[str, Any]]:
|
|
133
|
+
data = self._op("search.issues", {"query": query, "per_page": per_page})
|
|
134
|
+
return data if isinstance(data, list) else []
|
|
135
|
+
|
|
136
|
+
def raw(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
|
|
137
|
+
params: dict[str, Any] = {"method": method, "path": path}
|
|
138
|
+
if body is not None:
|
|
139
|
+
params["body"] = body
|
|
140
|
+
return self._op("raw.api", params)
|
sdxloop/gh/reporter.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""GithubReporterHook: mirrors run progress to a GitHub tracking issue.
|
|
2
|
+
|
|
3
|
+
The default consumer of the github-ops sandbox. When ``github.report_repo``
|
|
4
|
+
is configured, the hook opens a tracking issue at run start, comments as
|
|
5
|
+
tasks finish, and posts a final summary at run end. It never raises — a
|
|
6
|
+
reporting failure must not fail a run — and the EventBus isolates it anyway.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from sdxloop.events import Event, HostEventTypes
|
|
14
|
+
from sdxloop.gh.ops import GithubOps, IssueRef
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GithubReporterHook:
|
|
20
|
+
def __init__(self, ops: GithubOps, repo: str) -> None:
|
|
21
|
+
self.ops = ops
|
|
22
|
+
self.repo = repo
|
|
23
|
+
self.issue: IssueRef | None = None
|
|
24
|
+
self._task_lines: list[str] = []
|
|
25
|
+
|
|
26
|
+
# -- Hook protocol -----------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def on_event(self, event: Event) -> None:
|
|
29
|
+
try:
|
|
30
|
+
if event.type == HostEventTypes.RUN_START:
|
|
31
|
+
self._on_run_start(event)
|
|
32
|
+
elif event.type == HostEventTypes.TASK_END:
|
|
33
|
+
self._on_task_end(event)
|
|
34
|
+
elif event.type == HostEventTypes.RUN_END:
|
|
35
|
+
self._on_run_end(event)
|
|
36
|
+
except Exception:
|
|
37
|
+
logger.warning("github reporting failed for %s", event.type, exc_info=True)
|
|
38
|
+
|
|
39
|
+
# -- handlers ----------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def _on_run_start(self, event: Event) -> None:
|
|
42
|
+
outcome = str(event.data.get("outcome", ""))
|
|
43
|
+
body = (
|
|
44
|
+
f"sdxloop run `{event.run_id}` started.\n\n"
|
|
45
|
+
f"**Outcome:**\n\n> {outcome}\n\n"
|
|
46
|
+
"Progress is reported as comments on this issue."
|
|
47
|
+
)
|
|
48
|
+
self.issue = self.ops.issue_create(
|
|
49
|
+
self.repo,
|
|
50
|
+
title=f"sdxloop run {event.run_id}",
|
|
51
|
+
body=body,
|
|
52
|
+
labels=["sdxloop"],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def _on_task_end(self, event: Event) -> None:
|
|
56
|
+
if self.issue is None:
|
|
57
|
+
return
|
|
58
|
+
task_id = event.data.get("task_id", "?")
|
|
59
|
+
title = event.data.get("title", "")
|
|
60
|
+
state = event.data.get("state", "?")
|
|
61
|
+
marker = "✅" if state == "done" else "❌"
|
|
62
|
+
line = f"{marker} `{task_id}` {title} — **{state}**"
|
|
63
|
+
self._task_lines.append(line)
|
|
64
|
+
self.ops.issue_comment(self.repo, self.issue.number, line)
|
|
65
|
+
|
|
66
|
+
def _on_run_end(self, event: Event) -> None:
|
|
67
|
+
if self.issue is None:
|
|
68
|
+
return
|
|
69
|
+
state = event.data.get("state", "?")
|
|
70
|
+
summary = "\n".join(self._task_lines) or "_no tasks were executed_"
|
|
71
|
+
self.ops.issue_comment(
|
|
72
|
+
self.repo,
|
|
73
|
+
self.issue.number,
|
|
74
|
+
f"Run `{event.run_id}` finished: **{state}**\n\n{summary}",
|
|
75
|
+
)
|
sdxloop/ids.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Identifier generation for runs, jobs, and tasks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
|
|
8
|
+
# Crockford-flavored base32: lowercase, no i/l/o/u lookalikes.
|
|
9
|
+
_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
|
|
10
|
+
|
|
11
|
+
_RUN_ID_RE = re.compile(r"^r[0-9abcdefghjkmnpqrstvwxyz]{8}$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _token(length: int) -> str:
|
|
15
|
+
return "".join(secrets.choice(_ALPHABET) for _ in range(length))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def new_run_id() -> str:
|
|
19
|
+
"""A short, sandbox-name-safe run id, e.g. ``r7k2m9qp3``-style."""
|
|
20
|
+
return "r" + _token(8)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_run_id(value: str) -> bool:
|
|
24
|
+
return _RUN_ID_RE.fullmatch(value) is not None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def new_job_id() -> str:
|
|
28
|
+
return "j" + _token(10)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def task_id(index: int) -> str:
|
|
32
|
+
"""Deterministic task ids within a run: t1, t2, ..."""
|
|
33
|
+
if index < 1:
|
|
34
|
+
raise ValueError("task index starts at 1")
|
|
35
|
+
return f"t{index}"
|
sdxloop/py.typed
ADDED
|
File without changes
|
sdxloop/sbx/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Typed subprocess layer over the Docker Sandboxes ``sbx`` CLI."""
|
|
2
|
+
|
|
3
|
+
from sdxloop.sbx.cli import SbxCLI
|
|
4
|
+
from sdxloop.sbx.models import ExecResult, SandboxInfo, SandboxSpec, SecretSpec
|
|
5
|
+
|
|
6
|
+
__all__ = ["ExecResult", "SandboxInfo", "SandboxSpec", "SbxCLI", "SecretSpec"]
|