diff --git a/.gitignore b/.gitignore index e3c6cf75..761b3c72 100644 --- a/.gitignore +++ b/.gitignore @@ -213,4 +213,4 @@ __marimo__/ # Anton .anton/ -.DS_Store \ No newline at end of file +.DS_Store diff --git a/anton/chat.py b/anton/chat.py index a6e34e74..5ce72fd7 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -327,6 +327,22 @@ async def _handle_connect( ) +def _is_publishable_html(html_path: Path, output_dir: Path) -> bool: + """Check if an HTML file is publishable. + + Returns False if: + - HTML is in a subdirectory that contains .py files (fullstack app) + + Returns True if: + - HTML is in the root of output/ + - HTML is in a subdirectory without any .py files + """ + if html_path.parent == output_dir: + return True + + parent_dir = html_path.parent + has_py_files = any(parent_dir.glob("*.py")) + return not has_py_files def _extract_html_title(path, re_module) -> str: @@ -447,27 +463,114 @@ async def _handle_publish( _W(_P.home()).set_secret("ANTON_MINDS_API_KEY", api_key) console.print() - # 2. Find the HTML file to publish + # 2. Find the HTML file or fullstack artifact to publish import re + from anton.core.artifacts import ArtifactStore + from anton.publisher import FULLSTACK_ARTIFACT_TYPES + # Search the new artifacts// tree (recursive — each artifact # owns its own subfolder). The legacy `.anton/output/` flat # directory is no longer scanned; users move old files into a # proper artifact subfolder if they still want them publishable. artifacts_root = Path(settings.artifacts_dir) publish_index_dir = artifacts_root # `.published.json` lives at the root + store = ArtifactStore(artifacts_root) + + def _make_candidate(path: Path) -> tuple[str, Path, str, str] | None: + """Resolve a user-supplied path to (label, target, kind, file_key). + + Returns None when the path isn't publishable. `kind` is "html" or + "fullstack"; `file_key` is the entry used in `.published.json`. + """ + if path.is_dir(): + slug = path.name + artifact = store.open(slug) + if artifact and artifact.type in FULLSTACK_ARTIFACT_TYPES: + return (artifact.name or slug, path, "fullstack", f"{slug}/") + return None + if path.is_file() and path.suffix.lower() in {".html", ".htm"}: + # If the file lives inside a fullstack artifact (e.g. + # `my-app/static/index.html`), publish the whole artifact folder + # rather than the orphaned frontend — the `.py`-based heuristic in + # `_is_publishable_html` can't see `backend.py` one level up. + try: + rel = path.relative_to(artifacts_root) + owner_slug = rel.parts[0] if len(rel.parts) > 1 else None + except ValueError: + owner_slug = None + if owner_slug: + owner = store.open(owner_slug) + if owner and owner.type in FULLSTACK_ARTIFACT_TYPES: + folder = artifacts_root / owner_slug + return (owner.name or owner_slug, folder, "fullstack", f"{owner_slug}/") + if not _is_publishable_html(path, artifacts_root): + return None + title = _extract_html_title(path, re) + try: + rel_key = path.relative_to(artifacts_root).as_posix() + except ValueError: + rel_key = path.name + return (title or path.name, path, "html", rel_key) + return None if file_arg: - target = Path(file_arg) - if not target.is_absolute(): - target = Path(settings.workspace_path) / file_arg + target_path = Path(file_arg) + if not target_path.is_absolute(): + # Resolve relative to artifacts_root first (so `/publish my-app` works + # when there's an artifact slug), then fall back to workspace_path. + candidate_root = artifacts_root / file_arg + if candidate_root.exists(): + target_path = candidate_root + else: + target_path = Path(settings.workspace_path) / file_arg + + candidate = _make_candidate(target_path) + if candidate is None: + console.print(f" [anton.warning]Not publishable: {target_path}[/]") + console.print() + return + label, target, kind, file_key = candidate else: - # Recursively list HTML files under any artifact, sorted by mtime. - html_files = sorted( - artifacts_root.rglob("*.html"), key=lambda f: f.stat().st_mtime, reverse=True - ) if artifacts_root.is_dir() else [] - if not html_files: - console.print(f" [anton.warning]No HTML files found under {artifacts_root}/[/]") + candidates: list[tuple[str, Path, str, str]] = [] + + if artifacts_root.is_dir(): + # Fullstack artifacts — one entry per artifact folder. Collect slugs + # first so the HTML scan below can skip files inside these directories. + fullstack_slugs: set[str] = set() + for child in artifacts_root.iterdir(): + if not child.is_dir(): + continue + artifact = store.open(child.name) + if artifact and artifact.type in FULLSTACK_ARTIFACT_TYPES: + fullstack_slugs.add(child.name) + candidates.append( + (artifact.name or child.name, child, "fullstack", f"{child.name}/") + ) + + # HTML reports — recursive scan, mtime-sorted. + # Skip files that live inside a fullstack artifact directory (e.g. + # static/index.html) — those are already represented by the entry above. + for f in artifacts_root.rglob("*.html"): + try: + rel = f.relative_to(artifacts_root) + if rel.parts[0] in fullstack_slugs: + continue + except ValueError: + pass + if not _is_publishable_html(f, artifacts_root): + continue + title = _extract_html_title(f, re) + rel_key = f.relative_to(artifacts_root).as_posix() + candidates.append((title or f.name, f, "html", rel_key)) + + candidates.sort( + key=lambda c: c[1].stat().st_mtime if c[1].exists() else 0, + reverse=True, + ) + + if not candidates: + console.print(f" [anton.warning]Nothing publishable under {artifacts_root}/[/]") console.print() return @@ -475,18 +578,21 @@ async def _handle_publish( offset = 0 while True: - page = html_files[offset:offset + PAGE_SIZE] - has_more = offset + PAGE_SIZE < len(html_files) + page = candidates[offset:offset + PAGE_SIZE] + has_more = offset + PAGE_SIZE < len(candidates) console.print(" [anton.cyan]Available reports:[/]") console.print() - for i, f in enumerate(page, offset + 1): - title = _extract_html_title(f, re) - label = title or f.name - console.print(f" [bold]{i}[/] {label} [anton.muted]{f.name}[/]") + for i, (lbl, path, kind, _key) in enumerate(page, offset + 1): + try: + rel_path = path.relative_to(artifacts_root).as_posix() + except ValueError: + rel_path = path.name + tag = " [anton.muted][fullstack][/]" if kind == "fullstack" else "" + console.print(f" [bold]{i}[/] {lbl}{tag} [anton.muted]{rel_path}[/]") if has_more: - console.print(f"\n [anton.muted]m Show more ({len(html_files) - offset - PAGE_SIZE} remaining)[/]") + console.print(f"\n [anton.muted]m Show more ({len(candidates) - offset - PAGE_SIZE} remaining)[/]") console.print() choice = await prompt_or_cancel(" Select", default="1") @@ -501,9 +607,9 @@ async def _handle_publish( try: idx = int(choice) - 1 - if idx < 0 or idx >= len(html_files): + if idx < 0 or idx >= len(candidates): raise ValueError - target = html_files[idx] + label, target, kind, file_key = candidates[idx] break except (ValueError, IndexError): console.print(" [anton.warning]Invalid choice.[/]") @@ -511,11 +617,19 @@ async def _handle_publish( return if not target.exists(): - console.print(f" [anton.warning]File not found: {target}[/]") + console.print(f" [anton.warning]Path not found: {target}[/]") + console.print() + return + + # HTML safety check — fullstack targets are pre-validated via metadata. + if kind == "html" and not _is_publishable_html(target, artifacts_root): + console.print(" [anton.error]Cannot publish this HTML file:[/]") + console.print(" It is in a directory with Python files (fullstack application).") + console.print(" Only standalone HTML reports can be published.") console.print() return - # 3. Check if this file was previously published + # 3. Check if this artifact was previously published published_json = publish_index_dir / ".published.json" published_map = {} try: @@ -525,7 +639,6 @@ async def _handle_publish( pass report_id = None - file_key = target.name prev = published_map.get(file_key) if prev and prev.get("report_id"): @@ -1141,7 +1254,6 @@ async def _chat_loop( # Build runtime context so the LLM knows what it's running on runtime_context = build_runtime_context(settings) - artifacts_path = f"{settings.artifacts_dir.rstrip('/')}/" from anton.chat_session import get_runtime_factory session = ChatSession(ChatSessionConfig( @@ -1153,21 +1265,13 @@ async def _chat_loop( episodic=episodic, system_prompt_context=SystemPromptContext( runtime_context=runtime_context, - # See `chat_session.create_session` for the full version - # of this prompt fragment — both call sites use the same - # artifact-flow guidance. - output_context=( - f"User-facing artifacts live under `{artifacts_path}`. " - "Before producing one, call `create_artifact(name, description, type)`; " - "the tool returns the absolute folder path you should write into. " - "To modify an existing artifact, use `list_artifacts` then `open_artifact(slug)`." - ), ), workspace=workspace, console=console, history_store=history_store, session_id=current_session_id, proactive_dashboards=settings.proactive_dashboards, + output_dir=settings.artifacts_dir, tools=[CONNECT_DATASOURCE_TOOL, PUBLISH_TOOL], web_search_enabled=settings.web_search_enabled, web_fetch_enabled=settings.web_fetch_enabled, diff --git a/anton/chat_session.py b/anton/chat_session.py index bd21061b..0f7ac11a 100644 --- a/anton/chat_session.py +++ b/anton/chat_session.py @@ -101,7 +101,6 @@ def rebuild_session( refresh_knowledge(settings, cortex) runtime_context = build_runtime_context(settings) - artifacts_path = f"{settings.artifacts_dir.rstrip('/')}/" return ChatSession(ChatSessionConfig( llm_client=state["llm_client"], runtime_factory=get_runtime_factory(settings), @@ -111,22 +110,13 @@ def rebuild_session( episodic=episodic, system_prompt_context=SystemPromptContext( runtime_context=runtime_context, - # Tell the agent where artifacts live + how to claim a folder. - # `create_artifact` returns the actual path to write into; - # `` here is just so the LLM has the - # workspace anchor in mind when picking names. - output_context=( - f"User-facing artifacts live under `{artifacts_path}`. " - "Before producing one, call `create_artifact(name, description, type)`; " - "the tool returns the absolute folder path you should write into. " - "To modify an existing artifact, use `list_artifacts` then `open_artifact(slug)`." - ), ), workspace=workspace, console=console, history_store=history_store, session_id=session_id, proactive_dashboards=settings.proactive_dashboards, + output_dir=settings.artifacts_dir, web_search_enabled=settings.web_search_enabled, web_fetch_enabled=settings.web_fetch_enabled, )) diff --git a/anton/core/artifacts/backend_launcher.py b/anton/core/artifacts/backend_launcher.py new file mode 100644 index 00000000..03735a4a --- /dev/null +++ b/anton/core/artifacts/backend_launcher.py @@ -0,0 +1,265 @@ +"""Launch a fullstack artifact's backend script as a standalone subprocess. + +Extracted from `anton/core/tools/tool_handlers.handle_launch_backend` so it +can be invoked outside of a ChatSession — notably from cowork, which +auto-relaunches backends when the user opens a preview after the Anton +session that created them has ended. + +The helper owns: requirements.txt install into the scratchpad venv, free +port discovery, subprocess spawn with PR_SET_PDEATHSIG on Linux, HTTP/TCP +readiness probe, and idempotent reaping of any previously-tracked process +for the same slug. It does NOT own: artifact metadata writes (caller +updates `metadata.json.port` if appropriate), `--port`-flag protocol on +the backend script (assumed; callers wanting a different protocol should +build their own launcher). +""" +from __future__ import annotations + +import asyncio +import os +import signal +import socket +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Protocol + + +class ScratchpadPoolLike(Protocol): + """Minimal surface the launcher needs from a scratchpad pool. + + Both `anton.core.backends.ScratchpadManager` and cowork's module-level + pool wrapper satisfy this — the launcher stays decoupled from either + concrete implementation. + """ + + async def venv_python(self, name: str) -> str | None: ... + + async def get_or_create(self, name: str) -> Any: ... + # The returned object must expose: + # async install_packages(packages: list[str]) -> str + + +async def launch_artifact_backend( + *, + slug: str, + artifact_folder: Path, + scratchpad_pool: ScratchpadPoolLike, + tracked_backends: dict[str, dict], + path: str = "backend.py", + extra_args: list[str] | None = None, + extra_env: dict[str, str] | None = None, + health_path: str = "/", + health_timeout: float = 10.0, +) -> dict | str: + """Launch the artifact's backend script in its scratchpad venv. + + Returns a dict `{slug, port, pid, url, log_path, proc}` on success + (caller is responsible for persisting `port` to artifact metadata if + needed). Returns an error string on failure — the prefix tells the + caller whether the failure is in script resolution, dependency + install, or runtime readiness. + + `tracked_backends` is a dict the caller owns; the launcher stores the + spawned `asyncio.subprocess.Process` under `slug` and reaps any + previously-tracked process for the same slug before spawning. The + caller is responsible for cleaning the dict on shutdown. + + `extra_env` is merged over the inherited `os.environ` for the spawned + process only (e.g. datasource `DS_*` secrets) — it never mutates the + parent's environment, keeping secrets scoped to the backend subprocess. + """ + extra_args = list(extra_args or []) + folder = artifact_folder + + script = (folder / path).resolve() + try: + script.relative_to(folder.resolve()) + except ValueError: + return f"Error: `path` must stay within the artifact folder ({folder})." + if not script.is_file(): + return f"Error: backend script not found at {script}." + + if not isinstance(extra_args, list) or not all(isinstance(x, str) for x in extra_args): + return "Error: `extra_args` must be a list of strings." + if not health_path.startswith("/"): + health_path = "/" + health_path + + venv_python = await scratchpad_pool.venv_python(slug) + if not venv_python: + return ( + "Error: scratchpad venv Python is not available. " + "This usually means the runtime is remote, or no scratchpad cell " + "has run yet to provision the venv." + ) + + req_path = folder / "requirements.txt" + if req_path.is_file(): + packages: list[str] = [] + for raw_line in req_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.split("#", 1)[0].strip() + if not line or line.startswith("-"): + continue + packages.append(line) + if packages: + from datetime import datetime, timezone + + pad = await scratchpad_pool.get_or_create(slug) + install_result = await pad.install_packages(packages) + banner = ( + f"\n=== requirements.txt install " + f"({datetime.now(timezone.utc).isoformat(timespec='seconds')}) ===\n" + ) + with open(folder / "backend.log", "ab", buffering=0) as install_log: + install_log.write(banner.encode("utf-8")) + install_log.write(install_result.encode("utf-8")) + install_log.write(b"\n") + if install_result.startswith("Install failed") or install_result.startswith( + "Install timed out" + ): + return ( + "Error: dependency install failed for `requirements.txt`.\n" + + install_result + ) + + # Reap any previously-tracked backend for this slug before launching + # the new one — keeps the call idempotent across hot reloads. + prev = tracked_backends.pop(slug, None) + if prev is not None: + prev_proc = prev.get("proc") + if prev_proc is not None and prev_proc.returncode is None: + try: + prev_proc.terminate() + try: + await asyncio.wait_for(prev_proc.wait(), timeout=3) + except asyncio.TimeoutError: + prev_proc.kill() + await prev_proc.wait() + except ProcessLookupError: + pass + + # Bind-and-close to discover a free port. There is a TOCTOU window + # before the backend picks it up — acceptable in single-user dev. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + + cmd = [venv_python, str(script), "--port", str(port), *extra_args] + log_path = folder / "backend.log" + log_fd = open(log_path, "ab", buffering=0) + + # PR_SET_PDEATHSIG so the backend dies with the parent on Linux. macOS + # has no equivalent; we rely on caller-side reap there. + preexec_fn = None + if sys.platform.startswith("linux"): + def _set_pdeathsig() -> None: + try: + import ctypes + + libc = ctypes.CDLL("libc.so.6", use_errno=True) + PR_SET_PDEATHSIG = 1 + libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM, 0, 0, 0) + except Exception: + pass + + preexec_fn = _set_pdeathsig + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + cwd=str(folder), + stdout=log_fd, + stderr=log_fd, + stdin=asyncio.subprocess.DEVNULL, + preexec_fn=preexec_fn, + env={**os.environ, **(extra_env or {})}, + ) + except OSError as exc: + log_fd.close() + return f"Error: failed to spawn backend: {exc}" + finally: + try: + log_fd.close() + except OSError: + pass + + # Readiness — try HTTP first, fall back to TCP-connect. HTTP 4xx + # still counts as "process is alive and answering" → ready. + loop = asyncio.get_running_loop() + deadline = loop.time() + health_timeout + ready = False + last_err: str | None = None + while loop.time() < deadline: + if proc.returncode is not None: + tail = "" + try: + tail = log_path.read_text(errors="replace")[-2000:] + except OSError: + pass + return ( + f"Error: backend exited early (rc={proc.returncode}) before " + f"binding to :{port}.\nLog tail:\n{tail}" + ) + url = f"http://127.0.0.1:{port}{health_path}" + try: + await asyncio.wait_for( + loop.run_in_executor( + None, lambda: urllib.request.urlopen(url, timeout=1).close() + ), + timeout=1.5, + ) + ready = True + break + except urllib.error.HTTPError: + ready = True + break + except Exception as exc: + last_err = str(exc) + try: + await loop.run_in_executor( + None, + lambda: socket.create_connection( + ("127.0.0.1", port), timeout=0.5 + ).close(), + ) + ready = True + break + except OSError: + await asyncio.sleep(0.2) + + if not ready: + try: + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=2) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + except ProcessLookupError: + pass + tail = "" + try: + tail = log_path.read_text(errors="replace")[-2000:] + except OSError: + pass + return ( + f"Error: backend did not become ready on :{port} within " + f"{health_timeout}s (last error: {last_err}).\nLog tail:\n{tail}" + ) + + tracked_backends[slug] = { + "proc": proc, + "port": port, + "pid": proc.pid, + "log_path": str(log_path), + } + + return { + "slug": slug, + "port": port, + "pid": proc.pid, + "url": f"http://127.0.0.1:{port}", + "log_path": str(log_path), + "proc": proc, + } diff --git a/anton/core/artifacts/models.py b/anton/core/artifacts/models.py index 96bd3146..9a38884b 100644 --- a/anton/core/artifacts/models.py +++ b/anton/core/artifacts/models.py @@ -2,13 +2,18 @@ Schema split: Server-managed (deterministic): - id, slug, createdAt, updatedAt, files[], provenance[] - Agent-supplied (validated at create_artifact time): - name, description, type + schemaVersion, id, slug, createdAt, updatedAt, files[], provenance[] + Agent-supplied (validated at create_artifact / update_artifact time): + name, description, type, primary, port, datasources[] The `Artifact` model is the on-disk source of truth — the README that sits alongside it is rendered FROM the metadata, not the other way around. + +`schemaVersion` tags the on-disk layout so future format changes can +be migrated deterministically. Bump `METADATA_SCHEMA_VERSION` whenever +the shape changes incompatibly; records written before this field +existed load as version 1 (the field default). """ from __future__ import annotations @@ -18,6 +23,11 @@ from pydantic import BaseModel, Field +# On-disk metadata.json layout version. Bump on incompatible changes +# and add a migration keyed off the loaded `schemaVersion`. +METADATA_SCHEMA_VERSION = 1 + + # Closed enum of artifact shapes. The renderer uses this to pick # the right preview affordance (iframe sandbox for html-app / # fullstack-stateless-app, "open" for documents, table preview for @@ -69,6 +79,34 @@ class TurnEntry(BaseModel): files_touched: list[str] = Field(default_factory=list) +class DatasourceRef(BaseModel): + """A data-source connection that the artifact's backend reads from. + + Declared by the agent at backend-build time so the metadata can + record which vault connections a fullstack artifact depends on. + `engine` and `name` match a `~/.anton/data_vault/-` + record and are the only stored fields. `slug` and `env_prefix` + are derived on access (not persisted): `slug` is `-`; + `env_prefix` is the `DS__` token used to namespace the + field-level env vars handed to the backend subprocess. + """ + + engine: str # e.g. "postgres" + name: str # e.g. "prod_db" + + @property + def slug(self) -> str: + """`-` — the vault connection identifier.""" + return f"{self.engine}-{self.name}" + + @property + def env_prefix(self) -> str: + """`DS__` env-var namespace (special chars sanitized).""" + from anton.core.datasources.data_vault import _slug_env_prefix + + return _slug_env_prefix(self.engine, self.name) + + class ProvenanceEntry(BaseModel): """Provenance for a single conversation that contributed to the artifact. @@ -90,6 +128,10 @@ class Artifact(BaseModel): """ # ── Server-managed identity / timestamps ───────────────────── + # On-disk layout version. Records predating this field load as 1 + # (the default); `create()` stamps the current + # `METADATA_SCHEMA_VERSION` on fresh artifacts. + schemaVersion: int = 1 id: str # short hex (uuid4().hex[:8]) — stable across folder renames slug: str # matches folder name; sanitized from `name` with collision suffix createdAt: str @@ -108,6 +150,14 @@ class Artifact(BaseModel): # most cases — they generally know the filename they're going # to write). primary: str | None = None + port: int | None = None + + # ── Agent-declared datasources (fullstack apps) ───────────── + # Connections the backend reads from at runtime. Agent-supplied + # via `update_artifact(datasources=[...])` — typically right + # after writing `backend.py`, so the metadata stays in sync with + # the env-var references in the code. + datasources: list[DatasourceRef] = Field(default_factory=list) # ── Server-managed contents ───────────────────────────────── files: list[FileEntry] = Field(default_factory=list) diff --git a/anton/core/artifacts/store.py b/anton/core/artifacts/store.py index 636a691d..dc998623 100644 --- a/anton/core/artifacts/store.py +++ b/anton/core/artifacts/store.py @@ -23,8 +23,10 @@ from pathlib import Path from anton.core.artifacts.models import ( + METADATA_SCHEMA_VERSION, Artifact, ArtifactType, + DatasourceRef, FileEntry, ProvenanceEntry, TurnEntry, @@ -58,6 +60,9 @@ def _new_id() -> str: return uuid.uuid4().hex[:8] +_UNSET = object() + + def _sanitize_slug(value: str) -> str: """Map any name to a folder-safe slug. @@ -156,6 +161,7 @@ def create( slug = self._unique_slug(slug_base) now = _utc_now() artifact = Artifact( + schemaVersion=METADATA_SCHEMA_VERSION, id=_new_id(), slug=slug, createdAt=now, @@ -172,20 +178,33 @@ def create( self._save(artifact) return artifact - def set_primary(self, slug: str, primary: str | None) -> Artifact | None: - """Update the primary-file pointer on an existing artifact. + def update( + self, + slug: str, + *, + primary: str | None = _UNSET, # type: ignore[assignment] + port: int | None = _UNSET, # type: ignore[assignment] + datasources: list[DatasourceRef] | None = _UNSET, # type: ignore[assignment] + ) -> Artifact | None: + """Update mutable agent-supplied fields on an existing artifact. - Used when the agent created with no `primary` and decided - later, or when the primary file got renamed. Pass `None` to - clear (the renderer reverts to the heuristic). Returns the - updated artifact, or None when the slug is missing. + Only fields explicitly passed are modified; omitted fields are + left unchanged. Pass `primary=None` or `primary=""` to clear + the entry-point pointer. Pass `port=None` to clear the port. + Pass `datasources=[]` to clear the datasource list. + Returns the updated artifact, or None when the slug is missing. """ artifact = self._load_silent(slug) if artifact is None: return None - artifact.primary = ( - primary.strip() if isinstance(primary, str) and primary.strip() else None - ) + if primary is not _UNSET: + artifact.primary = ( + primary.strip() if isinstance(primary, str) and primary.strip() else None + ) + if port is not _UNSET: + artifact.port = int(port) if port is not None else None + if datasources is not _UNSET: + artifact.datasources = list(datasources or []) artifact.updatedAt = _utc_now() self._save(artifact) return artifact @@ -356,6 +375,11 @@ def _render_readme_text(artifact: Artifact) -> str: size_kb = max(1, round(f.bytes / 1024)) lines.append(f"- `{f.path}` ({size_kb} KB)") lines.append("") + if artifact.datasources: + lines.append("## Data sources") + for d in artifact.datasources: + lines.append(f"- `{d.slug}` ({d.engine}) — env prefix `{d.env_prefix}`") + lines.append("") if artifact.provenance: lines.append("## Provenance") for entry in artifact.provenance: diff --git a/anton/core/backends/base.py b/anton/core/backends/base.py index c45a1147..77148f68 100644 --- a/anton/core/backends/base.py +++ b/anton/core/backends/base.py @@ -92,6 +92,16 @@ async def cleanup(self) -> None: Unlike close(), cleanup() removes persistent storage too. """ + def venv_python(self) -> str | None: + """Path to the runtime's Python interpreter, if locally accessible. + + Used by tools (e.g. launch_backend) that need to spawn auxiliary + processes sharing the scratchpad's installed packages. Returns + None for runtimes whose interpreter isn't reachable from the + host process (e.g. remote / Lightsail backends). + """ + return None + async def execute( self, code: str, diff --git a/anton/core/backends/local.py b/anton/core/backends/local.py index d3fdccf0..411d26b1 100644 --- a/anton/core/backends/local.py +++ b/anton/core/backends/local.py @@ -161,6 +161,31 @@ def _create_venv(self) -> None: bin_dir = os.path.join(self._venv_dir, "bin") self._venv_python = os.path.join(bin_dir, "python") + def venv_python(self) -> str | None: + """Public accessor for the scratchpad's Python interpreter path. + + Returns None when the venv has not been provisioned yet (i.e. + no exec has run). Auxiliary tools that want to share installed + packages call this to discover the interpreter. + """ + if self._venv_python and os.path.isfile(self._venv_python): + return self._venv_python + return None + + def ensure_venv(self) -> str | None: + """Provision the venv on disk (recycle if present, create if not) and + return its python interpreter path. + + Public counterpart to the internal `_ensure_venv` used by `start()` + and `install_packages`. Exposed for callers that need only the venv + — not the full runtime sidecar — to spawn auxiliary processes + (e.g. cowork's artifact backend relaunch). Cheap when the venv + already exists; falls back to a fresh `uv venv` / `python -m venv` + otherwise. + """ + self._ensure_venv() + return self.venv_python() + def _verify_venv_python(self) -> bool: if self._venv_python is None: return False diff --git a/anton/core/backends/manager.py b/anton/core/backends/manager.py index f1d7d7fe..3c684872 100644 --- a/anton/core/backends/manager.py +++ b/anton/core/backends/manager.py @@ -84,3 +84,14 @@ async def close_all(self) -> None: for pad in self._pads.values(): await pad.close() self._pads.clear() + + async def venv_python(self, name: str = "main") -> str | None: + """Return the Python interpreter path of the named scratchpad. + + Provisions the scratchpad on demand so callers don't have to + synchronize with whatever cell the LLM happens to be running. + Returns None when the runtime can't expose a local interpreter + (e.g. remote backends). + """ + pad = await self.get_or_create(name) + return pad.venv_python() diff --git a/anton/core/datasources/data_vault.py b/anton/core/datasources/data_vault.py index aca6a49d..c2358b21 100644 --- a/anton/core/datasources/data_vault.py +++ b/anton/core/datasources/data_vault.py @@ -305,31 +305,45 @@ def list_connections(self) -> list[dict[str, str]]: continue return results - def inject_env(self, engine: str, name: str, *, flat: bool = False) -> list[str] | None: - """Load credentials and set DS_* environment variables. + def env_for(self, engine: str, name: str, *, flat: bool = False) -> dict[str, str] | None: + """Build the DS_* env mapping for a connection WITHOUT mutating os.environ. - Default (flat=False): injects namespaced vars, e.g. DS_POSTGRES_PROD_DB__HOST. - flat=True: injects legacy flat vars, e.g. DS_HOST — use only during + Default (flat=False): namespaced vars, e.g. DS_POSTGRES_PROD_DB__HOST. + flat=True: legacy flat vars, e.g. DS_HOST — use only during single-connection test_snippet execution. - Returns the list of env var names set, or None if connection not found. + Returns the {var: value} mapping, or None if connection not found. + Use this when the env should reach only a specific subprocess (pass + the result as an explicit `env`); use `inject_env` when the variables + must be visible in the current process. """ fields = self.load(engine, name) if fields is None: return None - var_names: list[str] = [] + env: dict[str, str] = {} if flat: for key, value in fields.items(): - var = f"DS_{key.upper()}" - os.environ[var] = value - var_names.append(var) + env[f"DS_{key.upper()}"] = value else: prefix = _slug_env_prefix(engine, name) for key, value in fields.items(): - var = f"{prefix}__{key.upper()}" - os.environ[var] = value if isinstance(value, str) else str(value) - var_names.append(var) - return var_names + env[f"{prefix}__{key.upper()}"] = value if isinstance(value, str) else str(value) + return env + + def inject_env(self, engine: str, name: str, *, flat: bool = False) -> list[str] | None: + """Load credentials and set DS_* environment variables. + + Default (flat=False): injects namespaced vars, e.g. DS_POSTGRES_PROD_DB__HOST. + flat=True: injects legacy flat vars, e.g. DS_HOST — use only during + single-connection test_snippet execution. + + Returns the list of env var names set, or None if connection not found. + """ + env = self.env_for(engine, name, flat=flat) + if env is None: + return None + os.environ.update(env) + return list(env) def clear_ds_env(self) -> None: """Remove all DS_* variables from os.environ.""" diff --git a/anton/core/llm/prompt_builder.py b/anton/core/llm/prompt_builder.py index d7340fe6..9d50a80c 100644 --- a/anton/core/llm/prompt_builder.py +++ b/anton/core/llm/prompt_builder.py @@ -4,7 +4,9 @@ from typing import TYPE_CHECKING from .prompts import ( + ARTIFACTS_PROMPT, BASE_VISUALIZATIONS_PROMPT, + BACKEND_GENERATION_PROMPT, CHAT_SYSTEM_PROMPT, VISUALIZATIONS_MARKDOWN_OUTPUT_FORMAT_PROMPT, VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT, @@ -19,18 +21,15 @@ class SystemPromptContext: """Bundled prompt-injection points for the system prompt. - Four levels with increasing importance (later = stronger influence): + Three levels with increasing importance (later = stronger influence): 1. ``prefix`` — prepended before the base prompt 2. ``runtime_context`` — interpolated into the RUNTIME IDENTITY section - 3. ``output_context`` — free-text instructions on where to - store generated resources (visualizations, HTML files, data exports) - 4. ``suffix`` — appended after all other sections + 3. ``suffix`` — appended after all other sections """ runtime_context: str = "" prefix: str = "" suffix: str = "" - output_context: str = "" class ChatSystemPromptBuilder: @@ -110,7 +109,7 @@ def _build_visualizations_section( self, *, proactive_dashboards: bool, - output_context: str, + output_dir: str, ) -> str: visualizations_output_format_prompt = ( VISUALIZATIONS_HTML_OUTPUT_FORMAT_PROMPT @@ -118,7 +117,7 @@ def _build_visualizations_section( else VISUALIZATIONS_MARKDOWN_OUTPUT_FORMAT_PROMPT ) output_format = visualizations_output_format_prompt.format( - output_context=output_context, + output_dir=output_dir, ) return BASE_VISUALIZATIONS_PROMPT.format(output_format=output_format) @@ -128,6 +127,7 @@ def build( current_datetime: str, system_prompt_context: SystemPromptContext, proactive_dashboards: bool, + output_dir: str, tool_defs: list["ToolDef"] | None = None, memory_context: str = "", project_context: str = "", @@ -137,7 +137,7 @@ def build( ) -> str: visualizations_section = self._build_visualizations_section( proactive_dashboards=proactive_dashboards, - output_context=system_prompt_context.output_context, + output_dir=output_dir, ) prompt = "" @@ -148,10 +148,13 @@ def build( prompt += CHAT_SYSTEM_PROMPT.format( runtime_context=system_prompt_context.runtime_context, + artifacts_section=ARTIFACTS_PROMPT, visualizations_section=visualizations_section, current_datetime=current_datetime, ) + prompt += "\n\n" + BACKEND_GENERATION_PROMPT.format(output_dir=output_dir) + tool_prompts = self._build_tool_prompts_section(tool_defs) if tool_prompts: prompt += tool_prompts diff --git a/anton/core/llm/prompts.py b/anton/core/llm/prompts.py index 37475d04..245a48c1 100644 --- a/anton/core/llm/prompts.py +++ b/anton/core/llm/prompts.py @@ -134,7 +134,7 @@ call is synchronous. - All .anton/.env variables are available as environment variables (os.environ). - Connected data source credentials are injected as namespaced environment \ -variables in the form DS___ \ +variables in the form DS____ \ (e.g. DS_POSTGRES_PROD_DB__HOST, DS_POSTGRES_PROD_DB__PASSWORD, \ DS_HUBSPOT_MAIN__ACCESS_TOKEN). Use those variables directly in scratchpad \ code and never read ~/.anton/data_vault/ files directly. @@ -156,6 +156,8 @@ - Host Python packages are available by default. Use the scratchpad install action to \ add more — installed packages persist across resets. +{artifacts_section} + {visualizations_section} CONVERSATION DISCIPLINE (critical): @@ -208,6 +210,58 @@ Only encode genuinely reusable knowledge — not transient conversation details. """ +# --------------------------------------------------------------------------- +# Artifact contract — universal entry point for any user-facing output +# --------------------------------------------------------------------------- + +ARTIFACTS_PROMPT = """\ +ARTIFACTS (applies to all user-facing output): +Any file you create that the user is meant to open, view, download, or run \ +is an ARTIFACT. Artifacts MUST be registered with `create_artifact` BEFORE \ +any file is written. The tool claims a dedicated folder under \ +`/artifacts//`, writes `metadata.json` + `README.md` for you, \ +and returns the absolute folder path. Write ALL of the artifact's files into \ +that returned path. + +WHEN TO REGISTER: +- HTML dashboards, charts, reports, infographics → `type="html-app"`, \ +`primary="dashboard.html"` (or whichever filename you'll use). +- Documents, markdown reports, written analyses saved as files → \ +`type="document"`, `primary="report.md"` (or `.pdf`, `.docx`, …). +- Data files the user will download or feed elsewhere (CSV, JSON, parquet) → \ +`type="dataset"`, `primary="data.csv"`. +- Generated images (PNG, SVG, etc.) → `type="image"`, `primary="chart.png"`. +- Fullstack web app (backend + frontend) — the DEFAULT fullstack type: keeps \ +NO local state between requests; every request is self-contained and any \ +persistence goes to external data sources (see BACKEND & FULLSTACK section) → \ +`type="fullstack-stateless-app"`, `primary="static/index.html"`. The frontend \ +lives in a `static/` subfolder of the artifact, served by `backend.py`. +- Fullstack web app (backend + frontend) that keeps local state between \ +requests — e.g. a SQLite DB or other on-disk store the backend reads and \ +writes across requests. Use ONLY when that state genuinely cannot live in an \ +external data source; prefer stateless when in doubt (see BACKEND & FULLSTACK \ +section) → `type="fullstack-stateful-app"`, `primary="static/index.html"`. \ +The frontend lives in a `static/` subfolder of the artifact, served by \ +`backend.py`. + +WHEN NOT TO REGISTER: +- Pure chat answers, tables, or markdown rendered inline in the conversation \ +(nothing is being saved to disk for the user). +- Internal scratchpad-only files used for computation that the user never \ +opens (intermediate CSVs, cached JSON, debug logs). +- Throwaway files inside the scratchpad's own working directory. + +WORKFLOW: +1. NEW artifact: call `create_artifact(name, description, type, primary?)` \ +→ use the returned `` for every subsequent write. +2. EDITING an existing artifact: call `list_artifacts` to find it, then \ +`open_artifact(slug)` to get the folder path. Do NOT call `create_artifact` \ +again — that creates a duplicate. +3. If you discover the entry-point filename only later (or change it), call \ +`update_artifact(slug, primary=...)` so the renderer opens the right file. +""" + + # --------------------------------------------------------------------------- # Visualization prompt variants — selected by ANTON_PROACTIVE_DASHBOARDS flag # --------------------------------------------------------------------------- @@ -247,9 +301,14 @@ BUILD THE DASHBOARD — use multiple scratchpad cells, but produce ONE single self-contained HTML file: - CRITICAL: The final dashboard MUST be a single .html file with ALL data, CSS, and JS inlined. \ -Do NOT reference external local files (like data.js) — browsers block local file:// cross-references \ -for security reasons and the dashboard will silently fail to load data. +Before the first write, call `create_artifact(type="html-app", \ +name=..., description=..., primary="dashboard.html")` and use the returned \ +`` for every file you write (the HTML, any sibling data files, \ +images, etc.). All paths below referring to "the output directory" mean \ +``. The final dashboard MUST be a single .html file with ALL \ +data, CSS, and JS inlined. Do NOT reference external local files (like \ +data.js) — browsers block local file:// cross-references for security \ +reasons and the dashboard will silently fail to load data. REROUND DISCIPLINE (critical — most "round-cap exhaustion" failures we've \ seen on real dashboards come from drifting off one or more of these): @@ -324,7 +383,6 @@ Output format: - Unless the user explicitly asks for a different format, always output visualizations \ as polished, single-file HTML pages — never raw PNGs or bare image files. -{output_context} Visual design: - Make it look good by default. Use a dark theme (#0d1117 background, #e6edf3 text), \ @@ -409,13 +467,301 @@ - For large datasets, summarize the top N and offer to show more. - When the user EXPLICITLY asks for a chart, dashboard, plot, or HTML visualization, \ THEN build it as a self-contained HTML file with inlined CSS, JS, and data. \ -{output_context} +Register the artifact FIRST via `create_artifact(type="html-app", \ +primary="dashboard.html", ...)` and write into the returned `` — \ +see the ARTIFACTS section above for the full contract. \ +Fallback only if `create_artifact` is unavailable: save to `{output_dir}` \ +(create it if needed). \ Use Apache ECharts (CDN), dark theme (#0d1117), and follow standard dashboard best practices. \ If the dataset is very large (>100KB), write it to a separate .js file in the same directory. \ Never split CSS or chart logic into separate files — only large data payloads.\ """ +BACKEND_GENERATION_PROMPT = """\ +BACKEND & FULLSTACK APPLICATION GENERATION: + +When the user asks to build a backend service, web application with a backend, or \ +API-driven system, follow this workflow. It covers BOTH fullstack artifact types — \ +the steps are identical; only the LOCAL STATE rule (see RULES) differs. + +HARD CONTRACT (violating ANY of these breaks launch or deployment — full \ +explanations in the RULES of step 4): +- The backend file is `/backend.py`; the `handler` attribute \ +and the `SECRETS` dict keep exactly those names. +- `handler = Mangum(app, lifespan="off")`. +- ALL API routes live under `/api/*` and are registered BEFORE \ +`app.mount("/", StaticFiles(...))`. +- The script accepts `--port` via argparse and binds to it — never hardcode a port. +- The entire frontend lives in `/static/`, entry-point \ +`static/index.html`. +- `/requirements.txt` exists and lists at least `fastapi`, \ +`mangum`, `uvicorn`. +- Secrets are read from `SECRETS[...]` at their point of use inside routes — \ +never copied into module-level variables at import time. + +1. REGISTER THE ARTIFACT: Follow the universal artifact contract from the \ +ARTIFACTS section. For backend apps specifically: + - `type`: pick between the two fullstack types: + * `"fullstack-stateless-app"` — the DEFAULT. Always start here. The app \ +keeps NO local state between requests (the deployment target is stateless: \ +AWS Lambda with a read-only filesystem, see RULES and DEPLOYMENT NOTES below); \ +all persistence goes through external data sources. + * `"fullstack-stateful-app"` — ONLY when the app genuinely requires local \ +on-disk state between requests (e.g. a SQLite DB) AND that state cannot live \ +in an external connected data source. When in doubt, choose stateless. + - `primary`: set to `"static/index.html"` — the frontend ALWAYS lives in a \ +`static/` subfolder of the artifact (see steps 4 and 5 below). + Use the returned `` for ALL subsequent writes — `backend.py` \ +and `requirements.txt` go directly in `/`; ALL frontend files \ +(HTML, CSS, JS, images, fonts) go into `/static/`. + +2. TECHNICAL SPECIFICATION (as a system analyst): Create a brief technical specification for \ +the application. The specification MUST include: + - Brief description of what the application does (keep it concise) + - Core features and requirements + - REST API specification in markdown format with: + * Endpoints and HTTP methods + * Request/response schemas (JSON examples) + * Error handling + - Framework: ALWAYS use FastAPI. No other framework is supported here — \ + every backend MUST be FastAPI so it can be invoked both locally and as \ + an AWS Lambda function via the canonical template in step 4. + - Key dependencies and libraries needed (in addition to the mandatory \ + `fastapi`, `mangum`, `uvicorn` — see step 4) + +3. FETCH & VALIDATE SAMPLE DATA: Using the scratchpad tool: + - Fetch representative sample data from the user's data source (API, database, file) + - Get enough data to understand: structure, data types, volume, and shape + - Answer these questions: + * Is the fetched data sufficient for building the application per the spec? + * Can this data type be used to implement the API as designed? + * Do we need different/more data, or should the spec be revised? + - If the answer to any question is "no" — go back to step 2 and revise the technical \ + specification based on what you learned about the actual data + +4. IMPLEMENT BACKEND: In a scratchpad **named exactly the artifact slug** \ +(use the `slug` returned by `create_artifact` / `open_artifact` as the scratchpad \ +name), implement the backend code. `launch_backend` runs the backend in this same \ +scratchpad's venv, so any packages you install or imports you test here will be \ +present at launch. + + CANONICAL TEMPLATE (use this skeleton verbatim, add your routes inside the \ +`# === API routes ===` block). It runs unchanged both locally \ +(`python backend.py --port=NNN`) and on AWS Lambda (handler = `backend.handler`): + + ```python + import argparse + import os + from pathlib import Path + from fastapi import FastAPI + from fastapi.middleware.cors import CORSMiddleware + from fastapi.staticfiles import StaticFiles + from mangum import Mangum + + app = FastAPI() + + # CORS — frontend may be served from a different origin (e.g. CloudFront/S3 + # in front of the Lambda). Tighten `allow_origins` in production. + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + + # === Secrets === + # Keys are the canonical DS____ env-var names. Locally + # each value comes from os.environ (the data vault injected it into Anton's + # env, which `launch_backend` inherits). In the cloud, the shared runner + # overlays the decrypted values onto this dict before each request. Leave + # SECRETS empty if the backend uses none. READ a secret by key AT ITS POINT + # OF USE (inside the route) — never copy a SECRETS value into a module-level + # variable at import time. + SECRETS = {{ + # "DS_POSTGRES_PROD_DB__PASSWORD": os.environ.get("DS_POSTGRES_PROD_DB__PASSWORD"), + }} + + # === API routes === + @app.get("/api/hello") + async def hello(): + # Example secret use (read at point of use, not at import): + # pw = SECRETS["DS_POSTGRES_PROD_DB__PASSWORD"] + return {{"hello": "world"}} + + # Static mount MUST come AFTER all API routes (mount at "/" catches every + # remaining path). Used for local preview; in Lambda, statics are served + # by an external service (CloudFront/S3), so this mount is harmless there. + STATIC_DIR = Path(__file__).parent / "static" + if STATIC_DIR.exists(): + app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static") + + # CLOUD entry-point. lifespan="off" is REQUIRED — there is no + # long-lived process for FastAPI startup/shutdown. + # (Locally, `uvicorn.run(app, ...)` below serves the app directly.) + handler = Mangum(app, lifespan="off") + + if __name__ == "__main__": + import uvicorn + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, required=True) + args = parser.parse_args() + uvicorn.run(app, host="127.0.0.1", port=args.port) + ``` + + RULES (critical): + - Save the file as `/backend.py` — the filename, the \ +`handler` attribute, and the `SECRETS` dict are load-bearing (the cloud \ +runner overlays secrets onto `backend.SECRETS` and invokes `backend.handler`). \ +Do NOT rename any of them. + - Keep `Mangum(app, lifespan="off")`. Without `lifespan="off"` Mangum \ +warns and may fail cold start. + - SECRETS: expose `SECRETS` as a module-level dict, keyed by the canonical \ +`DS____` name, with each entry initialized from \ +`os.environ.get(...)` (the local default). The cloud runner overlays the \ +decrypted values onto this same dict before each request. Read a secret AT \ +ITS POINT OF USE — `SECRETS["DS_..."]` inside the route — and NEVER hoist it \ +into a module-level variable at import time: the import runs before the \ +overlay, so the cloud value would be missed. If a credential-backed resource \ +(DB pool, API client) is needed, build it LAZILY on first request, never at \ +module level. + - ALL API endpoints MUST live under the `/api/*` path prefix (e.g. \ +`/api/items`, `/api/users/{{user_id}}`, `/api/search`). This is a hard \ +contract between backend and frontend: it separates API traffic from the \ +static mount at `/`, and lets edge routing (CloudFront behaviors, API \ +Gateway path-based routing) split frontend vs backend traffic by prefix \ +in production. NEVER expose routes at the root (e.g. `/items`, `/login`) — \ +they will collide with the static mount and break in deployment. + - API routes MUST be registered BEFORE `app.mount("/", StaticFiles(...))`. \ +FastAPI matches in registration order — a mount at `/` swallows everything \ +after it. + - The backend MUST accept `--port` via argparse and bind to that port. \ +NEVER hardcode the port — `launch_backend` picks a free one and passes it in. + - Prefer `async def` for I/O-bound routes (DB queries, external HTTP \ +calls via `httpx.AsyncClient`). Sync `def` is fine for trivial CPU work, but \ +sync blocking I/O inside an async app stalls the event loop. + - LOCAL STATE (the ONE rule that differs between the two fullstack types): + * `fullstack-stateless-app`: no local state of any kind survives a \ +request. No module-level mutable caches that matter across requests \ +(`USERS = {{}}`, `SESSIONS = []`) — in Lambda these globals may or may not \ +survive between invocations, never rely on them. Treat the filesystem as \ +read-only and non-persistent: anything written is lost between requests and \ +may fail outright depending on the host (Linux, Windows, or a read-only cloud \ +sandbox). NEVER write to `` at runtime, and never rely on a \ +file surviving to a later request. If a request genuinely needs scratch \ +space, use the OS temp dir via `tempfile` and treat it as ephemeral (gone \ +the moment the request ends). ALL persistence goes through external data \ +sources. + * `fullstack-stateful-app`: local on-disk state (e.g. a SQLite file) IS \ +allowed — keep it in the artifact root (`/`, next to \ +`backend.py`). Every other rule in this list still applies. + - LOGGING: `print()` and `logging.getLogger(__name__).info(...)` both go \ +to CloudWatch in Lambda and to `backend.log` locally — no extra setup needed. + - REQUIREMENTS: always save a `/requirements.txt` with at \ +minimum: + ``` + fastapi + mangum + uvicorn + ``` + Add any other libraries the backend imports (one per line: `pkg` or \ +`pkg==1.2`). `launch_backend` reads this file and installs everything into \ +the slug-named scratchpad's venv before spawning the process. Only simple \ +lines are supported — `-r`, `-e`, `--index-url`, blank lines and `#` \ +comments are ignored. + - Do NOT start the server inside the scratchpad — use `launch_backend` in step 6. + - DECLARE DATASOURCES: if `backend.py` reads any `DS____` \ +env var, call `update_artifact(slug=, datasources=[...])` immediately \ +after writing the file. Pass a flat list of connection slugs (e.g. \ +`["postgres-prod_db", "hubspot-main"]`); each slug MUST match a connection \ +from the `Connected Data Sources` section of this prompt. This records the \ +deployable's credential dependencies in `metadata.json` so the artifact can \ +be redeployed with the right env vars later. Skip this call only when the \ +backend uses no `DS_*` vars at all. + +5. BUILD FRONTEND (if needed): In a separate scratchpad: + - Build a single-file HTML dashboard or web interface + - Include all CSS and JS inlined (no external file references) + - Apply the HTML build guidance from the `VISUALIZATIONS` section above \ +(single self-contained HTML file; Apache ECharts via CDN for charts; dark \ +theme #0d1117; responsive layout with a viewport meta tag). If that section \ +is not present in this prompt, follow these same defaults regardless. + - Save the entry-point to `/static/index.html` (create the \ +`static/` subfolder if needed). ANY additional frontend assets (separate CSS, \ +JS, images, fonts, large data .js payloads) MUST also live under \ +`/static/` — never at the artifact root, since the backend only \ +serves files from `static/`. + - All backend endpoints MUST be called under the `/api/*` prefix (matches \ +the backend route convention from step 4). The frontend never calls bare \ +paths like `/items` — always `/api/items`. + - API base URL is supplied via a `` tag so the same HTML works \ +locally AND when deployed with frontend and backend on different origins \ +(e.g. CloudFront/S3 + API Gateway/Lambda). Include this line in ``: + ```html + + ``` + Empty `content` is the local default — fetch falls back to a relative \ +path and hits the same FastAPI process that serves the page. At deploy \ +time the publisher rewrites `content=""` to the real API root \ +(e.g. `content="https://abc123.execute-api.us-east-1.amazonaws.com"`). + - Read the meta tag once at startup and prepend it to every API call. \ +Use this exact pattern (or an equivalent helper) — do NOT scatter \ +`document.querySelector` calls across the codebase: + ```js + const API_BASE = document.querySelector('meta[name="api-base"]')?.content || ""; + const api = (path) => `${{API_BASE}}${{path}}`; + // usage: fetch(api('/api/items')) + ``` + - 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. + - 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): + - 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. + +DEPLOYMENT NOTES: +- Same `backend.py` runs in two modes: + - LOCAL: `python backend.py --port=NNN` (used by `launch_backend`). \ +uvicorn serves the FastAPI app and the `static/` mount, frontend reachable at `/`. \ +Secrets come from the `DS_*` env vars in `SECRETS`' defaults. + - CLOUD: a shared runner overlays the decrypted secrets onto `backend.SECRETS` \ +and invokes `backend.handler` (the Mangum ASGI app) per request. Statics are \ +served separately (the gateway reads `static/` from object storage), so the \ +`StaticFiles` mount sits unused there — the runner only sees `/api/*` traffic. +- Secrets ride in the backend module's `SECRETS` dict, not `os.environ` — the \ +shared cloud runner injects them per request without polluting the process env. +- 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` +- The backend must be running for the frontend to work +""" + CONSOLIDATION_PROMPT = """\ You are a memory consolidation system for an AI coding assistant. diff --git a/anton/core/runtime.py b/anton/core/runtime.py index f840d860..5f10f510 100644 --- a/anton/core/runtime.py +++ b/anton/core/runtime.py @@ -62,7 +62,6 @@ async def build_chat_session( model: Optional[str] = None, extra_tools: Optional[Sequence[Any]] = None, system_prompt_suffix: Optional[str] = None, - output_context: Optional[str] = None, ): """Build a ChatSession scoped to one workspace. @@ -83,9 +82,6 @@ async def build_chat_session( system_prompt_suffix Free-form text appended to the system prompt. Hosts use this to nudge tone or describe their UI affordances. None → no suffix. - output_context - Override for the per-session output-folder hint. None → use the default template - pointing at `settings.artifacts_dir`. Returns ------- @@ -144,11 +140,6 @@ async def build_chat_session( history_store = HistoryStore(episodes_dir) initial_history = history_store.load(session_id) - resolved_output_context = output_context or ( - f"Save generated files and dashboards to `{output_dir}`. " - "When you create a user-facing HTML dashboard or report, save it there." - ) - data_vault = LocalDataVault() if LocalDataVault is not None else None google_drive_oauth_connected = False if data_vault is not None: @@ -186,8 +177,8 @@ async def build_chat_session( system_prompt_context=SystemPromptContext( runtime_context=build_runtime_context(settings), suffix=final_suffix, - output_context=resolved_output_context, ), + output_dir=str(output_dir), workspace=workspace, data_vault=data_vault, initial_history=initial_history, diff --git a/anton/core/session.py b/anton/core/session.py index 6a352df6..58abf956 100644 --- a/anton/core/session.py +++ b/anton/core/session.py @@ -38,13 +38,14 @@ from anton.core.tools.registry import ToolRegistry from anton.core.tools.tool_defs import ( CREATE_ARTIFACT_TOOL, + LAUNCH_BACKEND_TOOL, LIST_ARTIFACTS_TOOL, MEMORIZE_TOOL, OPEN_ARTIFACT_TOOL, READ_IMAGE_TOOL, RECALL_TOOL, SCRATCHPAD_TOOL, - SET_ARTIFACT_PRIMARY_TOOL, + UPDATE_ARTIFACT_METADATA_TOOL, ToolDef, ) from anton.core.utils.scratchpad import prepare_scratchpad_exec, format_cell_result @@ -112,6 +113,7 @@ class ChatSessionConfig: harness: str | None = None proactive_dashboards: bool = False tools: list[ToolDef] = field(default_factory=list) + output_dir: str = ".anton/output" # Web tools — on by default. Each is independently resolved at session # construction into either a native provider capability (passed to the LLM # via ``native_web_tools``) or a handler-dispatched fallback ToolDef @@ -141,6 +143,7 @@ def __init__(self, config: ChatSessionConfig) -> None: self._cortex = config.cortex self._episodic = config.episodic self._system_prompt_context = config.system_prompt_context + self._output_dir = config.output_dir self._proactive_dashboards = config.proactive_dashboards self._extra_tools = config.tools self._workspace = config.workspace @@ -250,6 +253,11 @@ def _acc_has_similar(rule: str) -> bool: # at the start of each turn. Prevents double-summarization when # the post-recovery response still reports high pressure. self._compacted_this_turn = False + # Backends launched via the launch_backend tool. Keyed by + # artifact slug; each entry holds the asyncio.subprocess.Process + # plus its port. Reaped in close() so backend processes don't + # outlive the chat session. + self._tracked_backends: dict[str, dict] = {} # Resolve web tool routing once per session. ``_native_web_tools`` is # the set the planning provider will execute server-side (passed @@ -557,6 +565,7 @@ async def _build_system_prompt(self, user_message: str = "") -> str: current_datetime=_current_datetime, system_prompt_context=self._system_prompt_context, proactive_dashboards=self._proactive_dashboards, + output_dir=self._output_dir, tool_defs=self.tool_registry.get_tool_defs(), memory_context=memory_section, project_context=md_context, @@ -679,12 +688,35 @@ def _build_core_tools(self) -> None: self.tool_registry.register_tool(CREATE_ARTIFACT_TOOL) self.tool_registry.register_tool(LIST_ARTIFACTS_TOOL) self.tool_registry.register_tool(OPEN_ARTIFACT_TOOL) - self.tool_registry.register_tool(SET_ARTIFACT_PRIMARY_TOOL) + self.tool_registry.register_tool(UPDATE_ARTIFACT_METADATA_TOOL) + self.tool_registry.register_tool(LAUNCH_BACKEND_TOOL) async def close(self) -> None: """Clean up scratchpads and other resources.""" + await self._reap_tracked_backends() await self._scratchpads.close_all() + async def _reap_tracked_backends(self) -> None: + """Terminate every backend launched via launch_backend. + + SIGTERM first, then SIGKILL after a short grace period. Errors + are swallowed — close() must not raise on shutdown. + """ + for slug, info in list(self._tracked_backends.items()): + proc = info.get("proc") + if proc is None or proc.returncode is not None: + continue + try: + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=3) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + except (ProcessLookupError, OSError): + pass + self._tracked_backends.clear() + async def _summarize_history(self) -> None: """Compress old conversation turns into a summary using the coding model. diff --git a/anton/core/tools/tool_defs.py b/anton/core/tools/tool_defs.py index 7c3c617a..2b0b182c 100644 --- a/anton/core/tools/tool_defs.py +++ b/anton/core/tools/tool_defs.py @@ -1,12 +1,13 @@ from anton.core.tools.tool_handlers import ( handle_create_artifact, + handle_launch_backend, handle_list_artifacts, handle_memorize, handle_open_artifact, handle_read_image, handle_recall, handle_scratchpad, - handle_set_artifact_primary, + handle_update_artifact_metadata, ) from dataclasses import dataclass @@ -161,14 +162,18 @@ class ToolDef: "- dataset: data files (CSV, JSON, parquet) the user downloads or feeds elsewhere\n" "- image: a generated image (PNG, SVG, etc.)\n" "- mixed: multi-modal output that doesn't fit the above\n" - "- fullstack-stateless-app: HTML + JS + CSS that runs without a server\n" - "- fullstack-stateful-app: needs a backend process to serve\n\n" + "- fullstack-stateless-app: fullstack web app (backend + frontend) that keeps " + "no local state between requests; all persistence goes to external data sources. " + "DEFAULT for fullstack apps\n" + "- fullstack-stateful-app: fullstack web app (backend + frontend) that keeps " + "local state between requests (e.g. an on-disk SQLite DB). Use ONLY when that " + "state truly cannot live in an external data source; prefer stateless when in doubt\n\n" "Pass `primary` (optional) when you already know the entry-point " "filename you'll write — e.g. `\"dashboard.html\"` for an html-app, " - "`\"index.html\"` for a fullstack app, `\"report.pdf\"` for a " + "`\"static/index.html\"` for a fullstack app, `\"report.pdf\"` for a " "document. The renderer uses it to decide what to open by default. " "Skip when you don't know yet — the renderer falls back to a " - "heuristic, and you can set it later via `set_artifact_primary`.\n\n" + "heuristic, and you can set it later via `update_artifact`.\n\n" "To MODIFY an existing artifact instead of creating a new one, call " "`list_artifacts` first to find it, then `open_artifact(slug)` to get " "the path." @@ -207,15 +212,24 @@ class ToolDef: ) -SET_ARTIFACT_PRIMARY_TOOL = ToolDef( - name="set_artifact_primary", +UPDATE_ARTIFACT_METADATA_TOOL = ToolDef( + name="update_artifact", description=( - "Update the primary-file pointer on an existing artifact. Call this " - "when you created the artifact without a `primary` and now know what " - "it should be, or when the entry-point file's name changed. Pass an " - "empty string or omit `primary` to clear (the renderer reverts to " - "its heuristic — `index.html` → newest `.html` → newest non-" - "housekeeping file)." + "Update mutable fields on an existing artifact. Pass only the fields you want to change.\n\n" + "- `primary`: relative path of the entry-point file (e.g. \"index.html\"). " + "Pass empty string to clear (renderer reverts to heuristic: " + "`index.html` → newest `.html` → newest non-housekeeping file).\n" + "- `port`: port the backend process is listening on (fullstack apps only). " + "Normally written automatically by `launch_backend` — set manually only " + "if you started the server some other way.\n" + "- `datasources`: list of vault-connection slugs the artifact's backend " + "reads from (e.g. `[\"postgres-prod_db\", \"hubspot-main\"]`). REQUIRED " + "for fullstack apps whose `backend.py` references any " + "`DS____` env var — declare it right after writing " + "`backend.py` so metadata.json " + "captures which connections the deployable depends on. Slugs must match " + "existing vault connections (see `Connected Data Sources` in the system " + "prompt). Pass `[]` to clear." ), input_schema={ "type": "object", @@ -226,12 +240,28 @@ class ToolDef: }, "primary": { "type": "string", - "description": "Relative path of the new entry-point file. Empty string to clear.", + "description": "Relative path of the entry-point file. Empty string to clear.", + }, + "port": { + "type": "integer", + "description": "Port number the backend process is listening on.", + }, + "datasources": { + "type": "array", + "description": ( + "Vault-connection slugs the backend reads from. Replaces " + "the existing list — pass the full set every time. Use " + "`[]` to clear." + ), + "items": { + "type": "string", + "description": "Connection slug, e.g. \"postgres-prod_db\".", + }, }, }, "required": ["slug"], }, - handler=handle_set_artifact_primary, + handler=handle_update_artifact_metadata, ) @@ -276,6 +306,62 @@ class ToolDef: ) +LAUNCH_BACKEND_TOOL = ToolDef( + name="launch_backend", + description=( + "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" + "The spawned process inherits Anton's environment, including the " + "`DS____` variables of connected data sources.\n\n" + "Runs in a scratchpad named exactly `` (created on first call). " + "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 " + "(`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 " + "previously-launched backend before starting a new one.\n\n" + "Requirements on the backend script:\n" + "- MUST accept `--port` via argparse (or equivalent) and bind to it.\n" + "- MUST be reachable at `health_path` (default `/`) within " + "`health_timeout` seconds.\n" + "- stdout/stderr stream to `/backend.log`." + ), + input_schema={ + "type": "object", + "properties": { + "slug": { + "type": "string", + "description": "Folder slug of the artifact whose backend to launch.", + }, + "path": { + "type": "string", + "description": "Backend script path relative to the artifact folder. Default: \"backend.py\".", + }, + "extra_args": { + "type": "array", + "items": {"type": "string"}, + "description": "Additional CLI arguments appended after `--port `.", + }, + "health_path": { + "type": "string", + "description": "URL path for the readiness probe. Default: \"/\". Any HTTP response (including 4xx) counts as ready.", + }, + "health_timeout": { + "type": "number", + "description": "Seconds to wait for readiness before failing. Default: 10.", + }, + }, + "required": ["slug"], + }, + handler=handle_launch_backend, +) + + RECALL_TOOL = ToolDef( name="recall", description=( diff --git a/anton/core/tools/tool_handlers.py b/anton/core/tools/tool_handlers.py index e8c75e18..c23ca94e 100644 --- a/anton/core/tools/tool_handlers.py +++ b/anton/core/tools/tool_handlers.py @@ -117,13 +117,14 @@ async def handle_create_artifact(session: "ChatSession", tc_input: dict) -> str: }, indent=2) -async def handle_set_artifact_primary(session: "ChatSession", tc_input: dict) -> str: - """Update or clear the primary-file pointer on an existing artifact. - - The agent calls this when it created an artifact without a - primary and now knows what it should be, or when the primary - file's name changed. Pass `primary: null` to clear and revert - the renderer to its heuristic. +async def handle_update_artifact_metadata(session: "ChatSession", tc_input: dict) -> str: + """Update mutable metadata fields on an existing artifact. + + Only fields present in the input are modified. Supports: + - `primary`: entry-point file path (empty string to clear) + - `port`: backend port number (fullstack apps only) + - `datasources`: list of vault-connection slugs the backend reads from. + `engine`, `name`, and `env_prefix` are derived from the vault. """ import json @@ -134,17 +135,121 @@ async def handle_set_artifact_primary(session: "ChatSession", tc_input: dict) -> slug = (tc_input.get("slug") or "").strip() if not slug: return "Error: `slug` is required." - raw = tc_input.get("primary") - primary = raw if isinstance(raw, str) else None - artifact = store.set_primary(slug, primary) + + kwargs: dict = {} + if "primary" in tc_input: + kwargs["primary"] = tc_input["primary"] + if "port" in tc_input: + 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." + + if "datasources" in tc_input: + from anton.core.artifacts.models import DatasourceRef + from anton.core.datasources.data_vault import LocalDataVault + + raw_list = tc_input.get("datasources") or [] + if not isinstance(raw_list, list): + return "Error: `datasources` must be a list of slug strings." + + vault = session._data_vault or LocalDataVault() + known = {f"{c['engine']}-{c['name']}": (c["engine"], c["name"]) + for c in vault.list_connections()} + + refs: list[DatasourceRef] = [] + unknown: list[str] = [] + for item in raw_list: + if not isinstance(item, str): + return "Error: each entry in `datasources` must be a slug string." + ref_slug = item.strip() + if not ref_slug: + continue + if ref_slug not in known: + unknown.append(ref_slug) + continue + engine, name = known[ref_slug] + refs.append(DatasourceRef(engine=engine, name=name)) + if unknown: + return ( + f"Error: unknown datasource slug(s): {', '.join(unknown)}. " + f"Each slug must match an existing vault connection " + f"(format: `-`)." + ) + 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) +async def handle_launch_backend(session: "ChatSession", tc_input: dict) -> str: + """Launch the artifact's backend script as a standalone subprocess. + + Thin wrapper over `launch_artifact_backend`: validates tool-call shape, + resolves the artifact folder via the session's ArtifactStore, hands + the session's scratchpad pool + tracked-backends dict to the helper, + then persists the discovered port into metadata.json. + + The actual subprocess lifecycle (free-port discovery, dependency + install, health probe, idempotent reap) lives in + `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)." + + slug = (tc_input.get("slug") or "").strip() + if not slug: + return "Error: `slug` is required." + artifact = store.open(slug) + if artifact is None: + return f"Error: no artifact found for slug `{slug}`." + + rel_path = (tc_input.get("path") or "backend.py").strip() + extra_args = tc_input.get("extra_args") or [] + health_path = tc_input.get("health_path") or "/" + try: + health_timeout = float(tc_input.get("health_timeout", 10)) + except (TypeError, ValueError): + return "Error: `health_timeout` must be a number." + + tracked = getattr(session, "_tracked_backends", None) + if tracked is None: + tracked = {} + session._tracked_backends = tracked + + result = await launch_artifact_backend( + slug=slug, + artifact_folder=store.folder_for(slug), + scratchpad_pool=session._scratchpads, + tracked_backends=tracked, + path=rel_path, + extra_args=extra_args, + health_path=health_path, + health_timeout=health_timeout, + ) + if isinstance(result, str): + return result + + store.update(slug, port=result["port"]) + return json.dumps( + {k: v for k, v in result.items() if k != "proc"}, + indent=2, + ) + + async def handle_list_artifacts(session: "ChatSession", tc_input: dict) -> str: """List every artifact in the workspace, newest first. diff --git a/anton/publisher.py b/anton/publisher.py index df012fc0..3c1cafeb 100644 --- a/anton/publisher.py +++ b/anton/publisher.py @@ -2,16 +2,19 @@ from __future__ import annotations -import base64 -import hashlib import io -import json import os import re +import sys +import json +import base64 +import hashlib import secrets import zipfile from pathlib import Path +from anton.core.artifacts.models import Artifact +from anton.core.datasources.data_vault import LocalDataVault from anton.minds_client import minds_request from anton.utils.datasources import scrub_credentials @@ -23,7 +26,13 @@ ) # File extensions treated as text and subject to credential scrubbing. -_TEXT_EXTENSIONS = {".html", ".htm", ".js", ".css"} +_TEXT_EXTENSIONS = {".html", ".htm", ".js", ".css", ".py", ".txt"} + +# Artifact types that ship as a fullstack bundle (backend + static/ + secrets). +FULLSTACK_ARTIFACT_TYPES = frozenset({"fullstack-stateful-app", "fullstack-stateless-app"}) + +# Filenames inside an artifact folder that are housekeeping — never bundled. +_FULLSTACK_EXCLUDED = {"metadata.json", "README.md", "backend.log", ".published.json"} DEFAULT_PUBLISH_URL = "https://4nton.ai" @@ -122,6 +131,73 @@ def _zip_html(path: Path) -> bytes: return buf.getvalue() +def _load_artifact_metadata(artifact_dir: Path) -> Artifact | None: + """Return the parsed Artifact for a directory, or None when no/invalid metadata.""" + meta_path = artifact_dir / "metadata.json" + if not meta_path.is_file(): + return None + try: + return Artifact.model_validate(json.loads(meta_path.read_text(encoding="utf-8"))) + except Exception: + return None + + +def _zip_fullstack(artifact_dir: Path) -> tuple[bytes, list[str]]: + """Bundle backend.py + static/ + requirements.txt into a zip. + + Returns (zip_bytes, included_arcnames). Text files are scrubbed. + Housekeeping files (metadata.json, README.md, backend.log) are excluded. + """ + buf = io.BytesIO() + included: list[str] = [] + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + backend = artifact_dir / "backend.py" + if backend.is_file(): + _write_scrubbed(zf, backend, "backend.py") + included.append("backend.py") + + reqs = artifact_dir / "requirements.txt" + if reqs.is_file(): + _write_scrubbed(zf, reqs, "requirements.txt") + included.append("requirements.txt") + + static_dir = artifact_dir / "static" + if static_dir.is_dir(): + for f in sorted(static_dir.rglob("*")): + if not f.is_file(): + continue + arc_name = f"static/{f.relative_to(static_dir).as_posix()}" + if Path(arc_name).name in _FULLSTACK_EXCLUDED: + continue + _write_scrubbed(zf, f, arc_name) + included.append(arc_name) + return buf.getvalue(), included + + +def _collect_datasource_secrets( + artifact: Artifact, +) -> tuple[dict[str, str], list[str]]: + """Resolve DS_*__FIELD secrets for an artifact's declared datasources. + + Returns (secrets, missing) where `missing` is the list of slugs whose + vault entry could not be loaded. Caller decides how to surface that. + """ + vault = LocalDataVault() + secrets: dict[str, str] = {} + missing: list[str] = [] + for ref in artifact.datasources: + fields = vault.load(ref.engine, ref.name) + if not fields: + missing.append(ref.slug) + continue + for key, value in fields.items(): + if value is None: + continue + env_name = f"{ref.env_prefix}__{key.upper()}" + secrets[env_name] = str(value) + return secrets, missing + + def publish( file_path: Path, *, @@ -132,7 +208,12 @@ def publish( password: str | None = None, pwd_version: int = 1, ) -> dict: - """Zip and upload an HTML file/directory. Returns the upload response dict. + """Zip and upload an HTML file/directory or a fullstack artifact directory. + + For fullstack artifacts (metadata.json with type ∈ FULLSTACK_ARTIFACT_TYPES) + bundles backend.py + static/ + requirements.txt and resolves DS_*__FIELD + secrets from the local data vault for each declared datasource. Secrets + travel in the JSON body alongside the zip, not inside it. Args: report_id: If provided, updates an existing report (new version). @@ -144,13 +225,27 @@ def publish( pwd_version: Monotonic version bumped whenever the password changes, so previously issued access cookies invalidate. - Response keys: user_prefix, report_id, md5, view_url, version, files + Response keys (HTML path): user_prefix, report_id, md5, view_url, version, files """ if not file_path.exists(): raise FileNotFoundError(f"Path not found: {file_path}") - zipped = _zip_html(file_path) - payload_dict: dict = {"file_payload": base64.b64encode(zipped).decode()} + payload_dict: dict = {} + + artifact = _load_artifact_metadata(file_path) if file_path.is_dir() else None + if artifact is not None and artifact.type in FULLSTACK_ARTIFACT_TYPES: + zipped, _included = _zip_fullstack(file_path) + secrets, missing = _collect_datasource_secrets(artifact) + payload_dict["artifact_type"] = artifact.type + payload_dict["artifact_id"] = artifact.id + payload_dict["secrets"] = secrets + payload_dict["python_version"] = f"{sys.version_info.major}.{sys.version_info.minor}" + if missing: + payload_dict["missing_datasources"] = missing + else: + zipped = _zip_html(file_path) + + payload_dict["file_payload"] = base64.b64encode(zipped).decode() if report_id: payload_dict["report_id"] = report_id # Access control: send the hash (never the plaintext). Omitting the diff --git a/anton/utils/datasources.py b/anton/utils/datasources.py index be02789b..01e6364e 100644 --- a/anton/utils/datasources.py +++ b/anton/utils/datasources.py @@ -93,7 +93,7 @@ def scrub_credentials(text: str) -> str: value = os.environ.get(key, "") if not value: continue - text = text.replace(value, f"[{key}]") + text = re.sub(r'(? str: current_datetime="2026-04-10T12:00:00+00:00", system_prompt_context=SystemPromptContext(runtime_context="test runtime"), proactive_dashboards=False, + output_dir="", ) defaults.update(overrides) return builder.build(**defaults) diff --git a/tests/test_publish_api_key.py b/tests/test_publish_api_key.py index 866bc4db..5e07a815 100644 --- a/tests/test_publish_api_key.py +++ b/tests/test_publish_api_key.py @@ -16,7 +16,8 @@ def _make_settings(tmp_path: Path, api_key: str | None = None) -> MagicMock: settings = MagicMock() settings.minds_api_key = api_key - settings.workspace_path = tmp_path + settings.workspace_path = str(tmp_path) + settings.artifacts_dir = str(tmp_path / "artifacts") settings.publish_url = "https://4nton.ai" settings.minds_ssl_verify = True return settings @@ -35,9 +36,9 @@ def _make_console() -> MagicMock: def _make_html_file(tmp_path: Path) -> Path: - output_dir = tmp_path / ".anton" / "output" - output_dir.mkdir(parents=True) - html = output_dir / "report.html" + artifacts_dir = tmp_path / "artifacts" + artifacts_dir.mkdir(parents=True) + html = artifacts_dir / "report.html" html.write_text("Test") return html diff --git a/tests/test_session_skills_init.py b/tests/test_session_skills_init.py index 76c584af..e542a61f 100644 --- a/tests/test_session_skills_init.py +++ b/tests/test_session_skills_init.py @@ -69,6 +69,7 @@ def test_section_appears_when_store_passed( current_datetime="2026-04-10", system_prompt_context=SystemPromptContext(runtime_context="test"), proactive_dashboards=False, + output_dir="", skill_store=store_with_one_skill, ) assert "## Procedural memory" in prompt @@ -80,6 +81,7 @@ def test_section_omitted_when_no_store(self): current_datetime="2026-04-10", system_prompt_context=SystemPromptContext(runtime_context="test"), proactive_dashboards=False, + output_dir="", skill_store=None, ) assert "Procedural memory" not in prompt diff --git a/tests/test_skills_e2e.py b/tests/test_skills_e2e.py index 3c673ac4..f4bfb4a0 100644 --- a/tests/test_skills_e2e.py +++ b/tests/test_skills_e2e.py @@ -125,6 +125,7 @@ async def test_full_skills_loop(console, store_root): current_datetime="2026-04-10T13:00:00+00:00", system_prompt_context=SystemPromptContext(runtime_context="test"), proactive_dashboards=False, + output_dir="", skill_store=fresh_store, ) assert "## Procedural memory" in prompt