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