sbxloop 0.2.0__tar.gz → 0.4.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.
- {sbxloop-0.2.0 → sbxloop-0.4.0}/PKG-INFO +2 -2
- {sbxloop-0.2.0 → sbxloop-0.4.0}/pyproject.toml +2 -2
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/__init__.py +1 -1
- sbxloop-0.4.0/src/sbxloop/_vendor/sbxloop_worker-0.4.0-py3-none-any.whl +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/cli/app.py +168 -14
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/cli/doctor.py +24 -10
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/cli/tui.py +64 -25
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/config.py +25 -2
- sbxloop-0.4.0/src/sbxloop/deliver.py +144 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/engine.py +127 -16
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/model.py +32 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/phases.py +15 -2
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/prompts/execute.md +5 -1
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/store.py +33 -14
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/errors.py +4 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/events.py +2 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/gh/reporter.py +3 -2
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/pair.py +21 -5
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/provision.py +76 -8
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/sandbox.py +3 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/worker/client.py +4 -0
- sbxloop-0.2.0/src/sbxloop/_vendor/sbxloop_worker-0.2.0-py3-none-any.whl +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/.gitignore +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/README.md +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/hatch_build.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/cli/__init__.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/__init__.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/prompts/__init__.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/prompts/decompose.md +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/prompts/plan.md +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/engine/prompts/validate.md +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/gh/__init__.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/gh/ops.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/ids.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/py.typed +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/__init__.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/cli.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/models.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/sbx/parse.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/worker/__init__.py +0 -0
- {sbxloop-0.2.0 → sbxloop-0.4.0}/src/sbxloop/worker/wheel.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sbxloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Agentic loop orchestration on Docker Sandboxes (sbx) with isolated credential domains
|
|
5
5
|
Project-URL: Homepage, https://github.com/brettbergin/sbxloop
|
|
6
6
|
Project-URL: Repository, https://github.com/brettbergin/sbxloop
|
|
@@ -19,7 +19,7 @@ Requires-Python: >=3.11
|
|
|
19
19
|
Requires-Dist: pydantic>=2.7
|
|
20
20
|
Requires-Dist: python-dotenv>=1.0
|
|
21
21
|
Requires-Dist: rich>=13.7
|
|
22
|
-
Requires-Dist: sbxloop-worker==0.
|
|
22
|
+
Requires-Dist: sbxloop-worker==0.4.0
|
|
23
23
|
Requires-Dist: typer>=0.12
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sbxloop"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Agentic loop orchestration on Docker Sandboxes (sbx) with isolated credential domains"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -21,7 +21,7 @@ dependencies = [
|
|
|
21
21
|
"python-dotenv>=1.0",
|
|
22
22
|
"typer>=0.12",
|
|
23
23
|
"rich>=13.7",
|
|
24
|
-
"sbxloop-worker==0.
|
|
24
|
+
"sbxloop-worker==0.4.0",
|
|
25
25
|
]
|
|
26
26
|
|
|
27
27
|
[project.scripts]
|
|
@@ -6,7 +6,7 @@ agentic layer, and a GitHub-ops sandbox holding only ``GH_TOKEN`` for
|
|
|
6
6
|
user-facing GitHub interactions. The balanced network policy is the default.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
-
__version__ = "0.
|
|
9
|
+
__version__ = "0.4.0"
|
|
10
10
|
|
|
11
11
|
from sbxloop.config import Budgets, Config, load_config
|
|
12
12
|
from sbxloop.engine import LoopEngine, RunResult, run_outcome
|
|
Binary file
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import queue
|
|
5
6
|
import threading
|
|
6
7
|
import time
|
|
7
8
|
from pathlib import Path
|
|
@@ -11,15 +12,17 @@ import typer
|
|
|
11
12
|
from rich.console import Console
|
|
12
13
|
from rich.live import Live
|
|
13
14
|
from rich.table import Table
|
|
15
|
+
from rich.tree import Tree
|
|
14
16
|
|
|
15
17
|
import sbxloop
|
|
16
18
|
from sbxloop.cli.doctor import run_doctor
|
|
17
|
-
from sbxloop.cli.tui import Dashboard, format_event, plain_printer
|
|
19
|
+
from sbxloop.cli.tui import Dashboard, format_event, plain_printer, render_event
|
|
18
20
|
from sbxloop.config import Config, load_config, load_config_with_sources, load_dotenv_file
|
|
19
21
|
from sbxloop.engine.engine import LoopEngine
|
|
20
|
-
from sbxloop.engine.model import RunResult
|
|
22
|
+
from sbxloop.engine.model import RunResult, artifact_files, artifacts_dir
|
|
21
23
|
from sbxloop.engine.store import StateStore
|
|
22
24
|
from sbxloop.errors import SdxloopError
|
|
25
|
+
from sbxloop.events import Event
|
|
23
26
|
from sbxloop.sbx.cli import SbxCLI
|
|
24
27
|
from sbxloop.sbx.provision import sandbox_name
|
|
25
28
|
|
|
@@ -66,13 +69,23 @@ def _store(config: Config) -> StateStore:
|
|
|
66
69
|
|
|
67
70
|
|
|
68
71
|
def _drive_with_ui(engine: LoopEngine, *, tui: bool, action: Any) -> RunResult:
|
|
69
|
-
"""Run start/resume with
|
|
72
|
+
"""Run start/resume with the scrollback transcript + pinned status, or
|
|
73
|
+
plain event logs (--no-tui).
|
|
74
|
+
|
|
75
|
+
Transcript entries print permanently to the terminal's scrollback (via
|
|
76
|
+
``live.console.print``, which renders above the live region), so the
|
|
77
|
+
full conversation history survives; only the compact status panel at
|
|
78
|
+
the bottom is redrawn in place. Events arrive on the engine thread but
|
|
79
|
+
every terminal write happens here on the main thread, via a queue —
|
|
80
|
+
ordering stays deterministic and rich's Live never interleaves.
|
|
81
|
+
"""
|
|
70
82
|
if not tui:
|
|
71
83
|
engine.bus.subscribe(plain_printer(console))
|
|
72
84
|
return action() # type: ignore[no-any-return]
|
|
73
85
|
|
|
74
86
|
dashboard = Dashboard()
|
|
75
|
-
|
|
87
|
+
pending: queue.SimpleQueue[Event] = queue.SimpleQueue()
|
|
88
|
+
engine.bus.subscribe(pending.put)
|
|
76
89
|
outcome: dict[str, Any] = {}
|
|
77
90
|
|
|
78
91
|
def target() -> None:
|
|
@@ -83,29 +96,97 @@ def _drive_with_ui(engine: LoopEngine, *, tui: bool, action: Any) -> RunResult:
|
|
|
83
96
|
|
|
84
97
|
thread = threading.Thread(target=target, daemon=True)
|
|
85
98
|
with Live(dashboard.renderable(), console=console, refresh_per_second=8) as live:
|
|
99
|
+
|
|
100
|
+
def drain() -> None:
|
|
101
|
+
while True:
|
|
102
|
+
try:
|
|
103
|
+
event = pending.get_nowait()
|
|
104
|
+
except queue.Empty:
|
|
105
|
+
return
|
|
106
|
+
dashboard.on_event(event)
|
|
107
|
+
rendered = render_event(event)
|
|
108
|
+
if rendered is not None:
|
|
109
|
+
live.console.print(rendered)
|
|
110
|
+
|
|
86
111
|
thread.start()
|
|
87
112
|
while thread.is_alive():
|
|
113
|
+
drain()
|
|
88
114
|
live.update(dashboard.renderable())
|
|
89
115
|
time.sleep(0.15)
|
|
116
|
+
drain()
|
|
90
117
|
live.update(dashboard.renderable())
|
|
91
118
|
if "error" in outcome:
|
|
92
119
|
raise outcome["error"]
|
|
93
120
|
return outcome["result"] # type: ignore[no-any-return]
|
|
94
121
|
|
|
95
122
|
|
|
96
|
-
|
|
123
|
+
_TREE_MAX_FILES = 50
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _human_size(size: int) -> str:
|
|
127
|
+
value = float(size)
|
|
128
|
+
for unit in ("B", "KB", "MB"):
|
|
129
|
+
if value < 1024 or unit == "MB":
|
|
130
|
+
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
|
131
|
+
value /= 1024
|
|
132
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _artifacts_tree(root: Path, files: list[Path], cap: int = _TREE_MAX_FILES) -> Tree:
|
|
136
|
+
tree = Tree(f"[bold]{root}[/]")
|
|
137
|
+
nodes: dict[Path, Tree] = {root: tree}
|
|
138
|
+
for path in files[:cap]:
|
|
139
|
+
rel = path.relative_to(root)
|
|
140
|
+
parent = root
|
|
141
|
+
for part in rel.parts[:-1]:
|
|
142
|
+
child = parent / part
|
|
143
|
+
if child not in nodes:
|
|
144
|
+
nodes[child] = nodes[parent].add(f"{part}/")
|
|
145
|
+
parent = child
|
|
146
|
+
nodes[parent].add(f"{rel.name} [dim]({_human_size(path.stat().st_size)})[/]")
|
|
147
|
+
if len(files) > cap:
|
|
148
|
+
tree.add(f"[dim]… +{len(files) - cap} more[/]")
|
|
149
|
+
return tree
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _print_artifacts_summary(result: RunResult, config: Config) -> None:
|
|
153
|
+
target = artifacts_dir(result, config.state_dir)
|
|
154
|
+
if target is None or not target.is_dir():
|
|
155
|
+
return
|
|
156
|
+
files = artifact_files(target)
|
|
157
|
+
if not files:
|
|
158
|
+
console.print(f"\nartifacts: none produced (workspace: {target})")
|
|
159
|
+
return
|
|
160
|
+
via = "live workspace mount" if result.mounted else "harvested from the sandbox"
|
|
161
|
+
console.print(f"\nartifacts: {len(files)} file(s), {via}")
|
|
162
|
+
console.print(_artifacts_tree(target, files))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _finish(result: RunResult, config: Config) -> None:
|
|
97
166
|
style = "green" if result.succeeded else "red"
|
|
98
167
|
console.print(f"\nrun [bold cyan]{result.run_id}[/] finished: [bold {style}]{result.state}[/]")
|
|
99
168
|
for task in result.tasks:
|
|
100
169
|
console.print(f" {task.spec.id}: {task.state} ({task.spec.title})")
|
|
170
|
+
_print_artifacts_summary(result, config)
|
|
101
171
|
raise typer.Exit(0 if result.succeeded else 1)
|
|
102
172
|
|
|
103
173
|
|
|
104
174
|
@app.command()
|
|
105
175
|
def run(
|
|
106
176
|
outcome: Annotated[str, typer.Argument(help="The outcome to achieve.")],
|
|
107
|
-
|
|
108
|
-
|
|
177
|
+
report: Annotated[
|
|
178
|
+
bool | None,
|
|
179
|
+
typer.Option(
|
|
180
|
+
"--report/--no-report",
|
|
181
|
+
help="Post run progress to the configured [github].repo.",
|
|
182
|
+
),
|
|
183
|
+
] = None,
|
|
184
|
+
deliver: Annotated[
|
|
185
|
+
bool | None,
|
|
186
|
+
typer.Option(
|
|
187
|
+
"--deliver/--no-deliver",
|
|
188
|
+
help="Publish the completed run's artifacts as a PR to the configured [github].repo.",
|
|
189
|
+
),
|
|
109
190
|
] = None,
|
|
110
191
|
model: Annotated[str | None, typer.Option("--model", help="Copilot model id.")] = None,
|
|
111
192
|
keep_sandboxes: Annotated[
|
|
@@ -115,17 +196,36 @@ def run(
|
|
|
115
196
|
) -> None:
|
|
116
197
|
"""Run an agentic loop for OUTCOME in a fresh sandbox pair."""
|
|
117
198
|
config = _config_with_overrides(model=model, keep_sandboxes=keep_sandboxes or None)
|
|
118
|
-
if
|
|
199
|
+
if report is not None:
|
|
200
|
+
config = config.model_copy(
|
|
201
|
+
update={"github": config.github.model_copy(update={"report": report})}
|
|
202
|
+
)
|
|
203
|
+
if deliver is not None:
|
|
119
204
|
config = config.model_copy(
|
|
120
|
-
update={"github": config.github.model_copy(update={"
|
|
205
|
+
update={"github": config.github.model_copy(update={"deliver": deliver})}
|
|
121
206
|
)
|
|
207
|
+
wanted = [
|
|
208
|
+
feature
|
|
209
|
+
for feature, enabled in (
|
|
210
|
+
("progress reporting (--report)", config.github.report),
|
|
211
|
+
("PR delivery (--deliver)", config.github.deliver),
|
|
212
|
+
)
|
|
213
|
+
if enabled
|
|
214
|
+
]
|
|
215
|
+
if wanted and not config.github.enabled:
|
|
216
|
+
console.print(
|
|
217
|
+
f"[bold red]GitHub integration is not configured.[/] {', '.join(wanted)} "
|
|
218
|
+
'needs a repository: set [cyan]\\[github] repo = "owner/repo"[/] in '
|
|
219
|
+
"sbxloop.toml (see `sbxloop init`), then re-run."
|
|
220
|
+
)
|
|
221
|
+
raise typer.Exit(2)
|
|
122
222
|
engine = LoopEngine(config)
|
|
123
223
|
try:
|
|
124
224
|
result = _drive_with_ui(engine, tui=tui, action=lambda: engine.start(outcome))
|
|
125
225
|
except SdxloopError as exc:
|
|
126
226
|
console.print(f"[bold red]run failed:[/] {exc}")
|
|
127
227
|
raise typer.Exit(2) from exc
|
|
128
|
-
_finish(result)
|
|
228
|
+
_finish(result, config)
|
|
129
229
|
|
|
130
230
|
|
|
131
231
|
@app.command()
|
|
@@ -134,13 +234,14 @@ def resume(
|
|
|
134
234
|
tui: Annotated[bool, typer.Option("--tui/--no-tui")] = True,
|
|
135
235
|
) -> None:
|
|
136
236
|
"""Resume an unfinished run (fresh sandboxes, persisted state)."""
|
|
137
|
-
|
|
237
|
+
config = load_config()
|
|
238
|
+
engine = LoopEngine(config)
|
|
138
239
|
try:
|
|
139
240
|
result = _drive_with_ui(engine, tui=tui, action=lambda: engine.resume(run_id))
|
|
140
241
|
except SdxloopError as exc:
|
|
141
242
|
console.print(f"[bold red]resume failed:[/] {exc}")
|
|
142
243
|
raise typer.Exit(2) from exc
|
|
143
|
-
_finish(result)
|
|
244
|
+
_finish(result, config)
|
|
144
245
|
|
|
145
246
|
|
|
146
247
|
@app.command()
|
|
@@ -229,6 +330,49 @@ def logs(
|
|
|
229
330
|
time.sleep(0.5)
|
|
230
331
|
|
|
231
332
|
|
|
333
|
+
@app.command()
|
|
334
|
+
def artifacts(
|
|
335
|
+
run_id: Annotated[str, typer.Argument(help="Run id.")],
|
|
336
|
+
path: Annotated[
|
|
337
|
+
bool, typer.Option("--path", help="Print only the artifacts directory (for scripting).")
|
|
338
|
+
] = False,
|
|
339
|
+
tree: Annotated[
|
|
340
|
+
bool, typer.Option("--tree/--list", help="Render a file tree instead of a flat list.")
|
|
341
|
+
] = False,
|
|
342
|
+
) -> None:
|
|
343
|
+
"""Show where a run's artifacts live on the host, and what is in there."""
|
|
344
|
+
config = load_config()
|
|
345
|
+
store = _store(config)
|
|
346
|
+
try:
|
|
347
|
+
record = store.get_run(run_id)
|
|
348
|
+
except SdxloopError as exc:
|
|
349
|
+
console.print(f"[bold red]{exc}[/]")
|
|
350
|
+
raise typer.Exit(2) from exc
|
|
351
|
+
target = artifacts_dir(record, config.state_dir)
|
|
352
|
+
if target is None:
|
|
353
|
+
console.print(
|
|
354
|
+
f"[bold red]run {run_id} has no artifacts:[/] it never provisioned a workspace "
|
|
355
|
+
f"(state: {record.state})"
|
|
356
|
+
)
|
|
357
|
+
raise typer.Exit(2)
|
|
358
|
+
if path:
|
|
359
|
+
# bare path on stdout, nothing else — `cd $(sbxloop artifacts R --path)`
|
|
360
|
+
typer.echo(str(target))
|
|
361
|
+
return
|
|
362
|
+
if not target.is_dir():
|
|
363
|
+
console.print(f"[bold red]artifacts directory is gone:[/] {target}")
|
|
364
|
+
raise typer.Exit(2)
|
|
365
|
+
files = artifact_files(target)
|
|
366
|
+
via = "live workspace mount" if record.mounted else "harvested copy"
|
|
367
|
+
console.print(f"run [bold cyan]{run_id}[/]: {len(files)} file(s) ({via}) in [bold]{target}[/]")
|
|
368
|
+
if tree:
|
|
369
|
+
console.print(_artifacts_tree(target, files))
|
|
370
|
+
return
|
|
371
|
+
for file in files:
|
|
372
|
+
size = _human_size(file.stat().st_size)
|
|
373
|
+
console.print(f" {file.relative_to(target)} [dim]{size}[/]")
|
|
374
|
+
|
|
375
|
+
|
|
232
376
|
@sandbox_app.command("ls")
|
|
233
377
|
def sandbox_ls() -> None:
|
|
234
378
|
"""List sbxloop-managed sandboxes."""
|
|
@@ -345,8 +489,18 @@ secret_strategy = "proxy"
|
|
|
345
489
|
extra_allow_domains = []
|
|
346
490
|
|
|
347
491
|
[github]
|
|
348
|
-
#
|
|
349
|
-
#
|
|
492
|
+
# The GitHub integration. Unset (the default) disables GitHub entirely:
|
|
493
|
+
# no github sandbox is provisioned, GH_TOKEN is not required, and
|
|
494
|
+
# repo-facing features refuse to run. Set `repo` to the ONE repository
|
|
495
|
+
# sbxloop may work with; the toggles below act on it.
|
|
496
|
+
# repo = "you/your-repo"
|
|
497
|
+
# Post run progress (issues/comments) to the configured repo.
|
|
498
|
+
# report = false
|
|
499
|
+
# Open a PR with the run's artifacts when a run completes (or `--deliver`).
|
|
500
|
+
# GH_TOKEN needs contents:write + pull_requests:write on the repo.
|
|
501
|
+
# deliver = false
|
|
502
|
+
# deliver_base = "main" # base branch; unset uses the repo's default
|
|
503
|
+
# deliver_draft = false
|
|
350
504
|
|
|
351
505
|
[budgets]
|
|
352
506
|
max_revisions_per_task = 2
|
|
@@ -109,17 +109,31 @@ def collect_checks(
|
|
|
109
109
|
f"permission and export {COPILOT_TOKEN_ENV}",
|
|
110
110
|
)
|
|
111
111
|
)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
112
|
+
# GH_TOKEN matters only when the GitHub integration is configured; an
|
|
113
|
+
# unconfigured integration is a valid (GitHub-less) setup, not a failure.
|
|
114
|
+
if config.github.enabled:
|
|
115
|
+
gh_set = any(env.get(name) for name in GH_TOKEN_ENVS)
|
|
116
|
+
checks.append(
|
|
117
|
+
Check(
|
|
118
|
+
"/".join(GH_TOKEN_ENVS),
|
|
119
|
+
gh_set,
|
|
120
|
+
f"set (github integration: {config.github.repo})"
|
|
121
|
+
if gh_set
|
|
122
|
+
else f"not set but [github].repo = {config.github.repo!r} is configured — "
|
|
123
|
+
"create a fine-grained PAT (issues:write, contents:read, ...) "
|
|
124
|
+
"and export GH_TOKEN",
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
checks.append(
|
|
129
|
+
Check(
|
|
130
|
+
"github integration",
|
|
131
|
+
True,
|
|
132
|
+
'not configured — GitHub features disabled (set [github] repo = "owner/repo" '
|
|
133
|
+
"in sbxloop.toml to enable)",
|
|
134
|
+
hard=False,
|
|
135
|
+
)
|
|
121
136
|
)
|
|
122
|
-
)
|
|
123
137
|
|
|
124
138
|
# worker wheel
|
|
125
139
|
wheel = resolve_worker_wheel()
|
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
"""Rich rendering for runs: a
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
"""Rich rendering for runs: a permanent scrollback transcript plus a small
|
|
2
|
+
pinned status region.
|
|
3
|
+
|
|
4
|
+
Agent output is conversational (markdown, fenced code blocks), so the
|
|
5
|
+
transcript renders it as a chat thread — markdown panels with
|
|
6
|
+
syntax-highlighted code — rather than truncated log lines. Transcript
|
|
7
|
+
entries are printed to the terminal's normal scrollback as they happen and
|
|
8
|
+
are never rewritten or dropped: the full history of a run stays scrollable.
|
|
9
|
+
Only the compact status region (run state + task table) is live-updated in
|
|
10
|
+
place at the bottom. Host lifecycle events stay compact one-liners.
|
|
11
|
+
``format_event`` remains the dense single-line form used by
|
|
7
12
|
``sbxloop logs``.
|
|
8
13
|
"""
|
|
9
14
|
|
|
10
15
|
from __future__ import annotations
|
|
11
16
|
|
|
12
17
|
import datetime
|
|
13
|
-
from collections import deque
|
|
14
18
|
from typing import Any
|
|
15
19
|
|
|
16
20
|
from rich.console import Console, Group, RenderableType
|
|
@@ -50,12 +54,24 @@ _TRANSCRIPT_SKIP = {
|
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
AGENT_MESSAGE_CLIP = 4000
|
|
57
|
+
# One-line clip for tool arguments; output tail lines shown on tool failure.
|
|
58
|
+
TOOL_ARGS_LINE_CLIP = 160
|
|
59
|
+
TOOL_FAIL_TAIL_LINES = 6
|
|
53
60
|
|
|
54
61
|
|
|
55
62
|
def _stamp(event: Event) -> str:
|
|
56
63
|
return datetime.datetime.fromtimestamp(event.ts).strftime("%H:%M:%S")
|
|
57
64
|
|
|
58
65
|
|
|
66
|
+
def _one_line(text: str, limit: int = TOOL_ARGS_LINE_CLIP) -> str:
|
|
67
|
+
"""Collapse to a single display line, eliding the middle beyond limit."""
|
|
68
|
+
flat = " ".join(text.split())
|
|
69
|
+
if len(flat) <= limit:
|
|
70
|
+
return flat
|
|
71
|
+
keep = (limit - 1) // 2
|
|
72
|
+
return f"{flat[:keep]}…{flat[-keep:]}"
|
|
73
|
+
|
|
74
|
+
|
|
59
75
|
def render_event(event: Event) -> RenderableType | None:
|
|
60
76
|
"""One transcript entry for an event, or None when it should be skipped."""
|
|
61
77
|
if event.type in _TRANSCRIPT_SKIP:
|
|
@@ -88,10 +104,36 @@ def render_event(event: Event) -> RenderableType | None:
|
|
|
88
104
|
|
|
89
105
|
if event.type == EventTypes.AGENT_TOOL_START:
|
|
90
106
|
tool = data.get("tool") or "tool"
|
|
91
|
-
|
|
107
|
+
start = Text(f"{_stamp(event)} ⚙ {tool}", style="yellow")
|
|
108
|
+
args = _one_line(str(data.get("args") or ""))
|
|
109
|
+
if args:
|
|
110
|
+
start.append(" $ ", style="bold yellow")
|
|
111
|
+
start.append(args, style="yellow dim")
|
|
112
|
+
start.overflow = "fold"
|
|
113
|
+
return start
|
|
92
114
|
|
|
93
115
|
if event.type == EventTypes.AGENT_TOOL_END:
|
|
94
|
-
|
|
116
|
+
tool = data.get("tool") or "tool"
|
|
117
|
+
success = data.get("success")
|
|
118
|
+
exit_code = data.get("exit_code")
|
|
119
|
+
if success is None and exit_code is None:
|
|
120
|
+
return None # nothing informative beyond the start line
|
|
121
|
+
if success or (success is None and exit_code == 0):
|
|
122
|
+
suffix = " exit 0" if exit_code == 0 else ""
|
|
123
|
+
return Text(f"{_stamp(event)} ✓ {tool}{suffix}", style="green dim")
|
|
124
|
+
suffix = f" exit {exit_code}" if exit_code is not None else ""
|
|
125
|
+
failure = Text(f"{_stamp(event)} ✗ {tool}{suffix}", style="red")
|
|
126
|
+
tail = str(data.get("output") or "").strip()
|
|
127
|
+
if tail:
|
|
128
|
+
return Group(
|
|
129
|
+
failure,
|
|
130
|
+
Text(
|
|
131
|
+
"\n".join(tail.splitlines()[-TOOL_FAIL_TAIL_LINES:]),
|
|
132
|
+
style="red dim",
|
|
133
|
+
overflow="fold",
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
return failure
|
|
95
137
|
|
|
96
138
|
if event.type.startswith(_LIFECYCLE_PREFIXES):
|
|
97
139
|
line = format_event(event)
|
|
@@ -106,14 +148,16 @@ def render_event(event: Event) -> RenderableType | None:
|
|
|
106
148
|
|
|
107
149
|
|
|
108
150
|
class Dashboard:
|
|
109
|
-
"""Accumulates run
|
|
151
|
+
"""Accumulates run status from the event stream and renders the pinned
|
|
152
|
+
region. Transcript entries are NOT kept here — the drive loop prints
|
|
153
|
+
them straight to scrollback via ``render_event`` so history is never
|
|
154
|
+
truncated or rewritten."""
|
|
110
155
|
|
|
111
|
-
def __init__(self
|
|
156
|
+
def __init__(self) -> None:
|
|
112
157
|
self.run_id: str | None = None
|
|
113
158
|
self.run_state: str = "starting"
|
|
114
159
|
self.outcome: str = ""
|
|
115
160
|
self.tasks: dict[str, dict[str, Any]] = {}
|
|
116
|
-
self.transcript: deque[RenderableType] = deque(maxlen=max_entries)
|
|
117
161
|
|
|
118
162
|
# -- event intake ------------------------------------------------------
|
|
119
163
|
|
|
@@ -139,9 +183,6 @@ class Dashboard:
|
|
|
139
183
|
if data.get("title"):
|
|
140
184
|
entry["title"] = str(data["title"])
|
|
141
185
|
entry["state"] = str(data.get("state", entry["state"]))
|
|
142
|
-
rendered = render_event(event)
|
|
143
|
-
if rendered is not None:
|
|
144
|
-
self.transcript.append(rendered)
|
|
145
186
|
|
|
146
187
|
# -- rendering ---------------------------------------------------------
|
|
147
188
|
|
|
@@ -170,15 +211,10 @@ class Dashboard:
|
|
|
170
211
|
if not self.tasks:
|
|
171
212
|
table.add_row("…", "decomposing outcome", Text("pending", style="dim"), "")
|
|
172
213
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
transcript = Text("waiting for events…", style="dim")
|
|
178
|
-
return Group(
|
|
179
|
-
Panel(Group(header, outcome), border_style="cyan"),
|
|
180
|
-
table,
|
|
181
|
-
transcript,
|
|
214
|
+
return Panel(
|
|
215
|
+
Group(header, outcome, table),
|
|
216
|
+
border_style="cyan",
|
|
217
|
+
padding=(0, 1),
|
|
182
218
|
)
|
|
183
219
|
|
|
184
220
|
|
|
@@ -187,11 +223,14 @@ def format_event(event: Event) -> str:
|
|
|
187
223
|
parts = [_stamp(event), event.type]
|
|
188
224
|
if event.data.get("task_id"):
|
|
189
225
|
parts.append(f"[{event.data['task_id']}]")
|
|
190
|
-
|
|
226
|
+
keys = ("state", "content", "tool", "op", "line", "message", "outcome", "error", "url", "path")
|
|
227
|
+
for key in keys:
|
|
191
228
|
if event.data.get(key):
|
|
192
229
|
value = str(event.data[key]).replace("\n", " ")
|
|
193
230
|
parts.append(value[:160])
|
|
194
231
|
break
|
|
232
|
+
if event.data.get("args"):
|
|
233
|
+
parts.append(_one_line(str(event.data["args"]), 120))
|
|
195
234
|
return " ".join(parts)
|
|
196
235
|
|
|
197
236
|
|
|
@@ -12,12 +12,13 @@ strings.
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import os
|
|
15
|
+
import re
|
|
15
16
|
import tomllib
|
|
16
17
|
from collections.abc import Mapping
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import Any, Literal
|
|
19
20
|
|
|
20
|
-
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
21
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
|
21
22
|
|
|
22
23
|
from sbxloop.errors import ConfigError
|
|
23
24
|
|
|
@@ -42,7 +43,29 @@ class SandboxConfig(_ConfigModel):
|
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
class GithubConfig(_ConfigModel):
|
|
45
|
-
|
|
46
|
+
"""The GitHub integration. ``repo`` is the gate: unset (the default)
|
|
47
|
+
disables GitHub entirely — no github sandbox is provisioned, no GH_TOKEN
|
|
48
|
+
is required, and repo-facing features (progress reporting, delivery)
|
|
49
|
+
refuse to run. Setting it makes ``repo`` the one repository sbxloop is
|
|
50
|
+
allowed to work with; behavior toggles like ``report`` act on it."""
|
|
51
|
+
|
|
52
|
+
repo: str | None = None
|
|
53
|
+
report: bool = False
|
|
54
|
+
# Publish a completed run's artifacts as a PR to `repo`.
|
|
55
|
+
deliver: bool = False
|
|
56
|
+
deliver_base: str | None = None # base branch; None → the repo's default
|
|
57
|
+
deliver_draft: bool = False
|
|
58
|
+
|
|
59
|
+
@field_validator("repo")
|
|
60
|
+
@classmethod
|
|
61
|
+
def _check_repo(cls, value: str | None) -> str | None:
|
|
62
|
+
if value is not None and not re.fullmatch(r"[\w.-]+/[\w.-]+", value):
|
|
63
|
+
raise ValueError(f"github.repo must be owner/name, got {value!r}")
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def enabled(self) -> bool:
|
|
68
|
+
return self.repo is not None
|
|
46
69
|
|
|
47
70
|
|
|
48
71
|
class Budgets(_ConfigModel):
|