Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions anton/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,8 @@ async def _handle_unpublish(
import json as _json
from pathlib import Path as _Path

from anton.publisher import _STATE_SNAPSHOT

removed_report_id = selected.get("report_id") or ""
removed_md5 = selected.get("md5") or ""
artifacts_root = _Path(settings.artifacts_dir)
Expand All @@ -898,6 +900,20 @@ async def _handle_unpublish(
if matches:
del data[key]
changed = True
# Reset the state-snapshot baseline so a later /publish with a
# changed collection set is treated as fresh (not blocked).
# Modern layout: snapshot sits next to .published.json. Legacy
# root .published.json uses "{slug}/" keys → snapshot lives in
# the per-artifact subdir named by the key.
snap_dirs = [pub_file.parent]
if key.endswith("/"):
snap_dirs.append(pub_file.parent / key.rstrip("/"))
for snap_dir in snap_dirs:
snap = snap_dir / _STATE_SNAPSHOT
try:
snap.unlink()
except OSError:
pass
if changed:
try:
pub_file.write_text(_json.dumps(data, indent=2), encoding="utf-8")
Expand Down
50 changes: 49 additions & 1 deletion anton/core/artifacts/backend_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,55 @@

import asyncio
import os
import re
import shutil
import signal
import socket
import sys
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Protocol


def _anton_state_pythonpath_dir() -> str:
"""A private directory exposing ONLY the `anton_state` package, for PYTHONPATH.

Pointing PYTHONPATH at the package's real parent would leak the whole anton
repo (or its venv's site-packages) onto the backend's sys.path and shadow
the scratchpad venv's own deps (fastapi/pydantic) — a local != published
hazard. Instead we expose anton_state alone via a symlink (copy fallback).
In the cloud the package is vendored into the bundle instead (see publisher).
"""
import anton_state

pkg = Path(anton_state.__file__).resolve().parent
root = Path(tempfile.gettempdir()) / "anton_state_pp"
link = root / "anton_state"
root.mkdir(parents=True, exist_ok=True)
# Always reset the entry, then (re)link — keeps it fresh and simple.
if link.is_symlink() or link.exists():
if link.is_dir() and not link.is_symlink():
shutil.rmtree(link)
else:
link.unlink()
try:
link.symlink_to(pkg, target_is_directory=True)
except OSError:
shutil.copytree(pkg, link)
return str(root)


def _build_backend_env(extra_env: dict[str, str] | None) -> dict[str, str]:
"""Subprocess env: inherited environ + extra_env, with anton_state on PYTHONPATH."""
env = {**os.environ, **(extra_env or {})}
isolated = _anton_state_pythonpath_dir()
existing = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = isolated + (os.pathsep + existing if existing else "")
return env


class ScratchpadPoolLike(Protocol):
"""Minimal surface the launcher needs from a scratchpad pool.

Expand Down Expand Up @@ -101,6 +141,14 @@ async def launch_artifact_backend(
line = raw_line.split("#", 1)[0].strip()
if not line or line.startswith("-"):
continue
# `anton_state` is the internal STATE SDK — it is provided to the
# backend at runtime via PYTHONPATH (see _anton_state_pythonpath_dir),
# not published to any package registry. Drop it if the model listed
# it in requirements.txt, otherwise the install step fails with
# "anton-state was not found in the package registry".
pkg_name = re.split(r"[<>=!~ \[]", line, maxsplit=1)[0].strip()
if pkg_name.replace("-", "_").lower() == "anton_state":
continue
packages.append(line)
if packages:
from datetime import datetime, timezone
Expand Down Expand Up @@ -173,7 +221,7 @@ def _set_pdeathsig() -> None:
stderr=log_fd,
stdin=asyncio.subprocess.DEVNULL,
preexec_fn=preexec_fn,
env={**os.environ, **(extra_env or {})},
env=_build_backend_env(extra_env),
)
except OSError as exc:
log_fd.close()
Expand Down
4 changes: 3 additions & 1 deletion anton/core/llm/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,9 @@
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`.
`backend.py`. Light durable state uses the platform `STATE` store (declare \
`state_manifest.json`); heavy/relational data uses an external connected \
database.

WHEN NOT TO REGISTER:
- Pure chat answers, tables, or markdown rendered inline in the conversation \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,35 @@ HARD CONTRACT (violating ANY of these breaks launch or deployment — full expla
# "DS_POSTGRES_PROD_DB__PASSWORD": os.environ.get("DS_POSTGRES_PROD_DB__PASSWORD"),
}

# === State (durable storage) — INCLUDE THIS BLOCK ONLY FOR
# `fullstack-stateful-app`; OMIT it entirely for `fullstack-stateless-app`.
# STATE mirrors SECRETS: the cloud runner overlays {url, token} (a short-lived
# capability for the trusted state broker) before each request; locally it stays
# None and the SQLite driver is used. Declare the state KEY schema in
# `state_manifest.json` next to this file — generate it from the anton_state
# model, do NOT hand-write the JSON (see STATE MANIFEST below). Build the store
# AT POINT OF USE (inside a route), reading the current STATE — never at import time.
STATE = None

from anton_state import open_store
_STATE_DIR = Path(__file__).resolve().parent

def get_store():
return open_store(
state=STATE,
manifest_path=str(_STATE_DIR / "state_manifest.json"),
local_path=str(_STATE_DIR / ".anton_state.db"),
)

# === 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"]
# Example STATE use (stateful only; build the store at point of use):
# store = get_store()
# await store.put({"pk": user_id, "sk": "profile", "name": name})
# item = await store.get(user_id, "profile")
return {"hello": "world"}

# Static mount MUST come AFTER all API routes (mount at "/" catches every
Expand Down Expand Up @@ -123,7 +147,7 @@ HARD CONTRACT (violating ANY of these breaks launch or deployment — full expla
- 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 `<artifact_path>` 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 (`<artifact_path>/`, next to `backend.py`). Every other rule in this list still applies.
* `fullstack-stateful-app`: durable state goes through the platform `STATE` store (module-level `STATE`, built via `get_store()`), which is a document/key-value model — suitable for LIGHT state (counters, settings, sessions, simple documents keyed by id). Declare the state KEY schema in `state_manifest.json` next to `backend.py` (see STATE MANIFEST below for the exact format — do NOT hand-invent it). For HEAVY/relational needs (joins, transactions, analytics, large data) use an EXTERNAL database via a connected data source instead — do not force it into `STATE`. The `anton_state` SDK is injected at runtime (do NOT add it to `requirements.txt` — see REQUIREMENTS below) and needs pydantic v2, which the mandatory `fastapi` dependency provides — always keep `fastapi` in `requirements.txt`. 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 `<artifact_path>/requirements.txt` with at minimum:
```
Expand All @@ -132,8 +156,35 @@ HARD CONTRACT (violating ANY of these breaks launch or deployment — full expla
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.
NEVER list `anton_state` in `requirements.txt` — it is NOT a published package and the install will FAIL to resolve it (`anton-state was not found in the package registry`), aborting the launch. The STATE SDK is provided to the backend automatically at runtime, so `from anton_state import open_store` just works without any dependency line. This is the ONLY import you leave out of `requirements.txt`.
- Do NOT start the server inside the scratchpad — use `launch_backend` in step 6.
- DECLARE DATASOURCES: if `backend.py` reads any `DS_<ENGINE>_<NAME>__<FIELD>` env var, call `update_artifact(slug=<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.
- STATE MANIFEST (`fullstack-stateful-app` ONLY): `state_manifest.json` is a SINGLE universal contract read by the local SQLite driver AND (client-side) by the cloud HTTP driver — the trusted broker is schema-agnostic. GENERATE it from the `anton_state` model instead of hand-writing JSON (this makes a malformed manifest impossible):
```python
from anton_state.schema import StateSchema, Attr
StateSchema(
pk=Attr(name="pk"), # partition key (always type "S")
sk=Attr(name="sk"), # sort key — omit entirely if unused
collections=["comments", "users"], # every Collection(store, "<name>") you use
).to_manifest(f"{artifact_path}/state_manifest.json")
```
The manifest describes ONLY the KEY schema, never data fields. The resulting JSON is a FLAT object `{version, pk, sk?, gsis?, ttl_attribute?, collections?}` where `pk`/`sk` are `{"name": ..., "type": "S"}` — string keys only in v1. Do NOT wrap it in `entities`/`attributes`/`partition_key`/`sort_key` (a DynamoDB-CreateTable-style shape) and do NOT declare non-key attributes: those fail validation (`StateSchema ... pk Field required`) at the first request. Store the actual values freely via `store.put({...})` at runtime — they need no schema entry. List every `Collection(store, "<name>")` name in `collections` (this is NOT declaring data fields — it is the collection registry). Removing a name here when UPDATING an already-published artifact BLOCKS the publish (its stored data would be orphaned) — to change the set you must /unpublish first and publish again.
- STATE STORE API (`fullstack-stateful-app` ONLY): the `store` from `get_store()` is a key-value store keyed by `(pk, sk)`. PREFER the `Collection` helper for light state — it manages the sort key and defaults the partition:
```python
from anton_state import Collection
todos = Collection(get_store(), "todos")
await todos.put("id1", {"text": "buy milk"}) # pk defaults to one partition
items = await todos.list() # all items in the collection
n = await Collection(get_store(), "counters").increment("visits", field="n")
```
Low-level `store` methods (all async; NO `scan()` / "list everything"):
* `await store.get(pk, sk=None)` → one item or `None`
* `await store.put(item)` → write (dict MUST include `pk` and, if the schema has a sort key, `sk`); `_v` is set by the store — never set it yourself
* `await store.delete(pk, sk=None)`
* `await store.query(pk, *, sk_prefix=None, filters=None, limit=None)` → items sharing partition key `pk` (NO secondary indexes in v1 — there is no `index=` argument)
* `await store.increment(pk, sk=None, *, field, by=1)` → atomic counter (use this for counters; do NOT hand-roll read-modify-write)
* `await store.update(pk, sk=None, *, set_fields=None, add_fields=None, if_version=None)` → atomic partial update
DESIGN KEYS AROUND ACCESS PATTERNS: every "list" must map to a single `query(pk=...)` (or `Collection.list()`). Do NOT call the store in a loop — collect with one `query`. Do NOT wrap a STATE mutation (`put`/`delete`/`increment`/`update`) in your own retry loop: on a timeout the outcome is unknown and a retry can double-apply — surface the error instead.

5. BUILD FRONTEND (if needed): In a separate scratchpad:
- Build a single-file HTML dashboard or web interface
Expand Down
Loading
Loading