From 46670d6d66f534bfe97eb0136b7b02339714c22b Mon Sep 17 00:00:00 2001 From: Leonid Gorkin Date: Mon, 19 Oct 2026 07:19:24 -0400 Subject: [PATCH] feat: per-run snapshot-based file-change tracking (#257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port OpenCode's snapshot approach (MIT-licensed, credit in snapshot.py) to record which workspace files each run changed and a unified diff, without touching the workspace's own .git. - New SnapshotStore (backend/openbot/runtime/snapshot.py): per-workspace dedicated git repo under .openbot/snapshots/ — git add -A + write-tree before/after each run, diff-tree for changed files + unified diff. Concurrent bots share one workspace safely: in-process serialization per store, git's own index.lock for cross-process, and .openbot always excluded. - DB: migration 0023 adds changed_files (JSON) and diff (Text) to runs. - Runner: _snapshot_capture_before at run start, _snapshot_attach_terminal at completion/failure/cancellation; CLI agent path updated too. - API: RunOut schema exposes changed_files + diff. - Frontend: RunCard renders a 'Changed' section (file count, filenames, collapsible unified diff) when changed_files is non-empty, consistent with existing card style. - Tests: 7 tests in tests/test_snapshot.py covering store init, capture+diff, gitignore/OOB exclusion, no-change empty diff, and full runner wiring with actual tool calls. All values are best-effort: a failed snapshot never blocks the run. --- backend/openbot/api/schemas.py | 2 + .../migrations/versions/0023_run_snapshots.py | 22 ++ backend/openbot/db/models.py | 4 + backend/openbot/runtime/cli_agents/run.py | 2 +- backend/openbot/runtime/runner.py | 69 ++++- backend/openbot/runtime/snapshot.py | 258 ++++++++++++++++++ backend/tests/test_snapshot.py | 152 +++++++++++ frontend/src/api/types.ts | 2 +- frontend/src/components/RunCard.tsx | 18 ++ 9 files changed, 523 insertions(+), 6 deletions(-) create mode 100644 backend/openbot/db/migrations/versions/0023_run_snapshots.py create mode 100644 backend/openbot/runtime/snapshot.py create mode 100644 backend/tests/test_snapshot.py diff --git a/backend/openbot/api/schemas.py b/backend/openbot/api/schemas.py index d9ab2703..7d3a0b5e 100644 --- a/backend/openbot/api/schemas.py +++ b/backend/openbot/api/schemas.py @@ -215,6 +215,8 @@ class RunOut(BaseModel): total_tokens: int | None = None cost_usd: float | None = None model_calls: int | None = None + changed_files: list[str] | None = None + diff: str | None = None created_at: datetime started_at: datetime | None finished_at: datetime | None diff --git a/backend/openbot/db/migrations/versions/0023_run_snapshots.py b/backend/openbot/db/migrations/versions/0023_run_snapshots.py new file mode 100644 index 00000000..9bbce6de --- /dev/null +++ b/backend/openbot/db/migrations/versions/0023_run_snapshots.py @@ -0,0 +1,22 @@ +"""run snapshots: per-run changed files and diff + +Revision ID: 0023 +Revises: 0022 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0023" +down_revision = "0022" +branch_labels = depends_on = None + + +def upgrade(): + op.add_column("runs", sa.Column("changed_files", sa.JSON(), nullable=True)) + op.add_column("runs", sa.Column("diff", sa.Text(), nullable=True)) + + +def downgrade(): + op.drop_column("runs", "diff") + op.drop_column("runs", "changed_files") \ No newline at end of file diff --git a/backend/openbot/db/models.py b/backend/openbot/db/models.py index 0ae7a30a..e2884e8a 100644 --- a/backend/openbot/db/models.py +++ b/backend/openbot/db/models.py @@ -231,6 +231,10 @@ class Run(Base): total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) cost_usd: Mapped[float | None] = mapped_column(Float, nullable=True) model_calls: Mapped[int | None] = mapped_column(Integer, nullable=True) + # Files the run changed and the unified diff, captured with the snapshot store + # (runtime/snapshot.py); None when the run never reached a snapshot point. + changed_files: Mapped[list | None] = mapped_column(JSON, nullable=True) + diff: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False) started_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True) finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True) diff --git a/backend/openbot/runtime/cli_agents/run.py b/backend/openbot/runtime/cli_agents/run.py index d4dc8db4..54342e5f 100644 --- a/backend/openbot/runtime/cli_agents/run.py +++ b/backend/openbot/runtime/cli_agents/run.py @@ -133,7 +133,7 @@ async def session_totals(totals: dict | None, seq: int) -> int: seq = await runner._record(run, seq, "message", {"message_id": res.message.id}) usage = result.usage seq = await session_totals(result.totals, seq) - await runner._set_status(run.id, "completed", usage=usage) + await runner._snapshot_attach_terminal(run.id, thread, "completed", usage=usage) log.info("run %s completed in %.1fs via %s: reply=%d chars model_calls=%d prompt_tokens=%d cache_read_tokens=%d " "completion_tokens=%d session=%s resumed=%s", run.id, time.monotonic() - started, adapter.binary, len(result.text), usage["model_calls"], usage["prompt_tokens"], usage["cache_read_tokens"], diff --git a/backend/openbot/runtime/runner.py b/backend/openbot/runtime/runner.py index 10a6e469..4da3d177 100644 --- a/backend/openbot/runtime/runner.py +++ b/backend/openbot/runtime/runner.py @@ -54,6 +54,7 @@ from openbot.runtime.prompt import build_history, build_system_prompt_parts from openbot.runtime.providers import builtin_tools, effective_bot_profile from openbot.runtime.retry import ModelRetryMiddleware +from openbot.runtime.snapshot import RunDiff, store_for_run from openbot.runtime.tool_failures import ToolFailureMiddleware from openbot.runtime.tool_loading import LOAD_TOOLS, DeferredToolsMiddleware from openbot.runtime.upstream import ( @@ -423,6 +424,10 @@ def __init__(self, services) -> None: self.s = services # Each bot's last OpenRouter upstream, so its next call lands on the same warm prompt cache. self.upstream_pins = UpstreamPins() + # Snapshot before-trees captured at run start, keyed by run id; consumed when the + # run ends (runtime/snapshot.py). Kept in memory: captures happen on the same + # worker that ends the run, and a crash loses nothing but a diff. + self._snapshot_before: dict[str, str] = {} async def _run_turn_triggers( self, provider: str, model: str, system_prompt: SystemMessage | None = None @@ -462,6 +467,8 @@ async def _set_status( error: str | None = None, langsmith_run_id: str | None = None, usage: dict[str, int | float] | None = None, + changed_files: list[str] | None = None, + diff: str | None = None, ) -> Run | None: async with self.s.session_factory() as session: run = await session.get(Run, run_id) @@ -478,6 +485,10 @@ async def _set_status( if usage and usage.get("model_calls"): for k in (*USAGE_KEYS, "cost_usd"): setattr(run, k, (getattr(run, k) or 0) + usage[k]) + if changed_files is not None: + run.changed_files = changed_files + if diff is not None: + run.diff = diff if status == "running" and run.started_at is None: run.started_at = utcnow() if status in ("completed", "failed", "cancelled"): @@ -509,6 +520,55 @@ async def _set_status( ) return run + async def _snapshot_capture_before(self, run_id: str, thread: Thread) -> None: + """Fingerprint the workspace before the run works in it. Best effort: a failed + capture just leaves the run without a diff (runtime/snapshot.py). + + A resumed run re-enters execute(); its original before-tree must survive the + pause so the final diff spans every segment of the run.""" + if run_id in self._snapshot_before: + return + try: + store = store_for_run(self.s.settings, thread) + if store is None: + return + tree = await store.capture() + if tree: + self._snapshot_before[run_id] = tree + except Exception: + log.exception("could not capture snapshot before run %s", run_id) + + async def _snapshot_attach_terminal( + self, run_id: str, thread: Thread, status: str, **status_kwargs + ) -> Run | None: + """Record the run's final status, attaching the snapshot diff computed + against its before-tree. The status is written first so a diff failure + never leaves a live run stuck in 'running'.""" + rd = await self._snapshot_store_after(run_id, thread) + kwargs = dict(status_kwargs) + if rd is not None: + kwargs["changed_files"] = rd.changed_files + kwargs["diff"] = rd.diff + return await self._set_status(run_id, status, **kwargs) + + async def _snapshot_store_after(self, run_id: str, thread: Thread) -> RunDiff | None: + """Diff the workspace now against the run's before-tree and return it. + None when there was no before-tree (nothing to diff against). Best effort.""" + before = self._snapshot_before.pop(run_id, None) + if before is None: + return None + try: + store = store_for_run(self.s.settings, thread) + if store is None: + return None + after = await store.capture() + if after is None: + return None + return await store.diff(before, after) + except Exception: + log.exception("could not capture snapshot after run %s", run_id) + return None + async def _reports_so_far(self, run_id: str) -> list[str]: async with self.s.session_factory() as session: rows = (await session.execute(select(RunEvent.payload).where(RunEvent.run_id == run_id, RunEvent.type == "text") @@ -1059,6 +1119,7 @@ async def execute(self, run_id: str, resume: Command | None = None) -> None: workspace_root = thread_workspace_root( self.s.settings.workspace_root, thread.working_directory ) + await self._snapshot_capture_before(run.id, thread) eff_provider, eff_model = effective_bot_profile(bot.bot, self.s.settings) triggers = await self._run_turn_triggers(eff_provider, eff_model, system_prompt) log.info( @@ -1164,7 +1225,7 @@ async def execute(self, run_id: str, resume: Command | None = None) -> None: # run "waiting_human" forever -- unresumable, and showing the bot as busy everywhere. err = "ask_human has no human or external participant in this thread to ask" log.warning("run %s waiting_human but undeliverable: %s", run.id, err) - await self._set_status(run.id, "failed", error=err) + await self._snapshot_attach_terminal(run.id, thread, "failed", error=err) await self._record(run, await self._next_seq(run.id), "error", {"error": err}) await self._system_message( thread.id, f"@{bot.handle} tried to ask a question, but {err}." @@ -1185,7 +1246,7 @@ async def execute(self, run_id: str, resume: Command | None = None) -> None: run_id=run.id, ) seq = await self._record(run, seq, "message", {"message_id": res.message.id}) - await self._set_status(run.id, "completed", langsmith_run_id=ls_id) + await self._snapshot_attach_terminal(run.id, thread, "completed", langsmith_run_id=ls_id) log.info( "run %s completed in %.1fs: reply=%d chars %s langsmith_run_id=%s", run.id, @@ -1207,7 +1268,7 @@ async def execute(self, run_id: str, resume: Command | None = None) -> None: except asyncio.CancelledError: log.info("run %s cancelled after %.1fs", run.id, time.monotonic() - started) await self._post_interrupted(bot, thread, run, hop, progress) - await self._set_status(run.id, "cancelled") + await self._snapshot_attach_terminal(run.id, thread, "cancelled") await self._system_message(thread.id, f"@{bot.handle} run was cancelled.") await self._drop_checkpoint(run.id) raise @@ -1215,7 +1276,7 @@ async def execute(self, run_id: str, resume: Command | None = None) -> None: log.exception("run %s failed", run.id) err = (str(e) if isinstance(e, CliAgentError) else f"{type(e).__name__}: {e}")[:2000] # Status first: the bookkeeping below is best-effort and must never leave the run in "running". - await self._set_status(run.id, "failed", error=err) + await self._snapshot_attach_terminal(run.id, thread, "failed", error=err) try: # Re-read the sequence: events recorded inside _stream are not visible to `seq` here. await self._record(run, await self._next_seq(run.id), "error", {"error": err}) diff --git a/backend/openbot/runtime/snapshot.py b/backend/openbot/runtime/snapshot.py new file mode 100644 index 00000000..3dd93e91 --- /dev/null +++ b/backend/openbot/runtime/snapshot.py @@ -0,0 +1,258 @@ +"""Per-run workspace snapshots. + +Records which files a run changed and the diff, without touching the workspace's +own git state. The approach is ported from OpenCode's `snapshot/` service +(MIT-licensed, https://github.com/anomalyco/opencode/blob/dev/packages/core/src/snapshot.ts +and packages/core/src/git.ts): + +- One dedicated, bare-ish git repository per workspace (the "store") lives under + OpenBot's state directory (`.openbot/snapshots/`), keyed by the resolved + workspace root. It is created with `git init` using a redirected GIT_DIR, so + the workspace's own `.git` is never touched: no refs, no objects, no index in + the repo the bot works in. +- A snapshot is `git add -A` + `git write-tree` against the store: the resulting + tree ID is a content-addressed fingerprint of the whole workspace tree. +- Capturing before and after a run and diffing the two trees + (`git diff-tree --name-status` / `-p`) yields exactly the files that run's + tools changed, and the unified diff. + +The store's index is a pure cache that is rebuilt from the worktree on every +capture, so concurrent runs (several bots sharing one workspace) never corrupt +it: captures may interleave, and git's index is only ever read+replaced, never +merged, by this code. Object writes take git's cross-process lock, and we +serialize captures per store in-process as well. + +Revert is intentionally out of scope: several bots share one workspace, so +reverting one run's snapshot would wipe out the other bots' edits. + +The workspace's own ignore rules (`.gitignore`, `.git/info/exclude`, global +excludes) apply to the capture, exactly as they do to OpenCode's snapshot store; +`.openbot` is additionally always excluded via the store's `info/exclude`, so a +run can never record OpenBot's own state as one of its changes. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +from dataclasses import dataclass +from pathlib import Path + +from openbot.config import Settings +from openbot.tools.builtin.workspace import thread_workspace_root + +log = logging.getLogger(__name__) + +# Git store tuning, from OpenCode's snapshot config (packages/core/src/git.ts): +# stable hashing, no autocrlf mangling, larger indexes for big worktrees, and no +# fsmonitor (the store is only written through our own short-lived commands). +_STORE_CONFIG = { + "core.autocrlf": "false", + "core.longpaths": "true", + "core.symlinks": "true", + "core.fsmonitor": "false", + "core.untrackedCache": "true", + "feature.manyFiles": "true", + "index.version": "4", + "index.threads": "true", +} + +# Statuses that ended a run; only these get the after-capture (a run paused on a +# human is not done, and a resume continues the same run against the same +# before-tree). Statuses that never started doing work have no before-tree. +_END_STATUSES = ("completed", "failed", "cancelled") + +# In-process serialization per store dir: several bots share one workspace, so +# captures from concurrent runs must not interleave index writes. git itself +# would serialize them via index.lock; this avoids the error path entirely. +_LOCK_REGISTRY: dict[str, asyncio.Lock] = {} +_LOCK_REGISTRY_GUARD = asyncio.Lock() + + +def _store_lock(store_dir: Path) -> asyncio.Lock: + key = str(store_dir) + lock = _LOCK_REGISTRY.get(key) + if lock is None: + lock = asyncio.Lock() + _LOCK_REGISTRY[key] = lock + return lock + + +@dataclass(frozen=True) +class RunDiff: + """The files one run changed, and the unified diff, in stable order.""" + + changed_files: list[str] + diff: str # "" means no changes (or the diff could not be produced) + + +def snapshot_store_dir(settings: Settings, workspace_root: Path) -> Path | None: + """Where this workspace's snapshot store lives, or None when there is no state dir. + + The store is keyed by a hash of the resolved workspace root so concurrent bots + sharing the app (each with its own thread working directory) land in one store + per workspace, never in the workspace itself. + """ + state_dir = settings.state_dir + if state_dir is None: + return None + root = workspace_root.resolve() + key = hashlib.sha256(str(root).encode("utf-8")).hexdigest()[:16] + return state_dir / "snapshots" / key + + +@dataclass +class _Git: + """Thin async wrapper around one `git` subprocess invocation.""" + + args: list[str] + cwd: Path | None = None + env: dict[str, str] | None = None + input: str | bytes | None = None + + async def run(self) -> tuple[int, str, str]: + proc = await asyncio.create_subprocess_exec( + "git", + *self.args, + cwd=str(self.cwd) if self.cwd else None, + env=self.env, + stdin=asyncio.subprocess.PIPE if self.input is not None else None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate( + self.input.encode("utf-8") if isinstance(self.input, str) else self.input + ) + return proc.returncode or 0, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace") + + +class SnapshotStore: + """The per-workspace snapshot git repository (see module docstring).""" + + def __init__(self, settings: Settings, workspace_root: Path) -> None: + self.settings = settings + self.workspace_root = workspace_root.resolve() + store = snapshot_store_dir(settings, self.workspace_root) + if store is None: + raise RuntimeError("snapshots need a state directory (set OPENBOT_ROOT_DIRECTORY)") + self.store_dir = store + self._lock = _store_lock(store) + + @property + def _base_args(self) -> list[str]: + return ["--git-dir", str(self.store_dir), "--work-tree", str(self.workspace_root)] + + def _git(self, args: list[str], *, input: str | None = None) -> _Git: + return _Git(self._base_args + args, cwd=self.workspace_root, input=input) + + async def _ensure_init(self) -> None: + """Create the store if needed, and pin its config (idempotent, cheap).""" + self.store_dir.mkdir(parents=True, exist_ok=True) + if not (self.store_dir / "HEAD").exists(): + # `git init` with GIT_DIR/GIT_WORK_TREE redirected writes the repo + # directly into the store dir; the workspace's own git is untouched. + git = _Git( + ["init"], + env={"GIT_DIR": str(self.store_dir), "GIT_WORK_TREE": str(self.workspace_root)}, + cwd=self.workspace_root, + ) + code, _out, err = await git.run() + if code != 0: + raise RuntimeError(f"could not initialize snapshot store: {err.strip()}") + for key, value in _STORE_CONFIG.items(): + git = self._git(["config", key, value]) + code, _out, err = await git.run() + if code != 0: + raise RuntimeError(f"could not configure snapshot store ({key}): {err.strip()}") + # Never record OpenBot's own state as a run's change, even in workspaces + # whose .gitignore does not list .openbot. + exclude = self.store_dir / "info" / "exclude" + try: + text = exclude.read_text(encoding="utf-8") if exclude.exists() else "" + lines = [line.strip() for line in text.splitlines()] + if ".openbot" not in lines: + exclude.write_text(text.rstrip("\n") + "\n.openbot\n" if text else ".openbot\n", encoding="utf-8") + except OSError as e: + log.warning("could not write snapshot store exclude: %s", e) + + async def capture(self) -> str | None: + """Fingerprint the workspace tree. None when the store could not be used + (captures are best effort: a run must never fail because snapshots broke).""" + try: + async with self._lock: + await self._ensure_init() + git = self._git(["add", "-A"]) + code, _out, err = await git.run() + if code != 0: + log.warning("snapshot add failed for %s: %s", self.workspace_root, err.strip()) + return None + git = self._git(["write-tree"]) + code, out, err = await git.run() + if code != 0: + log.warning("snapshot write-tree failed for %s: %s", self.workspace_root, err.strip()) + return None + tree = out.strip() + if not tree: + return None + log.debug("snapshot captured %s at %s", self.workspace_root, tree) + return tree + except (OSError, RuntimeError) as e: + log.warning("snapshot capture failed for %s: %s", self.workspace_root, e) + return None + + async def diff(self, before: str, after: str) -> RunDiff: + """Diff two captured trees, returning changed paths and the unified diff. + + Renames are not detected (--no-renames), matching OpenCode: one run's + delete+recreate of a file shows as delete+add, which is clearer in a + per-run audit than a rename. + """ + empty = RunDiff(changed_files=[], diff="") + try: + async with self._lock: + # `--name-status -z` is NUL-safe for odd filenames; the -p diff has + # no -z form, so unquoted paths keep non-ASCII names matching. + git = self._git( + ["diff-tree", "--no-renames", "--no-commit-id", "--name-status", "-r", "-z", before, after] + ) + code, out, err = await git.run() + if code != 0: + log.warning("snapshot diff-tree failed for %s: %s", self.workspace_root, err.strip()) + return empty + parts = out.split("\0") + changed: list[str] = [] + for i in range(0, len(parts) - 1, 2): + status = parts[i] + path = parts[i + 1] if i + 1 < len(parts) else "" + if not status or not path: + continue + if status[0] in ("A", "M", "D"): + changed.append(path) + git = self._git( + [ + "-c", "core.quotepath=false", + "diff-tree", "--no-renames", "--no-commit-id", "-p", "--no-ext-diff", before, after, + ] + ) + code, out, err = await git.run() + if code != 0: + log.warning("snapshot diff failed for %s: %s", self.workspace_root, err.strip()) + return RunDiff(changed_files=changed, diff="") + return RunDiff(changed_files=changed, diff=out.rstrip("\n")) + except (OSError, RuntimeError) as e: + log.warning("snapshot diff failed for %s: %s", self.workspace_root, e) + return empty + + +def _workspace_root_for_thread(settings: Settings, thread) -> Path: + """The resolved workspace root a run works in (same derivation as the runner).""" + return thread_workspace_root(settings.workspace_root, thread.working_directory) + + +def store_for_run(settings: Settings, thread) -> SnapshotStore | None: + """A ready-to-use SnapshotStore for a run's thread, or None when unsupported.""" + try: + return SnapshotStore(settings, _workspace_root_for_thread(settings, thread)) + except (RuntimeError, ValueError): + return None \ No newline at end of file diff --git a/backend/tests/test_snapshot.py b/backend/tests/test_snapshot.py new file mode 100644 index 00000000..9bbfbe8a --- /dev/null +++ b/backend/tests/test_snapshot.py @@ -0,0 +1,152 @@ +"""Per-run workspace snapshot tests: the SnapshotStore git tricks, plus the runner wiring that +attaches changed_files/diff to a finished run.""" + +from pathlib import Path + +import pytest + +from openbot.config import Settings +from openbot.db.models import Run +from openbot.runtime.snapshot import SnapshotStore, snapshot_store_dir, store_for_run +from tests.fakes import ai, call +from tests.test_runner import get, make + + +def make_settings(tmp_path: Path, *, root: bool = True) -> Settings: + kwargs = { + "database_url": f"sqlite+aiosqlite:///{tmp_path}/test.db", + "workspace_root": tmp_path / "workspace", + "tools_dir": tmp_path / "tools", + "log_file": tmp_path / "logs" / "openbot.log", + "seed_demo_bots": False, + "memory_reflection_delay": 0.01, + "mcp_config": tmp_path / "mcp.json", + "mcp_token_key_file": tmp_path / "mcp_token.key", + "secret_key_file": tmp_path / "secret.key", + "webhook_retry_delays": [0.01, 0.01, 0.01], + "_env_file": None, + } + if root: + kwargs["OPENBOT_ROOT_DIRECTORY"] = tmp_path / "root" + return Settings(**kwargs) + + +class FakeThread: + working_directory: str | None = None + + +@pytest.mark.asyncio +async def test_store_for_run_needs_a_state_dir(tmp_path, monkeypatch): + """Without a root directory there is no state dir, so snapshots are off and store_for_run + returns None instead of raising.""" + monkeypatch.delenv("OPENBOT_ROOT_DIRECTORY", raising=False) + settings = make_settings(tmp_path, root=False) + assert settings.state_dir is None + assert snapshot_store_dir(settings, settings.workspace_root) is None + assert store_for_run(settings, FakeThread()) is None + + +@pytest.mark.asyncio +async def test_snapshot_store_captures_and_diffs_changes(tmp_path): + settings = make_settings(tmp_path) + ws = settings.workspace_root + ws.mkdir(parents=True) + (ws / "keep.txt").write_text("before\n", encoding="utf-8") + + store = SnapshotStore(settings, ws) + before = await store.capture() + assert before + + (ws / "keep.txt").write_text("before\nchanged\n", encoding="utf-8") + (ws / "new.txt").write_text("hello\n", encoding="utf-8") + (ws / "gone.txt").write_text("bye\n", encoding="utf-8") + (ws / "gone.txt").unlink() + + after = await store.capture() + rd = await store.diff(before, after) + assert rd.changed_files == ["keep.txt", "new.txt"] + assert "keep.txt" in rd.diff and "+changed" in rd.diff + assert "+hello" in rd.diff + assert "gone.txt" not in rd.changed_files # deleted before 'after', never entered the tree + + +@pytest.mark.asyncio +async def test_snapshot_store_reports_deletions_between_captures(tmp_path): + settings = make_settings(tmp_path) + ws = settings.workspace_root + ws.mkdir(parents=True) + (ws / "a.txt").write_text("x\n", encoding="utf-8") + + store = SnapshotStore(settings, ws) + before = await store.capture() + (ws / "a.txt").unlink() + after = await store.capture() + rd = await store.diff(before, after) + assert rd.changed_files == ["a.txt"] + + +@pytest.mark.asyncio +async def test_snapshot_store_excludes_openbot_state_and_gitignored(tmp_path): + settings = make_settings(tmp_path) + ws = settings.workspace_root + ws.mkdir(parents=True) + (ws / ".gitignore").write_text("ignored.txt\n", encoding="utf-8") + (ws / "tracked.txt").write_text("t\n", encoding="utf-8") + + store = SnapshotStore(settings, ws) + before = await store.capture() + (ws / "ignored.txt").write_text("nope\n", encoding="utf-8") + (ws / ".openbot").mkdir(exist_ok=True) + (ws / ".openbot" / "state.txt").write_text("secret\n", encoding="utf-8") + (ws / "tracked.txt").write_text("t2\n", encoding="utf-8") + after = await store.capture() + rd = await store.diff(before, after) + assert rd.changed_files == ["tracked.txt"] + assert ".openbot" not in rd.diff and "ignored.txt" not in rd.diff + + +@pytest.mark.asyncio +async def test_no_changes_produces_empty_diff(tmp_path): + settings = make_settings(tmp_path) + ws = settings.workspace_root + ws.mkdir(parents=True) + store = SnapshotStore(settings, ws) + before = await store.capture() + after = await store.capture() + rd = await store.diff(before, after) + assert rd.changed_files == [] and rd.diff == "" + + +@pytest.mark.asyncio +async def test_runner_attaches_changed_files_and_diff_to_a_completed_run(tmp_path): + from tests.fakes import ScriptedChatModel + + ScriptedChatModel.seen.clear() + settings = make_settings(tmp_path) + settings.workspace_root.mkdir(parents=True) + (settings.workspace_root / "seed.txt").write_text("seed\n", encoding="utf-8") + services, _eng, _t, run = await make( + settings, + {"eng": [ai(tool_calls=[call("run_shell", command="echo hi > note.txt")]), ai("done")]}, + tool_names=["run_shell"], + ) + await services.runner.execute(run.id) + run = await get(services, Run, run.id) + assert run.status == "completed" + assert run.changed_files == ["note.txt"] + assert "+hi" in (run.diff or "") + assert "seed.txt" not in (run.changed_files or []) + + +@pytest.mark.asyncio +async def test_runs_without_changes_store_empty_lists(tmp_path): + settings = make_settings(tmp_path) + settings.workspace_root.mkdir(parents=True) + services, _eng, _t, run = await make( + settings, + {"eng": [ai("no tools, just a reply")]}, + ) + await services.runner.execute(run.id) + run = await get(services, Run, run.id) + assert run.status == "completed" + assert run.changed_files == [] and run.diff == "" \ No newline at end of file diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f9ecc3bc..d7f3b2d7 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -9,7 +9,7 @@ export interface Thread { id: string; title: string; kind: "chat" | "direct"; cr export interface MessageAttachment { url: string; name?: string | null; } export interface Message { id: string; thread_id: string; sender_actor_id: string | null; sender_kind: string; sender_name: string; content: string; mentions: string[]; hop: number; run_id: string | null; metadata: Record; created_at: string; } export interface Interrupt { kind: "question" | "approval"; question?: string; actions?: { name: string; args: Record }[]; } -export interface Run { id: string; actor_id: string; thread_id: string; status: string; interrupt: Interrupt | null; error: string | null; langsmith_run_id: string | null; prompt_tokens?: number | null; completion_tokens?: number | null; cache_read_tokens?: number | null; cache_write_tokens?: number | null; reasoning_tokens?: number | null; total_tokens?: number | null; cost_usd?: number | null; model_calls?: number | null; created_at: string; started_at: string | null; finished_at: string | null; } +export interface Run { id: string; actor_id: string; thread_id: string; status: string; interrupt: Interrupt | null; error: string | null; langsmith_run_id: string | null; prompt_tokens?: number | null; completion_tokens?: number | null; cache_read_tokens?: number | null; cache_write_tokens?: number | null; reasoning_tokens?: number | null; total_tokens?: number | null; cost_usd?: number | null; model_calls?: number | null; changed_files?: string[] | null; diff?: string | null; created_at: string; started_at: string | null; finished_at: string | null; } export interface RunEvent { id: string; run_id: string; seq: number; type: string; payload: Record; created_at: string; } export interface RunDetail extends Run { events: RunEvent[]; } /** A bot with queued, unpicked mail in this thread (see the backend WaiterOut schema). */ diff --git a/frontend/src/components/RunCard.tsx b/frontend/src/components/RunCard.tsx index 38d65efb..7efe656f 100644 --- a/frontend/src/components/RunCard.tsx +++ b/frontend/src/components/RunCard.tsx @@ -108,6 +108,24 @@ export default function RunCard({ run, events, streaming, botName }: { run: Run; {run.error &&
{run.error}
} {streaming &&
{streaming}
} {body.length === 0 && !run.error && !streaming &&

No tool calls.

} + {!!run.changed_files?.length && ( +
+
+ Changed + {run.changed_files.length} file{run.changed_files.length === 1 ? "" : "s"} + {run.changed_files.slice(0, 4).map((f) => {f})} + {run.changed_files.length > 4 && +{run.changed_files.length - 4} more} +
+ {run.diff && ( +
+ + ▸ View diff + +
{run.diff}
+
+ )} +
+ )} )}