Add workspace handoff shortcuts and file-backed inputs
Remove the remaining shell glue from the canonical CLI workspace flows so users can hand off IDs and host-authored text files directly. Add --id-only on workspace create and shell open, plus --text-file and --patch-file for workspace file write and patch apply, while keeping the underlying SDK, MCP, and backend behavior unchanged. Update the top walkthroughs, contract docs, roadmap status, and use-case smoke runner to use the new shortcuts, and verify the milestone with uv lock, make check, make dist-check, focused CLI tests, and a real guest-backed smoke for create, file write, patch apply, and shell open/read.
This commit is contained in:
parent
788fc4fad4
commit
7a0620fc0c
15 changed files with 466 additions and 79 deletions
|
|
@ -6,6 +6,7 @@ import argparse
|
|||
import json
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -33,6 +34,10 @@ def _print_json(payload: dict[str, Any]) -> None:
|
|||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def _print_id_only(value: object) -> None:
|
||||
print(str(value), flush=True)
|
||||
|
||||
|
||||
def _write_stream(text: str, *, stream: Any) -> None:
|
||||
if text == "":
|
||||
return
|
||||
|
|
@ -632,13 +637,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
pyro run debian:12 -- git --version
|
||||
|
||||
Continue into the stable workspace path after that:
|
||||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace create debian:12 --seed-path ./repo --id-only
|
||||
pyro workspace sync push WORKSPACE_ID ./changes
|
||||
pyro workspace exec WORKSPACE_ID -- cat note.txt
|
||||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||
pyro workspace shell open WORKSPACE_ID
|
||||
pyro workspace shell open WORKSPACE_ID --id-only
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
|
||||
|
|
@ -867,13 +872,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace create debian:12 --seed-path ./repo --id-only
|
||||
pyro workspace create debian:12 --name repro-fix --label issue=123
|
||||
pyro workspace list
|
||||
pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex
|
||||
pyro workspace sync push WORKSPACE_ID ./repo --dest src
|
||||
pyro workspace file read WORKSPACE_ID src/app.py
|
||||
pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)"
|
||||
pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
|
||||
pyro workspace exec WORKSPACE_ID -- sh -lc 'printf "hello\\n" > note.txt'
|
||||
pyro workspace stop WORKSPACE_ID
|
||||
pyro workspace disk list WORKSPACE_ID
|
||||
|
|
@ -883,7 +888,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
||||
pyro workspace shell open WORKSPACE_ID
|
||||
pyro workspace shell open WORKSPACE_ID --id-only
|
||||
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
|
||||
|
|
@ -909,8 +914,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro workspace create debian:12
|
||||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace create debian:12 --id-only
|
||||
pyro workspace create debian:12 --seed-path ./repo --id-only
|
||||
pyro workspace create debian:12 --name repro-fix --label issue=123
|
||||
pyro workspace create debian:12 --network-policy egress
|
||||
pyro workspace create debian:12 --secret API_TOKEN=expected
|
||||
|
|
@ -995,11 +1000,17 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
metavar="NAME=PATH",
|
||||
help="Persist one UTF-8 secret copied from a host file at create time.",
|
||||
)
|
||||
workspace_create_parser.add_argument(
|
||||
workspace_create_output_group = workspace_create_parser.add_mutually_exclusive_group()
|
||||
workspace_create_output_group.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_create_output_group.add_argument(
|
||||
"--id-only",
|
||||
action="store_true",
|
||||
help="Print only the new workspace identifier.",
|
||||
)
|
||||
workspace_exec_parser = workspace_subparsers.add_parser(
|
||||
"exec",
|
||||
help="Run one command inside an existing workspace.",
|
||||
|
|
@ -1163,7 +1174,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
Examples:
|
||||
pyro workspace file list WORKSPACE_ID
|
||||
pyro workspace file read WORKSPACE_ID src/app.py
|
||||
pyro workspace file write WORKSPACE_ID src/app.py --text 'print("hi")'
|
||||
pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
|
|
@ -1230,17 +1241,24 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
),
|
||||
epilog=(
|
||||
"Example:\n"
|
||||
" pyro workspace file write WORKSPACE_ID src/app.py --text 'print(\"hi\")'"
|
||||
" pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py"
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_file_write_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_file_write_parser.add_argument("path", metavar="PATH")
|
||||
workspace_file_write_parser.add_argument(
|
||||
workspace_file_write_input_group = workspace_file_write_parser.add_mutually_exclusive_group(
|
||||
required=True
|
||||
)
|
||||
workspace_file_write_input_group.add_argument(
|
||||
"--text",
|
||||
required=True,
|
||||
help="UTF-8 text content to write into the target file.",
|
||||
)
|
||||
workspace_file_write_input_group.add_argument(
|
||||
"--text-file",
|
||||
metavar="PATH",
|
||||
help="Read UTF-8 text content from a host file.",
|
||||
)
|
||||
workspace_file_write_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -1256,7 +1274,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
epilog=dedent(
|
||||
"""
|
||||
Example:
|
||||
pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)"
|
||||
pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
|
||||
|
||||
Patch application is preflighted but not fully transactional. If an apply fails
|
||||
partway through, prefer `pyro workspace reset WORKSPACE_ID`.
|
||||
|
|
@ -1276,15 +1294,22 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"Apply one unified text patch for add, modify, and delete operations under "
|
||||
"`/workspace`."
|
||||
),
|
||||
epilog="Example:\n pyro workspace patch apply WORKSPACE_ID --patch \"$(cat fix.patch)\"",
|
||||
epilog="Example:\n pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch",
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_patch_apply_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
|
||||
workspace_patch_apply_parser.add_argument(
|
||||
workspace_patch_input_group = workspace_patch_apply_parser.add_mutually_exclusive_group(
|
||||
required=True
|
||||
)
|
||||
workspace_patch_input_group.add_argument(
|
||||
"--patch",
|
||||
required=True,
|
||||
help="Unified text patch to apply under `/workspace`.",
|
||||
)
|
||||
workspace_patch_input_group.add_argument(
|
||||
"--patch-file",
|
||||
metavar="PATH",
|
||||
help="Read a unified text patch from a UTF-8 host file.",
|
||||
)
|
||||
workspace_patch_apply_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -1529,7 +1554,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro workspace shell open WORKSPACE_ID
|
||||
pyro workspace shell open WORKSPACE_ID --id-only
|
||||
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
|
||||
pyro workspace shell signal WORKSPACE_ID SHELL_ID --signal INT
|
||||
|
|
@ -1550,7 +1575,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"open",
|
||||
help="Open a persistent interactive shell.",
|
||||
description="Open a new PTY shell inside a started workspace.",
|
||||
epilog="Example:\n pyro workspace shell open WORKSPACE_ID --cwd src",
|
||||
epilog="Example:\n pyro workspace shell open WORKSPACE_ID --cwd src --id-only",
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_shell_open_parser.add_argument(
|
||||
|
|
@ -1582,11 +1607,17 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
metavar="SECRET[=ENV_VAR]",
|
||||
help="Expose one persisted workspace secret as an environment variable in the shell.",
|
||||
)
|
||||
workspace_shell_open_parser.add_argument(
|
||||
workspace_shell_open_output_group = workspace_shell_open_parser.add_mutually_exclusive_group()
|
||||
workspace_shell_open_output_group.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
workspace_shell_open_output_group.add_argument(
|
||||
"--id-only",
|
||||
action="store_true",
|
||||
help="Print only the new shell identifier.",
|
||||
)
|
||||
workspace_shell_read_parser = workspace_shell_subparsers.add_parser(
|
||||
"read",
|
||||
help="Read merged PTY output from a shell.",
|
||||
|
|
@ -2092,6 +2123,20 @@ def _require_command(command_args: list[str]) -> str:
|
|||
return shlex.join(command_args)
|
||||
|
||||
|
||||
def _read_utf8_text_file(path_value: str, *, option_name: str) -> str:
|
||||
if path_value.strip() == "":
|
||||
raise ValueError(f"{option_name} must not be empty")
|
||||
candidate = Path(path_value).expanduser()
|
||||
if not candidate.exists():
|
||||
raise ValueError(f"{option_name} file not found: {candidate}")
|
||||
if candidate.is_dir():
|
||||
raise ValueError(f"{option_name} must point to a file, not a directory: {candidate}")
|
||||
try:
|
||||
return candidate.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError(f"{option_name} must contain UTF-8 text: {candidate}") from exc
|
||||
|
||||
|
||||
def _parse_workspace_secret_option(value: str) -> dict[str, str]:
|
||||
name, sep, secret_value = value.partition("=")
|
||||
if sep == "" or name.strip() == "" or secret_value == "":
|
||||
|
|
@ -2285,7 +2330,9 @@ def main() -> None:
|
|||
name=args.name,
|
||||
labels=labels or None,
|
||||
)
|
||||
if bool(args.json):
|
||||
if bool(getattr(args, "id_only", False)):
|
||||
_print_id_only(payload["workspace_id"])
|
||||
elif bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_workspace_summary_human(payload, action="Workspace")
|
||||
|
|
@ -2454,11 +2501,16 @@ def main() -> None:
|
|||
_print_workspace_file_read_human(payload)
|
||||
return
|
||||
if args.workspace_file_command == "write":
|
||||
text = (
|
||||
args.text
|
||||
if getattr(args, "text", None) is not None
|
||||
else _read_utf8_text_file(args.text_file, option_name="--text-file")
|
||||
)
|
||||
try:
|
||||
payload = pyro.write_workspace_file(
|
||||
args.workspace_id,
|
||||
args.path,
|
||||
text=args.text,
|
||||
text=text,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
|
|
@ -2472,10 +2524,15 @@ def main() -> None:
|
|||
_print_workspace_file_write_human(payload)
|
||||
return
|
||||
if args.workspace_command == "patch" and args.workspace_patch_command == "apply":
|
||||
patch_text = (
|
||||
args.patch
|
||||
if getattr(args, "patch", None) is not None
|
||||
else _read_utf8_text_file(args.patch_file, option_name="--patch-file")
|
||||
)
|
||||
try:
|
||||
payload = pyro.apply_workspace_patch(
|
||||
args.workspace_id,
|
||||
patch=args.patch,
|
||||
patch=patch_text,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
|
|
@ -2653,7 +2710,9 @@ def main() -> None:
|
|||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
if bool(args.json):
|
||||
if bool(getattr(args, "id_only", False)):
|
||||
_print_id_only(payload["shell_id"])
|
||||
elif bool(args.json):
|
||||
_print_json(payload)
|
||||
else:
|
||||
_print_workspace_shell_summary_human(payload, prefix="workspace-shell-open")
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
|
|||
"--secret",
|
||||
"--secret-file",
|
||||
"--json",
|
||||
"--id-only",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_DISK_EXPORT_FLAGS = ("--output", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_DISK_LIST_FLAGS = ("--recursive", "--json")
|
||||
|
|
@ -56,9 +57,9 @@ PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",)
|
|||
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_FILE_LIST_FLAGS = ("--recursive", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_FILE_READ_FLAGS = ("--max-bytes", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS = ("--text", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS = ("--text", "--text-file", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_LIST_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS = ("--patch", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS = ("--patch", "--patch-file", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_RESET_FLAGS = ("--snapshot", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS = ("--tail-lines", "--all", "--json")
|
||||
|
|
@ -82,6 +83,7 @@ PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = (
|
|||
"--rows",
|
||||
"--secret-env",
|
||||
"--json",
|
||||
"--id-only",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = (
|
||||
"--cursor",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
|||
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
||||
|
||||
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
||||
DEFAULT_CATALOG_VERSION = "3.6.0"
|
||||
DEFAULT_CATALOG_VERSION = "3.7.0"
|
||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||
(
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -107,6 +109,17 @@ def _log(message: str) -> None:
|
|||
print(f"[smoke] {message}", flush=True)
|
||||
|
||||
|
||||
def _run_pyro_cli(*args: str, cwd: Path) -> str:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-m", "pyro_mcp.cli", *args],
|
||||
cwd=str(cwd),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def _create_workspace(
|
||||
pyro: Pyro,
|
||||
*,
|
||||
|
|
@ -198,6 +211,7 @@ def _scenario_cold_start_validation(pyro: Pyro, *, root: Path, environment: str)
|
|||
def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> None:
|
||||
seed_dir = root / "seed"
|
||||
export_dir = root / "export"
|
||||
patch_path = root / "fix.patch"
|
||||
_write_text(seed_dir / "message.txt", "broken\n")
|
||||
_write_text(
|
||||
seed_dir / "check.sh",
|
||||
|
|
@ -210,6 +224,14 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
|
|||
"}\n"
|
||||
"printf '%s\\n' \"$value\"\n",
|
||||
)
|
||||
_write_text(
|
||||
patch_path,
|
||||
"--- a/message.txt\n"
|
||||
"+++ b/message.txt\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-broken\n"
|
||||
"+fixed\n",
|
||||
)
|
||||
workspace_id: str | None = None
|
||||
try:
|
||||
workspace_id = _create_workspace(
|
||||
|
|
@ -224,17 +246,16 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
|
|||
assert str(initial_read["content"]) == "broken\n", initial_read
|
||||
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
assert int(failing["exit_code"]) != 0, failing
|
||||
patch = pyro.apply_workspace_patch(
|
||||
patch_output = _run_pyro_cli(
|
||||
"workspace",
|
||||
"patch",
|
||||
"apply",
|
||||
workspace_id,
|
||||
patch=(
|
||||
"--- a/message.txt\n"
|
||||
"+++ b/message.txt\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-broken\n"
|
||||
"+fixed\n"
|
||||
),
|
||||
"--patch-file",
|
||||
str(patch_path),
|
||||
cwd=root,
|
||||
)
|
||||
assert bool(patch["changed"]) is True, patch
|
||||
assert "[workspace-patch] workspace_id=" in patch_output, patch_output
|
||||
passing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
assert int(passing["exit_code"]) == 0, passing
|
||||
assert str(passing["stdout"]) == "fixed\n", passing
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue