sbxloop 0.3.0__tar.gz → 0.4.1__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.3.0 → sbxloop-0.4.1}/.gitignore +4 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/PKG-INFO +2 -2
- {sbxloop-0.3.0 → sbxloop-0.4.1}/hatch_build.py +6 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/pyproject.toml +18 -3
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/__init__.py +10 -1
- sbxloop-0.3.0/src/sbxloop/_vendor/sbxloop_worker-0.3.0-py3-none-any.whl → sbxloop-0.4.1/src/sbxloop/_vendor/sbxloop_worker-0.4.1-py3-none-any.whl +0 -0
- sbxloop-0.4.1/src/sbxloop/_version.py +24 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/cli/app.py +78 -22
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/cli/doctor.py +24 -10
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/cli/tui.py +64 -25
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/config.py +25 -2
- sbxloop-0.4.1/src/sbxloop/deliver.py +144 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/engine.py +65 -11
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/model.py +10 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/errors.py +4 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/events.py +1 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/gh/reporter.py +3 -2
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/pair.py +9 -4
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/provision.py +14 -7
- {sbxloop-0.3.0 → sbxloop-0.4.1}/README.md +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/cli/__init__.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/__init__.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/phases.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/prompts/__init__.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/prompts/decompose.md +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/prompts/execute.md +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/prompts/plan.md +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/prompts/validate.md +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/engine/store.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/gh/__init__.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/gh/ops.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/ids.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/py.typed +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/__init__.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/cli.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/models.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/parse.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/sbx/sandbox.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/worker/__init__.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/src/sbxloop/worker/client.py +0 -0
- {sbxloop-0.3.0 → sbxloop-0.4.1}/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.1
|
|
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
|
|
22
|
+
Requires-Dist: sbxloop-worker
|
|
23
23
|
Requires-Dist: typer>=0.12
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
|
|
@@ -29,6 +29,12 @@ class VendorWorkerWheelHook(BuildHookInterface): # type: ignore[type-arg]
|
|
|
29
29
|
host_version = self.metadata.version
|
|
30
30
|
expected = f"sbxloop_worker-{host_version}-py3-none-any.whl"
|
|
31
31
|
|
|
32
|
+
# The static dependency list can't carry an exact pin now that versions
|
|
33
|
+
# come from git tags (hatch-vcs), so add the lockstep pin to the wheel
|
|
34
|
+
# metadata here.
|
|
35
|
+
if self.target_name == "wheel":
|
|
36
|
+
build_data.setdefault("dependencies", []).append(f"sbxloop-worker=={host_version}")
|
|
37
|
+
|
|
32
38
|
if (vendor_dir / expected).is_file():
|
|
33
39
|
return
|
|
34
40
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sbxloop"
|
|
3
|
-
|
|
3
|
+
dynamic = ["version"] # derived from git tags by hatch-vcs (see [tool.hatch.version])
|
|
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,10 @@ dependencies = [
|
|
|
21
21
|
"python-dotenv>=1.0",
|
|
22
22
|
"typer>=0.12",
|
|
23
23
|
"rich>=13.7",
|
|
24
|
-
|
|
24
|
+
# Unpinned here because the version is dynamic; the build hook
|
|
25
|
+
# (hatch_build.py) adds the exact lockstep pin `sbxloop-worker==X.Y.Z`
|
|
26
|
+
# to the wheel metadata at build time.
|
|
27
|
+
"sbxloop-worker",
|
|
25
28
|
]
|
|
26
29
|
|
|
27
30
|
[project.scripts]
|
|
@@ -36,9 +39,21 @@ Issues = "https://github.com/brettbergin/sbxloop/issues"
|
|
|
36
39
|
sbxloop-worker = { workspace = true }
|
|
37
40
|
|
|
38
41
|
[build-system]
|
|
39
|
-
requires = ["hatchling>=1.27"]
|
|
42
|
+
requires = ["hatchling>=1.27", "hatch-vcs"]
|
|
40
43
|
build-backend = "hatchling.build"
|
|
41
44
|
|
|
45
|
+
# Both workspace packages version from the same repo-root git tag, keeping the
|
|
46
|
+
# lockstep invariant by construction. `fallback_root` lets a wheel built from
|
|
47
|
+
# an extracted sdist (no git) pick the version up from the sdist's PKG-INFO.
|
|
48
|
+
[tool.hatch.version]
|
|
49
|
+
source = "vcs"
|
|
50
|
+
raw-options = { root = "../..", fallback_root = ".", local_scheme = "no-local-version", fallback_version = "0.0.0" }
|
|
51
|
+
|
|
52
|
+
# Write the resolved version into the package so `sbxloop.__version__` and
|
|
53
|
+
# `sbxloop --version` report it at runtime.
|
|
54
|
+
[tool.hatch.build.hooks.vcs]
|
|
55
|
+
version-file = "src/sbxloop/_version.py"
|
|
56
|
+
|
|
42
57
|
[tool.hatch.build.targets.wheel]
|
|
43
58
|
packages = ["src/sbxloop"]
|
|
44
59
|
artifacts = ["src/sbxloop/_vendor/*.whl"]
|
|
@@ -6,7 +6,16 @@ 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
|
-
|
|
9
|
+
try:
|
|
10
|
+
# written at build time by hatch-vcs (see pyproject [tool.hatch.build.hooks.vcs])
|
|
11
|
+
from sbxloop._version import __version__
|
|
12
|
+
except ImportError: # pragma: no cover - raw source tree that was never built
|
|
13
|
+
try:
|
|
14
|
+
from importlib.metadata import version as _pkg_version
|
|
15
|
+
|
|
16
|
+
__version__ = _pkg_version("sbxloop")
|
|
17
|
+
except Exception:
|
|
18
|
+
__version__ = "0.0.0"
|
|
10
19
|
|
|
11
20
|
from sbxloop.config import Budgets, Config, load_config
|
|
12
21
|
from sbxloop.engine import LoopEngine, RunResult, run_outcome
|
|
Binary file
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.4.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -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
|
|
@@ -15,12 +16,13 @@ from rich.tree import Tree
|
|
|
15
16
|
|
|
16
17
|
import sbxloop
|
|
17
18
|
from sbxloop.cli.doctor import run_doctor
|
|
18
|
-
from sbxloop.cli.tui import Dashboard, format_event, plain_printer
|
|
19
|
+
from sbxloop.cli.tui import Dashboard, format_event, plain_printer, render_event
|
|
19
20
|
from sbxloop.config import Config, load_config, load_config_with_sources, load_dotenv_file
|
|
20
21
|
from sbxloop.engine.engine import LoopEngine
|
|
21
|
-
from sbxloop.engine.model import RunResult, artifacts_dir
|
|
22
|
+
from sbxloop.engine.model import RunResult, artifact_files, artifacts_dir
|
|
22
23
|
from sbxloop.engine.store import StateStore
|
|
23
24
|
from sbxloop.errors import SdxloopError
|
|
25
|
+
from sbxloop.events import Event
|
|
24
26
|
from sbxloop.sbx.cli import SbxCLI
|
|
25
27
|
from sbxloop.sbx.provision import sandbox_name
|
|
26
28
|
|
|
@@ -67,13 +69,23 @@ def _store(config: Config) -> StateStore:
|
|
|
67
69
|
|
|
68
70
|
|
|
69
71
|
def _drive_with_ui(engine: LoopEngine, *, tui: bool, action: Any) -> RunResult:
|
|
70
|
-
"""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
|
+
"""
|
|
71
82
|
if not tui:
|
|
72
83
|
engine.bus.subscribe(plain_printer(console))
|
|
73
84
|
return action() # type: ignore[no-any-return]
|
|
74
85
|
|
|
75
86
|
dashboard = Dashboard()
|
|
76
|
-
|
|
87
|
+
pending: queue.SimpleQueue[Event] = queue.SimpleQueue()
|
|
88
|
+
engine.bus.subscribe(pending.put)
|
|
77
89
|
outcome: dict[str, Any] = {}
|
|
78
90
|
|
|
79
91
|
def target() -> None:
|
|
@@ -84,10 +96,24 @@ def _drive_with_ui(engine: LoopEngine, *, tui: bool, action: Any) -> RunResult:
|
|
|
84
96
|
|
|
85
97
|
thread = threading.Thread(target=target, daemon=True)
|
|
86
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
|
+
|
|
87
111
|
thread.start()
|
|
88
112
|
while thread.is_alive():
|
|
113
|
+
drain()
|
|
89
114
|
live.update(dashboard.renderable())
|
|
90
115
|
time.sleep(0.15)
|
|
116
|
+
drain()
|
|
91
117
|
live.update(dashboard.renderable())
|
|
92
118
|
if "error" in outcome:
|
|
93
119
|
raise outcome["error"]
|
|
@@ -106,16 +132,6 @@ def _human_size(size: int) -> str:
|
|
|
106
132
|
raise AssertionError("unreachable") # pragma: no cover
|
|
107
133
|
|
|
108
134
|
|
|
109
|
-
def _artifact_files(root: Path) -> list[Path]:
|
|
110
|
-
"""Regular files under root, hidden files/dirs excluded (an agent's .git
|
|
111
|
-
would otherwise swamp the listing), sorted for stable output."""
|
|
112
|
-
return sorted(
|
|
113
|
-
p
|
|
114
|
-
for p in root.rglob("*")
|
|
115
|
-
if p.is_file() and not any(part.startswith(".") for part in p.relative_to(root).parts)
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
|
|
119
135
|
def _artifacts_tree(root: Path, files: list[Path], cap: int = _TREE_MAX_FILES) -> Tree:
|
|
120
136
|
tree = Tree(f"[bold]{root}[/]")
|
|
121
137
|
nodes: dict[Path, Tree] = {root: tree}
|
|
@@ -137,7 +153,7 @@ def _print_artifacts_summary(result: RunResult, config: Config) -> None:
|
|
|
137
153
|
target = artifacts_dir(result, config.state_dir)
|
|
138
154
|
if target is None or not target.is_dir():
|
|
139
155
|
return
|
|
140
|
-
files =
|
|
156
|
+
files = artifact_files(target)
|
|
141
157
|
if not files:
|
|
142
158
|
console.print(f"\nartifacts: none produced (workspace: {target})")
|
|
143
159
|
return
|
|
@@ -158,8 +174,19 @@ def _finish(result: RunResult, config: Config) -> None:
|
|
|
158
174
|
@app.command()
|
|
159
175
|
def run(
|
|
160
176
|
outcome: Annotated[str, typer.Argument(help="The outcome to achieve.")],
|
|
161
|
-
|
|
162
|
-
|
|
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
|
+
),
|
|
163
190
|
] = None,
|
|
164
191
|
model: Annotated[str | None, typer.Option("--model", help="Copilot model id.")] = None,
|
|
165
192
|
keep_sandboxes: Annotated[
|
|
@@ -169,10 +196,29 @@ def run(
|
|
|
169
196
|
) -> None:
|
|
170
197
|
"""Run an agentic loop for OUTCOME in a fresh sandbox pair."""
|
|
171
198
|
config = _config_with_overrides(model=model, keep_sandboxes=keep_sandboxes or None)
|
|
172
|
-
if
|
|
199
|
+
if report is not None:
|
|
173
200
|
config = config.model_copy(
|
|
174
|
-
update={"github": config.github.model_copy(update={"
|
|
201
|
+
update={"github": config.github.model_copy(update={"report": report})}
|
|
175
202
|
)
|
|
203
|
+
if deliver is not None:
|
|
204
|
+
config = config.model_copy(
|
|
205
|
+
update={"github": config.github.model_copy(update={"deliver": deliver})}
|
|
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)
|
|
176
222
|
engine = LoopEngine(config)
|
|
177
223
|
try:
|
|
178
224
|
result = _drive_with_ui(engine, tui=tui, action=lambda: engine.start(outcome))
|
|
@@ -316,7 +362,7 @@ def artifacts(
|
|
|
316
362
|
if not target.is_dir():
|
|
317
363
|
console.print(f"[bold red]artifacts directory is gone:[/] {target}")
|
|
318
364
|
raise typer.Exit(2)
|
|
319
|
-
files =
|
|
365
|
+
files = artifact_files(target)
|
|
320
366
|
via = "live workspace mount" if record.mounted else "harvested copy"
|
|
321
367
|
console.print(f"run [bold cyan]{run_id}[/]: {len(files)} file(s) ({via}) in [bold]{target}[/]")
|
|
322
368
|
if tree:
|
|
@@ -443,8 +489,18 @@ secret_strategy = "proxy"
|
|
|
443
489
|
extra_allow_domains = []
|
|
444
490
|
|
|
445
491
|
[github]
|
|
446
|
-
#
|
|
447
|
-
#
|
|
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
|
|
448
504
|
|
|
449
505
|
[budgets]
|
|
450
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):
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Deliver a completed run's artifacts as a GitHub pull request.
|
|
2
|
+
|
|
3
|
+
Everything goes through the :class:`GithubOps` facade, i.e. runs as
|
|
4
|
+
``github.op`` jobs inside the github-ops sandbox — the only environment
|
|
5
|
+
holding ``GH_TOKEN``; the credential split is preserved. Files are committed
|
|
6
|
+
atomically through the git data API (blobs → tree → commit → ref) rather
|
|
7
|
+
than per-file contents PUTs: one commit regardless of file count, and
|
|
8
|
+
base64 blobs carry binary content.
|
|
9
|
+
|
|
10
|
+
Scaffold status: this is a real, unit-tested code path (stubbed GithubOps),
|
|
11
|
+
but per the project pattern — unverified external behaviors get a seam and
|
|
12
|
+
an e2e check, never a confident default — it is NOT field-proven until the
|
|
13
|
+
real-sbx e2e workflow exercises it. Known e2e-validation items:
|
|
14
|
+
|
|
15
|
+
- TODO(e2e): branch-name collisions (re-delivering the same run id — the
|
|
16
|
+
refs POST will 422; decide between force-update and suffixing)
|
|
17
|
+
- TODO(e2e): large workspaces (one blob POST per file; may need chunking
|
|
18
|
+
or a tarball-artifact fallback beyond a few hundred files)
|
|
19
|
+
- TODO(e2e): executable permission bits (every file is committed 100644)
|
|
20
|
+
- TODO(e2e): empty repositories (no base ref to branch from)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import base64
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from sbxloop.engine.model import artifact_files
|
|
30
|
+
from sbxloop.errors import DeliveryError
|
|
31
|
+
from sbxloop.gh.ops import GithubOps, PrRef
|
|
32
|
+
|
|
33
|
+
FILE_MODE = "100644"
|
|
34
|
+
BODY_FILE_LIST_CAP = 50
|
|
35
|
+
TITLE_CLIP = 72
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def branch_name(run_id: str) -> str:
|
|
39
|
+
return f"sbxloop/{run_id}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def deliver_workspace(
|
|
43
|
+
ops: GithubOps,
|
|
44
|
+
repo: str,
|
|
45
|
+
*,
|
|
46
|
+
run_id: str,
|
|
47
|
+
outcome: str,
|
|
48
|
+
source_dir: Path,
|
|
49
|
+
base: str | None = None,
|
|
50
|
+
draft: bool = False,
|
|
51
|
+
) -> PrRef:
|
|
52
|
+
"""Publish source_dir as one commit on a new branch and open a PR."""
|
|
53
|
+
files = artifact_files(source_dir)
|
|
54
|
+
if not files:
|
|
55
|
+
raise DeliveryError(f"nothing to deliver: no files in {source_dir}")
|
|
56
|
+
|
|
57
|
+
if base is None:
|
|
58
|
+
base = str(ops.repo_get(repo).get("default_branch") or "main")
|
|
59
|
+
base_sha = _base_commit_sha(ops, repo, base)
|
|
60
|
+
base_tree = _commit_tree_sha(ops, repo, base_sha)
|
|
61
|
+
|
|
62
|
+
entries = [
|
|
63
|
+
{
|
|
64
|
+
"path": file.relative_to(source_dir).as_posix(),
|
|
65
|
+
"mode": FILE_MODE,
|
|
66
|
+
"type": "blob",
|
|
67
|
+
"sha": _create_blob(ops, repo, file),
|
|
68
|
+
}
|
|
69
|
+
for file in files
|
|
70
|
+
]
|
|
71
|
+
tree = _sha(
|
|
72
|
+
ops.raw("POST", f"/repos/{repo}/git/trees", {"base_tree": base_tree, "tree": entries}),
|
|
73
|
+
f"tree for {repo}",
|
|
74
|
+
)
|
|
75
|
+
commit = _sha(
|
|
76
|
+
ops.raw(
|
|
77
|
+
"POST",
|
|
78
|
+
f"/repos/{repo}/git/commits",
|
|
79
|
+
{
|
|
80
|
+
"message": f"sbxloop run {run_id}: deliver artifacts\n\nOutcome: {outcome}",
|
|
81
|
+
"tree": tree,
|
|
82
|
+
"parents": [base_sha],
|
|
83
|
+
},
|
|
84
|
+
),
|
|
85
|
+
f"commit for {repo}",
|
|
86
|
+
)
|
|
87
|
+
branch = branch_name(run_id)
|
|
88
|
+
ops.raw("POST", f"/repos/{repo}/git/refs", {"ref": f"refs/heads/{branch}", "sha": commit})
|
|
89
|
+
|
|
90
|
+
return ops.pr_create(
|
|
91
|
+
repo,
|
|
92
|
+
base=base,
|
|
93
|
+
head=branch,
|
|
94
|
+
title=_title(outcome),
|
|
95
|
+
body=_body(run_id, outcome, source_dir, files),
|
|
96
|
+
draft=draft,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _base_commit_sha(ops: GithubOps, repo: str, base: str) -> str:
|
|
101
|
+
ref = ops.raw("GET", f"/repos/{repo}/git/ref/heads/{base}")
|
|
102
|
+
try:
|
|
103
|
+
return str(ref["object"]["sha"])
|
|
104
|
+
except (TypeError, KeyError) as exc:
|
|
105
|
+
raise DeliveryError(f"cannot resolve base branch {base!r} of {repo}") from exc
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _commit_tree_sha(ops: GithubOps, repo: str, commit_sha: str) -> str:
|
|
109
|
+
commit = ops.raw("GET", f"/repos/{repo}/git/commits/{commit_sha}")
|
|
110
|
+
try:
|
|
111
|
+
return str(commit["tree"]["sha"])
|
|
112
|
+
except (TypeError, KeyError) as exc:
|
|
113
|
+
raise DeliveryError(f"cannot read base commit {commit_sha} of {repo}") from exc
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _create_blob(ops: GithubOps, repo: str, file: Path) -> str:
|
|
117
|
+
content = base64.b64encode(file.read_bytes()).decode("ascii")
|
|
118
|
+
return _sha(
|
|
119
|
+
ops.raw("POST", f"/repos/{repo}/git/blobs", {"content": content, "encoding": "base64"}),
|
|
120
|
+
f"blob for {file.name}",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _sha(response: Any, what: str) -> str:
|
|
125
|
+
sha = response.get("sha") if isinstance(response, dict) else None
|
|
126
|
+
if not sha:
|
|
127
|
+
raise DeliveryError(f"GitHub returned no sha creating {what}: {response!r}")
|
|
128
|
+
return str(sha)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _title(outcome: str) -> str:
|
|
132
|
+
title = f"sbxloop: {outcome}"
|
|
133
|
+
return title if len(title) <= TITLE_CLIP else title[: TITLE_CLIP - 1] + "…"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _body(run_id: str, outcome: str, source_dir: Path, files: list[Path]) -> str:
|
|
137
|
+
listed = [f"- `{f.relative_to(source_dir).as_posix()}`" for f in files[:BODY_FILE_LIST_CAP]]
|
|
138
|
+
if len(files) > BODY_FILE_LIST_CAP:
|
|
139
|
+
listed.append(f"- … +{len(files) - BODY_FILE_LIST_CAP} more")
|
|
140
|
+
return (
|
|
141
|
+
f"Artifacts produced by sbxloop run `{run_id}`.\n\n"
|
|
142
|
+
f"**Outcome:** {outcome}\n\n"
|
|
143
|
+
f"**Files ({len(files)}):**\n" + "\n".join(listed) + "\n"
|
|
144
|
+
)
|
|
@@ -20,6 +20,7 @@ import time
|
|
|
20
20
|
from collections.abc import Callable, Sequence
|
|
21
21
|
|
|
22
22
|
from sbxloop.config import Config, load_config, load_dotenv_file
|
|
23
|
+
from sbxloop.deliver import deliver_workspace
|
|
23
24
|
from sbxloop.engine.model import (
|
|
24
25
|
RESUMABLE_RUN_STATES,
|
|
25
26
|
RunResult,
|
|
@@ -29,7 +30,14 @@ from sbxloop.engine.model import (
|
|
|
29
30
|
)
|
|
30
31
|
from sbxloop.engine.phases import PhaseRunner, clip
|
|
31
32
|
from sbxloop.engine.store import StateStore
|
|
32
|
-
from sbxloop.errors import
|
|
33
|
+
from sbxloop.errors import (
|
|
34
|
+
BudgetExceededError,
|
|
35
|
+
DeliveryError,
|
|
36
|
+
GithubOpsError,
|
|
37
|
+
SbxError,
|
|
38
|
+
SdxloopError,
|
|
39
|
+
StateError,
|
|
40
|
+
)
|
|
33
41
|
from sbxloop.events import EventBus, Hook, HostEventTypes
|
|
34
42
|
from sbxloop.gh.ops import GithubOps
|
|
35
43
|
from sbxloop.gh.reporter import GithubReporterHook
|
|
@@ -115,15 +123,20 @@ class LoopEngine:
|
|
|
115
123
|
transport=self.config.worker_transport,
|
|
116
124
|
python=self.worker_python,
|
|
117
125
|
)
|
|
118
|
-
github =
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
github = (
|
|
127
|
+
WorkerClient(
|
|
128
|
+
pair.github,
|
|
129
|
+
self.bus,
|
|
130
|
+
transport=self.config.worker_transport,
|
|
131
|
+
python=self.worker_python,
|
|
132
|
+
)
|
|
133
|
+
if pair.github is not None
|
|
134
|
+
else None
|
|
123
135
|
)
|
|
124
136
|
if self.install_workers:
|
|
125
137
|
agent.install(extras="copilot")
|
|
126
|
-
github
|
|
138
|
+
if github is not None:
|
|
139
|
+
github.install(extras="")
|
|
127
140
|
detach = self._attach_reporter(github, run_id)
|
|
128
141
|
try:
|
|
129
142
|
phases = PhaseRunner(
|
|
@@ -136,6 +149,8 @@ class LoopEngine:
|
|
|
136
149
|
# alive here, and partial artifacts beat none.
|
|
137
150
|
self._harvest(run_id, pair)
|
|
138
151
|
self._report_artifacts(run_id, pair)
|
|
152
|
+
if state == "completed":
|
|
153
|
+
self._deliver(run_id, outcome, pair, github)
|
|
139
154
|
except SdxloopError:
|
|
140
155
|
# State is already persisted; the exception is the kill signal.
|
|
141
156
|
raise
|
|
@@ -150,11 +165,12 @@ class LoopEngine:
|
|
|
150
165
|
mounted=pair.mounted,
|
|
151
166
|
)
|
|
152
167
|
|
|
153
|
-
def _attach_reporter(self, github: WorkerClient, run_id: str) -> Callable[[], None]:
|
|
154
|
-
|
|
155
|
-
if not
|
|
168
|
+
def _attach_reporter(self, github: WorkerClient | None, run_id: str) -> Callable[[], None]:
|
|
169
|
+
gh = self.config.github
|
|
170
|
+
if not gh.report or github is None:
|
|
156
171
|
return lambda: None
|
|
157
|
-
|
|
172
|
+
assert gh.repo is not None # report=True without a repo cannot provision a github worker
|
|
173
|
+
hook = GithubReporterHook(GithubOps(github, run_id), gh.repo)
|
|
158
174
|
return self.bus.attach_hook(hook)
|
|
159
175
|
|
|
160
176
|
def _harvest(self, run_id: str, pair: SandboxPair) -> None:
|
|
@@ -190,6 +206,44 @@ class LoopEngine:
|
|
|
190
206
|
mounted=pair.mounted,
|
|
191
207
|
)
|
|
192
208
|
|
|
209
|
+
def _deliver(
|
|
210
|
+
self, run_id: str, outcome: str, pair: SandboxPair, github: WorkerClient | None
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Publish the completed run's artifacts as a PR to the configured
|
|
213
|
+
[github].repo when delivery is enabled. The run has already
|
|
214
|
+
succeeded — delivery failure is loud (run.deliver event with the
|
|
215
|
+
error) but never changes the run state.
|
|
216
|
+
"""
|
|
217
|
+
gh = self.config.github
|
|
218
|
+
repo = gh.repo
|
|
219
|
+
if not gh.deliver or not repo or github is None:
|
|
220
|
+
return
|
|
221
|
+
source = (
|
|
222
|
+
pair.workspace
|
|
223
|
+
if pair.mounted
|
|
224
|
+
else self.config.state_dir / "runs" / run_id / "artifacts"
|
|
225
|
+
)
|
|
226
|
+
if source is None or not source.is_dir():
|
|
227
|
+
self.bus.emit(
|
|
228
|
+
HostEventTypes.RUN_DELIVER, run_id, repo=repo, error="no artifacts directory"
|
|
229
|
+
)
|
|
230
|
+
return
|
|
231
|
+
try:
|
|
232
|
+
pr = deliver_workspace(
|
|
233
|
+
GithubOps(github, run_id),
|
|
234
|
+
repo,
|
|
235
|
+
run_id=run_id,
|
|
236
|
+
outcome=outcome,
|
|
237
|
+
source_dir=source,
|
|
238
|
+
base=gh.deliver_base,
|
|
239
|
+
draft=gh.deliver_draft,
|
|
240
|
+
)
|
|
241
|
+
except (DeliveryError, GithubOpsError) as exc:
|
|
242
|
+
logger.warning("delivery to %s failed for run %s", repo, run_id, exc_info=True)
|
|
243
|
+
self.bus.emit(HostEventTypes.RUN_DELIVER, run_id, repo=repo, error=str(exc))
|
|
244
|
+
return
|
|
245
|
+
self.bus.emit(HostEventTypes.RUN_DELIVER, run_id, repo=repo, pr=pr.number, url=pr.url)
|
|
246
|
+
|
|
193
247
|
def _run_phases(
|
|
194
248
|
self, run_id: str, phases: PhaseRunner, deadline: float, pair: SandboxPair
|
|
195
249
|
) -> RunState:
|
|
@@ -158,6 +158,16 @@ class RunResult(_Model):
|
|
|
158
158
|
return self.state == "completed"
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
def artifact_files(root: Path) -> list[Path]:
|
|
162
|
+
"""Regular files under root, hidden files/dirs excluded (an agent's .git
|
|
163
|
+
would otherwise swamp listings and deliveries), sorted for stable output."""
|
|
164
|
+
return sorted(
|
|
165
|
+
p
|
|
166
|
+
for p in root.rglob("*")
|
|
167
|
+
if p.is_file() and not any(part.startswith(".") for part in p.relative_to(root).parts)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
161
171
|
def artifacts_dir(run: RunRecord | RunResult, state_dir: Path) -> Path | None:
|
|
162
172
|
"""The single host directory holding a run's artifacts.
|
|
163
173
|
|
|
@@ -65,6 +65,10 @@ class GithubOpsError(SdxloopError):
|
|
|
65
65
|
"""A GitHub operation failed in the github-ops sandbox."""
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
class DeliveryError(SdxloopError):
|
|
69
|
+
"""Delivering a run's workspace as a GitHub PR failed."""
|
|
70
|
+
|
|
71
|
+
|
|
68
72
|
class BudgetExceededError(SdxloopError):
|
|
69
73
|
"""A run or task exhausted one of its budgets."""
|
|
70
74
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""GithubReporterHook: mirrors run progress to a GitHub tracking issue.
|
|
2
2
|
|
|
3
|
-
The default consumer of the github-ops sandbox. When
|
|
4
|
-
is configured
|
|
3
|
+
The default consumer of the github-ops sandbox. When the GitHub integration
|
|
4
|
+
is configured (``[github].repo``) and reporting is enabled (``report = true``
|
|
5
|
+
or ``--report``), the hook opens a tracking issue at run start, comments as
|
|
5
6
|
tasks finish, and posts a final summary at run end. It never raises — a
|
|
6
7
|
reporting failure must not fail a run — and the EventBus isolates it anyway.
|
|
7
8
|
"""
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"""SandboxPair — the core sbxloop primitive — and its cleanup guarantees.
|
|
2
2
|
|
|
3
|
-
A run's pair consists of the agent sandbox (COPILOT_GITHUB_TOKEN only) and
|
|
4
|
-
the
|
|
3
|
+
A run's pair consists of the agent sandbox (COPILOT_GITHUB_TOKEN only) and,
|
|
4
|
+
when the GitHub integration is configured (``[github].repo``), the
|
|
5
|
+
github-ops sandbox (GH_TOKEN only) — otherwise ``pair.github`` is None and
|
|
6
|
+
no GitHub capability exists anywhere in the run. The pair is a context
|
|
7
|
+
manager whose
|
|
5
8
|
exit stops and removes both sandboxes unless ``keep`` is set; a process-wide
|
|
6
9
|
registry additionally cleans up on interpreter exit and on SIGINT/SIGTERM,
|
|
7
10
|
so aborted runs do not leak microVMs.
|
|
@@ -28,7 +31,7 @@ class SandboxPair:
|
|
|
28
31
|
self,
|
|
29
32
|
run_id: str,
|
|
30
33
|
agent: Sandbox,
|
|
31
|
-
github: Sandbox,
|
|
34
|
+
github: Sandbox | None = None,
|
|
32
35
|
*,
|
|
33
36
|
keep: bool = False,
|
|
34
37
|
workspace: Path | None = None,
|
|
@@ -64,12 +67,14 @@ class SandboxPair:
|
|
|
64
67
|
self.cleanup()
|
|
65
68
|
|
|
66
69
|
def cleanup(self) -> None:
|
|
67
|
-
"""Stop and remove
|
|
70
|
+
"""Stop and remove the pair's sandboxes. Idempotent, best-effort each."""
|
|
68
71
|
if self._cleaned:
|
|
69
72
|
return
|
|
70
73
|
self._cleaned = True
|
|
71
74
|
cleanup_registry.unregister(self)
|
|
72
75
|
for sandbox in (self.agent, self.github):
|
|
76
|
+
if sandbox is None:
|
|
77
|
+
continue
|
|
73
78
|
try:
|
|
74
79
|
sandbox.stop()
|
|
75
80
|
except Exception:
|
|
@@ -7,7 +7,9 @@ The credential split is enforced here:
|
|
|
7
7
|
token-exchange host — the value never enters the VM under the default
|
|
8
8
|
``proxy`` strategy.
|
|
9
9
|
- **github sandbox** gets only ``GH_TOKEN`` via sbx's built-in ``github``
|
|
10
|
-
secret service (scoped to github.com hosts).
|
|
10
|
+
secret service (scoped to github.com hosts). It is provisioned only when
|
|
11
|
+
the GitHub integration is configured (``[github].repo``); otherwise runs
|
|
12
|
+
have no GitHub capability and GH_TOKEN is not required.
|
|
11
13
|
|
|
12
14
|
The ``plain-env`` fallback strategy writes tokens to ``~/.sbxloop/env.sh``
|
|
13
15
|
inside the sandbox (weaker: the value is visible in the VM) for environments
|
|
@@ -145,17 +147,22 @@ class Provisioner:
|
|
|
145
147
|
workspace = workspace.resolve()
|
|
146
148
|
workspace.mkdir(parents=True, exist_ok=True)
|
|
147
149
|
|
|
150
|
+
# The github sandbox (and its token requirement) exists only when the
|
|
151
|
+
# GitHub integration is configured; without [github].repo a run has
|
|
152
|
+
# no GitHub capability at all — and one less microVM to boot.
|
|
153
|
+
github_enabled = self.config.github.enabled
|
|
154
|
+
|
|
148
155
|
# Fail fast on missing tokens before creating any microVM.
|
|
149
|
-
tokens: dict[SandboxRole, str] = {
|
|
150
|
-
|
|
151
|
-
"github"
|
|
152
|
-
}
|
|
156
|
+
tokens: dict[SandboxRole, str] = {"agent": self.copilot_token()}
|
|
157
|
+
if github_enabled:
|
|
158
|
+
tokens["github"] = self.gh_token()
|
|
153
159
|
|
|
154
160
|
agent_spec, github_spec = self.build_specs(run_id, workspace)
|
|
161
|
+
specs = (agent_spec, github_spec) if github_enabled else (agent_spec,)
|
|
155
162
|
created: list[Sandbox] = []
|
|
156
163
|
try:
|
|
157
164
|
sandboxes: dict[SandboxRole, Sandbox] = {}
|
|
158
|
-
for spec in
|
|
165
|
+
for spec in specs:
|
|
159
166
|
self.bus.emit("sandbox.provision_start", run_id, name=spec.name, role=spec.role)
|
|
160
167
|
self.cli.create(spec)
|
|
161
168
|
sandbox = Sandbox(self.cli, spec.name)
|
|
@@ -176,7 +183,7 @@ class Provisioner:
|
|
|
176
183
|
return SandboxPair(
|
|
177
184
|
run_id,
|
|
178
185
|
agent=sandboxes["agent"],
|
|
179
|
-
github=sandboxes
|
|
186
|
+
github=sandboxes.get("github"),
|
|
180
187
|
keep=self.config.keep_sandboxes,
|
|
181
188
|
workspace=workspace,
|
|
182
189
|
agent_workdir=agent_workdir,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|