1458 lines
63 KiB
Rust
1458 lines
63 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 {
|
|
worker.refresh_viewport(&worker_namespace, &worker_notify);
|
|
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-Window",
|
|
¤t.document.frame.window.to_string(),
|
|
);
|
|
add_header(
|
|
&mut response,
|
|
"X-Tessera-Layout-Version",
|
|
¤t.document.frame.layout_version.to_string(),
|
|
);
|
|
if let Some(viewport) = current.viewport {
|
|
add_header(
|
|
&mut response,
|
|
"X-Tessera-Viewport-Width",
|
|
&viewport.width.to_string(),
|
|
);
|
|
add_header(
|
|
&mut response,
|
|
"X-Tessera-Viewport-Height",
|
|
&viewport.height.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 close_inactive_window(
|
|
&mut self,
|
|
input: &crate::events::Input,
|
|
epoch: u64,
|
|
namespace: &Arc<RwLock<NamespaceState>>,
|
|
notify: &Notify,
|
|
) -> Response<std::io::Cursor<Vec<u8>>> {
|
|
if input.window == crate::window::MAIN_WINDOW {
|
|
return error_response(
|
|
StatusCode(409),
|
|
"close the primary shell with its OS window control",
|
|
);
|
|
}
|
|
let owns_runtime = self.repl.as_ref().is_some_and(|(owner, runtime)| {
|
|
*owner == epoch && runtime.id == input.session && input.session != 0
|
|
});
|
|
let mut discarded = false;
|
|
if owns_runtime {
|
|
let result = catch_unwind(AssertUnwindSafe(|| {
|
|
self.repl
|
|
.as_mut()
|
|
.expect("owned runtime")
|
|
.1
|
|
.retire_window(input.window)
|
|
}));
|
|
if !matches!(result, Ok(Ok(()))) {
|
|
// Never keep a half-cleaned VM live or borrow another generation.
|
|
self.repl = None;
|
|
discarded = true;
|
|
}
|
|
}
|
|
let Ok(mut current) = namespace.write() else {
|
|
return error_response(StatusCode(500), "namespace lock poisoned");
|
|
};
|
|
if current.submission != epoch {
|
|
return superseded(epoch);
|
|
}
|
|
let Some(view) = current.document.windows.get(&input.window) else {
|
|
return error_response(StatusCode(409), "window is no longer active");
|
|
};
|
|
if view.frame.session != input.session {
|
|
return error_response(
|
|
StatusCode(409),
|
|
"window belongs to another runtime generation",
|
|
);
|
|
}
|
|
let mut document = current.document.clone();
|
|
document.windows.remove(&input.window);
|
|
if discarded {
|
|
document.frame.session = 0;
|
|
document.frame.focus = None;
|
|
for view in document.windows.values_mut() {
|
|
view.frame.session = 0;
|
|
view.frame.focus = None;
|
|
}
|
|
current.runtime_session = 0;
|
|
current.window_commands.clear();
|
|
}
|
|
match current.commit(epoch, document) {
|
|
Ok(Some(_)) => {}
|
|
Ok(None) => return superseded(epoch),
|
|
Err(error) => return error_response(StatusCode(500), error.to_string()),
|
|
}
|
|
current.window_viewports.remove(&input.window);
|
|
drop(current);
|
|
notify();
|
|
json(
|
|
StatusCode(200),
|
|
serde_json::json!({"ok":true,"execution":"window-retire","window":input.window,"callbacks":false,"runtime_discarded":discarded}),
|
|
)
|
|
}
|
|
|
|
fn refresh_viewport(&mut self, namespace: &Arc<RwLock<NamespaceState>>, notify: &Notify) {
|
|
let (epoch, viewports) = match namespace.read() {
|
|
Ok(current) => {
|
|
let mut viewports: std::collections::BTreeMap<_, _> = current
|
|
.window_viewports
|
|
.iter()
|
|
.filter(|(id, _)| {
|
|
**id == crate::window::MAIN_WINDOW
|
|
|| (current.runtime_session != 0
|
|
&& current.document.windows.get(id).is_some_and(|view| {
|
|
view.frame.session == current.runtime_session
|
|
}))
|
|
})
|
|
.map(|(id, size)| (*id, *size))
|
|
.collect();
|
|
if let Some(viewport) = current.viewport {
|
|
viewports.insert(crate::window::MAIN_WINDOW, viewport);
|
|
}
|
|
(current.submission, viewports)
|
|
}
|
|
Err(_) => return,
|
|
};
|
|
let Some((owner, runtime)) = &mut self.repl else {
|
|
return;
|
|
};
|
|
if *owner != epoch {
|
|
self.repl = None;
|
|
return;
|
|
}
|
|
if runtime.is_faulted() {
|
|
return;
|
|
}
|
|
let id = runtime.id;
|
|
let result = catch_unwind(AssertUnwindSafe(|| -> Result<_> {
|
|
let document = runtime.resize_windows(&viewports)?;
|
|
Ok((document, runtime.take_window_commands()?))
|
|
}));
|
|
let mut current = match namespace.write() {
|
|
Ok(current) => current,
|
|
Err(_) => return,
|
|
};
|
|
if current.submission != epoch || current.runtime_session != id {
|
|
return;
|
|
}
|
|
let (document, commands) = match result {
|
|
Ok(Ok((document, commands))) => {
|
|
if let Some(document) = document {
|
|
(document, Some(commands))
|
|
} else {
|
|
let projected = !commands.is_empty();
|
|
current.publish_window_commands(id, commands);
|
|
drop(current);
|
|
if projected {
|
|
notify();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
failure => {
|
|
let mut document = current.document.clone();
|
|
document.frame.fault = Some(match failure {
|
|
Ok(Err(error)) => format!("Viewport update failed: {error:#}")
|
|
.chars()
|
|
.take(1024)
|
|
.collect(),
|
|
_ => {
|
|
self.repl = None;
|
|
current.runtime_session = 0;
|
|
current.window_commands.clear();
|
|
"Runtime discarded after an internal viewport failure".into()
|
|
}
|
|
});
|
|
for view in document.windows.values_mut() {
|
|
view.frame.fault = document.frame.fault.clone();
|
|
}
|
|
(document, None)
|
|
}
|
|
};
|
|
let changed = matches!(current.commit(epoch, document), Ok(Some(_)));
|
|
if changed && let Some(commands) = commands {
|
|
current.publish_window_commands(id, commands);
|
|
}
|
|
drop(current);
|
|
if changed {
|
|
notify();
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
self.refresh_viewport(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;
|
|
for document in current.document.windows.values_mut() {
|
|
document.frame.session = 0;
|
|
document.frame.focus = None;
|
|
}
|
|
current.runtime_session = 0;
|
|
current.window_commands.clear();
|
|
}
|
|
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"
|
|
| "scroll"
|
|
| "tab-activate"
|
|
| "tab-close"
|
|
| "tab-detach"
|
|
| "window-close"
|
|
) {
|
|
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 = if input.window == crate::window::MAIN_WINDOW {
|
|
¤t.document.frame
|
|
} else {
|
|
let Some(document) = current.document.windows.get(&input.window) else {
|
|
return error_response(StatusCode(409), "window is no longer active");
|
|
};
|
|
&document.frame
|
|
};
|
|
if input.kind == "window-close"
|
|
&& frame.window == input.window
|
|
&& frame.session == input.session
|
|
{
|
|
let owns = self.repl.as_ref().is_some_and(|(owner, runtime)| {
|
|
*owner == epoch && runtime.id == input.session && input.session != 0
|
|
});
|
|
let stopped = frame.fault.is_some()
|
|
|| !owns
|
|
|| self
|
|
.repl
|
|
.as_ref()
|
|
.is_some_and(|(_, runtime)| runtime.is_faulted());
|
|
if stopped {
|
|
drop(current);
|
|
return self.close_inactive_window(&input, epoch, namespace, notify);
|
|
}
|
|
}
|
|
if frame.window != input.window
|
|
|| frame.session != input.session
|
|
|| (input.kind != "window-close" && frame.layout_version != input.layout_version)
|
|
|| frame.fault.is_some()
|
|
|| if input.kind == "window-close" {
|
|
false
|
|
} else if matches!(
|
|
input.kind.as_str(),
|
|
"tab-activate" | "tab-close" | "tab-detach"
|
|
) {
|
|
!frame.tabs.iter().any(|tab| tab.id == input.mount)
|
|
} else {
|
|
!frame.mounts.iter().any(|mount| {
|
|
mount.id == input.mount
|
|
&& (mount.interactive
|
|
|| input.kind == "scroll" && mount.scroll.is_some())
|
|
})
|
|
}
|
|
|| self.repl.as_ref().is_none_or(|(context, runtime)| {
|
|
*context != epoch
|
|
|| runtime.id != input.session
|
|
|| (input.kind != "window-close" && !runtime.accepts_window(input.window))
|
|
})
|
|
{
|
|
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 = match self.next_session.checked_add(1) {
|
|
Some(id) => id,
|
|
None => return error_response(StatusCode(500), "session identities exhausted"),
|
|
};
|
|
let viewport = namespace.read().ok().and_then(|current| current.viewport);
|
|
match endor_host::ReplSession::new_with_viewport(self.next_session, viewport) {
|
|
Ok(session) => {
|
|
if let Ok(mut current) = namespace.write()
|
|
&& current.submission == epoch
|
|
{
|
|
current.publish_window_commands(session.id, Vec::new());
|
|
}
|
|
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(|| -> Result<_> {
|
|
let result = if let Some(event) = &event {
|
|
session.dispatch(event)?
|
|
} else {
|
|
session.execute(&job.source, &job.namespace)?
|
|
};
|
|
Ok((result, session.take_window_commands()?))
|
|
}));
|
|
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, commands))) => {
|
|
let projected = !commands.is_empty();
|
|
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()),
|
|
}
|
|
}
|
|
current.publish_window_commands(id, commands);
|
|
let revision = current.revision;
|
|
drop(current);
|
|
if rendered || projected {
|
|
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();
|
|
let fault: String = format!("{error:#}").chars().take(1024).collect();
|
|
document.frame.fault = Some(fault.clone());
|
|
for view in document.windows.values_mut() {
|
|
view.frame.fault = Some(fault.clone());
|
|
}
|
|
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());
|
|
for view in document.windows.values_mut() {
|
|
view.frame.fault = document.frame.fault.clone();
|
|
}
|
|
current.runtime_session = 0;
|
|
current.window_commands.clear();
|
|
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(|| {
|
|
let viewport = namespace
|
|
.read()
|
|
.map_err(|_| anyhow::anyhow!("namespace lock poisoned"))?
|
|
.viewport;
|
|
let (runtime, document) = if viewport.is_some() {
|
|
endor_host::ReplSession::load_with_viewport(
|
|
&job.source,
|
|
&job.namespace,
|
|
job.submission,
|
|
id,
|
|
viewport,
|
|
)?
|
|
} else {
|
|
endor_host::ReplSession::load(&job.source, &job.namespace, job.submission, id)?
|
|
};
|
|
let commands = runtime.take_window_commands()?;
|
|
Ok::<_, anyhow::Error>((runtime, document, commands))
|
|
}));
|
|
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, commands))) => match current.commit(job.submission, document)
|
|
{
|
|
Ok(Some(revision)) => {
|
|
current.publish_window_commands(id, commands);
|
|
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.
|
|
|
|
Window is a host-provided managed resource: ui.window(), page.window(), page.open(window), window.pages()/id()/viewport(), resize(width,height), setTitle(text) and focus(). Requests project to their actual native window only after successful input/candidate completion and carry the current runtime session. Resize is asynchronous and the reported viewport is authoritative; this host accepts 1..4096 logical dimensions with a native 720x480 minimum. Failed input/candidates discard pending requests and read-only rendering rejects window effects. Open Pages appear in native tab chrome, with page.title, click activation/close, Ctrl+Tab cycling and Cmd/Ctrl+W close when page keyboard focus is active. Overflow arrows reach additional pages. These operations call actual Page methods in the same VM; tab IDs are weak runtime projections and add no GC roots. The 36-logical-pixel bar reduces Page constraint height; window/ui viewport reports the full inner size and native pointer positions are translated into content coordinates. Window() now creates additional native windows in the same VM, up to 16 open resources. Page.open(window)/moveToWindow(window) preserve Page and business identity while replacing Mounts; detachToWindow() creates and returns a new Window. Dragging a tab away from its strip invokes the same method. Window.close()/secondary OS close remove Page roots, unmount, and retire the resource context; retained Pages can reopen in another live window, and closed Window resources reject viewport/resize/title/focus operations. Closing the primary OS shell exits the application. Secondary viewport inputs and frame publication use the same worker. Secondary close after callback/rendering failure uses host-only teardown without invoking source callbacks or resuming execution. After reset/discard, closing an old picture never touches a fresh VM resource with the same number; viewport inputs are also generation-scoped. A teardown failure discards the affected VM and disables remaining pictures. Native initialization/surface errors are reported rather than panicking; failed secondary creation leaves a managed resource closable from Console. Existing-window drag/drop, release-point placement and full application-exit notifications remain pending.
|
|
|
|
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. Page constraints now use the current logical window viewport. Column/Button propagate cross-axis width and Row propagates cross-axis height; desired sizes are clamped. ui.viewport() provides a managed snapshot whose private input participates in rendering dependencies. Native resize updates are coalesced on the same worker without adding source inputs. Source measurement caches use Mount/method/children/constraints and actual subcall object reads, including returned-result decoding; Mount-reading methods conservatively bypass caching. ui.stats() exposes sourceMeasureHits/sourceMeasureMisses outside read-only rendering. General constraint negotiation, wrapping and scrolling 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.
|
|
|
|
ScrollBlock(width,height,child) is a ContainerBlock with managed viewport dimensions and independent per-Mount offsets. mount.scrollTo(x,y), scrollBy(dx,dy), scrollPosition() and scrollIntoView() operate on actual occurrences. Position snapshots expose x/y/maxX/maxY from committed layout; relative requests accumulate and offsets clamp after content/viewport changes. Detached Mounts and read-only rendering mutations are rejected. scrollIntoView and requestFocus adjust nested scrollable ancestors. Native/source paint, focus outlines, hit testing and pointer selection share translated geometry and ancestor clips. Remounting starts a new offset. Paint x/y may be signed after scrolling; dimensions/radii remain nonnegative and coordinate budgets still apply. See examples/scroll.endor. Wheel chooses one eligible ancestor (not per-axis split); native scrollbar dragging, touch inertia and PageUp/PageDown remain pending.
|
|
|
|
Native wheel POST /events uses kind scroll with the normal window/session/layout_version, a scrollable mount ID, point=[x,y], delta=[dx,dy], and no press_point. Deltas are signed finite logical pixels within +/-1000000; positive moves toward the end of content. Other kinds must omit delta. Native Shift+wheel maps to horizontal. The host checks the active committed frame, target attachment and clipped viewport before invoking Mount.scrollBy. These fields are transport metadata; source code uses the Endor methods above.
|
|
|
|
Trait definitions Visual, EventTarget, Children and Presentable are managed nominal capabilities. ui.implement(type,capability) explicitly declares an implementation after required-method/positional-arity checks; ui.implements(type,capability) and ui.supports(value,capability) query it. Block declares Visual/EventTarget; ContainerBlock declares Children, inherited by Page. Same-name methods alone never declare an implementation. Source subclass construction and host method replacement validate inherited/affected descendant contracts. Native callable signatures are trusted; full parameter/return annotation and effect checks and formal trait syntax remain pending. EventTarget requires canonical on/dispatch/onEvent arities 3/2/1 after self; native optional convenience forms remain.
|
|
|
|
For a plain object explicitly implementing Presentable, present(self, window: Window): Block is invoked by ui.present(value) or ui.open(value), using the current managed Window and the same VM budget. The result must be an actual Block. Blocks pass through directly; unregistered values retain the summary fallback. Source views retain their business object via managed fields/callbacks/bindings, as in examples/presentable.endor. Viewing function definitions does not itself execute them. Rendering methods still run read-only with nominal result checks.
|
|
|
|
Block.bind(property, compute) binds an existing managed Block field to a zero-argument function and returns Subscription (disconnect/isConnected). This includes source-defined fields and native TextBlock.text/size/color, RectBlock.width/height/radius/color, Spacer.size and container padding/gap. Native fields can also be assigned directly. The target content owns compute/captures; tokens and host registry are weak. Rebinding disconnects the old connection; disconnect keeps the last value. Actual read dependencies are tracked at object granularity, including conservative global reads; no explicit source-list parameter is required. Hidden retained Blocks update at successful input/event stabilization boundaries before all windows publish. Computes are read-only for existing objects and may allocate results. They have 100000 instructions/128 frames each, 128 active bindings per Block and a 10000-computation refresh limit that errors rather than truncates propagation. Binding failure stops the live VM and preserves published pictures. Non-Block targets, explicit dependency lists and general type/effect checking remain pending. See examples/property-bindings.endor.
|
|
|
|
Native input POST /events is a transport envelope, not Endor source. Each frame and input carries an opaque runtime window identity; omitted input window defaults to 1 for existing clients. GET /code exposes X-Tessera-Window. A window/session/layout identity mismatch is rejected before invoking code. Tab actions use kind tab-activate/tab-close/tab-detach and the mount wire field carries the opaque tab ID from frame.tabs; point/press_point must be omitted. All spatial/tab actions validate session and layout_version before dispatch. Resource window-close uses mount=0 and no points; it validates the live window/session rather than its old layout version. 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,
|
|
};
|
|
|
|
#[test]
|
|
fn preserved_picture_close_does_not_retire_same_number_in_fresh_console_vm() {
|
|
let (_, mut picture) = endor_host::ReplSession::load(
|
|
include_str!("../examples/windows.endor"),
|
|
"app.main",
|
|
1,
|
|
150,
|
|
)
|
|
.unwrap();
|
|
picture.frame.session = 0;
|
|
for view in picture.windows.values_mut() {
|
|
view.frame.session = 0;
|
|
}
|
|
let mut fresh = endor_host::ReplSession::new(151).unwrap();
|
|
assert!(
|
|
fresh
|
|
.execute("pending=Window()\nui.window().resize(0,600)", "app.main")
|
|
.is_err()
|
|
);
|
|
assert!(fresh.accepts_window(2));
|
|
let namespace = Arc::new(RwLock::new(NamespaceState {
|
|
document: picture,
|
|
submission: 1,
|
|
runtime_session: 151,
|
|
..NamespaceState::default()
|
|
}));
|
|
let mut worker = Worker {
|
|
repl: Some((1, fresh)),
|
|
next_session: 151,
|
|
};
|
|
let notify: Notify = Arc::new(|| {});
|
|
namespace.write().unwrap().window_viewports.insert(
|
|
2,
|
|
crate::layout::Size {
|
|
width: 800.0,
|
|
height: 600.0,
|
|
},
|
|
);
|
|
worker.refresh_viewport(&namespace, ¬ify);
|
|
assert_eq!(
|
|
namespace.read().unwrap().document.windows[&2].frame.session,
|
|
0
|
|
);
|
|
let response = worker.close_inactive_window(
|
|
&crate::events::Input {
|
|
window: 2,
|
|
session: 0,
|
|
layout_version: 0,
|
|
mount: 0,
|
|
kind: "window-close".into(),
|
|
point: None,
|
|
delta: None,
|
|
press_point: None,
|
|
},
|
|
1,
|
|
&namespace,
|
|
¬ify,
|
|
);
|
|
assert_eq!(response.status_code(), StatusCode(200));
|
|
assert!(namespace.read().unwrap().document.windows.is_empty());
|
|
let runtime = &mut worker.repl.as_mut().unwrap().1;
|
|
assert_eq!(runtime.id, 151);
|
|
assert!(runtime.accepts_window(2));
|
|
assert_eq!(
|
|
runtime.execute("pending.id()", "app.main").unwrap().value,
|
|
"2"
|
|
);
|
|
}
|
|
|
|
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"));
|
|
}
|
|
}
|