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/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""sdxloop — agentic loop orchestration on Docker Sandboxes (sbx).
|
|
2
|
+
|
|
3
|
+
Every sdxloop operation runs as a pair of isolated microVM sandboxes: an agent
|
|
4
|
+
sandbox holding only ``COPILOT_GITHUB_TOKEN`` for the GitHub Copilot SDK
|
|
5
|
+
agentic layer, and a GitHub-ops sandbox holding only ``GH_TOKEN`` for
|
|
6
|
+
user-facing GitHub interactions. The balanced network policy is the default.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
from sdxloop.config import Budgets, Config, load_config
|
|
12
|
+
from sdxloop.engine import LoopEngine, RunResult, run_outcome
|
|
13
|
+
from sdxloop.events import Event, EventBus, Hook
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Budgets",
|
|
17
|
+
"Config",
|
|
18
|
+
"Event",
|
|
19
|
+
"EventBus",
|
|
20
|
+
"Hook",
|
|
21
|
+
"LoopEngine",
|
|
22
|
+
"RunResult",
|
|
23
|
+
"__version__",
|
|
24
|
+
"load_config",
|
|
25
|
+
"run_outcome",
|
|
26
|
+
]
|
|
Binary file
|
sdxloop/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The sdxloop command-line interface."""
|
sdxloop/cli/app.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""The sdxloop CLI: run agentic loops on Docker Sandboxes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.live import Live
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
import sdxloop
|
|
16
|
+
from sdxloop.cli.doctor import run_doctor
|
|
17
|
+
from sdxloop.cli.tui import Dashboard, format_event, plain_printer
|
|
18
|
+
from sdxloop.config import Config, load_config, load_config_with_sources
|
|
19
|
+
from sdxloop.engine.engine import LoopEngine
|
|
20
|
+
from sdxloop.engine.model import RunResult
|
|
21
|
+
from sdxloop.engine.store import StateStore
|
|
22
|
+
from sdxloop.errors import SdxloopError
|
|
23
|
+
from sdxloop.sbx.cli import SbxCLI
|
|
24
|
+
from sdxloop.sbx.provision import sandbox_name
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="sdxloop",
|
|
28
|
+
help="Agentic loop orchestration on Docker Sandboxes with isolated credentials.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
pretty_exceptions_show_locals=False,
|
|
31
|
+
)
|
|
32
|
+
sandbox_app = typer.Typer(help="Manage sdxloop sandboxes.", no_args_is_help=True)
|
|
33
|
+
config_app = typer.Typer(help="Inspect configuration.", no_args_is_help=True)
|
|
34
|
+
app.add_typer(sandbox_app, name="sandbox")
|
|
35
|
+
app.add_typer(config_app, name="config")
|
|
36
|
+
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _version_callback(value: bool) -> None:
|
|
41
|
+
if value:
|
|
42
|
+
console.print(f"sdxloop {sdxloop.__version__}")
|
|
43
|
+
raise typer.Exit()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.callback()
|
|
47
|
+
def _main_callback(
|
|
48
|
+
version: Annotated[
|
|
49
|
+
bool, typer.Option("--version", callback=_version_callback, is_eager=True)
|
|
50
|
+
] = False,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""sdxloop — agentic loops on Docker Sandboxes."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _config_with_overrides(**overrides: Any) -> Config:
|
|
56
|
+
config = load_config()
|
|
57
|
+
updates = {k: v for k, v in overrides.items() if v is not None}
|
|
58
|
+
return config.model_copy(update=updates) if updates else config
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _store(config: Config) -> StateStore:
|
|
62
|
+
return StateStore(config.state_dir / "state.db")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _drive_with_ui(engine: LoopEngine, *, tui: bool, action: Any) -> RunResult:
|
|
66
|
+
"""Run start/resume with either the live dashboard or plain event logs."""
|
|
67
|
+
if not tui:
|
|
68
|
+
engine.bus.subscribe(plain_printer(console))
|
|
69
|
+
return action() # type: ignore[no-any-return]
|
|
70
|
+
|
|
71
|
+
dashboard = Dashboard()
|
|
72
|
+
engine.bus.subscribe(dashboard.on_event)
|
|
73
|
+
outcome: dict[str, Any] = {}
|
|
74
|
+
|
|
75
|
+
def target() -> None:
|
|
76
|
+
try:
|
|
77
|
+
outcome["result"] = action()
|
|
78
|
+
except BaseException as exc:
|
|
79
|
+
outcome["error"] = exc
|
|
80
|
+
|
|
81
|
+
thread = threading.Thread(target=target, daemon=True)
|
|
82
|
+
with Live(dashboard.renderable(), console=console, refresh_per_second=8) as live:
|
|
83
|
+
thread.start()
|
|
84
|
+
while thread.is_alive():
|
|
85
|
+
live.update(dashboard.renderable())
|
|
86
|
+
time.sleep(0.15)
|
|
87
|
+
live.update(dashboard.renderable())
|
|
88
|
+
if "error" in outcome:
|
|
89
|
+
raise outcome["error"]
|
|
90
|
+
return outcome["result"] # type: ignore[no-any-return]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _finish(result: RunResult) -> None:
|
|
94
|
+
style = "green" if result.succeeded else "red"
|
|
95
|
+
console.print(f"\nrun [bold cyan]{result.run_id}[/] finished: [bold {style}]{result.state}[/]")
|
|
96
|
+
for task in result.tasks:
|
|
97
|
+
console.print(f" {task.spec.id}: {task.state} ({task.spec.title})")
|
|
98
|
+
raise typer.Exit(0 if result.succeeded else 1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command()
|
|
102
|
+
def run(
|
|
103
|
+
outcome: Annotated[str, typer.Argument(help="The outcome to achieve.")],
|
|
104
|
+
repo: Annotated[
|
|
105
|
+
str | None, typer.Option("--repo", help="owner/repo for GitHub progress reporting.")
|
|
106
|
+
] = None,
|
|
107
|
+
model: Annotated[str | None, typer.Option("--model", help="Copilot model id.")] = None,
|
|
108
|
+
keep_sandboxes: Annotated[
|
|
109
|
+
bool, typer.Option("--keep-sandboxes", help="Do not remove sandboxes at the end.")
|
|
110
|
+
] = False,
|
|
111
|
+
tui: Annotated[bool, typer.Option("--tui/--no-tui", help="Live dashboard.")] = True,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Run an agentic loop for OUTCOME in a fresh sandbox pair."""
|
|
114
|
+
config = _config_with_overrides(model=model, keep_sandboxes=keep_sandboxes or None)
|
|
115
|
+
if repo:
|
|
116
|
+
config = config.model_copy(
|
|
117
|
+
update={"github": config.github.model_copy(update={"report_repo": repo})}
|
|
118
|
+
)
|
|
119
|
+
engine = LoopEngine(config)
|
|
120
|
+
try:
|
|
121
|
+
result = _drive_with_ui(engine, tui=tui, action=lambda: engine.start(outcome))
|
|
122
|
+
except SdxloopError as exc:
|
|
123
|
+
console.print(f"[bold red]run failed:[/] {exc}")
|
|
124
|
+
raise typer.Exit(2) from exc
|
|
125
|
+
_finish(result)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command()
|
|
129
|
+
def resume(
|
|
130
|
+
run_id: Annotated[str, typer.Argument(help="Run id to resume.")],
|
|
131
|
+
tui: Annotated[bool, typer.Option("--tui/--no-tui")] = True,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Resume an unfinished run (fresh sandboxes, persisted state)."""
|
|
134
|
+
engine = LoopEngine(load_config())
|
|
135
|
+
try:
|
|
136
|
+
result = _drive_with_ui(engine, tui=tui, action=lambda: engine.resume(run_id))
|
|
137
|
+
except SdxloopError as exc:
|
|
138
|
+
console.print(f"[bold red]resume failed:[/] {exc}")
|
|
139
|
+
raise typer.Exit(2) from exc
|
|
140
|
+
_finish(result)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command()
|
|
144
|
+
def cancel(run_id: Annotated[str, typer.Argument()]) -> None:
|
|
145
|
+
"""Mark a run cancelled (takes effect at the next phase boundary)."""
|
|
146
|
+
config = load_config()
|
|
147
|
+
engine = LoopEngine(config)
|
|
148
|
+
try:
|
|
149
|
+
engine.cancel(run_id)
|
|
150
|
+
except SdxloopError as exc:
|
|
151
|
+
console.print(f"[bold red]{exc}[/]")
|
|
152
|
+
raise typer.Exit(2) from exc
|
|
153
|
+
console.print(f"run {run_id} cancelled")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command()
|
|
157
|
+
def status(
|
|
158
|
+
run_id: Annotated[str | None, typer.Argument(help="Run id for details.")] = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""List runs, or show one run's tasks and phase history."""
|
|
161
|
+
config = load_config()
|
|
162
|
+
store = _store(config)
|
|
163
|
+
if run_id is None:
|
|
164
|
+
table = Table(title="sdxloop runs")
|
|
165
|
+
table.add_column("run")
|
|
166
|
+
table.add_column("state")
|
|
167
|
+
table.add_column("outcome", max_width=60)
|
|
168
|
+
table.add_column("updated")
|
|
169
|
+
for record in store.list_runs():
|
|
170
|
+
table.add_row(
|
|
171
|
+
record.run_id,
|
|
172
|
+
record.state,
|
|
173
|
+
record.outcome[:60],
|
|
174
|
+
time.strftime("%Y-%m-%d %H:%M", time.localtime(record.updated_at)),
|
|
175
|
+
)
|
|
176
|
+
console.print(table)
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
record = store.get_run(run_id)
|
|
181
|
+
except SdxloopError as exc:
|
|
182
|
+
console.print(f"[bold red]{exc}[/]")
|
|
183
|
+
raise typer.Exit(2) from exc
|
|
184
|
+
console.print(f"run [bold cyan]{record.run_id}[/] state: [bold]{record.state}[/]")
|
|
185
|
+
console.print(f"outcome: {record.outcome}")
|
|
186
|
+
table = Table(title="tasks")
|
|
187
|
+
for column in ("task", "title", "state", "revisions", "replans"):
|
|
188
|
+
table.add_column(column)
|
|
189
|
+
for task in store.get_tasks(run_id):
|
|
190
|
+
table.add_row(
|
|
191
|
+
task.spec.id,
|
|
192
|
+
task.spec.title,
|
|
193
|
+
task.state,
|
|
194
|
+
str(task.revisions),
|
|
195
|
+
str(task.replans),
|
|
196
|
+
)
|
|
197
|
+
console.print(table)
|
|
198
|
+
attempts = store.phase_attempts(run_id)
|
|
199
|
+
console.print(f"{len(attempts)} phase attempts recorded")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command()
|
|
203
|
+
def logs(
|
|
204
|
+
run_id: Annotated[str, typer.Argument()],
|
|
205
|
+
follow: Annotated[bool, typer.Option("--follow", "-f")] = False,
|
|
206
|
+
type_prefix: Annotated[
|
|
207
|
+
str | None, typer.Option("--type", help="Filter by event type prefix.")
|
|
208
|
+
] = None,
|
|
209
|
+
task: Annotated[str | None, typer.Option("--task", help="Filter by task id.")] = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Replay (or tail) a run's event stream from the state store."""
|
|
212
|
+
config = load_config()
|
|
213
|
+
store = _store(config)
|
|
214
|
+
store.get_run(run_id) # validates existence
|
|
215
|
+
last_seq = 0
|
|
216
|
+
while True:
|
|
217
|
+
for seq, event in store.events(run_id, after_seq=last_seq, type_prefix=type_prefix):
|
|
218
|
+
last_seq = seq
|
|
219
|
+
if task and event.data.get("task_id") != task:
|
|
220
|
+
continue
|
|
221
|
+
console.print(format_event(event), highlight=False)
|
|
222
|
+
if not follow:
|
|
223
|
+
break
|
|
224
|
+
if store.get_run(run_id).state in ("completed", "failed", "cancelled"):
|
|
225
|
+
break
|
|
226
|
+
time.sleep(0.5)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@sandbox_app.command("ls")
|
|
230
|
+
def sandbox_ls() -> None:
|
|
231
|
+
"""List sdxloop-managed sandboxes."""
|
|
232
|
+
config = load_config()
|
|
233
|
+
cli = SbxCLI(app_name=config.app_name or None)
|
|
234
|
+
table = Table(title="sdxloop sandboxes")
|
|
235
|
+
for column in ("name", "agent", "status", "workspace"):
|
|
236
|
+
table.add_column(column)
|
|
237
|
+
for info in cli.ls():
|
|
238
|
+
if info.name.startswith("sdxloop-"):
|
|
239
|
+
table.add_row(info.name, info.agent or "", info.status or "", info.workspace or "")
|
|
240
|
+
console.print(table)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@sandbox_app.command("rm")
|
|
244
|
+
def sandbox_rm(
|
|
245
|
+
name: Annotated[str | None, typer.Argument(help="Sandbox name.")] = None,
|
|
246
|
+
run_id: Annotated[str | None, typer.Option("--run", help="Remove a run's pair.")] = None,
|
|
247
|
+
all_: Annotated[bool, typer.Option("--all", help="Remove all sdxloop sandboxes.")] = False,
|
|
248
|
+
) -> None:
|
|
249
|
+
"""Remove sdxloop sandboxes by name, by run, or all of them."""
|
|
250
|
+
config = load_config()
|
|
251
|
+
cli = SbxCLI(app_name=config.app_name or None)
|
|
252
|
+
targets: list[str] = []
|
|
253
|
+
if name:
|
|
254
|
+
targets.append(name)
|
|
255
|
+
if run_id:
|
|
256
|
+
targets += [sandbox_name(run_id, "agent"), sandbox_name(run_id, "github")]
|
|
257
|
+
if all_:
|
|
258
|
+
targets += [i.name for i in cli.ls() if i.name.startswith("sdxloop-")]
|
|
259
|
+
if not targets:
|
|
260
|
+
console.print("nothing to remove: pass a NAME, --run, or --all")
|
|
261
|
+
raise typer.Exit(2)
|
|
262
|
+
for target in dict.fromkeys(targets):
|
|
263
|
+
try:
|
|
264
|
+
cli.rm(target)
|
|
265
|
+
console.print(f"removed {target}")
|
|
266
|
+
except SdxloopError as exc:
|
|
267
|
+
console.print(f"[yellow]skip {target}:[/] {exc}")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@config_app.command("show")
|
|
271
|
+
def config_show() -> None:
|
|
272
|
+
"""Show the resolved configuration and where each value came from."""
|
|
273
|
+
try:
|
|
274
|
+
config, sources = load_config_with_sources()
|
|
275
|
+
except SdxloopError as exc:
|
|
276
|
+
console.print(f"[bold red]{exc}[/]")
|
|
277
|
+
raise typer.Exit(2) from exc
|
|
278
|
+
table = Table(title="sdxloop configuration")
|
|
279
|
+
table.add_column("key")
|
|
280
|
+
table.add_column("value")
|
|
281
|
+
table.add_column("source")
|
|
282
|
+
flat: dict[str, Any] = {}
|
|
283
|
+
|
|
284
|
+
def flatten(prefix: str, data: dict[str, Any]) -> None:
|
|
285
|
+
for key, value in data.items():
|
|
286
|
+
dotted = f"{prefix}{key}"
|
|
287
|
+
if isinstance(value, dict):
|
|
288
|
+
flatten(f"{dotted}.", value)
|
|
289
|
+
else:
|
|
290
|
+
flat[dotted] = value
|
|
291
|
+
|
|
292
|
+
flatten("", config.model_dump(mode="json"))
|
|
293
|
+
for dotted in sorted(flat):
|
|
294
|
+
table.add_row(dotted, repr(flat[dotted]), sources.get(dotted, "default"))
|
|
295
|
+
console.print(table)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@app.command()
|
|
299
|
+
def init(
|
|
300
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Write a commented sdxloop.toml with the default configuration."""
|
|
303
|
+
path = Path("sdxloop.toml")
|
|
304
|
+
if path.exists() and not force:
|
|
305
|
+
console.print("sdxloop.toml already exists (use --force to overwrite)")
|
|
306
|
+
raise typer.Exit(2)
|
|
307
|
+
path.write_text(DEFAULT_CONFIG_TOML)
|
|
308
|
+
console.print(f"wrote {path}")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@app.command()
|
|
312
|
+
def doctor() -> None:
|
|
313
|
+
"""Check that this host is ready to run sdxloop."""
|
|
314
|
+
ok = run_doctor(console)
|
|
315
|
+
raise typer.Exit(0 if ok else 1)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
DEFAULT_CONFIG_TOML = """\
|
|
319
|
+
# sdxloop configuration. Every key is optional; these are the defaults.
|
|
320
|
+
# Precedence: SDXLOOP_* env vars > this file > pyproject [tool.sdxloop].
|
|
321
|
+
|
|
322
|
+
# Copilot model for agent sessions ("auto" lets the SDK choose).
|
|
323
|
+
model = "auto"
|
|
324
|
+
# sbx --app-name isolating sdxloop's sandboxes/policies/secrets.
|
|
325
|
+
app_name = "sdxloop"
|
|
326
|
+
# Where run state (SQLite) and per-run workspaces live.
|
|
327
|
+
state_dir = ".sdxloop"
|
|
328
|
+
# Keep sandboxes around after a run (for debugging).
|
|
329
|
+
keep_sandboxes = false
|
|
330
|
+
# Worker transport: "stream" (default) or "poll".
|
|
331
|
+
worker_transport = "stream"
|
|
332
|
+
# Secret injection: "proxy" (sbx keychain proxy; recommended) or "plain-env".
|
|
333
|
+
secret_strategy = "proxy"
|
|
334
|
+
|
|
335
|
+
[sandbox]
|
|
336
|
+
# Custom sandbox template reference (defaults to the sbx shell template).
|
|
337
|
+
# template = "docker.io/you/your-template:v1"
|
|
338
|
+
# Extra network allow rules applied to both sandboxes.
|
|
339
|
+
extra_allow_domains = []
|
|
340
|
+
|
|
341
|
+
[github]
|
|
342
|
+
# Report run progress to a GitHub repo ("owner/repo"); empty disables.
|
|
343
|
+
# report_repo = "you/your-repo"
|
|
344
|
+
|
|
345
|
+
[budgets]
|
|
346
|
+
max_revisions_per_task = 2
|
|
347
|
+
max_replans_per_task = 1
|
|
348
|
+
max_tasks = 20
|
|
349
|
+
max_wall_clock_s = 7200.0
|
|
350
|
+
per_job_timeout_s = 900.0
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def main() -> None:
|
|
355
|
+
app()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
if __name__ == "__main__":
|
|
359
|
+
main()
|
sdxloop/cli/doctor.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""sdxloop doctor: host readiness checks with remediation hints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from sdxloop.config import load_config
|
|
11
|
+
from sdxloop.errors import SbxError, SbxNotFoundError
|
|
12
|
+
from sdxloop.sbx.cli import SbxCLI
|
|
13
|
+
from sdxloop.sbx.provision import (
|
|
14
|
+
AGENT_TOKEN_HOSTS,
|
|
15
|
+
COPILOT_TOKEN_ENV,
|
|
16
|
+
GH_TOKEN_ENVS,
|
|
17
|
+
)
|
|
18
|
+
from sdxloop.worker.wheel import resolve_worker_wheel
|
|
19
|
+
|
|
20
|
+
TESTED_SBX_SERIES = "0.35"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Check:
|
|
25
|
+
name: str
|
|
26
|
+
ok: bool
|
|
27
|
+
detail: str
|
|
28
|
+
hard: bool = True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def collect_checks(env: dict[str, str], cli: SbxCLI | None = None) -> list[Check]:
|
|
32
|
+
config = load_config(env=env)
|
|
33
|
+
cli = cli or SbxCLI(app_name=config.app_name or None)
|
|
34
|
+
checks: list[Check] = []
|
|
35
|
+
|
|
36
|
+
# sbx binary + version
|
|
37
|
+
try:
|
|
38
|
+
version = cli.version()
|
|
39
|
+
except SbxNotFoundError:
|
|
40
|
+
checks.append(
|
|
41
|
+
Check(
|
|
42
|
+
"sbx binary",
|
|
43
|
+
False,
|
|
44
|
+
"sbx not found on PATH — install Docker Sandboxes: "
|
|
45
|
+
"https://docs.docker.com/ai/sandboxes/",
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
version = None
|
|
49
|
+
else:
|
|
50
|
+
detail = f"found sbx {version or '(unknown version)'}"
|
|
51
|
+
if version and not version.startswith(TESTED_SBX_SERIES):
|
|
52
|
+
detail += f" (sdxloop is tested against {TESTED_SBX_SERIES}.x; parsing may drift)"
|
|
53
|
+
checks.append(Check("sbx binary", True, detail))
|
|
54
|
+
|
|
55
|
+
if version is not None:
|
|
56
|
+
# login / daemon reachable
|
|
57
|
+
try:
|
|
58
|
+
cli.ls()
|
|
59
|
+
checks.append(Check("sbx login", True, "sbx ls succeeded"))
|
|
60
|
+
except SbxError as exc:
|
|
61
|
+
checks.append(Check("sbx login", False, f"sbx ls failed ({exc}); run `sbx login`"))
|
|
62
|
+
|
|
63
|
+
# network policy reachable for the copilot hosts
|
|
64
|
+
for host in AGENT_TOKEN_HOSTS:
|
|
65
|
+
allowed = False
|
|
66
|
+
try:
|
|
67
|
+
allowed = cli.policy_check(host)
|
|
68
|
+
except SbxError:
|
|
69
|
+
allowed = False
|
|
70
|
+
checks.append(
|
|
71
|
+
Check(
|
|
72
|
+
f"policy: {host}",
|
|
73
|
+
allowed,
|
|
74
|
+
"reachable"
|
|
75
|
+
if allowed
|
|
76
|
+
else (
|
|
77
|
+
"blocked — run `sbx policy init balanced` and/or "
|
|
78
|
+
f"`sbx policy allow network {host}`"
|
|
79
|
+
),
|
|
80
|
+
hard=False, # per-sandbox allows are applied at provision time
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# tokens
|
|
85
|
+
checks.append(
|
|
86
|
+
Check(
|
|
87
|
+
COPILOT_TOKEN_ENV,
|
|
88
|
+
bool(env.get(COPILOT_TOKEN_ENV)),
|
|
89
|
+
"set"
|
|
90
|
+
if env.get(COPILOT_TOKEN_ENV)
|
|
91
|
+
else 'not set — create a fine-grained PAT with the "Copilot Requests" '
|
|
92
|
+
f"permission and export {COPILOT_TOKEN_ENV}",
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
gh_set = any(env.get(name) for name in GH_TOKEN_ENVS)
|
|
96
|
+
checks.append(
|
|
97
|
+
Check(
|
|
98
|
+
"/".join(GH_TOKEN_ENVS),
|
|
99
|
+
gh_set,
|
|
100
|
+
"set"
|
|
101
|
+
if gh_set
|
|
102
|
+
else "not set — create a fine-grained PAT (issues:write, contents:read, ...) "
|
|
103
|
+
"and export GH_TOKEN",
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# worker wheel
|
|
108
|
+
wheel = resolve_worker_wheel()
|
|
109
|
+
checks.append(
|
|
110
|
+
Check(
|
|
111
|
+
"worker wheel",
|
|
112
|
+
True,
|
|
113
|
+
f"resolved: {wheel.name}" if wheel else "no local wheel; will install from PyPI",
|
|
114
|
+
hard=False,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# state dir writable
|
|
119
|
+
try:
|
|
120
|
+
config.state_dir.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
probe = config.state_dir / ".doctor-probe"
|
|
122
|
+
probe.write_text("ok")
|
|
123
|
+
probe.unlink()
|
|
124
|
+
checks.append(Check("state dir", True, str(config.state_dir.resolve())))
|
|
125
|
+
except OSError as exc:
|
|
126
|
+
checks.append(Check("state dir", False, f"not writable: {exc}"))
|
|
127
|
+
|
|
128
|
+
return checks
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def run_doctor(console: Console, env: dict[str, str] | None = None) -> bool:
|
|
132
|
+
import os
|
|
133
|
+
|
|
134
|
+
checks = collect_checks(dict(os.environ) if env is None else env)
|
|
135
|
+
table = Table(title="sdxloop doctor")
|
|
136
|
+
table.add_column("check")
|
|
137
|
+
table.add_column("status")
|
|
138
|
+
table.add_column("detail", max_width=80)
|
|
139
|
+
for check in checks:
|
|
140
|
+
status = (
|
|
141
|
+
"[green]ok[/]" if check.ok else ("[red]FAIL[/]" if check.hard else "[yellow]warn[/]")
|
|
142
|
+
)
|
|
143
|
+
table.add_row(check.name, status, check.detail)
|
|
144
|
+
console.print(table)
|
|
145
|
+
return all(check.ok or not check.hard for check in checks)
|
sdxloop/cli/tui.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Rich rendering for runs: live dashboard and plain event logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
from collections import deque
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.console import Console, Group, RenderableType
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from sdxloop.events import Event, HostEventTypes
|
|
15
|
+
|
|
16
|
+
TASK_STATE_STYLES = {
|
|
17
|
+
"pending": "dim",
|
|
18
|
+
"planning": "yellow",
|
|
19
|
+
"executing": "cyan",
|
|
20
|
+
"scrutinizing": "magenta",
|
|
21
|
+
"verifying": "blue",
|
|
22
|
+
"validating": "magenta",
|
|
23
|
+
"done": "green",
|
|
24
|
+
"failed": "red",
|
|
25
|
+
"skipped": "dim red",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
INTERESTING_EVENTS = (
|
|
29
|
+
"run.",
|
|
30
|
+
"task.",
|
|
31
|
+
"sandbox.",
|
|
32
|
+
"agent.message",
|
|
33
|
+
"agent.tool",
|
|
34
|
+
"gh.",
|
|
35
|
+
"worker.error",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Dashboard:
|
|
40
|
+
"""Accumulates run state from the event stream and renders it."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, max_events: int = 12) -> None:
|
|
43
|
+
self.run_id: str | None = None
|
|
44
|
+
self.run_state: str = "starting"
|
|
45
|
+
self.outcome: str = ""
|
|
46
|
+
self.tasks: dict[str, dict[str, Any]] = {}
|
|
47
|
+
self.recent: deque[str] = deque(maxlen=max_events)
|
|
48
|
+
|
|
49
|
+
# -- event intake ------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def on_event(self, event: Event) -> None:
|
|
52
|
+
self.run_id = self.run_id or event.run_id
|
|
53
|
+
data = event.data
|
|
54
|
+
if event.type == HostEventTypes.RUN_START:
|
|
55
|
+
self.outcome = str(data.get("outcome", ""))
|
|
56
|
+
elif event.type == HostEventTypes.RUN_STATE or event.type == HostEventTypes.RUN_END:
|
|
57
|
+
self.run_state = str(data.get("state", self.run_state))
|
|
58
|
+
elif event.type in (HostEventTypes.TASK_START, HostEventTypes.TASK_STATE):
|
|
59
|
+
task_id = str(data.get("task_id"))
|
|
60
|
+
entry = self.tasks.setdefault(task_id, {"title": "", "state": "pending"})
|
|
61
|
+
if data.get("title"):
|
|
62
|
+
entry["title"] = str(data["title"])
|
|
63
|
+
if data.get("state"):
|
|
64
|
+
entry["state"] = str(data["state"])
|
|
65
|
+
entry["revisions"] = data.get("revisions", entry.get("revisions", 0))
|
|
66
|
+
entry["replans"] = data.get("replans", entry.get("replans", 0))
|
|
67
|
+
elif event.type == HostEventTypes.TASK_END:
|
|
68
|
+
task_id = str(data.get("task_id"))
|
|
69
|
+
entry = self.tasks.setdefault(task_id, {"title": "", "state": "pending"})
|
|
70
|
+
if data.get("title"):
|
|
71
|
+
entry["title"] = str(data["title"])
|
|
72
|
+
entry["state"] = str(data.get("state", entry["state"]))
|
|
73
|
+
if event.type.startswith(INTERESTING_EVENTS):
|
|
74
|
+
self.recent.append(format_event(event))
|
|
75
|
+
|
|
76
|
+
# -- rendering ---------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def renderable(self) -> RenderableType:
|
|
79
|
+
header = Text.assemble(
|
|
80
|
+
("run ", "bold"),
|
|
81
|
+
(self.run_id or "…", "bold cyan"),
|
|
82
|
+
(" state: ", ""),
|
|
83
|
+
(self.run_state, "bold yellow"),
|
|
84
|
+
)
|
|
85
|
+
outcome = Text(self.outcome[:200], style="italic dim")
|
|
86
|
+
|
|
87
|
+
table = Table(expand=True, box=None, pad_edge=False)
|
|
88
|
+
table.add_column("task", style="bold", no_wrap=True)
|
|
89
|
+
table.add_column("title", ratio=2)
|
|
90
|
+
table.add_column("state", no_wrap=True)
|
|
91
|
+
table.add_column("rev/replan", no_wrap=True, justify="right")
|
|
92
|
+
for task_id, entry in self.tasks.items():
|
|
93
|
+
state = str(entry.get("state", "?"))
|
|
94
|
+
table.add_row(
|
|
95
|
+
task_id,
|
|
96
|
+
str(entry.get("title", "")),
|
|
97
|
+
Text(state, style=TASK_STATE_STYLES.get(state, "")),
|
|
98
|
+
f"{entry.get('revisions', 0)}/{entry.get('replans', 0)}",
|
|
99
|
+
)
|
|
100
|
+
if not self.tasks:
|
|
101
|
+
table.add_row("…", "decomposing outcome", Text("pending", style="dim"), "")
|
|
102
|
+
|
|
103
|
+
events_panel = Panel(
|
|
104
|
+
Text("\n".join(self.recent) or "waiting for events…", overflow="ellipsis"),
|
|
105
|
+
title="events",
|
|
106
|
+
border_style="dim",
|
|
107
|
+
)
|
|
108
|
+
return Group(Panel(Group(header, outcome), border_style="cyan"), table, events_panel)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def format_event(event: Event) -> str:
|
|
112
|
+
stamp = datetime.datetime.fromtimestamp(event.ts).strftime("%H:%M:%S")
|
|
113
|
+
parts = [stamp, event.type]
|
|
114
|
+
if event.data.get("task_id"):
|
|
115
|
+
parts.append(f"[{event.data['task_id']}]")
|
|
116
|
+
for key in ("state", "content", "tool", "op", "line", "message", "outcome"):
|
|
117
|
+
if event.data.get(key):
|
|
118
|
+
value = str(event.data[key]).replace("\n", " ")
|
|
119
|
+
parts.append(value[:120])
|
|
120
|
+
break
|
|
121
|
+
return " ".join(parts)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def plain_printer(console: Console) -> Any:
|
|
125
|
+
def print_event(event: Event) -> None:
|
|
126
|
+
if event.type.startswith(INTERESTING_EVENTS):
|
|
127
|
+
console.print(format_event(event), highlight=False)
|
|
128
|
+
|
|
129
|
+
return print_event
|