Track builder workspace deployment base
All checks were successful
build / build (push) Successful in 27s

This commit is contained in:
robert
2026-05-19 09:10:20 -03:00
parent f0e52f9cdd
commit a3466c7a9a
3 changed files with 251 additions and 7 deletions

View File

@@ -92,6 +92,7 @@ def _ensure_bucket(s3: Any, bucket: str) -> None:
_AGENT_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
_BUILDER_STATE_FILE = ".a2a-builder-state.json"
def _validate_name(name: str) -> None:
@@ -106,6 +107,10 @@ def _agent_prefix(name: str) -> str:
return f"agents/{name}/"
def _builder_state_key(name: str) -> str:
return _agent_prefix(name) + _BUILDER_STATE_FILE
def build_tools(ctx: ToolContext) -> list[Any]:
bucket = ctx.bucket
settings = ctx.settings
@@ -156,8 +161,11 @@ def build_tools(ctx: ToolContext) -> list[Any]:
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
for obj in page.get("Contents") or ():
rel = obj["Key"][len(prefix):]
if rel == _BUILDER_STATE_FILE:
continue
out.append({
"path": obj["Key"][len(prefix):],
"path": rel,
"size": int(obj.get("Size") or 0),
})
return json.dumps({"agent": name, "files": out})
@@ -173,7 +181,10 @@ def build_tools(ctx: ToolContext) -> list[Any]:
prefix = _agent_prefix(name)
except ValueError as exc:
return json.dumps({"error": str(exc)})
key = prefix + path.lstrip("/")
rel = path.lstrip("/")
if rel == _BUILDER_STATE_FILE:
return json.dumps({"error": f"{_BUILDER_STATE_FILE} is managed by deploys"})
key = prefix + rel
s3.put_object(
Bucket=bucket, Key=key, Body=content.encode("utf-8"),
ContentType="text/plain; charset=utf-8",
@@ -187,7 +198,10 @@ def build_tools(ctx: ToolContext) -> list[Any]:
prefix = _agent_prefix(name)
except ValueError as exc:
return json.dumps({"error": str(exc)})
key = prefix + path.lstrip("/")
rel = path.lstrip("/")
if rel == _BUILDER_STATE_FILE:
return json.dumps({"error": f"{_BUILDER_STATE_FILE} is managed by deploys"})
key = prefix + rel
try:
obj = s3.get_object(Bucket=bucket, Key=key)
except ClientError as exc:
@@ -246,7 +260,12 @@ def build_tools(ctx: ToolContext) -> list[Any]:
})
@tool
async def cp_deploy_tarball(name: str, version: str = "0.1.0", public: bool = True) -> str:
async def cp_deploy_tarball(
name: str,
version: str = "0.1.0",
public: bool = True,
force: bool = False,
) -> str:
"""Tar the agent at ``agents/<name>/`` and POST it to the control
plane's ``/v1/agents/from-tarball`` endpoint on the user's behalf,
then BLOCK until the live ``.well-known/agent-card`` reports the
@@ -263,6 +282,11 @@ def build_tools(ctx: ToolContext) -> list[Any]:
actually accepts. Mismatches cause downstream ``agent 400`` from
``call_agent``.
``force`` defaults to false so a stale MinIO workspace cannot
silently overwrite source that was deployed from a user's computer
or another tool. Only set ``force=True`` after the user explicitly
accepts replacing the current managed repo contents.
Requires the orchestrator to have forwarded the user's CP JWT
(the platform does this automatically when this agent's Card
declared ``wants_cp_jwt=True``).
@@ -276,6 +300,23 @@ def build_tools(ctx: ToolContext) -> list[Any]:
"error": "no CP JWT forwarded — agent declaration missing wants_cp_jwt=True",
})
try:
latest_deploy = await _latest_cp_deployment(settings.cp_url, ctx.cp_jwt, name)
except RuntimeError as exc:
return json.dumps({"error": str(exc)})
builder_state = _read_builder_state(s3, bucket, name)
drift = _deployment_drift_error(
name,
latest_deploy,
builder_state,
force=force,
)
if drift is not None:
return json.dumps(drift)
base_head = builder_state.get("repo_head_sha")
if force:
base_head = None
bundle_bytes = _tarball_workspace_dir(s3, bucket, prefix)
if not bundle_bytes:
return json.dumps({"error": f"no files at agents/{name}/"})
@@ -290,6 +331,8 @@ def build_tools(ctx: ToolContext) -> list[Any]:
"entrypoint": entrypoint, "public": "true" if public else "false",
"description": "Built by agent-builder.",
}
if isinstance(base_head, str) and base_head:
data["base_head_sha"] = base_head
try:
async with httpx.AsyncClient(timeout=60.0) as c:
r = await c.post(
@@ -305,6 +348,21 @@ def build_tools(ctx: ToolContext) -> list[Any]:
name_ = body.get("name")
version_ = body.get("version")
url_ = body.get("url")
head_sha = body.get("head_sha")
if isinstance(head_sha, str) and head_sha:
_write_builder_state(
s3,
bucket,
name,
{
"agent": name,
"repo_head_sha": head_sha,
"deployment_id": body.get("deployment_id"),
"version": version_,
"updated_at": int(time.time()),
"source": "agent-builder",
},
)
# CP returns immediately with status="building". The agent isn't
# actually callable until the pod rolls + serves the new card.
@@ -320,6 +378,9 @@ def build_tools(ctx: ToolContext) -> list[Any]:
"version": version_,
"status": body.get("status"),
"url": url_,
"head_sha": head_sha,
"deployment_id": body.get("deployment_id"),
"workspace_base_head_sha": base_head,
"live": live,
}
if live:
@@ -478,6 +539,7 @@ def _tarball_workspace_dir(s3: Any, bucket: str, prefix: str) -> bytes:
gzipped tarball whose entries are relative to ``prefix``."""
paginator = s3.get_paginator("list_objects_v2")
buf = io.BytesIO()
added = False
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
for obj in page.get("Contents") or ():
@@ -485,12 +547,112 @@ def _tarball_workspace_dir(s3: Any, bucket: str, prefix: str) -> bytes:
rel = key[len(prefix):]
if not rel:
continue
if rel == _BUILDER_STATE_FILE:
continue
body = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
info = tarfile.TarInfo(name=rel)
info.size = len(body)
info.mode = 0o644
tf.addfile(info, io.BytesIO(body))
return buf.getvalue() if buf.getbuffer().nbytes > 0 else b""
added = True
return buf.getvalue() if added else b""
def _read_builder_state(s3: Any, bucket: str, name: str) -> dict[str, Any]:
try:
obj = s3.get_object(Bucket=bucket, Key=_builder_state_key(name))
except ClientError as exc:
code = exc.response.get("Error", {}).get("Code")
if code in {"404", "NoSuchKey", "NotFound"}:
return {}
return {}
try:
data = json.loads(obj["Body"].read().decode("utf-8"))
except (TypeError, ValueError, UnicodeDecodeError):
return {}
return data if isinstance(data, dict) else {}
def _write_builder_state(
s3: Any,
bucket: str,
name: str,
state: dict[str, Any],
) -> None:
body = json.dumps(state, sort_keys=True, indent=2).encode("utf-8")
s3.put_object(
Bucket=bucket,
Key=_builder_state_key(name),
Body=body,
ContentType="application/json",
)
async def _latest_cp_deployment(
cp_url: str,
cp_jwt: str,
name: str,
) -> dict[str, Any] | None:
try:
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.get(
f"{cp_url}/v1/agents/{name}/deployments",
headers={"authorization": f"bearer {cp_jwt}"},
)
except httpx.HTTPError as exc:
raise RuntimeError(f"cp unreachable: {exc}") from exc
if r.status_code == 404:
return None
if r.status_code >= 400:
raise RuntimeError(f"cp {r.status_code}: {r.text[:1000]}")
body = r.json() or []
if not isinstance(body, list) or not body:
return None
first = body[0]
return first if isinstance(first, dict) else None
def _deployment_drift_error(
name: str,
latest_deployment: dict[str, Any] | None,
builder_state: dict[str, Any],
*,
force: bool = False,
) -> dict[str, Any] | None:
if force or latest_deployment is None:
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,
"error": "workspace_untracked",
"agent": name,
"message": (
"This agent already has managed source, but the MinIO "
"workspace has no deploy base marker. Refresh it before "
"deploying, or use force=True only if the user wants to "
"replace the repo."
),
"current_deployment_id": latest_deployment.get("deploy_id"),
}
return None
def _read_entrypoint(bundle: bytes) -> str | None: