1104 lines
43 KiB
Rust
1104 lines
43 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 measure and arrange overrides execute bottom-up and top-down in one read-only VM layout call. Structural Mount links exist before first measurement; bounds queries reject premature geometry. Native base layout uses current occurrence child measurements, and Arrangement must assign every stable child slot exactly once. Layout dependencies changed by lifecycle hooks trigger another pass before publication. Constraints are currently finite/unconstrained page inputs; viewport propagation and source measurement caching remain pending.
|
|
|
|
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"));
|
|
}
|
|
}
|