3375df3f52
Broken bottle/agent files no longer block the agent selector or prevent unrelated agents from loading. Per-file parse errors are collected in `Manifest.broken_agents`; the CLI selector includes them via `all_agent_names`, and the error surfaces only when the specific agent is selected and launch is attempted (in `require_agent`/`bottle_for`). Closes #236
296 lines
11 KiB
Python
296 lines
11 KiB
Python
"""Internal bottle `extends:` resolution for manifests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from .manifest import ManifestBottle
|
|
from .manifest_egress import ManifestEgressConfig
|
|
|
|
|
|
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]:
|
|
"""Apply `extends:` chains and return resolved ManifestBottle objects."""
|
|
cache: dict[str, ManifestBottle] = {}
|
|
# Per-bottle effective git-gate.repos, as raw dicts keyed by repo name.
|
|
# Threaded alongside `cache` so a child can field-merge against its
|
|
# parent's repos without reconstructing them from parsed entries.
|
|
repos_cache: dict[str, dict[str, object]] = {}
|
|
for name in raws:
|
|
if name not in cache:
|
|
_resolve_one_bottle(name, raws, cache, repos_cache, ())
|
|
return cache
|
|
|
|
|
|
def resolve_bottles_partial(
|
|
raws: dict[str, dict[str, object]],
|
|
) -> tuple[dict[str, ManifestBottle], dict[str, ManifestError]]:
|
|
"""Apply `extends:` chains and return `(good, broken)`.
|
|
|
|
Bottles that fail validation (schema errors, bad extends, cycles) are
|
|
collected in `broken` rather than raising, so unrelated bottles remain
|
|
usable. Errors for parent bottles propagate to all children that extend
|
|
them."""
|
|
from .manifest import ManifestError
|
|
|
|
cache: dict[str, ManifestBottle] = {}
|
|
broken: dict[str, ManifestError] = {}
|
|
for name in raws:
|
|
if name not in cache and name not in broken:
|
|
_resolve_one_bottle_partial(name, raws, cache, broken, ())
|
|
return cache, broken
|
|
|
|
|
|
def _resolve_one_bottle(
|
|
name: str,
|
|
raws: dict[str, dict[str, object]],
|
|
cache: dict[str, ManifestBottle],
|
|
repos_cache: dict[str, dict[str, object]],
|
|
seen: tuple[str, ...],
|
|
) -> ManifestBottle:
|
|
from .manifest import ManifestBottle, ManifestError
|
|
|
|
if name in cache:
|
|
return cache[name]
|
|
if name in seen:
|
|
chain = " -> ".join(seen + (name,))
|
|
raise ManifestError(f"bottle '{name}' is in an extends cycle: {chain}")
|
|
raw = raws[name]
|
|
parent_name_raw = raw.get("extends")
|
|
# Strip `extends:` before passing to ManifestBottle.from_dict so it
|
|
# is not accidentally treated as a real ManifestBottle field by future
|
|
# schema additions. It is only meaningful here.
|
|
child_raw = {k: v for k, v in raw.items() if k != "extends"}
|
|
|
|
if parent_name_raw is None:
|
|
bottle = ManifestBottle.from_dict(name, child_raw)
|
|
cache[name] = bottle
|
|
repos_cache[name] = _resolve_repos_raw({}, child_raw)
|
|
return bottle
|
|
|
|
if not isinstance(parent_name_raw, str):
|
|
raise ManifestError(
|
|
f"bottle '{name}' extends must be a string "
|
|
f"(was {type(parent_name_raw).__name__})"
|
|
)
|
|
parent_name: str = parent_name_raw
|
|
if parent_name == name:
|
|
raise ManifestError(
|
|
f"bottle '{name}' extends itself; remove the "
|
|
f"self-reference"
|
|
)
|
|
if parent_name not in raws:
|
|
avail = ", ".join(sorted(raws.keys())) or "(none)"
|
|
raise ManifestError(
|
|
f"bottle '{name}' extends '{parent_name}' which is not "
|
|
f"defined. Available bottles: {avail}"
|
|
)
|
|
parent = _resolve_one_bottle(
|
|
parent_name, raws, cache, repos_cache, seen + (name,)
|
|
)
|
|
merged_repos_raw = _resolve_repos_raw(repos_cache[parent_name], child_raw)
|
|
bottle = _merge_bottles(parent, child_raw, merged_repos_raw, name)
|
|
cache[name] = bottle
|
|
repos_cache[name] = merged_repos_raw
|
|
return bottle
|
|
|
|
|
|
def _merge_bottles(
|
|
parent: ManifestBottle,
|
|
child_raw: dict[str, object],
|
|
merged_repos_raw: dict[str, object],
|
|
name: str,
|
|
) -> ManifestBottle:
|
|
"""Apply PRD 0025 merge rules."""
|
|
from .manifest import ManifestBottle, ManifestGitUser
|
|
from .manifest_egress import validate_egress_routes
|
|
from .manifest_util import as_json_object
|
|
|
|
# git-gate.repos: when the child declares repos, inject the already
|
|
# name-merged repo set (computed by _resolve_repos_raw) so the child
|
|
# parses with the full inherited+overridden list (issue #237).
|
|
if _child_declares_git_gate_repos(child_raw):
|
|
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
|
|
child_raw = {**child_raw, "git-gate": {**git_raw, "repos": merged_repos_raw}}
|
|
|
|
# Parse the child's declared fields into a ManifestBottle (with the
|
|
# usual defaults for anything missing). Validation runs the same
|
|
# way it would for a leaf bottle: typos / wrong types die here.
|
|
child = ManifestBottle.from_dict(name, child_raw)
|
|
|
|
# env: dict merge, child wins on collision.
|
|
merged_env = {**parent.env, **child.env}
|
|
|
|
# git-gate.user: per-field overlay. Each non-empty field on child
|
|
# wins; empties fall through to parent. The default ManifestGitUser()
|
|
# is two empty strings, so a child that omits git-gate.user
|
|
# inherits the parent's user verbatim.
|
|
merged_git_user = ManifestGitUser(
|
|
name=child.git_user.name or parent.git_user.name,
|
|
email=child.git_user.email or parent.git_user.email,
|
|
)
|
|
|
|
# git-gate.repos: when declared, child.git already holds the merged
|
|
# set (an explicit empty dict clears parent, leaving child.git empty).
|
|
# When omitted, the parent's entries are inherited verbatim.
|
|
if _child_declares_git_gate_repos(child_raw):
|
|
merged_git = child.git
|
|
else:
|
|
merged_git = parent.git
|
|
|
|
# egress.routes: missing means inherit; otherwise parent and child
|
|
# route lists concatenate. Other egress scalar fields remain
|
|
# presence-driven overlays.
|
|
merged_egress = (
|
|
_merge_egress(parent.egress, child.egress, child_raw)
|
|
if "egress" in child_raw
|
|
else parent.egress
|
|
)
|
|
|
|
# Presence-driven full-replace for the remaining scalar fields.
|
|
merged_agent_provider = (
|
|
child.agent_provider
|
|
if "agent_provider" in child_raw
|
|
else parent.agent_provider
|
|
)
|
|
merged_supervise = (
|
|
child.supervise if "supervise" in child_raw else parent.supervise
|
|
)
|
|
validate_egress_routes(name, merged_egress.routes)
|
|
|
|
return ManifestBottle(
|
|
env=merged_env,
|
|
agent_provider=merged_agent_provider,
|
|
git=merged_git,
|
|
git_user=merged_git_user,
|
|
egress=merged_egress,
|
|
supervise=merged_supervise,
|
|
)
|
|
|
|
|
|
def _resolve_repos_raw(
|
|
parent_repos: dict[str, object],
|
|
child_raw: dict[str, object],
|
|
) -> dict[str, object]:
|
|
"""Compute a bottle's effective git-gate.repos as raw dicts.
|
|
|
|
Repos are keyed by name. When the child omits git-gate.repos it
|
|
inherits the parent's set verbatim; an explicit empty dict clears it.
|
|
Otherwise parent and child unite by name, with same-name entries
|
|
field-merged (parent fields are defaults, child fields win)."""
|
|
from .manifest_util import as_json_object
|
|
|
|
if not _child_declares_git_gate_repos(child_raw):
|
|
return parent_repos
|
|
child_repos = _declared_repos_raw(child_raw)
|
|
if not child_repos:
|
|
return {}
|
|
# Parent entries keep their order; child-only names are appended.
|
|
names = list(parent_repos) + [n for n in child_repos if n not in parent_repos]
|
|
return {
|
|
name: {
|
|
**as_json_object(parent_repos.get(name, {}), "parent git-gate repo"),
|
|
**as_json_object(child_repos.get(name, {}), "child git-gate repo"),
|
|
}
|
|
for name in names
|
|
}
|
|
|
|
|
|
def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]:
|
|
"""Return the child's explicitly declared git-gate.repos as raw dicts,
|
|
or an empty dict when none are declared."""
|
|
from .manifest_util import as_json_object
|
|
|
|
if not _child_declares_git_gate_repos(child_raw):
|
|
return {}
|
|
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
|
|
return as_json_object(git_raw.get("repos", {}), "child git-gate.repos")
|
|
|
|
|
|
def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
|
|
from .manifest_util import as_json_object
|
|
|
|
git_raw = child_raw.get("git-gate")
|
|
if git_raw is None:
|
|
return False
|
|
git_obj = as_json_object(git_raw, "child git-gate")
|
|
return "repos" in git_obj
|
|
|
|
|
|
def _merge_egress(
|
|
parent: ManifestEgressConfig,
|
|
child: ManifestEgressConfig,
|
|
child_raw: dict[str, object],
|
|
) -> ManifestEgressConfig:
|
|
from .manifest_egress import ManifestEgressConfig
|
|
from .manifest_util import as_json_object
|
|
|
|
child_egress_raw = as_json_object(child_raw.get("egress"), "child egress")
|
|
routes = parent.routes + child.routes
|
|
log = child.Log if "log" in child_egress_raw else parent.Log
|
|
return ManifestEgressConfig(routes=routes, Log=log)
|
|
|
|
|
|
def _resolve_one_bottle_partial(
|
|
name: str,
|
|
raws: dict[str, dict[str, object]],
|
|
cache: dict[str, ManifestBottle],
|
|
broken: dict[str, ManifestError],
|
|
seen: tuple[str, ...],
|
|
) -> None:
|
|
"""Error-tolerant variant: on failure, adds to `broken` instead of raising."""
|
|
from .manifest import ManifestBottle, ManifestError
|
|
|
|
if name in cache or name in broken:
|
|
return
|
|
if name in seen:
|
|
chain = " -> ".join(seen + (name,))
|
|
broken[name] = ManifestError(
|
|
f"bottle '{name}' is in an extends cycle: {chain}"
|
|
)
|
|
return
|
|
|
|
raw = raws[name]
|
|
parent_name_raw = raw.get("extends")
|
|
child_raw = {k: v for k, v in raw.items() if k != "extends"}
|
|
|
|
try:
|
|
if parent_name_raw is None:
|
|
cache[name] = ManifestBottle.from_dict(name, child_raw)
|
|
return
|
|
|
|
if not isinstance(parent_name_raw, str):
|
|
broken[name] = ManifestError(
|
|
f"bottle '{name}' extends must be a string "
|
|
f"(was {type(parent_name_raw).__name__})"
|
|
)
|
|
return
|
|
|
|
parent_name: str = parent_name_raw
|
|
if parent_name == name:
|
|
broken[name] = ManifestError(
|
|
f"bottle '{name}' extends itself; remove the self-reference"
|
|
)
|
|
return
|
|
|
|
if parent_name not in raws:
|
|
avail = ", ".join(sorted(raws.keys())) or "(none)"
|
|
broken[name] = ManifestError(
|
|
f"bottle '{name}' extends '{parent_name}' which is not "
|
|
f"defined. Available bottles: {avail}"
|
|
)
|
|
return
|
|
|
|
_resolve_one_bottle_partial(parent_name, raws, cache, broken, seen + (name,))
|
|
if parent_name in broken:
|
|
broken[name] = ManifestError(
|
|
f"bottle '{name}' extends '{parent_name}' which failed to load: "
|
|
f"{broken[parent_name]}"
|
|
)
|
|
return
|
|
|
|
parent = cache[parent_name]
|
|
cache[name] = _merge_bottles(parent, child_raw, name)
|
|
except ManifestError as exc:
|
|
broken[name] = exc
|