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
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue