| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | | use crate::assistants::{self, Assistant, Available, Scope}; |
| 11 | | use crate::db::{self, Pool}; |
| 12 | | use crate::repos::RepoName; |
| 13 | | use crate::Shared; |
| 14 | | use oxy_core::repo::FileEdit; |
| 15 | | use std::collections::HashMap; |
| 16 | | use std::path::PathBuf; |
| 17 | | use std::sync::{LazyLock, Mutex}; |
| 18 | |
|
| 19 | |
|
| 20 | | pub const MAX_DESCRIPTION: usize = 4000; |
| 21 | |
|
| 22 | | pub const MAX_FILES: usize = 40; |
| 23 | | pub const MAX_FILE: usize = 64 * 1024; |
| 24 | | pub const MAX_TOTAL: usize = 256 * 1024; |
| 25 | |
|
| 26 | |
|
| 27 | |
|
| 28 | 6× | pub async fn assistants_for(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Available>> { |
| 29 | 6× | let scope = match db::accounts::org_by_name(pool, owner).await? { |
| 30 | 0× | Some(org) => Scope::Org(org.id), |
| 31 | 6× | None => Scope::Site, |
| 32 | | }; |
| 33 | 6× | Ok(assistants::available(pool, scope, owner).await?.into_iter().filter(Available::on).collect()) |
| 34 | 6× | } |
| 35 | |
|
| 36 | |
|
| 37 | | #[derive(Clone, Debug, PartialEq, Eq)] |
| 38 | | pub enum State { |
| 39 | | Running { assistant: String }, |
| 40 | | Done { assistant: String, change: String, files: Vec<String> }, |
| 41 | | Failed { assistant: String, error: String }, |
| 42 | | } |
| 43 | |
|
| 44 | 1× | static STATES: LazyLock<Mutex<HashMap<PathBuf, State>>> = LazyLock::new(|| Mutex::new(HashMap::new())); |
| 45 | |
|
| 46 | 17× | fn key(app: &Shared, name: &RepoName) -> PathBuf { |
| 47 | 17× | app.repos.dir(name) |
| 48 | 17× | } |
| 49 | |
|
| 50 | |
|
| 51 | 13× | pub fn state(app: &Shared, name: &RepoName) -> Option<State> { |
| 52 | 13× | STATES.lock().unwrap_or_else(|p| p.into_inner()).get(&key(app, name)).cloned() |
| 53 | 13× | } |
| 54 | |
|
| 55 | 4× | fn set(app: &Shared, name: &RepoName, s: State) { |
| 56 | 4× | STATES.lock().unwrap_or_else(|p| p.into_inner()).insert(key(app, name), s); |
| 57 | 4× | } |
| 58 | |
|
| 59 | |
|
| 60 | 2× | pub fn system_prompt(a: &Assistant, site: &str, owner_prompts: &str) -> String { |
| 61 | 2× | let mut s = format!( |
| 62 | | "You are {name}, an AI assistant writing the first files of a new repository on {site}, an OxygenHub site, \ |
| 63 | | for the person who just created it; they read, change and own everything you write. \ |
| 64 | | Make a small, working starting point for what they describe: a README.md saying what it is and how to build, \ |
| 65 | | run and test it; the build files the language's own tools expect; a minimal source layout; and a first test \ |
| 66 | | where the ecosystem has a convention. No secrets, no placeholder credentials, no binary files, nothing \ |
| 67 | | generated (lock files, build output). At most {MAX_FILES} files.\n\n\ |
| 68 | | Answer with files only, each written as\n\n\ |
| 69 | | <<<FILE path/from/the/repository/root>>>\n\ |
| 70 | | the file's whole content\n\ |
| 71 | | <<<END>>>\n\n\ |
| 72 | | with nothing before the first file and nothing after the last.", |
| 73 | | name = a.name |
| 74 | | ); |
| 75 | 2× | if !owner_prompts.trim().is_empty() { |
| 76 | 0× | s.push_str("\n\n"); |
| 77 | 0× | s.push_str(owner_prompts.trim()); |
| 78 | 2× | } |
| 79 | 2× | s |
| 80 | 2× | } |
| 81 | |
|
| 82 | |
|
| 83 | |
|
| 84 | 13× | pub fn parse_files(answer: &str) -> Result<Vec<(String, String)>, String> { |
| 85 | 13× | let mut files: Vec<(String, String)> = vec![]; |
| 86 | 13× | let mut current: Option<(String, Vec<&str>)> = None; |
| 87 | 165× | for line in answer.lines() { |
| 88 | 165× | let t = line.trim_end(); |
| 89 | 165× | if let Some((path, body)) = current.as_mut() { |
| 90 | 109× | if t.trim() == "<<<END>>>" { |
| 91 | 53× | let path = std::mem::take(path); |
| 92 | 53× | let content = body.join("\n") + "\n"; |
| 93 | 53× | current = None; |
| 94 | 53× | files.push((path, content)); |
| 95 | 56× | } else { |
| 96 | 56× | body.push(line); |
| 97 | 56× | } |
| 98 | 109× | continue; |
| 99 | 56× | } |
| 100 | 56× | if let Some(rest) = t.trim().strip_prefix("<<<FILE ") { |
| 101 | 54× | let path = rest.strip_suffix(">>>").ok_or_else(|| format!("a file line is not closed with >>>: {t:?}"))?.trim(); |
| 102 | 54× | current = Some((path.to_string(), vec![])); |
| 103 | 2× | } |
| 104 | | } |
| 105 | 13× | if let Some((path, _)) = current { |
| 106 | 1× | return Err(format!("the answer stopped inside {path:?} (no <<<END>>>)")); |
| 107 | 12× | } |
| 108 | 12× | if files.is_empty() { |
| 109 | 2× | return Err("the assistant answered without any files".into()); |
| 110 | 10× | } |
| 111 | 10× | if files.len() > MAX_FILES { |
| 112 | 1× | return Err(format!("the assistant wrote {} files; a starter has at most {MAX_FILES}", files.len())); |
| 113 | 9× | } |
| 114 | 9× | let mut total = 0; |
| 115 | 9× | let mut seen = std::collections::BTreeSet::new(); |
| 116 | 12× | for (path, content) in &files { |
| 117 | 12× | check_path(path)?; |
| 118 | 7× | if !seen.insert(path.clone()) { |
| 119 | 1× | return Err(format!("the assistant wrote {path} twice")); |
| 120 | 6× | } |
| 121 | 6× | if content.len() > MAX_FILE { |
| 122 | 1× | return Err(format!("{path} is {} bytes; a starter's files are at most {MAX_FILE}", content.len())); |
| 123 | 5× | } |
| 124 | 5× | if content.contains('\0') { |
| 125 | 0× | return Err(format!("{path} is not text")); |
| 126 | 5× | } |
| 127 | 5× | total += content.len(); |
| 128 | | } |
| 129 | 2× | if total > MAX_TOTAL { |
| 130 | 0× | return Err(format!("the starter is {total} bytes; at most {MAX_TOTAL}")); |
| 131 | 2× | } |
| 132 | 2× | Ok(files) |
| 133 | 13× | } |
| 134 | |
|
| 135 | 12× | fn check_path(path: &str) -> Result<(), String> { |
| 136 | 12× | let bad = |why: &str| Err(format!("the path {path:?} {why}")); |
| 137 | 12× | if path.is_empty() || path.len() > 200 { |
| 138 | 0× | return bad("is empty or longer than 200 characters"); |
| 139 | 12× | } |
| 140 | 12× | if path.starts_with('/') || path.contains('\\') || path.contains(':') { |
| 141 | 2× | return bad("is not relative to the repository's root"); |
| 142 | 10× | } |
| 143 | 13× | for part in path.split('/') { |
| 144 | 13× | if part.is_empty() || part == "." || part == ".." { |
| 145 | 2× | return bad("has an empty, . or .. part"); |
| 146 | 11× | } |
| 147 | 11× | if part.eq_ignore_ascii_case(".oxy") { |
| 148 | 1× | return bad("is inside the repository's own metadata"); |
| 149 | 10× | } |
| 150 | 10× | if part.chars().any(char::is_control) { |
| 151 | 0× | return bad("has a control character"); |
| 152 | 10× | } |
| 153 | | } |
| 154 | 7× | Ok(()) |
| 155 | 12× | } |
| 156 | |
|
| 157 | |
|
| 158 | 6× | pub async fn is_empty(app: &Shared, name: &RepoName, bookmark: &str) -> Result<bool, String> { |
| 159 | 6× | let b = bookmark.to_string(); |
| 160 | 6× | app.repos |
| 161 | 6× | .read(name, move |r, _| { |
| 162 | 6× | let view = r.view()?; |
| 163 | 6× | Ok(match view.bookmarks.get(&b) { |
| 164 | 0× | None => true, |
| 165 | 6× | Some(st) => r.store().get_state(st)?.heads.is_empty(), |
| 166 | | }) |
| 167 | 6× | }) |
| 168 | 6× | .await |
| 169 | 6× | .map_err(|e| e.to_string()) |
| 170 | 6× | } |
| 171 | |
|
| 172 | |
|
| 173 | |
|
| 174 | 2× | pub fn start(app: Shared, row: db::repos::RepoRow, by: db::accounts::Account, a: Assistant, description: String) { |
| 175 | 2× | let Some(name) = RepoName::new(&row.owner, &row.name) else { return }; |
| 176 | 2× | set(&app, &name, State::Running { assistant: a.name.clone() }); |
| 177 | 2× | tokio::spawn(async move { |
| 178 | 2× | let outcome = run(&app, &row, &by, &a, &description).await; |
| 179 | 2× | let s = match outcome { |
| 180 | 1× | Ok((change, files)) => State::Done { assistant: a.name.clone(), change, files }, |
| 181 | 1× | Err(error) => { |
| 182 | 1× | tracing::warn!("starter for {}/{}: {error}", row.owner, row.name); |
| 183 | 1× | State::Failed { assistant: a.name.clone(), error } |
| 184 | | } |
| 185 | | }; |
| 186 | 2× | set(&app, &name, s); |
| 187 | 2× | }); |
| 188 | 2× | } |
| 189 | |
|
| 190 | 2× | async fn run( |
| 191 | 2× | app: &Shared, |
| 192 | 2× | row: &db::repos::RepoRow, |
| 193 | 2× | by: &db::accounts::Account, |
| 194 | 2× | a: &Assistant, |
| 195 | 2× | description: &str, |
| 196 | 2× | ) -> Result<(String, Vec<String>), String> { |
| 197 | 2× | let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
| 198 | 2× | let signer = crate::web::edit::signer_for(app, by.id).await?; |
| 199 | 2× | let scope = match db::accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| e.to_string())? { |
| 200 | 0× | Some(org) => Scope::Org(org.id), |
| 201 | 2× | None => Scope::Site, |
| 202 | | }; |
| 203 | 2× | let mut prompts = assistants::inherited_prompts(&app.db, scope, &row.owner).await.map_err(|e| e.to_string())?; |
| 204 | 2× | let own = assistants::prompt(&app.db, scope).await.map_err(|e| e.to_string())?; |
| 205 | 2× | if !own.is_empty() && scope != Scope::Site { |
| 206 | 0× | prompts.push((format!("the {} organisation", row.owner), own)); |
| 207 | 2× | } else if !own.is_empty() { |
| 208 | 0× | prompts.push(("the site".to_string(), own)); |
| 209 | 2× | } |
| 210 | 2× | let prompts: String = |
| 211 | 2× | prompts.into_iter().map(|(from, text)| format!("## Instructions from {from}\n\n{text}\n")).collect::<Vec<_>>().join("\n"); |
| 212 | 2× | let site = app.config.base_url(); |
| 213 | 2× | let user = format!("The repository is {}/{}.\n\nWhat they want:\n\n{}", row.owner, row.name, description.trim()); |
| 214 | 2× | let answer = assistants::ask(app, a, &system_prompt(a, site, &prompts), &user, assistants::BACKGROUND_TIMEOUT).await?; |
| 215 | 2× | let files = parse_files(&answer)?; |
| 216 | 1× | if !is_empty(app, &name, &row.default_bookmark).await? { |
| 217 | 0× | return Err("the repository has files now, so the starter was not added".into()); |
| 218 | 1× | } |
| 219 | 2× | let paths: Vec<String> = files.iter().map(|(p, _)| p.clone()).collect(); |
| 220 | 2× | let edits: Vec<FileEdit> = files.into_iter().map(|(path, content)| FileEdit { path, content: Some(content.into_bytes()) }).collect(); |
| 221 | 1× | let short: String = description.trim().chars().take(400).collect(); |
| 222 | 1× | let message = format!("Starter files drafted by {} ({})\n\nAsked for by {}: {short}", a.name, a.model, by.username); |
| 223 | 1× | let b = row.default_bookmark.clone(); |
| 224 | 1× | let done = crate::repos::edits::commit(&app.repos, &name, b.clone(), b, edits, message, signer).await.map_err(|e| e.to_string())?; |
| 225 | 1× | db::accounts::audit(&app.db, Some(by), "repo.starter", &name.to_string(), &format!("{} files drafted by {}", paths.len(), a.name)) |
| 226 | 1× | .await |
| 227 | 1× | .ok(); |
| 228 | 1× | Ok((done.change.to_hex(), paths)) |
| 229 | 2× | } |
| 230 | |
|
| 231 | | #[cfg(test)] |
| 232 | | mod tests { |
| 233 | | use super::*; |
| 234 | |
|
| 235 | | #[test] |
| 236 | 1× | fn an_answer_is_read_as_files_and_checked() { |
| 237 | 1× | let answer = "<<<FILE README.md>>>\n# Hello\n<<<END>>>\n<<<FILE src/main.rs>>>\nfn main() {}\n<<<END>>>\n"; |
| 238 | 1× | let files = parse_files(answer).unwrap(); |
| 239 | 1× | assert_eq!( |
| 240 | | files, |
| 241 | 1× | vec![("README.md".to_string(), "# Hello\n".to_string()), ("src/main.rs".to_string(), "fn main() {}\n".to_string())] |
| 242 | | ); |
| 243 | 1× | assert!(parse_files("Sure! Here you go.").unwrap_err().contains("without any files")); |
| 244 | 1× | assert!(parse_files("<<<FILE a>>>\nx").unwrap_err().contains("no <<<END>>>")); |
| 245 | 5× | for bad in ["/etc/passwd", "../up", "a//b", "C:\\x", ".oxy/config"] { |
| 246 | 5× | assert!(parse_files(&format!("<<<FILE {bad}>>>\nx\n<<<END>>>")).is_err(), "{bad}"); |
| 247 | | } |
| 248 | 1× | assert!(parse_files("<<<FILE a>>>\n1\n<<<END>>>\n<<<FILE a>>>\n2\n<<<END>>>").unwrap_err().contains("twice")); |
| 249 | 41× | let many: String = (0..=MAX_FILES).map(|i| format!("<<<FILE f{i}>>>\nx\n<<<END>>>\n")).collect(); |
| 250 | 1× | assert!(parse_files(&many).unwrap_err().contains("at most")); |
| 251 | 1× | let big = format!("<<<FILE big>>>\n{}\n<<<END>>>", "x".repeat(MAX_FILE + 1)); |
| 252 | 1× | assert!(parse_files(&big).unwrap_err().contains("at most")); |
| 253 | 1× | } |
| 254 | | } |