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
|
|
@ -59,6 +59,14 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "Expose pyro tools over stdio for an MCP client." in mcp_help
|
||||
assert "Use this from an MCP client config after the CLI evaluation path works." in mcp_help
|
||||
|
||||
task_help = _subparser_choice(parser, "task").format_help()
|
||||
assert "pyro task create debian:12" in task_help
|
||||
assert "pyro task exec TASK_ID" in task_help
|
||||
|
||||
task_exec_help = _subparser_choice(_subparser_choice(parser, "task"), "exec").format_help()
|
||||
assert "persistent `/workspace`" in task_exec_help
|
||||
assert "pyro task exec TASK_ID -- cat note.txt" in task_exec_help
|
||||
|
||||
|
||||
def test_cli_run_prints_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
|
@ -318,6 +326,243 @@ def test_cli_requires_run_command() -> None:
|
|||
cli._require_command([])
|
||||
|
||||
|
||||
def test_cli_task_create_prints_json(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def create_task(self, **kwargs: Any) -> dict[str, Any]:
|
||||
assert kwargs["environment"] == "debian:12"
|
||||
return {"task_id": "task-123", "state": "started"}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="task",
|
||||
task_command="create",
|
||||
environment="debian:12",
|
||||
vcpu_count=1,
|
||||
mem_mib=1024,
|
||||
ttl_seconds=600,
|
||||
network=False,
|
||||
allow_host_compat=False,
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert output["task_id"] == "task-123"
|
||||
|
||||
|
||||
def test_cli_task_create_prints_human(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def create_task(self, **kwargs: Any) -> dict[str, Any]:
|
||||
del kwargs
|
||||
return {
|
||||
"task_id": "task-123",
|
||||
"environment": "debian:12",
|
||||
"state": "started",
|
||||
"workspace_path": "/workspace",
|
||||
"execution_mode": "guest_vsock",
|
||||
"vcpu_count": 1,
|
||||
"mem_mib": 1024,
|
||||
"command_count": 0,
|
||||
"last_command": None,
|
||||
}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="task",
|
||||
task_command="create",
|
||||
environment="debian:12",
|
||||
vcpu_count=1,
|
||||
mem_mib=1024,
|
||||
ttl_seconds=600,
|
||||
network=False,
|
||||
allow_host_compat=False,
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = capsys.readouterr().out
|
||||
assert "Task: task-123" in output
|
||||
assert "Workspace: /workspace" in output
|
||||
|
||||
|
||||
def test_cli_task_exec_prints_human_output(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def exec_task(self, task_id: str, *, command: str, timeout_seconds: int) -> dict[str, Any]:
|
||||
assert task_id == "task-123"
|
||||
assert command == "cat note.txt"
|
||||
assert timeout_seconds == 30
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"sequence": 2,
|
||||
"cwd": "/workspace",
|
||||
"execution_mode": "guest_vsock",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 4,
|
||||
"stdout": "hello\n",
|
||||
"stderr": "",
|
||||
}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="task",
|
||||
task_command="exec",
|
||||
task_id="task-123",
|
||||
timeout_seconds=30,
|
||||
json=False,
|
||||
command_args=["--", "cat", "note.txt"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "hello\n"
|
||||
assert "[task-exec] task_id=task-123 sequence=2 cwd=/workspace" in captured.err
|
||||
|
||||
|
||||
def test_cli_task_logs_and_delete_print_human(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def logs_task(self, task_id: str) -> dict[str, Any]:
|
||||
assert task_id == "task-123"
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"count": 1,
|
||||
"entries": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"exit_code": 0,
|
||||
"duration_ms": 2,
|
||||
"cwd": "/workspace",
|
||||
"command": "printf 'ok\\n'",
|
||||
"stdout": "ok\n",
|
||||
"stderr": "",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def delete_task(self, task_id: str) -> dict[str, Any]:
|
||||
assert task_id == "task-123"
|
||||
return {"task_id": task_id, "deleted": True}
|
||||
|
||||
class LogsParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="task",
|
||||
task_command="logs",
|
||||
task_id="task-123",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: LogsParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
|
||||
class DeleteParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="task",
|
||||
task_command="delete",
|
||||
task_id="task-123",
|
||||
json=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: DeleteParser())
|
||||
cli.main()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "#1 exit_code=0 duration_ms=2 cwd=/workspace" in output
|
||||
assert "Deleted task: task-123" in output
|
||||
|
||||
|
||||
def test_cli_task_status_and_delete_print_json(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def status_task(self, task_id: str) -> dict[str, Any]:
|
||||
assert task_id == "task-123"
|
||||
return {"task_id": task_id, "state": "started"}
|
||||
|
||||
def delete_task(self, task_id: str) -> dict[str, Any]:
|
||||
assert task_id == "task-123"
|
||||
return {"task_id": task_id, "deleted": True}
|
||||
|
||||
class StatusParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="task",
|
||||
task_command="status",
|
||||
task_id="task-123",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StatusParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
status = json.loads(capsys.readouterr().out)
|
||||
assert status["state"] == "started"
|
||||
|
||||
class DeleteParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="task",
|
||||
task_command="delete",
|
||||
task_id="task-123",
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: DeleteParser())
|
||||
cli.main()
|
||||
deleted = json.loads(capsys.readouterr().out)
|
||||
assert deleted["deleted"] is True
|
||||
|
||||
|
||||
def test_cli_task_exec_json_error_exits_nonzero(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def exec_task(self, task_id: str, *, command: str, timeout_seconds: int) -> dict[str, Any]:
|
||||
del task_id, command, timeout_seconds
|
||||
raise RuntimeError("task is unavailable")
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="task",
|
||||
task_command="exec",
|
||||
task_id="task-123",
|
||||
timeout_seconds=30,
|
||||
json=True,
|
||||
command_args=["--", "true"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
|
||||
with pytest.raises(SystemExit, match="1"):
|
||||
cli.main()
|
||||
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert payload["ok"] is False
|
||||
|
||||
|
||||
def test_print_env_helpers_render_human_output(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
cli._print_env_list_human(
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue