Pivot persistent APIs to workspaces

Replace the public persistent-sandbox contract with workspace-first naming across CLI, SDK, MCP, payloads, and on-disk state.

Rename the task surface to workspace equivalents, switch create-time seeding to `seed_path`, and store records under `workspaces/<workspace_id>/workspace.json` without carrying legacy task aliases or migrating old local task state.

Keep `pyro run` and `vm_*` unchanged. Validation covered `uv lock`, focused public-contract/API/CLI/manager tests, `UV_CACHE_DIR=.uv-cache make check`, and `UV_CACHE_DIR=.uv-cache make dist-check`.
This commit is contained in:
Thales Maciel 2026-03-12 01:21:49 -03:00
parent f57454bcb4
commit 48b82d8386
13 changed files with 743 additions and 618 deletions

View file

@ -18,7 +18,7 @@ from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION
from pyro_mcp.vm_manager import (
DEFAULT_MEM_MIB,
DEFAULT_VCPU_COUNT,
TASK_WORKSPACE_GUEST_PATH,
WORKSPACE_GUEST_PATH,
)
@ -151,17 +151,17 @@ def _print_doctor_human(payload: dict[str, Any]) -> None:
print(f"- {issue}")
def _print_task_summary_human(payload: dict[str, Any], *, action: str) -> None:
print(f"{action}: {str(payload.get('task_id', 'unknown'))}")
def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None:
print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}")
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'))}")
workspace_seed = payload.get("workspace_seed")
if isinstance(workspace_seed, dict):
mode = str(workspace_seed.get("mode", "empty"))
source_path = workspace_seed.get("source_path")
if isinstance(source_path, str) and source_path != "":
print(f"Workspace seed: {mode} from {source_path}")
seed_path = workspace_seed.get("seed_path")
if isinstance(seed_path, str) and seed_path != "":
print(f"Workspace seed: {mode} from {seed_path}")
else:
print(f"Workspace seed: {mode}")
print(f"Execution mode: {str(payload.get('execution_mode', 'pending'))}")
@ -179,16 +179,16 @@ def _print_task_summary_human(payload: dict[str, Any], *, action: str) -> None:
)
def _print_task_exec_human(payload: dict[str, Any]) -> None:
def _print_workspace_exec_human(payload: dict[str, Any]) -> None:
stdout = str(payload.get("stdout", ""))
stderr = str(payload.get("stderr", ""))
_write_stream(stdout, stream=sys.stdout)
_write_stream(stderr, stream=sys.stderr)
print(
"[task-exec] "
f"task_id={str(payload.get('task_id', 'unknown'))} "
"[workspace-exec] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"sequence={int(payload.get('sequence', 0))} "
f"cwd={str(payload.get('cwd', TASK_WORKSPACE_GUEST_PATH))} "
f"cwd={str(payload.get('cwd', WORKSPACE_GUEST_PATH))} "
f"execution_mode={str(payload.get('execution_mode', 'unknown'))} "
f"exit_code={int(payload.get('exit_code', 1))} "
f"duration_ms={int(payload.get('duration_ms', 0))}",
@ -197,27 +197,27 @@ def _print_task_exec_human(payload: dict[str, Any]) -> None:
)
def _print_task_sync_human(payload: dict[str, Any]) -> None:
def _print_workspace_sync_human(payload: dict[str, Any]) -> None:
workspace_sync = payload.get("workspace_sync")
if not isinstance(workspace_sync, dict):
print(f"Synced task: {str(payload.get('task_id', 'unknown'))}")
print(f"Synced workspace: {str(payload.get('workspace_id', 'unknown'))}")
return
print(
"[task-sync] "
f"task_id={str(payload.get('task_id', 'unknown'))} "
"[workspace-sync] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"mode={str(workspace_sync.get('mode', 'unknown'))} "
f"source={str(workspace_sync.get('source_path', 'unknown'))} "
f"destination={str(workspace_sync.get('destination', TASK_WORKSPACE_GUEST_PATH))} "
f"destination={str(workspace_sync.get('destination', WORKSPACE_GUEST_PATH))} "
f"entry_count={int(workspace_sync.get('entry_count', 0))} "
f"bytes_written={int(workspace_sync.get('bytes_written', 0))} "
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}"
)
def _print_task_logs_human(payload: dict[str, Any]) -> None:
def _print_workspace_logs_human(payload: dict[str, Any]) -> None:
entries = payload.get("entries")
if not isinstance(entries, list) or not entries:
print("No task logs found.")
print("No workspace logs found.")
return
for entry in entries:
if not isinstance(entry, dict):
@ -226,7 +226,7 @@ def _print_task_logs_human(payload: dict[str, Any]) -> None:
f"#{int(entry.get('sequence', 0))} "
f"exit_code={int(entry.get('exit_code', -1))} "
f"duration_ms={int(entry.get('duration_ms', 0))} "
f"cwd={str(entry.get('cwd', TASK_WORKSPACE_GUEST_PATH))}"
f"cwd={str(entry.get('cwd', WORKSPACE_GUEST_PATH))}"
)
print(f"$ {str(entry.get('command', ''))}")
stdout = str(entry.get("stdout", ""))
@ -267,8 +267,8 @@ def _build_parser() -> argparse.ArgumentParser:
pyro run debian:12 -- git --version
Need repeated commands in one workspace after that?
pyro task create debian:12 --source-path ./repo
pyro task sync push TASK_ID ./changes
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./changes
Use `pyro mcp serve` only after the CLI validation path works.
"""
@ -463,9 +463,9 @@ def _build_parser() -> argparse.ArgumentParser:
),
)
task_parser = subparsers.add_parser(
"task",
help="Manage persistent task workspaces.",
workspace_parser = subparsers.add_parser(
"workspace",
help="Manage persistent workspaces.",
description=(
"Create a persistent workspace when you need repeated commands in one "
"sandbox instead of one-shot `pyro run`."
@ -473,58 +473,62 @@ def _build_parser() -> argparse.ArgumentParser:
epilog=dedent(
"""
Examples:
pyro task create debian:12 --source-path ./repo
pyro task sync push TASK_ID ./repo --dest src
pyro task exec TASK_ID -- sh -lc 'printf "hello\\n" > note.txt'
pyro task logs TASK_ID
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./repo --dest src
pyro workspace exec WORKSPACE_ID -- sh -lc 'printf "hello\\n" > note.txt'
pyro workspace logs WORKSPACE_ID
"""
),
formatter_class=_HelpFormatter,
)
task_subparsers = task_parser.add_subparsers(dest="task_command", required=True, metavar="TASK")
task_create_parser = task_subparsers.add_parser(
workspace_subparsers = workspace_parser.add_subparsers(
dest="workspace_command",
required=True,
metavar="WORKSPACE",
)
workspace_create_parser = workspace_subparsers.add_parser(
"create",
help="Create and start a persistent task workspace.",
description="Create a task workspace that stays alive across repeated exec calls.",
help="Create and start a persistent workspace.",
description="Create a persistent workspace that stays alive across repeated exec calls.",
epilog=dedent(
"""
Examples:
pyro task create debian:12
pyro task create debian:12 --source-path ./repo
pyro task sync push TASK_ID ./changes
pyro workspace create debian:12
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./changes
"""
),
formatter_class=_HelpFormatter,
)
task_create_parser.add_argument(
workspace_create_parser.add_argument(
"environment",
metavar="ENVIRONMENT",
help="Curated environment to boot, for example `debian:12`.",
)
task_create_parser.add_argument(
workspace_create_parser.add_argument(
"--vcpu-count",
type=int,
default=DEFAULT_VCPU_COUNT,
help="Number of virtual CPUs to allocate to the task guest.",
help="Number of virtual CPUs to allocate to the guest.",
)
task_create_parser.add_argument(
workspace_create_parser.add_argument(
"--mem-mib",
type=int,
default=DEFAULT_MEM_MIB,
help="Guest memory allocation in MiB.",
)
task_create_parser.add_argument(
workspace_create_parser.add_argument(
"--ttl-seconds",
type=int,
default=600,
help="Time-to-live for the task before automatic cleanup.",
help="Time-to-live for the workspace before automatic cleanup.",
)
task_create_parser.add_argument(
workspace_create_parser.add_argument(
"--network",
action="store_true",
help="Enable outbound guest networking for the task guest.",
help="Enable outbound guest networking for the workspace guest.",
)
task_create_parser.add_argument(
workspace_create_parser.add_argument(
"--allow-host-compat",
action="store_true",
help=(
@ -532,143 +536,153 @@ def _build_parser() -> argparse.ArgumentParser:
"is unavailable."
),
)
task_create_parser.add_argument(
"--source-path",
workspace_create_parser.add_argument(
"--seed-path",
help=(
"Optional host directory or .tar/.tar.gz/.tgz archive to seed into `/workspace` "
"before the task is returned."
"before the workspace is returned."
),
)
task_create_parser.add_argument(
workspace_create_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
task_exec_parser = task_subparsers.add_parser(
workspace_exec_parser = workspace_subparsers.add_parser(
"exec",
help="Run one command inside an existing task workspace.",
description="Run one non-interactive command in the persistent `/workspace` for a task.",
epilog="Example:\n pyro task exec TASK_ID -- cat note.txt",
help="Run one command inside an existing workspace.",
description=(
"Run one non-interactive command in the persistent `/workspace` "
"for a workspace."
),
epilog="Example:\n pyro workspace exec WORKSPACE_ID -- cat note.txt",
formatter_class=_HelpFormatter,
)
task_exec_parser.add_argument("task_id", metavar="TASK_ID", help="Persistent task identifier.")
task_exec_parser.add_argument(
workspace_exec_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_exec_parser.add_argument(
"--timeout-seconds",
type=int,
default=30,
help="Maximum time allowed for the task command.",
help="Maximum time allowed for the workspace command.",
)
task_exec_parser.add_argument(
workspace_exec_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
task_exec_parser.add_argument(
workspace_exec_parser.add_argument(
"command_args",
nargs="*",
metavar="ARG",
help=(
"Command and arguments to run inside the task workspace. Prefix them with `--`, "
"for example `pyro task exec TASK_ID -- cat note.txt`."
"Command and arguments to run inside the workspace. Prefix them with `--`, "
"for example `pyro workspace exec WORKSPACE_ID -- cat note.txt`."
),
)
task_sync_parser = task_subparsers.add_parser(
workspace_sync_parser = workspace_subparsers.add_parser(
"sync",
help="Push host content into a started task workspace.",
help="Push host content into a started workspace.",
description=(
"Push host directory or archive content into `/workspace` for an existing "
"started task."
"started workspace."
),
epilog=dedent(
"""
Examples:
pyro task sync push TASK_ID ./repo
pyro task sync push TASK_ID ./patches --dest src
pyro workspace sync push WORKSPACE_ID ./repo
pyro workspace sync push WORKSPACE_ID ./patches --dest src
Sync is non-atomic. If a sync fails partway through, delete and recreate the task.
Sync is non-atomic. If a sync fails partway through, delete and recreate the workspace.
"""
),
formatter_class=_HelpFormatter,
)
task_sync_subparsers = task_sync_parser.add_subparsers(
dest="task_sync_command",
workspace_sync_subparsers = workspace_sync_parser.add_subparsers(
dest="workspace_sync_command",
required=True,
metavar="SYNC",
)
task_sync_push_parser = task_sync_subparsers.add_parser(
workspace_sync_push_parser = workspace_sync_subparsers.add_parser(
"push",
help="Push one host directory or archive into a started task.",
help="Push one host directory or archive into a started workspace.",
description="Import host content into `/workspace` or a subdirectory of it.",
epilog="Example:\n pyro task sync push TASK_ID ./repo --dest src",
epilog="Example:\n pyro workspace sync push WORKSPACE_ID ./repo --dest src",
formatter_class=_HelpFormatter,
)
task_sync_push_parser.add_argument(
"task_id",
metavar="TASK_ID",
help="Persistent task identifier.",
workspace_sync_push_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
task_sync_push_parser.add_argument(
workspace_sync_push_parser.add_argument(
"source_path",
metavar="SOURCE_PATH",
help="Host directory or .tar/.tar.gz/.tgz archive to push into the task workspace.",
help="Host directory or .tar/.tar.gz/.tgz archive to push into the workspace.",
)
task_sync_push_parser.add_argument(
workspace_sync_push_parser.add_argument(
"--dest",
default=TASK_WORKSPACE_GUEST_PATH,
default=WORKSPACE_GUEST_PATH,
help="Workspace destination path. Relative values resolve inside `/workspace`.",
)
task_sync_push_parser.add_argument(
workspace_sync_push_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
task_status_parser = task_subparsers.add_parser(
workspace_status_parser = workspace_subparsers.add_parser(
"status",
help="Inspect one task workspace.",
description="Show task state, sizing, workspace path, and latest command metadata.",
epilog="Example:\n pyro task status TASK_ID",
help="Inspect one workspace.",
description="Show workspace state, sizing, workspace path, and latest command metadata.",
epilog="Example:\n pyro workspace status WORKSPACE_ID",
formatter_class=_HelpFormatter,
)
task_status_parser.add_argument(
"task_id",
metavar="TASK_ID",
help="Persistent task identifier.",
workspace_status_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
task_status_parser.add_argument(
workspace_status_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
task_logs_parser = task_subparsers.add_parser(
workspace_logs_parser = workspace_subparsers.add_parser(
"logs",
help="Show command history for one task.",
description="Show persisted command history, including stdout and stderr, for one task.",
epilog="Example:\n pyro task logs TASK_ID",
help="Show command history for one workspace.",
description=(
"Show persisted command history, including stdout and stderr, "
"for one workspace."
),
epilog="Example:\n pyro workspace logs WORKSPACE_ID",
formatter_class=_HelpFormatter,
)
task_logs_parser.add_argument(
"task_id",
metavar="TASK_ID",
help="Persistent task identifier.",
workspace_logs_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
task_logs_parser.add_argument(
workspace_logs_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
task_delete_parser = task_subparsers.add_parser(
workspace_delete_parser = workspace_subparsers.add_parser(
"delete",
help="Delete one task workspace.",
description="Stop the backing sandbox if needed and remove the task workspace.",
epilog="Example:\n pyro task delete TASK_ID",
help="Delete one workspace.",
description="Stop the backing sandbox if needed and remove the workspace.",
epilog="Example:\n pyro workspace delete WORKSPACE_ID",
formatter_class=_HelpFormatter,
)
task_delete_parser.add_argument(
"task_id",
metavar="TASK_ID",
help="Persistent task identifier.",
workspace_delete_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
task_delete_parser.add_argument(
workspace_delete_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
@ -847,28 +861,28 @@ def main() -> None:
if exit_code != 0:
raise SystemExit(exit_code)
return
if args.command == "task":
if args.task_command == "create":
payload = pyro.create_task(
if args.command == "workspace":
if args.workspace_command == "create":
payload = pyro.create_workspace(
environment=args.environment,
vcpu_count=args.vcpu_count,
mem_mib=args.mem_mib,
ttl_seconds=args.ttl_seconds,
network=args.network,
allow_host_compat=args.allow_host_compat,
source_path=args.source_path,
seed_path=args.seed_path,
)
if bool(args.json):
_print_json(payload)
else:
_print_task_summary_human(payload, action="Task")
_print_workspace_summary_human(payload, action="Workspace")
return
if args.task_command == "exec":
if args.workspace_command == "exec":
command = _require_command(args.command_args)
if bool(args.json):
try:
payload = pyro.exec_task(
args.task_id,
payload = pyro.exec_workspace(
args.workspace_id,
command=command,
timeout_seconds=args.timeout_seconds,
)
@ -878,24 +892,24 @@ def main() -> None:
_print_json(payload)
else:
try:
payload = pyro.exec_task(
args.task_id,
payload = pyro.exec_workspace(
args.workspace_id,
command=command,
timeout_seconds=args.timeout_seconds,
)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_task_exec_human(payload)
_print_workspace_exec_human(payload)
exit_code = int(payload.get("exit_code", 1))
if exit_code != 0:
raise SystemExit(exit_code)
return
if args.task_command == "sync" and args.task_sync_command == "push":
if args.workspace_command == "sync" and args.workspace_sync_command == "push":
if bool(args.json):
try:
payload = pyro.push_task_sync(
args.task_id,
payload = pyro.push_workspace_sync(
args.workspace_id,
args.source_path,
dest=args.dest,
)
@ -905,36 +919,36 @@ def main() -> None:
_print_json(payload)
else:
try:
payload = pyro.push_task_sync(
args.task_id,
payload = pyro.push_workspace_sync(
args.workspace_id,
args.source_path,
dest=args.dest,
)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_task_sync_human(payload)
_print_workspace_sync_human(payload)
return
if args.task_command == "status":
payload = pyro.status_task(args.task_id)
if args.workspace_command == "status":
payload = pyro.status_workspace(args.workspace_id)
if bool(args.json):
_print_json(payload)
else:
_print_task_summary_human(payload, action="Task")
_print_workspace_summary_human(payload, action="Workspace")
return
if args.task_command == "logs":
payload = pyro.logs_task(args.task_id)
if args.workspace_command == "logs":
payload = pyro.logs_workspace(args.workspace_id)
if bool(args.json):
_print_json(payload)
else:
_print_task_logs_human(payload)
_print_workspace_logs_human(payload)
return
if args.task_command == "delete":
payload = pyro.delete_task(args.task_id)
if args.workspace_command == "delete":
payload = pyro.delete_workspace(args.workspace_id)
if bool(args.json):
_print_json(payload)
else:
print(f"Deleted task: {str(payload.get('task_id', 'unknown'))}")
print(f"Deleted workspace: {str(payload.get('workspace_id', 'unknown'))}")
return
if args.command == "doctor":
payload = doctor_report(platform=args.platform)