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

@ -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]]: