Add workspace naming and discovery

Make concurrent workspaces easier to rediscover and resume without relying on opaque IDs alone.

Add optional workspace names, key/value labels, workspace list, and workspace update across the CLI, Python SDK, and MCP surface, and persist last_activity_at so list ordering reflects real mutating activity.

Update the stable contract, install/first-run docs, roadmap, and Python workspace example to teach the new discovery flow, and validate it with focused manager/CLI/API/server coverage plus uv lock, make check, make dist-check, and a real multi-workspace smoke for create, list, update, exec, reorder, and delete.
This commit is contained in:
Thales Maciel 2026-03-12 23:16:10 -03:00
parent ab02ae46c7
commit 446f7fce04
21 changed files with 999 additions and 23 deletions

View file

@ -89,6 +89,8 @@ class Pyro:
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
seed_path: str | Path | None = None,
secrets: list[dict[str, str]] | None = None,
name: str | None = None,
labels: dict[str, str] | None = None,
) -> dict[str, Any]:
return self._manager.create_workspace(
environment=environment,
@ -99,6 +101,28 @@ class Pyro:
allow_host_compat=allow_host_compat,
seed_path=seed_path,
secrets=secrets,
name=name,
labels=labels,
)
def list_workspaces(self) -> dict[str, Any]:
return self._manager.list_workspaces()
def update_workspace(
self,
workspace_id: str,
*,
name: str | None = None,
clear_name: bool = False,
labels: dict[str, str] | None = None,
clear_labels: list[str] | None = None,
) -> dict[str, Any]:
return self._manager.update_workspace(
workspace_id,
name=name,
clear_name=clear_name,
labels=labels,
clear_labels=clear_labels,
)
def exec_workspace(
@ -508,6 +532,8 @@ class Pyro:
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
seed_path: str | None = None,
secrets: list[dict[str, str]] | None = None,
name: str | None = None,
labels: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Create and start a persistent workspace."""
return self.create_workspace(
@ -519,6 +545,30 @@ class Pyro:
allow_host_compat=allow_host_compat,
seed_path=seed_path,
secrets=secrets,
name=name,
labels=labels,
)
@server.tool()
async def workspace_list() -> dict[str, Any]:
"""List persisted workspaces with summary metadata."""
return self.list_workspaces()
@server.tool()
async def workspace_update(
workspace_id: str,
name: str | None = None,
clear_name: bool = False,
labels: dict[str, str] | None = None,
clear_labels: list[str] | None = None,
) -> dict[str, Any]:
"""Update optional workspace name and labels."""
return self.update_workspace(
workspace_id,
name=name,
clear_name=clear_name,
labels=labels,
clear_labels=clear_labels,
)
@server.tool()

View file

@ -159,9 +159,21 @@ def _print_doctor_human(payload: dict[str, Any]) -> None:
def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None:
print(f"{action} ID: {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"Workspace: {str(payload.get('workspace_path', '/workspace'))}")
last_activity_at = payload.get("last_activity_at")
if last_activity_at is not None:
print(f"Last activity at: {last_activity_at}")
print(f"Network policy: {str(payload.get('network_policy', 'off'))}")
workspace_seed = payload.get("workspace_seed")
if isinstance(workspace_seed, dict):
@ -207,6 +219,39 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
)
def _print_workspace_list_human(payload: dict[str, Any]) -> None:
workspaces = payload.get("workspaces")
if not isinstance(workspaces, list) or not workspaces:
print("No workspaces.")
return
for workspace in workspaces:
if not isinstance(workspace, dict):
continue
rendered_labels = ""
labels = workspace.get("labels")
if isinstance(labels, dict) and labels:
rendered_labels = " labels=" + ",".join(
f"{str(key)}={str(value)}" for key, value in sorted(labels.items())
)
rendered_name = ""
name = workspace.get("name")
if isinstance(name, str) and name != "":
rendered_name = f" name={name!r}"
print(
"[workspace] "
f"workspace_id={str(workspace.get('workspace_id', 'unknown'))}"
f"{rendered_name} "
f"state={str(workspace.get('state', 'unknown'))} "
f"environment={str(workspace.get('environment', 'unknown'))}"
f"{rendered_labels} "
f"last_activity_at={workspace.get('last_activity_at')} "
f"expires_at={workspace.get('expires_at')} "
f"commands={int(workspace.get('command_count', 0))} "
f"services={int(workspace.get('running_service_count', 0))}/"
f"{int(workspace.get('service_count', 0))}"
)
def _print_workspace_exec_human(payload: dict[str, Any]) -> None:
stdout = str(payload.get("stdout", ""))
stderr = str(payload.get("stderr", ""))
@ -799,6 +844,9 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro workspace create debian:12 --seed-path ./repo
pyro workspace create debian:12 --name repro-fix --label issue=123
pyro workspace list
pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex
pyro workspace sync push WORKSPACE_ID ./repo --dest src
pyro workspace file read WORKSPACE_ID src/app.py
pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)"
@ -839,8 +887,11 @@ def _build_parser() -> argparse.ArgumentParser:
Examples:
pyro workspace create debian:12
pyro workspace create debian:12 --seed-path ./repo
pyro workspace create debian:12 --name repro-fix --label issue=123
pyro workspace create debian:12 --network-policy egress
pyro workspace create debian:12 --secret API_TOKEN=expected
pyro workspace list
pyro workspace update WORKSPACE_ID --label owner=codex
pyro workspace sync push WORKSPACE_ID ./changes
pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
@ -895,6 +946,17 @@ def _build_parser() -> argparse.ArgumentParser:
"before the workspace is returned."
),
)
workspace_create_parser.add_argument(
"--name",
help="Optional human-friendly workspace name.",
)
workspace_create_parser.add_argument(
"--label",
action="append",
default=[],
metavar="KEY=VALUE",
help="Attach one discovery label to the workspace. May be repeated.",
)
workspace_create_parser.add_argument(
"--secret",
action="append",
@ -1804,6 +1866,58 @@ while true; do sleep 60; done'
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_list_parser = workspace_subparsers.add_parser(
"list",
help="List persisted workspaces.",
description="List persisted workspaces with names, labels, state, and activity ordering.",
epilog="Example:\n pyro workspace list",
formatter_class=_HelpFormatter,
)
workspace_list_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_update_parser = workspace_subparsers.add_parser(
"update",
help="Update workspace name or labels.",
description="Update discovery metadata for one existing workspace.",
epilog=dedent(
"""
Examples:
pyro workspace update WORKSPACE_ID --name repro-fix
pyro workspace update WORKSPACE_ID --label owner=codex --label issue=123
pyro workspace update WORKSPACE_ID --clear-label issue --clear-name
"""
),
formatter_class=_HelpFormatter,
)
workspace_update_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_update_parser.add_argument("--name", help="Set or replace the workspace name.")
workspace_update_parser.add_argument(
"--clear-name",
action="store_true",
help="Clear the current workspace name.",
)
workspace_update_parser.add_argument(
"--label",
action="append",
default=[],
metavar="KEY=VALUE",
help="Upsert one workspace label. May be repeated.",
)
workspace_update_parser.add_argument(
"--clear-label",
action="append",
default=[],
metavar="KEY",
help="Remove one workspace label key. May be repeated.",
)
workspace_update_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_status_parser = workspace_subparsers.add_parser(
"status",
help="Inspect one workspace.",
@ -1975,6 +2089,26 @@ def _parse_workspace_secret_env_options(values: list[str]) -> dict[str, str]:
return parsed
def _parse_workspace_label_options(values: list[str]) -> dict[str, str]:
parsed: dict[str, str] = {}
for raw_value in values:
key, sep, label_value = raw_value.partition("=")
if sep == "" or key.strip() == "" or label_value.strip() == "":
raise ValueError("workspace labels must use KEY=VALUE")
parsed[key.strip()] = label_value.strip()
return parsed
def _parse_workspace_clear_label_options(values: list[str]) -> list[str]:
parsed: list[str] = []
for raw_value in values:
label_key = raw_value.strip()
if label_key == "":
raise ValueError("workspace clear-label values must not be empty")
parsed.append(label_key)
return parsed
def _parse_workspace_publish_options(values: list[str]) -> list[dict[str, int | None]]:
parsed: list[dict[str, int | None]] = []
for raw_value in values:
@ -2103,6 +2237,7 @@ def main() -> None:
for value in getattr(args, "secret_file", [])
),
]
labels = _parse_workspace_label_options(getattr(args, "label", []))
payload = pyro.create_workspace(
environment=args.environment,
vcpu_count=args.vcpu_count,
@ -2112,12 +2247,45 @@ def main() -> None:
allow_host_compat=args.allow_host_compat,
seed_path=args.seed_path,
secrets=secrets or None,
name=args.name,
labels=labels or None,
)
if bool(args.json):
_print_json(payload)
else:
_print_workspace_summary_human(payload, action="Workspace")
return
if args.workspace_command == "list":
payload = pyro.list_workspaces()
if bool(args.json):
_print_json(payload)
else:
_print_workspace_list_human(payload)
return
if args.workspace_command == "update":
labels = _parse_workspace_label_options(getattr(args, "label", []))
clear_labels = _parse_workspace_clear_label_options(
getattr(args, "clear_label", [])
)
try:
payload = pyro.update_workspace(
args.workspace_id,
name=args.name,
clear_name=bool(args.clear_name),
labels=labels or None,
clear_labels=clear_labels or None,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_summary_human(payload, action="Workspace")
return
if args.workspace_command == "exec":
command = _require_command(args.command_args)
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))

View file

@ -13,6 +13,7 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
"exec",
"export",
"file",
"list",
"logs",
"patch",
"reset",
@ -23,6 +24,7 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
"status",
"stop",
"sync",
"update",
)
PUBLIC_CLI_WORKSPACE_DISK_SUBCOMMANDS = ("export", "list", "read")
PUBLIC_CLI_WORKSPACE_FILE_SUBCOMMANDS = ("list", "read", "write")
@ -38,6 +40,8 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
"--network-policy",
"--allow-host-compat",
"--seed-path",
"--name",
"--label",
"--secret",
"--secret-file",
"--json",
@ -51,6 +55,7 @@ PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json")
PUBLIC_CLI_WORKSPACE_FILE_LIST_FLAGS = ("--recursive", "--json")
PUBLIC_CLI_WORKSPACE_FILE_READ_FLAGS = ("--max-bytes", "--json")
PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS = ("--text", "--json")
PUBLIC_CLI_WORKSPACE_LIST_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS = ("--patch", "--json")
PUBLIC_CLI_WORKSPACE_RESET_FLAGS = ("--snapshot", "--json")
PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS = ("--json",)
@ -87,6 +92,13 @@ PUBLIC_CLI_WORKSPACE_START_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_STATUS_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_STOP_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS = ("--dest", "--json")
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS = (
"--name",
"--clear-name",
"--label",
"--clear-label",
"--json",
)
PUBLIC_CLI_RUN_FLAGS = (
"--vcpu-count",
"--mem-mib",
@ -118,6 +130,7 @@ PUBLIC_SDK_METHODS = (
"list_snapshots",
"list_workspace_disk",
"list_workspace_files",
"list_workspaces",
"logs_service",
"logs_workspace",
"network_info_vm",
@ -141,6 +154,7 @@ PUBLIC_SDK_METHODS = (
"stop_service",
"stop_vm",
"stop_workspace",
"update_workspace",
"write_shell",
"write_workspace_file",
)
@ -171,15 +185,16 @@ PUBLIC_MCP_TOOLS = (
"vm_stop",
"workspace_create",
"workspace_delete",
"workspace_diff",
"workspace_disk_export",
"workspace_disk_list",
"workspace_disk_read",
"workspace_diff",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_file_write",
"workspace_list",
"workspace_logs",
"workspace_patch_apply",
"workspace_reset",
@ -187,4 +202,5 @@ PUBLIC_MCP_TOOLS = (
"workspace_status",
"workspace_stop",
"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 = "3.2.0"
DEFAULT_CATALOG_VERSION = "3.3.0"
OCI_MANIFEST_ACCEPT = ", ".join(
(
"application/vnd.oci.image.index.v1+json",

View file

@ -78,7 +78,7 @@ DEFAULT_TIMEOUT_SECONDS = 30
DEFAULT_TTL_SECONDS = 600
DEFAULT_ALLOW_HOST_COMPAT = False
WORKSPACE_LAYOUT_VERSION = 7
WORKSPACE_LAYOUT_VERSION = 8
WORKSPACE_BASELINE_DIRNAME = "baseline"
WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar"
WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots"
@ -109,6 +109,7 @@ WORKSPACE_SHELL_SIGNAL_NAMES = shell_signal_names()
WORKSPACE_SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
WORKSPACE_SNAPSHOT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
WORKSPACE_SECRET_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
WORKSPACE_LABEL_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"]
WorkspaceArtifactType = Literal["file", "directory", "symlink"]
@ -156,6 +157,9 @@ class WorkspaceRecord:
last_error: str | None = None
metadata: dict[str, str] = field(default_factory=dict)
network: NetworkConfig | None = None
name: str | None = None
labels: dict[str, str] = field(default_factory=dict)
last_activity_at: float = 0.0
command_count: int = 0
last_command: dict[str, Any] | None = None
workspace_seed: dict[str, Any] = field(default_factory=dict)
@ -173,6 +177,8 @@ class WorkspaceRecord:
last_command: dict[str, Any] | None = None,
workspace_seed: dict[str, Any] | None = None,
secrets: list[WorkspaceSecretRecord] | None = None,
name: str | None = None,
labels: dict[str, str] | None = None,
) -> WorkspaceRecord:
return cls(
workspace_id=instance.vm_id,
@ -189,6 +195,9 @@ class WorkspaceRecord:
last_error=instance.last_error,
metadata=dict(instance.metadata),
network=instance.network,
name=name,
labels=dict(labels or {}),
last_activity_at=instance.created_at,
command_count=command_count,
last_command=last_command,
workspace_seed=dict(workspace_seed or _empty_workspace_seed_payload()),
@ -233,6 +242,9 @@ class WorkspaceRecord:
"last_error": self.last_error,
"metadata": self.metadata,
"network": _serialize_network(self.network),
"name": self.name,
"labels": self.labels,
"last_activity_at": self.last_activity_at,
"command_count": self.command_count,
"last_command": self.last_command,
"workspace_seed": self.workspace_seed,
@ -258,6 +270,11 @@ class WorkspaceRecord:
last_error=_optional_str(payload.get("last_error")),
metadata=_string_dict(payload.get("metadata")),
network=_deserialize_network(payload.get("network")),
name=_normalize_workspace_name(_optional_str(payload.get("name")), allow_none=True),
labels=_normalize_workspace_labels(payload.get("labels")),
last_activity_at=float(
payload.get("last_activity_at", float(payload["created_at"]))
),
command_count=int(payload.get("command_count", 0)),
last_command=_optional_dict(payload.get("last_command")),
workspace_seed=_workspace_seed_dict(payload.get("workspace_seed")),
@ -1287,6 +1304,65 @@ def _normalize_workspace_service_name(service_name: str) -> str:
return normalized
def _normalize_workspace_name(
name: str | None,
*,
allow_none: bool = False,
) -> str | None:
if name is None:
if allow_none:
return None
raise ValueError("name must not be empty")
normalized = name.strip()
if normalized == "":
if allow_none:
return None
raise ValueError("name must not be empty")
if len(normalized) > 120:
raise ValueError("name must be at most 120 characters")
return normalized
def _normalize_workspace_label_key(label_key: str) -> str:
normalized = label_key.strip()
if normalized == "":
raise ValueError("label key must not be empty")
if WORKSPACE_LABEL_KEY_RE.fullmatch(normalized) is None:
raise ValueError(
"label key must match "
r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"
)
return normalized
def _normalize_workspace_label_value(label_key: str, label_value: str) -> str:
normalized = label_value.strip()
if normalized == "":
raise ValueError(f"label {label_key!r} must not be empty")
if len(normalized) > 120:
raise ValueError(f"label {label_key!r} must be at most 120 characters")
if "\n" in normalized or "\r" in normalized:
raise ValueError(f"label {label_key!r} must not contain newlines")
try:
normalized.encode("utf-8")
except UnicodeEncodeError as exc:
raise ValueError(f"label {label_key!r} must be valid UTF-8 text") from exc
return normalized
def _normalize_workspace_labels(value: object) -> dict[str, str]:
if value is None:
return {}
if not isinstance(value, dict):
raise ValueError("labels must be an object mapping keys to values")
normalized: dict[str, str] = {}
for raw_key, raw_value in value.items():
key = _normalize_workspace_label_key(str(raw_key))
label_value = _normalize_workspace_label_value(key, str(raw_value))
normalized[key] = label_value
return dict(sorted(normalized.items()))
def _normalize_workspace_snapshot_name(
snapshot_name: str,
*,
@ -3643,10 +3719,14 @@ class VmManager:
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
seed_path: str | Path | None = None,
secrets: list[dict[str, str]] | None = None,
name: str | None = None,
labels: dict[str, str] | None = None,
) -> dict[str, Any]:
self._validate_limits(vcpu_count=vcpu_count, mem_mib=mem_mib, ttl_seconds=ttl_seconds)
get_environment(environment, runtime_paths=self._runtime_paths)
normalized_network_policy = _normalize_workspace_network_policy(str(network_policy))
normalized_name = None if name is None else _normalize_workspace_name(name)
normalized_labels = _normalize_workspace_labels(labels)
prepared_seed = self._prepare_workspace_seed(seed_path)
now = time.time()
workspace_id = uuid.uuid4().hex[:12]
@ -3709,6 +3789,8 @@ class VmManager:
network_policy=normalized_network_policy,
workspace_seed=prepared_seed.to_payload(),
secrets=secret_records,
name=normalized_name,
labels=normalized_labels,
)
if workspace.secrets:
self._install_workspace_secrets_locked(workspace, instance)
@ -3787,6 +3869,7 @@ 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._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
@ -3794,6 +3877,77 @@ class VmManager:
"workspace_sync": workspace_sync,
}
def list_workspaces(self) -> dict[str, Any]:
with self._lock:
now = time.time()
self._reap_expired_workspaces_locked(now)
workspaces: list[WorkspaceRecord] = []
for metadata_path in self._workspaces_dir.glob("*/workspace.json"):
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
continue
workspace = WorkspaceRecord.from_payload(payload)
self._refresh_workspace_liveness_locked(workspace)
self._refresh_workspace_service_counts_locked(workspace)
self._save_workspace_locked(workspace)
workspaces.append(workspace)
workspaces.sort(
key=lambda item: (
-item.last_activity_at,
-item.created_at,
item.workspace_id,
)
)
return {
"count": len(workspaces),
"workspaces": [
self._serialize_workspace_list_item(workspace) for workspace in workspaces
],
}
def update_workspace(
self,
workspace_id: str,
*,
name: str | None = None,
clear_name: bool = False,
labels: dict[str, str] | None = None,
clear_labels: list[str] | None = None,
) -> dict[str, Any]:
if name is not None and clear_name:
raise ValueError("name and clear_name cannot be used together")
normalized_name = None if name is None else _normalize_workspace_name(name)
normalized_labels = None if labels is None else _normalize_workspace_labels(labels)
normalized_clear_labels = [
_normalize_workspace_label_key(label_key) for label_key in (clear_labels or [])
]
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
self._ensure_workspace_not_expired_locked(workspace, time.time())
updated = False
if clear_name:
if workspace.name is not None:
workspace.name = None
updated = True
elif normalized_name is not None and workspace.name != normalized_name:
workspace.name = normalized_name
updated = True
if normalized_labels is not None:
for label_key, label_value in normalized_labels.items():
if workspace.labels.get(label_key) != label_value:
workspace.labels[label_key] = label_value
updated = True
for label_key in normalized_clear_labels:
if label_key in workspace.labels:
del workspace.labels[label_key]
updated = True
workspace.labels = dict(sorted(workspace.labels.items()))
if not updated:
raise ValueError("workspace update requested no effective metadata change")
self._touch_workspace_activity_locked(workspace)
self._save_workspace_locked(workspace)
return self._serialize_workspace(workspace)
def export_workspace(
self,
workspace_id: str,
@ -3950,6 +4104,7 @@ 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._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
@ -4105,6 +4260,7 @@ 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._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
@ -4179,6 +4335,7 @@ 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._save_workspace_locked(workspace)
self._save_workspace_snapshot_locked(snapshot)
return {
@ -4213,6 +4370,8 @@ class VmManager:
self._workspace_baseline_snapshot_locked(workspace)
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._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
"snapshot_name": normalized_snapshot_name,
@ -4278,6 +4437,7 @@ class VmManager:
workspace.last_command = None
workspace.reset_count += 1
workspace.last_reset_at = time.time()
self._touch_workspace_activity_locked(workspace, when=workspace.last_reset_at)
self._save_workspace_locked(workspace)
payload = self._serialize_workspace(workspace)
except Exception:
@ -4425,6 +4585,7 @@ 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._save_workspace_locked(workspace)
self._save_workspace_shell_locked(shell)
return self._serialize_workspace_shell(shell)
@ -4516,6 +4677,7 @@ 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._save_workspace_locked(workspace)
self._save_workspace_shell_locked(updated_shell)
response = self._serialize_workspace_shell(updated_shell)
@ -4565,6 +4727,7 @@ 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._save_workspace_locked(workspace)
self._save_workspace_shell_locked(updated_shell)
response = self._serialize_workspace_shell(updated_shell)
@ -4601,6 +4764,7 @@ 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._save_workspace_locked(workspace)
self._delete_workspace_shell_locked(workspace_id, shell_id)
response = self._serialize_workspace_shell(closed_shell)
@ -4720,6 +4884,7 @@ 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._save_workspace_locked(workspace)
self._save_workspace_service_locked(service)
return self._serialize_workspace_service(service)
@ -4799,6 +4964,7 @@ 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._save_workspace_locked(workspace)
self._save_workspace_service_locked(service)
response = self._serialize_workspace_service(service)
@ -4897,8 +5063,10 @@ class VmManager:
workspace.firecracker_pid = None
workspace.last_error = str(exc)
workspace.metadata = dict(instance.metadata)
self._touch_workspace_activity_locked(workspace)
self._save_workspace_locked(workspace)
raise
self._touch_workspace_activity_locked(workspace)
self._save_workspace_locked(workspace)
return self._serialize_workspace(workspace)
@ -4909,6 +5077,7 @@ class VmManager:
self._refresh_workspace_liveness_locked(workspace)
if workspace.state == "started":
self._refresh_workspace_service_counts_locked(workspace)
self._touch_workspace_activity_locked(workspace)
self._save_workspace_locked(workspace)
return self._serialize_workspace(workspace)
instance = workspace.to_instance(
@ -4931,6 +5100,7 @@ class VmManager:
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = None
workspace.metadata = dict(instance.metadata)
self._touch_workspace_activity_locked(workspace)
self._save_workspace_locked(workspace)
return self._serialize_workspace(workspace)
except Exception as exc:
@ -4945,6 +5115,7 @@ class VmManager:
workspace.firecracker_pid = None
workspace.last_error = str(exc)
workspace.metadata = dict(instance.metadata)
self._touch_workspace_activity_locked(workspace)
self._save_workspace_locked(workspace)
raise
@ -5081,12 +5252,15 @@ class VmManager:
)
return {
"workspace_id": workspace.workspace_id,
"name": workspace.name,
"labels": dict(workspace.labels),
"environment": workspace.environment,
"environment_version": workspace.metadata.get("environment_version"),
"vcpu_count": workspace.vcpu_count,
"mem_mib": workspace.mem_mib,
"ttl_seconds": workspace.ttl_seconds,
"created_at": workspace.created_at,
"last_activity_at": workspace.last_activity_at,
"expires_at": workspace.expires_at,
"state": workspace.state,
"network_policy": workspace.network_policy,
@ -5109,6 +5283,24 @@ class VmManager:
"metadata": workspace.metadata,
}
def _serialize_workspace_list_item(self, workspace: WorkspaceRecord) -> dict[str, Any]:
service_count, running_service_count = self._workspace_service_counts_locked(
workspace.workspace_id
)
return {
"workspace_id": workspace.workspace_id,
"name": workspace.name,
"labels": dict(workspace.labels),
"environment": workspace.environment,
"state": workspace.state,
"created_at": workspace.created_at,
"last_activity_at": workspace.last_activity_at,
"expires_at": workspace.expires_at,
"command_count": workspace.command_count,
"service_count": service_count,
"running_service_count": running_service_count,
}
def _serialize_workspace_shell(self, shell: WorkspaceShellRecord) -> dict[str, Any]:
return {
"workspace_id": shell.workspace_id,
@ -5258,6 +5450,14 @@ class VmManager:
env_values[env_name] = secret_values[secret_name]
return env_values
def _touch_workspace_activity_locked(
self,
workspace: WorkspaceRecord,
*,
when: float | None = None,
) -> None:
workspace.last_activity_at = time.time() if when is None else when
def _install_workspace_secrets_locked(
self,
workspace: WorkspaceRecord,
@ -5626,6 +5826,7 @@ class VmManager:
"duration_ms": exec_result.duration_ms,
"execution_mode": execution_mode,
}
self._touch_workspace_activity_locked(workspace)
return entry
def _read_workspace_logs_locked(self, workspace_id: str) -> list[dict[str, Any]]: