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.
This commit is contained in:
Thales Maciel 2026-03-13 19:21:11 -03:00
parent 899a6760c4
commit dc86d84e96
24 changed files with 994 additions and 31 deletions

View file

@ -2,6 +2,17 @@
All notable user-visible changes to `pyro-mcp` are documented here. 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 ## 4.2.0
- Added host bootstrap and repair helpers with `pyro host connect`, - Added host bootstrap and repair helpers with `pyro host connect`,

View file

@ -83,15 +83,15 @@ test:
check: lint typecheck test check: lint typecheck test
dist-check: dist-check:
uv run pyro --version uv run python -m pyro_mcp.cli --version
uv run pyro --help >/dev/null uv run python -m pyro_mcp.cli --help >/dev/null
uv run pyro host --help >/dev/null uv run python -m pyro_mcp.cli host --help >/dev/null
uv run pyro host doctor >/dev/null uv run python -m pyro_mcp.cli host doctor >/dev/null
uv run pyro mcp --help >/dev/null uv run python -m pyro_mcp.cli mcp --help >/dev/null
uv run pyro run --help >/dev/null uv run python -m pyro_mcp.cli run --help >/dev/null
uv run pyro env list >/dev/null uv run python -m pyro_mcp.cli env list >/dev/null
uv run pyro env inspect debian:12 >/dev/null uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null
uv run pyro doctor >/dev/null uv run python -m pyro_mcp.cli doctor >/dev/null
pypi-publish: pypi-publish:
@if [ -z "$$TWINE_PASSWORD" ]; then \ @if [ -z "$$TWINE_PASSWORD" ]; then \

View file

@ -30,7 +30,7 @@ SDK-first platform.
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - 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) - 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/) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
## Who It's For ## Who It's For
@ -76,7 +76,7 @@ What success looks like:
```bash ```bash
Platform: linux-x86_64 Platform: linux-x86_64
Runtime: PASS Runtime: PASS
Catalog version: 4.2.0 Catalog version: 4.3.0
... ...
[pull] phase=install environment=debian:12 [pull] phase=install environment=debian:12
[pull] phase=ready 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 file read "$WORKSPACE_ID" note.txt --content-only
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
pyro workspace summary "$WORKSPACE_ID"
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt

View file

@ -27,7 +27,7 @@ Networking: tun=yes ip_forward=yes
```bash ```bash
$ uvx --from pyro-mcp pyro env list $ 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 [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-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. 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 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 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 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 snapshot create "$WORKSPACE_ID" checkpoint
$ uvx --from pyro-mcp pyro workspace reset "$WORKSPACE_ID" --snapshot 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 $ uvx --from pyro-mcp pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt

View file

@ -93,7 +93,7 @@ uvx --from pyro-mcp pyro env list
Expected output: Expected output:
```bash ```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 [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-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. 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 file read "$WORKSPACE_ID" note.txt --content-only
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
pyro workspace summary "$WORKSPACE_ID"
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt

View file

@ -117,6 +117,7 @@ setup and repair path for supported hosts.
- `workspace_sync_push` - `workspace_sync_push`
- `workspace_exec` - `workspace_exec`
- `workspace_logs` - `workspace_logs`
- `workspace_summary`
- `workspace_file_list` - `workspace_file_list`
- `workspace_file_read` - `workspace_file_read`
- `workspace_file_write` - `workspace_file_write`
@ -133,6 +134,7 @@ That is enough for the normal persistent editing loop:
- sync or seed repo content - sync or seed repo content
- inspect and edit files without shell quoting - inspect and edit files without shell quoting
- run commands repeatedly in one sandbox - run commands repeatedly in one sandbox
- review the current session in one concise summary
- diff and export results - diff and export results
- reset and retry - reset and retry
- delete the workspace when the task is done - delete the workspace when the task is done

View file

@ -6,7 +6,7 @@ goal:
make the core agent-workspace use cases feel trivial from a chat-driven LLM make the core agent-workspace use cases feel trivial from a chat-driven LLM
interface. interface.
Current baseline is `4.2.0`: Current baseline is `4.3.0`:
- `pyro mcp serve` is now the default product entrypoint - `pyro mcp serve` is now the default product entrypoint
- `workspace-core` is now the default MCP profile - `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 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 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 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 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 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, - `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 Codex, and OpenCode users can connect or repair the supported chat-host path
without manually composing raw MCP commands or config edits. 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: 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.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) - [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md)

View file

@ -1,6 +1,6 @@
# `4.3.0` Reviewable Agent Output # `4.3.0` Reviewable Agent Output
Status: Planned Status: Done
## Goal ## Goal

View file

@ -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 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 up on both success and failure. Treat `make smoke-use-cases` as the trustworthy
guest-backed verification path for the advertised workspace workflows. 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
```

View file

@ -16,9 +16,10 @@ Chat-host recipe:
1. Create a named snapshot before the review starts. 1. Create a named snapshot before the review starts.
2. Open a readable PTY shell and inspect the checklist interactively. 2. Open a readable PTY shell and inspect the checklist interactively.
3. Run the review or evaluation script in the same workspace. 3. Run the review or evaluation script in the same workspace.
4. Export the final report. 4. Capture `workspace summary` to review what changed and what to export.
5. Reset back to the snapshot if the review branch goes sideways. 5. Export the final report.
6. Delete the workspace when the evaluation is done. 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, This is the stable shell-facing story: readable PTY output for chat loops,
checkpointed evaluation, explicit export, and reset when a review branch goes checkpointed evaluation, explicit export, and reset when a review branch goes

View file

@ -1,6 +1,6 @@
[project] [project]
name = "pyro-mcp" name = "pyro-mcp"
version = "4.2.0" version = "4.3.0"
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }

View file

@ -204,6 +204,9 @@ class Pyro:
def logs_workspace(self, workspace_id: str) -> dict[str, Any]: def logs_workspace(self, workspace_id: str) -> dict[str, Any]:
return self._manager.logs_workspace(workspace_id) 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( def export_workspace(
self, self,
workspace_id: str, workspace_id: str,
@ -818,6 +821,13 @@ class Pyro:
"""Return persisted command history for one workspace.""" """Return persisted command history for one workspace."""
return self.logs_workspace(workspace_id) 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"): if _enabled("workspace_export"):
@server.tool() @server.tool()

View file

@ -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) 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: def _print_workspace_snapshot_human(payload: dict[str, Any], *, prefix: str) -> None:
snapshot = payload.get("snapshot") snapshot = payload.get("snapshot")
if not isinstance(snapshot, dict): 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 create debian:12 --seed-path ./repo --id-only
pyro workspace sync push WORKSPACE_ID ./changes pyro workspace sync push WORKSPACE_ID ./changes
pyro workspace exec WORKSPACE_ID -- cat note.txt pyro workspace exec WORKSPACE_ID -- cat note.txt
pyro workspace summary WORKSPACE_ID
pyro workspace diff WORKSPACE_ID pyro workspace diff WORKSPACE_ID
pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint
@ -1178,6 +1320,7 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace start WORKSPACE_ID pyro workspace start WORKSPACE_ID
pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace summary WORKSPACE_ID
pyro workspace diff WORKSPACE_ID pyro workspace diff WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID --id-only pyro workspace shell open WORKSPACE_ID --id-only
@ -2317,6 +2460,33 @@ while true; do sleep 60; done'
action="store_true", action="store_true",
help="Print structured JSON instead of human-readable output.", 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( workspace_logs_parser = workspace_subparsers.add_parser(
"logs", "logs",
help="Show command history for one workspace.", help="Show command history for one workspace.",
@ -3305,6 +3475,13 @@ def main() -> None:
else: else:
_print_workspace_summary_human(payload, action="Workspace") _print_workspace_summary_human(payload, action="Workspace")
return 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": if args.workspace_command == "logs":
payload = pyro.logs_workspace(args.workspace_id) payload = pyro.logs_workspace(args.workspace_id)
if bool(args.json): if bool(args.json):

View file

@ -44,6 +44,7 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
"start", "start",
"status", "status",
"stop", "stop",
"summary",
"sync", "sync",
"update", "update",
) )
@ -120,6 +121,7 @@ PUBLIC_CLI_WORKSPACE_SNAPSHOT_LIST_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_START_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_START_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_STATUS_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_STATUS_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_STOP_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_SYNC_PUSH_FLAGS = ("--dest", "--json")
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS = ( PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS = (
"--name", "--name",
@ -184,6 +186,7 @@ PUBLIC_SDK_METHODS = (
"stop_service", "stop_service",
"stop_vm", "stop_vm",
"stop_workspace", "stop_workspace",
"summarize_workspace",
"update_workspace", "update_workspace",
"write_shell", "write_shell",
"write_workspace_file", "write_workspace_file",
@ -228,6 +231,7 @@ PUBLIC_MCP_TOOLS = (
"workspace_logs", "workspace_logs",
"workspace_patch_apply", "workspace_patch_apply",
"workspace_reset", "workspace_reset",
"workspace_summary",
"workspace_start", "workspace_start",
"workspace_status", "workspace_status",
"workspace_stop", "workspace_stop",
@ -249,6 +253,7 @@ PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = (
"workspace_logs", "workspace_logs",
"workspace_patch_apply", "workspace_patch_apply",
"workspace_reset", "workspace_reset",
"workspace_summary",
"workspace_status", "workspace_status",
"workspace_sync_push", "workspace_sync_push",
"workspace_update", "workspace_update",

View file

@ -19,7 +19,7 @@ from typing import Any
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
DEFAULT_ENVIRONMENT_VERSION = "1.0.0" DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
DEFAULT_CATALOG_VERSION = "4.2.0" DEFAULT_CATALOG_VERSION = "4.3.0"
OCI_MANIFEST_ACCEPT = ", ".join( OCI_MANIFEST_ACCEPT = ", ".join(
( (
"application/vnd.oci.image.index.v1+json", "application/vnd.oci.image.index.v1+json",
@ -48,7 +48,7 @@ class VmEnvironment:
oci_repository: str | None = None oci_repository: str | None = None
oci_reference: str | None = None oci_reference: str | None = None
source_digest: 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) @dataclass(frozen=True)

View file

@ -79,12 +79,13 @@ DEFAULT_TIMEOUT_SECONDS = 30
DEFAULT_TTL_SECONDS = 600 DEFAULT_TTL_SECONDS = 600
DEFAULT_ALLOW_HOST_COMPAT = False DEFAULT_ALLOW_HOST_COMPAT = False
WORKSPACE_LAYOUT_VERSION = 8 WORKSPACE_LAYOUT_VERSION = 9
WORKSPACE_BASELINE_DIRNAME = "baseline" WORKSPACE_BASELINE_DIRNAME = "baseline"
WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar" WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar"
WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots" WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots"
WORKSPACE_DIRNAME = "workspace" WORKSPACE_DIRNAME = "workspace"
WORKSPACE_COMMANDS_DIRNAME = "commands" WORKSPACE_COMMANDS_DIRNAME = "commands"
WORKSPACE_REVIEW_DIRNAME = "review"
WORKSPACE_SHELLS_DIRNAME = "shells" WORKSPACE_SHELLS_DIRNAME = "shells"
WORKSPACE_SERVICES_DIRNAME = "services" WORKSPACE_SERVICES_DIRNAME = "services"
WORKSPACE_SECRETS_DIRNAME = "secrets" 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"] WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"]
WorkspaceSeedOriginKind = Literal["empty", "manual_seed_path", "project_path", "repo_url"] WorkspaceSeedOriginKind = Literal["empty", "manual_seed_path", "project_path", "repo_url"]
WorkspaceArtifactType = Literal["file", "directory", "symlink"] 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"] WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"]
WorkspaceSnapshotKind = Literal["baseline", "named"] WorkspaceSnapshotKind = Literal["baseline", "named"]
WorkspaceSecretSourceKind = Literal["literal", "file"] 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 @dataclass
class WorkspaceSnapshotRecord: class WorkspaceSnapshotRecord:
"""Persistent snapshot metadata stored on disk per workspace.""" """Persistent snapshot metadata stored on disk per workspace."""
@ -3775,6 +3815,7 @@ class VmManager:
runtime_dir = self._workspace_runtime_dir(workspace_id) runtime_dir = self._workspace_runtime_dir(workspace_id)
host_workspace_dir = self._workspace_host_dir(workspace_id) host_workspace_dir = self._workspace_host_dir(workspace_id)
commands_dir = self._workspace_commands_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) shells_dir = self._workspace_shells_dir(workspace_id)
services_dir = self._workspace_services_dir(workspace_id) services_dir = self._workspace_services_dir(workspace_id)
secrets_dir = self._workspace_secrets_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) workspace_dir.mkdir(parents=True, exist_ok=False)
host_workspace_dir.mkdir(parents=True, exist_ok=True) host_workspace_dir.mkdir(parents=True, exist_ok=True)
commands_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) shells_dir.mkdir(parents=True, exist_ok=True)
services_dir.mkdir(parents=True, exist_ok=True) services_dir.mkdir(parents=True, exist_ok=True)
secrets_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.last_error = instance.last_error
workspace.metadata = dict(instance.metadata) workspace.metadata = dict(instance.metadata)
self._touch_workspace_activity_locked(workspace) 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) self._save_workspace_locked(workspace)
return { return {
"workspace_id": workspace_id, "workspace_id": workspace_id,
@ -3993,8 +4049,8 @@ class VmManager:
def export_workspace( def export_workspace(
self, self,
workspace_id: str, workspace_id: str,
*,
path: str, path: str,
*,
output_path: str | Path, output_path: str | Path,
) -> dict[str, Any]: ) -> dict[str, Any]:
normalized_path, _ = _normalize_workspace_destination(path) normalized_path, _ = _normalize_workspace_destination(path)
@ -4026,6 +4082,23 @@ class VmManager:
workspace.firecracker_pid = instance.firecracker_pid workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata) 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) self._save_workspace_locked(workspace)
return { return {
"workspace_id": workspace_id, "workspace_id": workspace_id,
@ -4185,6 +4258,22 @@ class VmManager:
workspace.firecracker_pid = instance.firecracker_pid workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata) 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) self._save_workspace_locked(workspace)
return { return {
"workspace_id": workspace_id, "workspace_id": workspace_id,
@ -4302,6 +4391,17 @@ class VmManager:
workspace.last_error = instance.last_error workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata) workspace.metadata = dict(instance.metadata)
self._touch_workspace_activity_locked(workspace) 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) self._save_workspace_locked(workspace)
return { return {
"workspace_id": workspace_id, "workspace_id": workspace_id,
@ -4379,6 +4479,17 @@ class VmManager:
self._touch_workspace_activity_locked(workspace) self._touch_workspace_activity_locked(workspace)
self._save_workspace_locked(workspace) self._save_workspace_locked(workspace)
self._save_workspace_snapshot_locked(snapshot) 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 { return {
"workspace_id": workspace_id, "workspace_id": workspace_id,
"snapshot": self._serialize_workspace_snapshot(snapshot), "snapshot": self._serialize_workspace_snapshot(snapshot),
@ -4412,6 +4523,11 @@ class VmManager:
self._load_workspace_snapshot_locked(workspace_id, normalized_snapshot_name) self._load_workspace_snapshot_locked(workspace_id, normalized_snapshot_name)
self._delete_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._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) self._save_workspace_locked(workspace)
return { return {
"workspace_id": workspace_id, "workspace_id": workspace_id,
@ -5013,6 +5129,24 @@ class VmManager:
self._touch_workspace_activity_locked(workspace) self._touch_workspace_activity_locked(workspace)
self._save_workspace_locked(workspace) self._save_workspace_locked(workspace)
self._save_workspace_service_locked(service) 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) return self._serialize_workspace_service(service)
def list_services(self, workspace_id: str) -> dict[str, Any]: def list_services(self, workspace_id: str) -> dict[str, Any]:
@ -5132,6 +5266,18 @@ class VmManager:
workspace.firecracker_pid = instance.firecracker_pid workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata) 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_locked(workspace)
self._save_workspace_service_locked(service) self._save_workspace_service_locked(service)
return self._serialize_workspace_service(service) return self._serialize_workspace_service(service)
@ -5165,6 +5311,157 @@ class VmManager:
"entries": redacted_entries, "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]: def stop_workspace(self, workspace_id: str) -> dict[str, Any]:
with self._lock: with self._lock:
workspace = self._load_workspace_locked(workspace_id) workspace = self._load_workspace_locked(workspace_id)
@ -5795,6 +6092,9 @@ class VmManager:
def _workspace_commands_dir(self, workspace_id: str) -> Path: def _workspace_commands_dir(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / WORKSPACE_COMMANDS_DIRNAME 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: def _workspace_shells_dir(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / WORKSPACE_SHELLS_DIRNAME 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: def _workspace_shell_record_path(self, workspace_id: str, shell_id: str) -> Path:
return self._workspace_shells_dir(workspace_id) / f"{shell_id}.json" 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: def _workspace_service_record_path(self, workspace_id: str, service_name: str) -> Path:
return self._workspace_services_dir(workspace_id) / f"{service_name}.json" return self._workspace_services_dir(workspace_id) / f"{service_name}.json"
@ -6004,6 +6307,46 @@ class VmManager:
entries.append(entry) entries.append(entry)
return entries 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: def _workspace_instance_for_live_shell_locked(self, workspace: WorkspaceRecord) -> VmInstance:
instance = self._workspace_instance_for_live_operation_locked( instance = self._workspace_instance_for_live_operation_locked(
workspace, workspace,
@ -6360,10 +6703,12 @@ class VmManager:
shutil.rmtree(self._workspace_runtime_dir(workspace_id), ignore_errors=True) 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_host_dir(workspace_id), ignore_errors=True)
shutil.rmtree(self._workspace_commands_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_shells_dir(workspace_id), ignore_errors=True)
shutil.rmtree(self._workspace_services_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_host_dir(workspace_id).mkdir(parents=True, exist_ok=True)
self._workspace_commands_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_shells_dir(workspace_id).mkdir(parents=True, exist_ok=True)
self._workspace_services_dir(workspace_id).mkdir(parents=True, exist_ok=True) self._workspace_services_dir(workspace_id).mkdir(parents=True, exist_ok=True)

View file

@ -457,6 +457,11 @@ def _scenario_review_eval(pyro: Pyro, *, root: Path, environment: str) -> None:
assert int(rerun["exit_code"]) == 0, rerun assert int(rerun["exit_code"]) == 0, rerun
pyro.export_workspace(workspace_id, "review-report.txt", output_path=export_path) pyro.export_workspace(workspace_id, "review-report.txt", output_path=export_path)
assert export_path.read_text(encoding="utf-8") == "review=pass\n" 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: finally:
if shell_id is not None and workspace_id is not None: if shell_id is not None and workspace_id is not None:
try: try:

View file

@ -455,6 +455,7 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
services = pyro.list_services(workspace_id) services = pyro.list_services(workspace_id)
service_status = pyro.status_service(workspace_id, "app") service_status = pyro.status_service(workspace_id, "app")
service_logs = pyro.logs_service(workspace_id, "app", all=True) service_logs = pyro.logs_service(workspace_id, "app", all=True)
summary = pyro.summarize_workspace(workspace_id)
reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint") reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint")
deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint") deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint")
status = pyro.status_workspace(workspace_id) 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_status["state"] == "running"
assert service_logs["stderr"].count("[REDACTED]") >= 1 assert service_logs["stderr"].count("[REDACTED]") >= 1
assert service_logs["tail_lines"] is None 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["workspace_reset"]["snapshot_name"] == "checkpoint"
assert reset["secrets"] == created["secrets"] assert reset["secrets"] == created["secrets"]
assert deleted_snapshot["deleted"] is True 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})) calls.append(("logs_workspace", {"workspace_id": workspace_id}))
return {"workspace_id": workspace_id, "count": 0, "entries": []} 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( def open_shell(
self, self,
workspace_id: str, workspace_id: str,
@ -1185,6 +1197,9 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
status = _extract_structured( status = _extract_structured(
await server.call_tool("workspace_status", {"workspace_id": "workspace-123"}) 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( logs = _extract_structured(
await server.call_tool("workspace_logs", {"workspace_id": "workspace-123"}) 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 ( return (
status, status,
summary,
logs, logs,
opened, opened,
read, read,
@ -1300,13 +1316,15 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
results = asyncio.run(_run()) results = asyncio.run(_run())
assert results[0]["state"] == "started" assert results[0]["state"] == "started"
assert results[1]["count"] == 0 assert results[1]["workspace_id"] == "workspace-123"
assert results[2]["shell_id"] == "shell-1" assert results[2]["count"] == 0
assert results[6]["closed"] is True assert results[3]["shell_id"] == "shell-1"
assert results[7]["state"] == "running" assert results[7]["closed"] is True
assert results[10]["state"] == "running" assert results[8]["state"] == "running"
assert results[11]["state"] == "running"
assert calls == [ assert calls == [
("status_workspace", {"workspace_id": "workspace-123"}), ("status_workspace", {"workspace_id": "workspace-123"}),
("summarize_workspace", {"workspace_id": "workspace-123"}),
("logs_workspace", {"workspace_id": "workspace-123"}), ("logs_workspace", {"workspace_id": "workspace-123"}),
( (
"open_shell", "open_shell",

View file

@ -39,6 +39,7 @@ def test_cli_help_guides_first_run() -> None:
assert "pyro host print-config opencode" in help_text assert "pyro host print-config opencode" in help_text
assert "If you want terminal-level visibility into the workspace model:" 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 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 snapshot create WORKSPACE_ID checkpoint" in help_text
assert "pyro workspace reset WORKSPACE_ID --snapshot 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 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 start WORKSPACE_ID" in workspace_help
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" 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 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 assert "pyro workspace shell open WORKSPACE_ID --id-only" in workspace_help
workspace_create_help = _subparser_choice( 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 "--label" in workspace_update_help
assert "--clear-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( workspace_file_help = _subparser_choice(
_subparser_choice(parser, "workspace"), "file" _subparser_choice(parser, "workspace"), "file"
).format_help() ).format_help()
@ -2515,6 +2523,170 @@ def test_cli_workspace_logs_prints_json(
assert payload["count"] == 0 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( def test_cli_workspace_delete_prints_human(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str], 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 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( def test_cli_workspace_shell_write_signal_close_json(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],

View file

@ -58,6 +58,7 @@ from pyro_mcp.contract import (
PUBLIC_CLI_WORKSPACE_START_FLAGS, PUBLIC_CLI_WORKSPACE_START_FLAGS,
PUBLIC_CLI_WORKSPACE_STOP_FLAGS, PUBLIC_CLI_WORKSPACE_STOP_FLAGS,
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS, PUBLIC_CLI_WORKSPACE_SUBCOMMANDS,
PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS,
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS, PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS,
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS, PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS,
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS, PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS,
@ -272,6 +273,11 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
).format_help() ).format_help()
for flag in PUBLIC_CLI_WORKSPACE_STOP_FLAGS: for flag in PUBLIC_CLI_WORKSPACE_STOP_FLAGS:
assert flag in workspace_stop_help_text 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( workspace_shell_help_text = _subparser_choice(
_subparser_choice(parser, "workspace"), _subparser_choice(parser, "workspace"),
"shell", "shell",

View file

@ -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( reset = _extract_structured(
await server.call_tool( await server.call_tool(
"workspace_reset", "workspace_reset",
@ -639,6 +642,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
service_status, service_status,
service_logs, service_logs,
service_stopped, service_stopped,
summary,
reset, reset,
deleted_snapshot, deleted_snapshot,
logs, logs,
@ -664,6 +668,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
service_status, service_status,
service_logs, service_logs,
service_stopped, service_stopped,
summary,
reset, reset,
deleted_snapshot, deleted_snapshot,
logs, 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["stderr"].count("[REDACTED]") >= 1
assert service_logs["tail_lines"] is None assert service_logs["tail_lines"] is None
assert service_stopped["state"] == "stopped" 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["workspace_reset"]["snapshot_name"] == "checkpoint"
assert reset["secrets"] == created["secrets"] assert reset["secrets"] == created["secrets"]
assert reset["command_count"] == 0 assert reset["command_count"] == 0

View file

@ -699,6 +699,124 @@ def test_workspace_diff_and_export_round_trip(tmp_path: Path) -> None:
assert logs["count"] == 0 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: def test_workspace_file_ops_and_patch_round_trip(tmp_path: Path) -> None:
seed_dir = tmp_path / "seed" seed_dir = tmp_path / "seed"
seed_dir.mkdir() seed_dir.mkdir()

View file

@ -391,6 +391,69 @@ class _FakePyro:
"workspace_reset": {"snapshot_name": snapshot}, "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]: def open_shell(self, workspace_id: str, **_: Any) -> dict[str, Any]:
workspace = self._resolve_workspace(workspace_id) workspace = self._resolve_workspace(workspace_id)
self._shell_counter += 1 self._shell_counter += 1

2
uv.lock generated
View file

@ -715,7 +715,7 @@ crypto = [
[[package]] [[package]]
name = "pyro-mcp" name = "pyro-mcp"
version = "4.2.0" version = "4.3.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mcp" }, { name = "mcp" },