Sync builder workspaces from managed repo
All checks were successful
build / build (push) Successful in 2s

This commit is contained in:
robert
2026-05-19 09:18:39 -03:00
parent 16b6139bed
commit d43533df80
3 changed files with 185 additions and 23 deletions

View File

@@ -443,6 +443,63 @@ def build_tools(ctx: ToolContext) -> list[Any]:
"card": body.get("card") or {},
})
@tool
async def sync_agent_workspace_from_repo(name: str) -> str:
"""Replace ``agents/<name>/`` in MinIO with the current managed repo source.
Use this before editing an existing deployed agent, or after
``cp_deploy_tarball`` returns ``workspace_untracked`` /
``workspace_drift``. It downloads the owner's current managed repo
through the control plane, deletes the old MinIO files for that
agent, writes the repo source files, and records the repo head as the
deploy base marker. This intentionally overwrites the builder
workspace copy so it matches the repo.
"""
try:
prefix = _agent_prefix(name)
except ValueError as exc:
return json.dumps({"error": str(exc)})
if not ctx.cp_jwt:
return json.dumps({
"error": "no CP JWT forwarded — agent declaration missing wants_cp_jwt=True",
})
try:
async with httpx.AsyncClient(timeout=60.0) as c:
r = await c.get(
f"{settings.cp_url}/v1/agents/{name}/source",
headers={"authorization": f"bearer {ctx.cp_jwt}"},
)
except httpx.HTTPError as exc:
return json.dumps({"error": f"cp unreachable: {exc}"})
if r.status_code >= 400:
return json.dumps({"error": f"cp {r.status_code}", "detail": r.text[:1000]})
head_sha = r.headers.get("x-a2a-repo-head-sha")
try:
files = _files_from_tarball(r.content)
except ValueError as exc:
return json.dumps({"error": f"invalid source tarball: {exc}"})
if not files:
return json.dumps({"error": "managed repo source export was empty"})
written = _replace_workspace_files(s3, bucket, prefix, files)
_write_builder_state(
s3,
bucket,
name,
{
"agent": name,
"repo_head_sha": head_sha,
"file_count": len(written),
"updated_at": int(time.time()),
"source": "repo-sync",
},
)
return json.dumps({
"ok": True,
"agent": name,
"repo_head_sha": head_sha,
"files": written,
})
@tool
def list_a2a_pack(subdir: str = "") -> str:
"""List ``.py`` files under the installed ``a2a_pack`` package.
@@ -527,6 +584,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
return [
init_agent_template, list_agent_files, write_agent_file, read_agent_file,
test_agent_in_sandbox, cp_deploy_tarball, cp_refresh_agent,
sync_agent_workspace_from_repo,
list_a2a_pack, read_a2a_pack,
]
@@ -588,6 +646,62 @@ def _write_builder_state(
)
def _delete_workspace_objects(s3: Any, bucket: str, prefix: str) -> None:
paginator = s3.get_paginator("list_objects_v2")
batch: list[dict[str, str]] = []
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
for obj in page.get("Contents") or ():
batch.append({"Key": obj["Key"]})
if len(batch) >= 1000:
s3.delete_objects(Bucket=bucket, Delete={"Objects": batch})
batch = []
if batch:
s3.delete_objects(Bucket=bucket, Delete={"Objects": batch})
def _files_from_tarball(bundle: bytes) -> dict[str, bytes]:
out: dict[str, bytes] = {}
try:
with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf:
for member in tf.getmembers():
if not member.isfile():
continue
rel = member.name
while rel.startswith("./"):
rel = rel[2:]
if not rel or rel.startswith("/") or ".." in rel.split("/"):
raise ValueError(f"unsafe member path: {member.name}")
if rel == _BUILDER_STATE_FILE:
continue
extracted = tf.extractfile(member)
if extracted is None:
continue
out[rel] = extracted.read()
except tarfile.TarError as exc:
raise ValueError(str(exc)) from exc
return out
def _replace_workspace_files(
s3: Any,
bucket: str,
prefix: str,
files: dict[str, bytes],
) -> list[dict[str, Any]]:
_delete_workspace_objects(s3, bucket, prefix)
written: list[dict[str, Any]] = []
for rel, body in sorted(files.items()):
key = prefix + rel
s3.put_object(
Bucket=bucket,
Key=key,
Body=body,
ContentType="application/octet-stream",
)
written.append({"path": rel, "size": len(body)})
return written
async def _latest_cp_deployment(
cp_url: str,
cp_jwt: str,
@@ -623,22 +737,6 @@ def _deployment_drift_error(
return None
latest_head = latest_deployment.get("head_sha")
base_head = builder_state.get("repo_head_sha")
if isinstance(latest_head, str) and latest_head:
if isinstance(base_head, str) and base_head == latest_head:
return None
return {
"ok": False,
"error": "workspace_drift",
"agent": name,
"message": (
"This MinIO workspace is not based on the latest managed "
"repo deployment. Refresh it before deploying, or use "
"force=True only if the user wants to replace the repo."
),
"workspace_base_head_sha": base_head,
"current_head_sha": latest_head,
"current_deployment_id": latest_deployment.get("deploy_id"),
}
if not isinstance(base_head, str) or not base_head:
return {
"ok": False,
@@ -650,6 +748,7 @@ def _deployment_drift_error(
"deploying, or use force=True only if the user wants to "
"replace the repo."
),
"current_head_sha": latest_head,
"current_deployment_id": latest_deployment.get("deploy_id"),
}
return None