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" },