refactor(manifest): split Manifest into ManifestIndex + Manifest single-value type

Manifest now holds exactly one agent and one effective bottle (with
git_user overlay already applied). The old multi-agent/bottle
collection is renamed ManifestIndex. BottleSpec.manifest starts as
ManifestIndex from the CLI and becomes Manifest after _validate()
calls load_for_agent(); all provisioning code downstream reads
spec.manifest.agent / spec.manifest.bottle instead of indexing by name.
This commit is contained in:
2026-06-23 00:56:30 +00:00
committed by didericis
parent b350e57395
commit 32f85256d3
41 changed files with 330 additions and 308 deletions
+7 -7
View File
@@ -10,7 +10,7 @@ import tempfile
from pathlib import Path
from typing import Any, Callable
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
def fixture_minimal_dict() -> dict[str, Any]:
@@ -62,16 +62,16 @@ def fixture_with_git_dict() -> dict[str, Any]:
}
def fixture_minimal() -> Manifest:
return Manifest.from_json_obj(fixture_minimal_dict())
def fixture_minimal() -> ManifestIndex:
return ManifestIndex.from_json_obj(fixture_minimal_dict())
def fixture_with_egress() -> Manifest:
return Manifest.from_json_obj(fixture_with_egress_dict())
def fixture_with_egress() -> ManifestIndex:
return ManifestIndex.from_json_obj(fixture_with_egress_dict())
def fixture_with_git() -> Manifest:
return Manifest.from_json_obj(fixture_with_git_dict())
def fixture_with_git() -> ManifestIndex:
return ManifestIndex.from_json_obj(fixture_with_git_dict())
def write_fixture(fn: Callable[[], dict[str, Any]]) -> Path:
@@ -29,7 +29,7 @@ from bot_bottle.backend.macos_container.util import (
dns_server as _container_dns_server,
is_available as _container_available,
)
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
_AGENT_PROMPT = "You are a launch smoke-test agent. Be brief."
@@ -52,8 +52,8 @@ def _minimal_agent_dockerfile(path: Path) -> None:
)
def _minimal_manifest(dockerfile: Path) -> Manifest:
return Manifest.from_json_obj({
def _minimal_manifest(dockerfile: Path) -> ManifestIndex:
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"agent_provider": {
+2 -2
View File
@@ -31,7 +31,7 @@ from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.bottle_state import cleanup_state
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from tests._docker import skip_unless_docker
@@ -101,7 +101,7 @@ class TestSandboxEscape(unittest.TestCase):
cls._key_path.write_text("placeholder\n")
cls._key_path.chmod(0o600)
manifest = Manifest.from_json_obj({
manifest = ManifestIndex.from_json_obj({
"bottles": {
"dev": {
# Three fake secrets — different shapes — land
@@ -22,15 +22,15 @@ from pathlib import Path
from unittest.mock import patch
from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from tests._docker import skip_unless_docker
def _manifest() -> Manifest:
def _manifest() -> ManifestIndex:
"""Bottle with supervise on so the bundle exercises egress +
supervise. Git is off because a meaningful git-gate test needs
a real upstream and SSH keys — out of scope for a bundle smoke."""
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"supervise": True,
@@ -35,15 +35,15 @@ from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.backend.smolmachines.smolvm import is_available as _smolvm_available
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from tests._docker import skip_unless_docker
_AGENT_PROMPT = "You are demo. Be brief."
def _minimal_manifest() -> Manifest:
return Manifest.from_json_obj({
def _minimal_manifest() -> ManifestIndex:
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"egress": {
+3 -3
View File
@@ -18,11 +18,11 @@ from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.resolve_common import mint_slug
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
def _manifest() -> Manifest:
return Manifest.from_json_obj({
def _manifest() -> ManifestIndex:
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"env": {
+3 -3
View File
@@ -17,11 +17,11 @@ from bot_bottle import supervise
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
def _manifest() -> Manifest:
return Manifest.from_json_obj({
def _manifest() -> ManifestIndex:
return ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {
"demo": {
+2 -2
View File
@@ -31,7 +31,7 @@ class TestCmdStartSelector(unittest.TestCase):
# Stub Manifest.resolve so no on-disk manifest is needed.
self._manifest = _make_manifest(["researcher", "implementer"])
self._resolve_patch = patch(
"bot_bottle.cli.start.Manifest.resolve",
"bot_bottle.cli.start.ManifestIndex.resolve",
return_value=self._manifest,
)
self._resolve_patch.start()
@@ -150,7 +150,7 @@ class TestCmdStartLabelCollision(unittest.TestCase):
def setUp(self):
self._manifest = _make_manifest(["researcher"])
patch("bot_bottle.cli.start.Manifest.resolve", return_value=self._manifest).start()
patch("bot_bottle.cli.start.ManifestIndex.resolve", return_value=self._manifest).start()
self._launch_mock = patch(
"bot_bottle.cli.start._launch_bottle", return_value=0,
).start()
+3 -3
View File
@@ -31,7 +31,7 @@ from bot_bottle.egress import (
EgressRoute,
)
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from bot_bottle.supervise import SupervisePlan
@@ -40,7 +40,7 @@ STAGE = Path("/tmp/cb-stage")
STATE = Path("/tmp/cb-state")
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest:
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex:
"""Minimal manifest with the toggles the chunk-1 matrix needs.
The renderer only reads from the plan, not the manifest, so this
is just here to back BottleSpec."""
@@ -61,7 +61,7 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
"auth": {"scheme": "Bearer", "token_ref": "TOK"},
}],
}
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {"dev": bottle},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
+3 -3
View File
@@ -24,7 +24,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.contrib.claude.agent_provider import ClaudeAgentProvider
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import Manifest, ManifestIndex
from bot_bottle.supervise import SupervisePlan
@@ -55,7 +55,7 @@ def _plan(
bottle_json: dict = {"agent_provider": {"template": "claude"}} # type: ignore
if supervise:
bottle_json["supervise"] = True
manifest = Manifest.from_json_obj({
manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
"demo": {
@@ -64,7 +64,7 @@ def _plan(
"bottle": "dev",
},
},
})
}).load_for_agent("demo")
spec = BottleSpec(
manifest=manifest, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x",
+3 -3
View File
@@ -24,7 +24,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.contrib.codex.agent_provider import CodexAgentProvider
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import Manifest, ManifestIndex
from bot_bottle.supervise import SupervisePlan
@@ -55,7 +55,7 @@ def _plan(
bottle_json: dict = {"agent_provider": {"template": "codex"}} # type: ignore
if supervise:
bottle_json["supervise"] = True
manifest = Manifest.from_json_obj({
manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
"demo": {
@@ -64,7 +64,7 @@ def _plan(
"bottle": "dev",
},
},
})
}).load_for_agent("demo")
spec = BottleSpec(
manifest=manifest, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x",
+3 -3
View File
@@ -16,7 +16,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.contrib.pi.agent_provider import PiAgentProvider
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import Manifest, ManifestIndex
_URL = "http://supervise:9100/"
@@ -43,7 +43,7 @@ def _plan(
skills: list[str] | None = None,
agent_provision: AgentProvisionPlan | None = None,
) -> DockerBottlePlan:
manifest = Manifest.from_json_obj({
manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": {"agent_provider": {"template": "pi"}}},
"agents": {
"demo": {
@@ -52,7 +52,7 @@ def _plan(
"bottle": "dev",
},
},
})
}).load_for_agent("demo")
spec = BottleSpec(
manifest=manifest, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x",
+6 -3
View File
@@ -21,14 +21,17 @@ from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from bot_bottle.manifest import Manifest, ManifestIndex
def _manifest() -> Manifest:
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
}).load_for_agent("demo")
def _plan(tmp: str) -> DockerBottlePlan:
+3 -3
View File
@@ -21,7 +21,7 @@ from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import Manifest, ManifestIndex
class _Provider(AgentProvider):
@@ -51,10 +51,10 @@ def _plan(*, git_user: dict | None = None, # type: ignore
bottle_json: dict = {} # type: ignore
if git_user is not None:
bottle_json["git-gate"] = {"user": git_user}
manifest = Manifest.from_json_obj({
manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
}).load_for_agent("demo")
spec = BottleSpec(
manifest=manifest, agent_name="demo",
copy_cwd=copy_cwd, user_cwd=user_cwd,
+4 -4
View File
@@ -13,12 +13,12 @@ from bot_bottle.egress import (
egress_token_env_map,
)
from bot_bottle.log import Die
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from bot_bottle.yaml_subset import parse_yaml_subset
def _bottle(routes): # type: ignore
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
@@ -362,9 +362,9 @@ class TestRenderRoutes(unittest.TestCase):
self.assertEqual("x.example", cfg.routes[0].host)
def test_log_via_manifest_flows_to_render(self):
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from bot_bottle.egress_addon_core import load_config, LOG_BLOCKS
m = Manifest.from_json_obj({
m = ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {
"log": 1,
"routes": [{"host": "x.example"}],
+2 -2
View File
@@ -15,7 +15,7 @@ from bot_bottle.git_gate import (
git_gate_render_hook,
git_gate_upstreams_for_bottle,
)
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from tests.fixtures import fixture_minimal, fixture_with_git
@@ -280,7 +280,7 @@ class TestPrepare(unittest.TestCase):
self.assertEqual(0o600, os.stat(upstream.known_hosts_file).st_mode & 0o777)
def test_prepare_skips_known_hosts_file_when_key_missing(self):
manifest = Manifest.from_json_obj({
manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": {"git-gate": {"repos": {
"foo": {
"url": "ssh://git@github.com/didericis/foo.git",
+50 -29
View File
@@ -1,14 +1,14 @@
"""Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047).
An agent file may declare `git-gate.user` (name/email). At
`Manifest.bottle_for()` it overlays the referenced bottle's
`ManifestIndex.load_for_agent()` it overlays the referenced bottle's
`git-gate.user` per-field, agent-wins-on-non-empty. `git-gate.repos` is
rejected on agents. `Manifest.git_identity_summary()` reports the
effective identity with per-field `(agent)`/`(bottle)` provenance.
The `from_json_obj` path drives `Agent.from_dict` + `bottle_for`;
a temp-dir case locks the md loader (the `_AGENT_KEYS` allow + the
`git-gate` threading into `agent_dict`)."""
The `from_json_obj` path drives `Agent.from_dict` + the overlay in
load_for_agent; a temp-dir case locks the md loader (the `_AGENT_KEYS`
allow + the `git-gate` threading into `agent_dict`)."""
from __future__ import annotations
@@ -19,7 +19,7 @@ import textwrap
import unittest
from pathlib import Path
from bot_bottle.manifest import ManifestError, Manifest
from bot_bottle.manifest import ManifestError, Manifest, ManifestIndex
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
@@ -32,13 +32,28 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore
"""Build an index with one agent 'impl' and load it, returning a Manifest."""
bottle: dict = {} # type: ignore
if bottle_user is not None:
bottle = {"git-gate": {"user": bottle_user}}
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
if agent_git is not None:
agent["git-gate"] = agent_git
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {"dev": bottle},
"agents": {"impl": agent},
}).load_for_agent("impl")
def _index(*, bottle_user=None, agent_git=None) -> ManifestIndex:
"""Build an index with one agent 'impl' without loading it."""
bottle: dict = {} # type: ignore
if bottle_user is not None:
bottle = {"git-gate": {"user": bottle_user}}
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
if agent_git is not None:
agent["git-gate"] = agent_git
return ManifestIndex.from_json_obj({
"bottles": {"dev": bottle},
"agents": {"impl": agent},
})
@@ -47,7 +62,7 @@ def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore
class TestAgentGitUserOverlay(unittest.TestCase):
def test_agent_supplies_both_fields(self):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
u = m.bottle_for("impl").git_user
u = m.bottle.git_user
self.assertEqual("a", u.name)
self.assertEqual("a@b", u.email)
@@ -56,7 +71,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"name": "a"}},
)
u = m.bottle_for("impl").git_user
u = m.bottle.git_user
self.assertEqual("a", u.name) # agent wins
self.assertEqual("b@c", u.email) # bottle falls through
@@ -65,34 +80,40 @@ class TestAgentGitUserOverlay(unittest.TestCase):
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"email": "a@b"}},
)
u = m.bottle_for("impl").git_user
u = m.bottle.git_user
self.assertEqual("B", u.name)
self.assertEqual("a@b", u.email)
def test_agent_identity_with_bottle_declaring_none(self):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
self.assertTrue(m.bottles["dev"].git_user.is_empty())
self.assertFalse(m.bottle_for("impl").git_user.is_empty())
idx = _index(agent_git={"user": {"name": "a", "email": "a@b"}})
# Raw bottle has no git_user; loaded manifest has merged git_user from agent
self.assertTrue(idx.bottles["dev"].git_user.is_empty())
m = idx.load_for_agent("impl")
self.assertFalse(m.bottle.git_user.is_empty())
def test_bottle_only_identity_preserved_when_agent_silent(self):
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
u = m.bottle_for("impl").git_user
u = m.bottle.git_user
self.assertEqual("B", u.name)
self.assertEqual("b@c", u.email)
def test_bottle_for_returns_same_instance_when_no_overlay(self):
m = _manifest(bottle_user={"name": "B"})
self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
def test_no_overlay_uses_bottle_instance_directly(self):
idx = _index(bottle_user={"name": "B"})
m = idx.load_for_agent("impl")
# Agent has no git_user — bottle instance should be the same object
self.assertIs(idx.bottles["dev"], m.bottle)
def test_bottle_for_returns_same_instance_when_overlay_is_noop(self):
m = _manifest(
def test_noop_overlay_uses_bottle_instance_directly(self):
idx = _index(
bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"name": "B", "email": "b@c"}},
)
self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
m = idx.load_for_agent("impl")
# Agent git_user == bottle git_user — no replace needed
self.assertEqual(idx.bottles["dev"].git_user, m.bottle.git_user)
def test_other_bottle_fields_untouched_by_overlay(self):
m = Manifest.from_json_obj({
idx = ManifestIndex.from_json_obj({
"bottles": {"dev": {
"env": {"FOO": "bar"},
"supervise": True,
@@ -103,7 +124,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
"git-gate": {"user": {"name": "a"}},
}},
})
b = m.bottle_for("impl")
b = idx.load_for_agent("impl").bottle
self.assertEqual("a", b.git_user.name)
self.assertEqual({"FOO": "bar"}, dict(b.env))
self.assertTrue(b.supervise)
@@ -131,7 +152,7 @@ class TestGitIdentitySummary(unittest.TestCase):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
self.assertEqual(
"name=a (agent), email=a@b (agent)",
m.git_identity_summary("impl"),
m.git_identity_summary(),
)
def test_mixed_provenance(self):
@@ -141,19 +162,19 @@ class TestGitIdentitySummary(unittest.TestCase):
)
self.assertEqual(
"name=a (agent), email=b@c (bottle)",
m.git_identity_summary("impl"),
m.git_identity_summary(),
)
def test_bottle_only(self):
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
self.assertEqual(
"name=B (bottle), email=b@c (bottle)",
m.git_identity_summary("impl"),
m.git_identity_summary(),
)
def test_none_when_unset_anywhere(self):
m = _manifest()
self.assertIsNone(m.git_identity_summary("impl"))
self.assertIsNone(m.git_identity_summary())
_BOTTLE_DEV = """
@@ -217,13 +238,13 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
def test_md_agent_git_user_overlays_bottle(self):
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_GIT)
m = Manifest.resolve(str(self.home)).load_for_agent("impl")
u = m.bottle_for("impl").git_user
m = ManifestIndex.resolve(str(self.home)).load_for_agent("impl")
u = m.bottle.git_user
self.assertEqual("agent-name", u.name)
self.assertEqual("bottle@example.com", u.email)
self.assertEqual(
"name=agent-name (agent), email=bottle@example.com (bottle)",
m.git_identity_summary("impl"),
m.git_identity_summary(),
)
def test_md_agent_repos_fails_at_preflight(self):
@@ -232,7 +253,7 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_REPOS)
from bot_bottle.manifest import ManifestError
names = Manifest.resolve(str(self.home))
names = ManifestIndex.resolve(str(self.home))
self.assertIn("impl", names.all_agent_names)
with self.assertRaises(ManifestError) as ctx:
names.load_for_agent("impl")
+11 -11
View File
@@ -9,18 +9,18 @@ partial `auth` is an error, auth omission means unauthenticated."""
import unittest
from bot_bottle.manifest import ManifestError, Manifest
from bot_bottle.manifest import ManifestError, ManifestIndex
def _bottle(routes): # type: ignore
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
def _provider_bottle(provider, routes): # type: ignore
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"agent_provider": {"template": provider},
@@ -32,7 +32,7 @@ def _provider_bottle(provider, routes): # type: ignore
def _provider_config_bottle(agent_provider): # type: ignore
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {"dev": {"agent_provider": agent_provider}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
@@ -433,7 +433,7 @@ class TestRouteValidation(unittest.TestCase):
self.assertEqual((), b.egress.routes)
def test_no_egress_block_means_empty(self):
b = Manifest.from_json_obj({
b = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
@@ -443,7 +443,7 @@ class TestRouteValidation(unittest.TestCase):
class TestConfigShape(unittest.TestCase):
def test_unknown_egress_key_rejected(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj({
ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"wat": []}}},
"agents": {"demo": {"skills": [], "prompt": "",
"bottle": "dev"}},
@@ -454,14 +454,14 @@ class TestConfigShape(unittest.TestCase):
self.assertEqual(0, b.egress.Log)
def test_log_level_1_accepted(self):
b = Manifest.from_json_obj({
b = ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"log": 1, "routes": []}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
self.assertEqual(1, b.egress.Log)
def test_log_level_2_accepted(self):
b = Manifest.from_json_obj({
b = ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"log": 2, "routes": []}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"]
@@ -469,7 +469,7 @@ class TestConfigShape(unittest.TestCase):
def test_log_invalid_level_rejected(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj({
ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"log": 3}}},
"agents": {"demo": {"skills": [], "prompt": "",
"bottle": "dev"}},
@@ -477,7 +477,7 @@ class TestConfigShape(unittest.TestCase):
def test_log_bool_rejected(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj({
ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"log": True}}},
"agents": {"demo": {"skills": [], "prompt": "",
"bottle": "dev"}},
@@ -485,7 +485,7 @@ class TestConfigShape(unittest.TestCase):
def test_log_string_rejected(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj({
ManifestIndex.from_json_obj({
"bottles": {"dev": {"egress": {"log": "full"}}},
"agents": {"demo": {"skills": [], "prompt": "",
"bottle": "dev"}},
+2 -2
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import unittest
from bot_bottle.manifest import ManifestError, Manifest
from bot_bottle.manifest import ManifestError, ManifestIndex
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
@@ -28,7 +28,7 @@ def _build(**bottles) -> Manifest: # type: ignore
"""Build a manifest with the given bottles and one trivial agent
referencing the first bottle (so the manifest is valid)."""
first = next(iter(bottles))
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": bottles,
"agents": {
"demo": {"skills": [], "prompt": "", "bottle": first},
+38 -38
View File
@@ -2,7 +2,7 @@
import unittest
from bot_bottle.manifest import ManifestError, Manifest
from bot_bottle.manifest import ManifestError, ManifestIndex
def _manifest(repos: dict) -> dict: # type: ignore
@@ -14,7 +14,7 @@ def _manifest(repos: dict) -> dict: # type: ignore
class TestGitEntryParsing(unittest.TestCase):
def test_parses_minimal_entry(self):
m = Manifest.from_json_obj(_manifest({
m = ManifestIndex.from_json_obj(_manifest({
"bot-bottle": {
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -30,7 +30,7 @@ class TestGitEntryParsing(unittest.TestCase):
self.assertEqual("didericis/bot-bottle.git", e.UpstreamPath)
def test_default_port_is_22(self):
m = Manifest.from_json_obj(_manifest({
m = ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/didericis/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -41,7 +41,7 @@ class TestGitEntryParsing(unittest.TestCase):
self.assertEqual("github.com", e.UpstreamHost)
def test_host_key_optional(self):
m = Manifest.from_json_obj(_manifest({
m = ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -50,7 +50,7 @@ class TestGitEntryParsing(unittest.TestCase):
self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey)
def test_host_key_stored(self):
m = Manifest.from_json_obj(_manifest({
m = ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -60,7 +60,7 @@ class TestGitEntryParsing(unittest.TestCase):
self.assertEqual("ssh-ed25519 AAAA", m.bottles["dev"].git[0].KnownHostKey)
def test_repo_name_becomes_Name(self):
m = Manifest.from_json_obj(_manifest({
m = ManifestIndex.from_json_obj(_manifest({
"my-repo": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -70,19 +70,19 @@ class TestGitEntryParsing(unittest.TestCase):
def test_missing_url_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {"key": {"provider": "static", "path": "/dev/null"}},
}))
def test_missing_key_block_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {"url": "ssh://git@github.com/foo.git"},
}))
def test_unknown_key_in_entry_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -92,7 +92,7 @@ class TestGitEntryParsing(unittest.TestCase):
def test_non_ssh_url_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "https://github.com/didericis/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -101,7 +101,7 @@ class TestGitEntryParsing(unittest.TestCase):
def test_scp_style_url_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "git@github.com:didericis/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -110,7 +110,7 @@ class TestGitEntryParsing(unittest.TestCase):
def test_url_without_user_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -119,7 +119,7 @@ class TestGitEntryParsing(unittest.TestCase):
def test_url_without_path_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com",
"key": {"provider": "static", "path": "/dev/null"},
@@ -128,7 +128,7 @@ class TestGitEntryParsing(unittest.TestCase):
def test_non_numeric_port_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com:notaport/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -136,7 +136,7 @@ class TestGitEntryParsing(unittest.TestCase):
}))
def test_ip_literal_upstream(self):
m = Manifest.from_json_obj(_manifest({
m = ManifestIndex.from_json_obj(_manifest({
"bot-bottle": {
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -152,7 +152,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_two_repos_different_hosts_both_parsed(self):
# Repo names come from dict keys; two distinct keys always produce
# two distinct entries (uniqueness is guaranteed at the YAML/dict level).
m = Manifest.from_json_obj({
m = ManifestIndex.from_json_obj({
"bottles": {"dev": {"git-gate": {"repos": {
"foo": {
"url": "ssh://git@a.example/x.git",
@@ -170,7 +170,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_legacy_ssh_field_dies_with_hint(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj({
ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"ssh": [{
@@ -187,7 +187,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_name_with_single_quote_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"o'reilly": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -196,7 +196,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_name_with_space_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"my repo": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -205,7 +205,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_name_with_semicolon_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo;bar": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -214,7 +214,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_name_with_dollar_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo$bar": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -222,7 +222,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
}))
def test_valid_name_with_dots_and_hyphens_accepted(self):
m = Manifest.from_json_obj(_manifest({
m = ManifestIndex.from_json_obj(_manifest({
"my.repo-name_1": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -233,7 +233,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_legacy_git_key_dies_with_hint(self):
msg = ""
try:
Manifest.from_json_obj({
ManifestIndex.from_json_obj({
"bottles": {"dev": {"git": {"remotes": {}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
@@ -247,7 +247,7 @@ class TestStaticKey(unittest.TestCase):
"""git-gate.repos entries with key.provider = "static"."""
def test_static_key_minimal(self):
m = Manifest.from_json_obj(_manifest({
m = ManifestIndex.from_json_obj(_manifest({
"bot-bottle": {
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"key": {"provider": "static", "path": "/home/user/.ssh/id_ed25519"},
@@ -260,7 +260,7 @@ class TestStaticKey(unittest.TestCase):
self.assertEqual("/home/user/.ssh/id_ed25519", e.IdentityFile)
def test_static_key_sets_identity_file_at_parse_time(self):
m = Manifest.from_json_obj(_manifest({
m = ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"},
@@ -270,7 +270,7 @@ class TestStaticKey(unittest.TestCase):
def test_static_key_missing_path_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static"},
@@ -279,7 +279,7 @@ class TestStaticKey(unittest.TestCase):
def test_static_key_unknown_field_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null", "api_url": "x"},
@@ -291,7 +291,7 @@ class TestGiteaKey(unittest.TestCase):
"""git-gate.repos entries with key.provider = "gitea"."""
def test_gitea_key_minimal(self):
m = Manifest.from_json_obj(_manifest({
m = ManifestIndex.from_json_obj(_manifest({
"bot-bottle": {
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"key": {
@@ -308,7 +308,7 @@ class TestGiteaKey(unittest.TestCase):
self.assertEqual("", e.IdentityFile)
def test_gitea_key_with_api_url(self):
m = Manifest.from_json_obj(_manifest({
m = ManifestIndex.from_json_obj(_manifest({
"repo": {
"url": "ssh://git@gitea.example.com/org/repo.git",
"key": {
@@ -321,7 +321,7 @@ class TestGiteaKey(unittest.TestCase):
self.assertEqual("https://gitea.example.com", m.bottles["dev"].git[0].Key.api_url)
def test_gitea_key_has_no_identity_file_at_parse_time(self):
m = Manifest.from_json_obj(_manifest({
m = ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/didericis/foo.git",
"key": {"provider": "gitea", "forge_token_env": "T"},
@@ -331,7 +331,7 @@ class TestGiteaKey(unittest.TestCase):
def test_gitea_key_missing_forge_token_env_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "gitea"},
@@ -340,7 +340,7 @@ class TestGiteaKey(unittest.TestCase):
def test_gitea_key_unknown_field_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {
@@ -357,7 +357,7 @@ class TestKeyBlockValidation(unittest.TestCase):
def test_missing_provider_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {"path": "/dev/null"},
@@ -366,7 +366,7 @@ class TestKeyBlockValidation(unittest.TestCase):
def test_unknown_provider_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {
"url": "ssh://git@github.com/foo.git",
"key": {"provider": "github"},
@@ -375,14 +375,14 @@ class TestKeyBlockValidation(unittest.TestCase):
def test_missing_key_block_dies(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest({
ManifestIndex.from_json_obj(_manifest({
"foo": {"url": "ssh://git@github.com/foo.git"},
}))
class TestEmptyGitGateField(unittest.TestCase):
def test_no_git_gate_field_yields_empty_tuple(self):
m = Manifest.from_json_obj({
m = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
@@ -390,13 +390,13 @@ class TestEmptyGitGateField(unittest.TestCase):
def test_git_gate_object_type_required(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj({
ManifestIndex.from_json_obj({
"bottles": {"dev": {"git-gate": "not-a-dict"}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
def test_empty_repos_yields_empty_tuple(self):
m = Manifest.from_json_obj({
m = ManifestIndex.from_json_obj({
"bottles": {"dev": {"git-gate": {"repos": {}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
+10 -10
View File
@@ -2,7 +2,7 @@
import unittest
from bot_bottle.manifest import ManifestError, ManifestGitUser, Manifest
from bot_bottle.manifest import ManifestError, ManifestGitUser, ManifestIndex
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
@@ -23,7 +23,7 @@ def _manifest(git_user): # type: ignore
class TestGitUserParsing(unittest.TestCase):
def test_parses_both_fields(self):
m = Manifest.from_json_obj(_manifest({
m = ManifestIndex.from_json_obj(_manifest({
"name": "Eric Bauerfeld",
"email": "eric+claude@dideric.is",
}))
@@ -33,13 +33,13 @@ class TestGitUserParsing(unittest.TestCase):
self.assertFalse(u.is_empty())
def test_name_only(self):
m = Manifest.from_json_obj(_manifest({"name": "Bot"}))
m = ManifestIndex.from_json_obj(_manifest({"name": "Bot"}))
u = m.bottles["dev"].git_user
self.assertEqual("Bot", u.name)
self.assertEqual("", u.email)
def test_email_only(self):
m = Manifest.from_json_obj(_manifest({"email": "bot@example.com"}))
m = ManifestIndex.from_json_obj(_manifest({"email": "bot@example.com"}))
u = m.bottles["dev"].git_user
self.assertEqual("", u.name)
self.assertEqual("bot@example.com", u.email)
@@ -47,7 +47,7 @@ class TestGitUserParsing(unittest.TestCase):
def test_omitted_defaults_to_empty(self):
# No git.user block at all → empty GitUser, is_empty True →
# provisioner skips the `git config` step entirely.
m = Manifest.from_json_obj({
m = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
@@ -59,13 +59,13 @@ class TestGitUserParsing(unittest.TestCase):
# / half-finished edit; fail loudly rather than silently
# no-op (the operator clearly meant to configure something).
msg = _error_message(
Manifest.from_json_obj, _manifest({"name": "", "email": ""}),
ManifestIndex.from_json_obj, _manifest({"name": "", "email": ""}),
)
self.assertIn("neither name nor email", msg)
def test_unknown_key_dies(self):
msg = _error_message(
Manifest.from_json_obj,
ManifestIndex.from_json_obj,
_manifest({"name": "Bot", "username": "bot"}),
)
self.assertIn("unknown key", msg)
@@ -73,19 +73,19 @@ class TestGitUserParsing(unittest.TestCase):
def test_non_string_name_dies(self):
msg = _error_message(
Manifest.from_json_obj, _manifest({"name": 42}),
ManifestIndex.from_json_obj, _manifest({"name": 42}),
)
self.assertIn("git-gate.user.name must be a string", msg)
def test_non_string_email_dies(self):
msg = _error_message(
Manifest.from_json_obj, _manifest({"email": ["x@y.z"]}),
ManifestIndex.from_json_obj, _manifest({"email": ["x@y.z"]}),
)
self.assertIn("git-gate.user.email must be a string", msg)
def test_legacy_top_level_git_user_dies(self):
msg = _error_message(
Manifest.from_json_obj,
ManifestIndex.from_json_obj,
{
"bottles": {"dev": {"git_user": {"name": "Bot"}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+25 -30
View File
@@ -11,7 +11,7 @@ import textwrap
import unittest
from pathlib import Path
from bot_bottle.manifest import ManifestError, Manifest
from bot_bottle.manifest import ManifestError, Manifest, ManifestIndex
def _write(p: Path, text: str) -> None:
@@ -45,7 +45,7 @@ _AGENT_IMPL = """
class _ResolveCase(unittest.TestCase):
"""Drives `Manifest.resolve(cwd)` against a temp $HOME and a
"""Drives `ManifestIndex.resolve(cwd)` against a temp $HOME and a
temp cwd. Subclasses lay down fixture files in setUp."""
def setUp(self) -> None:
@@ -71,8 +71,8 @@ class _ResolveCase(unittest.TestCase):
def cwd_cb(self) -> Path:
return self.cwd_root / ".bot-bottle"
def resolve(self) -> Manifest:
return Manifest.resolve(str(self.cwd_root))
def resolve(self) -> ManifestIndex:
return ManifestIndex.resolve(str(self.cwd_root))
class TestBottleFileParses(_ResolveCase):
@@ -83,8 +83,7 @@ class TestBottleFileParses(_ResolveCase):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve().load_for_agent("implementer")
self.assertIn("dev", m.bottles)
routes = m.bottles["dev"].egress.routes
routes = m.bottle.egress.routes
self.assertEqual(2, len(routes))
self.assertEqual("api.anthropic.com", routes[0].Host)
self.assertEqual("Bearer", routes[0].AuthScheme)
@@ -101,7 +100,7 @@ class TestAgentFileParses(_ResolveCase):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve().load_for_agent("implementer")
a = m.agents["implementer"]
a = m.agent
self.assertEqual("dev", a.bottle)
self.assertEqual(("init-prd",), a.skills)
# Body became the prompt; whitespace stripped.
@@ -129,9 +128,9 @@ class TestCwdAgentOverridesHome(_ResolveCase):
""",
)
m = self.resolve().load_for_agent("implementer")
self.assertIn("CWD-OVERRIDE-PROMPT", m.agents["implementer"].prompt)
# Home bottle still present
self.assertEqual(2, len(m.bottles["dev"].egress.routes))
self.assertIn("CWD-OVERRIDE-PROMPT", m.agent.prompt)
# Home bottle still present with its two egress routes
self.assertEqual(2, len(m.bottle.egress.routes))
class TestCwdBottlesIgnored(_ResolveCase):
@@ -159,7 +158,7 @@ class TestCwdBottlesIgnored(_ResolveCase):
# Home value wins because cwd bottles are ignored entirely.
self.assertEqual(
"api.anthropic.com",
m.bottles["dev"].egress.routes[0].Host,
m.bottle.egress.routes[0].Host,
)
@@ -176,12 +175,12 @@ class TestStdlibOnly(unittest.TestCase):
class TestExistingFromJsonObjStillWorks(unittest.TestCase):
"""SC #6: `Manifest.from_json_obj` continues to work as a
"""SC #6: `ManifestIndex.from_json_obj` continues to work as a
programmatic entry point even though disk loading moved to the
MD layout."""
def test_from_json_obj(self):
m = Manifest.from_json_obj({
m = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "hi",
"bottle": "dev"}},
@@ -216,8 +215,8 @@ class TestAgentFileDoublesAsClaudeCodeSubagent(_ResolveCase):
""",
)
m = self.resolve().load_for_agent("implementer")
self.assertEqual("dev", m.agents["implementer"].bottle)
self.assertEqual(("init-prd",), m.agents["implementer"].skills)
self.assertEqual("dev", m.agent.bottle)
self.assertEqual(("init-prd",), m.agent.skills)
class TestManifestEntryPointParity(_ResolveCase):
@@ -229,7 +228,7 @@ class TestManifestEntryPointParity(_ResolveCase):
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
md_manifest = self.resolve().load_for_agent("implementer")
json_manifest = Manifest.from_json_obj({
json_index = ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"egress": {
@@ -256,17 +255,17 @@ class TestManifestEntryPointParity(_ResolveCase):
})
self.assertEqual(
md_manifest.agents["implementer"],
json_manifest.agents["implementer"],
md_manifest.agent,
json_index.agents["implementer"],
)
self.assertEqual(
md_manifest.bottles["dev"].egress.routes,
json_manifest.bottles["dev"].egress.routes,
md_manifest.bottle.egress.routes,
json_index.bottles["dev"].egress.routes,
)
def test_json_agent_rejects_unknown_keys(self):
with self.assertRaises(ManifestError):
Manifest.from_json_obj({
ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {
"implementer": {
@@ -277,7 +276,7 @@ class TestManifestEntryPointParity(_ResolveCase):
})
def test_json_agent_accepts_claude_code_passthrough_keys(self):
manifest = Manifest.from_json_obj({
index = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {
"implementer": {
@@ -291,7 +290,7 @@ class TestManifestEntryPointParity(_ResolveCase):
},
})
self.assertEqual("dev", manifest.agents["implementer"].bottle)
self.assertEqual("dev", index.agents["implementer"].bottle)
class TestBrokenAgentOnlyFailsAtPreflight(_ResolveCase):
@@ -359,7 +358,7 @@ class TestBrokenAgentOnlyFailsAtPreflight(_ResolveCase):
self.assertIn("broken-agent", m.all_agent_names)
# Valid agent loads fine.
full = m.load_for_agent("implementer")
self.assertIn("implementer", full.agents)
self.assertEqual("dev", full.agent.bottle)
# Broken bottle's agent raises at preflight.
with self.assertRaises(ManifestError):
m.load_for_agent("broken-agent")
@@ -385,7 +384,7 @@ class TestNoManifestDies(_ResolveCase):
self.resolve()
def test_missing_ok_returns_empty_manifest(self):
m = Manifest.resolve(str(self.cwd_root), missing_ok=True)
m = ManifestIndex.resolve(str(self.cwd_root), missing_ok=True)
self.assertEqual({}, dict(m.bottles))
self.assertEqual({}, dict(m.agents))
@@ -411,7 +410,7 @@ class TestUnknownBottleReferenceFailsAtPreflight(_ResolveCase):
self.assertIn("implementer", m.all_agent_names)
# Valid agent loads fine.
full = m.load_for_agent("implementer")
self.assertIn("implementer", full.agents)
self.assertEqual("dev", full.agent.bottle)
# Stray agent fails at preflight.
with self.assertRaises(ManifestError):
m.load_for_agent("stray")
@@ -431,7 +430,3 @@ class TestFilenameValidation(_ResolveCase):
self.assertIn("implementer", m.all_agent_names)
self.assertNotIn("BadName", m.all_agent_names)
self.assertNotIn("badname", m.all_agent_names)
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -7,7 +7,7 @@ silently ignoring."""
import unittest
from typing import Any
from bot_bottle.manifest import ManifestError, ManifestBottle, Manifest
from bot_bottle.manifest import ManifestError, ManifestBottle, ManifestIndex
def _manifest_with_runtime(value: object) -> dict[str, Any]:
@@ -19,7 +19,7 @@ def _manifest_with_runtime(value: object) -> dict[str, Any]:
class TestManifestRuntimeRemoved(unittest.TestCase):
def test_loads_when_runtime_absent(self):
m = Manifest.from_json_obj({
m = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
@@ -32,7 +32,7 @@ class TestManifestRuntimeRemoved(unittest.TestCase):
for value in ("runsc", "runc", "kata-runtime", "", 42, None):
with self.subTest(value=value):
with self.assertRaises(ManifestError):
Manifest.from_json_obj(_manifest_with_runtime(value))
ManifestIndex.from_json_obj(_manifest_with_runtime(value))
if __name__ == "__main__":
+3 -3
View File
@@ -19,14 +19,14 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import Manifest, ManifestIndex
def _manifest() -> Manifest:
return Manifest.from_json_obj({
return ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
}).load_for_agent("demo")
def _spec(manifest: Manifest, tmp: str) -> BottleSpec:
+2 -2
View File
@@ -10,7 +10,7 @@ from bot_bottle.git_gate import (
GIT_GATE_HOSTNAME,
git_gate_render_gitconfig,
)
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
from tests.fixtures import fixture_minimal, fixture_with_git
@@ -72,7 +72,7 @@ class TestGitGateGitconfigRender(unittest.TestCase):
def test_ip_upstream_emits_single_insteadof(self):
# In the new format the dict key is the repo name, not a host
# alias, so there is only one insteadOf rule — for the IP URL.
m = Manifest.from_json_obj({
m = ManifestIndex.from_json_obj({
"bottles": {"dev": {"git-gate": {"repos": {
"bot-bottle": {
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
+3 -3
View File
@@ -33,7 +33,7 @@ from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.util import AGENT_CA_PATH
from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, Manifest
from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, Manifest, ManifestIndex
from bot_bottle.supervise import SupervisePlan
@@ -110,7 +110,7 @@ def _plan(
bottle_json["git-gate"] = git_gate_json
if supervise:
bottle_json["supervise"] = True
manifest = Manifest.from_json_obj({
manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
"demo": {
@@ -119,7 +119,7 @@ def _plan(
"bottle": "dev",
},
},
})
}).load_for_agent("demo")
spec = BottleSpec(
manifest=manifest,
agent_name="demo",