Add workspace service lifecycle with typed readiness
Make persistent workspaces capable of running long-lived background processes instead of forcing everything through one-shot exec calls. Add workspace service start/list/status/logs/stop across the CLI, Python SDK, and MCP server, with multiple named services per workspace, typed readiness probes (file, tcp, http, and command), and aggregate service counts on workspace status. Keep service state and logs outside /workspace so diff and export semantics stay workspace-scoped, and extend the guest agent plus backends to persist service records and logs across separate calls. Update the 2.7.0 docs, examples, changelog, and roadmap milestone to reflect the shipped surface. Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed Firecracker smoke for workspace create, two service starts, list/status/logs, diff unaffected, stop, and delete.
This commit is contained in:
parent
84a7e18d4d
commit
f504f0a331
28 changed files with 4098 additions and 124 deletions
|
|
@ -17,6 +17,9 @@ from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
|
|||
from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION
|
||||
from pyro_mcp.vm_manager import (
|
||||
DEFAULT_MEM_MIB,
|
||||
DEFAULT_SERVICE_LOG_TAIL_LINES,
|
||||
DEFAULT_SERVICE_READY_INTERVAL_MS,
|
||||
DEFAULT_SERVICE_READY_TIMEOUT_SECONDS,
|
||||
DEFAULT_VCPU_COUNT,
|
||||
WORKSPACE_GUEST_PATH,
|
||||
WORKSPACE_SHELL_SIGNAL_NAMES,
|
||||
|
|
@ -171,6 +174,11 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
|
|||
f"{int(payload.get('mem_mib', 0))} MiB"
|
||||
)
|
||||
print(f"Command count: {int(payload.get('command_count', 0))}")
|
||||
print(
|
||||
"Services: "
|
||||
f"{int(payload.get('running_service_count', 0))}/"
|
||||
f"{int(payload.get('service_count', 0))} running"
|
||||
)
|
||||
last_command = payload.get("last_command")
|
||||
if isinstance(last_command, dict):
|
||||
print(
|
||||
|
|
@ -304,6 +312,42 @@ def _print_workspace_shell_read_human(payload: dict[str, Any]) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _print_workspace_service_summary_human(payload: dict[str, Any], *, prefix: str) -> None:
|
||||
print(
|
||||
f"[{prefix}] "
|
||||
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
|
||||
f"service_name={str(payload.get('service_name', 'unknown'))} "
|
||||
f"state={str(payload.get('state', 'unknown'))} "
|
||||
f"cwd={str(payload.get('cwd', WORKSPACE_GUEST_PATH))} "
|
||||
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _print_workspace_service_list_human(payload: dict[str, Any]) -> None:
|
||||
services = payload.get("services")
|
||||
if not isinstance(services, list) or not services:
|
||||
print("No workspace services found.")
|
||||
return
|
||||
for service in services:
|
||||
if not isinstance(service, dict):
|
||||
continue
|
||||
print(
|
||||
f"{str(service.get('service_name', 'unknown'))} "
|
||||
f"[{str(service.get('state', 'unknown'))}] "
|
||||
f"cwd={str(service.get('cwd', WORKSPACE_GUEST_PATH))}"
|
||||
)
|
||||
|
||||
|
||||
def _print_workspace_service_logs_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_workspace_service_summary_human(payload, prefix="workspace-service-logs")
|
||||
|
||||
|
||||
class _HelpFormatter(
|
||||
argparse.RawDescriptionHelpFormatter,
|
||||
argparse.ArgumentDefaultsHelpFormatter,
|
||||
|
|
@ -339,6 +383,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
|
||||
pyro workspace shell open WORKSPACE_ID
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
|
||||
Use `pyro mcp serve` only after the CLI validation path works.
|
||||
"""
|
||||
|
|
@ -549,6 +595,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
||||
pyro workspace shell open WORKSPACE_ID
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
pyro workspace logs WORKSPACE_ID
|
||||
"""
|
||||
),
|
||||
|
|
@ -570,6 +618,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace sync push WORKSPACE_ID ./changes
|
||||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
|
|
@ -943,6 +993,160 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_service_parser = workspace_subparsers.add_parser(
|
||||
"service",
|
||||
help="Manage long-running services inside a workspace.",
|
||||
description=(
|
||||
"Start, inspect, and stop named long-running services inside one started workspace."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
pyro workspace service list WORKSPACE_ID
|
||||
pyro workspace service status WORKSPACE_ID app
|
||||
pyro workspace service logs WORKSPACE_ID app --tail-lines 50
|
||||
pyro workspace service stop WORKSPACE_ID app
|
||||
|
||||
Use `--ready-file` by default in the curated Debian environments. `--ready-command`
|
||||
remains available as an escape hatch.
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_service_subparsers = workspace_service_parser.add_subparsers(
|
||||
dest="workspace_service_command",
|
||||
required=True,
|
||||
metavar="SERVICE",
|
||||
)
|
||||
workspace_service_start_parser = workspace_service_subparsers.add_parser(
|
||||
"start",
|
||||
help="Start one named long-running service.",
|
||||
description="Start a named service inside a started workspace with optional readiness.",
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID app --ready-command 'test -f .ready' -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_service_start_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_service_start_parser.add_argument("service_name", metavar="SERVICE_NAME")
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--cwd",
|
||||
default=WORKSPACE_GUEST_PATH,
|
||||
help="Service working directory. Relative values resolve inside `/workspace`.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--ready-file",
|
||||
help="Mark the service ready once this workspace path exists.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--ready-tcp",
|
||||
help="Mark the service ready once this HOST:PORT accepts guest-local TCP connections.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--ready-http",
|
||||
help="Mark the service ready once this guest-local URL returns 2xx or 3xx.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--ready-command",
|
||||
help="Escape hatch readiness probe command. Use typed readiness when possible.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--ready-timeout-seconds",
|
||||
type=int,
|
||||
default=DEFAULT_SERVICE_READY_TIMEOUT_SECONDS,
|
||||
help="Maximum time to wait for readiness before failing the service start.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--ready-interval-ms",
|
||||
type=int,
|
||||
default=DEFAULT_SERVICE_READY_INTERVAL_MS,
|
||||
help="Polling interval between readiness checks.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"command_args",
|
||||
nargs="*",
|
||||
metavar="ARG",
|
||||
help="Service command and arguments. Prefix them with `--`.",
|
||||
)
|
||||
workspace_service_list_parser = workspace_service_subparsers.add_parser(
|
||||
"list",
|
||||
help="List named services in one workspace.",
|
||||
description="List named services and their current states for one workspace.",
|
||||
epilog="Example:\n pyro workspace service list WORKSPACE_ID",
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_service_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_service_list_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_service_status_parser = workspace_service_subparsers.add_parser(
|
||||
"status",
|
||||
help="Inspect one service.",
|
||||
description="Show state and readiness metadata for one named workspace service.",
|
||||
epilog="Example:\n pyro workspace service status WORKSPACE_ID app",
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_service_status_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_service_status_parser.add_argument("service_name", metavar="SERVICE_NAME")
|
||||
workspace_service_status_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_service_logs_parser = workspace_service_subparsers.add_parser(
|
||||
"logs",
|
||||
help="Read persisted service stdout and stderr.",
|
||||
description="Read service stdout and stderr without using `workspace logs`.",
|
||||
epilog="Example:\n pyro workspace service logs WORKSPACE_ID app --tail-lines 50",
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_service_logs_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_service_logs_parser.add_argument("service_name", metavar="SERVICE_NAME")
|
||||
workspace_service_logs_parser.add_argument(
|
||||
"--tail-lines",
|
||||
type=int,
|
||||
default=DEFAULT_SERVICE_LOG_TAIL_LINES,
|
||||
help="Maximum number of trailing lines to return from each service log stream.",
|
||||
)
|
||||
workspace_service_logs_parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Return full stdout and stderr instead of tailing them.",
|
||||
)
|
||||
workspace_service_logs_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_service_stop_parser = workspace_service_subparsers.add_parser(
|
||||
"stop",
|
||||
help="Stop one running service.",
|
||||
description="Stop one named workspace service with TERM then KILL fallback.",
|
||||
epilog="Example:\n pyro workspace service stop WORKSPACE_ID app",
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_service_stop_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_service_stop_parser.add_argument("service_name", metavar="SERVICE_NAME")
|
||||
workspace_service_stop_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_status_parser = workspace_subparsers.add_parser(
|
||||
"status",
|
||||
help="Inspect one workspace.",
|
||||
|
|
@ -1372,6 +1576,128 @@ def main() -> None:
|
|||
else:
|
||||
_print_workspace_shell_summary_human(payload, prefix="workspace-shell-close")
|
||||
return
|
||||
if args.workspace_command == "service":
|
||||
if args.workspace_service_command == "start":
|
||||
readiness_count = sum(
|
||||
value is not None
|
||||
for value in (
|
||||
args.ready_file,
|
||||
args.ready_tcp,
|
||||
args.ready_http,
|
||||
args.ready_command,
|
||||
)
|
||||
)
|
||||
if readiness_count > 1:
|
||||
error = (
|
||||
"choose at most one of --ready-file, --ready-tcp, "
|
||||
"--ready-http, or --ready-command"
|
||||
)
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": error})
|
||||
else:
|
||||
print(f"[error] {error}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1)
|
||||
readiness: dict[str, Any] | None = None
|
||||
if args.ready_file is not None:
|
||||
readiness = {"type": "file", "path": args.ready_file}
|
||||
elif args.ready_tcp is not None:
|
||||
readiness = {"type": "tcp", "address": args.ready_tcp}
|
||||
elif args.ready_http is not None:
|
||||
readiness = {"type": "http", "url": args.ready_http}
|
||||
elif args.ready_command is not None:
|
||||
readiness = {"type": "command", "command": args.ready_command}
|
||||
command = _require_command(args.command_args)
|
||||
try:
|
||||
payload = pyro.start_service(
|
||||
args.workspace_id,
|
||||
args.service_name,
|
||||
command=command,
|
||||
cwd=args.cwd,
|
||||
readiness=readiness,
|
||||
ready_timeout_seconds=args.ready_timeout_seconds,
|
||||
ready_interval_ms=args.ready_interval_ms,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_workspace_service_summary_human(
|
||||
payload,
|
||||
prefix="workspace-service-start",
|
||||
)
|
||||
return
|
||||
if args.workspace_service_command == "list":
|
||||
try:
|
||||
payload = pyro.list_services(args.workspace_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_workspace_service_list_human(payload)
|
||||
return
|
||||
if args.workspace_service_command == "status":
|
||||
try:
|
||||
payload = pyro.status_service(args.workspace_id, args.service_name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_workspace_service_summary_human(
|
||||
payload,
|
||||
prefix="workspace-service-status",
|
||||
)
|
||||
return
|
||||
if args.workspace_service_command == "logs":
|
||||
try:
|
||||
payload = pyro.logs_service(
|
||||
args.workspace_id,
|
||||
args.service_name,
|
||||
tail_lines=args.tail_lines,
|
||||
all=bool(args.all),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_workspace_service_logs_human(payload)
|
||||
return
|
||||
if args.workspace_service_command == "stop":
|
||||
try:
|
||||
payload = pyro.stop_service(args.workspace_id, args.service_name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_workspace_service_summary_human(
|
||||
payload,
|
||||
prefix="workspace-service-stop",
|
||||
)
|
||||
return
|
||||
if args.workspace_command == "status":
|
||||
payload = pyro.status_workspace(args.workspace_id)
|
||||
if bool(args.json):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue