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:
parent
ab02ae46c7
commit
446f7fce04
21 changed files with 999 additions and 23 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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", []))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue