diff --git a/anton/core/memory/acc.py b/anton/core/memory/acc.py index ea6e36c3..d0e77ab9 100644 --- a/anton/core/memory/acc.py +++ b/anton/core/memory/acc.py @@ -88,6 +88,7 @@ from __future__ import annotations +import json import re from collections import Counter, defaultdict from dataclasses import dataclass, field @@ -235,6 +236,27 @@ class Lesson: _SEVERITY_CLIMB_PEAK = 5 +def _unwrap_envelope_message(text: str) -> str: + """Reduce a structured tool result to its human-readable message. + + Side-effecting tools return a JSON envelope (ENG-696). JSON is almost + entirely quoted runs, and the signature pass below collapses every quoted + run ≤80 chars — so every envelope failure, whatever went wrong, normalises + to one identical signature and unrelated errors read as the same error + repeating. Signing the `message` keeps the discrimination the prose had. + Non-JSON text passes through untouched. + """ + if not text.startswith("{"): + return text + try: + payload = json.loads(text) + except ValueError: + return text + if isinstance(payload, dict) and isinstance(payload.get("message"), str): + return payload["message"] + return text + + def _normalise_error_signature(text: str) -> str: """Collapse the variable parts of an error message into a stable signature so that "Refusing to save record for engine='gmail-1'" @@ -242,7 +264,7 @@ def _normalise_error_signature(text: str) -> str: same string. Cheap regex pass — paths, integers, hex, quoted short tokens all become placeholders. """ - s = text or "" + s = _unwrap_envelope_message(text or "") s = re.sub(r"0x[0-9a-fA-F]+", "0xN", s) s = re.sub(r"\b\d+\b", "N", s) s = re.sub(r"'[^']{1,80}'", "'X'", s) diff --git a/anton/core/memory/builtin_skills/build-fullstack-backend/SKILL.md b/anton/core/memory/builtin_skills/build-fullstack-backend/SKILL.md index d7ba130f..561d16ad 100644 --- a/anton/core/memory/builtin_skills/build-fullstack-backend/SKILL.md +++ b/anton/core/memory/builtin_skills/build-fullstack-backend/SKILL.md @@ -155,14 +155,14 @@ HARD CONTRACT (violating ANY of these breaks launch or deployment — full expla - NEVER hardcode an absolute URL in the source — no `fetch('http://localhost:PORT/...')`, no `fetch('https://api.example.com/...')`, no `const API_BASE = 'http://...'`. The meta tag is the ONLY place the base URL is configured. 6. LAUNCH THE BACKEND: Call the `launch_backend` tool with the artifact's slug: - - `launch_backend(slug=)` — the tool picks a free port, spawns `python backend.py --port ` as a standalone process with `` as cwd, waits for readiness, writes the port into `metadata.json`, and returns `{slug, port, pid, url, log_path}` as JSON. + - `launch_backend(slug=)` — the tool picks a free port, spawns `python backend.py --port ` as a standalone process with `` as cwd, waits for readiness, writes the port into `metadata.json`, and returns a JSON envelope: the URL in `external_url` and `{slug, port, pid, log_path}` under `details`. - Uses the scratchpad named `` — created automatically on first call. If `/requirements.txt` exists, its packages are installed into that scratchpad's venv before spawn (install output is appended to `backend.log` with a banner). An install failure aborts the launch and is returned as an error string — fix `requirements.txt` and retry. - Backend stdout/stderr stream to `/backend.log` — read it if the launch fails or the API misbehaves. - Do NOT call `update_artifact(port=...)` manually — `launch_backend` does it. - The launched process outlives the scratchpad cell and is reaped automatically when the Anton session ends. - Calling `launch_backend` again for the same slug terminates the previous process and starts a fresh one — use this for hot reloads after code changes. -7. PREVIEW THE APPLICATION: Direct the user to the `url` returned by `launch_backend` (e.g. http://127.0.0.1:54321): +7. PREVIEW THE APPLICATION: Direct the user to the `external_url` returned by `launch_backend` (e.g. http://127.0.0.1:54321): - CRITICAL: Open that URL, NOT the HTML file from disk (file://...). The backend serves the frontend at `/`, so opening the URL loads the page and its `fetch()` calls land on the same origin. - If the user opens the HTML file directly from disk, `fetch()` calls fail due to browser CORS/file:// restrictions. @@ -174,5 +174,5 @@ DEPLOYMENT NOTES: - The local backend process shuts down when the Anton CLI session ends (per MVP constraints). PUBLISH OR SHARE: -- After building, offer to preview the frontend by directing the user to the URL returned by `launch_backend` +- After building, offer to preview the frontend by directing the user to the `external_url` returned by `launch_backend` - The backend must be running for the frontend to work \ No newline at end of file diff --git a/anton/core/tools/side_effect.py b/anton/core/tools/side_effect.py new file mode 100644 index 00000000..5bed762a --- /dev/null +++ b/anton/core/tools/side_effect.py @@ -0,0 +1,79 @@ +"""Structured result contract for side-effecting tools (ENG-696). + +Side-effecting tools (publish, create/update artifact, launch backend) used to +return a human-readable string or an ad-hoc JSON dict. Neither made the +*committed state* machine-readable: a consumer (eval, monitoring, the model +itself) could not reliably tell what was committed, where it lives, or whether a +retry would duplicate it. + +`SideEffectResult` is the single envelope every such tool returns. It carries: + +- `success` — did the side effect commit. +- `resource_id` — stable identity of the committed resource (slug / report id). +- `external_url` — where the resource can be reached, if it has a URL. +- `idempotency_key` — stable key for the operation; a re-run with the same key + targets the same resource (dedup handle), so retries are recognisable. +- `committed_at` — ISO-8601 UTC instant the side effect committed; `None` when + nothing was committed (validation failure, pre-commit error). +- `content_hash` — hash of the committed content, when the tool has content. +- `details` — tool-specific machine-readable fields that don't fit the common + ones above (e.g. launch_backend's `port` / `pid` / `log_path`); `None` when + the tool has no extras. +- `message` — the human-readable line kept for the model / desktop UI. + +It serialises to a JSON string inside a `ToolOutcome` and sets `ToolOutcome.ok` +from `success`, so the ENG-1276 error streak keys on the explicit verdict. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime, timezone + +from anton.core.tools.registry import ToolOutcome + + +def now_iso() -> str: + """Current instant as an ISO-8601 UTC string (the `committed_at` basis).""" + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class SideEffectResult: + """Machine-readable outcome of a side-effecting tool call (see module doc).""" + + success: bool + message: str + resource_id: str | None = None + external_url: str | None = None + idempotency_key: str | None = None + committed_at: str | None = None + content_hash: str | None = None + details: dict | None = None + + def to_outcome(self, reason: str = "") -> ToolOutcome: + """Render to a `ToolOutcome`: JSON payload as content, verdict as `ok`.""" + payload = { + "success": self.success, + "message": self.message, + "resource_id": self.resource_id, + "external_url": self.external_url, + "idempotency_key": self.idempotency_key, + "committed_at": self.committed_at, + "content_hash": self.content_hash, + "details": self.details, + } + # `details` is a free-form per-tool dict; `default=str` keeps a stray + # non-JSON value (Path, datetime) from crashing the whole tool — which + # dispatch would otherwise surface as a spurious failure. + return ToolOutcome( + content=json.dumps(payload, indent=2, default=str), + ok=self.success, + reason=reason, + ) + + @classmethod + def failed(cls, message: str, reason: str = "") -> ToolOutcome: + """Shorthand for a non-committing failure (`committed_at` stays None).""" + return cls(success=False, message=message).to_outcome(reason=reason) diff --git a/anton/core/tools/tool_defs.py b/anton/core/tools/tool_defs.py index 05ad4b8d..cc4cd50a 100644 --- a/anton/core/tools/tool_defs.py +++ b/anton/core/tools/tool_defs.py @@ -334,8 +334,9 @@ class ToolDef: "Start an artifact's backend script as a standalone subprocess. " "Picks a free TCP port, runs the script with `--port ` " "(plus any `extra_args`), waits until the server is reachable, " - "records the port in the artifact's `metadata.json`, and returns " - "`{slug, port, pid, url, log_path}` as JSON.\n\n" + "records the port in the artifact's `metadata.json`, and returns a " + "structured result carrying the backend's `url` (as `external_url`), " + "plus `pid`/`port`/log path in the message.\n\n" "The backend MUST follow the contract in the `build-fullstack-backend` " "skill (template, `--port`, `/api/*` prefix, SECRETS). If you haven't " "recalled it this conversation, call " @@ -347,7 +348,7 @@ class ToolDef: "If `/requirements.txt` exists, its package lines are " "installed into that scratchpad's venv before spawn — install output " "appended to `backend.log`, install failures abort the launch and are " - "returned as an error string. Only simple lines are supported " + "returned as a failure result. Only simple lines are supported " "(`pkg` / `pkg==1.2`); blank lines, `#` comments, and `-`-prefixed " "flags (`-r`, `-e`, `--index-url`) are ignored.\n\n" "Idempotent: a second call with the same slug terminates the " diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index a1eff32a..bc42c9ef 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -8,6 +8,7 @@ from anton.core.backends.base import Cell from anton.core.tools.registry import ToolOutcome +from anton.core.tools.side_effect import SideEffectResult, now_iso from anton.core.utils.scratchpad import ( prepare_scratchpad_exec, format_cell_result, @@ -78,34 +79,36 @@ def _artifact_store(session: "ChatSession"): return ArtifactStore(workspace.artifacts_dir) -async def handle_create_artifact(session: "ChatSession", tc_input: dict) -> str: +async def handle_create_artifact(session: "ChatSession", tc_input: dict) -> ToolOutcome: """Create a fresh artifact folder + metadata.json + README.md. - Returns a JSON-shaped string the LLM can parse into the artifact - path. The agent is expected to write its output files under - `/...` after this call returns. + Returns a `SideEffectResult` whose `message` carries the artifact path the + agent writes output files under (`/...`) after this call returns. """ - import json - store = _artifact_store(session) if store is None: - return "Artifact store unavailable (no workspace bound to this session)." + return SideEffectResult.failed( + "Artifact store unavailable (no workspace bound to this session).", + reason="store_unavailable", + ) name = (tc_input.get("name") or "").strip() description = (tc_input.get("description") or "").strip() artifact_type = (tc_input.get("type") or "").strip() primary = tc_input.get("primary") if not name: - return "Error: `name` is required." + return SideEffectResult.failed("Error: `name` is required.", reason="missing_name") if not description: - return "Error: `description` is required." + return SideEffectResult.failed( + "Error: `description` is required.", reason="missing_description" + ) from anton.core.artifacts.models import ARTIFACT_TYPES if artifact_type not in ARTIFACT_TYPES: - return ( - f"Error: `type` must be one of {ARTIFACT_TYPES}. " - f"Got: {artifact_type!r}." + return SideEffectResult.failed( + f"Error: `type` must be one of {ARTIFACT_TYPES}. Got: {artifact_type!r}.", + reason="invalid_type", ) artifact = store.create( # type: ignore[arg-type] @@ -115,17 +118,27 @@ async def handle_create_artifact(session: "ChatSession", tc_input: dict) -> str: primary=primary if isinstance(primary, str) else None, ) folder = store.folder_for(artifact.slug) - return json.dumps({ - "id": artifact.id, - "slug": artifact.slug, - "name": artifact.name, - "type": artifact.type, - "primary": artifact.primary, - "path": str(folder), - }, indent=2) + return SideEffectResult( + success=True, + message=( + f"Created artifact `{artifact.slug}` ({artifact.type}). " + f"Write output files under: {folder}" + + (f" (primary: {artifact.primary})" if artifact.primary else "") + ), + resource_id=artifact.slug, + idempotency_key=artifact.slug, + committed_at=now_iso(), + details={ + "slug": artifact.slug, + "path": str(folder), + "name": artifact.name, + "type": artifact.type, + "primary": artifact.primary, + }, + ).to_outcome() -async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict) -> str: +async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict) -> ToolOutcome: """Update mutable metadata fields on an existing artifact. Only fields present in the input are modified. Supports: @@ -134,15 +147,16 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict - `datasources`: list of vault-connection slugs the backend reads from. `engine`, `name`, and `env_prefix` are derived from the vault. """ - import json - store = _artifact_store(session) if store is None: - return "Artifact store unavailable (no workspace bound to this session)." + return SideEffectResult.failed( + "Artifact store unavailable (no workspace bound to this session).", + reason="store_unavailable", + ) slug = (tc_input.get("slug") or "").strip() if not slug: - return "Error: `slug` is required." + return SideEffectResult.failed("Error: `slug` is required.", reason="missing_slug") kwargs: dict = {} if "primary" in tc_input: @@ -151,7 +165,7 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict try: kwargs["port"] = int(tc_input["port"]) if tc_input["port"] is not None else None except (TypeError, ValueError): - return "Error: `port` must be a number." + return SideEffectResult.failed("Error: `port` must be a number.", reason="invalid_port") if "datasources" in tc_input: from anton.core.artifacts.models import DatasourceRef @@ -159,7 +173,10 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict raw_list = tc_input.get("datasources") or [] if not isinstance(raw_list, list): - return "Error: `datasources` must be a list of slug strings." + return SideEffectResult.failed( + "Error: `datasources` must be a list of slug strings.", + reason="invalid_datasources", + ) vault = session._data_vault or LocalDataVault() known = {f"{c['engine']}-{c['name']}": (c["engine"], c["name"]) @@ -169,7 +186,10 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict unknown: list[str] = [] for item in raw_list: if not isinstance(item, str): - return "Error: each entry in `datasources` must be a slug string." + return SideEffectResult.failed( + "Error: each entry in `datasources` must be a slug string.", + reason="invalid_datasources", + ) ref_slug = item.strip() if not ref_slug: continue @@ -179,25 +199,40 @@ async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict engine, name = known[ref_slug] refs.append(DatasourceRef(engine=engine, name=name)) if unknown: - return ( + return SideEffectResult.failed( f"Error: unknown datasource slug(s): {', '.join(unknown)}. " f"Each slug must match an existing vault connection " - f"(format: `-`)." + f"(format: `-`).", + reason="unknown_datasource", ) kwargs["datasources"] = refs artifact = store.update(slug, **kwargs) if artifact is None: - return f"Error: no artifact found for slug `{slug}`." - return json.dumps({ - "slug": artifact.slug, - "primary": artifact.primary, - "port": artifact.port, - "datasources": [d.slug for d in artifact.datasources], - }, indent=2) + return SideEffectResult.failed( + f"Error: no artifact found for slug `{slug}`.", reason="artifact_not_found" + ) + datasources = [d.slug for d in artifact.datasources] + return SideEffectResult( + success=True, + message=( + f"Updated artifact `{artifact.slug}` " + f"(primary={artifact.primary}, port={artifact.port}, " + f"datasources={datasources})." + ), + resource_id=artifact.slug, + idempotency_key=artifact.slug, + committed_at=now_iso(), + details={ + "slug": artifact.slug, + "primary": artifact.primary, + "port": artifact.port, + "datasources": datasources, + }, + ).to_outcome() -async def handle_launch_backend(session: "ChatSession", tc_input: dict) -> str: +async def handle_launch_backend(session: "ChatSession", tc_input: dict) -> ToolOutcome: """Launch the artifact's backend script as a standalone subprocess. Thin wrapper over `launch_artifact_backend`: validates tool-call shape, @@ -210,20 +245,23 @@ async def handle_launch_backend(session: "ChatSession", tc_input: dict) -> str: `anton.core.artifacts.backend_launcher.launch_artifact_backend` so other entry points (e.g. cowork's auto-relaunch) can reuse it. """ - import json - from anton.core.artifacts.backend_launcher import launch_artifact_backend store = _artifact_store(session) if store is None: - return "Artifact store unavailable (no workspace bound to this session)." + return SideEffectResult.failed( + "Artifact store unavailable (no workspace bound to this session).", + reason="store_unavailable", + ) slug = (tc_input.get("slug") or "").strip() if not slug: - return "Error: `slug` is required." + return SideEffectResult.failed("Error: `slug` is required.", reason="missing_slug") artifact = store.open(slug) if artifact is None: - return f"Error: no artifact found for slug `{slug}`." + return SideEffectResult.failed( + f"Error: no artifact found for slug `{slug}`.", reason="artifact_not_found" + ) rel_path = (tc_input.get("path") or "backend.py").strip() extra_args = tc_input.get("extra_args") or [] @@ -231,7 +269,9 @@ async def handle_launch_backend(session: "ChatSession", tc_input: dict) -> str: try: health_timeout = float(tc_input.get("health_timeout", 10)) except (TypeError, ValueError): - return "Error: `health_timeout` must be a number." + return SideEffectResult.failed( + "Error: `health_timeout` must be a number.", reason="invalid_health_timeout" + ) tracked = getattr(session, "_tracked_backends", None) if tracked is None: @@ -248,14 +288,31 @@ async def handle_launch_backend(session: "ChatSession", tc_input: dict) -> str: health_path=health_path, health_timeout=health_timeout, ) + # The launcher rolls back on failure (kills the process, never tracks it), + # so a string result means nothing committed. if isinstance(result, str): - return result + return SideEffectResult.failed(result, reason="launch_failed") store.update(slug, port=result["port"]) - return json.dumps( - {k: v for k, v in result.items() if k != "proc"}, - indent=2, - ) + url = result.get("url", "") + return SideEffectResult( + success=True, + message=( + f"Backend for `{slug}` is running at {url} " + f"(pid {result.get('pid')}, port {result.get('port')}, " + f"log {result.get('log_path')})." + ), + resource_id=slug, + external_url=url or None, + idempotency_key=slug, + committed_at=now_iso(), + details={ + "slug": slug, + "port": result.get("port"), + "pid": result.get("pid"), + "log_path": result.get("log_path"), + }, + ).to_outcome() async def handle_list_artifacts(session: "ChatSession", tc_input: dict) -> str: diff --git a/tests/test_side_effect_result.py b/tests/test_side_effect_result.py new file mode 100644 index 00000000..775c4596 --- /dev/null +++ b/tests/test_side_effect_result.py @@ -0,0 +1,268 @@ +"""Contract checks for the side-effecting-tool result envelope (ENG-696).""" + +import asyncio +import json + +from anton.core.memory.acc import _normalise_error_signature +from anton.core.tools.registry import ToolOutcome +from anton.core.tools.side_effect import SideEffectResult, now_iso +from anton.core.tools.tool_handlers import ( + handle_create_artifact, + handle_launch_backend, + handle_update_artifact_metadata, +) + +REQUIRED_FIELDS = { + "success", + "resource_id", + "external_url", + "idempotency_key", + "committed_at", + "content_hash", + "details", +} + + +def test_success_outcome_carries_every_required_field(): + res = SideEffectResult( + success=True, + message="Published successfully!", + resource_id="rep-1", + external_url="https://4nton.ai/view/x", + idempotency_key="rep-1", + committed_at=now_iso(), + content_hash="md5:abc", + details={"port": 8080}, + ) + outcome = res.to_outcome() + assert isinstance(outcome, ToolOutcome) + # The verdict mirrors success so the ENG-1276 error streak keys on it. + assert outcome.ok is True + payload = json.loads(outcome.content) + assert REQUIRED_FIELDS <= payload.keys() + assert payload["success"] is True + assert payload["external_url"] == "https://4nton.ai/view/x" + assert payload["committed_at"] is not None + # Tool-specific fields ride the machine-readable `details` channel. + assert payload["details"] == {"port": 8080} + + +def test_failed_marks_no_commit(): + outcome = SideEffectResult.failed("PUBLISH FAILED: nope", reason="ValueError") + assert outcome.ok is False + assert outcome.reason == "ValueError" + payload = json.loads(outcome.content) + assert payload["success"] is False + # Nothing committed → committed_at must be null, not a spurious timestamp. + assert payload["committed_at"] is None + assert "PUBLISH FAILED: nope" in payload["message"] + + +def test_environmental_failure_is_explicit_ok_false(): + # No workspace bound → store unavailable. This is now an explicit failure + # verdict (ok=False), so it counts toward the ENG-1276 error streak and the + # circuit breaker eventually tells the model to stop hammering a store that + # will never appear. Pre-envelope this returned a plain string with none of + # the legacy marker phrases → classified ok=None → never counted. This is a + # deliberate behavior change, consistent with #308 (ENG-350's rejection was + # likewise migrated to ok=False). + outcome = asyncio.run( + handle_create_artifact(object(), {"name": "x", "description": "y", "type": "html-app"}) + ) + assert isinstance(outcome, ToolOutcome) + assert outcome.ok is False + + +class _WS: + def __init__(self, d): + self.artifacts_dir = d + + +class _Sess: + def __init__(self, d): + self._workspace = _WS(d) + self._data_vault = None + + +def test_validation_failure_is_explicit_ok_false(tmp_path): + # Missing `name` is a validation rejection — also an explicit failure now. + outcome = asyncio.run( + handle_create_artifact(_Sess(tmp_path), {"description": "y", "type": "html-app"}) + ) + assert outcome.ok is False + + +def test_create_artifact_success_serializes(tmp_path): + # Success path must serialize cleanly — `details.path` is a Path, and + # to_outcome() JSON-encodes the payload; an unserialized Path would crash + # the tool and dispatch would report a spurious failure. + outcome = asyncio.run( + handle_create_artifact( + _Sess(tmp_path), {"name": "My Art", "description": "y", "type": "html-app"} + ) + ) + assert outcome.ok is True + payload = json.loads(outcome.content) + assert isinstance(payload["details"]["path"], str) + assert payload["resource_id"] == payload["details"]["slug"] + + +def test_distinct_envelope_failures_stay_distinct(): + # The ACC dedupes failures by a normalised signature, and the normaliser + # collapses every quoted run — raw envelope JSON is almost entirely quoted + # runs, so three unrelated failures would hash to ONE signature and fire a + # false "the same error repeats, retrying won't help" lesson. + messages = [ + "Error: `name` is required.", + "Error: no artifact found for slug `foo`.", + "PUBLISH FAILED: settings module unavailable", + ] + sigs = { + _normalise_error_signature(SideEffectResult.failed(m).content[:300]) + for m in messages + } + assert len(sigs) == 3 + + +def test_error_signature_still_collapses_variable_parts(): + # Unwrapping the envelope must not cost the normaliser its actual job: + # two failures differing only in a quoted token still share a signature. + a = SideEffectResult.failed("Refusing to save record for engine='gmail-1'").content + b = SideEffectResult.failed("Refusing to save record for engine='gmail-2'").content + assert _normalise_error_signature(a) == _normalise_error_signature(b) + # Plain prose is unaffected by the unwrap. + assert _normalise_error_signature("Error: plain") == "Error: plain" + + +# --- update_artifact ------------------------------------------------------- + + +def test_update_artifact_not_found_is_failure(tmp_path): + outcome = asyncio.run( + handle_update_artifact_metadata(_Sess(tmp_path), {"slug": "nope", "primary": "a.html"}) + ) + assert outcome.ok is False + assert outcome.reason == "artifact_not_found" + assert json.loads(outcome.content)["committed_at"] is None + + +def test_update_artifact_invalid_port_is_failure(tmp_path): + outcome = asyncio.run( + handle_update_artifact_metadata(_Sess(tmp_path), {"slug": "x", "port": "not-a-number"}) + ) + assert outcome.ok is False + assert outcome.reason == "invalid_port" + + +def test_update_artifact_success_carries_identity(tmp_path): + sess = _Sess(tmp_path) + created = json.loads( + asyncio.run( + handle_create_artifact( + sess, {"name": "My Art", "description": "y", "type": "html-app"} + ) + ).content + ) + slug = created["resource_id"] + + outcome = asyncio.run( + handle_update_artifact_metadata(sess, {"slug": slug, "port": 8080}) + ) + assert outcome.ok is True + payload = json.loads(outcome.content) + assert payload["resource_id"] == slug + assert payload["idempotency_key"] == slug + assert payload["committed_at"] + assert payload["details"]["port"] == 8080 + + +# --- launch_backend -------------------------------------------------------- + + +def test_launch_backend_not_found_is_failure(tmp_path): + outcome = asyncio.run(handle_launch_backend(_Sess(tmp_path), {"slug": "nope"})) + assert outcome.ok is False + assert outcome.reason == "artifact_not_found" + assert json.loads(outcome.content)["committed_at"] is None + + +def test_launch_backend_invalid_health_timeout_is_failure(tmp_path): + # The artifact must exist — the not-found guard runs before this one. + sess = _Sess(tmp_path) + created = json.loads( + asyncio.run( + handle_create_artifact( + sess, + {"name": "App3", "description": "y", "type": "fullstack-stateless-app"}, + ) + ).content + ) + outcome = asyncio.run( + handle_launch_backend( + sess, {"slug": created["resource_id"], "health_timeout": "soon"} + ) + ) + assert outcome.ok is False + assert outcome.reason == "invalid_health_timeout" + + +def test_launch_backend_success_carries_url_and_details(tmp_path, monkeypatch): + sess = _Sess(tmp_path) + created = json.loads( + asyncio.run( + handle_create_artifact( + sess, + {"name": "App", "description": "y", "type": "fullstack-stateless-app"}, + ) + ).content + ) + slug = created["resource_id"] + sess._scratchpads = None + + async def _fake_launch(**kwargs): + return { + "slug": slug, + "port": 8123, + "pid": 4242, + "url": "http://127.0.0.1:8123", + "log_path": "/tmp/backend.log", + "proc": object(), + } + + monkeypatch.setattr( + "anton.core.artifacts.backend_launcher.launch_artifact_backend", _fake_launch + ) + outcome = asyncio.run(handle_launch_backend(sess, {"slug": slug})) + assert outcome.ok is True + payload = json.loads(outcome.content) + assert payload["external_url"] == "http://127.0.0.1:8123" + assert payload["resource_id"] == slug + assert payload["details"]["port"] == 8123 + assert payload["details"]["pid"] == 4242 + assert payload["details"]["log_path"] == "/tmp/backend.log" + + +def test_launch_backend_launcher_error_is_failure(tmp_path, monkeypatch): + sess = _Sess(tmp_path) + created = json.loads( + asyncio.run( + handle_create_artifact( + sess, + {"name": "App2", "description": "y", "type": "fullstack-stateless-app"}, + ) + ).content + ) + slug = created["resource_id"] + sess._scratchpads = None + + async def _fake_launch(**kwargs): + return "Error: backend exited early (rc=1) before binding to :8123." + + monkeypatch.setattr( + "anton.core.artifacts.backend_launcher.launch_artifact_backend", _fake_launch + ) + outcome = asyncio.run(handle_launch_backend(sess, {"slug": slug})) + assert outcome.ok is False + assert outcome.reason == "launch_failed" + # The launcher rolls back on failure, so nothing committed. + assert json.loads(outcome.content)["committed_at"] is None