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:
parent
899a6760c4
commit
dc86d84e96
24 changed files with 994 additions and 31 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -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`,
|
||||||
|
|
|
||||||
18
Makefile
18
Makefile
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# `4.3.0` Reviewable Agent Output
|
# `4.3.0` Reviewable Agent Output
|
||||||
|
|
||||||
Status: Planned
|
Status: Done
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
2
uv.lock
generated
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue