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:
parent
9b9b83ebeb
commit
535efc6919
28 changed files with 968 additions and 67 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
149
src/pyro_mcp/project_startup.py
Normal file
149
src/pyro_mcp/project_startup.py
Normal 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}"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue