Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 8 additions & 3 deletions packages/agentenv-protocol/examples/website/notes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ them. The page shows the universe's time.
| `website.toml` | The site's spec: its name, domains, health path, source, seed, collections, personas, roles, routes, state read and clock (see `docs/website-spec.md`). |
| `site.py` | The server: the `Notes` WebsiteEnvironment, declared from `website.toml`, its `@api` routes, and `serve()` on `PORT` (8000). |
| `data/seed.json` | The seed data: two users (`ann`, `ben`), their notes, and `current_user`, the acting user. |
| `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike; opened at a path without its final slash, such as `/website/notes`, the page first reloads itself with it. |
| `web/` | A minimal Vite app (`index.html`, `src/main.js`, `src/style.css`, and `src/site-client.js`, a copy of the SDK's browser client). Its assets (`base: "./"`) and API calls are relative to the page, so it works at `/` and under the gateway's `/website/notes/` alike; opened at a path without its final slash, such as `/website/notes`, the page first reloads itself with it. |
| `Dockerfile` | The server image: Node builds `web/`, then Python installs this repo's `packages/agentenv-protocol` with uvicorn. It installs `curl`, which the gateway's compose health check runs. |
| `env.toml` | The website env as a bundle entry, pointing at `website.toml`. |

Expand Down Expand Up @@ -38,7 +38,8 @@ them. The page shows the universe's time.
- **Time.** Notes are stamped with `self.now()`, which follows the gateway's clock once `clock/v1`'s `sync_time` names
it. The frontend reads `X-Universe-Now` from every API response into `universeNow()` and shows it in the header.
It uses this helper rather than `clock_patch=True`, so the page works the same against a server written without the
SDK.
SDK. The helper and the page's `api()`, `session()` and `signIn()` come from `src/site-client.js`, a copy of
`agentenv_protocol/website/client/site-client.js`, which a test keeps it the same as.
- **Readiness.** `@ready_check seeded` holds `/healthz` at 503 until the store has users, for example after a
`data/reset` and before the next `data/add`.

Expand All @@ -54,6 +55,10 @@ PYTHONPATH=<repo>/packages/agentenv-protocol/src python site.py
Open http://localhost:8000. To work on the frontend, run `npm run dev` in `web/` while `site.py` runs; Vite proxies
`/api` to port 8000.

Or run `agent-env env website dev` from this folder: it builds `web/` when it is out of date and rebuilds it as it
changes, serves `site.py` with reload, syncs it to a local stand-in for the gateway's clock, loads `data/seed.json`
through the data plane after every reload, and runs the conformance check once.

## Test it

```bash
Expand Down Expand Up @@ -88,7 +93,7 @@ the container before using it.
```bash
agent-env env website put --id notes-site \
--server-dockerfile packages/agentenv-protocol/examples/website/notes/Dockerfile \
--server-docker-context . --health-path /healthz
--server-docker-context .
```

Run it from the repo root. `environment_name` comes from the card, which the spec names `notes`. The stored env
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "./style.css";
import { api, followUniverseClock, session, signIn, universeNow } from "./site-client.js";

let offsetMs = 0;
let filter = "all";
let renders = 0;
const saving = new Map();
Expand All @@ -9,27 +9,6 @@ const landed = new Map();
const drafts = new Map();
const CLOCK_REFRESH_MS = 5000;

export function universeNow() {
return new Date(Date.now() + offsetMs);
}

function followUniverse(response) {
const at = Date.parse(response.headers.get("X-Universe-Now") || "");
if (!Number.isNaN(at)) offsetMs = at - Date.now();
}

async function api(path, { method = "GET", body } = {}) {
const response = await fetch(path, {
method,
headers: body === undefined ? {} : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
followUniverse(response);
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || `${method} ${path}: ${response.status}`);
return data;
}

const $ = (id) => document.getElementById(id);

function setDraft(id, kind, value, saved) {
Expand Down Expand Up @@ -77,7 +56,7 @@ function queueSave(id, fields) {
}

function showError(error) {
$("error").textContent = error ? error.message : "";
$("error").textContent = error ? error.error || error.message : "";
$("error").hidden = !error;
}

Expand Down Expand Up @@ -193,9 +172,9 @@ async function run(action) {
}

async function start() {
const [session, users] = await Promise.all([api("api/session"), api("api/users")]);
const [signedIn, users] = await Promise.all([session(), api("api/users")]);
$("user").replaceChildren(...users.map((user) => new Option(user.name || user.id, user.id)));
let signedInAs = session.user ?? "";
let signedInAs = signedIn.user ?? "";
$("user").value = signedInAs;
$("user").addEventListener("change", (event) => {
const user = event.target.value;
Expand All @@ -208,7 +187,7 @@ async function start() {
const failed = pending.find((state) => state.error);
if (failed) throw failed.error;
}
await api("api/sign-in", { method: "POST", body: { user } });
await signIn(user);
signedInAs = user;
drafts.clear();
} finally {
Expand Down Expand Up @@ -245,7 +224,7 @@ async function start() {

tick();
setInterval(tick, 1000);
setInterval(() => fetch("healthz").then(followUniverse).catch(() => {}), CLOCK_REFRESH_MS);
followUniverseClock({ path: "healthz", everyMs: CLOCK_REFRESH_MS });
await render();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// The browser client of a site on agentenv_protocol.website: JSON calls to the site's API, the universe's time from
// X-Universe-Now, and the signed-in user. It has no dependencies. Paths are relative to the page, so a site works at
// "/" and under the gateway's "/website/<name>/" alike: a leading "/" is dropped.

let offsetMs = 0;

// An answer other than success: `status` (0 when the server couldn't be reached), and the site's `code` and `error`.
export class ApiError extends Error {
constructor(status, code, error, body) {
super(error);
this.name = "ApiError";
this.status = status;
this.code = code;
this.error = error;
this.body = body;
}
}

// The universe's time: the X-Universe-Now of the latest answer, extrapolated at real speed since it came. An idle
// page keeps it right with followUniverseClock().
export function universeNow() {
return new Date(Date.now() + offsetMs);
}

// Read X-Universe-Now from `path`, the site's health path, every `everyMs`, so universeNow() follows a clock that
// stops or runs at another speed while the page makes no calls. Returns a function that stops it.
export function followUniverseClock({ path = "api/health", everyMs = 5000 } = {}) {
const id = setInterval(() => fetch(relative(path), { credentials: "same-origin" }).then(follow).catch(() => {}), everyMs);
return () => clearInterval(id);
}

function follow(response) {
const at = Date.parse(response.headers.get("X-Universe-Now") || "");
if (!Number.isNaN(at)) offsetMs = at - Date.now();
}

function relative(path) {
return String(path).replace(/^\/+/, "");
}

// Call the site's API: `body` is sent as JSON; the answer's JSON is returned, or an ApiError thrown.
export async function api(path, { method = "GET", body, headers = {} } = {}) {
const url = relative(path);
let response;
try {
response = await fetch(url, {
method,
credentials: "same-origin",
headers: body === undefined ? headers : { "Content-Type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (cause) {
throw new ApiError(0, "network_error", `${method} ${url}: ${cause?.message || cause}`, null);
}
follow(response);
let text;
try {
text = await response.text();
} catch (cause) {
throw new ApiError(response.status, "network_error", `${method} ${url}: ${cause?.message || cause}`, null);
}
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
if (!response.ok) {
const error = data && typeof data === "object" && data.error ? data.error : `${method} ${url}: ${response.status}`;
const code = data && typeof data === "object" && data.code ? data.code : "http_error";
throw new ApiError(response.status, code, error, data);
}
return data;
}

// Who the browser is signed in as: {user, role, signed_in}. A browser with no session is signed in as the site's
// acting user.
export function session() {
return api("api/session");
}

// Sign in as `user` through the site's own sign-in route, `api/sign-in` unless `path` names another.
export function signIn(user, { path = "api/sign-in" } = {}) {
return api(path, { method: "POST", body: { user } });
}

export function signOut() {
return api("api/session", { method: "DELETE" });
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
package, and uvicorn to serve. The class is optional: a server in any language
that meets the contract works, which ``python -m agentenv_protocol.website.conformance <url>`` checks. Whether a site
is ready is its ``WebsiteReadiness`` answer to ``ready``, which ``website_readiness`` reads of one site and
``env_readiness`` of every site of an env. What a task left a site with is its data plane's ``data/get``."""
``env_readiness`` of every site of an env. What a task left a site with is its data plane's ``data/get``. A site's pages
can call its API with ``client/site-client.js``, a dependency-free ES module (with its ``.d.ts``) to copy into them:
``api()``, ``universeNow()`` from ``X-Universe-Now``, ``session()``, ``signIn()`` and ``signOut()``."""

from __future__ import annotations

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/** An answer other than success: `status` (0 when the server couldn't be reached), and the site's `code` and `error`. */
export declare class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly error: string;
readonly body: unknown;
constructor(status: number, code: string, error: string, body: unknown);
}

export interface ApiOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | string;
body?: unknown;
headers?: Record<string, string>;
}

export interface Session {
user: string | null;
role: string;
signed_in: boolean;
}

/** The universe's time: the X-Universe-Now of the latest answer, extrapolated at real speed since it came. */
export declare function universeNow(): Date;
/** Read X-Universe-Now from `path`, the site's health path, every `everyMs` (5000), so universeNow() follows a clock that stops or runs at another speed; returns a function that stops it. */
export declare function followUniverseClock(options?: { path?: string; everyMs?: number }): () => void;

/** Call the site's API at `path`, relative to the page; returns the answer's JSON or throws an ApiError. */
export declare function api<T = any>(path: string, options?: ApiOptions): Promise<T>;

/** Who the browser is signed in as; a browser with no session is signed in as the site's acting user. */
export declare function session(): Promise<Session>;

/** Sign in as `user` through the site's own sign-in route, `api/sign-in` unless `path` names another. */
export declare function signIn<T = { user: string }>(user: string, options?: { path?: string }): Promise<T>;

export declare function signOut(): Promise<Session>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// The browser client of a site on agentenv_protocol.website: JSON calls to the site's API, the universe's time from
// X-Universe-Now, and the signed-in user. It has no dependencies. Paths are relative to the page, so a site works at
// "/" and under the gateway's "/website/<name>/" alike: a leading "/" is dropped.

let offsetMs = 0;

// An answer other than success: `status` (0 when the server couldn't be reached), and the site's `code` and `error`.
export class ApiError extends Error {
constructor(status, code, error, body) {
super(error);
this.name = "ApiError";
this.status = status;
this.code = code;
this.error = error;
this.body = body;
}
}

// The universe's time: the X-Universe-Now of the latest answer, extrapolated at real speed since it came. An idle
// page keeps it right with followUniverseClock().
export function universeNow() {
return new Date(Date.now() + offsetMs);
}

// Read X-Universe-Now from `path`, the site's health path, every `everyMs`, so universeNow() follows a clock that
// stops or runs at another speed while the page makes no calls. Returns a function that stops it.
export function followUniverseClock({ path = "api/health", everyMs = 5000 } = {}) {
const id = setInterval(() => fetch(relative(path), { credentials: "same-origin" }).then(follow).catch(() => {}), everyMs);
return () => clearInterval(id);
}

function follow(response) {
const at = Date.parse(response.headers.get("X-Universe-Now") || "");
if (!Number.isNaN(at)) offsetMs = at - Date.now();
}

function relative(path) {
return String(path).replace(/^\/+/, "");
}

// Call the site's API: `body` is sent as JSON; the answer's JSON is returned, or an ApiError thrown.
export async function api(path, { method = "GET", body, headers = {} } = {}) {
const url = relative(path);
let response;
try {
response = await fetch(url, {
method,
credentials: "same-origin",
headers: body === undefined ? headers : { "Content-Type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (cause) {
throw new ApiError(0, "network_error", `${method} ${url}: ${cause?.message || cause}`, null);
}
follow(response);
let text;
try {
text = await response.text();
} catch (cause) {
throw new ApiError(response.status, "network_error", `${method} ${url}: ${cause?.message || cause}`, null);
}
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
if (!response.ok) {
const error = data && typeof data === "object" && data.error ? data.error : `${method} ${url}: ${response.status}`;
const code = data && typeof data === "object" && data.code ? data.code : "http_error";
throw new ApiError(response.status, code, error, data);
}
return data;
}

// Who the browser is signed in as: {user, role, signed_in}. A browser with no session is signed in as the site's
// acting user.
export function session() {
return api("api/session");
}

// Sign in as `user` through the site's own sign-in route, `api/sign-in` unless `path` names another.
export function signIn(user, { path = "api/sign-in" } = {}) {
return api(path, { method: "POST", body: { user } });
}

export function signOut() {
return api("api/session", { method: "DELETE" });
}
27 changes: 27 additions & 0 deletions packages/agentenv-protocol/tests/test_website_notes_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import importlib.util
import json
import re
import shutil
import subprocess
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
Expand Down Expand Up @@ -237,3 +239,28 @@ def test_the_built_page_loads_its_assets_and_api_under_the_gateways_prefix() ->
assert not [path for path in called if path.startswith("/")], called
assert browser.get(urljoin(page_url, "api/session")).json()["user"] == "ann"
assert [u["id"] for u in browser.get(urljoin(page_url, "api/users")).json()] == ["ann", "ben"]


def test_the_page_uses_the_sdks_browser_client_as_the_package_ships_it() -> None:
shipped = Path(website.__code__.co_filename).parent / "client" / "site-client.js"
assert (NOTES_DIR / "web" / "src" / "site-client.js").read_text(encoding="utf-8") == shipped.read_text(encoding="utf-8")
assert 'from "./site-client.js"' in (NOTES_DIR / "web" / "src" / "main.js").read_text(encoding="utf-8")


DROPPED_BODY = """
globalThis.location = {href: "http://site.test/"};
globalThis.fetch = async () => ({ok: false, status: 502, headers: {get: () => null},
text: async () => { throw new Error("connection reset"); }});
const {api, ApiError} = await import(process.argv[1]);
try { await api("api/notes"); } catch (error) {
console.log(JSON.stringify({isApiError: error instanceof ApiError, status: error.status, code: error.code}));
}
"""


@pytest.mark.skipif(shutil.which("node") is None, reason="needs node")
def test_the_browser_client_reports_a_body_lost_after_its_headers_as_a_network_api_error() -> None:
shipped = Path(website.__code__.co_filename).parent / "client" / "site-client.js"
out = subprocess.run(["node", "--input-type=module", "-e", DROPPED_BODY, shipped.as_uri()],
capture_output=True, text=True, check=True).stdout
assert json.loads(out) == {"isApiError": True, "status": 502, "code": "network_error"}
Loading
Loading