Add persistent task workspace alpha

Start the first workspace milestone toward the task-oriented product without changing the existing one-shot vm_run/pyro run contract.

Add a disk-backed task registry in the manager, auto-started task workspaces rooted at /workspace, repeated non-cleaning exec, and persisted command journals exposed through task create/exec/status/logs/delete across the CLI, Python SDK, and MCP server.

Update the public contract, docs, examples, and version/catalog metadata for 2.1.0, and cover the new surface with manager, CLI, SDK, and MCP tests. Validation: 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-11 20:10:10 -03:00
parent 6e16e74fd5
commit 58df176148
19 changed files with 1730 additions and 48 deletions

View file

@ -17,6 +17,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,
)
@ -149,6 +150,67 @@ 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'))}")
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'))}")
print(f"Execution mode: {str(payload.get('execution_mode', 'pending'))}")
print(
f"Resources: {int(payload.get('vcpu_count', 0))} vCPU / "
f"{int(payload.get('mem_mib', 0))} MiB"
)
print(f"Command count: {int(payload.get('command_count', 0))}")
last_command = payload.get("last_command")
if isinstance(last_command, dict):
print(
"Last command: "
f"{str(last_command.get('command', 'unknown'))} "
f"(exit_code={int(last_command.get('exit_code', -1))})"
)
def _print_task_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'))} "
f"sequence={int(payload.get('sequence', 0))} "
f"cwd={str(payload.get('cwd', TASK_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))}",
file=sys.stderr,
flush=True,
)
def _print_task_logs_human(payload: dict[str, Any]) -> None:
entries = payload.get("entries")
if not isinstance(entries, list) or not entries:
print("No task logs found.")
return
for entry in entries:
if not isinstance(entry, dict):
continue
print(
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))}"
)
print(f"$ {str(entry.get('command', ''))}")
stdout = str(entry.get("stdout", ""))
stderr = str(entry.get("stderr", ""))
if stdout != "":
print(stdout, end="" if stdout.endswith("\n") else "\n")
if stderr != "":
print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr)
class _HelpFormatter(
argparse.RawDescriptionHelpFormatter,
argparse.ArgumentDefaultsHelpFormatter,
@ -178,6 +240,9 @@ def _build_parser() -> argparse.ArgumentParser:
pyro env pull debian:12
pyro run debian:12 -- git --version
Need repeated commands in one workspace after that?
pyro task create debian:12
Use `pyro mcp serve` only after the CLI validation path works.
"""
),
@ -371,6 +436,152 @@ def _build_parser() -> argparse.ArgumentParser:
),
)
task_parser = subparsers.add_parser(
"task",
help="Manage persistent task workspaces.",
description=(
"Create a persistent workspace when you need repeated commands in one "
"sandbox instead of one-shot `pyro run`."
),
epilog=dedent(
"""
Examples:
pyro task create debian:12
pyro task exec TASK_ID -- sh -lc 'printf "hello\\n" > note.txt'
pyro task logs TASK_ID
"""
),
formatter_class=_HelpFormatter,
)
task_subparsers = task_parser.add_subparsers(dest="task_command", required=True, metavar="TASK")
task_create_parser = task_subparsers.add_parser(
"create",
help="Create and start a persistent task workspace.",
description="Create a task workspace that stays alive across repeated exec calls.",
epilog="Example:\n pyro task create debian:12",
formatter_class=_HelpFormatter,
)
task_create_parser.add_argument(
"environment",
metavar="ENVIRONMENT",
help="Curated environment to boot, for example `debian:12`.",
)
task_create_parser.add_argument(
"--vcpu-count",
type=int,
default=DEFAULT_VCPU_COUNT,
help="Number of virtual CPUs to allocate to the task guest.",
)
task_create_parser.add_argument(
"--mem-mib",
type=int,
default=DEFAULT_MEM_MIB,
help="Guest memory allocation in MiB.",
)
task_create_parser.add_argument(
"--ttl-seconds",
type=int,
default=600,
help="Time-to-live for the task before automatic cleanup.",
)
task_create_parser.add_argument(
"--network",
action="store_true",
help="Enable outbound guest networking for the task guest.",
)
task_create_parser.add_argument(
"--allow-host-compat",
action="store_true",
help=(
"Opt into host-side compatibility execution if guest boot or guest exec "
"is unavailable."
),
)
task_create_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
task_exec_parser = task_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",
formatter_class=_HelpFormatter,
)
task_exec_parser.add_argument("task_id", metavar="TASK_ID", help="Persistent task identifier.")
task_exec_parser.add_argument(
"--timeout-seconds",
type=int,
default=30,
help="Maximum time allowed for the task command.",
)
task_exec_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
task_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`."
),
)
task_status_parser = task_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",
formatter_class=_HelpFormatter,
)
task_status_parser.add_argument(
"task_id",
metavar="TASK_ID",
help="Persistent task identifier.",
)
task_status_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
task_logs_parser = task_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",
formatter_class=_HelpFormatter,
)
task_logs_parser.add_argument(
"task_id",
metavar="TASK_ID",
help="Persistent task identifier.",
)
task_logs_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
task_delete_parser = task_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",
formatter_class=_HelpFormatter,
)
task_delete_parser.add_argument(
"task_id",
metavar="TASK_ID",
help="Persistent task identifier.",
)
task_delete_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
doctor_parser = subparsers.add_parser(
"doctor",
help="Inspect runtime and host diagnostics.",
@ -451,7 +662,7 @@ def _require_command(command_args: list[str]) -> str:
if command_args and command_args[0] == "--":
command_args = command_args[1:]
if not command_args:
raise ValueError("command is required after `pyro run --`")
raise ValueError("command is required after `--`")
return " ".join(command_args)
@ -544,6 +755,70 @@ 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(
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,
)
if bool(args.json):
_print_json(payload)
else:
_print_task_summary_human(payload, action="Task")
return
if args.task_command == "exec":
command = _require_command(args.command_args)
if bool(args.json):
try:
payload = pyro.exec_task(
args.task_id,
command=command,
timeout_seconds=args.timeout_seconds,
)
except Exception as exc: # noqa: BLE001
_print_json({"ok": False, "error": str(exc)})
raise SystemExit(1) from exc
_print_json(payload)
else:
try:
payload = pyro.exec_task(
args.task_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)
exit_code = int(payload.get("exit_code", 1))
if exit_code != 0:
raise SystemExit(exit_code)
return
if args.task_command == "status":
payload = pyro.status_task(args.task_id)
if bool(args.json):
_print_json(payload)
else:
_print_task_summary_human(payload, action="Task")
return
if args.task_command == "logs":
payload = pyro.logs_task(args.task_id)
if bool(args.json):
_print_json(payload)
else:
_print_task_logs_human(payload)
return
if args.task_command == "delete":
payload = pyro.delete_task(args.task_id)
if bool(args.json):
_print_json(payload)
else:
print(f"Deleted task: {str(payload.get('task_id', 'unknown'))}")
return
if args.command == "doctor":
payload = doctor_report(platform=args.platform)
if bool(args.json):