Files
endorphin-tessera-gui/src/api.rs
T
codex-bot 7b11e32eb6
Verify Tessera GUI / verify (push) Failing after 7m31s
Dispatch source hit testing for native pointer clicks
2026-10-21 15:37:26 +08:00

1102 lines
42 KiB
Rust

use std::{
io::Read,
ops::{Deref, DerefMut},
panic::{AssertUnwindSafe, catch_unwind},
sync::Mutex,
sync::{
Arc, RwLock,
mpsc::{SyncSender, TrySendError, sync_channel},
},
thread,
};
use anyhow::{Context as _, Result};
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
use winit::event_loop::EventLoopProxy;
use crate::{
activity::{Activity, ActivityLog},
app::AppEvent,
document::NamespaceState,
endor_host,
};
pub const API_ADDRESS: &str = "127.0.0.1:4737";
const MAX_CODE_SIZE: u64 = endor_host::MAX_SOURCE_BYTES as u64;
type Notify = Arc<dyn Fn() + Send + Sync>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RequestKind {
Event,
Code,
Expression,
Repl,
ResetRepl,
}
struct BuildRequest {
request: ObservedRequest,
kind: RequestKind,
source: String,
namespace: String,
submission: u64,
}
pub fn start(
namespace: Arc<RwLock<NamespaceState>>,
proxy: EventLoopProxy<AppEvent>,
) -> Result<Activity> {
let server = Server::http(API_ADDRESS)
.map_err(|error| anyhow::anyhow!(error))
.with_context(|| format!("bind Tessera API to {API_ADDRESS}"))?;
let notify: Notify = Arc::new(move || {
let _ = proxy.send_event(AppEvent::DocumentUpdated);
});
let activity = Arc::new(Mutex::new(ActivityLog::default()));
start_server_observed(
Arc::new(server),
namespace,
notify.clone(),
activity.clone(),
notify,
)?;
Ok(activity)
}
#[cfg(test)]
fn start_server(
server: Arc<Server>,
namespace: Arc<RwLock<NamespaceState>>,
notify: Notify,
) -> Result<thread::JoinHandle<()>> {
start_server_observed(
server,
namespace,
notify,
Arc::new(Mutex::new(ActivityLog::default())),
Arc::new(|| {}),
)
}
fn start_server_observed(
server: Arc<Server>,
namespace: Arc<RwLock<NamespaceState>>,
notify: Notify,
activity: Activity,
observer_notify: Notify,
) -> Result<thread::JoinHandle<()>> {
let (sender, receiver) = sync_channel::<BuildRequest>(8);
let worker_namespace = namespace.clone();
let worker_notify = notify.clone();
thread::Builder::new()
.name("tessera-endor-worker".into())
.spawn(move || {
let mut worker = Worker::default();
loop {
match receiver.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(job) => {
let response = worker.run(&job, &worker_namespace, &worker_notify);
let _ = job.request.respond(response);
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if worker.repl.as_ref().is_some_and(|(epoch, _)| {
worker_namespace
.read()
.is_ok_and(|current| current.submission != *epoch)
}) {
worker.repl = None;
}
}
}
}
})
.context("start Aster execution worker")?;
thread::Builder::new()
.name("tessera-agent-api".to_owned())
.spawn(move || {
for request in server.incoming_requests() {
handle(
request,
&namespace,
&notify,
&sender,
&activity,
&observer_notify,
);
}
})
.context("start Tessera agent API thread")
}
// Capture only allowlisted headers: credentials and cookies never enter the inspector.
struct ObservedRequest {
inner: Request,
activity: Activity,
id: u64,
notify: Notify,
}
impl ObservedRequest {
fn new(inner: Request, activity: Activity, notify: Notify) -> Self {
let headers = inner
.headers()
.iter()
.filter(|h| h.field.equiv("Content-Type") || h.field.equiv("Content-Length"))
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let origin = if inner
.headers()
.iter()
.any(|h| h.field.equiv("X-Tessera-Origin") && h.value.as_str() == "console")
{
"Console"
} else {
"HTTP"
}
.to_owned();
let id = activity.lock().unwrap().begin(
inner.method().to_string(),
inner.url().to_owned(),
origin,
headers,
);
notify();
Self {
inner,
activity,
id,
notify,
}
}
fn record_body(&self, body: &str) {
self.activity.lock().unwrap().body(self.id, body);
(self.notify)();
}
fn respond(self, response: Response<std::io::Cursor<Vec<u8>>>) -> std::io::Result<()> {
let status = response.status_code();
let headers = response.headers().to_vec();
let length = response.data_length();
let reader = response.into_reader();
self.activity.lock().unwrap().finish(
self.id,
status.0,
&String::from_utf8_lossy(reader.get_ref()),
headers
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"),
);
(self.notify)();
self.inner
.respond(Response::new(status, headers, reader, length, None))
}
}
impl Deref for ObservedRequest {
type Target = Request;
fn deref(&self) -> &Request {
&self.inner
}
}
impl DerefMut for ObservedRequest {
fn deref_mut(&mut self) -> &mut Request {
&mut self.inner
}
}
fn handle(
request: Request,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
sender: &SyncSender<BuildRequest>,
activity: &Activity,
observer_notify: &Notify,
) {
let request = ObservedRequest::new(request, activity.clone(), observer_notify.clone());
let method = request.method().clone();
let path = request
.url()
.split('?')
.next()
.unwrap_or(request.url())
.to_owned();
let evaluation = namespace_from_route(&path, "/evaluate");
let repl = namespace_from_route(&path, "/repl");
let event = namespace_from_route(&path, "/events");
let route = if method == Method::Put {
namespace_from_path(&path).map(|name| (name, RequestKind::Code))
} else if method == Method::Post {
event
.map(|name| (name, RequestKind::Event))
.or_else(|| repl.map(|name| (name, RequestKind::Repl)))
.or(evaluation.map(|name| (name, RequestKind::Expression)))
} else if method == Method::Delete {
repl.map(|name| (name, RequestKind::ResetRepl))
} else {
None
};
if let Some((addressed, kind)) = route {
submit(request, addressed, namespace, sender, kind);
return;
}
let response = if method == Method::Get && path == "/agent" {
match namespace.read() {
Ok(namespace) => markdown(StatusCode(200), agent_guide(&namespace)),
Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"),
}
} else if let Some(addressed_namespace) = namespace_from_path(&path).map(str::to_owned) {
code_request(method, &addressed_namespace, namespace, notify)
} else if method == Method::Options {
text(StatusCode(204), "", "text/plain")
} else {
error_response(StatusCode(404), "route not found; start with GET /agent")
};
let _ = request.respond(response);
}
fn code_request(
method: Method,
addressed_namespace: &str,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
let Ok(current) = namespace.read() else {
return error_response(StatusCode(500), "namespace lock is poisoned");
};
if addressed_namespace != current.name {
return error_response(
StatusCode(404),
format!(
"namespace `{addressed_namespace}` is not active; current namespace is `{}`",
current.name
),
);
}
if method == Method::Get {
let mut response = text(
StatusCode(200),
&current.document.source,
"text/plain; charset=utf-8",
);
add_header(
&mut response,
"X-Tessera-Revision",
&current.revision.to_string(),
);
add_header(
&mut response,
"X-Tessera-Session",
&current.document.frame.session.to_string(),
);
add_header(
&mut response,
"X-Tessera-Layout-Version",
&current.document.frame.layout_version.to_string(),
);
add_header(
&mut response,
"X-Tessera-Source-Kind",
if current
.document
.root
.as_ref()
.is_some_and(|root| root.kind == "repl-session")
{
"repl-transcript"
} else {
"source-snapshot"
},
);
return response;
}
drop(current);
match method {
Method::Delete => match namespace.write() {
Ok(mut current) => {
let revision = match current.clear() {
Ok(revision) => revision,
Err(error) => return error_response(StatusCode(500), error.to_string()),
};
drop(current);
notify();
json(
StatusCode(200),
serde_json::json!({
"ok": true,
"namespace": addressed_namespace,
"revision": revision
}),
)
}
Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"),
},
Method::Options => text(StatusCode(204), "", "text/plain"),
_ => error_response(StatusCode(405), "code supports GET, PUT, and DELETE"),
}
}
fn submit(
mut request: ObservedRequest,
addressed: &str,
namespace: &Arc<RwLock<NamespaceState>>,
sender: &SyncSender<BuildRequest>,
kind: RequestKind,
) {
let source = match read_code(&mut request) {
Ok(source) => source,
Err(error) => {
let _ = request.respond(error_response(StatusCode(400), error.to_string()));
return;
}
};
request.record_body(&source);
let Ok(mut current) = namespace.write() else {
let _ = request.respond(error_response(
StatusCode(500),
"namespace lock is poisoned",
));
return;
};
if current.name != addressed {
let _ = request.respond(error_response(StatusCode(404), "namespace is not active"));
return;
}
let submission = match if kind == RequestKind::Code {
current.next_submission()
} else {
Ok(current.submission)
} {
Ok(id) => id,
Err(error) => {
let _ = request.respond(error_response(StatusCode(500), error.to_string()));
return;
}
};
let job = BuildRequest {
request,
kind,
source,
namespace: addressed.into(),
submission,
};
match sender.try_send(job) {
// A rejected queue entry never invalidates accepted work. The worker
// uses this same lock when committing, so publication is atomic.
Ok(()) => {
if kind == RequestKind::Code {
current.submission = submission;
}
}
Err(TrySendError::Full(job)) => {
let _ = job
.request
.respond(error_response(StatusCode(503), "build queue is full"));
}
Err(TrySendError::Disconnected(job)) => {
let _ = job.request.respond(error_response(
StatusCode(503),
"execution worker is unavailable",
));
}
}
}
#[derive(Default)]
struct Worker {
repl: Option<(u64, endor_host::ReplSession)>,
next_session: u64,
}
impl Worker {
fn run(
&mut self,
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
if job.kind == RequestKind::Code {
return self.replace_program(job, namespace, notify);
}
if job.kind == RequestKind::Expression {
return build(job, namespace, notify);
}
let epoch = match namespace.read() {
Ok(current) => current.submission,
Err(_) => return error_response(StatusCode(500), "namespace lock is poisoned"),
};
if epoch != job.submission {
return superseded(job.submission);
}
if job.kind == RequestKind::ResetRepl {
self.repl = None;
if let Ok(mut current) = namespace.write() {
current.document.frame.session = 0;
current.document.frame.focus = None;
}
notify();
return json(
StatusCode(200),
serde_json::json!({"ok":true,"execution":"repl-reset","persistent":true,"session_reset":true,"message":"Console memory cleared; displayed page preserved"}),
);
}
let event = if job.kind == RequestKind::Event {
let input: crate::events::Input = match serde_json::from_str(&job.source) {
Ok(input) => input,
Err(error) => return error_response(StatusCode(400), error.to_string()),
};
if !matches!(input.kind.as_str(), "click" | "focus") {
return error_response(StatusCode(400), "unsupported event kind");
}
if let Err(message) = input.validate() {
return error_response(StatusCode(400), message);
}
let current = match namespace.read() {
Ok(current) => current,
Err(_) => return error_response(StatusCode(500), "namespace lock poisoned"),
};
let frame = &current.document.frame;
if frame.session != input.session
|| frame.layout_version != input.layout_version
|| frame.fault.is_some()
|| !frame
.mounts
.iter()
.any(|mount| mount.id == input.mount && mount.interactive)
|| self.repl.as_ref().is_none_or(|(context, runtime)| {
*context != epoch || runtime.id != input.session
})
{
return error_response(
StatusCode(409),
"event targets an obsolete or inactive frame",
);
}
Some(input)
} else {
None
};
if self
.repl
.as_ref()
.is_none_or(|(current, _)| *current != epoch)
{
self.next_session += 1;
match endor_host::ReplSession::new(self.next_session) {
Ok(session) => self.repl = Some((epoch, session)),
Err(error) => return error_response(StatusCode(500), error.to_string()),
}
}
let session = &mut self.repl.as_mut().expect("initialized REPL").1;
let id = session.id;
let result = catch_unwind(AssertUnwindSafe(|| {
if let Some(event) = &event {
session.dispatch(event)
} else {
session.execute(&job.source, &job.namespace)
}
}));
let faulted = session.is_faulted();
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock is poisoned");
};
if current.submission != epoch {
drop(current);
self.repl = None;
return superseded(job.submission);
}
match result {
Ok(Ok(result)) => {
let rendered = result.document.is_some();
if let Some(document) = result.document {
match current.commit(epoch, document) {
Ok(Some(_)) => {}
Ok(None) => return superseded(job.submission),
Err(error) => return error_response(StatusCode(500), error.to_string()),
}
}
let revision = current.revision;
drop(current);
if rendered {
notify();
}
json(
StatusCode(200),
serde_json::json!({"ok":true,"execution":if event.is_some() { "event" } else { "repl" },"persistent":true,"session":id,"input":result.input,"value":result.value,"bindings":result.bindings,"rendered":rendered,"revision":revision}),
)
}
Ok(Err(error)) => {
if event.is_some() || faulted {
let mut document = current.document.clone();
document.frame.fault = Some(format!("{error:#}").chars().take(1024).collect());
let _ = current.commit(epoch, document);
drop(current);
notify();
}
compile_error(error)
}
Err(_) => {
let mut document = current.document.clone();
document.frame.fault =
Some("Runtime discarded after an internal execution failure".into());
let _ = current.commit(epoch, document);
drop(current);
self.repl = None;
notify();
json(
StatusCode(500),
serde_json::json!({"ok":false,"session_reset":true,"error":"Console execution failed internally; session discarded, displayed page preserved"}),
)
}
}
}
fn replace_program(
&mut self,
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
if namespace
.read()
.is_ok_and(|current| current.submission != job.submission)
{
return superseded(job.submission);
}
self.next_session = match self.next_session.checked_add(1) {
Some(id) => id,
None => return error_response(StatusCode(500), "session identities exhausted"),
};
let id = self.next_session;
let result = catch_unwind(AssertUnwindSafe(|| {
endor_host::ReplSession::load(&job.source, &job.namespace, job.submission, id)
}));
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock poisoned");
};
if current.submission != job.submission {
return superseded(job.submission);
}
match result {
Ok(Ok((runtime, document))) => match current.commit(job.submission, document) {
Ok(Some(revision)) => {
self.repl = Some((job.submission, runtime));
drop(current);
notify();
json(
StatusCode(200),
serde_json::json!({"ok":true,"namespace":job.namespace,"submission":job.submission,"revision":revision,"diagnostics":[],"session":id,"persistent":true}),
)
}
Ok(None) => superseded(job.submission),
Err(error) => error_response(StatusCode(500), error.to_string()),
},
failed => {
// The candidate never mutated the displayed program's heap.
// Restore that program's event epoch when a replacement fails.
if let Some((epoch, runtime)) = &mut self.repl
&& current.document.frame.session == runtime.id
{
*epoch = job.submission;
}
drop(current);
match failed {
Ok(Err(error)) => compile_error(error),
_ => error_response(
StatusCode(500),
"candidate failed internally; previous runtime preserved",
),
}
}
}
}
}
fn build(
job: &BuildRequest,
namespace: &Arc<RwLock<NamespaceState>>,
notify: &Notify,
) -> Response<std::io::Cursor<Vec<u8>>> {
if job.kind == RequestKind::Expression {
return match catch_unwind(AssertUnwindSafe(|| endor_host::evaluate(&job.source))) {
Ok(Ok(value)) => json(
StatusCode(200),
serde_json::json!({"ok":true,"value":value,"execution":"isolated-expression","persistent":false}),
),
Ok(Err(error)) => compile_error(error),
Err(_) => error_response(StatusCode(500), "expression execution failed internally"),
};
}
match namespace.read() {
Ok(current) if current.submission != job.submission => return superseded(job.submission),
Err(_) => return error_response(StatusCode(500), "namespace lock is poisoned"),
_ => {}
}
// Only an independent native snapshot crosses into the published state.
// A fresh VM is discarded on success, error and unwind alike.
let result = catch_unwind(AssertUnwindSafe(|| {
endor_host::compile(&job.source, &job.namespace, job.submission)
}));
let Ok(mut current) = namespace.write() else {
return error_response(StatusCode(500), "namespace lock is poisoned");
};
if current.submission != job.submission {
return superseded(job.submission);
}
match result {
Ok(Ok(document)) => match current.commit(job.submission, document) {
Ok(Some(revision)) => {
drop(current);
notify();
json(
StatusCode(200),
serde_json::json!({ "ok": true, "namespace": job.namespace,
"submission": job.submission, "revision": revision, "diagnostics": [] }),
)
}
Ok(None) => superseded(job.submission),
Err(error) => error_response(StatusCode(500), error.to_string()),
},
Ok(Err(error)) => compile_error(error),
Err(_) => error_response(
StatusCode(500),
"candidate execution failed internally; previous document preserved",
),
}
}
fn superseded(submission: u64) -> Response<std::io::Cursor<Vec<u8>>> {
json(
StatusCode(409),
serde_json::json!({ "ok": false, "submission": submission,
"error": "submission superseded by a newer PUT or DELETE" }),
)
}
fn namespace_from_route<'a>(path: &'a str, suffix: &str) -> Option<&'a str> {
path.strip_prefix("/v1/namespaces/")?
.strip_suffix(suffix)
.filter(|namespace| !namespace.is_empty() && !namespace.contains('/'))
}
fn namespace_from_path(path: &str) -> Option<&str> {
namespace_from_route(path, "/code")
}
fn read_code(request: &mut Request) -> Result<String> {
if request
.body_length()
.is_some_and(|length| length as u64 > MAX_CODE_SIZE)
{
anyhow::bail!("code exceeds the 1 MiB limit");
}
let mut code = String::new();
request
.as_reader()
.take(MAX_CODE_SIZE + 1)
.read_to_string(&mut code)
.context("read code")?;
if code.len() as u64 > MAX_CODE_SIZE {
anyhow::bail!("code exceeds the 1 MiB limit");
}
Ok(code)
}
fn agent_guide(namespace: &NamespaceState) -> String {
format!(
r##"# Tessera Agent API
Current namespace: `{name}`
Current revision: `{revision}`
Submit an ordinary Endor script. Tessera embeds Aster's frontend, compiler and VM.
The namespace comes from the request path. Top-level bindings share the candidate
VM's globals; `ui.page().render(root)` selects the visible UI. No main function is
required. Typed helper functions and the older main-returning-View form also work.
## Code CRUD
- Read: `GET http://{address}/v1/namespaces/{name}/code`
- Create or update: `PUT http://{address}/v1/namespaces/{name}/code`
- Delete: `DELETE http://{address}/v1/namespaces/{name}/code`
Send PUT bodies as `text/plain; charset=utf-8`.
```endor
{example}```
## Live Console session
`POST http://{address}/v1/namespaces/{name}/repl` accepts statements, function
definitions and expressions as text/plain. Consecutive requests use one real VM
and globals dictionary on the execution worker. `title = ui.text("Hello")` creates
an object without displaying it. A later `page.render(root)` mounts its page. UI references share replacement state: after mounting, assigning a new UI value to a reference updates its aliases and mounted positions. `page.unmount()` releases the mount.
`let title = ...` is also supported. `_` retains the actual last value. No input
history is replayed. Responses include session/input IDs, value, binding count,
rendered flag and revision. Before the first mount the page/revision stay unchanged; afterwards successful inputs publish changed mounted references automatically.
`DELETE http://{address}/v1/namespaces/{name}/repl` resets Console objects and bindings,
leaving the displayed native snapshot intact. Parsing/compilation happen before
execution. On runtime error earlier statements in that input may remain live, but
its pending render is discarded; previous displayed page is preserved.
Session limits: 256 executed inputs, 1 MiB transcript, 10000 cumulative AST nodes,
4096 retained visual objects and 64 retained pages. Aster collects unreachable objects/cycles between inputs; there is no fixed UI pool. No VM handles cross to the window.
A newer accepted complete-source PUT or code DELETE invalidates queued Console inputs.
A successful PUT retains its fresh VM as the live program for Console and GUI events;
a failed PUT preserves the previous program. DELETE releases the live context.
GET /code returns the source of the displayed snapshot: exact submitted source for
PUT, an annotated Console transcript after a REPL render. X-Tessera-Source-Kind
identifies source-snapshot versus repl-transcript; a transcript is not a replay log.
The compatibility `POST http://{address}/v1/namespaces/{name}/evaluate` endpoint
still accepts one isolated expression in a fresh VM without ui or previous bindings.
It does not publish a page or change revision. Console does not use this endpoint.
All execution endpoints share the eight-entry queue. These are in-process bounded
host executions, not a complete untrusted-code sandbox or a process-isolation design.
## Managed visual types
`Object`, `Type`, `Function`, `Block`, `ContainerBlock`, `TextBlock`, `RectBlock`,
`Spacer`, `Row`, `Column`, `Button`, `Page`, `Mount`, and `Event` are managed nominal
Type objects. Use `page = Page()` and `label = TextBlock("Hello")` in Endor source.
The existing ui constructors return these same typed objects. `page.type`,
`Page.base`, `Page.name`, `isInstance(page, Block)` and `isSubtype(Page, Block)`
expose actual type relations. Types, bases, instances and native constructors live
in the same GC graph. Ordinary source classes support `class Child: Base`, typed field
declarations, explicit first `self` parameters, positional field construction and
member assignment. Inherited methods bind the actual receiver; overridden methods
are dynamically selected through self. Concrete native visual subclasses reuse real native allocators; abstract bases remain unconstructible. Traits and annotation checking remain pending. Native Block/Page methods use the same type method tables and binding path.
Source paint overrides execute in the actual frame pipeline under a read-only heap policy. Source hitTest overrides similarly refine native pointer targets in VM paint order; Point.inside(bounds) and Point.inCircle(cx, cy, radius) provide primitive shape queries. Managed DisplayList/ObjectList/PaintCommand and Constraints/Bounds/Point results are ordinary runtime objects.
Native input POST /events is a transport envelope, not Endor source. The window
sends current session, layout_version and Mount identity; stale positions fail without calling a callback. Pointer clicks also include point and press_point [x,y] in logical page coordinates. Both targets are resolved through read-only hitTest, with fallback behind a rejected candidate; a click is dispatched only when both resolve to the same Mount. Keyboard/code clicks omit coordinates and remain semantic activation. GET /code also exposes X-Tessera-Session and X-Tessera-Layout-Version headers for transport clients. Native mouse, Tab, Enter and Space delegate to the same
managed click/focus methods used by code. Events appear in Network, not Console source history.
## UI host object
Positional arguments only:
- `ui.text(value, size = 1, color = "#242422")`; integer size 1..8.
- `ui.rect(width, height, radius = 0, color = "#DCE8FF")`.
- `ui.column(padding, gap, child...)` and `ui.row(padding, gap, child...)`.
- `ui.spacer(size)`.
- `ui.page()` creates a page; `page.render(root)` mounts its root; `page.unmount()` releases the mount. The last render
in a successful submission selects the page shown in this namespace.
Defaults above describe the host API; do not write keyword arguments in Endor.
Colors use #RRGGBB or #RRGGBBAA. Dimensions are finite numbers in 0..1000000.
Use newlines between complete top-level statements. Multiline calls, function
bodies and expression continuations are passed to Aster as complete inputs.
Limits: 1 MiB source, 4096 constructed nodes, 64 pages, 64 UI levels, 10000 expanded
layout nodes, 16 KiB per text, 128 VM frames. Script statements each receive an
equal share of a total 1000000-instruction allowance. Older module initialization
and main execution each retain their own 1000000-instruction allowance.
PUT waits for its worker result. Errors return 422 with diagnostics and preserve
the last successful revision. A newer accepted PUT or DELETE supersedes older
work (409); a full eight-entry queue returns 503. DELETE immediately clears the
document. GET remains available while Aster executes. The window redraws from an
owned snapshot; resize does not rerun Endor. Text uses system fonts, antialiasing
and font fallback; Chinese needs an installed CJK font. Size 1 is 14 logical pixels
with 20-pixel line height; larger sizes multiply both. Native Block methods include
setText/setColor, container append/remove/replace/move, Page open/close/navigation,
and code-driven button.onClick/click. ui.state and ui.textOf provide explicit text
binding. Mouse routing, tab chrome/multiple windows and incremental layout remain
in progress; process isolation is future work. Console retains objects
only in its explicitly resettable in-memory VM. It is not an untrusted-code sandbox.
"##,
name = namespace.name,
revision = namespace.revision,
address = API_ADDRESS,
example = include_str!("../examples/page.endor"),
)
}
fn compile_error(error: anyhow::Error) -> Response<std::io::Cursor<Vec<u8>>> {
let mut diagnostic =
serde_json::json!({ "severity": "error", "message": format!("{error:#}") });
if let Some(source) = error.downcast_ref::<frontend::SourceDiagnostic>() {
diagnostic["line"] = source.location.line.into();
diagnostic["column"] = source.location.column.into();
diagnostic["start_byte"] = source.diagnostic.span.start.into();
diagnostic["end_byte"] = source.diagnostic.span.end.into();
}
json(
StatusCode(422),
serde_json::json!({
"ok": false,
"diagnostics": [diagnostic]
}),
)
}
fn error_response(
status: StatusCode,
message: impl Into<String>,
) -> Response<std::io::Cursor<Vec<u8>>> {
json(status, serde_json::json!({ "error": message.into() }))
}
fn json(status: StatusCode, value: serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
text(
status,
&value.to_string(),
"application/json; charset=utf-8",
)
}
fn markdown(status: StatusCode, value: String) -> Response<std::io::Cursor<Vec<u8>>> {
text(status, &value, "text/markdown; charset=utf-8")
}
fn text(status: StatusCode, value: &str, content_type: &str) -> Response<std::io::Cursor<Vec<u8>>> {
let mut response = Response::from_string(value).with_status_code(status);
add_header(&mut response, "Content-Type", content_type);
add_header(
&mut response,
"Access-Control-Allow-Origin",
"http://127.0.0.1",
);
add_header(
&mut response,
"Access-Control-Allow-Methods",
"GET, PUT, POST, DELETE, OPTIONS",
);
add_header(
&mut response,
"Access-Control-Allow-Headers",
"Content-Type, X-Tessera-Origin",
);
response
}
fn add_header(response: &mut Response<std::io::Cursor<Vec<u8>>>, name: &str, value: &str) {
if let Ok(header) = Header::from_bytes(name, value) {
response.add_header(header);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
io::Write,
net::{SocketAddr, TcpStream},
sync::atomic::{AtomicUsize, Ordering},
time::Duration,
};
struct TestServer {
server: Arc<Server>,
address: SocketAddr,
api: Option<thread::JoinHandle<()>>,
}
impl TestServer {
fn request(&self, method: &str, path: &str, body: &str) -> (u16, String, String) {
let mut stream = TcpStream::connect(self.address).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(10)))
.unwrap();
write!(stream, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
let (headers, body) = response.split_once("\r\n\r\n").unwrap();
let status = headers.split_whitespace().nth(1).unwrap().parse().unwrap();
(status, headers.to_owned(), body.to_owned())
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.server.unblock();
if let Some(api) = self.api.take() {
let _ = api.join();
}
}
}
#[test]
fn console_keeps_memory_until_reset_or_complete_put_without_erasing_the_page() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let api = start_server(server.clone(), namespace.clone(), Arc::new(|| {})).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let repl = "/v1/namespaces/app.main/repl";
let code = "/v1/namespaces/app.main/code";
let created = client.request("POST", repl, "title = ui.text(\"中文\")");
assert_eq!(created.0, 200, "{}", created.2);
let created: serde_json::Value = serde_json::from_str(&created.2).unwrap();
assert_eq!(created["rendered"], false);
assert_eq!(created["revision"], 0);
let read: serde_json::Value =
serde_json::from_str(&client.request("POST", repl, "title").2).unwrap();
assert_eq!(read["value"], created["value"]);
assert_eq!(
client.request("POST", repl, "ui.page().render(title)").0,
200
);
let snapshot = client.request("GET", code, "");
assert!(
snapshot
.1
.to_lowercase()
.contains("x-tessera-source-kind: repl-transcript")
);
assert_eq!(namespace.read().unwrap().revision, 1);
assert_eq!(client.request("DELETE", repl, "").0, 200);
assert_eq!(client.request("GET", code, "").2, snapshot.2);
assert_eq!(client.request("POST", repl, "title").0, 422);
assert_eq!(namespace.read().unwrap().revision, 1);
assert_eq!(client.request("POST", repl, "retained = 42").0, 200);
assert_eq!(
client
.request("PUT", code, include_str!("../examples/page.endor"))
.0,
200
);
assert_eq!(client.request("POST", repl, "retained").0, 422);
assert_eq!(namespace.read().unwrap().revision, 2);
}
#[test]
fn http_executes_real_endor_preserves_failed_updates_and_supports_delete() {
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let notifications = Arc::new(AtomicUsize::new(0));
let counter = notifications.clone();
let notify: Notify = Arc::new(move || {
counter.fetch_add(1, Ordering::SeqCst);
});
let api = start_server(server.clone(), namespace.clone(), notify).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
let route = "/v1/namespaces/app.main/code";
let example = include_str!("../examples/page.endor");
let (status, _, guide) = client.request("GET", "/agent", "");
assert_eq!(status, 200);
assert!(guide.contains("let myui = ui.page()"));
let (status, _, body) = client.request("PUT", route, example);
assert_eq!(status, 200, "{body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap()["revision"],
1
);
let (status, headers, source) = client.request("GET", route, "");
assert_eq!(status, 200);
assert!(headers.to_lowercase().contains("x-tessera-revision: 1"));
assert_eq!(source, example);
let (status, _, body) =
client.request("PUT", route, "function main(): View { return missing }");
assert_eq!(status, 422, "{body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap()["diagnostics"][0]["line"],
1
);
assert_eq!(client.request("GET", route, "").2, example);
let second = example.replace("Hello Endor", "Second");
assert_eq!(client.request("PUT", route, &second).0, 200);
assert_eq!(namespace.read().unwrap().revision, 2);
assert_eq!(client.request("DELETE", route, "").0, 200);
assert_eq!(client.request("GET", route, "").2, "");
assert!(namespace.read().unwrap().document.frame.commands.is_empty());
assert_eq!(client.request("PUT", route, example).0, 200);
assert_eq!(namespace.read().unwrap().revision, 4);
assert_eq!(
client
.request("PUT", "/v1/namespaces/other/code", example)
.0,
404
);
assert_eq!(notifications.load(Ordering::SeqCst), 4);
}
#[test]
fn http_delete_remains_available_and_invalidates_queued_put() {
use std::{
sync::{Mutex, mpsc},
time::Instant,
};
let server = Arc::new(Server::http("127.0.0.1:0").unwrap());
let namespace = Arc::new(RwLock::new(NamespaceState::default()));
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let release_rx = Mutex::new(release_rx);
let calls = AtomicUsize::new(0);
let notify: Notify = Arc::new(move || {
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
entered_tx.send(()).unwrap();
release_rx.lock().unwrap().recv().unwrap();
}
});
let api = start_server(server.clone(), namespace.clone(), notify).unwrap();
let client = TestServer {
address: server.server_addr().to_ip().unwrap(),
server,
api: Some(api),
};
// Unblock even if a test assertion fails, before scoped threads join.
struct Release(mpsc::Sender<()>);
impl Drop for Release {
fn drop(&mut self) {
let _ = self.0.send(());
}
}
thread::scope(|scope| {
let release = Release(release_tx);
let client = &client;
let route = "/v1/namespaces/app.main/code";
let example = include_str!("../examples/page.endor");
let first = scope.spawn(move || client.request("PUT", route, example));
entered_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let pending = scope.spawn(move || client.request("PUT", route, example));
let deadline = Instant::now() + Duration::from_secs(10);
while namespace.read().unwrap().submission < 2 {
assert!(Instant::now() < deadline, "second PUT was not admitted");
thread::sleep(Duration::from_millis(5));
}
assert_eq!(client.request("GET", route, "").2, example);
assert_eq!(client.request("DELETE", route, "").0, 200);
drop(release);
assert_eq!(first.join().unwrap().0, 200);
assert_eq!(pending.join().unwrap().0, 409);
assert_eq!(client.request("GET", route, "").2, "");
assert_eq!(namespace.read().unwrap().revision, 2);
});
}
#[test]
fn extracts_namespace_only_from_code_routes() {
assert_eq!(
namespace_from_path("/v1/namespaces/app.main/code"),
Some("app.main")
);
assert_eq!(namespace_from_path("/v1/namespaces/app.main"), None);
assert_eq!(namespace_from_path("/v1/namespaces/a/b/code"), None);
}
#[test]
fn guide_contains_only_code_crud_routes() {
let guide = agent_guide(&NamespaceState::default());
assert!(guide.contains("Code CRUD"));
assert!(guide.contains("let myui = ui.page()"));
assert!(!guide.contains("export scene"));
assert!(guide.contains("PUT http://127.0.0.1:4737"));
assert!(!guide.contains("display_list"));
}
}