refactor: reorganize template files and update provisioning paths
This commit is contained in:
@@ -38,9 +38,9 @@ COPY backend/migrations ./migrations
|
|||||||
COPY backend/alembic.ini ./alembic.ini
|
COPY backend/alembic.ini ./alembic.ini
|
||||||
COPY backend/app ./app
|
COPY backend/app ./app
|
||||||
|
|
||||||
# Copy repo-level templates used by agent provisioning, etc.
|
# Copy provisioning templates.
|
||||||
# (backend code resolves these from repo root)
|
# In-repo these live at `backend/templates/`; runtime path is `/app/templates`.
|
||||||
COPY templates ./templates
|
COPY backend/templates ./templates
|
||||||
|
|
||||||
# Default API port
|
# Default API port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -544,9 +544,16 @@ class OpenClawGatewayControlPlane(GatewayControlPlane):
|
|||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
name = item.get("name")
|
name = item.get("name")
|
||||||
|
if not isinstance(name, str) or not name:
|
||||||
|
name = item.get("path")
|
||||||
if isinstance(name, str) and name:
|
if isinstance(name, str) and name:
|
||||||
supported.add(name)
|
supported.add(name)
|
||||||
return supported or set(DEFAULT_GATEWAY_FILES)
|
|
||||||
|
# Always include Mission Control's expected template files even if the gateway's default
|
||||||
|
# agent reports a different file set (e.g. `prompts/system.md`). This prevents provisioning
|
||||||
|
# from silently skipping our templates due to gateway-side defaults or version skew.
|
||||||
|
supported.update(DEFAULT_GATEWAY_FILES)
|
||||||
|
return supported
|
||||||
|
|
||||||
async def list_agent_files(self, agent_id: str) -> dict[str, dict[str, Any]]:
|
async def list_agent_files(self, agent_id: str) -> dict[str, dict[str, Any]]:
|
||||||
payload = await openclaw_call(
|
payload = await openclaw_call(
|
||||||
@@ -564,6 +571,8 @@ class OpenClawGatewayControlPlane(GatewayControlPlane):
|
|||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
name = item.get("name")
|
name = item.get("name")
|
||||||
|
if not isinstance(name, str) or not name:
|
||||||
|
name = item.get("path")
|
||||||
if isinstance(name, str) and name:
|
if isinstance(name, str) and name:
|
||||||
index[name] = dict(item)
|
index[name] = dict(item)
|
||||||
return index
|
return index
|
||||||
@@ -686,11 +695,15 @@ class BaseAgentLifecycleManager(ABC):
|
|||||||
agent_id: str,
|
agent_id: str,
|
||||||
rendered: dict[str, str],
|
rendered: dict[str, str],
|
||||||
existing_files: dict[str, dict[str, Any]],
|
existing_files: dict[str, dict[str, Any]],
|
||||||
|
action: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
for name, content in rendered.items():
|
for name, content in rendered.items():
|
||||||
if content == "":
|
if content == "":
|
||||||
continue
|
continue
|
||||||
if name in PRESERVE_AGENT_EDITABLE_FILES:
|
# Preserve "editable" files only during updates. During first-time provisioning,
|
||||||
|
# the gateway may pre-create defaults for USER/SELF/etc, and we still want to
|
||||||
|
# apply Mission Control's templates.
|
||||||
|
if action == "update" and name in PRESERVE_AGENT_EDITABLE_FILES:
|
||||||
entry = existing_files.get(name)
|
entry = existing_files.get(name)
|
||||||
if entry and not bool(entry.get("missing")):
|
if entry and not bool(entry.get("missing")):
|
||||||
continue
|
continue
|
||||||
@@ -764,6 +777,7 @@ class BaseAgentLifecycleManager(ABC):
|
|||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
rendered=rendered,
|
rendered=rendered,
|
||||||
existing_files=existing_files,
|
existing_files=existing_files,
|
||||||
|
action=options.action,
|
||||||
)
|
)
|
||||||
if options.reset_session:
|
if options.reset_session:
|
||||||
await self._control_plane.reset_agent_session(session_key)
|
await self._control_plane.reset_agent_session(session_key)
|
||||||
|
|||||||
@@ -81,6 +81,13 @@ def test_agent_lifecycle_workspace_path_preserves_tilde_in_workspace_root():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_templates_root_points_to_repo_templates_dir():
|
||||||
|
root = agent_provisioning._templates_root()
|
||||||
|
assert root.name == "templates"
|
||||||
|
assert root.parent.name == "backend"
|
||||||
|
assert (root / "AGENTS.md").exists()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _GatewayStub:
|
class _GatewayStub:
|
||||||
id: UUID
|
id: UUID
|
||||||
@@ -164,3 +171,100 @@ async def test_provision_main_agent_uses_dedicated_openclaw_agent_id(monkeypatch
|
|||||||
expected_agent_id = GatewayAgentIdentity.openclaw_agent_id_for_id(gateway_id)
|
expected_agent_id = GatewayAgentIdentity.openclaw_agent_id_for_id(gateway_id)
|
||||||
assert captured["patched_agent_id"] == expected_agent_id
|
assert captured["patched_agent_id"] == expected_agent_id
|
||||||
assert captured["files_index_agent_id"] == expected_agent_id
|
assert captured["files_index_agent_id"] == expected_agent_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_supported_files_always_includes_default_gateway_files(monkeypatch):
|
||||||
|
"""Provisioning should not depend solely on whatever the gateway's default agent reports."""
|
||||||
|
|
||||||
|
async def _fake_openclaw_call(method, params=None, config=None):
|
||||||
|
_ = config
|
||||||
|
if method == "agents.list":
|
||||||
|
return {"defaultId": "main"}
|
||||||
|
if method == "agents.files.list":
|
||||||
|
assert params == {"agentId": "main"}
|
||||||
|
return {"files": [{"path": "prompts/system.md", "missing": True}]}
|
||||||
|
raise AssertionError(f"Unexpected method: {method}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent_provisioning, "openclaw_call", _fake_openclaw_call)
|
||||||
|
cp = agent_provisioning.OpenClawGatewayControlPlane(
|
||||||
|
agent_provisioning.GatewayClientConfig(url="ws://gateway.example/ws", token=None),
|
||||||
|
)
|
||||||
|
supported = await cp.list_supported_files()
|
||||||
|
|
||||||
|
# Newer gateways may surface other file paths, but we still must include our templates.
|
||||||
|
assert "prompts/system.md" in supported
|
||||||
|
for required in agent_provisioning.DEFAULT_GATEWAY_FILES:
|
||||||
|
assert required in supported
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_provision_overwrites_user_md_on_first_provision(monkeypatch):
|
||||||
|
"""Gateway may pre-create USER.md; we still want MC's template on first provision."""
|
||||||
|
|
||||||
|
class _ControlPlaneStub:
|
||||||
|
def __init__(self):
|
||||||
|
self.writes: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
async def ensure_agent_session(self, session_key, *, label=None):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def reset_agent_session(self, session_key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_agent_session(self, session_key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def upsert_agent(self, registration):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_agent(self, agent_id, *, delete_files=True):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def list_supported_files(self):
|
||||||
|
# Minimal set.
|
||||||
|
return {"USER.md"}
|
||||||
|
|
||||||
|
async def list_agent_files(self, agent_id):
|
||||||
|
# Pretend gateway created USER.md already.
|
||||||
|
return {"USER.md": {"name": "USER.md", "missing": False}}
|
||||||
|
|
||||||
|
async def set_agent_file(self, *, agent_id, name, content):
|
||||||
|
self.writes.append((name, content))
|
||||||
|
|
||||||
|
async def patch_agent_heartbeats(self, entries):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _GatewayTiny:
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
token: str | None
|
||||||
|
workspace_root: str
|
||||||
|
|
||||||
|
class _Manager(agent_provisioning.BaseAgentLifecycleManager):
|
||||||
|
def _agent_id(self, agent):
|
||||||
|
return "agent-x"
|
||||||
|
|
||||||
|
def _build_context(self, *, agent, auth_token, user, board):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
gateway = _GatewayTiny(
|
||||||
|
id=uuid4(),
|
||||||
|
name="G",
|
||||||
|
url="ws://x",
|
||||||
|
token=None,
|
||||||
|
workspace_root="/tmp",
|
||||||
|
)
|
||||||
|
cp = _ControlPlaneStub()
|
||||||
|
mgr = _Manager(gateway, cp) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Rendered content is non-empty; action is "provision" so we should overwrite.
|
||||||
|
await mgr._set_agent_files(
|
||||||
|
agent_id="agent-x",
|
||||||
|
rendered={"USER.md": "from-mc"},
|
||||||
|
existing_files={"USER.md": {"name": "USER.md", "missing": False}},
|
||||||
|
action="provision",
|
||||||
|
)
|
||||||
|
assert ("USER.md", "from-mc") in cp.writes
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
# Build from repo root so the backend image can include repo-level assets
|
# Build from repo root so the backend image can include repo-level assets
|
||||||
# like `templates/`.
|
# like `backend/templates/`.
|
||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ There is currently no queue runtime configured in this repo.
|
|||||||
Repo root:
|
Repo root:
|
||||||
- `compose.yml` — local/self-host stack
|
- `compose.yml` — local/self-host stack
|
||||||
- `.env.example` — compose/local defaults
|
- `.env.example` — compose/local defaults
|
||||||
- `templates/` — shared templates
|
- `backend/templates/` — shared templates
|
||||||
|
|
||||||
Backend:
|
Backend:
|
||||||
- `backend/app/api/` — REST routers
|
- `backend/app/api/` — REST routers
|
||||||
|
|||||||
Reference in New Issue
Block a user