diff --git a/docs/design/registry.md b/docs/design/registry.md index ffe2c94..a1a6958 100644 --- a/docs/design/registry.md +++ b/docs/design/registry.md @@ -298,6 +298,119 @@ file of the same name. | SemVer ranges | Out of scope v1 | Adds resolver complexity for marginal benefit until ecosystems exist. | | Index format | YAML primary, JSON fallback | Consistent with workflow files. JSON tolerated for tooling. | +## Ad-hoc references + +A **workflow reference without pre-configured registry** allows teams to compose +workflows across GitHub organizations and repositories without registry setup. + +### Motivation + +Configured registries are ideal for standard repos (org-wide workflows, team +templates). But ad-hoc cross-team composition is common: Team C wants to run a +workflow from Team A's repo in combination with Team B's workflow, without any +team having to register each other's repos. Ad-hoc references lower friction for +one-off usage. + +### Syntax + +``` +workflow@owner/repo[#ref] +``` + +If the part after `@` contains `/`, it is treated as a literal `owner/repo` +GitHub reference (ad-hoc) and fetched directly. Otherwise it is looked up as a +configured registry name (existing behavior). + +Examples: + +``` +analysis@myorg/team-a # default branch HEAD of myorg/team-a +analysis@myorg/team-a#v1.0.0 # tag v1.0.0 of myorg/team-a +analysis@myorg/team-a#main # main branch of myorg/team-a +analysis@myorg/team-a#abc1234 # specific commit SHA +``` + +### Disambiguation rule + +At parse time, Conductor disambiguates between ad-hoc and registry references: + +- `analysis@team` → registry name `team` (no `/` in the part after `@`) +- `analysis@myorg/team-a` → ad-hoc reference to `myorg/team-a` (contains `/`) + +Registry names are configured by the user and cannot contain `/`, so there is +no ambiguity. Both forms coexist: configured registries are recommended for +frequently-used sources, ad-hoc references for occasional cross-team pulls. + +### Caching + +Ad-hoc workflows are cached at: + +``` +~/.conductor/cache/registries/_adhoc///// +``` + +This isolates ad-hoc caches from named registries, avoiding collisions when the +same workflow name exists in different sources. + +### Reference resolution + +Ad-hoc references follow the same resolution rules as registry references: + +- Missing `#` → use the **default branch HEAD** (re-resolved on each fetch). +- Explicit `#` or `#` → pinned to that tag or the current HEAD of the branch. +- Explicit `#` → pinned to an exact commit. +- Multiple `@` or multiple `#` are hard errors. + +### Authentication + +Ad-hoc references use the same authentication as named GitHub registries: +- Public repos work automatically. +- Private repos use `gh auth token` if available, otherwise fail with a clear error. + +### Usage + +Ad-hoc references work everywhere registry references work: + +```bash +conductor run 'analysis@myorg/team-a#v1.0.0' --input question="..." +conductor validate 'analysis@myorg/team-a#main' +conductor resume 'analysis@myorg/team-a#v1.0.0' +``` + +As a sub-workflow (see [Sub-workflows](#sub-workflows) in the workflow syntax guide): + +```yaml +agents: + - name: team_a_analysis + type: workflow + workflow: analysis@myorg/team-a#v1.0.0 + input_mapping: + data: "{{ workflow.input.raw_data }}" +``` + +### Example: Cross-team composition + +Team C's workflow references Team A's and Team B's workflows without any +pre-registry setup: + +```yaml +agents: + - name: team_a_pipeline + type: workflow + workflow: qa-bot@teamA/qa-workflows#main + input_mapping: + question: "{{ workflow.input.query }}" + + - name: team_b_pipeline + type: workflow + workflow: reviewer@teamB/review-workflows#v2.1.0 + input_mapping: + content: "{{ team_a_pipeline.output.answer }}" +``` + +Both workflows are fetched and composed in Team C's workflow without any +registry configuration. + ## Open questions - **Sibling fetch scope for GitHub.** Should we fetch only files in the diff --git a/docs/workflow-syntax.md b/docs/workflow-syntax.md index 731fc11..876547d 100644 --- a/docs/workflow-syntax.md +++ b/docs/workflow-syntax.md @@ -273,7 +273,10 @@ agents: **Key semantics:** -- The `workflow` path is resolved relative to the parent workflow file +- The `workflow` field can be: + - A local file path: `./research-pipeline.yaml` (resolved relative to the parent) + - A configured registry reference: `qa-bot@team#v1.2.3` (see [Workflow Registry](design/registry.md)) + - An ad-hoc GitHub reference: `analysis@myorg/team-a#main` (owner/repo fetched directly from GitHub) - Sub-workflow inherits the parent's provider configuration - Sub-workflow output is stored in context and accessible via `{{ agent_name.output.field }}` - Recursive composition is supported (sub-workflows can reference other sub-workflows) with a global depth limit of `MAX_SUBWORKFLOW_DEPTH = 10` @@ -288,6 +291,33 @@ prompt: | {{ deep_research.output.findings }} ``` +**Workflow reference types** — the `workflow` field supports three forms: + +```yaml +agents: + # Local file path (relative to parent workflow) + - name: local_pipeline + type: workflow + workflow: ./shared/research-pipeline.yaml + + # Configured registry reference + - name: registry_pipeline + type: workflow + workflow: qa-bot@team#v1.2.3 + + # Ad-hoc GitHub reference (no registry setup required) + - name: adhoc_pipeline + type: workflow + workflow: analysis@myorg/team-a#main + input_mapping: + data: "{{ workflow.input.raw_data }}" +``` + +The ad-hoc form (`workflow@owner/repo[#ref]`) allows cross-team workflow +composition without pre-configuring registries. See +[Ad-hoc References](design/registry.md#ad-hoc-references) in the registry design +doc for details on caching, authentication, and ref resolution. + **Sub-workflows in `for_each` groups** — `type: workflow` agents can be used inside `for_each` groups to fan out one sub-workflow run per item in the source array. Each iteration receives its own `input_mapping` evaluated against the loop variable, and emits its own `subworkflow_started` / `subworkflow_completed` events: ```yaml diff --git a/src/conductor/cli/app.py b/src/conductor/cli/app.py index aefaa42..ce2a6ef 100644 --- a/src/conductor/cli/app.py +++ b/src/conductor/cli/app.py @@ -359,25 +359,12 @@ def run( import asyncio import json - from conductor.registry.cache import fetch_workflow as fetch_registry_workflow + from conductor.registry.cache import resolve_and_fetch from conductor.registry.errors import RegistryError from conductor.registry.resolver import resolve_ref try: - ref = resolve_ref(workflow) - if ref.kind == "file": - assert ref.path is not None - workflow_path = ref.path - else: - assert ref.registry_name is not None - assert ref.registry_entry is not None - assert ref.workflow is not None - workflow_path = fetch_registry_workflow( - registry_name=ref.registry_name, - registry_entry=ref.registry_entry, - workflow_name=ref.workflow, - ref=ref.ref, - ) + workflow_path = resolve_and_fetch(resolve_ref(workflow)) except RegistryError as e: print_error(e) raise typer.Exit(code=1) from None @@ -507,25 +494,12 @@ def validate( conductor validate ./examples/my-workflow.yaml conductor validate qa-bot@team@1.0.0 """ - from conductor.registry.cache import fetch_workflow as fetch_registry_workflow + from conductor.registry.cache import resolve_and_fetch from conductor.registry.errors import RegistryError from conductor.registry.resolver import resolve_ref try: - ref = resolve_ref(workflow) - if ref.kind == "file": - assert ref.path is not None - workflow_path = ref.path - else: - assert ref.registry_name is not None - assert ref.registry_entry is not None - assert ref.workflow is not None - workflow_path = fetch_registry_workflow( - registry_name=ref.registry_name, - registry_entry=ref.registry_entry, - workflow_name=ref.workflow, - ref=ref.ref, - ) + workflow_path = resolve_and_fetch(resolve_ref(workflow)) except RegistryError as e: print_error(e) raise typer.Exit(code=1) from None @@ -563,7 +537,7 @@ def show( conductor show qa-bot conductor show qa-bot@my-registry@1.0.0 """ - from conductor.registry.cache import fetch_workflow as fetch_registry_workflow + from conductor.registry.cache import resolve_and_fetch from conductor.registry.errors import RegistryError from conductor.registry.resolver import resolve_ref @@ -576,15 +550,7 @@ def show( console.print(f"[bold red]Error:[/bold red] Workflow file not found: {workflow}") raise typer.Exit(code=1) else: - assert ref.registry_name is not None - assert ref.registry_entry is not None - assert ref.workflow is not None - workflow_path = fetch_registry_workflow( - registry_name=ref.registry_name, - registry_entry=ref.registry_entry, - workflow_name=ref.workflow, - ref=ref.ref, - ) + workflow_path = resolve_and_fetch(ref) except RegistryError as e: print_error(e) raise typer.Exit(code=1) from None @@ -819,7 +785,7 @@ def resume( # Resolve workflow ref if provided resolved_workflow: Path | None = None if workflow is not None: - from conductor.registry.cache import fetch_workflow as fetch_registry_workflow + from conductor.registry.cache import resolve_and_fetch from conductor.registry.errors import RegistryError from conductor.registry.resolver import resolve_ref @@ -834,15 +800,7 @@ def resume( ) raise typer.Exit(code=1) else: - assert ref.registry_name is not None - assert ref.registry_entry is not None - assert ref.workflow is not None - resolved_workflow = fetch_registry_workflow( - registry_name=ref.registry_name, - registry_entry=ref.registry_entry, - workflow_name=ref.workflow, - ref=ref.ref, - ) + resolved_workflow = resolve_and_fetch(ref) except RegistryError as e: print_error(e) raise typer.Exit(code=1) from None diff --git a/src/conductor/config/validator.py b/src/conductor/config/validator.py index 318f720..7365b0f 100644 --- a/src/conductor/config/validator.py +++ b/src/conductor/config/validator.py @@ -905,6 +905,7 @@ def _resolve_subworkflow_ref_for_validation( Returns: Tuple of (resolved path or None on error, list of error strings). """ + from conductor.registry.cache import resolve_and_fetch from conductor.registry.errors import RegistryError from conductor.registry.resolver import resolve_ref @@ -926,23 +927,12 @@ def _resolve_subworkflow_ref_for_validation( errors.append(f"{label}: sub-workflow file not found: '{candidate}'") return None, errors - # Registry reference: fetch (uses cache; makes network request on first access). - from conductor.registry.cache import fetch_workflow - - # registry_name, registry_entry, and workflow are always set when kind == "registry" - assert resolved.registry_name is not None # noqa: S101 - assert resolved.registry_entry is not None # noqa: S101 - assert resolved.workflow is not None # noqa: S101 - + # Named registry or ad-hoc reference: fetch (uses cache; makes network + # request on first access). try: - sub_path = fetch_workflow( - resolved.registry_name, - resolved.registry_entry, - resolved.workflow, - resolved.ref, - ) + sub_path = resolve_and_fetch(resolved) except RegistryError as exc: - errors.append(f"{label}: failed to fetch registry sub-workflow '{workflow_ref}': {exc}") + errors.append(f"{label}: failed to fetch sub-workflow '{workflow_ref}': {exc}") return None, errors return sub_path, errors diff --git a/src/conductor/engine/workflow.py b/src/conductor/engine/workflow.py index 44994ef..b065aea 100644 --- a/src/conductor/engine/workflow.py +++ b/src/conductor/engine/workflow.py @@ -749,6 +749,7 @@ async def _resolve_subworkflow_path( ExecutionError: If the registry reference is malformed, names an unknown registry, or the registry fetch fails. """ + from conductor.registry.cache import resolve_and_fetch from conductor.registry.errors import RegistryError from conductor.registry.resolver import resolve_ref @@ -759,7 +760,7 @@ async def _resolve_subworkflow_path( if candidate.is_file(): return candidate - # Step 2: heuristic parse — file-looking path or registry reference? + # Step 2: parse as file-path / named-registry / ad-hoc reference. try: resolved = resolve_ref(agent_workflow) except RegistryError as exc: @@ -767,8 +768,9 @@ async def _resolve_subworkflow_path( f"Failed to resolve sub-workflow '{agent_workflow}' " f"(referenced by agent '{agent_name}'): {exc}", suggestion=( - "Check the registry reference syntax and ensure the registry " - "is configured (run 'conductor registry list')." + "Check the registry reference syntax. For named registries, " + "ensure the registry is configured (run 'conductor registry list'). " + "For ad-hoc references, use 'workflow@owner/repo[#ref]'." ), ) from exc @@ -777,29 +779,16 @@ async def _resolve_subworkflow_path( # candidate path so the caller emits a clear "file not found" error. return candidate - # Step 4: registry reference — fetch (or return cached) local path. - from conductor.registry.cache import fetch_workflow - - # registry_name, registry_entry, and workflow are always set when kind == "registry" - assert resolved.registry_name is not None # noqa: S101 - assert resolved.registry_entry is not None # noqa: S101 - assert resolved.workflow is not None # noqa: S101 - + # Step 4: dispatch to the unified fetcher (handles registry + adhoc). try: - return await asyncio.to_thread( - fetch_workflow, - resolved.registry_name, - resolved.registry_entry, - resolved.workflow, - resolved.ref, - ) + return await asyncio.to_thread(resolve_and_fetch, resolved) except RegistryError as exc: raise ExecutionError( - f"Failed to fetch registry sub-workflow '{agent_workflow}' " + f"Failed to fetch sub-workflow '{agent_workflow}' " f"(referenced by agent '{agent_name}'): {exc}", suggestion=( - "Check that the registry name and workflow name are correct " - "and the registry is reachable." + "Check that the registry/repo and workflow name are correct " + "and the source is reachable." ), ) from exc diff --git a/src/conductor/registry/cache.py b/src/conductor/registry/cache.py index 964c137..8ff09ca 100644 --- a/src/conductor/registry/cache.py +++ b/src/conductor/registry/cache.py @@ -11,6 +11,13 @@ Cache layout:: /cache/registries//// + +For ad-hoc references (``workflow@owner/repo#ref``) the registry namespace +is ``_adhoc//`` so adhoc caches are isolated from named +registry caches and cannot collide with any user-configured registry name +(named registries reject names containing ``/``):: + + /cache/registries/_adhoc///// """ from __future__ import annotations @@ -19,6 +26,7 @@ import shutil import tempfile from pathlib import Path +from typing import TYPE_CHECKING from conductor.registry.config import RegistryEntry, RegistryType from conductor.registry.errors import RegistryError @@ -26,8 +34,15 @@ from conductor.registry.index import load_index from conductor.registry.version_resolver import materialize_to_sha, resolve_ref +if TYPE_CHECKING: + from conductor.registry.resolver import ResolvedRef + # fetch_file returns bytes; list_directory returns filenames (not full paths) +# Reserved cache namespace for ad-hoc references. Cannot collide with a +# named registry because configured registry names cannot contain '/'. +_ADHOC_NAMESPACE = "_adhoc" + # --------------------------------------------------------------------------- # Path helpers # --------------------------------------------------------------------------- @@ -195,6 +210,106 @@ def fetch_workflow( return result +def fetch_workflow_adhoc( + owner: str, + repo: str, + workflow_name: str, + ref: str | None = None, +) -> Path: + """Fetch an ad-hoc workflow from a GitHub repo without registry config. + + Constructs a synthetic ``RegistryEntry`` for ``owner/repo`` and reuses + the same fetch + cache pipeline as named registries. Cache entries are + namespaced under ``_adhoc///`` so they're isolated from + configured registries. + + Args: + owner: GitHub repository owner. + repo: GitHub repository name. + workflow_name: Workflow key as listed in the repo's ``index.yaml``. + ref: Optional git ref (tag, branch, or SHA). ``None`` resolves to + the repository's default branch HEAD. + + Returns: + Path to the cached workflow YAML file. + + Raises: + RegistryError: On fetch failure, missing workflow, or I/O errors. + """ + synthetic_entry = RegistryEntry( + type=RegistryType.github, + source=f"{owner}/{repo}", + ) + synthetic_registry_name = f"{_ADHOC_NAMESPACE}/{owner}/{repo}" + return fetch_workflow( + registry_name=synthetic_registry_name, + registry_entry=synthetic_entry, + workflow_name=workflow_name, + ref=ref, + ) + + +def resolve_and_fetch(resolved: ResolvedRef) -> Path: + """Return a local filesystem path for any kind of resolved reference. + + Single dispatcher used by the CLI, engine, and validator so each call + site does not need to switch on ``ResolvedRef.kind``. Behavior by kind: + + * ``file``: returns ``resolved.path`` unchanged. Caller is responsible + for verifying the path exists. + * ``registry``: fetches via :func:`fetch_workflow` (cached under the + configured registry name). + * ``adhoc``: fetches via :func:`fetch_workflow_adhoc` (cached under the + ``_adhoc//`` namespace). + + Args: + resolved: A :class:`~conductor.registry.resolver.ResolvedRef` from + :func:`~conductor.registry.resolver.resolve_ref`. + + Returns: + A local ``Path`` to the workflow YAML file. + + Raises: + RegistryError: When a registry/adhoc fetch fails. + ValueError: If ``resolved`` has missing required fields for its kind. + """ + if resolved.kind == "file": + if resolved.path is None: + raise ValueError("ResolvedRef(kind='file') must have a non-None path") + return resolved.path + + if resolved.kind == "registry": + if ( + resolved.registry_name is None + or resolved.registry_entry is None + or resolved.workflow is None + ): + raise ValueError( + "ResolvedRef(kind='registry') must have non-None " + "registry_name, registry_entry, and workflow" + ) + return fetch_workflow( + registry_name=resolved.registry_name, + registry_entry=resolved.registry_entry, + workflow_name=resolved.workflow, + ref=resolved.ref, + ) + + if resolved.kind == "adhoc": + if resolved.adhoc_owner is None or resolved.adhoc_repo is None or resolved.workflow is None: + raise ValueError( + "ResolvedRef(kind='adhoc') must have non-None adhoc_owner, adhoc_repo, and workflow" + ) + return fetch_workflow_adhoc( + owner=resolved.adhoc_owner, + repo=resolved.adhoc_repo, + workflow_name=resolved.workflow, + ref=resolved.ref, + ) + + raise ValueError(f"Unknown ResolvedRef kind: {resolved.kind!r}") + + # --------------------------------------------------------------------------- # GitHub fetch # --------------------------------------------------------------------------- diff --git a/src/conductor/registry/resolver.py b/src/conductor/registry/resolver.py index 4dcf407..348a0ed 100644 --- a/src/conductor/registry/resolver.py +++ b/src/conductor/registry/resolver.py @@ -1,14 +1,18 @@ """Workflow reference resolution. Parses user-supplied workflow references (e.g. ``qa-bot@team#v1.2.3``) and -determines whether an argument is a local file path or a registry reference. +determines whether an argument is a local file path, a configured registry +reference, or an ad-hoc GitHub registry reference. Resolution rules (in order): 1. If the argument exists as a file on disk, treat it as a local path. 2. If it looks like a file path (has path separators or YAML extension), treat it as a local path — even if the file doesn't exist yet. 3. Otherwise parse as a registry reference using ``[@][#]`` - syntax, where ```` is a git tag, branch, or commit SHA. + syntax. The ```` segment can be either: + - A configured registry name (looked up in ``~/.conductor/registries.toml``), or + - An ``owner/repo`` literal (contains ``/``) that is fetched ad-hoc from + GitHub without requiring registry configuration. """ from __future__ import annotations @@ -27,17 +31,21 @@ class ResolvedRef: """A resolved workflow reference.""" - kind: Literal["file", "registry"] + kind: Literal["file", "registry", "adhoc"] # For file refs path: Path | None = None - # For registry refs + # For registry refs (and adhoc refs) workflow: str | None = None registry_name: str | None = None ref: str | None = None # Git tag / branch / SHA. None means "latest" registry_entry: RegistryEntry | None = None + # For adhoc refs only — owner and repo parsed from the right side of '@' + adhoc_owner: str | None = None + adhoc_repo: str | None = None + def resolve_ref(ref: str) -> ResolvedRef: """Resolve a workflow reference string to a :class:`ResolvedRef`. @@ -50,6 +58,15 @@ def resolve_ref(ref: str) -> ResolvedRef: branch, or commit SHA). An empty registry segment (``name@#ref``) selects the configured default registry. + The ```` segment supports two forms: + + - A configured registry name (e.g. ``qa-bot@team#v1.0.0``) — looked up in + ``~/.conductor/registries.toml``. + - An ``owner/repo`` literal (e.g. ``qa-bot@acme/workflows#v1.0.0``) — + detected by the presence of ``/``. Treated as an **ad-hoc** GitHub + reference, fetched and cached without requiring registry + pre-configuration. Returns ``kind="adhoc"``. + Args: ref: The raw reference string from the CLI. @@ -74,7 +91,18 @@ def _looks_like_file_path(ref: str) -> bool: * The ref exists as a file on disk. * The ref contains a path separator (``/`` or ``\\``). * The ref ends with ``.yaml`` or ``.yml``. + + Refs containing ``@`` are always treated as registry references (named + or ad-hoc), regardless of whether the rest looks like a path. This + allows the ad-hoc form ``workflow@owner/repo#ref`` (which contains ``/`` + in the registry slot) to be parsed correctly rather than being + misclassified as a file path. """ + # Registry refs (named or ad-hoc) always contain '@'. Yield to the + # registry parser so 'workflow@owner/repo#ref' is not treated as a file. + if "@" in ref: + return False + if "/" in ref or "\\" in ref: return True @@ -86,11 +114,16 @@ def _looks_like_file_path(ref: str) -> bool: def _parse_registry_ref(raw: str) -> ResolvedRef: - """Parse *raw* as ``[@][#]`` and resolve against config. + """Parse *raw* as ``[@][#]`` and resolve. + + The ```` segment can be: + - A configured registry name → looked up in ``~/.conductor/registries.toml`` + - An ``owner/repo`` literal (contains ``/``) → ad-hoc GitHub reference, + no configuration required Raises: RegistryError: On malformed syntax, missing default registry, or - unknown registry name. + unknown registry name (named-registry form only). """ # Split on '#' first — the right side (if any) is the git ref. hash_parts = raw.split("#") @@ -130,6 +163,60 @@ def _parse_registry_ref(raw: str) -> ResolvedRef: raw_registry: str | None = at_parts[1] if len(at_parts) == 2 else None + # Ad-hoc owner/repo form — detected by '/' in the registry segment. + if raw_registry is not None and "/" in raw_registry: + return _parse_adhoc_ref(workflow, raw_registry, git_ref) + + # Named-registry form (existing behavior). + return _parse_named_registry_ref(workflow, raw_registry, git_ref) + + +def _parse_adhoc_ref( + workflow: str, + raw_registry: str, + git_ref: str | None, +) -> ResolvedRef: + """Parse an ad-hoc ``@/[#]`` reference. + + No registry configuration lookup — the ``owner/repo`` literal is used + directly to fetch from GitHub. + + Raises: + RegistryError: If ``owner/repo`` is malformed (e.g. empty owner or + repo, or contains additional path segments). + """ + parts = raw_registry.split("/") + if len(parts) != 2 or not parts[0] or not parts[1]: + raise RegistryError( + f"Invalid ad-hoc registry source '{raw_registry}'. " + "Expected '/' with exactly one '/'.", + suggestion=("Use 'workflow@owner/repo[#ref]' (e.g. analysis@acme/workflows#v1.0.0)."), + ) + + owner, repo = parts + return ResolvedRef( + kind="adhoc", + workflow=workflow, + registry_name=raw_registry, + ref=git_ref, + adhoc_owner=owner, + adhoc_repo=repo, + ) + + +def _parse_named_registry_ref( + workflow: str, + raw_registry: str | None, + git_ref: str | None, +) -> ResolvedRef: + """Parse a named ``[@][#]`` reference. + + Looks up the registry by name in the user's configuration. + + Raises: + RegistryError: If the registry is missing (and no default is + configured), or the named registry doesn't exist in config. + """ config = load_config() # Determine the registry name: empty string or None → use default. @@ -139,7 +226,9 @@ def _parse_registry_ref(raw: str) -> ResolvedRef: "No default registry configured", suggestion=( "Run 'conductor registry add --default' to " - "configure a default registry." + "configure a default registry, or use the ad-hoc form " + "'workflow@owner/repo[#ref]' to reference a GitHub repo " + "directly." ), ) registry_name = config.default @@ -152,7 +241,9 @@ def _parse_registry_ref(raw: str) -> ResolvedRef: f"Registry '{registry_name}' not found", suggestion=( f"Available registries: {available}. " - "Run 'conductor registry list' to see all registries." + "Run 'conductor registry list' to see all registries, " + "or use the ad-hoc form 'workflow@owner/repo[#ref]' " + "to reference a GitHub repo directly." ), ) diff --git a/tests/test_config/test_validator.py b/tests/test_config/test_validator.py index 805fc66..e9cc38b 100644 --- a/tests/test_config/test_validator.py +++ b/tests/test_config/test_validator.py @@ -1356,7 +1356,7 @@ def test_registry_fetch_failure_errors(self, tmp_path: Path) -> None: "conductor.registry.cache.fetch_workflow", side_effect=RegistryError("workflow not found"), ), - pytest.raises(ConfigurationError, match="failed to fetch registry sub-workflow"), + pytest.raises(ConfigurationError, match="failed to fetch sub-workflow"), ): validate_workflow_config(config, workflow_path=parent) @@ -1422,7 +1422,7 @@ def test_for_each_workflow_agent_ref_validated(self, tmp_path: Path) -> None: "conductor.registry.cache.fetch_workflow", side_effect=RegistryError("workflow not found"), ), - pytest.raises(ConfigurationError, match="failed to fetch registry sub-workflow"), + pytest.raises(ConfigurationError, match="failed to fetch sub-workflow"), ): validate_workflow_config(config, workflow_path=parent) @@ -1690,3 +1690,85 @@ def test_validation_depth_limit_emits_warning(self, tmp_path: Path) -> None: assert any("depth limit" in w for w in warnings), ( f"Expected a depth-limit warning, got warnings: {warnings}" ) + + def test_adhoc_ref_validates_fetched_workflow(self, tmp_path: Path) -> None: + """Ad-hoc registry reference (owner/repo) validates fetched workflow. + + Uses `_make_config("analysis@myorg/workflows#v1.0.0")` where the + registry slot contains a literal owner/repo path. Mocks only + ``fetch_workflow_adhoc`` so the validator's real ``resolve_ref`` + runs end-to-end, exercising the parsing of the adhoc format. + """ + import textwrap + from unittest.mock import patch + + from conductor.config.validator import validate_workflow_config + + cached_sub = tmp_path / "fetched.yaml" + cached_sub.write_text( + textwrap.dedent("""\ + workflow: + name: analysis + entry_point: step + runtime: + provider: copilot + limits: + max_iterations: 10 + agents: + - name: step + type: agent + prompt: go + routes: + - to: "$end" + output: {} + """), + encoding="utf-8", + ) + + parent = tmp_path / "parent.yaml" + parent.write_text("dummy", encoding="utf-8") + + # Capture fetch_workflow_adhoc args to verify the right owner/repo/workflow/ref + captured_args: dict[str, object] = {} + + def capture_adhoc_fetch(owner, repo, workflow_name, ref): + captured_args["owner"] = owner + captured_args["repo"] = repo + captured_args["workflow_name"] = workflow_name + captured_args["ref"] = ref + return cached_sub + + config = self._make_config("analysis@myorg/workflows#v1.0.0") + with patch( + "conductor.registry.cache.fetch_workflow_adhoc", + side_effect=capture_adhoc_fetch, + ): + warnings = validate_workflow_config(config, workflow_path=parent) + + assert warnings == [] + assert captured_args == { + "owner": "myorg", + "repo": "workflows", + "workflow_name": "analysis", + "ref": "v1.0.0", + } + + def test_adhoc_fetch_failure_validation_error(self, tmp_path: Path) -> None: + """Ad-hoc registry fetch failure during validation produces ConfigurationError.""" + from unittest.mock import patch + + from conductor.config.validator import validate_workflow_config + from conductor.registry.errors import RegistryError + + parent = tmp_path / "parent.yaml" + parent.write_text("dummy", encoding="utf-8") + + config = self._make_config("missing@acme/tools#latest") + with ( + patch( + "conductor.registry.cache.fetch_workflow_adhoc", + side_effect=RegistryError("workflow not found"), + ), + pytest.raises(ConfigurationError, match="failed to fetch sub-workflow"), + ): + validate_workflow_config(config, workflow_path=parent) diff --git a/tests/test_engine/test_subworkflow.py b/tests/test_engine/test_subworkflow.py index b192de4..ef8266f 100644 --- a/tests/test_engine/test_subworkflow.py +++ b/tests/test_engine/test_subworkflow.py @@ -1616,7 +1616,7 @@ async def test_registry_fetch_failure_raises_execution_error( "conductor.registry.cache.fetch_workflow", side_effect=RegistryError("not found"), ), - pytest.raises(ExecutionError, match="Failed to fetch registry sub-workflow"), + pytest.raises(ExecutionError, match="Failed to fetch sub-workflow"), ): await engine.run({}) @@ -1861,3 +1861,126 @@ def failing_handler(agent, prompt, context): "resume must resolve the registry ref to the same cached path as the original run" ) assert result["result"] == "analysis-complete" + + @pytest.mark.asyncio + async def test_adhoc_ref_resolved_and_executed( + self, tmp_workflow_dir: Path, tmp_path: Path + ) -> None: + """Ad-hoc registry reference (owner/repo in registry slot) fetches and executes. + + Sub-workflow agent with `workflow: "analysis@myorg/workflows#v1.0.0"` + where the registry slot contains a literal owner/repo path. + Mocks only ``fetch_workflow_adhoc`` so the engine's real + ``_resolve_subworkflow_path`` and ``resolve_ref`` run end-to-end, + exercising the parsing of the adhoc format. + """ + from unittest.mock import patch + + # Write a real cached sub-workflow to a temp location + cached_sub = tmp_path / "sub.yaml" + _write_yaml( + cached_sub, + """\ + workflow: + name: sub-from-adhoc + entry_point: inner + runtime: + provider: copilot + limits: + max_iterations: 10 + agents: + - name: inner + type: agent + prompt: do it + routes: + - to: "$end" + output: + result: "{{ inner.output.result }}" + """, + ) + + parent_path = tmp_workflow_dir / "parent.yaml" + parent_path.write_text("dummy", encoding="utf-8") + + config = WorkflowConfig( + workflow=WorkflowDef( + name="parent", + entry_point="sub_wf", + runtime=RuntimeConfig(provider="copilot"), + context=ContextConfig(mode="accumulate"), + limits=LimitsConfig(max_iterations=10), + ), + agents=[ + AgentDef( + name="sub_wf", + type="workflow", + workflow="analysis@myorg/workflows#v1.0.0", + routes=[RouteDef(to="$end")], + ), + ], + output={"result": "{{ sub_wf.output.result }}"}, + ) + + def mock_handler(agent, prompt, context): + return {"result": "adhoc-result"} + + from conductor.providers.copilot import CopilotProvider + + provider = CopilotProvider(mock_handler=mock_handler) + + # Patch fetch_workflow_adhoc so it returns the cached sub-workflow path. + # Real resolve_ref parses the ref string and creates an adhoc kind. + with patch( + "conductor.registry.cache.fetch_workflow_adhoc", return_value=cached_sub + ) as mock_adhoc_fetch: + engine = WorkflowEngine(config, provider, workflow_path=parent_path) + result = await engine.run({}) + + assert result.get("result") == "adhoc-result" + # Verify fetch_workflow_adhoc was called with the right args + mock_adhoc_fetch.assert_called_once() + call_kwargs = mock_adhoc_fetch.call_args[1] + assert call_kwargs["owner"] == "myorg" + assert call_kwargs["repo"] == "workflows" + assert call_kwargs["workflow_name"] == "analysis" + assert call_kwargs["ref"] == "v1.0.0" + + @pytest.mark.asyncio + async def test_adhoc_fetch_failure_raises_execution_error(self, tmp_workflow_dir: Path) -> None: + """Ad-hoc registry fetch failure is wrapped in ExecutionError.""" + from unittest.mock import patch + + from conductor.registry.errors import RegistryError + + parent_path = tmp_workflow_dir / "parent.yaml" + parent_path.write_text("dummy", encoding="utf-8") + + config = WorkflowConfig( + workflow=WorkflowDef( + name="parent", + entry_point="sub_wf", + runtime=RuntimeConfig(provider="copilot"), + context=ContextConfig(mode="accumulate"), + limits=LimitsConfig(max_iterations=10), + ), + agents=[ + AgentDef( + name="sub_wf", + type="workflow", + workflow="missing@acme/tools#latest", + routes=[RouteDef(to="$end")], + ), + ], + ) + + mock_provider = MagicMock() + engine = WorkflowEngine(config, mock_provider, workflow_path=parent_path) + + with ( + patch( + "conductor.registry.cache.fetch_workflow_adhoc", + side_effect=RegistryError("workflow not found in repository"), + ), + pytest.raises(ExecutionError, match="Failed to fetch sub-workflow"), + ): + await engine.run({}) diff --git a/tests/test_registry/test_cache.py b/tests/test_registry/test_cache.py index 68ba955..fe096fe 100644 --- a/tests/test_registry/test_cache.py +++ b/tests/test_registry/test_cache.py @@ -624,3 +624,224 @@ def test_prune_temp_dirs_missing_base_returns_zero( # No cache dir at all assert prune_temp_dirs() == 0 assert prune_temp_dirs("reg-a") == 0 + + +# --------------------------------------------------------------------------- +# Ad-hoc fetch + resolve_and_fetch unifier +# --------------------------------------------------------------------------- + + +class TestFetchWorkflowAdhoc: + """Tests for fetch_workflow_adhoc and the _adhoc cache namespace.""" + + @patch("conductor.registry.cache._fetch_github") + @patch("conductor.registry.cache.load_index") + @patch("conductor.registry.cache.materialize_to_sha") + @patch("conductor.registry.cache.resolve_ref") + def test_adhoc_fetches_under_adhoc_namespace( + self, + mock_resolve_ref: object, + mock_materialize: object, + mock_load_index: object, + mock_fetch_github: object, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Ad-hoc fetch caches under /_adhoc/////.""" + from conductor.registry.cache import fetch_workflow_adhoc + + home = _setup_conductor_home(tmp_path, monkeypatch) + mock_resolve_ref.return_value = "v1.0.0" # type: ignore[union-attr] + mock_materialize.return_value = _FAKE_SHA # type: ignore[union-attr] + mock_load_index.return_value = _make_index() # type: ignore[union-attr] + mock_fetch_github.side_effect = ( # type: ignore[union-attr] + lambda entry, path, sha, dest_dir: _write_workflow_file(dest_dir) + ) + + result = fetch_workflow_adhoc( + owner="myorg", + repo="workflows", + workflow_name="qa-bot", + ref="v1.0.0", + ) + + assert result.exists() + assert result.name == "qa-bot.yaml" + # Cache directory is namespaced under _adhoc/// + expected_dir = ( + home / "cache" / "registries" / "_adhoc" / "myorg" / "workflows" / "qa-bot" / _SHA_DIR + ) + assert result.parent == expected_dir + + @patch("conductor.registry.cache._fetch_github") + @patch("conductor.registry.cache.load_index") + @patch("conductor.registry.cache.materialize_to_sha") + @patch("conductor.registry.cache.resolve_ref") + def test_adhoc_isolated_from_named_registry_cache( + self, + mock_resolve_ref: object, + mock_materialize: object, + mock_load_index: object, + mock_fetch_github: object, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Same SHA fetched ad-hoc and as named-registry produces distinct caches.""" + from conductor.registry.cache import fetch_workflow_adhoc + + home = _setup_conductor_home(tmp_path, monkeypatch) + mock_resolve_ref.return_value = "v1.0.0" # type: ignore[union-attr] + mock_materialize.return_value = _FAKE_SHA # type: ignore[union-attr] + mock_load_index.return_value = _make_index() # type: ignore[union-attr] + mock_fetch_github.side_effect = ( # type: ignore[union-attr] + lambda entry, path, sha, dest_dir: _write_workflow_file(dest_dir) + ) + + # Fetch via named registry first + named_entry = RegistryEntry(type=RegistryType.github, source="myorg/workflows") + named_result = fetch_workflow("official", named_entry, "qa-bot", ref="v1.0.0") + + # Fetch the same workflow via ad-hoc + adhoc_result = fetch_workflow_adhoc( + owner="myorg", + repo="workflows", + workflow_name="qa-bot", + ref="v1.0.0", + ) + + # Both succeed but live in different cache directories + assert named_result.parent != adhoc_result.parent + # Sanity: named_result lives under official/, adhoc under _adhoc/myorg/workflows/ + assert (home / "cache" / "registries" / "official").exists() + assert (home / "cache" / "registries" / "_adhoc" / "myorg" / "workflows").exists() + # Adhoc cache path includes the _adhoc/ namespace segment; named does not. + assert "_adhoc" in adhoc_result.relative_to(home).parts + assert "_adhoc" not in named_result.relative_to(home).parts + + @patch("conductor.registry.cache._fetch_github") + @patch("conductor.registry.cache.load_index") + @patch("conductor.registry.cache.materialize_to_sha") + @patch("conductor.registry.cache.resolve_ref") + def test_adhoc_cache_hit_skips_fetch( + self, + mock_resolve_ref: object, + mock_materialize: object, + mock_load_index: object, + mock_fetch_github: object, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Pre-populated ad-hoc cache returns immediately without fetching.""" + from conductor.registry.cache import fetch_workflow_adhoc + + home = _setup_conductor_home(tmp_path, monkeypatch) + mock_resolve_ref.return_value = "v1.0.0" # type: ignore[union-attr] + mock_materialize.return_value = _FAKE_SHA # type: ignore[union-attr] + + # Pre-populate the ad-hoc cache + sha_dir = ( + home / "cache" / "registries" / "_adhoc" / "myorg" / "workflows" / "qa-bot" / _SHA_DIR + ) + sha_dir.mkdir(parents=True) + (sha_dir / "qa-bot.yaml").write_text("name: qa-bot\n", encoding="utf-8") + + result = fetch_workflow_adhoc( + owner="myorg", + repo="workflows", + workflow_name="qa-bot", + ref="v1.0.0", + ) + + assert result.parent == sha_dir + mock_fetch_github.assert_not_called() # type: ignore[union-attr] + mock_load_index.assert_not_called() # type: ignore[union-attr] + + +class TestResolveAndFetch: + """Tests for the resolve_and_fetch unifier dispatcher.""" + + def test_file_kind_returns_path_unchanged(self, tmp_path: Path) -> None: + from conductor.registry.cache import resolve_and_fetch + from conductor.registry.resolver import ResolvedRef + + local = tmp_path / "wf.yaml" + local.write_text("name: wf\n") + ref = ResolvedRef(kind="file", path=local) + assert resolve_and_fetch(ref) == local + + def test_file_kind_missing_path_raises(self) -> None: + from conductor.registry.cache import resolve_and_fetch + from conductor.registry.resolver import ResolvedRef + + ref = ResolvedRef(kind="file", path=None) + with pytest.raises(ValueError, match="non-None path"): + resolve_and_fetch(ref) + + @patch("conductor.registry.cache.fetch_workflow") + def test_registry_kind_dispatches_to_fetch_workflow( + self, mock_fetch: object, tmp_path: Path + ) -> None: + from conductor.registry.cache import resolve_and_fetch + from conductor.registry.resolver import ResolvedRef + + entry = RegistryEntry(type=RegistryType.github, source="o/r") + ref = ResolvedRef( + kind="registry", + workflow="qa-bot", + registry_name="team", + ref="v1.0.0", + registry_entry=entry, + ) + expected_path = tmp_path / "result.yaml" + mock_fetch.return_value = expected_path # type: ignore[union-attr] + + result = resolve_and_fetch(ref) + + assert result == expected_path + mock_fetch.assert_called_once_with( # type: ignore[union-attr] + registry_name="team", + registry_entry=entry, + workflow_name="qa-bot", + ref="v1.0.0", + ) + + @patch("conductor.registry.cache.fetch_workflow_adhoc") + def test_adhoc_kind_dispatches_to_fetch_workflow_adhoc( + self, mock_fetch: object, tmp_path: Path + ) -> None: + from conductor.registry.cache import resolve_and_fetch + from conductor.registry.resolver import ResolvedRef + + ref = ResolvedRef( + kind="adhoc", + workflow="qa-bot", + registry_name="myorg/workflows", + ref="v1.0.0", + adhoc_owner="myorg", + adhoc_repo="workflows", + ) + expected_path = tmp_path / "result.yaml" + mock_fetch.return_value = expected_path # type: ignore[union-attr] + + result = resolve_and_fetch(ref) + + assert result == expected_path + mock_fetch.assert_called_once_with( # type: ignore[union-attr] + owner="myorg", + repo="workflows", + workflow_name="qa-bot", + ref="v1.0.0", + ) + + def test_adhoc_kind_missing_fields_raises(self) -> None: + from conductor.registry.cache import resolve_and_fetch + from conductor.registry.resolver import ResolvedRef + + ref = ResolvedRef( + kind="adhoc", + workflow="qa-bot", + adhoc_owner=None, # missing! + adhoc_repo="workflows", + ) + with pytest.raises(ValueError, match="adhoc_owner"): + resolve_and_fetch(ref) diff --git a/tests/test_registry/test_integration.py b/tests/test_registry/test_integration.py index 6c3108f..4a98dd7 100644 --- a/tests/test_registry/test_integration.py +++ b/tests/test_registry/test_integration.py @@ -431,3 +431,111 @@ def test_file_path_takes_precedence_over_registry( ref = resolve_ref(str(local_file)) assert ref.kind == "file" assert ref.path == local_file + + +# --------------------------------------------------------------------------- +# Ad-hoc references (workflow@owner/repo[#ref]) +# --------------------------------------------------------------------------- + + +class TestAdhocRefIntegration: + """End-to-end tests for ad-hoc registry references — no pre-installation.""" + + def test_resolve_then_fetch_via_resolve_and_fetch( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Real resolve_ref + mocked fetch produces a cached file under _adhoc/.""" + from unittest.mock import patch + + from conductor.registry.cache import resolve_and_fetch + + home = _setup_home(tmp_path, monkeypatch) + + # Pre-populate a "fetched" workflow in the expected adhoc cache dir. + # In the real flow, _fetch_github would write here; we short-circuit + # by mocking materialize_to_sha + load_index + _fetch_github. + fake_sha = "c" * 40 + sha_dir = ( + home + / "cache" + / "registries" + / "_adhoc" + / "myorg" + / "team-a" + / "analysis" + / fake_sha[:12] + ) + + def fake_fetch_github(entry, workflow_path, sha, dest_dir): + (dest_dir / "analysis.yaml").write_text(_SIMPLE_WORKFLOW) + + from conductor.registry.index import RegistryIndex, WorkflowInfo + + fake_index = RegistryIndex( + workflows={ + "analysis": WorkflowInfo(description="", path="analysis.yaml"), + } + ) + + with ( + patch("conductor.registry.cache.materialize_to_sha", return_value=fake_sha), + patch("conductor.registry.cache.resolve_ref", return_value="v1.0.0"), + patch("conductor.registry.cache.load_index", return_value=fake_index), + patch("conductor.registry.cache._fetch_github", side_effect=fake_fetch_github), + ): + # No registry configured — but ad-hoc form works anyway + resolved = resolve_ref("analysis@myorg/team-a#v1.0.0") + assert resolved.kind == "adhoc" + assert resolved.adhoc_owner == "myorg" + assert resolved.adhoc_repo == "team-a" + + cached = resolve_and_fetch(resolved) + + assert cached.exists() + assert cached.parent == sha_dir + assert "test-workflow" in cached.read_text() + + def test_adhoc_works_with_no_registries_configured( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Ad-hoc resolution does not consult registry config.""" + _setup_home(tmp_path, monkeypatch) + # No add_registry() call — config is empty. + + # Without ad-hoc, this would raise "No default registry configured" + # because there's no '/' in the registry slot. With ad-hoc + '/' in + # the slot, no config lookup happens. + resolved = resolve_ref("analysis@myorg/team-a#v1.0.0") + assert resolved.kind == "adhoc" + assert resolved.adhoc_owner == "myorg" + assert resolved.adhoc_repo == "team-a" + assert resolved.workflow == "analysis" + + def test_adhoc_coexists_with_named_registry( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A named registry and ad-hoc refs to a different repo both work.""" + _setup_home(tmp_path, monkeypatch) + + reg_dir = _create_local_registry( + tmp_path, + { + "named-wf": { + "description": "", + "path": "named.yaml", + "content": _SIMPLE_WORKFLOW, + }, + }, + ) + add_registry("team-a", str(reg_dir), registry_type=RegistryType.path, set_default=False) + + # Named ref → registry kind, looks up "team-a" in config + named = resolve_ref("named-wf@team-a") + assert named.kind == "registry" + assert named.registry_name == "team-a" + + # Ad-hoc ref → adhoc kind, no config lookup (note '/' in registry slot) + adhoc = resolve_ref("analysis@otherorg/team-a#v1.0.0") + assert adhoc.kind == "adhoc" + assert adhoc.adhoc_owner == "otherorg" + assert adhoc.adhoc_repo == "team-a" diff --git a/tests/test_registry/test_resolver.py b/tests/test_registry/test_resolver.py index f176490..b56d5cd 100644 --- a/tests/test_registry/test_resolver.py +++ b/tests/test_registry/test_resolver.py @@ -323,3 +323,92 @@ def test_unknown_registry_with_ref_raises(self, monkeypatch: pytest.MonkeyPatch) with pytest.raises(RegistryError, match="Registry 'nope' not found"): resolve_ref("qa-bot@nope#v1.0.0") + + +# --------------------------------------------------------------------------- +# Ad-hoc owner/repo refs +# --------------------------------------------------------------------------- + + +class TestAdhocRef: + """Tests for the ad-hoc ``workflow@owner/repo[#ref]`` form.""" + + def test_adhoc_with_pinned_tag(self) -> None: + """``analysis@acme/workflows#v1.0.0`` parses as adhoc — no config needed.""" + ref = resolve_ref("analysis@acme/workflows#v1.0.0") + assert ref.kind == "adhoc" + assert ref.workflow == "analysis" + assert ref.adhoc_owner == "acme" + assert ref.adhoc_repo == "workflows" + assert ref.ref == "v1.0.0" + # registry_name preserved as the raw owner/repo string for diagnostics + assert ref.registry_name == "acme/workflows" + # registry_entry intentionally None — no config lookup happened + assert ref.registry_entry is None + + def test_adhoc_with_pinned_branch(self) -> None: + ref = resolve_ref("analysis@acme/workflows#main") + assert ref.kind == "adhoc" + assert ref.adhoc_owner == "acme" + assert ref.adhoc_repo == "workflows" + assert ref.ref == "main" + + def test_adhoc_without_ref_defaults_to_none(self) -> None: + """Omitted ``#ref`` resolves to default branch HEAD at fetch time.""" + ref = resolve_ref("analysis@acme/workflows") + assert ref.kind == "adhoc" + assert ref.adhoc_owner == "acme" + assert ref.adhoc_repo == "workflows" + assert ref.ref is None + + def test_adhoc_with_dashes_and_dots(self) -> None: + """Owner/repo names with dashes, dots, underscores are accepted.""" + ref = resolve_ref("qa.bot_v2@my-org/team.workflows_v2#v1.0.0") + assert ref.kind == "adhoc" + assert ref.adhoc_owner == "my-org" + assert ref.adhoc_repo == "team.workflows_v2" + assert ref.workflow == "qa.bot_v2" + + def test_adhoc_does_not_load_config(self, monkeypatch: pytest.MonkeyPatch) -> None: + """No call to load_config() when the registry slot looks like owner/repo.""" + called = {"n": 0} + + def fake_load_config() -> RegistriesConfig: + called["n"] += 1 + return _make_config() + + monkeypatch.setattr("conductor.registry.resolver.load_config", fake_load_config) + resolve_ref("analysis@acme/workflows#v1.0.0") + assert called["n"] == 0, "ad-hoc form must not consult registry config" + + def test_adhoc_too_many_slashes_rejected(self) -> None: + """``owner/repo/extra`` is rejected — exactly one slash required.""" + with pytest.raises(RegistryError, match="Invalid ad-hoc registry source"): + resolve_ref("analysis@acme/workflows/extra#v1.0.0") + + def test_adhoc_empty_owner_rejected(self) -> None: + with pytest.raises(RegistryError, match="Invalid ad-hoc registry source"): + resolve_ref("analysis@/workflows#v1.0.0") + + def test_adhoc_empty_repo_rejected(self) -> None: + with pytest.raises(RegistryError, match="Invalid ad-hoc registry source"): + resolve_ref("analysis@acme/#v1.0.0") + + def test_named_registry_not_treated_as_adhoc(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Refs without '/' in the registry slot still use named-registry lookup.""" + config = _make_config() + _patch_config(monkeypatch, config) + ref = resolve_ref("qa-bot@team#v1.0.0") + assert ref.kind == "registry" + assert ref.registry_name == "team" + assert ref.adhoc_owner is None + assert ref.adhoc_repo is None + + def test_adhoc_help_in_unknown_registry_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Unknown named registry error mentions the ad-hoc fallback.""" + config = _make_config(default="team") + _patch_config(monkeypatch, config) + with pytest.raises(RegistryError, match="ad-hoc form") as exc_info: + resolve_ref("qa-bot@nope#v1.0.0") + # The hint should suggest the workflow@owner/repo form. + assert "workflow@owner/repo" in str(exc_info.value)