use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{bail, Context, Result};
use ingot_compiler::compile_path;
use ingot_diagnostics::Severity;
use ingot_language_service::LanguageService;
use ingot_studio::{Answers, Head, Method, Reply, Studio};
use serde::Serialize;
use serde_json::json;
use crate::launch;
use crate::manifest::{resolve_target, MANIFEST_NAME};
use crate::runs;
const STUDIO_SCHEMA_VERSION: u32 = 1;
const PREFERRED_PORT: u16 = 7317;
pub struct StudioConfig {
pub bind: Option<String>,
}
pub fn serve(config: &StudioConfig) -> Result<u8> {
let bind = match &config.bind {
Some(address) => address
.parse::<SocketAddr>()
.with_context(|| format!("`{address}` is not an address:port"))?,
None => SocketAddr::from(([127, 0, 0, 1], PREFERRED_PORT)),
};
let routes = Arc::new(Routes::default());
let mut studio = match Studio::start(bind, routes.clone()) {
Ok(studio) => studio,
Err(error) if config.bind.is_none() && error.kind() == std::io::ErrorKind::AddrInUse => {
Studio::start(SocketAddr::from(([127, 0, 0, 1], 0)), routes)
.context("starting the studio")?
}
Err(error) => return Err(error).context("starting the studio"),
};
println!("{}", studio.url());
eprintln!("Ingot Studio is serving on {}", studio.address());
eprintln!("open the URL above; the token in it is this process's and is stored nowhere");
eprintln!("press Ctrl-C to stop");
studio.wait();
Ok(crate::EXIT_OK)
}
#[derive(Default)]
struct Routes {
launcher: launch::Launcher,
}
impl Answers for Routes {
fn answer(&self, request: &Head, body: &[u8]) -> Reply {
let route = request.path.strip_prefix("/api/").unwrap_or_default();
let result = match (request.method, route) {
(Method::Get, "projects") => bookmarked(),
(Method::Post, "projects") => with_path(request, |path| bookmark(path, true)),
(Method::Delete, "projects") => with_path(request, |path| bookmark(path, false)),
(Method::Get, "project") => with_path(request, project),
(Method::Get, "runs") => with_path(request, |path| self.run_list(path)),
(Method::Get, "run") => with_id(request, run_detail),
(Method::Delete, "run") => with_id(request, |path, id| self.run_delete(path, id)),
(Method::Post, "run") => with_path(request, |path| self.start(path, body)),
(Method::Delete, "launch") => with_path(request, |path| self.stop(request, path)),
(Method::Post, "launches") => with_path(request, |path| {
self.launcher.clear(path);
self.run_list(path)
}),
(Method::Get, "machine") => machine(),
_ => return Reply::Unknown,
};
match result {
Ok(document) => Reply::Json(document),
Err(error) => Reply::Refused(format!("{error:#}")),
}
}
}
impl Routes {
fn run_list(&self, path: &Path) -> Result<String> {
let target = resolve_target(Some(path))?;
Ok(serde_json::to_string(&json!({
"schemaVersion": STUDIO_SCHEMA_VERSION,
"runs": runs::list(&target.out_dir),
"launches": self.launcher.of(&resolve(path)),
}))?)
}
fn start(&self, path: &Path, body: &[u8]) -> Result<String> {
let target = resolve_target(Some(path))?;
let request: launch::StartRequest = if body.is_empty() {
serde_json::from_str("{}")?
} else {
serde_json::from_slice(body).context("reading the run request")?
};
let project = resolve(&target.root);
self.launcher.start(&project, &request)?;
self.run_list(path)
}
fn run_delete(&self, path: &Path, id: &str) -> Result<String> {
let target = resolve_target(Some(path))?;
runs::delete(&target.out_dir, id)?;
self.run_list(path)
}
fn stop(&self, request: &Head, path: &Path) -> Result<String> {
let Some(pid) = request.param("pid").and_then(|pid| pid.parse::<u32>().ok()) else {
bail!("this route needs a numeric `pid` parameter");
};
self.launcher.stop(&resolve(path), pid)?;
self.run_list(path)
}
}
fn with_path(request: &Head, then: impl Fn(&Path) -> Result<String>) -> Result<String> {
let Some(path) = request.param("path") else {
bail!("this route needs a `path` parameter");
};
then(Path::new(path))
}
fn with_id(request: &Head, then: impl Fn(&Path, &str) -> Result<String>) -> Result<String> {
let (Some(path), Some(id)) = (request.param("path"), request.param("id")) else {
bail!("this route needs `path` and `id` parameters");
};
then(Path::new(path), id)
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProjectEntry {
path: String,
name: String,
version: Option<String>,
description: Option<String>,
problem: Option<String>,
runs: usize,
}
fn bookmarks_path() -> Result<PathBuf> {
if let Some(directory) = std::env::var_os("INGOT_CONFIG_DIR") {
return Ok(PathBuf::from(directory).join("projects.json"));
}
let base = if cfg!(windows) {
std::env::var_os("APPDATA").map(PathBuf::from)
} else if cfg!(target_os = "macos") {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join("Library").join("Application Support"))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join(".config"))
})
};
let Some(base) = base else {
bail!(
"no configuration directory could be found; set INGOT_CONFIG_DIR to say where the \
project list should live"
);
};
Ok(base.join("ingot").join("projects.json"))
}
fn load_bookmarks() -> Vec<PathBuf> {
let Ok(path) = bookmarks_path() else {
return Vec::new();
};
let Ok(text) = std::fs::read_to_string(path) else {
return Vec::new();
};
serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|value| {
value
.get("projects")?
.as_array()?
.iter()
.map(|entry| entry.as_str().map(PathBuf::from))
.collect::<Option<Vec<_>>>()
})
.unwrap_or_default()
}
fn save_bookmarks(projects: &[PathBuf]) -> Result<()> {
let path = bookmarks_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let document = json!({
"schemaVersion": STUDIO_SCHEMA_VERSION,
"projects": projects.iter().map(|path| path.display().to_string()).collect::<Vec<_>>(),
});
std::fs::write(
&path,
format!("{}\n", serde_json::to_string_pretty(&document)?),
)
.with_context(|| format!("writing {}", path.display()))
}
fn bookmarked() -> Result<String> {
let entries: Vec<ProjectEntry> = load_bookmarks()
.iter()
.map(PathBuf::as_path)
.map(describe)
.collect();
Ok(serde_json::to_string(&json!({
"schemaVersion": STUDIO_SCHEMA_VERSION,
"projects": entries,
}))?)
}
fn bookmark(path: &Path, add: bool) -> Result<String> {
let mut projects = load_bookmarks();
if add {
let manifest = path.join(MANIFEST_NAME);
if !manifest.is_file() {
bail!(
"{} holds no {MANIFEST_NAME}\n a project is the directory the manifest is in; \
`ingot init <name>` creates one",
path.display()
);
}
let resolved = resolve(path);
if !projects.contains(&resolved) {
projects.push(resolved);
}
} else {
let resolved = resolve(path);
projects.retain(|entry| entry != path && entry != &resolved);
}
save_bookmarks(&projects)?;
bookmarked()
}
fn resolve(path: &Path) -> PathBuf {
let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if cfg!(windows) {
let text = resolved.display().to_string();
if let Some(rest) = text.strip_prefix(r"\\?\") {
if rest.as_bytes().get(1) == Some(&b':') {
return PathBuf::from(rest);
}
}
}
resolved
}
fn describe(path: &Path) -> ProjectEntry {
let display = path.display().to_string();
let fallback = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| display.clone());
match resolve_target(Some(path)) {
Ok(target) => {
let manifest = target.manifest.as_ref();
ProjectEntry {
path: display,
name: manifest
.map(|manifest| manifest.project.name.clone())
.unwrap_or(fallback),
version: manifest.map(|manifest| manifest.project.version.clone()),
description: manifest.and_then(|manifest| manifest.project.description.clone()),
problem: None,
runs: runs::count(&target.out_dir),
}
}
Err(error) => ProjectEntry {
path: display,
name: fallback,
version: None,
description: None,
problem: Some(format!("{error:#}")),
runs: 0,
},
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct EditorNote {
code: String,
severity: &'static str,
message: String,
location: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentView {
name: String,
inputs: Vec<Field>,
outputs: Vec<Field>,
effects: Vec<String>,
tools: Vec<ToolView>,
model: String,
steps: usize,
}
#[derive(Debug, Serialize)]
struct Field {
name: String,
#[serde(rename = "type")]
ty: String,
}
#[derive(Debug, Serialize)]
struct ToolView {
name: String,
reach: Vec<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct PlanView {
server: String,
agent: String,
enforced: bool,
rendered: String,
}
fn project(path: &Path) -> Result<String> {
let target = resolve_target(Some(path))?;
let compilation = compile_path(&target.entry)
.with_context(|| format!("reading {}", target.entry.display()))?;
let service = LanguageService::new();
let checked = service.check_file(&target.entry)?;
let diagnostics: Vec<EditorNote> = checked
.diagnostics
.iter()
.map(|diagnostic| EditorNote {
code: diagnostic.code.clone(),
severity: match diagnostic.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Note => "note",
},
message: diagnostic.message.clone(),
location: format!(
"{}:{}:{}",
diagnostic.range.file,
diagnostic.range.range.start.line + 1,
diagnostic.range.range.start.character + 1
),
})
.collect();
let agents: Vec<AgentView> = compilation
.agents
.iter()
.map(|agent| AgentView {
name: agent.agent.clone(),
inputs: fields(&agent.inputs),
outputs: fields(&agent.outputs),
effects: agent.effects.clone(),
tools: agent
.tools
.iter()
.map(|tool| ToolView {
name: tool.name.clone(),
reach: tool
.scopes
.iter()
.flat_map(|(effect, values)| {
values.iter().map(move |value| format!("{effect} {value}"))
})
.collect(),
})
.collect(),
model: describe_model(&agent.requirements.model),
steps: agent.nodes.len(),
})
.collect();
let readiness = crate::doctor::report(&target, &compilation);
let (plans, problems) =
match crate::sandbox::plan_all(&compilation, &target.mcp(), &target.workspace()) {
Ok(plans) => (
plans
.into_values()
.map(|plan| PlanView {
server: plan.server.clone(),
agent: plan.agent.clone(),
enforced: plan.is_fully_enforced(),
rendered: ingot_sandbox::render(&plan),
})
.collect::<Vec<_>>(),
Vec::new(),
),
Err(problems) => (Vec::new(), problems),
};
Ok(serde_json::to_string(&json!({
"schemaVersion": STUDIO_SCHEMA_VERSION,
"path": target.root.display().to_string(),
"entry": target.entry.display().to_string(),
"workspace": target.workspace().display().to_string(),
"outDir": target.out_dir.display().to_string(),
"compiles": !compilation.has_errors(),
"diagnostics": diagnostics,
"agents": agents,
"readiness": readiness,
"boundary": { "plans": plans, "problems": problems },
"runs": runs::count(&target.out_dir),
}))?)
}
fn fields(map: &std::collections::BTreeMap<String, String>) -> Vec<Field> {
map.iter()
.map(|(name, ty)| Field {
name: name.clone(),
ty: ty.clone(),
})
.collect()
}
fn describe_model(requirement: &ingot_ir::ModelRequirement) -> String {
match requirement {
ingot_ir::ModelRequirement::Exact { reference } => reference.clone(),
other => serde_json::to_value(other)
.ok()
.and_then(|value| {
value
.get("mode")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| "unspecified".to_string()),
}
}
fn run_detail(path: &Path, id: &str) -> Result<String> {
let target = resolve_target(Some(path))?;
Ok(serde_json::to_string(&runs::read(&target.out_dir, id)?)?)
}
fn machine() -> Result<String> {
let providers: Vec<serde_json::Value> = crate::run::BUILT_IN
.iter()
.map(|built_in| {
let variables: Vec<serde_json::Value> = built_in
.variables
.iter()
.map(|variable| {
json!({ "name": variable, "set": std::env::var_os(variable).is_some() })
})
.collect();
let set = variables
.iter()
.any(|variable| variable["set"].as_bool().unwrap_or(false));
json!({
"name": built_in.name,
"protocol": built_in.protocol,
"included": built_in.included,
"declared": false,
"variables": variables,
"ready": built_in.included && set,
})
})
.collect();
let runtime = match ingot_sandbox::detect() {
Ok(runtime) => json!({
"available": true,
"program": runtime.program,
"version": runtime.version,
"error": serde_json::Value::Null,
}),
Err(error) => json!({
"available": false,
"program": serde_json::Value::Null,
"version": serde_json::Value::Null,
"error": error.to_string(),
}),
};
let detected = ingot_sandbox::detect().ok();
let images: Vec<serde_json::Value> = [
(
crate::image::reference_image(),
"contained runs happen inside this",
),
(
ingot_sandbox::DEFAULT_EGRESS_IMAGE.to_string(),
"a contained server's traffic is filtered by this",
),
]
.into_iter()
.map(|(reference, purpose)| {
let present = detected
.as_ref()
.and_then(|runtime| ingot_sandbox::image_exists(runtime, &reference).ok())
.unwrap_or(false);
json!({ "reference": reference, "purpose": purpose, "present": present })
})
.collect();
Ok(serde_json::to_string(&json!({
"schemaVersion": STUDIO_SCHEMA_VERSION,
"version": env!("CARGO_PKG_VERSION"),
"providers": providers,
"runtime": runtime,
"images": images,
}))?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::run::BUILT_IN_PROVIDERS;
#[test]
fn the_machine_page_lists_every_vendor_that_needs_no_declaring() {
let shown: Vec<&str> = crate::run::BUILT_IN
.iter()
.map(|built_in| built_in.name)
.collect();
assert_eq!(shown, BUILT_IN_PROVIDERS.to_vec());
}
#[test]
fn a_bookmarked_path_is_one_a_person_would_recognise() {
let here = resolve(Path::new("."));
assert!(here.is_absolute());
assert!(
!here.display().to_string().starts_with(r"\\?\"),
"{}",
here.display()
);
}
#[test]
fn a_verbatim_unc_path_is_left_alone() {
let unc = Path::new(r"\\?\UNC\server\share\project");
assert_eq!(resolve(unc), unc.to_path_buf());
}
#[test]
fn the_bookmark_file_holds_paths_and_nothing_else() {
let directory = std::env::temp_dir().join(format!("ingot-studio-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&directory);
std::env::set_var("INGOT_CONFIG_DIR", &directory);
save_bookmarks(&[PathBuf::from("/tmp/one"), PathBuf::from("/tmp/two")])
.expect("the list must save");
let text = std::fs::read_to_string(bookmarks_path().expect("a path"))
.expect("the list must read back");
let value: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
assert_eq!(
value
.as_object()
.expect("an object")
.keys()
.collect::<Vec<_>>(),
vec!["projects", "schemaVersion"]
);
assert_eq!(load_bookmarks().len(), 2);
std::env::remove_var("INGOT_CONFIG_DIR");
let _ = std::fs::remove_dir_all(&directory);
}
}