Add project-aware chat startup defaults

Make repo-root chat startup native by letting MCP servers carry a default project source for workspace creation. When a chat host starts from a Git checkout, workspace_create can now omit seed_path and inherit the server startup source; explicit --project-path and clean-clone --repo-url/--repo-ref paths are supported as fallbacks.

Add project startup resolution and materialization, surface origin_kind/origin_ref in workspace_seed, update chat-host docs and the repro/fix smoke to use project-aware workspace creation, and switch dist-check to uv run pyro so verification stays stable after uv reinstalls.

Validated with uv lock, focused startup/server/CLI pytest coverage, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and real guest-backed smokes for both explicit project_path and bare repo-root auto-detection.
This commit is contained in:
Thales Maciel 2026-03-13 15:51:47 -03:00
parent 9b9b83ebeb
commit 535efc6919
28 changed files with 968 additions and 67 deletions

View file

@ -13,6 +13,12 @@ from pyro_mcp.contract import (
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
)
from pyro_mcp.project_startup import (
ProjectStartupSource,
describe_project_startup_source,
materialize_project_startup_source,
resolve_project_startup_source,
)
from pyro_mcp.vm_manager import (
DEFAULT_ALLOW_HOST_COMPAT,
DEFAULT_MEM_MIB,
@ -39,6 +45,18 @@ def _validate_mcp_profile(profile: str) -> McpToolProfile:
return cast(McpToolProfile, profile)
def _workspace_create_description(startup_source: ProjectStartupSource | None) -> str:
if startup_source is None:
return "Create and start a persistent workspace."
described_source = describe_project_startup_source(startup_source)
if described_source is None:
return "Create and start a persistent workspace."
return (
"Create and start a persistent workspace. If `seed_path` is omitted, "
f"the server seeds from {described_source}."
)
class Pyro:
"""High-level facade over the ephemeral VM runtime."""
@ -462,14 +480,31 @@ class Pyro:
allow_host_compat=allow_host_compat,
)
def create_server(self, *, profile: McpToolProfile = "workspace-core") -> FastMCP:
def create_server(
self,
*,
profile: McpToolProfile = "workspace-core",
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
no_project_source: bool = False,
) -> FastMCP:
"""Create an MCP server for one of the stable public tool profiles.
`workspace-core` is the default stable chat-host profile in 4.x. Use
`profile="workspace-full"` only when the host truly needs the full
advanced workspace surface.
advanced workspace surface. By default, the server auto-detects the
nearest Git worktree root from its current working directory and uses
that source when `workspace_create` omits `seed_path`. `project_path`,
`repo_url`, and `no_project_source` override that behavior explicitly.
"""
normalized_profile = _validate_mcp_profile(profile)
startup_source = resolve_project_startup_source(
project_path=project_path,
repo_url=repo_url,
repo_ref=repo_ref,
no_project_source=no_project_source,
)
enabled_tools = set(_PROFILE_TOOLS[normalized_profile])
server = FastMCP(name="pyro_mcp")
@ -583,9 +618,56 @@ class Pyro:
return self.reap_expired()
if _enabled("workspace_create"):
workspace_create_description = _workspace_create_description(startup_source)
def _create_workspace_from_server_defaults(
*,
environment: str,
vcpu_count: int,
mem_mib: int,
ttl_seconds: int,
network_policy: str,
allow_host_compat: bool,
seed_path: str | None,
secrets: list[dict[str, str]] | None,
name: str | None,
labels: dict[str, str] | None,
) -> dict[str, Any]:
if seed_path is not None or startup_source is None:
return self.create_workspace(
environment=environment,
vcpu_count=vcpu_count,
mem_mib=mem_mib,
ttl_seconds=ttl_seconds,
network_policy=network_policy,
allow_host_compat=allow_host_compat,
seed_path=seed_path,
secrets=secrets,
name=name,
labels=labels,
)
with materialize_project_startup_source(startup_source) as resolved_seed_path:
prepared_seed = self._manager._prepare_workspace_seed( # noqa: SLF001
resolved_seed_path,
origin_kind=startup_source.kind,
origin_ref=startup_source.origin_ref,
)
return self._manager.create_workspace(
environment=environment,
vcpu_count=vcpu_count,
mem_mib=mem_mib,
ttl_seconds=ttl_seconds,
network_policy=network_policy,
allow_host_compat=allow_host_compat,
secrets=secrets,
name=name,
labels=labels,
_prepared_seed=prepared_seed,
)
if normalized_profile == "workspace-core":
@server.tool(name="workspace_create")
@server.tool(name="workspace_create", description=workspace_create_description)
async def workspace_create_core(
environment: str,
vcpu_count: int = DEFAULT_VCPU_COUNT,
@ -596,8 +678,7 @@ class Pyro:
name: str | None = None,
labels: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Create and start a persistent workspace."""
return self.create_workspace(
return _create_workspace_from_server_defaults(
environment=environment,
vcpu_count=vcpu_count,
mem_mib=mem_mib,
@ -612,7 +693,7 @@ class Pyro:
else:
@server.tool(name="workspace_create")
@server.tool(name="workspace_create", description=workspace_create_description)
async def workspace_create_full(
environment: str,
vcpu_count: int = DEFAULT_VCPU_COUNT,
@ -625,8 +706,7 @@ class Pyro:
name: str | None = None,
labels: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Create and start a persistent workspace."""
return self.create_workspace(
return _create_workspace_from_server_defaults(
environment=environment,
vcpu_count=vcpu_count,
mem_mib=mem_mib,

View file

@ -191,7 +191,16 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
if isinstance(workspace_seed, dict):
mode = str(workspace_seed.get("mode", "empty"))
seed_path = workspace_seed.get("seed_path")
if isinstance(seed_path, str) and seed_path != "":
origin_kind = workspace_seed.get("origin_kind")
origin_ref = workspace_seed.get("origin_ref")
if isinstance(origin_kind, str) and isinstance(origin_ref, str) and origin_ref != "":
if origin_kind == "project_path":
print(f"Workspace seed: {mode} from project {origin_ref}")
elif origin_kind == "repo_url":
print(f"Workspace seed: {mode} from clean clone {origin_ref}")
else:
print(f"Workspace seed: {mode} from {origin_ref}")
elif isinstance(seed_path, str) and seed_path != "":
print(f"Workspace seed: {mode} from {seed_path}")
else:
print(f"Workspace seed: {mode}")
@ -770,6 +779,8 @@ def _build_parser() -> argparse.ArgumentParser:
"""
Examples:
pyro mcp serve
pyro mcp serve --project-path .
pyro mcp serve --repo-url https://github.com/example/project.git
pyro mcp serve --profile vm-run
pyro mcp serve --profile workspace-full
"""
@ -783,12 +794,15 @@ def _build_parser() -> argparse.ArgumentParser:
description=(
"Expose pyro tools over stdio for an MCP client. Bare `pyro mcp "
"serve` now starts `workspace-core`, the recommended first profile "
"for most chat hosts."
"for most chat hosts. When launched from inside a Git checkout, it "
"also seeds the first workspace from that repo by default."
),
epilog=dedent(
"""
Default and recommended first start:
pyro mcp serve
pyro mcp serve --project-path .
pyro mcp serve --repo-url https://github.com/example/project.git
Profiles:
workspace-core: default for normal persistent chat editing
@ -796,6 +810,12 @@ def _build_parser() -> argparse.ArgumentParser:
workspace-full: larger opt-in surface for shells, services,
snapshots, secrets, network policy, and disk tools
Project-aware startup:
- bare `pyro mcp serve` auto-detects the nearest Git checkout
from the current working directory
- use --project-path when the host does not preserve cwd
- use --repo-url for a clean-clone source outside a local checkout
Use --profile workspace-full only when the host truly needs those
extra workspace capabilities.
"""
@ -812,6 +832,33 @@ def _build_parser() -> argparse.ArgumentParser:
"`workspace-full` is the larger opt-in profile."
),
)
mcp_source_group = mcp_serve_parser.add_mutually_exclusive_group()
mcp_source_group.add_argument(
"--project-path",
help=(
"Seed default workspaces from this local project path. If the path "
"is inside a Git checkout, pyro uses that repo root."
),
)
mcp_source_group.add_argument(
"--repo-url",
help=(
"Seed default workspaces from a clean host-side clone of this repo URL "
"when `workspace_create` omits `seed_path`."
),
)
mcp_serve_parser.add_argument(
"--repo-ref",
help="Optional branch, tag, or commit to checkout after cloning --repo-url.",
)
mcp_serve_parser.add_argument(
"--no-project-source",
action="store_true",
help=(
"Disable automatic Git checkout detection from the current working "
"directory."
),
)
run_parser = subparsers.add_parser(
"run",
@ -2304,7 +2351,13 @@ def main() -> None:
_print_prune_human(prune_payload)
return
if args.command == "mcp":
pyro.create_server(profile=args.profile).run(transport="stdio")
pyro.create_server(
profile=args.profile,
project_path=args.project_path,
repo_url=args.repo_url,
repo_ref=args.repo_ref,
no_project_source=bool(args.no_project_source),
).run(transport="stdio")
return
if args.command == "run":
command = _require_command(args.command_args)

View file

@ -6,7 +6,13 @@ PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace")
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
PUBLIC_CLI_MCP_SERVE_FLAGS = ("--profile",)
PUBLIC_CLI_MCP_SERVE_FLAGS = (
"--profile",
"--project-path",
"--repo-url",
"--repo-ref",
"--no-project-source",
)
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
"create",
"delete",

View file

@ -0,0 +1,149 @@
"""Server-scoped project startup source helpers for MCP chat flows."""
from __future__ import annotations
import shutil
import subprocess
import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator, Literal
ProjectStartupSourceKind = Literal["project_path", "repo_url"]
@dataclass(frozen=True)
class ProjectStartupSource:
"""Server-scoped default source for workspace creation."""
kind: ProjectStartupSourceKind
origin_ref: str
resolved_path: Path | None = None
repo_ref: str | None = None
def _run_git(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run( # noqa: S603
command,
cwd=str(cwd) if cwd is not None else None,
check=False,
capture_output=True,
text=True,
)
def _detect_git_root(start_dir: Path) -> Path | None:
result = _run_git(["git", "rev-parse", "--show-toplevel"], cwd=start_dir)
if result.returncode != 0:
return None
stdout = result.stdout.strip()
if stdout == "":
return None
return Path(stdout).expanduser().resolve()
def _resolve_project_path(project_path: str | Path, *, cwd: Path) -> Path:
resolved = Path(project_path).expanduser()
if not resolved.is_absolute():
resolved = (cwd / resolved).resolve()
else:
resolved = resolved.resolve()
if not resolved.exists():
raise ValueError(f"project_path {resolved} does not exist")
if not resolved.is_dir():
raise ValueError(f"project_path {resolved} must be a directory")
git_root = _detect_git_root(resolved)
if git_root is not None:
return git_root
return resolved
def resolve_project_startup_source(
*,
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
no_project_source: bool = False,
cwd: Path | None = None,
) -> ProjectStartupSource | None:
working_dir = Path.cwd() if cwd is None else cwd.resolve()
if no_project_source:
if project_path is not None or repo_url is not None or repo_ref is not None:
raise ValueError(
"--no-project-source cannot be combined with --project-path, "
"--repo-url, or --repo-ref"
)
return None
if project_path is not None and repo_url is not None:
raise ValueError("--project-path and --repo-url are mutually exclusive")
if repo_ref is not None and repo_url is None:
raise ValueError("--repo-ref requires --repo-url")
if project_path is not None:
resolved_path = _resolve_project_path(project_path, cwd=working_dir)
return ProjectStartupSource(
kind="project_path",
origin_ref=str(resolved_path),
resolved_path=resolved_path,
)
if repo_url is not None:
normalized_repo_url = repo_url.strip()
if normalized_repo_url == "":
raise ValueError("--repo-url must not be empty")
normalized_repo_ref = None if repo_ref is None else repo_ref.strip()
if normalized_repo_ref == "":
raise ValueError("--repo-ref must not be empty")
return ProjectStartupSource(
kind="repo_url",
origin_ref=normalized_repo_url,
repo_ref=normalized_repo_ref,
)
detected_root = _detect_git_root(working_dir)
if detected_root is None:
return None
return ProjectStartupSource(
kind="project_path",
origin_ref=str(detected_root),
resolved_path=detected_root,
)
@contextmanager
def materialize_project_startup_source(source: ProjectStartupSource) -> Iterator[Path]:
if source.kind == "project_path":
if source.resolved_path is None:
raise RuntimeError("project_path source is missing a resolved path")
yield source.resolved_path
return
temp_dir = Path(tempfile.mkdtemp(prefix="pyro-project-source-"))
clone_dir = temp_dir / "clone"
try:
clone_result = _run_git(["git", "clone", "--quiet", source.origin_ref, str(clone_dir)])
if clone_result.returncode != 0:
stderr = clone_result.stderr.strip() or "git clone failed"
raise RuntimeError(f"failed to clone repo_url {source.origin_ref!r}: {stderr}")
if source.repo_ref is not None:
checkout_result = _run_git(
["git", "checkout", "--quiet", source.repo_ref],
cwd=clone_dir,
)
if checkout_result.returncode != 0:
stderr = checkout_result.stderr.strip() or "git checkout failed"
raise RuntimeError(
f"failed to checkout repo_ref {source.repo_ref!r} for "
f"repo_url {source.origin_ref!r}: {stderr}"
)
yield clone_dir
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def describe_project_startup_source(source: ProjectStartupSource | None) -> str | None:
if source is None:
return None
if source.kind == "project_path":
return f"the current project at {source.origin_ref}"
if source.repo_ref is None:
return f"the clean clone source {source.origin_ref}"
return f"the clean clone source {source.origin_ref} at ref {source.repo_ref}"

View file

@ -2,6 +2,8 @@
from __future__ import annotations
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from pyro_mcp.api import McpToolProfile, Pyro
@ -12,14 +14,26 @@ def create_server(
manager: VmManager | None = None,
*,
profile: McpToolProfile = "workspace-core",
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
no_project_source: bool = False,
) -> FastMCP:
"""Create and return a configured MCP server instance.
`workspace-core` is the default stable chat-host profile in 4.x. Use
`profile="workspace-full"` only when the host truly needs the full
advanced workspace surface.
advanced workspace surface. By default, the server auto-detects the
nearest Git worktree root from its current working directory for
project-aware `workspace_create` calls.
"""
return Pyro(manager=manager).create_server(profile=profile)
return Pyro(manager=manager).create_server(
profile=profile,
project_path=project_path,
repo_url=repo_url,
repo_ref=repo_ref,
no_project_source=no_project_source,
)
def main() -> None:

View file

@ -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 = "4.0.0"
DEFAULT_CATALOG_VERSION = "4.1.0"
OCI_MANIFEST_ACCEPT = ", ".join(
(
"application/vnd.oci.image.index.v1+json",
@ -48,7 +48,7 @@ class VmEnvironment:
oci_repository: str | None = None
oci_reference: str | None = None
source_digest: str | None = None
compatibility: str = ">=4.0.0,<5.0.0"
compatibility: str = ">=4.1.0,<5.0.0"
@dataclass(frozen=True)

View file

@ -116,6 +116,7 @@ WORKSPACE_SECRET_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
WORKSPACE_LABEL_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"]
WorkspaceSeedOriginKind = Literal["empty", "manual_seed_path", "project_path", "repo_url"]
WorkspaceArtifactType = Literal["file", "directory", "symlink"]
WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"]
WorkspaceSnapshotKind = Literal["baseline", "named"]
@ -524,6 +525,8 @@ class PreparedWorkspaceSeed:
mode: WorkspaceSeedMode
source_path: str | None
origin_kind: WorkspaceSeedOriginKind = "empty"
origin_ref: str | None = None
archive_path: Path | None = None
entry_count: int = 0
bytes_written: int = 0
@ -534,14 +537,19 @@ class PreparedWorkspaceSeed:
*,
destination: str = WORKSPACE_GUEST_PATH,
path_key: str = "seed_path",
include_origin: bool = True,
) -> dict[str, Any]:
return {
payload = {
"mode": self.mode,
path_key: self.source_path,
"destination": destination,
"entry_count": self.entry_count,
"bytes_written": self.bytes_written,
}
if include_origin:
payload["origin_kind"] = self.origin_kind
payload["origin_ref"] = self.origin_ref
return payload
def cleanup(self) -> None:
if self.cleanup_dir is not None:
@ -614,6 +622,8 @@ def _empty_workspace_seed_payload() -> dict[str, Any]:
return {
"mode": "empty",
"seed_path": None,
"origin_kind": "empty",
"origin_ref": None,
"destination": WORKSPACE_GUEST_PATH,
"entry_count": 0,
"bytes_written": 0,
@ -628,6 +638,8 @@ def _workspace_seed_dict(value: object) -> dict[str, Any]:
{
"mode": str(value.get("mode", payload["mode"])),
"seed_path": _optional_str(value.get("seed_path")),
"origin_kind": str(value.get("origin_kind", payload["origin_kind"])),
"origin_ref": _optional_str(value.get("origin_ref")),
"destination": str(value.get("destination", payload["destination"])),
"entry_count": int(value.get("entry_count", payload["entry_count"])),
"bytes_written": int(value.get("bytes_written", payload["bytes_written"])),
@ -3747,13 +3759,16 @@ class VmManager:
secrets: list[dict[str, str]] | None = None,
name: str | None = None,
labels: dict[str, str] | None = None,
_prepared_seed: PreparedWorkspaceSeed | None = None,
) -> dict[str, Any]:
self._validate_limits(vcpu_count=vcpu_count, mem_mib=mem_mib, ttl_seconds=ttl_seconds)
get_environment(environment, runtime_paths=self._runtime_paths)
normalized_network_policy = _normalize_workspace_network_policy(str(network_policy))
normalized_name = None if name is None else _normalize_workspace_name(name)
normalized_labels = _normalize_workspace_labels(labels)
prepared_seed = self._prepare_workspace_seed(seed_path)
if _prepared_seed is not None and seed_path is not None:
raise ValueError("_prepared_seed and seed_path are mutually exclusive")
prepared_seed = _prepared_seed or self._prepare_workspace_seed(seed_path)
now = time.time()
workspace_id = uuid.uuid4().hex[:12]
workspace_dir = self._workspace_dir(workspace_id)
@ -3885,6 +3900,7 @@ class VmManager:
workspace_sync = prepared_seed.to_payload(
destination=normalized_destination,
path_key="source_path",
include_origin=False,
)
workspace_sync["entry_count"] = int(import_summary["entry_count"])
workspace_sync["bytes_written"] = int(import_summary["bytes_written"])
@ -5663,12 +5679,30 @@ class VmManager:
execution_mode = instance.metadata.get("execution_mode", "unknown")
return exec_result, execution_mode
def _prepare_workspace_seed(self, seed_path: str | Path | None) -> PreparedWorkspaceSeed:
def _prepare_workspace_seed(
self,
seed_path: str | Path | None,
*,
origin_kind: WorkspaceSeedOriginKind | None = None,
origin_ref: str | None = None,
) -> PreparedWorkspaceSeed:
if seed_path is None:
return PreparedWorkspaceSeed(mode="empty", source_path=None)
return PreparedWorkspaceSeed(
mode="empty",
source_path=None,
origin_kind="empty" if origin_kind is None else origin_kind,
origin_ref=origin_ref,
)
resolved_source_path = Path(seed_path).expanduser().resolve()
if not resolved_source_path.exists():
raise ValueError(f"seed_path {resolved_source_path} does not exist")
effective_origin_kind: WorkspaceSeedOriginKind = (
"manual_seed_path" if origin_kind is None else origin_kind
)
effective_origin_ref = str(resolved_source_path) if origin_ref is None else origin_ref
public_source_path = (
None if effective_origin_kind == "repo_url" else str(resolved_source_path)
)
if resolved_source_path.is_dir():
cleanup_dir = Path(tempfile.mkdtemp(prefix="pyro-workspace-seed-"))
archive_path = cleanup_dir / "workspace-seed.tar"
@ -5680,7 +5714,9 @@ class VmManager:
raise
return PreparedWorkspaceSeed(
mode="directory",
source_path=str(resolved_source_path),
source_path=public_source_path,
origin_kind=effective_origin_kind,
origin_ref=effective_origin_ref,
archive_path=archive_path,
entry_count=entry_count,
bytes_written=bytes_written,
@ -5696,7 +5732,9 @@ class VmManager:
entry_count, bytes_written = _inspect_seed_archive(resolved_source_path)
return PreparedWorkspaceSeed(
mode="tar_archive",
source_path=str(resolved_source_path),
source_path=public_source_path,
origin_kind=effective_origin_kind,
origin_ref=effective_origin_ref,
archive_path=resolved_source_path,
entry_count=entry_count,
bytes_written=bytes_written,

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import argparse
import asyncio
import tempfile
import time
from dataclasses import dataclass
@ -107,6 +108,15 @@ def _log(message: str) -> None:
print(f"[smoke] {message}", flush=True)
def _extract_structured_tool_result(raw_result: object) -> dict[str, object]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
raise TypeError("unexpected MCP tool result shape")
_, structured = raw_result
if not isinstance(structured, dict):
raise TypeError("expected structured dictionary result")
return structured
def _create_workspace(
pyro: Pyro,
*,
@ -126,6 +136,30 @@ def _create_workspace(
return str(created["workspace_id"])
def _create_project_aware_workspace(
pyro: Pyro,
*,
environment: str,
project_path: Path,
name: str,
labels: dict[str, str],
) -> dict[str, object]:
async def _run() -> dict[str, object]:
server = pyro.create_server(profile="workspace-core", project_path=project_path)
return _extract_structured_tool_result(
await server.call_tool(
"workspace_create",
{
"environment": environment,
"name": name,
"labels": labels,
},
)
)
return asyncio.run(_run())
def _safe_delete_workspace(pyro: Pyro, workspace_id: str | None) -> None:
if workspace_id is None:
return
@ -221,14 +255,19 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
)
workspace_id: str | None = None
try:
workspace_id = _create_workspace(
created = _create_project_aware_workspace(
pyro,
environment=environment,
seed_path=seed_dir,
project_path=seed_dir,
name="repro-fix-loop",
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"},
)
workspace_id = str(created["workspace_id"])
_log(f"repro-fix-loop workspace_id={workspace_id}")
workspace_seed = created["workspace_seed"]
assert isinstance(workspace_seed, dict), created
assert workspace_seed["origin_kind"] == "project_path", created
assert workspace_seed["origin_ref"] == str(seed_dir.resolve()), created
initial_read = pyro.read_workspace_file(workspace_id, "message.txt")
assert str(initial_read["content"]) == "broken\n", initial_read
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")