use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use percent_encoding::percent_decode_str;
use pointlock_ir::FlowIR;
use pointlock_store::{Store, StoreError};
use serde::Serialize;
use serde_json::json;
use crate::{Failure, exit};
pub struct ServeConfig {
pub store_dir: PathBuf,
pub lockfile_path: Option<PathBuf>,
pub artifacts_dir: Option<PathBuf>,
pub port: u16,
pub ui_dir: Option<PathBuf>,
pub vision: crate::VisionArg,
}
struct ServeCtx {
store_dir: PathBuf,
artifacts_dir: Option<PathBuf>,
ui_dir: Option<PathBuf>,
current_lockfile_digest: Option<String>,
token: String,
vision_arg: crate::VisionArg,
vision: Option<std::sync::Arc<dyn pointlock_vision::VisionVerifier>>,
}
pub fn serve(config: ServeConfig) -> Result<i32, Failure> {
Store::open(&config.store_dir)
.map_err(|err| Failure::new(exit::INTERNAL, format!("store error: {err}")))?;
let vision = crate::commands::vision_verifier(config.vision)?;
let current_lockfile_digest = config
.lockfile_path
.as_deref()
.map(crate::commands::load_lockfile)
.transpose()?
.map(|lockfile| lockfile.digest.to_string());
let server = tiny_http::Server::http(("127.0.0.1", config.port))
.map_err(|err| Failure::new(exit::INTERNAL, format!("bind 127.0.0.1: {err}")))?;
let port = server
.server_addr()
.to_ip()
.map(|addr| addr.port())
.unwrap_or(config.port);
let ctx = Arc::new(ServeCtx {
store_dir: config.store_dir,
artifacts_dir: config.artifacts_dir,
ui_dir: config.ui_dir,
current_lockfile_digest,
token: uuid::Uuid::new_v4().simple().to_string(),
vision_arg: config.vision,
vision,
});
println!("pointlock projection host (spine §10.4 canonical form)");
if ctx.ui_dir.is_some() {
println!(" http://127.0.0.1:{port}/?token={}", ctx.token);
}
println!(" http://127.0.0.1:{port}/api/flows?token={}", ctx.token);
println!(" (the token is a temporary capability; the listener is loopback-only)");
for request in server.incoming_requests() {
let ctx = Arc::clone(&ctx);
std::thread::spawn(move || handle(request, &ctx));
}
Ok(exit::PASS)
}
struct Reply {
status: u16,
content_type: String,
body: Vec<u8>,
extra_headers: Vec<(&'static str, &'static str)>,
}
impl Reply {
fn json(status: u16, value: &impl Serialize) -> Reply {
let mut body = serde_json::to_vec_pretty(value).unwrap_or_else(|_| b"{}".to_vec());
body.push(b'\n');
Reply {
status,
content_type: "application/json".to_owned(),
body,
extra_headers: Vec::new(),
}
}
fn error(status: u16, message: impl Into<String>) -> Reply {
Reply::json(status, &json!({ "error": message.into() }))
}
}
fn handle(mut request: tiny_http::Request, ctx: &ServeCtx) {
let url = request.url().to_owned();
let (path, query) = split_url(&url);
if matches!(request.method(), tiny_http::Method::Get)
&& let Some(ui_dir) = &ctx.ui_dir
&& !path.starts_with("/api/")
&& !path.starts_with("/evidence/")
{
respond_reply(request, static_file(ui_dir, &path));
return;
}
if query.get("token") != Some(&ctx.token) {
respond_reply(
request,
Reply::error(401, "missing or wrong token; use the startup-printed URL"),
);
return;
}
let is_get = matches!(request.method(), tiny_http::Method::Get);
if is_get && path == "/api/inbox/stream" {
stream_revisions(request, ctx, None);
return;
}
if is_get
&& let Some(run_id) = path
.strip_prefix("/api/runs/")
.and_then(|rest| rest.strip_suffix("/stream"))
&& !run_id.contains('/')
{
let run_id = decode(run_id);
if let Err(err) = Store::open(&ctx.store_dir).and_then(|store| store.revision(&run_id)) {
respond_reply(request, store_reply(err));
return;
}
stream_revisions(request, ctx, Some(run_id));
return;
}
let reply = if is_get {
route_get(ctx, &path, &query)
} else if matches!(request.method(), tiny_http::Method::Post)
&& path.starts_with("/api/repair/")
{
match read_body(&mut request) {
Ok(body) => route_repair(ctx, &path, &body),
Err(reply) => reply,
}
} else {
route_non_get(&path)
};
respond_reply(request, reply);
}
fn read_body(request: &mut tiny_http::Request) -> Result<serde_json::Value, Reply> {
use std::io::Read as _;
let mut raw = String::new();
let mut reader = request.as_reader().take(1024 * 1024);
if reader.read_to_string(&mut raw).is_err() {
return Err(Reply::error(400, "unreadable request body"));
}
serde_json::from_str(&raw).map_err(|err| Reply::error(400, format!("body is not JSON: {err}")))
}
fn safe_content_type(media_type: &str) -> &str {
let ok = !media_type.is_empty()
&& media_type.len() <= 200
&& media_type
.bytes()
.all(|byte| (0x20..=0x7e).contains(&byte) && byte != b'"');
if ok {
media_type
} else {
"application/octet-stream"
}
}
fn respond_reply(request: tiny_http::Request, reply: Reply) {
let content_type = safe_content_type(&reply.content_type).to_owned();
let mut response = tiny_http::Response::from_data(reply.body)
.with_status_code(tiny_http::StatusCode(reply.status))
.with_header(
tiny_http::Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())
.expect("sanitized ascii header"),
);
for (name, value) in reply.extra_headers {
if let Ok(header) = tiny_http::Header::from_bytes(name.as_bytes(), value.as_bytes()) {
response = response.with_header(header);
}
}
let _ = request.respond(response);
}
fn static_file(ui_dir: &Path, path: &str) -> Reply {
let relative = if path == "/" {
"index.html"
} else {
&path[1..]
};
let mut resolved = ui_dir.to_path_buf();
for segment in relative.split('/') {
let segment = decode(segment);
if segment.is_empty() || segment == "." || segment == ".." || segment.contains(['/', '\\'])
{
return Reply::error(404, "no such asset");
}
resolved.push(segment);
}
match std::fs::read(&resolved) {
Ok(bytes) => Reply {
status: 200,
content_type: mime_of(&resolved).to_owned(),
body: bytes,
extra_headers: vec![("X-Content-Type-Options", "nosniff")],
},
Err(_) => Reply::error(404, "no such asset"),
}
}
fn mime_of(path: &Path) -> &'static str {
match path.extension().and_then(|ext| ext.to_str()) {
Some("html") => "text/html; charset=utf-8",
Some("js") => "text/javascript",
Some("css") => "text/css",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("ico") => "image/x-icon",
Some("json" | "map") => "application/json",
Some("woff2") => "font/woff2",
_ => "application/octet-stream",
}
}
fn split_url(url: &str) -> (String, BTreeMap<String, String>) {
let (path, query) = match url.split_once('?') {
Some((path, query)) => (path, query),
None => (url, ""),
};
let mut map = BTreeMap::new();
for pair in query.split('&').filter(|pair| !pair.is_empty()) {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
map.insert(decode(key), decode(value));
}
(path.to_owned(), map)
}
fn decode(text: &str) -> String {
percent_decode_str(text)
.decode_utf8()
.map(|value| value.into_owned())
.unwrap_or_else(|_| text.to_owned())
}
fn route_non_get(path: &str) -> Reply {
if path.starts_with("/api/repair/") {
return Reply::error(405, "repair endpoints are POST with a JSON body");
}
if path.starts_with("/api/inbox/") && path.ends_with("/respond") {
return Reply::error(
501,
"the webUi collect channel is reserved for v0.2 (06 §4.2); respond via pointlock-human-cli",
);
}
Reply::error(405, "read-only projection host: GET only")
}
fn route_get(ctx: &ServeCtx, path: &str, query: &BTreeMap<String, String>) -> Reply {
let store = match Store::open(&ctx.store_dir) {
Ok(store) => store,
Err(err) => return Reply::error(500, format!("store error: {err}")),
};
if path.starts_with("/api/repair/") {
return Reply::error(405, "repair endpoints are POST with a JSON body");
}
if path == "/api/flows" {
return flows_index(ctx, &store);
}
if let Some(flow_id) = path.strip_prefix("/api/flows/")
&& !flow_id.is_empty()
&& !flow_id.contains('/')
{
return flow_detail(ctx, &store, &decode(flow_id), query.get("irHash"));
}
if path == "/api/runs" {
return runs_index(&store, query.get("flowId"));
}
if let Some(rest) = path.strip_prefix("/api/runs/") {
if let Some((run_id, tail)) = rest.split_once('/') {
let run_id = decode(run_id);
return match tail {
"timeline" => timeline(&store, &run_id, query),
"revision" => revision(&store, &run_id),
_ => match tail.strip_prefix("steps/") {
Some(encoded) => dossier(ctx, &store, &run_id, &decode(encoded)),
None => Reply::error(404, format!("no such endpoint: {path}")),
},
};
}
if !rest.is_empty() {
return overview(&store, &decode(rest));
}
}
if path == "/api/inbox" {
return inbox(&store);
}
if path == "/api/inbox/revision" {
return match store.global_revision() {
Ok(revision) => Reply::json(
200,
&json!({ "projectionVersion": 1, "revision": revision }),
),
Err(err) => store_reply(err),
};
}
if let Some(sha256) = path.strip_prefix("/evidence/")
&& !sha256.is_empty()
{
return evidence(&store, sha256);
}
Reply::error(404, format!("no such endpoint: {path}"))
}
fn body_str<'a>(body: &'a serde_json::Value, key: &str) -> Option<&'a str> {
body.get(key).and_then(|value| value.as_str())
}
fn resume_locks() -> &'static std::sync::Mutex<std::collections::BTreeSet<String>> {
static LOCKS: std::sync::OnceLock<std::sync::Mutex<std::collections::BTreeSet<String>>> =
std::sync::OnceLock::new();
LOCKS.get_or_init(Default::default)
}
fn run_self(args: &[&str]) -> Result<std::process::Output, Reply> {
let exe = std::env::current_exe()
.map_err(|err| Reply::error(500, format!("cannot resolve the pointlock binary: {err}")))?;
std::process::Command::new(exe)
.args(args)
.output()
.map_err(|err| Reply::error(500, format!("spawn pointlock: {err}")))
}
fn route_repair(ctx: &ServeCtx, path: &str, body: &serde_json::Value) -> Reply {
match path {
"/api/repair/compile" => repair_compile(ctx, body),
"/api/repair/align-preview" => repair_align_preview(ctx, body),
"/api/repair/resume" => repair_resume(ctx, body),
_ => Reply::error(404, format!("no such repair action: {path}")),
}
}
fn repair_compile(ctx: &ServeCtx, body: &serde_json::Value) -> Reply {
let Some(flow_path) = body_str(body, "flowPath") else {
return Reply::error(400, "body needs flowPath (the *.flow.yaml to compile)");
};
let out_path = match body_str(body, "outPath") {
Some(out) => PathBuf::from(out),
None => match &ctx.artifacts_dir {
Some(dir) => {
let stem = Path::new(flow_path)
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("repaired");
dir.join(format!("{stem}.ir.json"))
}
None => {
return Reply::error(
400,
"no outPath and the host has no --artifacts dir to write into",
);
}
},
};
let out_str = out_path.display().to_string();
let mut args = vec!["compile", "--out", &out_str, "--format", "json"];
if let Some(lockfile) = body_str(body, "lockfilePath") {
args.extend(["--lockfile", lockfile]);
}
if let Some(provider) = body_str(body, "provider") {
args.extend(["--provider", provider]);
}
args.extend(["--", flow_path]);
let output = match run_self(&args) {
Ok(output) => output,
Err(reply) => return reply,
};
if output.status.success() {
let ir_hash = std::fs::read_to_string(&out_path)
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.and_then(|value| {
value
.get("irHash")
.or_else(|| value.get("root").and_then(|root| root.get("irHash")))
.and_then(|hash| hash.as_str())
.map(str::to_owned)
});
return Reply::json(
200,
&json!({ "ok": true, "artifact": out_str, "irHash": ir_hash }),
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
match serde_json::from_str::<serde_json::Value>(&stdout) {
Ok(diagnostics) if diagnostics.is_array() => {
Reply::json(200, &json!({ "ok": false, "diagnostics": diagnostics }))
}
_ => Reply::error(
500,
format!(
"compile failed without JSON diagnostics: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
),
}
}
fn repair_align_preview(ctx: &ServeCtx, body: &serde_json::Value) -> Reply {
let (Some(run_id), Some(flow_ir_path)) =
(body_str(body, "runId"), body_str(body, "flowIrPath"))
else {
return Reply::error(400, "body needs runId and flowIrPath");
};
let (flow, subflow_list) =
match crate::commands::load_artifact_for_serve(Path::new(flow_ir_path)) {
Ok(loaded) => loaded,
Err(failure) => return Reply::error(400, failure.message),
};
let subflows: BTreeMap<pointlock_ir::Hash, FlowIR> = subflow_list
.into_iter()
.map(|callee| (callee.ir_hash.clone(), callee))
.collect();
let platform = match body_str(body, "lockfilePath") {
Some(lockfile_path) => match crate::commands::load_lockfile(Path::new(lockfile_path)) {
Ok(lockfile) => Some(crate::commands::wire_str(&lockfile.device.platform)),
Err(failure) => return Reply::error(400, failure.message),
},
None => None,
};
let store = match Store::open(&ctx.store_dir) {
Ok(store) => store,
Err(err) => return store_reply(err),
};
match store.run_status(run_id) {
Ok(pointlock_store::RunStatus::Running) => {
return Reply::error(409, "the run is still running; preview after it suspends");
}
Ok(_) => {}
Err(err) => return store_reply(err),
}
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(err) => return Reply::error(500, format!("tokio runtime: {err}")),
};
let force_reexec: Vec<String> = body
.get("forceReexecute")
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
let old_flow_ir: Option<pointlock_ir::FlowIR> = match body_str(body, "oldIrPath") {
None => None,
Some(path) => match std::fs::read_to_string(path)
.map_err(|err| err.to_string())
.and_then(|raw| serde_json::from_str(&raw).map_err(|err| err.to_string()))
{
Ok(flow) => Some(flow),
Err(err) => return Reply::error(400, format!("oldIrPath unreadable: {err}")),
},
};
match runtime.block_on(pointlock_runner::Runner::align_preview(
&flow,
&subflows,
run_id,
&store,
platform.as_deref(),
ctx.vision.as_deref(),
&force_reexec,
old_flow_ir.as_ref(),
)) {
Ok(report) => {
let rendered = report
.resume_point
.as_ref()
.map(|path| pointlock_ir::render_run_path(path));
Reply::json(
200,
&json!({
"projectionVersion": 1,
"runId": run_id,
"report": report,
"resumePointRendered": rendered,
}),
)
}
Err(pointlock_runner::RunnerError::M0Unsupported { detail }) => Reply::error(400, detail),
Err(pointlock_runner::RunnerError::Store(err)) => store_reply(err),
Err(err) => Reply::error(500, err.to_string()),
}
}
fn repair_resume(ctx: &ServeCtx, body: &serde_json::Value) -> Reply {
let (Some(run_id), Some(flow_ir_path)) =
(body_str(body, "runId"), body_str(body, "flowIrPath"))
else {
return Reply::error(400, "body needs runId and flowIrPath");
};
match Store::open(&ctx.store_dir).and_then(|store| store.run_status(run_id)) {
Ok(pointlock_store::RunStatus::Running) => {
return Reply::error(
409,
"the run is still running; resume applies after it suspends",
);
}
Ok(_) => {}
Err(err) => return store_reply(err),
}
{
let mut locks = resume_locks().lock().expect("resume lock poisoned");
if !locks.insert(run_id.to_owned()) {
return Reply::error(409, "a resume for this run is already in flight");
}
}
let unlock = |run_id: &str| {
resume_locks()
.lock()
.expect("resume lock poisoned")
.remove(run_id);
};
let store_str = ctx.store_dir.display().to_string();
let mut args = vec!["resume", "--store", &store_str, "--run", run_id];
if ctx.vision_arg == crate::VisionArg::Anthropic {
args.extend(["--vision", "anthropic"]);
}
if let Some(lockfile) = body_str(body, "lockfilePath") {
args.extend(["--lockfile", lockfile]);
}
if let Some(provider) = body_str(body, "provider") {
args.extend(["--provider", provider]);
}
if let Some(daemon_cmd) = body_str(body, "daemonCmd") {
args.extend(["--daemon-cmd", daemon_cmd]);
}
let daemon_env: Vec<String> = body
.get("daemonEnv")
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
for item in &daemon_env {
args.extend(["--daemon-env", item]);
}
let allow_reexec: Vec<String> = body
.get("allowMutatingReexec")
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
for step_id in &allow_reexec {
args.extend(["--allow-mutating-reexec", step_id]);
}
let force_reexec: Vec<String> = body
.get("forceReexecute")
.and_then(|value| value.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
for step_id in &force_reexec {
args.extend(["--force-reexecute", step_id]);
}
args.extend(["--", flow_ir_path]);
let output = match run_self(&args) {
Ok(output) => output,
Err(reply) => {
unlock(run_id);
return reply;
}
};
unlock(run_id);
Reply::json(
200,
&json!({
"exitCode": output.status.code(),
"stdout": String::from_utf8_lossy(&output.stdout),
"stderr": String::from_utf8_lossy(&output.stderr),
}),
)
}
fn store_reply(err: StoreError) -> Reply {
match err {
StoreError::UnknownRun(_)
| StoreError::UnknownStepInstance { .. }
| StoreError::NoCheckpoint(_) => Reply::error(404, err.to_string()),
StoreError::AmbiguousStep { .. } | StoreError::BadRunPath { .. } => {
Reply::error(400, err.to_string())
}
other => Reply::error(500, other.to_string()),
}
}
struct ScannedArtifact {
flow: FlowIR,
file: PathBuf,
modified_at_ms: u64,
}
fn scan_artifacts(dir: &Path) -> (Vec<ScannedArtifact>, Vec<FlowIR>) {
let mut roots = Vec::new();
let mut pool = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return (roots, pool);
};
for entry in entries.flatten() {
let file = entry.path();
if file.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let Ok(loaded) = crate::commands::load_artifact_for_serve(&file) else {
continue; };
let modified_at_ms = entry
.metadata()
.and_then(|meta| meta.modified())
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0);
pool.push(loaded.0.clone());
pool.extend(loaded.1.clone());
roots.push(ScannedArtifact {
flow: loaded.0,
file,
modified_at_ms,
});
}
(roots, pool)
}
fn artifact_pool(ctx: &ServeCtx) -> Vec<FlowIR> {
match &ctx.artifacts_dir {
Some(dir) => scan_artifacts(dir).1,
None => Vec::new(),
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct VersionEntry {
ir_hash: String,
lockfile_digest: String,
file: String,
modified_at_ms: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RunIndexEntry {
run_id: String,
status: String,
created_at_ms: u64,
ir_hash: String,
device_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
flow_verdict_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
flow_verdict_degraded: Option<bool>,
}
fn run_rows(store: &Store, flow_id: Option<&str>) -> Result<Vec<RunIndexEntry>, StoreError> {
let mut rows = Vec::new();
for run in store.list_runs()? {
if flow_id.is_some_and(|wanted| wanted != run.flow_id) {
continue;
}
let overview = pointlock_store::projection::run_overview(store, &run.run_id)?;
rows.push(RunIndexEntry {
run_id: run.run_id,
status: overview.status,
created_at_ms: overview.created_at_ms,
ir_hash: overview.ir_hash,
device_id: overview.device_id,
flow_verdict_status: overview.flow_verdict_status,
flow_verdict_degraded: overview.flow_verdict_degraded,
});
}
Ok(rows)
}
fn flows_index(ctx: &ServeCtx, store: &Store) -> Reply {
let roots = match &ctx.artifacts_dir {
Some(dir) => scan_artifacts(dir).0,
None => Vec::new(),
};
let mut by_flow: BTreeMap<String, Vec<&ScannedArtifact>> = BTreeMap::new();
for artifact in &roots {
by_flow
.entry(artifact.flow.flow_id.to_string())
.or_default()
.push(artifact);
}
let runs = match store.list_runs() {
Ok(runs) => runs,
Err(err) => return store_reply(err),
};
for run in &runs {
by_flow.entry(run.flow_id.clone()).or_default();
}
let mut flows = Vec::new();
for (flow_id, mut artifacts) in by_flow {
artifacts.sort_by_key(|artifact| artifact.modified_at_ms);
let versions: Vec<VersionEntry> = artifacts
.iter()
.map(|artifact| VersionEntry {
ir_hash: artifact.flow.ir_hash.to_string(),
lockfile_digest: artifact.flow.lockfile_digest.to_string(),
file: artifact.file.display().to_string(),
modified_at_ms: artifact.modified_at_ms,
})
.collect();
let rows = match run_rows(store, Some(&flow_id)) {
Ok(rows) => rows,
Err(err) => return store_reply(err),
};
flows.push(json!({
"flowId": flow_id,
"latestIrHash": versions.last().map(|version| version.ir_hash.clone()),
"versions": versions,
"runs": rows,
}));
}
Reply::json(
200,
&json!({
"projectionVersion": 1,
"currentLockfileDigest": ctx.current_lockfile_digest,
"flows": flows,
}),
)
}
fn flow_detail(ctx: &ServeCtx, store: &Store, flow_id: &str, ir_hash: Option<&String>) -> Reply {
let roots = match &ctx.artifacts_dir {
Some(dir) => scan_artifacts(dir).0,
None => Vec::new(),
};
let mut versions: Vec<&ScannedArtifact> = roots
.iter()
.filter(|artifact| artifact.flow.flow_id.as_ref() == flow_id)
.collect();
versions.sort_by_key(|artifact| artifact.modified_at_ms);
let selected = match ir_hash {
Some(wanted) => versions
.iter()
.find(|artifact| artifact.flow.ir_hash.to_string() == *wanted)
.copied(),
None => versions.last().copied(),
};
let Some(selected) = selected else {
if let Some(wanted) = ir_hash {
let pool = artifact_pool(ctx);
if let Some(callee) = pool.iter().find(|flow| {
flow.flow_id.as_ref() == flow_id && flow.ir_hash.to_string() == *wanted
}) {
let graph = pointlock_store::projection::flow_graph_view(callee);
return Reply::json(
200,
&json!({
"projectionVersion": 1,
"flowId": flow_id,
"versions": [],
"graph": graph,
"runs": [],
}),
);
}
}
return Reply::error(
404,
format!("no artifact for flow '{flow_id}' (scan --artifacts, check --serve flags)"),
);
};
let graph = pointlock_store::projection::flow_graph_view(&selected.flow);
let rows = match run_rows(store, Some(flow_id)) {
Ok(rows) => rows,
Err(err) => return store_reply(err),
};
Reply::json(
200,
&json!({
"projectionVersion": 1,
"flowId": flow_id,
"versions": versions
.iter()
.map(|artifact| json!({
"irHash": artifact.flow.ir_hash.to_string(),
"lockfileDigest": artifact.flow.lockfile_digest.to_string(),
"file": artifact.file.display().to_string(),
"modifiedAtMs": artifact.modified_at_ms,
}))
.collect::<Vec<_>>(),
"graph": graph,
"runs": rows,
}),
)
}
fn runs_index(store: &Store, flow_id: Option<&String>) -> Reply {
match run_rows(store, flow_id.map(String::as_str)) {
Ok(rows) => Reply::json(200, &json!({ "projectionVersion": 1, "runs": rows })),
Err(err) => store_reply(err),
}
}
fn overview(store: &Store, run_id: &str) -> Reply {
match pointlock_store::projection::run_overview(store, run_id) {
Ok(overview) => Reply::json(200, &overview),
Err(err) => store_reply(err),
}
}
fn timeline(store: &Store, run_id: &str, query: &BTreeMap<String, String>) -> Reply {
let filter = match query.get("filter").map(String::as_str) {
None | Some("all") => pointlock_store::projection::RunTimelineFilter::All,
Some("observations") => pointlock_store::projection::RunTimelineFilter::Observations,
Some("actions") => pointlock_store::projection::RunTimelineFilter::Actions,
Some("errors") => pointlock_store::projection::RunTimelineFilter::Errors,
Some("verdicts") => pointlock_store::projection::RunTimelineFilter::Verdicts,
Some(other) => {
return Reply::error(
400,
format!("unknown filter '{other}' (all|observations|actions|errors|verdicts)"),
);
}
};
let page = match parse_number(query.get("page"), 1) {
Ok(page) => page,
Err(reply) => return reply,
};
let page_size = match parse_number(query.get("pageSize"), 50) {
Ok(size) => size,
Err(reply) => return reply,
};
match pointlock_store::projection::timeline_page(store, run_id, filter, page, page_size) {
Ok(page) => Reply::json(200, &page),
Err(err) => store_reply(err),
}
}
fn parse_number(raw: Option<&String>, default: u32) -> Result<u32, Reply> {
match raw {
None => Ok(default),
Some(text) => text
.parse::<u32>()
.map_err(|_| Reply::error(400, format!("not a number: '{text}'"))),
}
}
fn dossier(ctx: &ServeCtx, store: &Store, run_id: &str, step: &str) -> Reply {
let path = match pointlock_store::projection::locate_step(store, run_id, step) {
Ok(path) => path,
Err(err) => return store_reply(err),
};
let artifacts = artifact_pool(ctx);
match pointlock_store::projection::step_dossier(store, run_id, &path, &artifacts) {
Ok(dossier) => Reply::json(200, &dossier),
Err(err) => store_reply(err),
}
}
fn revision(store: &Store, run_id: &str) -> Reply {
match store.revision(run_id) {
Ok(revision) => Reply::json(
200,
&json!({ "projectionVersion": 1, "runId": run_id, "revision": revision }),
),
Err(err) => store_reply(err),
}
}
fn inbox(store: &Store) -> Reply {
match pointlock_store::projection::human_inbox(store) {
Ok(entries) => Reply::json(200, &json!({ "projectionVersion": 1, "inbox": entries })),
Err(err) => store_reply(err),
}
}
fn evidence(store: &Store, sha256: &str) -> Reply {
if sha256.len() != 64
|| !sha256
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
{
return Reply::error(400, "evidence key must be 64 lowercase hex chars");
}
match store.evidence_meta(sha256) {
Ok(Some(meta)) => match std::fs::read(&meta.abs_path) {
Ok(bytes) => Reply {
status: 200,
content_type: meta.media_type,
body: bytes,
extra_headers: vec![
("X-Content-Type-Options", "nosniff"),
("Content-Security-Policy", "sandbox"),
],
},
Err(err) => Reply::error(500, format!("evidence bytes unreadable: {err}")),
},
Ok(None) => Reply::error(404, format!("no evidence with sha256 {sha256}")),
Err(err) => store_reply(err),
}
}
fn stream_revisions(request: tiny_http::Request, ctx: &ServeCtx, run_id: Option<String>) {
let mut writer = request.into_writer();
let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n";
if write_flush(&mut writer, head.as_bytes()).is_err() {
return;
}
let mut last: Option<u64> = None;
let mut next_heartbeat = Instant::now() + HEARTBEAT;
loop {
let revision = current_revision(ctx, run_id.as_deref());
let payload = if let Some(revision) = revision.filter(|_| revision != last) {
last = Some(revision);
Some(format!("data: {{\"revision\":{revision}}}\n\n"))
} else if Instant::now() >= next_heartbeat {
next_heartbeat = Instant::now() + HEARTBEAT;
Some(": ping\n\n".to_owned())
} else {
None
};
if let Some(payload) = payload
&& write_flush(&mut writer, payload.as_bytes()).is_err()
{
return; }
std::thread::sleep(POLL);
}
}
const POLL: Duration = Duration::from_millis(250);
const HEARTBEAT: Duration = Duration::from_secs(15);
fn write_flush(writer: &mut (impl Write + ?Sized), bytes: &[u8]) -> std::io::Result<()> {
writer.write_all(bytes)?;
writer.flush()
}
fn current_revision(ctx: &ServeCtx, run_id: Option<&str>) -> Option<u64> {
let store = Store::open(&ctx.store_dir).ok()?;
match run_id {
Some(run_id) => store.revision(run_id).ok(),
None => store.global_revision().ok(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_urls_and_decodes() {
let (path, query) = split_url("/api/runs/r1/steps/demo%40aaaaaaaa%2Fstep_a?token=t&x=1");
assert_eq!(path, "/api/runs/r1/steps/demo%40aaaaaaaa%2Fstep_a");
assert_eq!(query.get("token").map(String::as_str), Some("t"));
assert_eq!(decode("demo%40aaaaaaaa%2Fstep_a"), "demo@aaaaaaaa/step_a");
}
#[test]
fn non_get_routes_answer_typed_refusals() {
assert_eq!(route_non_get("/api/repair/compile").status, 405);
assert_eq!(route_non_get("/api/inbox/req-1/respond").status, 501);
assert_eq!(route_non_get("/api/flows").status, 405);
}
}