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

@ -204,6 +204,9 @@ class Pyro:
def logs_workspace(self, workspace_id: str) -> dict[str, Any]:
return self._manager.logs_workspace(workspace_id)
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
return self._manager.summarize_workspace(workspace_id)
def export_workspace(
self,
workspace_id: str,
@ -818,6 +821,13 @@ class Pyro:
"""Return persisted command history for one workspace."""
return self.logs_workspace(workspace_id)
if _enabled("workspace_summary"):
@server.tool()
async def workspace_summary(workspace_id: str) -> dict[str, Any]:
"""Summarize the current workspace session for human review."""
return self.summarize_workspace(workspace_id)
if _enabled("workspace_export"):
@server.tool()

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)
def _print_workspace_review_summary_human(payload: dict[str, Any]) -> None:
print(f"Workspace review: {str(payload.get('workspace_id', 'unknown'))}")
name = payload.get("name")
if isinstance(name, str) and name != "":
print(f"Name: {name}")
labels = payload.get("labels")
if isinstance(labels, dict) and labels:
rendered_labels = ", ".join(
f"{str(key)}={str(value)}" for key, value in sorted(labels.items())
)
print(f"Labels: {rendered_labels}")
print(f"Environment: {str(payload.get('environment', 'unknown'))}")
print(f"State: {str(payload.get('state', 'unknown'))}")
print(f"Last activity at: {payload.get('last_activity_at')}")
print(f"Session started at: {payload.get('session_started_at')}")
outcome = payload.get("outcome")
if isinstance(outcome, dict):
print(
"Outcome: "
f"commands={int(outcome.get('command_count', 0))} "
f"services={int(outcome.get('running_service_count', 0))}/"
f"{int(outcome.get('service_count', 0))} "
f"exports={int(outcome.get('export_count', 0))} "
f"snapshots={int(outcome.get('snapshot_count', 0))} "
f"resets={int(outcome.get('reset_count', 0))}"
)
last_command = outcome.get("last_command")
if isinstance(last_command, dict):
print(
"Last command: "
f"{str(last_command.get('command', 'unknown'))} "
f"(exit_code={int(last_command.get('exit_code', -1))})"
)
def _print_events(title: str, events: object, *, formatter: Any) -> None:
if not isinstance(events, list) or not events:
return
print(f"{title}:")
for event in events:
if not isinstance(event, dict):
continue
print(f"- {formatter(event)}")
commands = payload.get("commands")
if isinstance(commands, dict):
_print_events(
"Recent commands",
commands.get("recent"),
formatter=lambda event: (
f"#{int(event.get('sequence', 0))} "
f"exit={int(event.get('exit_code', -1))} "
f"cwd={str(event.get('cwd', WORKSPACE_GUEST_PATH))} "
f"cmd={str(event.get('command', ''))}"
),
)
edits = payload.get("edits")
if isinstance(edits, dict):
_print_events(
"Recent edits",
edits.get("recent"),
formatter=lambda event: (
f"{str(event.get('event_kind', 'edit'))} "
f"at={event.get('recorded_at')} "
f"path={event.get('path') or event.get('destination') or 'n/a'}"
),
)
changes = payload.get("changes")
if isinstance(changes, dict):
if not bool(changes.get("available")):
print(f"Changes: unavailable ({str(changes.get('reason', 'unknown reason'))})")
elif not bool(changes.get("changed")):
print("Changes: no current workspace changes.")
else:
summary = changes.get("summary")
if isinstance(summary, dict):
print(
"Changes: "
f"total={int(summary.get('total', 0))} "
f"added={int(summary.get('added', 0))} "
f"modified={int(summary.get('modified', 0))} "
f"deleted={int(summary.get('deleted', 0))} "
f"type_changed={int(summary.get('type_changed', 0))} "
f"non_text={int(summary.get('non_text', 0))}"
)
_print_events(
"Top changed paths",
changes.get("entries"),
formatter=lambda event: (
f"{str(event.get('status', 'changed'))} "
f"{str(event.get('path', 'unknown'))} "
f"[{str(event.get('artifact_type', 'unknown'))}]"
),
)
services = payload.get("services")
if isinstance(services, dict):
_print_events(
"Current services",
services.get("current"),
formatter=lambda event: (
f"{str(event.get('service_name', 'unknown'))} "
f"state={str(event.get('state', 'unknown'))}"
),
)
_print_events(
"Recent service events",
services.get("recent"),
formatter=lambda event: (
f"{str(event.get('event_kind', 'service'))} "
f"{str(event.get('service_name', 'unknown'))} "
f"state={str(event.get('state', 'unknown'))}"
),
)
artifacts = payload.get("artifacts")
if isinstance(artifacts, dict):
_print_events(
"Recent exports",
artifacts.get("exports"),
formatter=lambda event: (
f"{str(event.get('workspace_path', 'unknown'))} -> "
f"{str(event.get('output_path', 'unknown'))}"
),
)
snapshots = payload.get("snapshots")
if isinstance(snapshots, dict):
print(f"Named snapshots: {int(snapshots.get('named_count', 0))}")
_print_events(
"Recent snapshot events",
snapshots.get("recent"),
formatter=lambda event: (
f"{str(event.get('event_kind', 'snapshot'))} "
f"{str(event.get('snapshot_name', 'unknown'))}"
),
)
def _print_workspace_snapshot_human(payload: dict[str, Any], *, prefix: str) -> None:
snapshot = payload.get("snapshot")
if not isinstance(snapshot, dict):
@ -765,6 +906,7 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace create debian:12 --seed-path ./repo --id-only
pyro workspace sync push WORKSPACE_ID ./changes
pyro workspace exec WORKSPACE_ID -- cat note.txt
pyro workspace summary WORKSPACE_ID
pyro workspace diff WORKSPACE_ID
pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
@ -1178,6 +1320,7 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace start WORKSPACE_ID
pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace summary WORKSPACE_ID
pyro workspace diff WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID --id-only
@ -2317,6 +2460,33 @@ while true; do sleep 60; done'
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_summary_parser = workspace_subparsers.add_parser(
"summary",
help="Summarize the current workspace session for review.",
description=(
"Summarize the current workspace session since the last reset, including recent "
"commands, edits, services, exports, snapshots, and current change status."
),
epilog=dedent(
"""
Example:
pyro workspace summary WORKSPACE_ID
Use `workspace logs`, `workspace diff`, and `workspace export` for drill-down.
"""
),
formatter_class=_HelpFormatter,
)
workspace_summary_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_summary_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_logs_parser = workspace_subparsers.add_parser(
"logs",
help="Show command history for one workspace.",
@ -3305,6 +3475,13 @@ def main() -> None:
else:
_print_workspace_summary_human(payload, action="Workspace")
return
if args.workspace_command == "summary":
payload = pyro.summarize_workspace(args.workspace_id)
if bool(args.json):
_print_json(payload)
else:
_print_workspace_review_summary_human(payload)
return
if args.workspace_command == "logs":
payload = pyro.logs_workspace(args.workspace_id)
if bool(args.json):

View file

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

View file

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

View file

@ -79,12 +79,13 @@ DEFAULT_TIMEOUT_SECONDS = 30
DEFAULT_TTL_SECONDS = 600
DEFAULT_ALLOW_HOST_COMPAT = False
WORKSPACE_LAYOUT_VERSION = 8
WORKSPACE_LAYOUT_VERSION = 9
WORKSPACE_BASELINE_DIRNAME = "baseline"
WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar"
WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots"
WORKSPACE_DIRNAME = "workspace"
WORKSPACE_COMMANDS_DIRNAME = "commands"
WORKSPACE_REVIEW_DIRNAME = "review"
WORKSPACE_SHELLS_DIRNAME = "shells"
WORKSPACE_SERVICES_DIRNAME = "services"
WORKSPACE_SECRETS_DIRNAME = "secrets"
@ -118,6 +119,16 @@ WORKSPACE_LABEL_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"]
WorkspaceSeedOriginKind = Literal["empty", "manual_seed_path", "project_path", "repo_url"]
WorkspaceArtifactType = Literal["file", "directory", "symlink"]
WorkspaceReviewEventKind = Literal[
"file_write",
"patch_apply",
"service_start",
"service_stop",
"snapshot_create",
"snapshot_delete",
"sync_push",
"workspace_export",
]
WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"]
WorkspaceSnapshotKind = Literal["baseline", "named"]
WorkspaceSecretSourceKind = Literal["literal", "file"]
@ -317,6 +328,35 @@ class WorkspaceSecretRecord:
)
@dataclass(frozen=True)
class WorkspaceReviewEventRecord:
"""Persistent concise review event metadata stored on disk per workspace."""
workspace_id: str
event_kind: WorkspaceReviewEventKind
recorded_at: float
payload: dict[str, Any]
def to_payload(self) -> dict[str, Any]:
return {
"layout_version": WORKSPACE_LAYOUT_VERSION,
"workspace_id": self.workspace_id,
"event_kind": self.event_kind,
"recorded_at": self.recorded_at,
"payload": self.payload,
}
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> WorkspaceReviewEventRecord:
raw_payload = payload.get("payload")
return cls(
workspace_id=str(payload["workspace_id"]),
event_kind=cast(WorkspaceReviewEventKind, str(payload["event_kind"])),
recorded_at=float(payload["recorded_at"]),
payload=dict(raw_payload) if isinstance(raw_payload, dict) else {},
)
@dataclass
class WorkspaceSnapshotRecord:
"""Persistent snapshot metadata stored on disk per workspace."""
@ -3775,6 +3815,7 @@ class VmManager:
runtime_dir = self._workspace_runtime_dir(workspace_id)
host_workspace_dir = self._workspace_host_dir(workspace_id)
commands_dir = self._workspace_commands_dir(workspace_id)
review_dir = self._workspace_review_dir(workspace_id)
shells_dir = self._workspace_shells_dir(workspace_id)
services_dir = self._workspace_services_dir(workspace_id)
secrets_dir = self._workspace_secrets_dir(workspace_id)
@ -3783,6 +3824,7 @@ class VmManager:
workspace_dir.mkdir(parents=True, exist_ok=False)
host_workspace_dir.mkdir(parents=True, exist_ok=True)
commands_dir.mkdir(parents=True, exist_ok=True)
review_dir.mkdir(parents=True, exist_ok=True)
shells_dir.mkdir(parents=True, exist_ok=True)
services_dir.mkdir(parents=True, exist_ok=True)
secrets_dir.mkdir(parents=True, exist_ok=True)
@ -3912,6 +3954,20 @@ class VmManager:
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._touch_workspace_activity_locked(workspace)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="sync_push",
payload={
"mode": str(workspace_sync["mode"]),
"source_path": str(workspace_sync["source_path"]),
"destination": str(workspace_sync["destination"]),
"entry_count": int(workspace_sync["entry_count"]),
"bytes_written": int(workspace_sync["bytes_written"]),
"execution_mode": str(
instance.metadata.get("execution_mode", "pending")
),
},
)
self._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
@ -3993,8 +4049,8 @@ class VmManager:
def export_workspace(
self,
workspace_id: str,
*,
path: str,
*,
output_path: str | Path,
) -> dict[str, Any]:
normalized_path, _ = _normalize_workspace_destination(path)
@ -4026,6 +4082,23 @@ class VmManager:
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="workspace_export",
payload={
"workspace_path": normalized_path,
"output_path": str(Path(str(extracted["output_path"]))),
"artifact_type": str(extracted["artifact_type"]),
"entry_count": int(extracted["entry_count"]),
"bytes_written": int(extracted["bytes_written"]),
"execution_mode": str(
exported.get(
"execution_mode",
instance.metadata.get("execution_mode", "pending"),
)
),
},
)
self._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
@ -4185,6 +4258,22 @@ class VmManager:
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._touch_workspace_activity_locked(workspace)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="file_write",
payload={
"path": str(payload["path"]),
"size_bytes": int(payload["size_bytes"]),
"bytes_written": int(payload["bytes_written"]),
"execution_mode": str(
payload.get(
"execution_mode",
instance.metadata.get("execution_mode", "pending"),
)
),
},
)
self._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
@ -4302,6 +4391,17 @@ class VmManager:
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._touch_workspace_activity_locked(workspace)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="patch_apply",
payload={
"summary": dict(summary),
"entries": [dict(entry) for entry in entries[:10]],
"execution_mode": str(
instance.metadata.get("execution_mode", "pending")
),
},
)
self._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
@ -4379,6 +4479,17 @@ class VmManager:
self._touch_workspace_activity_locked(workspace)
self._save_workspace_locked(workspace)
self._save_workspace_snapshot_locked(snapshot)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="snapshot_create",
payload={
"snapshot_name": snapshot.snapshot_name,
"kind": snapshot.kind,
"entry_count": snapshot.entry_count,
"bytes_written": snapshot.bytes_written,
"created_at": snapshot.created_at,
},
)
return {
"workspace_id": workspace_id,
"snapshot": self._serialize_workspace_snapshot(snapshot),
@ -4412,6 +4523,11 @@ class VmManager:
self._load_workspace_snapshot_locked(workspace_id, normalized_snapshot_name)
self._delete_workspace_snapshot_locked(workspace_id, normalized_snapshot_name)
self._touch_workspace_activity_locked(workspace)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="snapshot_delete",
payload={"snapshot_name": normalized_snapshot_name},
)
self._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
@ -5013,6 +5129,24 @@ class VmManager:
self._touch_workspace_activity_locked(workspace)
self._save_workspace_locked(workspace)
self._save_workspace_service_locked(service)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="service_start",
payload={
"service_name": service.service_name,
"state": service.state,
"command": service.command,
"cwd": service.cwd,
"readiness": (
dict(service.readiness) if service.readiness is not None else None
),
"ready_at": service.ready_at,
"published_ports": [
_serialize_workspace_published_port_public(published_port)
for published_port in service.published_ports
],
},
)
return self._serialize_workspace_service(service)
def list_services(self, workspace_id: str) -> dict[str, Any]:
@ -5132,6 +5266,18 @@ class VmManager:
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._touch_workspace_activity_locked(workspace)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="service_stop",
payload={
"service_name": service.service_name,
"state": service.state,
"exit_code": service.exit_code,
"stop_reason": service.stop_reason,
"ended_at": service.ended_at,
},
)
self._save_workspace_locked(workspace)
self._save_workspace_service_locked(service)
return self._serialize_workspace_service(service)
@ -5165,6 +5311,157 @@ class VmManager:
"entries": redacted_entries,
}
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
self._ensure_workspace_not_expired_locked(workspace, time.time())
self._refresh_workspace_liveness_locked(workspace)
self._refresh_workspace_service_counts_locked(workspace)
self._save_workspace_locked(workspace)
command_entries = self._read_workspace_logs_locked(workspace.workspace_id)
recent_commands = [
{
"sequence": int(entry["sequence"]),
"command": str(entry["command"]),
"cwd": str(entry["cwd"]),
"exit_code": int(entry["exit_code"]),
"duration_ms": int(entry["duration_ms"]),
"execution_mode": str(entry["execution_mode"]),
"recorded_at": float(entry["recorded_at"]),
}
for entry in command_entries[-5:]
]
recent_commands.reverse()
review_events = self._list_workspace_review_events_locked(workspace.workspace_id)
def _recent_events(
kinds: set[WorkspaceReviewEventKind],
*,
limit: int = 5,
) -> list[dict[str, Any]]:
matched = [
{
"event_kind": event.event_kind,
"recorded_at": event.recorded_at,
**event.payload,
}
for event in review_events
if event.event_kind in kinds
]
matched = matched[-limit:]
matched.reverse()
return matched
current_services = [
self._serialize_workspace_service(service)
for service in self._list_workspace_services_locked(workspace.workspace_id)
]
current_services.sort(key=lambda item: str(item["service_name"]))
try:
snapshots = self._list_workspace_snapshots_locked(workspace)
named_snapshot_count = max(len(snapshots) - 1, 0)
except RuntimeError:
named_snapshot_count = 0
service_count = len(current_services)
running_service_count = sum(
1 for service in current_services if service["state"] == "running"
)
execution_mode = str(workspace.metadata.get("execution_mode", "pending"))
payload: dict[str, Any] = {
"workspace_id": workspace.workspace_id,
"name": workspace.name,
"labels": dict(workspace.labels),
"environment": workspace.environment,
"state": workspace.state,
"workspace_path": WORKSPACE_GUEST_PATH,
"execution_mode": execution_mode,
"last_activity_at": workspace.last_activity_at,
"session_started_at": (
workspace.last_reset_at
if workspace.last_reset_at is not None
else workspace.created_at
),
"outcome": {
"command_count": workspace.command_count,
"last_command": workspace.last_command,
"service_count": service_count,
"running_service_count": running_service_count,
"export_count": sum(
1 for event in review_events if event.event_kind == "workspace_export"
),
"snapshot_count": named_snapshot_count,
"reset_count": workspace.reset_count,
},
"commands": {
"total": workspace.command_count,
"recent": recent_commands,
},
"edits": {
"recent": _recent_events({"sync_push", "file_write", "patch_apply"}),
},
"services": {
"current": current_services,
"recent": _recent_events({"service_start", "service_stop"}),
},
"artifacts": {
"exports": _recent_events({"workspace_export"}),
},
"snapshots": {
"named_count": named_snapshot_count,
"recent": _recent_events({"snapshot_create", "snapshot_delete"}),
},
}
if payload["state"] != "started":
payload["changes"] = {
"available": False,
"reason": (
f"workspace {workspace_id!r} must be in 'started' state before "
"workspace_summary can compute current changes"
),
"changed": False,
"summary": None,
"entries": [],
}
return payload
try:
diff_payload = self.diff_workspace(workspace_id)
except Exception as exc:
payload["changes"] = {
"available": False,
"reason": str(exc),
"changed": False,
"summary": None,
"entries": [],
}
return payload
diff_entries: list[dict[str, Any]] = []
raw_entries = diff_payload.get("entries")
if isinstance(raw_entries, list):
for entry in raw_entries[:10]:
if not isinstance(entry, dict):
continue
diff_entries.append(
{
key: value
for key, value in entry.items()
if key != "text_patch"
}
)
payload["changes"] = {
"available": True,
"reason": None,
"changed": bool(diff_payload.get("changed", False)),
"summary": diff_payload.get("summary"),
"entries": diff_entries,
}
return payload
def stop_workspace(self, workspace_id: str) -> dict[str, Any]:
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
@ -5795,6 +6092,9 @@ class VmManager:
def _workspace_commands_dir(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / WORKSPACE_COMMANDS_DIRNAME
def _workspace_review_dir(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / WORKSPACE_REVIEW_DIRNAME
def _workspace_shells_dir(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / WORKSPACE_SHELLS_DIRNAME
@ -5810,6 +6110,9 @@ class VmManager:
def _workspace_shell_record_path(self, workspace_id: str, shell_id: str) -> Path:
return self._workspace_shells_dir(workspace_id) / f"{shell_id}.json"
def _workspace_review_record_path(self, workspace_id: str, event_id: str) -> Path:
return self._workspace_review_dir(workspace_id) / f"{event_id}.json"
def _workspace_service_record_path(self, workspace_id: str, service_name: str) -> Path:
return self._workspace_services_dir(workspace_id) / f"{service_name}.json"
@ -6004,6 +6307,46 @@ class VmManager:
entries.append(entry)
return entries
def _record_workspace_review_event_locked(
self,
workspace_id: str,
*,
event_kind: WorkspaceReviewEventKind,
payload: dict[str, Any],
when: float | None = None,
) -> WorkspaceReviewEventRecord:
recorded_at = time.time() if when is None else when
event = WorkspaceReviewEventRecord(
workspace_id=workspace_id,
event_kind=event_kind,
recorded_at=recorded_at,
payload=dict(payload),
)
event_id = f"{int(recorded_at * 1_000_000_000):020d}-{uuid.uuid4().hex[:8]}"
record_path = self._workspace_review_record_path(workspace_id, event_id)
record_path.parent.mkdir(parents=True, exist_ok=True)
record_path.write_text(
json.dumps(event.to_payload(), indent=2, sort_keys=True),
encoding="utf-8",
)
return event
def _list_workspace_review_events_locked(
self,
workspace_id: str,
) -> list[WorkspaceReviewEventRecord]:
review_dir = self._workspace_review_dir(workspace_id)
if not review_dir.exists():
return []
events: list[WorkspaceReviewEventRecord] = []
for record_path in sorted(review_dir.glob("*.json")):
payload = json.loads(record_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
continue
events.append(WorkspaceReviewEventRecord.from_payload(payload))
events.sort(key=lambda item: (item.recorded_at, item.event_kind))
return events
def _workspace_instance_for_live_shell_locked(self, workspace: WorkspaceRecord) -> VmInstance:
instance = self._workspace_instance_for_live_operation_locked(
workspace,
@ -6360,10 +6703,12 @@ class VmManager:
shutil.rmtree(self._workspace_runtime_dir(workspace_id), ignore_errors=True)
shutil.rmtree(self._workspace_host_dir(workspace_id), ignore_errors=True)
shutil.rmtree(self._workspace_commands_dir(workspace_id), ignore_errors=True)
shutil.rmtree(self._workspace_review_dir(workspace_id), ignore_errors=True)
shutil.rmtree(self._workspace_shells_dir(workspace_id), ignore_errors=True)
shutil.rmtree(self._workspace_services_dir(workspace_id), ignore_errors=True)
self._workspace_host_dir(workspace_id).mkdir(parents=True, exist_ok=True)
self._workspace_commands_dir(workspace_id).mkdir(parents=True, exist_ok=True)
self._workspace_review_dir(workspace_id).mkdir(parents=True, exist_ok=True)
self._workspace_shells_dir(workspace_id).mkdir(parents=True, exist_ok=True)
self._workspace_services_dir(workspace_id).mkdir(parents=True, exist_ok=True)

View file

@ -457,6 +457,11 @@ def _scenario_review_eval(pyro: Pyro, *, root: Path, environment: str) -> None:
assert int(rerun["exit_code"]) == 0, rerun
pyro.export_workspace(workspace_id, "review-report.txt", output_path=export_path)
assert export_path.read_text(encoding="utf-8") == "review=pass\n"
summary = pyro.summarize_workspace(workspace_id)
assert summary["workspace_id"] == workspace_id, summary
assert summary["changes"]["available"] is True, summary
assert summary["artifacts"]["exports"], summary
assert summary["snapshots"]["named_count"] >= 1, summary
finally:
if shell_id is not None and workspace_id is not None:
try: