OXYGENHUB

Coverage / crates/hub/src/starter.rs 88.9%

168 of 189 lines run, 23 of 58 functions · run #532 on main · run missed

1
//! A new repository's starting files, drafted by an assistant (#347): the
2
//! person creating it describes what they want, an assistant set up for the
3
//! owner (the site's, or the organisation's) answers with files, and the
4
//! hub commits them to the empty repository's default bookmark, authored by
5
//! that person and signed by the hub as their delegate, as a web edit is.
6
//! The change says which assistant drafted it and what was asked. It runs
7
//! in the background, since a model may take longer than a proxied request
8
//! may last; its state is kept here until the hub restarts.
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
/// The most a description may say.
20
pub const MAX_DESCRIPTION: usize = 4000;
21
/// The most files a starter may have, the largest one and all of them.
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
/// The assistants a new repository under `owner` may ask: the site's, and
27
/// the organisation's when `owner` is one, as its repositories would see.
286×
pub async fn assistants_for(pool: &Pool, owner: &str) -> sqlx::Result<Vec<Available>> {
296×
    let scope = match db::accounts::org_by_name(pool, owner).await? {
300×
        Some(org) => Scope::Org(org.id),
316×
        None => Scope::Site,
32
    };
336×
    Ok(assistants::available(pool, scope, owner).await?.into_iter().filter(Available::on).collect())
346×
}
35
36
/// Where a repository's starter stands.
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
441×
static STATES: LazyLock<Mutex<HashMap<PathBuf, State>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
45
4615×
fn key(app: &Shared, name: &RepoName) -> PathBuf {
4715×
    app.repos.dir(name)
4815×
}
49
50
/// The starter of `name` on this hub, if one was asked for since it started.
5111×
pub fn state(app: &Shared, name: &RepoName) -> Option<State> {
5211×
    STATES.lock().unwrap_or_else(|p| p.into_inner()).get(&key(app, name)).cloned()
5311×
}
54
554×
fn set(app: &Shared, name: &RepoName, s: State) {
564×
    STATES.lock().unwrap_or_else(|p| p.into_inner()).insert(key(app, name), s);
574×
}
58
59
/// What the assistant is told: what to write and the one shape to answer in.
602×
pub fn system_prompt(a: &Assistant, site: &str, owner_prompts: &str) -> String {
612×
    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
    );
752×
    if !owner_prompts.trim().is_empty() {
760×
        s.push_str("\n\n");
770×
        s.push_str(owner_prompts.trim());
782×
    }
792×
    s
802×
}
81
82
/// The files an answer holds, checked: relative paths without `..`, text,
83
/// within the limits. `Err` says what was wrong, for the person who asked.
8413×
pub fn parse_files(answer: &str) -> Result<Vec<(String, String)>, String> {
8513×
    let mut files: Vec<(String, String)> = vec![];
8613×
    let mut current: Option<(String, Vec<&str>)> = None;
87165×
    for line in answer.lines() {
88165×
        let t = line.trim_end();
89165×
        if let Some((path, body)) = current.as_mut() {
90109×
            if t.trim() == "<<<END>>>" {
9153×
                let path = std::mem::take(path);
9253×
                let content = body.join("\n") + "\n";
9353×
                current = None;
9453×
                files.push((path, content));
9556×
            } else {
9656×
                body.push(line);
9756×
            }
98109×
            continue;
9956×
        }
10056×
        if let Some(rest) = t.trim().strip_prefix("<<<FILE ") {
10154×
            let path = rest.strip_suffix(">>>").ok_or_else(|| format!("a file line is not closed with >>>: {t:?}"))?.trim();
10254×
            current = Some((path.to_string(), vec![]));
1032×
        }
104
    }
10513×
    if let Some((path, _)) = current {
1061×
        return Err(format!("the answer stopped inside {path:?} (no <<<END>>>)"));
10712×
    }
10812×
    if files.is_empty() {
1092×
        return Err("the assistant answered without any files".into());
11010×
    }
11110×
    if files.len() > MAX_FILES {
1121×
        return Err(format!("the assistant wrote {} files; a starter has at most {MAX_FILES}", files.len()));
1139×
    }
1149×
    let mut total = 0;
1159×
    let mut seen = std::collections::BTreeSet::new();
11612×
    for (path, content) in &files {
11712×
        check_path(path)?;
1187×
        if !seen.insert(path.clone()) {
1191×
            return Err(format!("the assistant wrote {path} twice"));
1206×
        }
1216×
        if content.len() > MAX_FILE {
1221×
            return Err(format!("{path} is {} bytes; a starter's files are at most {MAX_FILE}", content.len()));
1235×
        }
1245×
        if content.contains('\0') {
1250×
            return Err(format!("{path} is not text"));
1265×
        }
1275×
        total += content.len();
128
    }
1292×
    if total > MAX_TOTAL {
1300×
        return Err(format!("the starter is {total} bytes; at most {MAX_TOTAL}"));
1312×
    }
1322×
    Ok(files)
13313×
}
134
13512×
fn check_path(path: &str) -> Result<(), String> {
13612×
    let bad = |why: &str| Err(format!("the path {path:?} {why}"));
13712×
    if path.is_empty() || path.len() > 200 {
1380×
        return bad("is empty or longer than 200 characters");
13912×
    }
14012×
    if path.starts_with('/') || path.contains('\\') || path.contains(':') {
1412×
        return bad("is not relative to the repository's root");
14210×
    }
14313×
    for part in path.split('/') {
14413×
        if part.is_empty() || part == "." || part == ".." {
1452×
            return bad("has an empty, . or .. part");
14611×
        }
14711×
        if part.eq_ignore_ascii_case(".oxy") {
1481×
            return bad("is inside the repository's own metadata");
14910×
        }
15010×
        if part.chars().any(char::is_control) {
1510×
            return bad("has a control character");
15210×
        }
153
    }
1547×
    Ok(())
15512×
}
156
157
/// Whether `name`'s default bookmark holds nothing yet.
1586×
pub async fn is_empty(app: &Shared, name: &RepoName, bookmark: &str) -> Result<bool, String> {
1596×
    let b = bookmark.to_string();
1606×
    app.repos
1616×
        .read(name, move |r, _| {
1626×
            let view = r.view()?;
1636×
            Ok(match view.bookmarks.get(&b) {
1640×
                None => true,
1656×
                Some(st) => r.store().get_state(st)?.heads.is_empty(),
166
            })
1676×
        })
1686×
        .await
1696×
        .map_err(|e| e.to_string())
1706×
}
171
172
/// Ask `a` for `row`'s starting files in the background, for `by`, and
173
/// commit them; [`state`] follows it.
1742×
pub fn start(app: Shared, row: db::repos::RepoRow, by: db::accounts::Account, a: Assistant, description: String) {
1752×
    let Some(name) = RepoName::new(&row.owner, &row.name) else { return };
1762×
    set(&app, &name, State::Running { assistant: a.name.clone() });
1772×
    tokio::spawn(async move {
1782×
        let outcome = run(&app, &row, &by, &a, &description).await;
1792×
        let s = match outcome {
1801×
            Ok((change, files)) => State::Done { assistant: a.name.clone(), change, files },
1811×
            Err(error) => {
1821×
                tracing::warn!("starter for {}/{}: {error}", row.owner, row.name);
1831×
                State::Failed { assistant: a.name.clone(), error }
184
            }
185
        };
1862×
        set(&app, &name, s);
1872×
    });
1882×
}
189
1902×
async fn run(
1912×
    app: &Shared,
1922×
    row: &db::repos::RepoRow,
1932×
    by: &db::accounts::Account,
1942×
    a: &Assistant,
1952×
    description: &str,
1962×
) -> Result<(String, Vec<String>), String> {
1972×
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
1982×
    let signer = crate::web::edit::signer_for(app, by.id).await?;
1992×
    let scope = match db::accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| e.to_string())? {
2000×
        Some(org) => Scope::Org(org.id),
2012×
        None => Scope::Site,
202
    };
2032×
    let mut prompts = assistants::inherited_prompts(&app.db, scope, &row.owner).await.map_err(|e| e.to_string())?;
2042×
    let own = assistants::prompt(&app.db, scope).await.map_err(|e| e.to_string())?;
2052×
    if !own.is_empty() && scope != Scope::Site {
2060×
        prompts.push((format!("the {} organisation", row.owner), own));
2072×
    } else if !own.is_empty() {
2080×
        prompts.push(("the site".to_string(), own));
2092×
    }
2102×
    let prompts: String =
2112×
        prompts.into_iter().map(|(from, text)| format!("## Instructions from {from}\n\n{text}\n")).collect::<Vec<_>>().join("\n");
2122×
    let site = app.config.base_url();
2132×
    let user = format!("The repository is {}/{}.\n\nWhat they want:\n\n{}", row.owner, row.name, description.trim());
2142×
    let answer = assistants::ask(app, a, &system_prompt(a, site, &prompts), &user, assistants::BACKGROUND_TIMEOUT).await?;
2152×
    let files = parse_files(&answer)?;
2161×
    if !is_empty(app, &name, &row.default_bookmark).await? {
2170×
        return Err("the repository has files now, so the starter was not added".into());
2181×
    }
2192×
    let paths: Vec<String> = files.iter().map(|(p, _)| p.clone()).collect();
2202×
    let edits: Vec<FileEdit> = files.into_iter().map(|(path, content)| FileEdit { path, content: Some(content.into_bytes()) }).collect();
2211×
    let short: String = description.trim().chars().take(400).collect();
2221×
    let message = format!("Starter files drafted by {} ({})\n\nAsked for by {}: {short}", a.name, a.model, by.username);
2231×
    let b = row.default_bookmark.clone();
2241×
    let done = crate::repos::edits::commit(&app.repos, &name, b.clone(), b, edits, message, signer).await.map_err(|e| e.to_string())?;
2251×
    db::accounts::audit(&app.db, Some(by), "repo.starter", &name.to_string(), &format!("{} files drafted by {}", paths.len(), a.name))
2261×
        .await
2271×
        .ok();
2281×
    Ok((done.change.to_hex(), paths))
2292×
}
230
231
#[cfg(test)]
232
mod tests {
233
    use super::*;
234
235
    #[test]
2361×
    fn an_answer_is_read_as_files_and_checked() {
2371×
        let answer = "<<<FILE README.md>>>\n# Hello\n<<<END>>>\n<<<FILE src/main.rs>>>\nfn main() {}\n<<<END>>>\n";
2381×
        let files = parse_files(answer).unwrap();
2391×
        assert_eq!(
240
            files,
2411×
            vec![("README.md".to_string(), "# Hello\n".to_string()), ("src/main.rs".to_string(), "fn main() {}\n".to_string())]
242
        );
2431×
        assert!(parse_files("Sure! Here you go.").unwrap_err().contains("without any files"));
2441×
        assert!(parse_files("<<<FILE a>>>\nx").unwrap_err().contains("no <<<END>>>"));
2455×
        for bad in ["/etc/passwd", "../up", "a//b", "C:\\x", ".oxy/config"] {
2465×
            assert!(parse_files(&format!("<<<FILE {bad}>>>\nx\n<<<END>>>")).is_err(), "{bad}");
247
        }
2481×
        assert!(parse_files("<<<FILE a>>>\n1\n<<<END>>>\n<<<FILE a>>>\n2\n<<<END>>>").unwrap_err().contains("twice"));
24941×
        let many: String = (0..=MAX_FILES).map(|i| format!("<<<FILE f{i}>>>\nx\n<<<END>>>\n")).collect();
2501×
        assert!(parse_files(&many).unwrap_err().contains("at most"));
2511×
        let big = format!("<<<FILE big>>>\n{}\n<<<END>>>", "x".repeat(MAX_FILE + 1));
2521×
        assert!(parse_files(&big).unwrap_err().contains("at most"));
2531×
    }
254
}