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:
parent
6e16e74fd5
commit
58df176148
19 changed files with 1730 additions and 48 deletions
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue