From dc86d84e96f8a94e3f1c5a23a19d486aee5398a6 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 13 Mar 2026 19:21:11 -0300 Subject: [PATCH] Add workspace review summaries Add workspace summary across the CLI, SDK, and MCP, and include it in the workspace-core profile so chat hosts can review one concise view of the current session. Persist lightweight review events for syncs, file edits, patch applies, exports, service lifecycle, and snapshot activity, then synthesize them with command history, current services, snapshot state, and current diff data since the last reset. Update the walkthroughs, use-case docs, public contract, changelog, and roadmap for 4.3.0, and make dist-check invoke the CLI module directly so local package reinstall quirks do not break the packaging gate. Validation: uv lock; ./.venv/bin/pytest --no-cov tests/test_vm_manager.py tests/test_cli.py tests/test_api.py tests/test_server.py tests/test_public_contract.py tests/test_workspace_use_case_smokes.py; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed workspace create -> patch apply -> workspace summary --json -> delete smoke. --- CHANGELOG.md | 11 + Makefile | 18 +- README.md | 5 +- docs/first-run.md | 3 +- docs/install.md | 3 +- docs/public-contract.md | 2 + docs/roadmap/llm-chat-ergonomics.md | 8 +- .../4.3.0-reviewable-agent-output.md | 2 +- docs/use-cases/README.md | 6 + docs/use-cases/review-eval-workflows.md | 7 +- pyproject.toml | 2 +- src/pyro_mcp/api.py | 10 + src/pyro_mcp/cli.py | 177 +++++++++ src/pyro_mcp/contract.py | 5 + src/pyro_mcp/vm_environments.py | 4 +- src/pyro_mcp/vm_manager.py | 349 +++++++++++++++++- src/pyro_mcp/workspace_use_case_smokes.py | 5 + tests/test_api.py | 28 +- tests/test_cli.py | 182 +++++++++ tests/test_public_contract.py | 6 + tests/test_server.py | 9 + tests/test_vm_manager.py | 118 ++++++ tests/test_workspace_use_case_smokes.py | 63 ++++ uv.lock | 2 +- 24 files changed, 994 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d30ab17..6fa1330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 4.3.0 + +- Added `pyro workspace summary`, `Pyro.summarize_workspace()`, and MCP + `workspace_summary` so users and chat hosts can review a concise view of the + current workspace session since the last reset. +- Added a lightweight review-event log for edits, syncs, exports, service + lifecycle, and snapshot activity without duplicating the command journal. +- Updated the main workspace walkthroughs and review/eval recipe so + `workspace summary` is the first review surface before dropping down to raw + diffs, logs, and exported files. + ## 4.2.0 - Added host bootstrap and repair helpers with `pyro host connect`, diff --git a/Makefile b/Makefile index 46ebd93..c76fe4e 100644 --- a/Makefile +++ b/Makefile @@ -83,15 +83,15 @@ test: check: lint typecheck test dist-check: - uv run pyro --version - uv run pyro --help >/dev/null - uv run pyro host --help >/dev/null - uv run pyro host doctor >/dev/null - uv run pyro mcp --help >/dev/null - uv run pyro run --help >/dev/null - uv run pyro env list >/dev/null - uv run pyro env inspect debian:12 >/dev/null - uv run pyro doctor >/dev/null + uv run python -m pyro_mcp.cli --version + uv run python -m pyro_mcp.cli --help >/dev/null + uv run python -m pyro_mcp.cli host --help >/dev/null + uv run python -m pyro_mcp.cli host doctor >/dev/null + uv run python -m pyro_mcp.cli mcp --help >/dev/null + uv run python -m pyro_mcp.cli run --help >/dev/null + uv run python -m pyro_mcp.cli env list >/dev/null + uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null + uv run python -m pyro_mcp.cli doctor >/dev/null pypi-publish: @if [ -z "$$TWINE_PASSWORD" ]; then \ diff --git a/README.md b/README.md index 8ab2054..74f0965 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ SDK-first platform. - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) - Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) -- What's new in 4.2.0: [CHANGELOG.md#420](CHANGELOG.md#420) +- What's new in 4.3.0: [CHANGELOG.md#430](CHANGELOG.md#430) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) ## Who It's For @@ -76,7 +76,7 @@ What success looks like: ```bash Platform: linux-x86_64 Runtime: PASS -Catalog version: 4.2.0 +Catalog version: 4.3.0 ... [pull] phase=install environment=debian:12 [pull] phase=ready environment=debian:12 @@ -207,6 +207,7 @@ pyro workspace sync push "$WORKSPACE_ID" ./changes pyro workspace file read "$WORKSPACE_ID" note.txt --content-only pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch pyro workspace exec "$WORKSPACE_ID" -- cat note.txt +pyro workspace summary "$WORKSPACE_ID" pyro workspace snapshot create "$WORKSPACE_ID" checkpoint pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt diff --git a/docs/first-run.md b/docs/first-run.md index 6fa3442..9e26999 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -27,7 +27,7 @@ Networking: tun=yes ip_forward=yes ```bash $ uvx --from pyro-mcp pyro env list -Catalog version: 4.2.0 +Catalog version: 4.3.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -155,6 +155,7 @@ $ uvx --from pyro-mcp pyro workspace sync push "$WORKSPACE_ID" ./changes $ uvx --from pyro-mcp pyro workspace file read "$WORKSPACE_ID" note.txt --content-only $ uvx --from pyro-mcp pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch $ uvx --from pyro-mcp pyro workspace exec "$WORKSPACE_ID" -- cat note.txt +$ uvx --from pyro-mcp pyro workspace summary "$WORKSPACE_ID" $ uvx --from pyro-mcp pyro workspace snapshot create "$WORKSPACE_ID" checkpoint $ uvx --from pyro-mcp pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint $ uvx --from pyro-mcp pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt diff --git a/docs/install.md b/docs/install.md index 1879146..4a57178 100644 --- a/docs/install.md +++ b/docs/install.md @@ -93,7 +93,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 4.2.0 +Catalog version: 4.3.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -239,6 +239,7 @@ pyro workspace sync push "$WORKSPACE_ID" ./changes pyro workspace file read "$WORKSPACE_ID" note.txt --content-only pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch pyro workspace exec "$WORKSPACE_ID" -- cat note.txt +pyro workspace summary "$WORKSPACE_ID" pyro workspace snapshot create "$WORKSPACE_ID" checkpoint pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt diff --git a/docs/public-contract.md b/docs/public-contract.md index b061cd5..e1e0856 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -117,6 +117,7 @@ setup and repair path for supported hosts. - `workspace_sync_push` - `workspace_exec` - `workspace_logs` +- `workspace_summary` - `workspace_file_list` - `workspace_file_read` - `workspace_file_write` @@ -133,6 +134,7 @@ That is enough for the normal persistent editing loop: - sync or seed repo content - inspect and edit files without shell quoting - run commands repeatedly in one sandbox +- review the current session in one concise summary - diff and export results - reset and retry - delete the workspace when the task is done diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index ff6ccc6..60b85b6 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -6,7 +6,7 @@ goal: make the core agent-workspace use cases feel trivial from a chat-driven LLM interface. -Current baseline is `4.2.0`: +Current baseline is `4.3.0`: - `pyro mcp serve` is now the default product entrypoint - `workspace-core` is now the default MCP profile @@ -81,7 +81,7 @@ capability gaps: 11. [`4.0.0` Workspace-Core Default Profile](llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md) - Done 12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Done 13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Done -14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Planned +14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Done 15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Planned 16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Planned @@ -120,10 +120,12 @@ Completed so far: - `4.2.0` adds first-class host bootstrap and repair helpers so Claude Code, Codex, and OpenCode users can connect or repair the supported chat-host path without manually composing raw MCP commands or config edits. +- `4.3.0` adds a concise workspace review surface so users can inspect what the + agent changed and ran since the last reset without reconstructing the + session from several lower-level views by hand. Planned next: -- [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) diff --git a/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md b/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md index 838a28c..b1b02ba 100644 --- a/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md +++ b/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md @@ -1,6 +1,6 @@ # `4.3.0` Reviewable Agent Output -Status: Planned +Status: Done ## Goal diff --git a/docs/use-cases/README.md b/docs/use-cases/README.md index 0f93188..ec249b7 100644 --- a/docs/use-cases/README.md +++ b/docs/use-cases/README.md @@ -30,3 +30,9 @@ That runner generates its own host fixtures, creates real guest-backed workspace verifies the intended flow, exports one concrete result when relevant, and cleans up on both success and failure. Treat `make smoke-use-cases` as the trustworthy guest-backed verification path for the advertised workspace workflows. + +For a concise review before exporting, resetting, or handing work off, use: + +```bash +pyro workspace summary WORKSPACE_ID +``` diff --git a/docs/use-cases/review-eval-workflows.md b/docs/use-cases/review-eval-workflows.md index 1d8d12e..3ab9204 100644 --- a/docs/use-cases/review-eval-workflows.md +++ b/docs/use-cases/review-eval-workflows.md @@ -16,9 +16,10 @@ Chat-host recipe: 1. Create a named snapshot before the review starts. 2. Open a readable PTY shell and inspect the checklist interactively. 3. Run the review or evaluation script in the same workspace. -4. Export the final report. -5. Reset back to the snapshot if the review branch goes sideways. -6. Delete the workspace when the evaluation is done. +4. Capture `workspace summary` to review what changed and what to export. +5. Export the final report. +6. Reset back to the snapshot if the review branch goes sideways. +7. Delete the workspace when the evaluation is done. This is the stable shell-facing story: readable PTY output for chat loops, checkpointed evaluation, explicit export, and reset when a review branch goes diff --git a/pyproject.toml b/pyproject.toml index 1d6b8e0..963ecdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "4.2.0" +version = "4.3.0" description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." readme = "README.md" license = { file = "LICENSE" } diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 35feeb9..935c619 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -204,6 +204,9 @@ class Pyro: def logs_workspace(self, workspace_id: str) -> dict[str, Any]: return self._manager.logs_workspace(workspace_id) + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + return self._manager.summarize_workspace(workspace_id) + def export_workspace( self, workspace_id: str, @@ -818,6 +821,13 @@ class Pyro: """Return persisted command history for one workspace.""" return self.logs_workspace(workspace_id) + if _enabled("workspace_summary"): + + @server.tool() + async def workspace_summary(workspace_id: str) -> dict[str, Any]: + """Summarize the current workspace session for human review.""" + return self.summarize_workspace(workspace_id) + if _enabled("workspace_export"): @server.tool() diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 204df2a..cfb6c86 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -550,6 +550,147 @@ def _print_workspace_logs_human(payload: dict[str, Any]) -> None: print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr) +def _print_workspace_review_summary_human(payload: dict[str, Any]) -> None: + print(f"Workspace review: {str(payload.get('workspace_id', 'unknown'))}") + name = payload.get("name") + if isinstance(name, str) and name != "": + print(f"Name: {name}") + labels = payload.get("labels") + if isinstance(labels, dict) and labels: + rendered_labels = ", ".join( + f"{str(key)}={str(value)}" for key, value in sorted(labels.items()) + ) + print(f"Labels: {rendered_labels}") + print(f"Environment: {str(payload.get('environment', 'unknown'))}") + print(f"State: {str(payload.get('state', 'unknown'))}") + print(f"Last activity at: {payload.get('last_activity_at')}") + print(f"Session started at: {payload.get('session_started_at')}") + + outcome = payload.get("outcome") + if isinstance(outcome, dict): + print( + "Outcome: " + f"commands={int(outcome.get('command_count', 0))} " + f"services={int(outcome.get('running_service_count', 0))}/" + f"{int(outcome.get('service_count', 0))} " + f"exports={int(outcome.get('export_count', 0))} " + f"snapshots={int(outcome.get('snapshot_count', 0))} " + f"resets={int(outcome.get('reset_count', 0))}" + ) + last_command = outcome.get("last_command") + if isinstance(last_command, dict): + print( + "Last command: " + f"{str(last_command.get('command', 'unknown'))} " + f"(exit_code={int(last_command.get('exit_code', -1))})" + ) + + def _print_events(title: str, events: object, *, formatter: Any) -> None: + if not isinstance(events, list) or not events: + return + print(f"{title}:") + for event in events: + if not isinstance(event, dict): + continue + print(f"- {formatter(event)}") + + commands = payload.get("commands") + if isinstance(commands, dict): + _print_events( + "Recent commands", + commands.get("recent"), + formatter=lambda event: ( + f"#{int(event.get('sequence', 0))} " + f"exit={int(event.get('exit_code', -1))} " + f"cwd={str(event.get('cwd', WORKSPACE_GUEST_PATH))} " + f"cmd={str(event.get('command', ''))}" + ), + ) + + edits = payload.get("edits") + if isinstance(edits, dict): + _print_events( + "Recent edits", + edits.get("recent"), + formatter=lambda event: ( + f"{str(event.get('event_kind', 'edit'))} " + f"at={event.get('recorded_at')} " + f"path={event.get('path') or event.get('destination') or 'n/a'}" + ), + ) + + changes = payload.get("changes") + if isinstance(changes, dict): + if not bool(changes.get("available")): + print(f"Changes: unavailable ({str(changes.get('reason', 'unknown reason'))})") + elif not bool(changes.get("changed")): + print("Changes: no current workspace changes.") + else: + summary = changes.get("summary") + if isinstance(summary, dict): + print( + "Changes: " + f"total={int(summary.get('total', 0))} " + f"added={int(summary.get('added', 0))} " + f"modified={int(summary.get('modified', 0))} " + f"deleted={int(summary.get('deleted', 0))} " + f"type_changed={int(summary.get('type_changed', 0))} " + f"non_text={int(summary.get('non_text', 0))}" + ) + _print_events( + "Top changed paths", + changes.get("entries"), + formatter=lambda event: ( + f"{str(event.get('status', 'changed'))} " + f"{str(event.get('path', 'unknown'))} " + f"[{str(event.get('artifact_type', 'unknown'))}]" + ), + ) + + services = payload.get("services") + if isinstance(services, dict): + _print_events( + "Current services", + services.get("current"), + formatter=lambda event: ( + f"{str(event.get('service_name', 'unknown'))} " + f"state={str(event.get('state', 'unknown'))}" + ), + ) + _print_events( + "Recent service events", + services.get("recent"), + formatter=lambda event: ( + f"{str(event.get('event_kind', 'service'))} " + f"{str(event.get('service_name', 'unknown'))} " + f"state={str(event.get('state', 'unknown'))}" + ), + ) + + artifacts = payload.get("artifacts") + if isinstance(artifacts, dict): + _print_events( + "Recent exports", + artifacts.get("exports"), + formatter=lambda event: ( + f"{str(event.get('workspace_path', 'unknown'))} -> " + f"{str(event.get('output_path', 'unknown'))}" + ), + ) + + snapshots = payload.get("snapshots") + if isinstance(snapshots, dict): + print(f"Named snapshots: {int(snapshots.get('named_count', 0))}") + _print_events( + "Recent snapshot events", + snapshots.get("recent"), + formatter=lambda event: ( + f"{str(event.get('event_kind', 'snapshot'))} " + f"{str(event.get('snapshot_name', 'unknown'))}" + ), + ) + + def _print_workspace_snapshot_human(payload: dict[str, Any], *, prefix: str) -> None: snapshot = payload.get("snapshot") if not isinstance(snapshot, dict): @@ -765,6 +906,7 @@ def _build_parser() -> argparse.ArgumentParser: pyro workspace create debian:12 --seed-path ./repo --id-only pyro workspace sync push WORKSPACE_ID ./changes pyro workspace exec WORKSPACE_ID -- cat note.txt + pyro workspace summary WORKSPACE_ID pyro workspace diff WORKSPACE_ID pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint @@ -1178,6 +1320,7 @@ def _build_parser() -> argparse.ArgumentParser: pyro workspace start WORKSPACE_ID pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint + pyro workspace summary WORKSPACE_ID pyro workspace diff WORKSPACE_ID pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt pyro workspace shell open WORKSPACE_ID --id-only @@ -2317,6 +2460,33 @@ while true; do sleep 60; done' action="store_true", help="Print structured JSON instead of human-readable output.", ) + workspace_summary_parser = workspace_subparsers.add_parser( + "summary", + help="Summarize the current workspace session for review.", + description=( + "Summarize the current workspace session since the last reset, including recent " + "commands, edits, services, exports, snapshots, and current change status." + ), + epilog=dedent( + """ + Example: + pyro workspace summary WORKSPACE_ID + + Use `workspace logs`, `workspace diff`, and `workspace export` for drill-down. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_summary_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_summary_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) workspace_logs_parser = workspace_subparsers.add_parser( "logs", help="Show command history for one workspace.", @@ -3305,6 +3475,13 @@ def main() -> None: else: _print_workspace_summary_human(payload, action="Workspace") return + if args.workspace_command == "summary": + payload = pyro.summarize_workspace(args.workspace_id) + if bool(args.json): + _print_json(payload) + else: + _print_workspace_review_summary_human(payload) + return if args.workspace_command == "logs": payload = pyro.logs_workspace(args.workspace_id) if bool(args.json): diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index f706633..cddbbff 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -44,6 +44,7 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( "start", "status", "stop", + "summary", "sync", "update", ) @@ -120,6 +121,7 @@ PUBLIC_CLI_WORKSPACE_SNAPSHOT_LIST_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_START_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_STATUS_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_STOP_FLAGS = ("--json",) +PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS = ("--dest", "--json") PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS = ( "--name", @@ -184,6 +186,7 @@ PUBLIC_SDK_METHODS = ( "stop_service", "stop_vm", "stop_workspace", + "summarize_workspace", "update_workspace", "write_shell", "write_workspace_file", @@ -228,6 +231,7 @@ PUBLIC_MCP_TOOLS = ( "workspace_logs", "workspace_patch_apply", "workspace_reset", + "workspace_summary", "workspace_start", "workspace_status", "workspace_stop", @@ -249,6 +253,7 @@ PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = ( "workspace_logs", "workspace_patch_apply", "workspace_reset", + "workspace_summary", "workspace_status", "workspace_sync_push", "workspace_update", diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index a6cbd46..82375e1 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -19,7 +19,7 @@ from typing import Any from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths DEFAULT_ENVIRONMENT_VERSION = "1.0.0" -DEFAULT_CATALOG_VERSION = "4.2.0" +DEFAULT_CATALOG_VERSION = "4.3.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", @@ -48,7 +48,7 @@ class VmEnvironment: oci_repository: str | None = None oci_reference: str | None = None source_digest: str | None = None - compatibility: str = ">=4.2.0,<5.0.0" + compatibility: str = ">=4.3.0,<5.0.0" @dataclass(frozen=True) diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index 4384d97..1e99682 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -79,12 +79,13 @@ DEFAULT_TIMEOUT_SECONDS = 30 DEFAULT_TTL_SECONDS = 600 DEFAULT_ALLOW_HOST_COMPAT = False -WORKSPACE_LAYOUT_VERSION = 8 +WORKSPACE_LAYOUT_VERSION = 9 WORKSPACE_BASELINE_DIRNAME = "baseline" WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar" WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots" WORKSPACE_DIRNAME = "workspace" WORKSPACE_COMMANDS_DIRNAME = "commands" +WORKSPACE_REVIEW_DIRNAME = "review" WORKSPACE_SHELLS_DIRNAME = "shells" WORKSPACE_SERVICES_DIRNAME = "services" WORKSPACE_SECRETS_DIRNAME = "secrets" @@ -118,6 +119,16 @@ WORKSPACE_LABEL_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"] WorkspaceSeedOriginKind = Literal["empty", "manual_seed_path", "project_path", "repo_url"] WorkspaceArtifactType = Literal["file", "directory", "symlink"] +WorkspaceReviewEventKind = Literal[ + "file_write", + "patch_apply", + "service_start", + "service_stop", + "snapshot_create", + "snapshot_delete", + "sync_push", + "workspace_export", +] WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"] WorkspaceSnapshotKind = Literal["baseline", "named"] WorkspaceSecretSourceKind = Literal["literal", "file"] @@ -317,6 +328,35 @@ class WorkspaceSecretRecord: ) +@dataclass(frozen=True) +class WorkspaceReviewEventRecord: + """Persistent concise review event metadata stored on disk per workspace.""" + + workspace_id: str + event_kind: WorkspaceReviewEventKind + recorded_at: float + payload: dict[str, Any] + + def to_payload(self) -> dict[str, Any]: + return { + "layout_version": WORKSPACE_LAYOUT_VERSION, + "workspace_id": self.workspace_id, + "event_kind": self.event_kind, + "recorded_at": self.recorded_at, + "payload": self.payload, + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> WorkspaceReviewEventRecord: + raw_payload = payload.get("payload") + return cls( + workspace_id=str(payload["workspace_id"]), + event_kind=cast(WorkspaceReviewEventKind, str(payload["event_kind"])), + recorded_at=float(payload["recorded_at"]), + payload=dict(raw_payload) if isinstance(raw_payload, dict) else {}, + ) + + @dataclass class WorkspaceSnapshotRecord: """Persistent snapshot metadata stored on disk per workspace.""" @@ -3775,6 +3815,7 @@ class VmManager: runtime_dir = self._workspace_runtime_dir(workspace_id) host_workspace_dir = self._workspace_host_dir(workspace_id) commands_dir = self._workspace_commands_dir(workspace_id) + review_dir = self._workspace_review_dir(workspace_id) shells_dir = self._workspace_shells_dir(workspace_id) services_dir = self._workspace_services_dir(workspace_id) secrets_dir = self._workspace_secrets_dir(workspace_id) @@ -3783,6 +3824,7 @@ class VmManager: workspace_dir.mkdir(parents=True, exist_ok=False) host_workspace_dir.mkdir(parents=True, exist_ok=True) commands_dir.mkdir(parents=True, exist_ok=True) + review_dir.mkdir(parents=True, exist_ok=True) shells_dir.mkdir(parents=True, exist_ok=True) services_dir.mkdir(parents=True, exist_ok=True) secrets_dir.mkdir(parents=True, exist_ok=True) @@ -3912,6 +3954,20 @@ class VmManager: workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="sync_push", + payload={ + "mode": str(workspace_sync["mode"]), + "source_path": str(workspace_sync["source_path"]), + "destination": str(workspace_sync["destination"]), + "entry_count": int(workspace_sync["entry_count"]), + "bytes_written": int(workspace_sync["bytes_written"]), + "execution_mode": str( + instance.metadata.get("execution_mode", "pending") + ), + }, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -3993,8 +4049,8 @@ class VmManager: def export_workspace( self, workspace_id: str, - *, path: str, + *, output_path: str | Path, ) -> dict[str, Any]: normalized_path, _ = _normalize_workspace_destination(path) @@ -4026,6 +4082,23 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="workspace_export", + payload={ + "workspace_path": normalized_path, + "output_path": str(Path(str(extracted["output_path"]))), + "artifact_type": str(extracted["artifact_type"]), + "entry_count": int(extracted["entry_count"]), + "bytes_written": int(extracted["bytes_written"]), + "execution_mode": str( + exported.get( + "execution_mode", + instance.metadata.get("execution_mode", "pending"), + ) + ), + }, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -4185,6 +4258,22 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="file_write", + payload={ + "path": str(payload["path"]), + "size_bytes": int(payload["size_bytes"]), + "bytes_written": int(payload["bytes_written"]), + "execution_mode": str( + payload.get( + "execution_mode", + instance.metadata.get("execution_mode", "pending"), + ) + ), + }, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -4302,6 +4391,17 @@ class VmManager: workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="patch_apply", + payload={ + "summary": dict(summary), + "entries": [dict(entry) for entry in entries[:10]], + "execution_mode": str( + instance.metadata.get("execution_mode", "pending") + ), + }, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -4379,6 +4479,17 @@ class VmManager: self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) self._save_workspace_snapshot_locked(snapshot) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="snapshot_create", + payload={ + "snapshot_name": snapshot.snapshot_name, + "kind": snapshot.kind, + "entry_count": snapshot.entry_count, + "bytes_written": snapshot.bytes_written, + "created_at": snapshot.created_at, + }, + ) return { "workspace_id": workspace_id, "snapshot": self._serialize_workspace_snapshot(snapshot), @@ -4412,6 +4523,11 @@ class VmManager: self._load_workspace_snapshot_locked(workspace_id, normalized_snapshot_name) self._delete_workspace_snapshot_locked(workspace_id, normalized_snapshot_name) self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="snapshot_delete", + payload={"snapshot_name": normalized_snapshot_name}, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -5013,6 +5129,24 @@ class VmManager: self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) self._save_workspace_service_locked(service) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="service_start", + payload={ + "service_name": service.service_name, + "state": service.state, + "command": service.command, + "cwd": service.cwd, + "readiness": ( + dict(service.readiness) if service.readiness is not None else None + ), + "ready_at": service.ready_at, + "published_ports": [ + _serialize_workspace_published_port_public(published_port) + for published_port in service.published_ports + ], + }, + ) return self._serialize_workspace_service(service) def list_services(self, workspace_id: str) -> dict[str, Any]: @@ -5132,6 +5266,18 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="service_stop", + payload={ + "service_name": service.service_name, + "state": service.state, + "exit_code": service.exit_code, + "stop_reason": service.stop_reason, + "ended_at": service.ended_at, + }, + ) self._save_workspace_locked(workspace) self._save_workspace_service_locked(service) return self._serialize_workspace_service(service) @@ -5165,6 +5311,157 @@ class VmManager: "entries": redacted_entries, } + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + self._ensure_workspace_not_expired_locked(workspace, time.time()) + self._refresh_workspace_liveness_locked(workspace) + self._refresh_workspace_service_counts_locked(workspace) + self._save_workspace_locked(workspace) + + command_entries = self._read_workspace_logs_locked(workspace.workspace_id) + recent_commands = [ + { + "sequence": int(entry["sequence"]), + "command": str(entry["command"]), + "cwd": str(entry["cwd"]), + "exit_code": int(entry["exit_code"]), + "duration_ms": int(entry["duration_ms"]), + "execution_mode": str(entry["execution_mode"]), + "recorded_at": float(entry["recorded_at"]), + } + for entry in command_entries[-5:] + ] + recent_commands.reverse() + + review_events = self._list_workspace_review_events_locked(workspace.workspace_id) + + def _recent_events( + kinds: set[WorkspaceReviewEventKind], + *, + limit: int = 5, + ) -> list[dict[str, Any]]: + matched = [ + { + "event_kind": event.event_kind, + "recorded_at": event.recorded_at, + **event.payload, + } + for event in review_events + if event.event_kind in kinds + ] + matched = matched[-limit:] + matched.reverse() + return matched + + current_services = [ + self._serialize_workspace_service(service) + for service in self._list_workspace_services_locked(workspace.workspace_id) + ] + current_services.sort(key=lambda item: str(item["service_name"])) + try: + snapshots = self._list_workspace_snapshots_locked(workspace) + named_snapshot_count = max(len(snapshots) - 1, 0) + except RuntimeError: + named_snapshot_count = 0 + + service_count = len(current_services) + running_service_count = sum( + 1 for service in current_services if service["state"] == "running" + ) + execution_mode = str(workspace.metadata.get("execution_mode", "pending")) + payload: dict[str, Any] = { + "workspace_id": workspace.workspace_id, + "name": workspace.name, + "labels": dict(workspace.labels), + "environment": workspace.environment, + "state": workspace.state, + "workspace_path": WORKSPACE_GUEST_PATH, + "execution_mode": execution_mode, + "last_activity_at": workspace.last_activity_at, + "session_started_at": ( + workspace.last_reset_at + if workspace.last_reset_at is not None + else workspace.created_at + ), + "outcome": { + "command_count": workspace.command_count, + "last_command": workspace.last_command, + "service_count": service_count, + "running_service_count": running_service_count, + "export_count": sum( + 1 for event in review_events if event.event_kind == "workspace_export" + ), + "snapshot_count": named_snapshot_count, + "reset_count": workspace.reset_count, + }, + "commands": { + "total": workspace.command_count, + "recent": recent_commands, + }, + "edits": { + "recent": _recent_events({"sync_push", "file_write", "patch_apply"}), + }, + "services": { + "current": current_services, + "recent": _recent_events({"service_start", "service_stop"}), + }, + "artifacts": { + "exports": _recent_events({"workspace_export"}), + }, + "snapshots": { + "named_count": named_snapshot_count, + "recent": _recent_events({"snapshot_create", "snapshot_delete"}), + }, + } + + if payload["state"] != "started": + payload["changes"] = { + "available": False, + "reason": ( + f"workspace {workspace_id!r} must be in 'started' state before " + "workspace_summary can compute current changes" + ), + "changed": False, + "summary": None, + "entries": [], + } + return payload + + try: + diff_payload = self.diff_workspace(workspace_id) + except Exception as exc: + payload["changes"] = { + "available": False, + "reason": str(exc), + "changed": False, + "summary": None, + "entries": [], + } + return payload + + diff_entries: list[dict[str, Any]] = [] + raw_entries = diff_payload.get("entries") + if isinstance(raw_entries, list): + for entry in raw_entries[:10]: + if not isinstance(entry, dict): + continue + diff_entries.append( + { + key: value + for key, value in entry.items() + if key != "text_patch" + } + ) + payload["changes"] = { + "available": True, + "reason": None, + "changed": bool(diff_payload.get("changed", False)), + "summary": diff_payload.get("summary"), + "entries": diff_entries, + } + return payload + def stop_workspace(self, workspace_id: str) -> dict[str, Any]: with self._lock: workspace = self._load_workspace_locked(workspace_id) @@ -5795,6 +6092,9 @@ class VmManager: def _workspace_commands_dir(self, workspace_id: str) -> Path: return self._workspace_dir(workspace_id) / WORKSPACE_COMMANDS_DIRNAME + def _workspace_review_dir(self, workspace_id: str) -> Path: + return self._workspace_dir(workspace_id) / WORKSPACE_REVIEW_DIRNAME + def _workspace_shells_dir(self, workspace_id: str) -> Path: return self._workspace_dir(workspace_id) / WORKSPACE_SHELLS_DIRNAME @@ -5810,6 +6110,9 @@ class VmManager: def _workspace_shell_record_path(self, workspace_id: str, shell_id: str) -> Path: return self._workspace_shells_dir(workspace_id) / f"{shell_id}.json" + def _workspace_review_record_path(self, workspace_id: str, event_id: str) -> Path: + return self._workspace_review_dir(workspace_id) / f"{event_id}.json" + def _workspace_service_record_path(self, workspace_id: str, service_name: str) -> Path: return self._workspace_services_dir(workspace_id) / f"{service_name}.json" @@ -6004,6 +6307,46 @@ class VmManager: entries.append(entry) return entries + def _record_workspace_review_event_locked( + self, + workspace_id: str, + *, + event_kind: WorkspaceReviewEventKind, + payload: dict[str, Any], + when: float | None = None, + ) -> WorkspaceReviewEventRecord: + recorded_at = time.time() if when is None else when + event = WorkspaceReviewEventRecord( + workspace_id=workspace_id, + event_kind=event_kind, + recorded_at=recorded_at, + payload=dict(payload), + ) + event_id = f"{int(recorded_at * 1_000_000_000):020d}-{uuid.uuid4().hex[:8]}" + record_path = self._workspace_review_record_path(workspace_id, event_id) + record_path.parent.mkdir(parents=True, exist_ok=True) + record_path.write_text( + json.dumps(event.to_payload(), indent=2, sort_keys=True), + encoding="utf-8", + ) + return event + + def _list_workspace_review_events_locked( + self, + workspace_id: str, + ) -> list[WorkspaceReviewEventRecord]: + review_dir = self._workspace_review_dir(workspace_id) + if not review_dir.exists(): + return [] + events: list[WorkspaceReviewEventRecord] = [] + for record_path in sorted(review_dir.glob("*.json")): + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + continue + events.append(WorkspaceReviewEventRecord.from_payload(payload)) + events.sort(key=lambda item: (item.recorded_at, item.event_kind)) + return events + def _workspace_instance_for_live_shell_locked(self, workspace: WorkspaceRecord) -> VmInstance: instance = self._workspace_instance_for_live_operation_locked( workspace, @@ -6360,10 +6703,12 @@ class VmManager: shutil.rmtree(self._workspace_runtime_dir(workspace_id), ignore_errors=True) shutil.rmtree(self._workspace_host_dir(workspace_id), ignore_errors=True) shutil.rmtree(self._workspace_commands_dir(workspace_id), ignore_errors=True) + shutil.rmtree(self._workspace_review_dir(workspace_id), ignore_errors=True) shutil.rmtree(self._workspace_shells_dir(workspace_id), ignore_errors=True) shutil.rmtree(self._workspace_services_dir(workspace_id), ignore_errors=True) self._workspace_host_dir(workspace_id).mkdir(parents=True, exist_ok=True) self._workspace_commands_dir(workspace_id).mkdir(parents=True, exist_ok=True) + self._workspace_review_dir(workspace_id).mkdir(parents=True, exist_ok=True) self._workspace_shells_dir(workspace_id).mkdir(parents=True, exist_ok=True) self._workspace_services_dir(workspace_id).mkdir(parents=True, exist_ok=True) diff --git a/src/pyro_mcp/workspace_use_case_smokes.py b/src/pyro_mcp/workspace_use_case_smokes.py index 40e0ed9..26635d9 100644 --- a/src/pyro_mcp/workspace_use_case_smokes.py +++ b/src/pyro_mcp/workspace_use_case_smokes.py @@ -457,6 +457,11 @@ def _scenario_review_eval(pyro: Pyro, *, root: Path, environment: str) -> None: assert int(rerun["exit_code"]) == 0, rerun pyro.export_workspace(workspace_id, "review-report.txt", output_path=export_path) assert export_path.read_text(encoding="utf-8") == "review=pass\n" + summary = pyro.summarize_workspace(workspace_id) + assert summary["workspace_id"] == workspace_id, summary + assert summary["changes"]["available"] is True, summary + assert summary["artifacts"]["exports"], summary + assert summary["snapshots"]["named_count"] >= 1, summary finally: if shell_id is not None and workspace_id is not None: try: diff --git a/tests/test_api.py b/tests/test_api.py index 538e419..2fb7d10 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -455,6 +455,7 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None: services = pyro.list_services(workspace_id) service_status = pyro.status_service(workspace_id, "app") service_logs = pyro.logs_service(workspace_id, "app", all=True) + summary = pyro.summarize_workspace(workspace_id) reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint") deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint") status = pyro.status_workspace(workspace_id) @@ -491,6 +492,9 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None: assert service_status["state"] == "running" assert service_logs["stderr"].count("[REDACTED]") >= 1 assert service_logs["tail_lines"] is None + assert summary["workspace_id"] == workspace_id + assert summary["commands"]["total"] >= 1 + assert summary["changes"]["available"] is True assert reset["workspace_reset"]["snapshot_name"] == "checkpoint" assert reset["secrets"] == created["secrets"] assert deleted_snapshot["deleted"] is True @@ -1054,6 +1058,14 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non calls.append(("logs_workspace", {"workspace_id": workspace_id})) return {"workspace_id": workspace_id, "count": 0, "entries": []} + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + calls.append(("summarize_workspace", {"workspace_id": workspace_id})) + return { + "workspace_id": workspace_id, + "state": "started", + "changes": {"available": True, "changed": False, "summary": None, "entries": []}, + } + def open_shell( self, workspace_id: str, @@ -1185,6 +1197,9 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non status = _extract_structured( await server.call_tool("workspace_status", {"workspace_id": "workspace-123"}) ) + summary = _extract_structured( + await server.call_tool("workspace_summary", {"workspace_id": "workspace-123"}) + ) logs = _extract_structured( await server.call_tool("workspace_logs", {"workspace_id": "workspace-123"}) ) @@ -1286,6 +1301,7 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non ) return ( status, + summary, logs, opened, read, @@ -1300,13 +1316,15 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non results = asyncio.run(_run()) assert results[0]["state"] == "started" - assert results[1]["count"] == 0 - assert results[2]["shell_id"] == "shell-1" - assert results[6]["closed"] is True - assert results[7]["state"] == "running" - assert results[10]["state"] == "running" + assert results[1]["workspace_id"] == "workspace-123" + assert results[2]["count"] == 0 + assert results[3]["shell_id"] == "shell-1" + assert results[7]["closed"] is True + assert results[8]["state"] == "running" + assert results[11]["state"] == "running" assert calls == [ ("status_workspace", {"workspace_id": "workspace-123"}), + ("summarize_workspace", {"workspace_id": "workspace-123"}), ("logs_workspace", {"workspace_id": "workspace-123"}), ( "open_shell", diff --git a/tests/test_cli.py b/tests/test_cli.py index b55e752..2d15c18 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -39,6 +39,7 @@ def test_cli_help_guides_first_run() -> None: assert "pyro host print-config opencode" in help_text assert "If you want terminal-level visibility into the workspace model:" in help_text assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in help_text + assert "pyro workspace summary WORKSPACE_ID" in help_text assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in help_text assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in help_text assert "pyro workspace sync push WORKSPACE_ID ./changes" in help_text @@ -127,6 +128,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "pyro workspace start WORKSPACE_ID" in workspace_help assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in workspace_help assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in workspace_help + assert "pyro workspace summary WORKSPACE_ID" in workspace_help assert "pyro workspace shell open WORKSPACE_ID --id-only" in workspace_help workspace_create_help = _subparser_choice( @@ -181,6 +183,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "--label" in workspace_update_help assert "--clear-label" in workspace_update_help + workspace_summary_help = _subparser_choice( + _subparser_choice(parser, "workspace"), "summary" + ).format_help() + assert "Summarize the current workspace session since the last reset" in workspace_summary_help + assert "pyro workspace summary WORKSPACE_ID" in workspace_summary_help + workspace_file_help = _subparser_choice( _subparser_choice(parser, "workspace"), "file" ).format_help() @@ -2515,6 +2523,170 @@ def test_cli_workspace_logs_prints_json( assert payload["count"] == 0 +def test_cli_workspace_summary_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "name": "review-eval", + "labels": {"suite": "smoke"}, + "environment": "debian:12", + "state": "started", + "last_activity_at": 2.0, + "session_started_at": 1.0, + "outcome": { + "command_count": 1, + "last_command": {"command": "cat note.txt", "exit_code": 0}, + "service_count": 0, + "running_service_count": 0, + "export_count": 1, + "snapshot_count": 1, + "reset_count": 0, + }, + "commands": {"total": 1, "recent": []}, + "edits": {"recent": []}, + "changes": {"available": True, "changed": False, "summary": None, "entries": []}, + "services": {"current": [], "recent": []}, + "artifacts": {"exports": []}, + "snapshots": {"named_count": 1, "recent": []}, + } + + class SummaryParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="summary", + workspace_id="workspace-123", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SummaryParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["workspace_id"] == "workspace-123" + assert payload["outcome"]["export_count"] == 1 + + +def test_cli_workspace_summary_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "name": "review-eval", + "labels": {"suite": "smoke", "use_case": "review-eval"}, + "environment": "debian:12", + "state": "started", + "last_activity_at": 3.0, + "session_started_at": 1.0, + "outcome": { + "command_count": 2, + "last_command": {"command": "sh review.sh", "exit_code": 0}, + "service_count": 1, + "running_service_count": 0, + "export_count": 1, + "snapshot_count": 1, + "reset_count": 0, + }, + "commands": { + "total": 2, + "recent": [ + { + "sequence": 2, + "command": "sh review.sh", + "cwd": "/workspace", + "exit_code": 0, + "duration_ms": 12, + "execution_mode": "guest_vsock", + "recorded_at": 3.0, + } + ], + }, + "edits": { + "recent": [ + { + "event_kind": "patch_apply", + "recorded_at": 2.0, + "path": "/workspace/note.txt", + } + ] + }, + "changes": { + "available": True, + "changed": True, + "summary": { + "total": 1, + "added": 0, + "modified": 1, + "deleted": 0, + "type_changed": 0, + "text_patched": 1, + "non_text": 0, + }, + "entries": [ + { + "path": "/workspace/note.txt", + "status": "modified", + "artifact_type": "file", + } + ], + }, + "services": { + "current": [{"service_name": "app", "state": "stopped"}], + "recent": [ + { + "event_kind": "service_stop", + "service_name": "app", + "state": "stopped", + } + ], + }, + "artifacts": { + "exports": [ + { + "workspace_path": "review-report.txt", + "output_path": "/tmp/review-report.txt", + } + ] + }, + "snapshots": { + "named_count": 1, + "recent": [ + {"event_kind": "snapshot_create", "snapshot_name": "checkpoint"} + ], + }, + } + + class SummaryParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="summary", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SummaryParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "Workspace review: workspace-123" in output + assert "Outcome: commands=2 services=0/1 exports=1 snapshots=1 resets=0" in output + assert "Recent commands:" in output + assert "Recent edits:" in output + assert "Changes: total=1 added=0 modified=1 deleted=0 type_changed=0 non_text=0" in output + assert "Recent exports:" in output + assert "Recent snapshot events:" in output + + def test_cli_workspace_delete_prints_human( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -3028,6 +3200,16 @@ def test_content_only_read_docs_are_aligned() -> None: assert 'workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch' in first_run +def test_workspace_summary_docs_are_aligned() -> None: + readme = Path("README.md").read_text(encoding="utf-8") + install = Path("docs/install.md").read_text(encoding="utf-8") + first_run = Path("docs/first-run.md").read_text(encoding="utf-8") + + assert 'workspace summary "$WORKSPACE_ID"' in readme + assert 'workspace summary "$WORKSPACE_ID"' in install + assert 'workspace summary "$WORKSPACE_ID"' in first_run + + def test_cli_workspace_shell_write_signal_close_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 35aad8f..527c68b 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -58,6 +58,7 @@ from pyro_mcp.contract import ( PUBLIC_CLI_WORKSPACE_START_FLAGS, PUBLIC_CLI_WORKSPACE_STOP_FLAGS, PUBLIC_CLI_WORKSPACE_SUBCOMMANDS, + PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS, PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS, PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS, PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS, @@ -272,6 +273,11 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: ).format_help() for flag in PUBLIC_CLI_WORKSPACE_STOP_FLAGS: assert flag in workspace_stop_help_text + workspace_summary_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "summary" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS: + assert flag in workspace_summary_help_text workspace_shell_help_text = _subparser_choice( _subparser_choice(parser, "workspace"), "shell", diff --git a/tests/test_server.py b/tests/test_server.py index e52c2d0..1017d77 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -602,6 +602,9 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: }, ) ) + summary = _extract_structured( + await server.call_tool("workspace_summary", {"workspace_id": workspace_id}) + ) reset = _extract_structured( await server.call_tool( "workspace_reset", @@ -639,6 +642,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: service_status, service_logs, service_stopped, + summary, reset, deleted_snapshot, logs, @@ -664,6 +668,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: service_status, service_logs, service_stopped, + summary, reset, deleted_snapshot, logs, @@ -700,6 +705,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: assert service_logs["stderr"].count("[REDACTED]") >= 1 assert service_logs["tail_lines"] is None assert service_stopped["state"] == "stopped" + assert summary["workspace_id"] == created["workspace_id"] + assert summary["commands"]["total"] >= 1 + assert summary["changes"]["available"] is True + assert summary["artifacts"]["exports"][0]["workspace_path"] == "/workspace/subdir/more.txt" assert reset["workspace_reset"]["snapshot_name"] == "checkpoint" assert reset["secrets"] == created["secrets"] assert reset["command_count"] == 0 diff --git a/tests/test_vm_manager.py b/tests/test_vm_manager.py index 22e953c..73fc74f 100644 --- a/tests/test_vm_manager.py +++ b/tests/test_vm_manager.py @@ -699,6 +699,124 @@ def test_workspace_diff_and_export_round_trip(tmp_path: Path) -> None: assert logs["count"] == 0 +def test_workspace_summary_synthesizes_current_session(tmp_path: Path) -> None: + seed_dir = tmp_path / "seed" + seed_dir.mkdir() + (seed_dir / "note.txt").write_text("hello\n", encoding="utf-8") + update_dir = tmp_path / "update" + update_dir.mkdir() + (update_dir / "more.txt").write_text("more\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + name="review-eval", + labels={"suite": "smoke"}, + )["workspace_id"] + ) + manager.push_workspace_sync(workspace_id, source_path=update_dir) + manager.write_workspace_file(workspace_id, "src/app.py", text="print('hello')\n") + manager.apply_workspace_patch( + workspace_id, + patch=( + "--- a/note.txt\n" + "+++ b/note.txt\n" + "@@ -1 +1 @@\n" + "-hello\n" + "+patched\n" + ), + ) + manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30) + manager.create_snapshot(workspace_id, "checkpoint") + export_path = tmp_path / "exported-note.txt" + manager.export_workspace(workspace_id, "note.txt", output_path=export_path) + manager.start_service( + workspace_id, + "app", + command='sh -lc \'trap "exit 0" TERM; touch .ready; while true; do sleep 60; done\'', + readiness={"type": "file", "path": ".ready"}, + ) + manager.stop_service(workspace_id, "app") + + summary = manager.summarize_workspace(workspace_id) + + assert summary["workspace_id"] == workspace_id + assert summary["name"] == "review-eval" + assert summary["labels"] == {"suite": "smoke"} + assert summary["outcome"]["command_count"] == 1 + assert summary["outcome"]["export_count"] == 1 + assert summary["outcome"]["snapshot_count"] == 1 + assert summary["commands"]["total"] == 1 + assert summary["commands"]["recent"][0]["command"] == "cat note.txt" + assert [event["event_kind"] for event in summary["edits"]["recent"]] == [ + "patch_apply", + "file_write", + "sync_push", + ] + assert summary["changes"]["available"] is True + assert summary["changes"]["changed"] is True + assert summary["changes"]["summary"]["total"] == 4 + assert summary["services"]["current"][0]["service_name"] == "app" + assert [event["event_kind"] for event in summary["services"]["recent"]] == [ + "service_stop", + "service_start", + ] + assert summary["artifacts"]["exports"][0]["workspace_path"] == "/workspace/note.txt" + assert summary["snapshots"]["named_count"] == 1 + assert summary["snapshots"]["recent"][0]["snapshot_name"] == "checkpoint" + + +def test_workspace_summary_degrades_gracefully_for_stopped_and_legacy_workspaces( + tmp_path: Path, +) -> None: + seed_dir = tmp_path / "seed" + seed_dir.mkdir() + (seed_dir / "note.txt").write_text("hello\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + stopped_workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + )["workspace_id"] + ) + manager.exec_workspace(stopped_workspace_id, command="cat note.txt", timeout_seconds=30) + manager.stop_workspace(stopped_workspace_id) + stopped_summary = manager.summarize_workspace(stopped_workspace_id) + assert stopped_summary["commands"]["total"] == 1 + assert stopped_summary["changes"]["available"] is False + assert "must be in 'started' state" in str(stopped_summary["changes"]["reason"]) + + legacy_workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + )["workspace_id"] + ) + baseline_path = ( + tmp_path / "vms" / "workspaces" / legacy_workspace_id / "baseline" / "workspace.tar" + ) + baseline_path.unlink() + legacy_summary = manager.summarize_workspace(legacy_workspace_id) + assert legacy_summary["changes"]["available"] is False + assert "baseline snapshot" in str(legacy_summary["changes"]["reason"]) + + def test_workspace_file_ops_and_patch_round_trip(tmp_path: Path) -> None: seed_dir = tmp_path / "seed" seed_dir.mkdir() diff --git a/tests/test_workspace_use_case_smokes.py b/tests/test_workspace_use_case_smokes.py index b02fd10..8297c22 100644 --- a/tests/test_workspace_use_case_smokes.py +++ b/tests/test_workspace_use_case_smokes.py @@ -391,6 +391,69 @@ class _FakePyro: "workspace_reset": {"snapshot_name": snapshot}, } + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + changed = self._diff_changed(workspace) + return { + "workspace_id": workspace_id, + "name": workspace.name, + "labels": dict(workspace.labels), + "environment": workspace.environment, + "state": "started", + "last_activity_at": workspace.last_activity_at, + "session_started_at": workspace.created_at, + "outcome": { + "command_count": 0, + "last_command": None, + "service_count": len(workspace.services), + "running_service_count": sum( + 1 + for service in workspace.services.values() + if service["state"] == "running" + ), + "export_count": 1, + "snapshot_count": max(len(workspace.snapshots) - 1, 0), + "reset_count": workspace.reset_count, + }, + "commands": {"total": 0, "recent": []}, + "edits": {"recent": []}, + "changes": { + "available": True, + "reason": None, + "changed": changed, + "summary": {"total": 1 if changed else 0}, + "entries": ( + [ + { + "path": "/workspace/artifact.txt", + "status": "modified", + "artifact_type": "file", + } + ] + if changed + else [] + ), + }, + "services": { + "current": [ + {"service_name": name, "state": service["state"]} + for name, service in sorted(workspace.services.items()) + ], + "recent": [], + }, + "artifacts": { + "exports": [ + { + "workspace_path": "review-report.txt", + "output_path": str( + self._workspace_dir(workspace_id) / "exported-review.txt" + ), + } + ] + }, + "snapshots": {"named_count": max(len(workspace.snapshots) - 1, 0), "recent": []}, + } + def open_shell(self, workspace_id: str, **_: Any) -> dict[str, Any]: workspace = self._resolve_workspace(workspace_id) self._shell_counter += 1 diff --git a/uv.lock b/uv.lock index cc18ce9..5944d05 100644 --- a/uv.lock +++ b/uv.lock @@ -715,7 +715,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "4.2.0" +version = "4.3.0" source = { editable = "." } dependencies = [ { name = "mcp" },