diff --git a/Makefile b/Makefile index 79c66da2..f254fb52 100644 --- a/Makefile +++ b/Makefile @@ -147,7 +147,7 @@ rq-worker: ## Run background queue worker loop cd $(BACKEND_DIR) && uv run python ../scripts/rq worker .PHONY: backend-templates-sync -backend-templates-sync: ## Sync templates to existing gateway agents (usage: make backend-templates-sync GATEWAY_ID= SYNC_ARGS="--reset-sessions") +backend-templates-sync: ## Sync templates to existing gateway agents (usage: make backend-templates-sync GATEWAY_ID= SYNC_ARGS="--reset-sessions --overwrite") @if [ -z "$(GATEWAY_ID)" ]; then echo "GATEWAY_ID is required (uuid)"; exit 1; fi cd $(BACKEND_DIR) && uv run python scripts/sync_gateway_templates.py --gateway-id "$(GATEWAY_ID)" $(SYNC_ARGS) diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py index 2a37a362..2e5cbac9 100644 --- a/backend/app/api/gateways.py +++ b/backend/app/api/gateways.py @@ -41,6 +41,7 @@ INCLUDE_MAIN_QUERY = Query(default=True) RESET_SESSIONS_QUERY = Query(default=False) ROTATE_TOKENS_QUERY = Query(default=False) FORCE_BOOTSTRAP_QUERY = Query(default=False) +OVERWRITE_QUERY = Query(default=False) LEAD_ONLY_QUERY = Query(default=False) BOARD_ID_QUERY = Query(default=None) _RUNTIME_TYPE_REFERENCES = (UUID,) @@ -53,6 +54,7 @@ def _template_sync_query( reset_sessions: bool = RESET_SESSIONS_QUERY, rotate_tokens: bool = ROTATE_TOKENS_QUERY, force_bootstrap: bool = FORCE_BOOTSTRAP_QUERY, + overwrite: bool = OVERWRITE_QUERY, board_id: UUID | None = BOARD_ID_QUERY, ) -> GatewayTemplateSyncQuery: return GatewayTemplateSyncQuery( @@ -61,6 +63,7 @@ def _template_sync_query( reset_sessions=reset_sessions, rotate_tokens=rotate_tokens, force_bootstrap=force_bootstrap, + overwrite=overwrite, board_id=board_id, ) diff --git a/backend/app/services/openclaw/admin_service.py b/backend/app/services/openclaw/admin_service.py index 587703d5..c156ec6b 100644 --- a/backend/app/services/openclaw/admin_service.py +++ b/backend/app/services/openclaw/admin_service.py @@ -347,6 +347,7 @@ class GatewayAdminLifecycleService(OpenClawDBService): reset_sessions=query.reset_sessions, rotate_tokens=query.rotate_tokens, force_bootstrap=query.force_bootstrap, + overwrite=query.overwrite, board_id=query.board_id, ), ) diff --git a/backend/app/services/openclaw/provisioning.py b/backend/app/services/openclaw/provisioning.py index 49fa7b5f..39913ee6 100644 --- a/backend/app/services/openclaw/provisioning.py +++ b/backend/app/services/openclaw/provisioning.py @@ -61,6 +61,7 @@ class ProvisionOptions: action: str = "provision" force_bootstrap: bool = False + overwrite: bool = False _ROLE_SOUL_MAX_CHARS = 24_000 @@ -715,6 +716,7 @@ class BaseAgentLifecycleManager(ABC): desired_file_names: set[str] | None = None, existing_files: dict[str, dict[str, Any]], action: str, + overwrite: bool = False, ) -> None: preserve_files = ( self._preserve_files(agent) if agent is not None else set(PRESERVE_AGENT_EDITABLE_FILES) @@ -728,15 +730,10 @@ class BaseAgentLifecycleManager(ABC): # Preserve "editable" files only during updates. During first-time provisioning, # the gateway may pre-create defaults for USER/MEMORY/etc, and we still want to # apply Mission Control's templates. - if action == "update" and name in preserve_files: + if action == "update" and not overwrite and name in preserve_files: entry = existing_files.get(name) if entry and not bool(entry.get("missing")): - size = entry.get("size") - if isinstance(size, int) and size == 0: - # Treat 0-byte placeholders as missing so update can fill them. - pass - else: - continue + continue try: await self._control_plane.set_agent_file( agent_id=agent_id, @@ -840,6 +837,7 @@ class BaseAgentLifecycleManager(ABC): desired_file_names=set(rendered.keys()), existing_files=existing_files, action=options.action, + overwrite=options.overwrite, ) @@ -1013,6 +1011,7 @@ class OpenClawGatewayProvisioner: user: User | None, action: str = "provision", force_bootstrap: bool = False, + overwrite: bool = False, reset_session: bool = False, wake: bool = True, deliver_wakeup: bool = True, @@ -1056,7 +1055,11 @@ class OpenClawGatewayProvisioner: session_key=session_key, auth_token=auth_token, user=user, - options=ProvisionOptions(action=action, force_bootstrap=force_bootstrap), + options=ProvisionOptions( + action=action, + force_bootstrap=force_bootstrap, + overwrite=overwrite, + ), session_label=agent.name or "Gateway Agent", ) diff --git a/backend/app/services/openclaw/provisioning_db.py b/backend/app/services/openclaw/provisioning_db.py index e44163e5..2272a078 100644 --- a/backend/app/services/openclaw/provisioning_db.py +++ b/backend/app/services/openclaw/provisioning_db.py @@ -113,6 +113,7 @@ class GatewayTemplateSyncOptions: reset_sessions: bool = False rotate_tokens: bool = False force_bootstrap: bool = False + overwrite: bool = False board_id: UUID | None = None @@ -569,6 +570,7 @@ async def _sync_one_agent( user=ctx.options.user, action="update", force_bootstrap=ctx.options.force_bootstrap, + overwrite=ctx.options.overwrite, reset_session=ctx.options.reset_sessions, wake=False, ) @@ -639,6 +641,7 @@ async def _sync_main_agent( user=ctx.options.user, action="update", force_bootstrap=ctx.options.force_bootstrap, + overwrite=ctx.options.overwrite, reset_session=ctx.options.reset_sessions, wake=False, ) diff --git a/backend/app/services/openclaw/session_service.py b/backend/app/services/openclaw/session_service.py index 627a9967..3669b0fd 100644 --- a/backend/app/services/openclaw/session_service.py +++ b/backend/app/services/openclaw/session_service.py @@ -48,6 +48,7 @@ class GatewayTemplateSyncQuery: reset_sessions: bool rotate_tokens: bool force_bootstrap: bool + overwrite: bool board_id: UUID | None diff --git a/backend/scripts/sync_gateway_templates.py b/backend/scripts/sync_gateway_templates.py index 91266c24..97ee109e 100644 --- a/backend/scripts/sync_gateway_templates.py +++ b/backend/scripts/sync_gateway_templates.py @@ -51,6 +51,11 @@ def _parse_args() -> argparse.Namespace: action="store_true", help="Force BOOTSTRAP.md to be rendered during update sync", ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Overwrite editable files (e.g. USER.md, MEMORY.md) during update sync", + ) return parser.parse_args() @@ -81,6 +86,7 @@ async def _run() -> int: reset_sessions=bool(args.reset_sessions), rotate_tokens=bool(args.rotate_tokens), force_bootstrap=bool(args.force_bootstrap), + overwrite=bool(args.overwrite), board_id=board_id, ), ) diff --git a/backend/tests/test_agent_provisioning_utils.py b/backend/tests/test_agent_provisioning_utils.py index fb593357..91295e9c 100644 --- a/backend/tests/test_agent_provisioning_utils.py +++ b/backend/tests/test_agent_provisioning_utils.py @@ -216,8 +216,8 @@ async def test_provision_overwrites_user_md_on_first_provision(monkeypatch): @pytest.mark.asyncio -async def test_set_agent_files_update_writes_zero_size_user_md(): - """Treat empty placeholder files as missing during update.""" +async def test_set_agent_files_update_preserves_user_md_even_when_size_zero(): + """Update should preserve editable files unless overwrite is explicitly requested.""" class _ControlPlaneStub: def __init__(self): @@ -278,6 +278,135 @@ async def test_set_agent_files_update_writes_zero_size_user_md(): existing_files={"USER.md": {"name": "USER.md", "missing": False, "size": 0}}, action="update", ) + assert cp.writes == [] + + +@pytest.mark.asyncio +async def test_set_agent_files_update_preserves_nonmissing_user_md(): + 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_agent_files(self, agent_id): + return {} + + 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] + + await mgr._set_agent_files( + agent_id="agent-x", + rendered={"USER.md": "filled"}, + existing_files={"USER.md": {"name": "USER.md", "missing": False}}, + action="update", + ) + assert cp.writes == [] + + +@pytest.mark.asyncio +async def test_set_agent_files_update_overwrite_writes_preserved_user_md(): + 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_agent_files(self, agent_id): + return {} + + 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] + + await mgr._set_agent_files( + agent_id="agent-x", + rendered={"USER.md": "filled"}, + existing_files={"USER.md": {"name": "USER.md", "missing": False}}, + action="update", + overwrite=True, + ) assert ("USER.md", "filled") in cp.writes diff --git a/frontend/src/api/generated/model/syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams.ts b/frontend/src/api/generated/model/syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams.ts index 00fff9e5..5b2524be 100644 --- a/frontend/src/api/generated/model/syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams.ts +++ b/frontend/src/api/generated/model/syncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams.ts @@ -12,5 +12,6 @@ export type SyncGatewayTemplatesApiV1GatewaysGatewayIdTemplatesSyncPostParams = reset_sessions?: boolean; rotate_tokens?: boolean; force_bootstrap?: boolean; + overwrite?: boolean; board_id?: string | null; };