//! A new repository's starting files, drafted by an assistant (#347): the //! person creating it describes what they want, an assistant set up for the //! owner (the site's, or the organisation's) answers with files, and the //! hub commits them to the empty repository's default bookmark, authored by //! that person and signed by the hub as their delegate, as a web edit is. //! The change says which assistant drafted it and what was asked. It runs //! in the background, since a model may take longer than a proxied request //! may last; its state is kept here until the hub restarts. use crate::assistants::{self, Assistant, Available, Scope}; use crate::db::{self, Pool}; use crate::repos::RepoName; use crate::Shared; use oxy_core::repo::FileEdit; use std::collections::HashMap; use std::path::PathBuf; use std::sync::{LazyLock, Mutex}; /// The most a description may say. pub const MAX_DESCRIPTION: usize = 4000; /// The most files a starter may have, the largest one and all of them. pub const MAX_FILES: usize = 40; pub const MAX_FILE: usize = 64 * 1024; pub const MAX_TOTAL: usize = 256 * 1024; /// The assistants a new repository under `owner` may ask: the site's, and /// the organisation's when `owner` is one, as its repositories would see. pub async fn assistants_for(pool: &Pool, owner: &str) -> sqlx::Result> { let scope = match db::accounts::org_by_name(pool, owner).await? { Some(org) => Scope::Org(org.id), None => Scope::Site, }; Ok(assistants::available(pool, scope, owner).await?.into_iter().filter(Available::on).collect()) } /// Where a repository's starter stands. #[derive(Clone, Debug, PartialEq, Eq)] pub enum State { Running { assistant: String }, Done { assistant: String, change: String, files: Vec }, Failed { assistant: String, error: String }, } static STATES: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); fn key(app: &Shared, name: &RepoName) -> PathBuf { app.repos.dir(name) } /// The starter of `name` on this hub, if one was asked for since it started. pub fn state(app: &Shared, name: &RepoName) -> Option { STATES.lock().unwrap_or_else(|p| p.into_inner()).get(&key(app, name)).cloned() } fn set(app: &Shared, name: &RepoName, s: State) { STATES.lock().unwrap_or_else(|p| p.into_inner()).insert(key(app, name), s); } /// What the assistant is told: what to write and the one shape to answer in. pub fn system_prompt(a: &Assistant, site: &str, owner_prompts: &str) -> String { let mut s = format!( "You are {name}, an AI assistant writing the first files of a new repository on {site}, an OxygenHub site, \ for the person who just created it; they read, change and own everything you write. \ Make a small, working starting point for what they describe: a README.md saying what it is and how to build, \ run and test it; the build files the language's own tools expect; a minimal source layout; and a first test \ where the ecosystem has a convention. No secrets, no placeholder credentials, no binary files, nothing \ generated (lock files, build output). At most {MAX_FILES} files.\n\n\ Answer with files only, each written as\n\n\ <<>>\n\ the file's whole content\n\ <<>>\n\n\ with nothing before the first file and nothing after the last.", name = a.name ); if !owner_prompts.trim().is_empty() { s.push_str("\n\n"); s.push_str(owner_prompts.trim()); } s } /// The files an answer holds, checked: relative paths without `..`, text, /// within the limits. `Err` says what was wrong, for the person who asked. pub fn parse_files(answer: &str) -> Result, String> { let mut files: Vec<(String, String)> = vec![]; let mut current: Option<(String, Vec<&str>)> = None; for line in answer.lines() { let t = line.trim_end(); if let Some((path, body)) = current.as_mut() { if t.trim() == "<<>>" { let path = std::mem::take(path); let content = body.join("\n") + "\n"; current = None; files.push((path, content)); } else { body.push(line); } continue; } if let Some(rest) = t.trim().strip_prefix("<<>>").ok_or_else(|| format!("a file line is not closed with >>>: {t:?}"))?.trim(); current = Some((path.to_string(), vec![])); } } if let Some((path, _)) = current { return Err(format!("the answer stopped inside {path:?} (no <<>>)")); } if files.is_empty() { return Err("the assistant answered without any files".into()); } if files.len() > MAX_FILES { return Err(format!("the assistant wrote {} files; a starter has at most {MAX_FILES}", files.len())); } let mut total = 0; let mut seen = std::collections::BTreeSet::new(); for (path, content) in &files { check_path(path)?; if !seen.insert(path.clone()) { return Err(format!("the assistant wrote {path} twice")); } if content.len() > MAX_FILE { return Err(format!("{path} is {} bytes; a starter's files are at most {MAX_FILE}", content.len())); } if content.contains('\0') { return Err(format!("{path} is not text")); } total += content.len(); } if total > MAX_TOTAL { return Err(format!("the starter is {total} bytes; at most {MAX_TOTAL}")); } Ok(files) } fn check_path(path: &str) -> Result<(), String> { let bad = |why: &str| Err(format!("the path {path:?} {why}")); if path.is_empty() || path.len() > 200 { return bad("is empty or longer than 200 characters"); } if path.starts_with('/') || path.contains('\\') || path.contains(':') { return bad("is not relative to the repository's root"); } for part in path.split('/') { if part.is_empty() || part == "." || part == ".." { return bad("has an empty, . or .. part"); } if part.eq_ignore_ascii_case(".oxy") { return bad("is inside the repository's own metadata"); } if part.chars().any(char::is_control) { return bad("has a control character"); } } Ok(()) } /// Whether `name`'s default bookmark holds nothing yet. pub async fn is_empty(app: &Shared, name: &RepoName, bookmark: &str) -> Result { let b = bookmark.to_string(); app.repos .read(name, move |r, _| { let view = r.view()?; Ok(match view.bookmarks.get(&b) { None => true, Some(st) => r.store().get_state(st)?.heads.is_empty(), }) }) .await .map_err(|e| e.to_string()) } /// Ask `a` for `row`'s starting files in the background, for `by`, and /// commit them; [`state`] follows it. pub fn start(app: Shared, row: db::repos::RepoRow, by: db::accounts::Account, a: Assistant, description: String) { let Some(name) = RepoName::new(&row.owner, &row.name) else { return }; set(&app, &name, State::Running { assistant: a.name.clone() }); tokio::spawn(async move { let outcome = run(&app, &row, &by, &a, &description).await; let s = match outcome { Ok((change, files)) => State::Done { assistant: a.name.clone(), change, files }, Err(error) => { tracing::warn!("starter for {}/{}: {error}", row.owner, row.name); State::Failed { assistant: a.name.clone(), error } } }; set(&app, &name, s); }); } async fn run( app: &Shared, row: &db::repos::RepoRow, by: &db::accounts::Account, a: &Assistant, description: &str, ) -> Result<(String, Vec), String> { let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; let signer = crate::web::edit::signer_for(app, by.id).await?; let scope = match db::accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| e.to_string())? { Some(org) => Scope::Org(org.id), None => Scope::Site, }; let mut prompts = assistants::inherited_prompts(&app.db, scope, &row.owner).await.map_err(|e| e.to_string())?; let own = assistants::prompt(&app.db, scope).await.map_err(|e| e.to_string())?; if !own.is_empty() && scope != Scope::Site { prompts.push((format!("the {} organisation", row.owner), own)); } else if !own.is_empty() { prompts.push(("the site".to_string(), own)); } let prompts: String = prompts.into_iter().map(|(from, text)| format!("## Instructions from {from}\n\n{text}\n")).collect::>().join("\n"); let site = app.config.base_url(); let user = format!("The repository is {}/{}.\n\nWhat they want:\n\n{}", row.owner, row.name, description.trim()); let answer = assistants::ask(app, a, &system_prompt(a, site, &prompts), &user, assistants::BACKGROUND_TIMEOUT).await?; let files = parse_files(&answer)?; if !is_empty(app, &name, &row.default_bookmark).await? { return Err("the repository has files now, so the starter was not added".into()); } let paths: Vec = files.iter().map(|(p, _)| p.clone()).collect(); let edits: Vec = files.into_iter().map(|(path, content)| FileEdit { path, content: Some(content.into_bytes()) }).collect(); let short: String = description.trim().chars().take(400).collect(); let message = format!("Starter files drafted by {} ({})\n\nAsked for by {}: {short}", a.name, a.model, by.username); let b = row.default_bookmark.clone(); let done = crate::repos::edits::commit(&app.repos, &name, b.clone(), b, edits, message, signer).await.map_err(|e| e.to_string())?; db::accounts::audit(&app.db, Some(by), "repo.starter", &name.to_string(), &format!("{} files drafted by {}", paths.len(), a.name)) .await .ok(); Ok((done.change.to_hex(), paths)) } #[cfg(test)] mod tests { use super::*; #[test] fn an_answer_is_read_as_files_and_checked() { let answer = "<<>>\n# Hello\n<<>>\n<<>>\nfn main() {}\n<<>>\n"; let files = parse_files(answer).unwrap(); assert_eq!( files, vec![("README.md".to_string(), "# Hello\n".to_string()), ("src/main.rs".to_string(), "fn main() {}\n".to_string())] ); assert!(parse_files("Sure! Here you go.").unwrap_err().contains("without any files")); assert!(parse_files("<<>>\nx").unwrap_err().contains("no <<>>")); for bad in ["/etc/passwd", "../up", "a//b", "C:\\x", ".oxy/config"] { assert!(parse_files(&format!("<<>>\nx\n<<>>")).is_err(), "{bad}"); } assert!(parse_files("<<>>\n1\n<<>>\n<<>>\n2\n<<>>").unwrap_err().contains("twice")); let many: String = (0..=MAX_FILES).map(|i| format!("<<>>\nx\n<<>>\n")).collect(); assert!(parse_files(&many).unwrap_err().contains("at most")); let big = format!("<<>>\n{}\n<<>>", "x".repeat(MAX_FILE + 1)); assert!(parse_files(&big).unwrap_err().contains("at most")); } }