1102 lines
42 KiB
Rust
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,
|
|
¬ify,
|
|
&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),
|
|
¤t.document.source,
|
|
"text/plain; charset=utf-8",
|
|
);
|
|
add_header(
|
|
&mut response,
|
|
"X-Tessera-Revision",
|
|
¤t.revision.to_string(),
|
|
);
|
|
add_header(
|
|
&mut response,
|
|
"X-Tessera-Session",
|
|
¤t.document.frame.session.to_string(),
|
|
);
|
|
add_header(
|
|
&mut response,
|
|
"X-Tessera-Layout-Version",
|
|
¤t.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 = ¤t.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"));
|
|
}
|
|
}
|