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

@@ -110,9 +110,13 @@ Your tools:
- test_agent_in_sandbox(name) — pip install + ``a2a card`` round-trip; - test_agent_in_sandbox(name) — pip install + ``a2a card`` round-trip;
check exit_code == 0 and the card JSON check exit_code == 0 and the card JSON
looks right looks right
- cp_deploy_tarball(name, version="0.1.0", public=True) - cp_deploy_tarball(name, version="0.1.0", public=True, force=False)
— ship it to the platform; returns the — ship it to the platform; returns the
public URL public URL. It refuses stale MinIO
workspaces if the managed repo changed
elsewhere; use force=True only after
the user explicitly accepts replacing
the current repo source.
- cp_refresh_agent(name) — force the control plane to re-fetch - cp_refresh_agent(name) — force the control plane to re-fetch
the agent's live card after an the agent's live card after an
out-of-band redeploy out-of-band redeploy
@@ -144,6 +148,10 @@ Discipline:
scaffold. scaffold.
- When deploying, return the URL the platform gave back to the user so - When deploying, return the URL the platform gave back to the user so
they can curl it / share it. they can curl it / share it.
- If cp_deploy_tarball returns workspace_drift or workspace_untracked,
stop and report that the builder workspace is stale. Do not use
force=True unless the user explicitly says to overwrite the managed
repo with the current MinIO files.
- After cp_deploy_tarball returns, compare ``live_skills[].input_schema`` - After cp_deploy_tarball returns, compare ``live_skills[].input_schema``
with the skill signatures you wrote. If an argument is missing from the with the skill signatures you wrote. If an argument is missing from the
live schema, treat the deploy as failed, fix the source/schema version, live schema, treat the deploy as failed, fix the source/schema version,

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}$") _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: def _validate_name(name: str) -> None:
@@ -106,6 +107,10 @@ def _agent_prefix(name: str) -> str:
return f"agents/{name}/" 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]: def build_tools(ctx: ToolContext) -> list[Any]:
bucket = ctx.bucket bucket = ctx.bucket
settings = ctx.settings settings = ctx.settings
@@ -156,8 +161,11 @@ def build_tools(ctx: ToolContext) -> list[Any]:
paginator = s3.get_paginator("list_objects_v2") paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket, Prefix=prefix): for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
for obj in page.get("Contents") or (): for obj in page.get("Contents") or ():
rel = obj["Key"][len(prefix):]
if rel == _BUILDER_STATE_FILE:
continue
out.append({ out.append({
"path": obj["Key"][len(prefix):], "path": rel,
"size": int(obj.get("Size") or 0), "size": int(obj.get("Size") or 0),
}) })
return json.dumps({"agent": name, "files": out}) return json.dumps({"agent": name, "files": out})
@@ -173,7 +181,10 @@ def build_tools(ctx: ToolContext) -> list[Any]:
prefix = _agent_prefix(name) prefix = _agent_prefix(name)
except ValueError as exc: except ValueError as exc:
return json.dumps({"error": str(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( s3.put_object(
Bucket=bucket, Key=key, Body=content.encode("utf-8"), Bucket=bucket, Key=key, Body=content.encode("utf-8"),
ContentType="text/plain; charset=utf-8", ContentType="text/plain; charset=utf-8",
@@ -187,7 +198,10 @@ def build_tools(ctx: ToolContext) -> list[Any]:
prefix = _agent_prefix(name) prefix = _agent_prefix(name)
except ValueError as exc: except ValueError as exc:
return json.dumps({"error": str(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: try:
obj = s3.get_object(Bucket=bucket, Key=key) obj = s3.get_object(Bucket=bucket, Key=key)
except ClientError as exc: except ClientError as exc:
@@ -246,7 +260,12 @@ def build_tools(ctx: ToolContext) -> list[Any]:
}) })
@tool @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 """Tar the agent at ``agents/<name>/`` and POST it to the control
plane's ``/v1/agents/from-tarball`` endpoint on the user's behalf, plane's ``/v1/agents/from-tarball`` endpoint on the user's behalf,
then BLOCK until the live ``.well-known/agent-card`` reports the 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 actually accepts. Mismatches cause downstream ``agent 400`` from
``call_agent``. ``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 Requires the orchestrator to have forwarded the user's CP JWT
(the platform does this automatically when this agent's Card (the platform does this automatically when this agent's Card
declared ``wants_cp_jwt=True``). 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", "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) bundle_bytes = _tarball_workspace_dir(s3, bucket, prefix)
if not bundle_bytes: if not bundle_bytes:
return json.dumps({"error": f"no files at agents/{name}/"}) 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", "entrypoint": entrypoint, "public": "true" if public else "false",
"description": "Built by agent-builder.", "description": "Built by agent-builder.",
} }
if isinstance(base_head, str) and base_head:
data["base_head_sha"] = base_head
try: try:
async with httpx.AsyncClient(timeout=60.0) as c: async with httpx.AsyncClient(timeout=60.0) as c:
r = await c.post( r = await c.post(
@@ -305,6 +348,21 @@ def build_tools(ctx: ToolContext) -> list[Any]:
name_ = body.get("name") name_ = body.get("name")
version_ = body.get("version") version_ = body.get("version")
url_ = body.get("url") 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 # CP returns immediately with status="building". The agent isn't
# actually callable until the pod rolls + serves the new card. # actually callable until the pod rolls + serves the new card.
@@ -320,6 +378,9 @@ def build_tools(ctx: ToolContext) -> list[Any]:
"version": version_, "version": version_,
"status": body.get("status"), "status": body.get("status"),
"url": url_, "url": url_,
"head_sha": head_sha,
"deployment_id": body.get("deployment_id"),
"workspace_base_head_sha": base_head,
"live": live, "live": live,
} }
if 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``.""" gzipped tarball whose entries are relative to ``prefix``."""
paginator = s3.get_paginator("list_objects_v2") paginator = s3.get_paginator("list_objects_v2")
buf = io.BytesIO() buf = io.BytesIO()
added = False
with tarfile.open(fileobj=buf, mode="w:gz") as tf: with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for page in paginator.paginate(Bucket=bucket, Prefix=prefix): for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
for obj in page.get("Contents") or (): 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):] rel = key[len(prefix):]
if not rel: if not rel:
continue continue
if rel == _BUILDER_STATE_FILE:
continue
body = s3.get_object(Bucket=bucket, Key=key)["Body"].read() body = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
info = tarfile.TarInfo(name=rel) info = tarfile.TarInfo(name=rel)
info.size = len(body) info.size = len(body)
info.mode = 0o644 info.mode = 0o644
tf.addfile(info, io.BytesIO(body)) 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: def _read_entrypoint(bundle: bytes) -> str | None:

View File

@@ -5,7 +5,10 @@ import tarfile
import unittest import unittest
from agent_builder.tools import ( from agent_builder.tools import (
_BUILDER_STATE_FILE,
_deployment_drift_error,
_render_a2a_init_template, _render_a2a_init_template,
_tarball_workspace_dir,
) )
@@ -37,6 +40,49 @@ class TemplateInitTests(unittest.TestCase):
self.assertIn("A2AAgent", files["agent.py"]) self.assertIn("A2AAgent", files["agent.py"])
def test_deployment_drift_error_blocks_stale_workspace(self) -> None:
latest = {"deploy_id": "dpl_1", "head_sha": "abc1234"}
err = _deployment_drift_error("demo-agent", latest, {}, force=False)
self.assertIsNotNone(err)
assert err is not None
self.assertEqual(err["error"], "workspace_drift")
self.assertEqual(err["current_head_sha"], "abc1234")
self.assertIsNone(err["workspace_base_head_sha"])
def test_deployment_drift_error_allows_matching_or_forced_workspace(self) -> None:
latest = {"deploy_id": "dpl_1", "head_sha": "abc1234"}
self.assertIsNone(
_deployment_drift_error(
"demo-agent",
latest,
{"repo_head_sha": "abc1234"},
force=False,
)
)
self.assertIsNone(
_deployment_drift_error(
"demo-agent",
latest,
{"repo_head_sha": "old9999"},
force=True,
)
)
def test_tarball_workspace_excludes_builder_state(self) -> None:
prefix = "agents/demo-agent/"
s3 = _FakeS3({
prefix + "agent.py": b"print('ok')\n",
prefix + _BUILDER_STATE_FILE: b'{"repo_head_sha":"abc1234"}',
})
bundle = _tarball_workspace_dir(s3, "bucket", prefix)
with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf:
self.assertEqual(tf.getnames(), ["agent.py"])
def _tarball(files: dict[str, str]) -> bytes: def _tarball(files: dict[str, str]) -> bytes:
buf = io.BytesIO() buf = io.BytesIO()
@@ -47,3 +93,31 @@ def _tarball(files: dict[str, str]) -> bytes:
info.size = len(body) info.size = len(body)
tf.addfile(info, io.BytesIO(body)) tf.addfile(info, io.BytesIO(body))
return buf.getvalue() return buf.getvalue()
class _FakeS3:
def __init__(self, objects: dict[str, bytes]) -> None:
self.objects = objects
def get_paginator(self, name: str) -> "_FakePaginator":
assert name == "list_objects_v2"
return _FakePaginator(self.objects)
def get_object(self, *, Bucket: str, Key: str) -> dict[str, io.BytesIO]:
return {"Body": io.BytesIO(self.objects[Key])}
class _FakePaginator:
def __init__(self, objects: dict[str, bytes]) -> None:
self.objects = objects
def paginate(self, *, Bucket: str, Prefix: str) -> list[dict[str, object]]:
return [
{
"Contents": [
{"Key": key, "Size": len(value)}
for key, value in sorted(self.objects.items())
if key.startswith(Prefix)
]
}
]