OXYGENHUB

AI assistants: settings per site, organisation and repository e71846aa07b9

Each level holds assistants (name, endpoint, API shape, model, extra
headers, token, context budget, on/off) and a prompt. A repository sees
its organisation's and the site's beside its own: the innermost of a
name wins and one turned off above is off below; prompts combine
outermost first under headings. Tokens are sealed with
ChaCha20-Poly1305 under keys/assistants.key and never shown again; TEST
makes a one-line exchange. refs #97
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 15:32 UTC
Signature
✔ signed by Andy McCall
Hash
e71846aa07b9b70b04e7b7fde94498cb1301d6ecdb9059df648fa4f1401ff428
Depends on
101740454617 19bb31ff1f5c 3556d08e17f9 3f4b0ea6eabf 5ea4872f23e8 64caed18236a 749d9a5fcd9f 7974adb0be66 8fb35df7d66e 9008bf6c2b8c 93be9ce4653e a160b98fb922 b5668108487f b64e2537d702 b6b7e4cf6b24 d23464c0ac60 d4792b4a4629 ea85398a51bc
Depended on by
c43e4f631d99 737de0fac722 c7067c7bda56 33d98147511b a7aa76ac52d7 119fd17d213f 5436f0dd9429 c08cec372c81 5d90e0e045e2 2cbf6f348595 b5a0a69b3622 3dd6582de060 9a777fec4ed9 5360cea36c30 ffb67a81188c 1f78655a0466 62ceb2eec18c 155cab142b63 e8e7158bbc73 ed89fae5180b 206c1fc1b06a 83940d53664f 89e89836cae7 d1727c573f30 4ac01a732552 2a83f5303458 7d2eb590051f
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
40 atoms, 4 tree operations, 17 files

Browse the tree at this change

added crates/hub/migrations/0035_assistants.sql +27 −0
1 +
-- AI assistants (hub spec §13.4, #97): chat-completion endpoints the hub
2 +
-- may ask to review a proposal or draft its description. Each lives at
3 +
-- one level (site: scope_id 0; an organisation; a repository) and each
4 +
-- level may add a prompt. Tokens are sealed with keys/assistants.key.
5 +
CREATE TABLE assistants (
6 +
    id          INTEGER PRIMARY KEY,
7 +
    scope       TEXT NOT NULL CHECK (scope IN ('site', 'org', 'repo')),
8 +
    scope_id    INTEGER NOT NULL DEFAULT 0,
9 +
    name        TEXT NOT NULL COLLATE NOCASE,
10 +
    endpoint    TEXT NOT NULL,
11 +
    api         TEXT NOT NULL,
12 +
    model       TEXT NOT NULL,
13 +
    headers     TEXT NOT NULL DEFAULT '',
14 +
    token       BLOB NOT NULL,
15 +
    enabled     INTEGER NOT NULL DEFAULT 1,
16 +
    budget      INTEGER NOT NULL DEFAULT 120000,
17 +
    created_ms  INTEGER NOT NULL,
18 +
    updated_ms  INTEGER NOT NULL,
19 +
    UNIQUE (scope, scope_id, name)
20 +
);
21 +
CREATE TABLE assistant_prompts (
22 +
    scope       TEXT NOT NULL CHECK (scope IN ('site', 'org', 'repo')),
23 +
    scope_id    INTEGER NOT NULL DEFAULT 0,
24 +
    prompt      TEXT NOT NULL,
25 +
    updated_ms  INTEGER NOT NULL,
26 +
    PRIMARY KEY (scope, scope_id)
27 +
);
added crates/hub/src/assistants.rs +525 −0
1 +
//! AI assistants (hub spec §13.4, #97): chat-completion endpoints the hub
2 +
//! may ask to review a proposal or draft its description. Assistants are
3 +
//! kept at three levels, site, organisation and repository, and combined
4 +
//! downwards: a repository sees its organisation's and the site's beside
5 +
//! its own, the innermost of a name wins, and one disabled at an outer
6 +
//! level is off for everything inside it. Each level adds a prompt; the
7 +
//! prompt in force for a repository is the site's, the organisation's and
8 +
//! the repository's own, each under a heading. Tokens are sealed with
9 +
//! ChaCha20-Poly1305 under `keys/assistants.key`, shown masked, and never
10 +
//! leave the server.
11 +
12 +
use crate::db::{now_ms, Pool};
13 +
use crate::Shared;
14 +
use rand::RngCore;
15 +
use ring::aead;
16 +
use sqlx::FromRow;
17 +
use std::path::Path;
18 +
use std::time::Duration;
19 +
20 +
/// The request shapes an assistant may speak.
21 +
pub const APIS: [&str; 2] = ["chat-completions", "anthropic-messages"];
22 +
/// Characters of context (prompt, description, diffs) a call may carry
23 +
/// unless the assistant says otherwise.
24 +
pub const DEFAULT_BUDGET: i64 = 120_000;
25 +
/// How long one call may take.
26 +
pub const CALL_TIMEOUT: Duration = Duration::from_secs(60);
27 +
/// The most an answer is kept.
28 +
const MAX_ANSWER: usize = 64 * 1024;
29 +
30 +
/// Which level an assistant or a prompt belongs to.
31 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32 +
pub enum Scope {
33 +
    Site,
34 +
    Org(i64),
35 +
    Repo(i64),
36 +
}
37 +
38 +
impl Scope {
39 +
    pub fn kind(self) -> &'static str {
40 +
        match self {
41 +
            Scope::Site => "site",
42 +
            Scope::Org(_) => "org",
43 +
            Scope::Repo(_) => "repo",
44 +
        }
45 +
    }
46 +
47 +
    pub fn id(self) -> i64 {
48 +
        match self {
49 +
            Scope::Site => 0,
50 +
            Scope::Org(id) | Scope::Repo(id) => id,
51 +
        }
52 +
    }
53 +
}
54 +
55 +
#[derive(Clone, Debug, FromRow)]
56 +
pub struct Assistant {
57 +
    pub id: i64,
58 +
    pub scope: String,
59 +
    pub scope_id: i64,
60 +
    pub name: String,
61 +
    pub endpoint: String,
62 +
    /// One of [`APIS`].
63 +
    pub api: String,
64 +
    pub model: String,
65 +
    /// Extra request headers, one `Name: value` per line.
66 +
    pub headers: String,
67 +
    /// The sealed token; empty when the endpoint needs none.
68 +
    pub token: Vec<u8>,
69 +
    pub enabled: bool,
70 +
    pub budget: i64,
71 +
    pub created_ms: i64,
72 +
    pub updated_ms: i64,
73 +
}
74 +
75 +
impl Assistant {
76 +
    pub fn scope(&self) -> Scope {
77 +
        match self.scope.as_str() {
78 +
            "org" => Scope::Org(self.scope_id),
79 +
            "repo" => Scope::Repo(self.scope_id),
80 +
            _ => Scope::Site,
81 +
        }
82 +
    }
83 +
84 +
    pub fn has_token(&self) -> bool {
85 +
        !self.token.is_empty()
86 +
    }
87 +
88 +
    /// The extra headers as pairs; malformed lines are skipped.
89 +
    pub fn header_list(&self) -> Vec<(String, String)> {
90 +
        parse_headers(&self.headers).unwrap_or_default()
91 +
    }
92 +
}
93 +
94 +
/// What a settings form supplies for an assistant.
95 +
#[derive(Clone, Debug, Default)]
96 +
pub struct Fields {
97 +
    pub name: String,
98 +
    pub endpoint: String,
99 +
    pub api: String,
100 +
    pub model: String,
101 +
    pub headers: String,
102 +
    pub enabled: bool,
103 +
    pub budget: i64,
104 +
}
105 +
106 +
/// `Name: value` lines to pairs: `Err(line)` names the first bad one.
107 +
pub fn parse_headers(text: &str) -> Result<Vec<(String, String)>, String> {
108 +
    let mut out = vec![];
109 +
    for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
110 +
        let Some((name, value)) = line.split_once(':') else { return Err(line.to_string()) };
111 +
        let (name, value) = (name.trim(), value.trim());
112 +
        let name_ok = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
113 +
        if !name_ok || value.chars().any(|c| c.is_control()) {
114 +
            return Err(line.to_string());
115 +
        }
116 +
        out.push((name.to_string(), value.to_string()));
117 +
    }
118 +
    Ok(out)
119 +
}
120 +
121 +
// -------------------------------------------------------------- sealing
122 +
123 +
/// The key that seals tokens, kept in `<data>/keys/assistants.key`.
124 +
#[derive(Clone)]
125 +
pub struct SecretKey([u8; 32]);
126 +
127 +
impl SecretKey {
128 +
    pub fn load_or_create(keys: &Path) -> anyhow::Result<SecretKey> {
129 +
        std::fs::create_dir_all(keys)?;
130 +
        let path = keys.join("assistants.key");
131 +
        if let Ok(bytes) = std::fs::read(&path) {
132 +
            if bytes.len() == 32 {
133 +
                return Ok(SecretKey(bytes.try_into().unwrap()));
134 +
            }
135 +
        }
136 +
        let mut k = [0u8; 32];
137 +
        rand::rngs::OsRng.fill_bytes(&mut k);
138 +
        std::fs::write(&path, k)?;
139 +
        #[cfg(unix)]
140 +
        {
141 +
            use std::os::unix::fs::PermissionsExt;
142 +
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
143 +
        }
144 +
        Ok(SecretKey(k))
145 +
    }
146 +
147 +
    fn key(&self) -> aead::LessSafeKey {
148 +
        aead::LessSafeKey::new(aead::UnboundKey::new(&aead::CHACHA20_POLY1305, &self.0).expect("key length"))
149 +
    }
150 +
151 +
    /// Seal a token: a random nonce, then the ciphertext and tag. An
152 +
    /// empty token seals to nothing, meaning "no token".
153 +
    pub fn seal(&self, plain: &str) -> Vec<u8> {
154 +
        if plain.is_empty() {
155 +
            return vec![];
156 +
        }
157 +
        let mut nonce = [0u8; 12];
158 +
        rand::rngs::OsRng.fill_bytes(&mut nonce);
159 +
        let mut out = plain.as_bytes().to_vec();
160 +
        self.key().seal_in_place_append_tag(aead::Nonce::assume_unique_for_key(nonce), aead::Aad::empty(), &mut out).expect("seal");
161 +
        let mut sealed = nonce.to_vec();
162 +
        sealed.extend(out);
163 +
        sealed
164 +
    }
165 +
166 +
    /// The token back, or `None` when the bytes were sealed under another
167 +
    /// key or damaged. Nothing opens to an empty token.
168 +
    pub fn open(&self, sealed: &[u8]) -> Option<String> {
169 +
        if sealed.is_empty() {
170 +
            return Some(String::new());
171 +
        }
172 +
        let (nonce, rest) = sealed.split_at_checked(12)?;
173 +
        let mut buf = rest.to_vec();
174 +
        let nonce = aead::Nonce::try_assume_unique_for_key(nonce).ok()?;
175 +
        let plain = self.key().open_in_place(nonce, aead::Aad::empty(), &mut buf).ok()?;
176 +
        String::from_utf8(plain.to_vec()).ok()
177 +
    }
178 +
}
179 +
180 +
// ------------------------------------------------------------- storage
181 +
182 +
pub async fn list(pool: &Pool, scope: Scope) -> sqlx::Result<Vec<Assistant>> {
183 +
    sqlx::query_as("SELECT * FROM assistants WHERE scope = ? AND scope_id = ? ORDER BY name")
184 +
        .bind(scope.kind())
185 +
        .bind(scope.id())
186 +
        .fetch_all(pool)
187 +
        .await
188 +
}
189 +
190 +
pub async fn get(pool: &Pool, id: i64) -> sqlx::Result<Option<Assistant>> {
191 +
    sqlx::query_as("SELECT * FROM assistants WHERE id = ?").bind(id).fetch_optional(pool).await
192 +
}
193 +
194 +
/// Add an assistant; `None` when the level already has one of that name.
195 +
pub async fn create(pool: &Pool, scope: Scope, f: &Fields, token: &[u8]) -> sqlx::Result<Option<Assistant>> {
196 +
    let now = now_ms();
197 +
    let r = sqlx::query_as(
198 +
        "INSERT INTO assistants (scope, scope_id, name, endpoint, api, model, headers, token, enabled, budget, created_ms, updated_ms) \
199 +
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING RETURNING *",
200 +
    )
201 +
    .bind(scope.kind())
202 +
    .bind(scope.id())
203 +
    .bind(&f.name)
204 +
    .bind(&f.endpoint)
205 +
    .bind(&f.api)
206 +
    .bind(&f.model)
207 +
    .bind(&f.headers)
208 +
    .bind(token)
209 +
    .bind(f.enabled)
210 +
    .bind(f.budget)
211 +
    .bind(now)
212 +
    .bind(now)
213 +
    .fetch_optional(pool)
214 +
    .await?;
215 +
    Ok(r)
216 +
}
217 +
218 +
/// Change an assistant's details; `token` is `None` to keep the stored
219 +
/// one. False when the name is taken at that level.
220 +
pub async fn update(pool: &Pool, id: i64, f: &Fields, token: Option<&[u8]>) -> sqlx::Result<bool> {
221 +
    let r = match token {
222 +
        Some(t) => sqlx::query(
223 +
            "UPDATE assistants SET name = ?, endpoint = ?, api = ?, model = ?, headers = ?, enabled = ?, budget = ?, token = ?, updated_ms = ? \
224 +
             WHERE id = ? AND NOT EXISTS (SELECT 1 FROM assistants o WHERE o.scope = assistants.scope AND o.scope_id = assistants.scope_id AND o.name = ? AND o.id <> ?)",
225 +
        )
226 +
        .bind(&f.name)
227 +
        .bind(&f.endpoint)
228 +
        .bind(&f.api)
229 +
        .bind(&f.model)
230 +
        .bind(&f.headers)
231 +
        .bind(f.enabled)
232 +
        .bind(f.budget)
233 +
        .bind(t)
234 +
        .bind(now_ms())
235 +
        .bind(id)
236 +
        .bind(&f.name)
237 +
        .bind(id)
238 +
        .execute(pool)
239 +
        .await?,
240 +
        None => sqlx::query(
241 +
            "UPDATE assistants SET name = ?, endpoint = ?, api = ?, model = ?, headers = ?, enabled = ?, budget = ?, updated_ms = ? \
242 +
             WHERE id = ? AND NOT EXISTS (SELECT 1 FROM assistants o WHERE o.scope = assistants.scope AND o.scope_id = assistants.scope_id AND o.name = ? AND o.id <> ?)",
243 +
        )
244 +
        .bind(&f.name)
245 +
        .bind(&f.endpoint)
246 +
        .bind(&f.api)
247 +
        .bind(&f.model)
248 +
        .bind(&f.headers)
249 +
        .bind(f.enabled)
250 +
        .bind(f.budget)
251 +
        .bind(now_ms())
252 +
        .bind(id)
253 +
        .bind(&f.name)
254 +
        .bind(id)
255 +
        .execute(pool)
256 +
        .await?,
257 +
    };
258 +
    Ok(r.rows_affected() == 1)
259 +
}
260 +
261 +
pub async fn set_enabled(pool: &Pool, id: i64, enabled: bool) -> sqlx::Result<()> {
262 +
    sqlx::query("UPDATE assistants SET enabled = ?, updated_ms = ? WHERE id = ?")
263 +
        .bind(enabled)
264 +
        .bind(now_ms())
265 +
        .bind(id)
266 +
        .execute(pool)
267 +
        .await?;
268 +
    Ok(())
269 +
}
270 +
271 +
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
272 +
    sqlx::query("DELETE FROM assistants WHERE id = ?").bind(id).execute(pool).await?;
273 +
    Ok(())
274 +
}
275 +
276 +
/// Forget a level's assistants and prompt, when the organisation or
277 +
/// repository goes.
278 +
pub async fn delete_scope(pool: &Pool, scope: Scope) -> sqlx::Result<()> {
279 +
    sqlx::query("DELETE FROM assistants WHERE scope = ? AND scope_id = ?").bind(scope.kind()).bind(scope.id()).execute(pool).await?;
280 +
    sqlx::query("DELETE FROM assistant_prompts WHERE scope = ? AND scope_id = ?").bind(scope.kind()).bind(scope.id()).execute(pool).await?;
281 +
    Ok(())
282 +
}
283 +
284 +
/// The prompt a level adds, empty when none.
285 +
pub async fn prompt(pool: &Pool, scope: Scope) -> sqlx::Result<String> {
286 +
    Ok(sqlx::query_scalar("SELECT prompt FROM assistant_prompts WHERE scope = ? AND scope_id = ?")
287 +
        .bind(scope.kind())
288 +
        .bind(scope.id())
289 +
        .fetch_optional(pool)
290 +
        .await?
291 +
        .unwrap_or_default())
292 +
}
293 +
294 +
pub async fn set_prompt(pool: &Pool, scope: Scope, text: &str) -> sqlx::Result<()> {
295 +
    if text.trim().is_empty() {
296 +
        sqlx::query("DELETE FROM assistant_prompts WHERE scope = ? AND scope_id = ?")
297 +
            .bind(scope.kind())
298 +
            .bind(scope.id())
299 +
            .execute(pool)
300 +
            .await?;
301 +
    } else {
302 +
        sqlx::query(
303 +
            "INSERT INTO assistant_prompts (scope, scope_id, prompt, updated_ms) VALUES (?, ?, ?, ?) \
304 +
             ON CONFLICT (scope, scope_id) DO UPDATE SET prompt = excluded.prompt, updated_ms = excluded.updated_ms",
305 +
        )
306 +
        .bind(scope.kind())
307 +
        .bind(scope.id())
308 +
        .bind(text.trim())
309 +
        .bind(now_ms())
310 +
        .execute(pool)
311 +
        .await?;
312 +
    }
313 +
    Ok(())
314 +
}
315 +
316 +
// ----------------------------------------------------- what applies where
317 +
318 +
/// The levels above a scope, outermost first: the site, then the
319 +
/// organisation when the owner is one.
320 +
pub async fn levels_above(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(Scope, String)>> {
321 +
    let mut out = vec![];
322 +
    match scope {
323 +
        Scope::Site => {}
324 +
        Scope::Org(_) => out.push((Scope::Site, "the site".to_string())),
325 +
        Scope::Repo(_) => {
326 +
            out.push((Scope::Site, "the site".to_string()));
327 +
            if let Some(org) = crate::db::accounts::org_by_name(pool, owner).await? {
328 +
                out.push((Scope::Org(org.id), format!("the {} organisation", org.name)));
329 +
            }
330 +
        }
331 +
    }
332 +
    Ok(out)
333 +
}
334 +
335 +
/// An assistant seen from a level: where it comes from, and whether it is
336 +
/// on once outer levels have had their say.
337 +
#[derive(Clone, Debug)]
338 +
pub struct Available {
339 +
    pub assistant: Assistant,
340 +
    /// `the site`, `the acme organisation`, or empty for the level's own.
341 +
    pub from: String,
342 +
    /// Off because a level above disabled an assistant of this name.
343 +
    pub off_above: bool,
344 +
}
345 +
346 +
impl Available {
347 +
    pub fn on(&self) -> bool {
348 +
        self.assistant.enabled && !self.off_above
349 +
    }
350 +
}
351 +
352 +
/// Every assistant a level sees, outermost first, one per name: the
353 +
/// innermost definition of a name wins, and a name disabled at an outer
354 +
/// level is off inside it whatever the inner level says.
355 +
pub async fn available(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<Available>> {
356 +
    let mut levels = levels_above(pool, scope, owner).await?;
357 +
    levels.push((scope, String::new()));
358 +
    let mut out: Vec<Available> = vec![];
359 +
    let mut off: Vec<String> = vec![];
360 +
    for (level, from) in levels {
361 +
        for a in list(pool, level).await? {
362 +
            let off_above = off.iter().any(|n| n.eq_ignore_ascii_case(&a.name));
363 +
            if !a.enabled && !off.iter().any(|n| n.eq_ignore_ascii_case(&a.name)) {
364 +
                off.push(a.name.clone());
365 +
            }
366 +
            out.retain(|o| !o.assistant.name.eq_ignore_ascii_case(&a.name));
367 +
            out.push(Available { assistant: a, from: from.clone(), off_above });
368 +
        }
369 +
    }
370 +
    Ok(out)
371 +
}
372 +
373 +
/// The assistants a repository may ask, by name.
374 +
pub async fn enabled_for_repo(pool: &Pool, repo: &crate::db::repos::RepoRow) -> sqlx::Result<Vec<Assistant>> {
375 +
    Ok(available(pool, Scope::Repo(repo.id), &repo.owner).await?.into_iter().filter(Available::on).map(|a| a.assistant).collect())
376 +
}
377 +
378 +
/// The prompts a level inherits, outermost first: (where from, text),
379 +
/// only the levels that set one.
380 +
pub async fn inherited_prompts(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(String, String)>> {
381 +
    let mut out = vec![];
382 +
    for (level, from) in levels_above(pool, scope, owner).await? {
383 +
        let text = prompt(pool, level).await?;
384 +
        if !text.is_empty() {
385 +
            out.push((from, text));
386 +
        }
387 +
    }
388 +
    Ok(out)
389 +
}
390 +
391 +
/// The prompt in force for a repository: each level's text under a
392 +
/// heading, outermost first, so a project adds to what the site says.
393 +
pub async fn prompt_for_repo(pool: &Pool, repo: &crate::db::repos::RepoRow) -> sqlx::Result<String> {
394 +
    let scope = Scope::Repo(repo.id);
395 +
    let mut parts = inherited_prompts(pool, scope, &repo.owner).await?;
396 +
    let own = prompt(pool, scope).await?;
397 +
    if !own.is_empty() {
398 +
        parts.push(("this repository".to_string(), own));
399 +
    }
400 +
    Ok(parts.into_iter().map(|(from, text)| format!("## Instructions from {from}\n\n{text}\n")).collect::<Vec<_>>().join("\n"))
401 +
}
402 +
403 +
// -------------------------------------------------------------- calling
404 +
405 +
/// Ask an assistant: the system prompt and the request, the answer's
406 +
/// text. Errors are for the person who asked: a status and the first
407 +
/// line of what the endpoint said, or why no answer came.
408 +
pub async fn ask(app: &Shared, a: &Assistant, system: &str, user: &str) -> Result<String, String> {
409 +
    let Some(token) = app.assistant_key.open(&a.token) else {
410 +
        return Err("its token cannot be read: set it again".into());
411 +
    };
412 +
    let client = reqwest::Client::builder()
413 +
        .timeout(CALL_TIMEOUT)
414 +
        .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION")))
415 +
        .build()
416 +
        .map_err(|e| e.to_string())?;
417 +
    let mut req = client.post(&a.endpoint).header("content-type", "application/json");
418 +
    let body = match a.api.as_str() {
419 +
        "anthropic-messages" => {
420 +
            if !token.is_empty() {
421 +
                req = req.header("x-api-key", &token);
422 +
            }
423 +
            req = req.header("anthropic-version", "2023-06-01");
424 +
            serde_json::json!({
425 +
                "model": a.model,
426 +
                "max_tokens": 4096,
427 +
                "system": system,
428 +
                "messages": [{ "role": "user", "content": user }],
429 +
            })
430 +
        }
431 +
        _ => {
432 +
            if !token.is_empty() {
433 +
                req = req.header("authorization", format!("Bearer {token}"));
434 +
            }
435 +
            serde_json::json!({
436 +
                "model": a.model,
437 +
                "messages": [
438 +
                    { "role": "system", "content": system },
439 +
                    { "role": "user", "content": user },
440 +
                ],
441 +
            })
442 +
        }
443 +
    };
444 +
    for (name, value) in a.header_list() {
445 +
        req = req.header(name, value);
446 +
    }
447 +
    let r = req.json(&body).send().await.map_err(|e| {
448 +
        if e.is_timeout() {
449 +
            "no answer within 60 seconds".to_string()
450 +
        } else {
451 +
            scrub(&e.to_string(), &token)
452 +
        }
453 +
    })?;
454 +
    let status = r.status();
455 +
    let text: String = r.text().await.unwrap_or_default().chars().take(MAX_ANSWER).collect();
456 +
    if !status.is_success() {
457 +
        let why = serde_json::from_str::<serde_json::Value>(&text)
458 +
            .ok()
459 +
            .and_then(|v| v["error"]["message"].as_str().or(v["error"].as_str()).or(v["message"].as_str()).map(str::to_string))
460 +
            .unwrap_or_else(|| text.lines().next().unwrap_or("").chars().take(160).collect());
461 +
        return Err(scrub(&format!("{} {}", status.as_u16(), why.trim()), &token));
462 +
    }
463 +
    let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| "the answer was not JSON".to_string())?;
464 +
    let answer = match a.api.as_str() {
465 +
        "anthropic-messages" => v["content"]
466 +
            .as_array()
467 +
            .map(|parts| parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join(""))
468 +
            .unwrap_or_default(),
469 +
        _ => match &v["choices"][0]["message"]["content"] {
470 +
            serde_json::Value::String(s) => s.clone(),
471 +
            serde_json::Value::Array(parts) => parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join(""),
472 +
            _ => String::new(),
473 +
        },
474 +
    };
475 +
    if answer.trim().is_empty() {
476 +
        return Err("the answer carried no text".into());
477 +
    }
478 +
    Ok(answer)
479 +
}
480 +
481 +
/// A tiny exchange, for the settings page's TEST: the start of the answer.
482 +
pub async fn test(app: &Shared, a: &Assistant) -> Result<String, String> {
483 +
    let answer = ask(app, a, "You are being connected to an OxygenHub site. Answer briefly.", "Reply with the single word OK.").await?;
484 +
    Ok(answer.trim().chars().take(80).collect())
485 +
}
486 +
487 +
/// Never let the token through in an error message.
488 +
fn scrub(text: &str, token: &str) -> String {
489 +
    if token.is_empty() {
490 +
        text.to_string()
491 +
    } else {
492 +
        text.replace(token, "***")
493 +
    }
494 +
}
495 +
496 +
#[cfg(test)]
497 +
mod tests {
498 +
    use super::*;
499 +
500 +
    #[test]
501 +
    fn tokens_seal_and_open_only_under_their_key() {
502 +
        let dir = tempfile::tempdir().unwrap();
503 +
        let k = SecretKey::load_or_create(dir.path()).unwrap();
504 +
        let sealed = k.seal("sk-very-secret");
505 +
        assert_ne!(sealed, b"sk-very-secret");
506 +
        assert!(!String::from_utf8_lossy(&sealed).contains("secret"));
507 +
        assert_eq!(k.open(&sealed).as_deref(), Some("sk-very-secret"));
508 +
        assert_eq!(SecretKey::load_or_create(dir.path()).unwrap().open(&sealed).as_deref(), Some("sk-very-secret"), "the key is kept");
509 +
        let other = SecretKey::load_or_create(tempfile::tempdir().unwrap().path()).unwrap();
510 +
        assert_eq!(other.open(&sealed), None);
511 +
        assert_eq!(k.open(&[1, 2, 3]), None);
512 +
        assert_eq!(k.seal(""), Vec::<u8>::new());
513 +
        assert_eq!(k.open(&[]).as_deref(), Some(""));
514 +
    }
515 +
516 +
    #[test]
517 +
    fn headers_parse_or_name_the_bad_line() {
518 +
        assert_eq!(
519 +
            parse_headers("X-Org: acme\n\n OpenAI-Beta : v1 ").unwrap(),
520 +
            vec![("X-Org".into(), "acme".into()), ("OpenAI-Beta".into(), "v1".into())]
521 +
        );
522 +
        assert_eq!(parse_headers("no colon").unwrap_err(), "no colon");
523 +
        assert_eq!(parse_headers("bad name: x").unwrap_err(), "bad name: x");
524 +
    }
525 +
}
added crates/hub/src/web/assistants.rs +186 −0
1 +
//! The AI assistants panel (hub spec §13.4, #97) shared by the
2 +
//! administration, organisation and repository settings pages: the
3 +
//! level's own assistants, the ones it inherits, and its prompt.
4 +
5 +
use crate::assistants::{self, Assistant, Available, Fields, Scope, APIS, DEFAULT_BUDGET};
6 +
use crate::auth::Viewer;
7 +
use crate::db::accounts;
8 +
use crate::Shared;
9 +
use serde::Deserialize;
10 +
11 +
/// Whose panel.
12 +
pub struct Where {
13 +
    pub scope: Scope,
14 +
    /// The owner name, so a repository finds its organisation.
15 +
    pub owner: String,
16 +
    /// What the audit log names.
17 +
    pub label: String,
18 +
}
19 +
20 +
#[derive(Deserialize)]
21 +
pub struct AssistantForm {
22 +
    #[serde(rename = "_csrf")]
23 +
    pub csrf_token: String,
24 +
    #[serde(default)]
25 +
    action: String,
26 +
    #[serde(default)]
27 +
    id: Option<i64>,
28 +
    #[serde(default)]
29 +
    name: String,
30 +
    #[serde(default)]
31 +
    endpoint: String,
32 +
    #[serde(default)]
33 +
    api: String,
34 +
    #[serde(default)]
35 +
    model: String,
36 +
    #[serde(default)]
37 +
    headers: String,
38 +
    #[serde(default)]
39 +
    token: String,
40 +
    #[serde(default)]
41 +
    enabled: Option<String>,
42 +
    #[serde(default)]
43 +
    budget: String,
44 +
    #[serde(default)]
45 +
    prompt: String,
46 +
}
47 +
48 +
/// Everything the panel shows.
49 +
pub struct Panel {
50 +
    /// The level's own assistants, each with whether a level above turned
51 +
    /// its name off.
52 +
    pub own: Vec<Available>,
53 +
    /// The ones the levels above provide, outermost first.
54 +
    pub inherited: Vec<Available>,
55 +
    pub prompt: String,
56 +
    /// (where from, text) for each level above that set a prompt.
57 +
    pub inherited_prompts: Vec<(String, String)>,
58 +
    pub apis: Vec<&'static str>,
59 +
    pub default_budget: i64,
60 +
}
61 +
62 +
pub async fn panel(app: &Shared, at: &Where) -> sqlx::Result<Panel> {
63 +
    let (own, inherited): (Vec<_>, Vec<_>) =
64 +
        assistants::available(&app.db, at.scope, &at.owner).await?.into_iter().partition(|a| a.from.is_empty());
65 +
    Ok(Panel {
66 +
        own,
67 +
        inherited,
68 +
        prompt: assistants::prompt(&app.db, at.scope).await?,
69 +
        inherited_prompts: assistants::inherited_prompts(&app.db, at.scope, &at.owner).await?,
70 +
        apis: APIS.to_vec(),
71 +
        default_budget: DEFAULT_BUDGET,
72 +
    })
73 +
}
74 +
75 +
fn name_ok(name: &str) -> bool {
76 +
    !name.is_empty() && name.chars().count() <= 40 && name.chars().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '.' | '-' | '_'))
77 +
}
78 +
79 +
fn endpoint_ok(url: &str) -> bool {
80 +
    (url.starts_with("http://") || url.starts_with("https://")) && url.len() <= 2000 && !url.contains(char::is_whitespace)
81 +
}
82 +
83 +
/// The form's details checked: `Err` is the message for the person.
84 +
fn fields(f: &AssistantForm) -> Result<Fields, String> {
85 +
    let name = f.name.trim().to_string();
86 +
    if !name_ok(&name) {
87 +
        return Err("An assistant's name is up to 40 letters, digits, spaces, dots, dashes or underscores.".into());
88 +
    }
89 +
    let endpoint = f.endpoint.trim().to_string();
90 +
    if !endpoint_ok(&endpoint) {
91 +
        return Err("The endpoint is the full URL the request is posted to, starting with https://.".into());
92 +
    }
93 +
    if !APIS.contains(&f.api.as_str()) {
94 +
        return Err("The API is chat-completions or anthropic-messages.".into());
95 +
    }
96 +
    let model = f.model.trim().to_string();
97 +
    if model.is_empty() || model.len() > 100 || model.contains(char::is_whitespace) {
98 +
        return Err("A model name is needed, such as claude-opus-5-5 or gpt-5.".into());
99 +
    }
100 +
    let headers = f.headers.trim().to_string();
101 +
    if let Err(line) = assistants::parse_headers(&headers) {
102 +
        return Err(format!("Extra headers are one Name: value per line; this line is not: {line}"));
103 +
    }
104 +
    if headers.lines().count() > 10 {
105 +
        return Err("Up to ten extra headers.".into());
106 +
    }
107 +
    let budget = if f.budget.trim().is_empty() {
108 +
        DEFAULT_BUDGET
109 +
    } else {
110 +
        match f.budget.trim().parse::<i64>() {
111 +
            Ok(n) if (1_000..=2_000_000).contains(&n) => n,
112 +
            _ => return Err("The context budget is a number of characters from 1000 to 2000000.".into()),
113 +
        }
114 +
    };
115 +
    Ok(Fields { name, endpoint, api: f.api.clone(), model, headers, enabled: f.enabled.is_some(), budget })
116 +
}
117 +
118 +
/// Apply the panel's form: `Ok(notice)` or `Err(message for the user)`.
119 +
pub async fn apply(app: &Shared, viewer: &Viewer, at: &Where, f: AssistantForm) -> sqlx::Result<Result<String, String>> {
120 +
    let owned = |a: Option<Assistant>| a.filter(|a| a.scope() == at.scope);
121 +
    let actor = viewer.account();
122 +
    Ok(Ok(match (f.action.as_str(), f.id) {
123 +
        ("add", None) => {
124 +
            let fields = match fields(&f) {
125 +
                Ok(x) => x,
126 +
                Err(e) => return Ok(Err(e)),
127 +
            };
128 +
            let token = app.assistant_key.seal(f.token.trim());
129 +
            match assistants::create(&app.db, at.scope, &fields, &token).await? {
130 +
                Some(a) => {
131 +
                    accounts::audit(&app.db, actor, "assistant.added", &at.label, &a.name).await.ok();
132 +
                    format!("{} added.", a.name)
133 +
                }
134 +
                None => return Ok(Err(format!("There is already an assistant called {} here.", fields.name))),
135 +
            }
136 +
        }
137 +
        ("update", Some(id)) => {
138 +
            let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) };
139 +
            let fields = match fields(&f) {
140 +
                Ok(x) => x,
141 +
                Err(e) => return Ok(Err(e)),
142 +
            };
143 +
            let token = f.token.trim();
144 +
            let sealed = (!token.is_empty()).then(|| app.assistant_key.seal(token));
145 +
            if !assistants::update(&app.db, id, &fields, sealed.as_deref()).await? {
146 +
                return Ok(Err(format!("There is already an assistant called {} here.", fields.name)));
147 +
            }
148 +
            accounts::audit(&app.db, actor, "assistant.updated", &at.label, &a.name).await.ok();
149 +
            format!("{} updated.", fields.name)
150 +
        }
151 +
        ("toggle", Some(id)) => {
152 +
            let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) };
153 +
            assistants::set_enabled(&app.db, id, !a.enabled).await?;
154 +
            accounts::audit(&app.db, actor, if a.enabled { "assistant.disabled" } else { "assistant.enabled" }, &at.label, &a.name)
155 +
                .await
156 +
                .ok();
157 +
            format!("{} is {}.", a.name, if a.enabled { "off" } else { "on" })
158 +
        }
159 +
        ("delete", Some(id)) => {
160 +
            let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) };
161 +
            assistants::delete(&app.db, id).await?;
162 +
            accounts::audit(&app.db, actor, "assistant.deleted", &at.label, &a.name).await.ok();
163 +
            format!("{} deleted.", a.name)
164 +
        }
165 +
        ("test", Some(id)) => {
166 +
            let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) };
167 +
            match assistants::test(app, &a).await {
168 +
                Ok(answer) => format!("{} answered: {answer}", a.name),
169 +
                Err(why) => return Ok(Err(format!("{} did not answer: {why}", a.name))),
170 +
            }
171 +
        }
172 +
        ("prompt", _) => {
173 +
            if f.prompt.chars().count() > 20_000 {
174 +
                return Ok(Err("A prompt is up to 20000 characters.".into()));
175 +
            }
176 +
            assistants::set_prompt(&app.db, at.scope, &f.prompt).await?;
177 +
            accounts::audit(&app.db, actor, "assistant.prompt", &at.label, "").await.ok();
178 +
            if f.prompt.trim().is_empty() {
179 +
                "Prompt cleared.".to_string()
180 +
            } else {
181 +
                "Prompt saved.".to_string()
182 +
            }
183 +
        }
184 +
        _ => return Ok(Err("Unknown action.".into())),
185 +
    }))
186 +
}
added templates/assistants_panel.html +69 −0
1 +
{% macro panel(base, a, csrf, what) %}
2 +
<section class="panel" id="assistants">
3 +
  <h2>AI assistants</h2>
4 +
  <p class="small mute">{{ what }} An assistant reviews a proposal when asked, or drafts its description; its answers are shown as the person who asked, via the assistant. It never approves.</p>
5 +
  {% if !a.inherited.is_empty() %}
6 +
  <h3>Inherited</h3>
7 +
  <ul class="hooks">
8 +
  {% for i in a.inherited %}
9 +
  <li class="hook">
10 +
    <div class="hook-head"><strong>{{ i.assistant.name }}</strong> <span class="tag">{{ i.assistant.model }}</span>
11 +
      {% if i.on() %}<span class="tag head">on</span>{% else if i.off_above %}<span class="tag dim">off above</span>{% else %}<span class="tag dim">off</span>{% endif %}
12 +
      <span class="small mute">from {{ i.from }}</span></div>
13 +
  </li>
14 +
  {% endfor %}
15 +
  </ul>
16 +
  {% endif %}
17 +
  {% if a.own.is_empty() %}<p class="mute small">{% if a.inherited.is_empty() %}None.{% else %}None of its own.{% endif %}</p>{% else %}
18 +
  <ul class="hooks">
19 +
  {% for o in a.own %}{% let r = o.assistant %}
20 +
  <li class="hook">
21 +
    <div class="hook-head">
22 +
      <strong>{{ r.name }}</strong> <span class="tag">{{ r.model }}</span> <span class="tag">{{ r.api }}</span>
23 +
      {% if o.on() %}<span class="tag head">on</span>{% else if o.off_above %}<span class="tag dim" title="An assistant of this name is turned off at a level above">off above</span>{% else %}<span class="tag dim">off</span>{% endif %}
24 +
    </div>
25 +
    <p class="small mute"><code>{{ r.endpoint }}</code> · token {% if r.has_token() %}set{% else %}none{% endif %} · context {{ r.budget }} characters</p>
26 +
    <form class="tiny" method="post" action="{{ base }}/assistants"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.id }}"><button name="action" value="toggle">{% if r.enabled %}turn off{% else %}turn on{% endif %}</button> <button name="action" value="test">test</button> <button name="action" value="delete" class="danger-btn" onclick="return confirm('Delete {{ r.name }}?')">delete</button></form>
27 +
    <details class="hook-edit"><summary class="small">edit</summary>
28 +
      <form class="card" method="post" action="{{ base }}/assistants">
29 +
        <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.id }}"><input type="hidden" name="action" value="update">
30 +
        <label for="an{{ r.id }}">Name</label><input type="text" id="an{{ r.id }}" name="name" value="{{ r.name }}">
31 +
        <label for="ae{{ r.id }}">Endpoint</label><input type="text" id="ae{{ r.id }}" name="endpoint" value="{{ r.endpoint }}" autocomplete="off">
32 +
        <label for="aa{{ r.id }}">API</label>
33 +
        <select id="aa{{ r.id }}" name="api">{% for api in a.apis %}<option value="{{ api }}"{% if r.api == *api %} selected{% endif %}>{{ api }}</option>{% endfor %}</select>
34 +
        <label for="am{{ r.id }}">Model</label><input type="text" id="am{{ r.id }}" name="model" value="{{ r.model }}">
35 +
        <label for="at{{ r.id }}">Token</label><input type="password" id="at{{ r.id }}" name="token" placeholder="{% if r.has_token() %}unchanged{% else %}none{% endif %}" autocomplete="new-password">
36 +
        <label for="ah{{ r.id }}">Extra headers</label><textarea id="ah{{ r.id }}" name="headers" rows="2" placeholder="Name: value">{{ r.headers }}</textarea>
37 +
        <label for="ab{{ r.id }}">Context budget (characters)</label><input type="text" id="ab{{ r.id }}" name="budget" value="{{ r.budget }}">
38 +
        <div class="choice"><label><input type="checkbox" name="enabled" value="1"{% if r.enabled %} checked{% endif %}> on</label></div>
39 +
        <button type="submit">SAVE</button>
40 +
      </form>
41 +
    </details>
42 +
  </li>
43 +
  {% endfor %}
44 +
  </ul>{% endif %}
45 +
  <form class="card" method="post" action="{{ base }}/assistants">
46 +
    <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="action" value="add">
47 +
    <h3>Add an assistant</h3>
48 +
    <label for="aname">Name</label><input type="text" id="aname" name="name" placeholder="Claude" required>
49 +
    <label for="aendpoint">Endpoint</label><input type="text" id="aendpoint" name="endpoint" placeholder="https://api.anthropic.com/v1/messages" required autocomplete="off">
50 +
    <label for="aapi">API</label>
51 +
    <select id="aapi" name="api">{% for api in a.apis %}<option value="{{ api }}">{{ api }}{% if *api == "chat-completions" %} (OpenAI, DeepSeek, Ollama, most others){% else %} (Anthropic){% endif %}</option>{% endfor %}</select>
52 +
    <label for="amodel">Model</label><input type="text" id="amodel" name="model" placeholder="claude-opus-5-5" required>
53 +
    <label for="atoken">Token</label><input type="password" id="atoken" name="token" autocomplete="new-password">
54 +
    <p class="small mute">Sent as a bearer token, or as the API key for Anthropic; kept sealed on the server and never shown again. Leave it empty for an endpoint that needs none.</p>
55 +
    <label for="aheaders">Extra headers</label><textarea id="aheaders" name="headers" rows="2" placeholder="Name: value"></textarea>
56 +
    <label for="abudget">Context budget (characters)</label><input type="text" id="abudget" name="budget" value="{{ a.default_budget }}">
57 +
    <div class="choice"><label><input type="checkbox" name="enabled" value="1" checked> on</label></div>
58 +
    <button type="submit">ADD</button>
59 +
  </form>
60 +
  <form class="card" method="post" action="{{ base }}/assistants">
61 +
    <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="action" value="prompt">
62 +
    <h3>Prompt</h3>
63 +
    {% for (from, text) in a.inherited_prompts %}<details><summary class="small mute">From {{ from }}</summary><pre class="snippet">{{ text }}</pre></details>{% endfor %}
64 +
    <label for="aprompt">What every assistant is told here{% if !a.inherited_prompts.is_empty() %}, after the prompts above{% endif %}</label>
65 +
    <textarea id="aprompt" name="prompt" rows="6" placeholder="Review for correctness first. This project is written in Rust; the style guide is in docs/style.md.">{{ a.prompt }}</textarea>
66 +
    <button type="submit">SAVE PROMPT</button>
67 +
  </form>
68 +
</section>
69 +
{% endmacro %}
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **AI assistants, step one: settings (#97).** Administration, an organisation's settings and a repository's settings each hold a list of assistants (name, endpoint, API shape, model, extra headers, token, context budget, on or off) and a prompt. A repository sees its organisation's and the site's beside its own; the innermost of a name wins and one turned off above is off below. Tokens are sealed with ChaCha20-Poly1305 under `keys/assistants.key` and never shown again; TEST sends a one-line exchange. Review requests and drafted descriptions follow.
8 +
7 9
- **A runner's own labels and time limit (#98).** A runner's page under Admin → Runners has a Settings form: labels an administrator gives it beside those it reports (jobs that ask for a label match either), and a job time limit in minutes below the site's, applied when it claims.
8 10
9 11
- **Mail settings on the admin page (#101).** The SMTP URL and From address are set and saved from Administration, overriding `hub.toml`'s `[email]`, with a button that sends a test message to the administrator's own address; the password is kept on the server and shown masked. An empty URL turns mail off.
…
89 unchanged lines
modified Cargo.lock +1 −0
…
2240 unchanged lines
2241 2241
 "rcgen",
2242 2242
 "regex",
2243 2243
 "reqwest",
2244 +
 "ring",
2244 2245
 "rustls",
2245 2246
 "serde",
2246 2247
 "serde_json",
…
2404 unchanged lines
modified crates/hub/Cargo.toml +2 −0
…
67 unchanged lines
68 68
lettre.workspace = true
69 69
regex.workspace = true
70 70
reqwest.workspace = true
71 +
# Seals assistant tokens at rest (hub spec §13.4); already in the tree through rustls.
72 +
ring = "0.17"
71 73
futures.workspace = true
72 74
tokio-stream.workspace = true
73 75
…
4 unchanged lines
modified crates/hub/src/lib.rs +5 −0
…
4 unchanged lines
5 5
6 6
pub mod api;
7 7
pub mod apt;
8 +
pub mod assistants;
8 9
pub mod auth;
9 10
pub mod config;
10 11
pub mod db;
…
23 unchanged lines
34 35
    pub server: Arc<oxy_server::Server>,
35 36
    pub hub_key: keys::HubKey,
36 37
    pub cookie_key: auth::CookieKey,
38 +
    /// Seals AI assistants' tokens at rest (hub spec §13.4).
39 +
    pub assistant_key: assistants::SecretKey,
37 40
    pub events: events::Events,
38 41
    /// Who is resolving which conflict together (hub spec §6.10).
39 42
    pub rooms: Arc<live::Rooms>,
…
13 unchanged lines
53 56
    let repos = Arc::new(Repos::new(config.repos_dir()));
54 57
    let hub_key = keys::ensure_identity(&config.keys_dir(), &config.site.name, config.base_url())?;
55 58
    let cookie_key = auth::CookieKey::load_or_create(&config.keys_dir())?;
59 +
    let assistant_key = assistants::SecretKey::load_or_create(&config.keys_dir())?;
56 60
    let backend = protocol::HubBackend::new(repos.clone(), db.clone(), config.auth.push_tokens.clone(), tokio::runtime::Handle::current());
57 61
    let slot = backend.app_slot();
58 62
    let server = oxy_server::Server::new(backend);
…
6 unchanged lines
65 69
        server,
66 70
        hub_key,
67 71
        cookie_key,
72 +
        assistant_key,
68 73
        events: events::Events::default(),
69 74
        rooms: Arc::new(live::Rooms::default()),
70 75
        started: std::time::Instant::now(),
…
50 unchanged lines
modified crates/hub/src/web/admin.rs +21 −0
…
27 unchanged lines
28 28
    mail_from: String,
29 29
    mail_enabled: bool,
30 30
    my_email: String,
31 +
    /// The site's AI assistants and prompt (#97).
32 +
    assistants: super::assistants::Panel,
31 33
}
32 34
33 35
fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
…
16 unchanged lines
50 52
        Some(u) => accounts::account_by_name(&app.db, u).await.map_err(e)?.map(|a| a.email).unwrap_or_default(),
51 53
        None => String::new(),
52 54
    };
55 +
    let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?;
53 56
    Ok(Page(AdminPage {
57 +
        assistants,
54 58
        shell,
55 59
        accounts: accounts_list,
56 60
        orgs,
…
304 unchanged lines
361 365
    let error = (!failed.is_empty()).then(|| failed.join("; "));
362 366
    storage_page(&app, shell, Some(summary), error).await
363 367
}
368 +
369 +
fn site_assistants() -> super::assistants::Where {
370 +
    super::assistants::Where { scope: crate::assistants::Scope::Site, owner: String::new(), label: "site".into() }
371 +
}
372 +
373 +
/// `POST /admin/assistants`: the site's assistants and prompt (#97).
374 +
pub async fn assistants(
375 +
    State(app): State<Shared>,
376 +
    viewer: Viewer,
377 +
    Form(f): Form<super::assistants::AssistantForm>,
378 +
) -> Result<Page<AdminPage>, Error> {
379 +
    let shell = Shell::new(&app, "/admin", &viewer);
380 +
    require_admin(&viewer, &shell)?;
381 +
    csrf(&viewer, &f.csrf_token, &shell)?;
382 +
    let outcome = super::assistants::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?;
383 +
    admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await
384 +
}
modified crates/hub/src/web/mod.rs +4 −0
…
3 unchanged lines
4 4
//! render.
5 5
6 6
mod admin;
7 +
mod assistants;
7 8
mod avatar;
8 9
pub mod chain;
9 10
mod device;
…
72 unchanged lines
82 83
        .route("/orgs/{name}/settings/members", post(orgs::member))
83 84
        .route("/orgs/{name}/settings/teams", post(orgs::team))
84 85
        .route("/orgs/{name}/settings/hooks", post(orgs::hooks))
86 +
        .route("/orgs/{name}/settings/assistants", post(orgs::assistants))
85 87
        .route("/orgs/{name}/settings/runners/token", post(orgs::runner_token))
86 88
        .route("/orgs/{name}/settings/runners/{id}/remove", post(orgs::runner_remove))
87 89
        .route("/orgs/{name}/settings/hooks/{id}/deliveries", get(orgs::deliveries))
…
18 unchanged lines
106 108
        .route("/admin/runners/{id}/limits", post(runners::set_limits))
107 109
        .route("/admin/mail", post(admin::set_mail))
108 110
        .route("/admin/mail/test", post(admin::test_mail))
111 +
        .route("/admin/assistants", post(admin::assistants))
109 112
        .route("/admin/runners/{id}/remove", post(runners::remove))
110 113
        .nest("/api/v1", crate::api::router(app.config.limits.asset_limit.0 as usize))
111 114
        .route("/{owner}", get(home::owner))
…
50 unchanged lines
162 165
        .route("/{owner}/{name}/issue/{number}/project", post(projects::place_issue))
163 166
        .route("/{owner}/{name}/proposal/{number}/events", get(proposals::events))
164 167
        .route("/{owner}/{name}/settings/hooks", post(repo_settings::hooks))
168 +
        .route("/{owner}/{name}/settings/assistants", post(repo_settings::assistants))
165 169
        .route("/{owner}/{name}/settings/hooks/{id}/deliveries", get(repo_settings::deliveries))
166 170
        .route("/{owner}/{name}/settings/hooks/{id}/redeliver/{delivery}", post(repo_settings::redeliver))
167 171
        .route("/{owner}/{name}/settings/checks", post(repo_settings::required_checks))
…
460 unchanged lines
modified crates/hub/src/web/orgs.rs +25 −1
…
69 unchanged lines
70 70
    runners: Vec<(crate::db::runners::Runner, String, bool)>,
71 71
    /// A registration token just made, shown once.
72 72
    runner_token: Option<String>,
73 +
    /// The organisation's AI assistants and prompt (#97).
74 +
    assistants: super::assistants::Panel,
73 75
}
74 76
75 77
async fn org_page(
…
21 unchanged lines
97 99
        };
98 100
        runners.push((r, serves, own));
99 101
    }
100 -
    Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error, runners, runner_token: None }))
102 +
    let assistants = super::assistants::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
103 +
    Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error, runners, runner_token: None, assistants }))
101 104
}
102 105
103 106
async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> {
…
255 unchanged lines
359 362
    accounts::audit(&app.db, Some(&me), "flow.runner.removed", &r.name, &org.name).await.ok();
360 363
    org_page(&app, shell, org, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
361 364
}
365 +
366 +
fn org_assistants(org: &accounts::Organisation) -> super::assistants::Where {
367 +
    super::assistants::Where { scope: crate::assistants::Scope::Org(org.id), owner: org.name.clone(), label: org.name.clone() }
368 +
}
369 +
370 +
/// `POST /orgs/{name}/settings/assistants`: the organisation's assistants
371 +
/// and prompt (#97).
372 +
pub async fn assistants(
373 +
    State(app): State<Shared>,
374 +
    viewer: Viewer,
375 +
    Path(name): Path<String>,
376 +
    Form(f): Form<super::assistants::AssistantForm>,
377 +
) -> Result<Page<OrgSettingsPage>, Error> {
378 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
379 +
    let (_, org) = owned_org(&app, &shell, &viewer, &name).await?;
380 +
    csrf(&viewer, &f.csrf_token, &shell)?;
381 +
    match super::assistants::apply(&app, &viewer, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
382 +
        Ok(notice) => org_page(&app, shell, org, Some(notice), None).await,
383 +
        Err(error) => org_page(&app, shell, org, None, Some(error)).await,
384 +
    }
385 +
}
modified crates/hub/src/web/repo_settings.rs +30 −0
…
99 unchanged lines
100 100
    runners: Vec<(db::runners::Runner, String, bool)>,
101 101
    /// A registration token just made, shown once.
102 102
    runner_token: Option<String>,
103 +
    /// The repository's AI assistants and prompt (#97).
104 +
    assistants: super::assistants::Panel,
103 105
}
104 106
105 107
async fn settings_page(
…
33 unchanged lines
139 141
            (r, serves, own)
140 142
        })
141 143
        .collect();
144 +
    let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
142 145
    Ok(Page(RepoSettingsPage {
146 +
        assistants,
143 147
        runners,
144 148
        runner_token: None,
145 149
        shell,
…
150 unchanged lines
296 300
    let _guard = app.repos.write_guard(&repo).await;
297 301
    app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?;
298 302
    db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
303 +
    crate::assistants::delete_scope(&app.db, crate::assistants::Scope::Repo(row.id)).await.ok();
299 304
    accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok();
300 305
    Ok(Redirect::to(&format!("/{owner}")).into_response())
301 306
}
…
215 unchanged lines
517 522
    accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok();
518 523
    settings_page(&app, shell, row, role, Some(format!("{} removed: its credential no longer works.", r.name)), None).await
519 524
}
525 +
526 +
fn repo_assistants(row: &db::repos::RepoRow) -> super::assistants::Where {
527 +
    super::assistants::Where {
528 +
        scope: crate::assistants::Scope::Repo(row.id),
529 +
        owner: row.owner.clone(),
530 +
        label: format!("{}/{}", row.owner, row.name),
531 +
    }
532 +
}
533 +
534 +
/// `POST /{owner}/{name}/settings/assistants`: the repository's assistants
535 +
/// and prompt (#97), for its admins.
536 +
pub async fn assistants(
537 +
    State(app): State<Shared>,
538 +
    viewer: Viewer,
539 +
    Path((owner, name)): Path<(String, String)>,
540 +
    Form(f): Form<super::assistants::AssistantForm>,
541 +
) -> Result<Page<RepoSettingsPage>, Error> {
542 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
543 +
    let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
544 +
    csrf(&viewer, &f.csrf_token, &shell)?;
545 +
    match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? {
546 +
        Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await,
547 +
        Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await,
548 +
    }
549 +
}
modified crates/hub/tests/accounts.rs +164 −0
…
625 unchanged lines
626 626
    let r = post(app, "/admin/mail", None, &format!("_csrf={csrf}&smtp=&from=")).await;
627 627
    assert_ne!(r.status, StatusCode::OK);
628 628
}
629 +
630 +
/// A stand-in chat-completions endpoint: answers "OK from mock" and keeps
631 +
/// the Authorization header and body of each call.
632 +
async fn mock_assistant() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
633 +
    use std::sync::{Arc, Mutex};
634 +
    let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![]));
635 +
    let s2 = seen.clone();
636 +
    let router = axum::Router::new().route(
637 +
        "/v1/chat/completions",
638 +
        axum::routing::post(move |headers: axum::http::HeaderMap, body: String| {
639 +
            let s2 = s2.clone();
640 +
            async move {
641 +
                let auth = headers.get("authorization").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
642 +
                s2.lock().unwrap().push(format!("{auth} {body}"));
643 +
                axum::Json(serde_json::json!({ "choices": [{ "message": { "role": "assistant", "content": "OK from mock" } }] }))
644 +
            }
645 +
        }),
646 +
    );
647 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
648 +
    let port = listener.local_addr().unwrap().port();
649 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
650 +
    (format!("http://127.0.0.1:{port}/v1/chat/completions"), seen)
651 +
}
652 +
653 +
fn enc(s: &str) -> String {
654 +
    s.replace('%', "%25").replace(':', "%3A").replace('/', "%2F").replace(' ', "+").replace('\n', "%0A").replace('&', "%26")
655 +
}
656 +
657 +
#[tokio::test(flavor = "multi_thread")]
658 +
async fn ai_assistants_are_kept_per_level_with_sealed_tokens_and_combined_downwards() {
659 +
    use oxygenhub::assistants::{self, Scope};
660 +
    let h = hub().await;
661 +
    let app = &h.app;
662 +
    let andy = register(app, "andy", "").await;
663 +
    let (endpoint, seen) = mock_assistant().await;
664 +
665 +
    // The site level: an administrator adds Claude with a token.
666 +
    let page = get(app, "/admin", Some(&andy)).await.body;
667 +
    assert!(page.contains("AI assistants") && page.contains("Add an assistant"), "{page}");
668 +
    let csrf = csrf_of(&page);
669 +
    let add = |name: &str, extra: &str| {
670 +
        format!(
671 +
            "_csrf={csrf}&action=add&name={name}&endpoint={}&api=chat-completions&model=claude-opus-5-5&token=sk-test-secret&enabled=1&budget=50000{extra}",
672 +
            enc(&endpoint)
673 +
        )
674 +
    };
675 +
    let r = post(app, "/admin/assistants", Some(&andy), &add("Claude", "")).await;
676 +
    assert!(r.body.contains("Claude added.") && r.body.contains("token set"), "{}", r.body);
677 +
    assert!(!r.body.contains("sk-test-secret"), "the token never comes back");
678 +
    let site = assistants::list(&app.db, Scope::Site).await.unwrap();
679 +
    assert_eq!(site.len(), 1);
680 +
    let claude = &site[0];
681 +
    assert!(!String::from_utf8_lossy(&claude.token).contains("sk-test"), "sealed at rest");
682 +
    assert_eq!(app.assistant_key.open(&claude.token).as_deref(), Some("sk-test-secret"));
683 +
    // A second of the same name is refused; a bad endpoint is explained.
684 +
    let r = post(app, "/admin/assistants", Some(&andy), &add("claude", "")).await;
685 +
    assert!(r.body.contains("already an assistant called"), "{}", r.body);
686 +
    let r = post(
687 +
        app,
688 +
        "/admin/assistants",
689 +
        Some(&andy),
690 +
        &format!("_csrf={csrf}&action=add&name=Bad&endpoint=ftp%3A%2F%2Fx&api=chat-completions&model=m"),
691 +
    )
692 +
    .await;
693 +
    assert!(r.body.contains("starting with https://"), "{}", r.body);
694 +
695 +
    // TEST calls the endpoint with the bearer token and shows the answer.
696 +
    let r = post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=test&id={}", claude.id)).await;
697 +
    assert!(r.body.contains("Claude answered: OK from mock"), "{}", r.body);
698 +
    {
699 +
        let calls = seen.lock().unwrap();
700 +
        assert_eq!(calls.len(), 1);
701 +
        assert!(calls[0].starts_with("Bearer sk-test-secret "), "{}", calls[0]);
702 +
        assert!(calls[0].contains("\"model\":\"claude-opus-5-5\"") && calls[0].contains("Reply with the single word OK"), "{}", calls[0]);
703 +
    }
704 +
    // An update with the token field empty keeps the stored token.
705 +
    let r = post(
706 +
        app,
707 +
        "/admin/assistants",
708 +
        Some(&andy),
709 +
        &format!("_csrf={csrf}&action=update&id={}&name=Claude&endpoint={}&api=chat-completions&model=claude-opus-5-5&token=&enabled=1&budget=60000", claude.id, enc(&endpoint)),
710 +
    )
711 +
    .await;
712 +
    assert!(r.body.contains("Claude updated.") && r.body.contains("context 60000 characters"), "{}", r.body);
713 +
    assert_eq!(
714 +
        app.assistant_key.open(&assistants::get(&app.db, claude.id).await.unwrap().unwrap().token).as_deref(),
715 +
        Some("sk-test-secret")
716 +
    );
717 +
    // The site prompt.
718 +
    let r =
719 +
        post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=prompt&prompt={}", enc("Review for correctness first.")))
720 +
            .await;
721 +
    assert!(r.body.contains("Prompt saved.") && r.body.contains("Review for correctness first."), "{}", r.body);
722 +
723 +
    // An organisation and its repository see the site's assistant and prompt.
724 +
    let r = post(app, "/orgs/new", Some(&andy), &format!("_csrf={csrf}&name=acme&display_name=Acme")).await;
725 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
726 +
    let r = post(app, "/new", Some(&andy), &format!("_csrf={csrf}&owner=acme&name=plans&visibility=public")).await;
727 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
728 +
    let org_page = get(app, "/orgs/acme/settings", Some(&andy)).await.body;
729 +
    assert!(org_page.contains("Inherited") && org_page.contains("from the site") && org_page.contains("None of its own"), "{org_page}");
730 +
    assert!(org_page.contains("From the site") && org_page.contains("Review for correctness first."), "{org_page}");
731 +
    let repo = db::repos::get(&app.db, "acme", "plans").await.unwrap().unwrap();
732 +
    let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap();
733 +
    assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["Claude"]);
734 +
735 +
    // The organisation adds a prompt and its own DeepSeek; the repository
736 +
    // sees both levels' prompts under headings, outermost first.
737 +
    let r = post(
738 +
        app,
739 +
        "/orgs/acme/settings/assistants",
740 +
        Some(&andy),
741 +
        &format!("_csrf={csrf}&action=prompt&prompt={}", enc("We write 6502 assembly.")),
742 +
    )
743 +
    .await;
744 +
    assert!(r.body.contains("Prompt saved."), "{}", r.body);
745 +
    let r = post(app, "/orgs/acme/settings/assistants", Some(&andy), &add("DeepSeek", "")).await;
746 +
    assert!(r.body.contains("DeepSeek added."), "{}", r.body);
747 +
    let prompt = assistants::prompt_for_repo(&app.db, &repo).await.unwrap();
748 +
    assert!(prompt.starts_with("## Instructions from the site\n\nReview for correctness first.\n\n## Instructions from the acme organisation\n\nWe write 6502 assembly."), "{prompt}");
749 +
    let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body;
750 +
    assert!(repo_page.contains("from the acme organisation") && repo_page.contains("From the acme organisation"), "{repo_page}");
751 +
    let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap();
752 +
    assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["Claude", "DeepSeek"]);
753 +
754 +
    // Turning Claude off at the site turns it off below, even where a
755 +
    // repository defines its own Claude; turning it back on lets the
756 +
    // repository's own definition win.
757 +
    let r = post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=toggle&id={}", claude.id)).await;
758 +
    assert!(r.body.contains("Claude is off."), "{}", r.body);
759 +
    let r = post(app, "/acme/plans/settings/assistants", Some(&andy), &add("Claude", "")).await;
760 +
    assert!(r.body.contains("Claude added."), "{}", r.body);
761 +
    let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap();
762 +
    assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["DeepSeek"]);
763 +
    let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body;
764 +
    assert!(repo_page.contains("off above"), "{repo_page}");
765 +
    post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=toggle&id={}", claude.id)).await;
766 +
    let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap();
767 +
    assert_eq!(on.iter().map(|a| (a.name.as_str(), a.scope.as_str())).collect::<Vec<_>>(), [("DeepSeek", "org"), ("Claude", "repo")]);
768 +
769 +
    // Only administrators and owners reach the panels.
770 +
    let invite = {
771 +
        let r = post(app, "/admin/invite", Some(&andy), &format!("_csrf={csrf}")).await;
772 +
        let i = r.body.find("invite=").map(|i| i + 7).expect("an invitation");
773 +
        r.body[i..].chars().take_while(|c| c.is_ascii_alphanumeric()).collect::<String>()
774 +
    };
775 +
    let bob = register(app, "bob", &invite).await;
776 +
    assert_eq!(
777 +
        post(app, "/admin/assistants", Some(&bob), &format!("_csrf={csrf}&action=prompt&prompt=x")).await.status,
778 +
        StatusCode::NOT_FOUND
779 +
    );
780 +
    assert_eq!(
781 +
        post(app, "/orgs/acme/settings/assistants", Some(&bob), &format!("_csrf={csrf}&action=prompt&prompt=x")).await.status,
782 +
        StatusCode::NOT_FOUND
783 +
    );
784 +
    assert_eq!(
785 +
        post(app, "/acme/plans/settings/assistants", Some(&bob), &format!("_csrf={csrf}&action=prompt&prompt=x")).await.status,
786 +
        StatusCode::NOT_FOUND
787 +
    );
788 +
    // Deleting the repository forgets its assistants and prompt.
789 +
    let r = post(app, "/acme/plans/settings/delete", Some(&andy), &format!("_csrf={csrf}&confirm=acme%2Fplans")).await;
790 +
    assert!(r.status == StatusCode::SEE_OTHER || r.status == StatusCode::OK, "{} {}", r.status, r.body);
791 +
    assert!(assistants::list(&app.db, Scope::Repo(repo.id)).await.unwrap().is_empty());
792 +
}
modified docs/spec.md +2 −2
…
536 unchanged lines
537 537
- *Costs.* One microVM per open workspace: 2 vCPU and 4 GB by default, from the runner's capacity like any job, so open workspaces compete with pipeline jobs; a runner label `workspace` keeps them on runners meant for it. Idle stop at 30 minutes keeps the fleet small.
538 538
- *Open questions.* Whether the Oxygen VCS extension should talk to the microVM's `oxy` (simplest; works offline in the VM) or to the hub's API (no `oxy` in the VM, but a second code path for every operation): the first. Whether a stopped workspace's overlay should be kept for a day so an unpushed edit is not lost: probably yes, as a tarball artifact of the job, restored on reopen. Whether to allow terminals: yes, it is the point.
539 539
540 -
### 13.4 AI assistants (planned, design 2026-10-04)
540 +
### 13.4 AI assistants (settings done 2026-10-04; review requests and drafts planned)
541 541
542 542
An **assistant** is a chat-completion endpoint the hub may ask to review a proposal or draft its description (oxygenhub#97). Assistants are configured at three levels and combined downwards:
543 543
544 544
- **Where.** Administration (site), an organisation's settings, a repository's settings each hold a list of assistants: name (`Claude`, `Antigravity`, `ChatGPT`, `DeepSeek`, …), endpoint URL, API shape (`chat-completions` or `anthropic-messages`), model, extra headers, the token, and an *enabled* switch. Each level also holds a **prompt**. A repository sees the assistants of its organisation and of the site beside its own; an assistant disabled at an outer level is off for everything inside it, whatever an inner level says. The effective prompt for a call is the site's, then the organisation's, then the repository's, each under a heading, so a project adds to what the site says rather than replacing it, and each settings page shows the prompt it inherits above its own.
545 -
- **Secrets.** Tokens are kept encrypted at rest (ChaCha20-Poly1305 with a key in the data directory's `keys/`, beside the cookie key), shown masked, and never returned by the API.
545 +
- **Secrets.** Tokens are sealed at rest with ChaCha20-Poly1305 under `keys/assistants.key` (mode 0600, made at first start beside the cookie key; each token carries its own random nonce), shown only as set or none, and never returned by the API. An empty token suits endpoints that need none. Each settings panel has TEST, a one-line exchange whose answer or error is shown. Each level's changes are in the audit log (`assistant.added`, `.updated`, `.enabled`, `.disabled`, `.deleted`, `.prompt`).
546 546
- **Request review.** The proposal page's Reviewers menu lists the enabled assistants beside people. Asking one queues a worker task: the hub builds the context (the proposal's title and description, the diff of each changed file, trimmed to a size budget, the effective prompt), calls the endpoint, and records the answer as a review of verdict `comment` whose author is the person who asked and whose **via** is the assistant, shown as "andymccall via Claude" with an `agent` badge, exactly as agent-signed changes are shown (§6). An assistant never approves: approvals count only people. The event and the audit log record who asked which assistant; the proposal's author is told as for any review.
547 547
- **Draft the description.** On a new or open proposal, "Draft with …" asks an assistant for a description from the changes and puts it in the editor for the requester to edit before saving; nothing is saved by the assistant itself.
548 548
- **Limits and failure.** One request per assistant per proposal at a time, a per-repository rate limit, a 60-second call timeout, and a context budget (characters) per assistant; a failed call leaves an event ("Claude did not answer: …") and no review.
…
149 unchanged lines
modified templates/admin.html +2 −0
1 1
{% extends "base.html" %}
2 +
{% import "assistants_panel.html" as ai %}
2 3
{% block title %}Administration{% endblock %}
3 4
{% block content %}
4 5
<div class="head"><h1>Administration <small>{{ accounts.len() }} accounts · {{ orgs.len() }} organisations · {{ repos }} repositories</small></h1></div>
…
50 unchanged lines
55 56
  <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} An empty URL turns it off. The password stays on the server and is shown masked.</p>
56 57
</aside>
57 58
</div>
59 +
{% call ai::panel("/admin", assistants, shell.csrf, "Every repository on the site may ask these, unless an organisation or repository turns one off.") %}
58 60
{% endblock %}
modified templates/org_settings.html +3 −0
1 1
{% extends "base.html" %}
2 2
{% import "webhooks_panel.html" as w %}
3 +
{% import "assistants_panel.html" as ai %}
3 4
{% block title %}{{ org.name }} settings{% endblock %}
4 5
{% block content %}
5 6
<div class="head"><h1>{{ org.name }} <small>organisation settings · <a href="/{{ org.name }}">repositories</a></small></h1></div>
…
3 unchanged lines
9 10
  <a href="#logo">Logo</a>
10 11
  <a href="#members">Members</a>
11 12
  <a href="#webhooks">Webhooks</a>
13 +
  <a href="#assistants">AI assistants</a>
12 14
  <a href="#teams">Teams</a>
13 15
</nav>
14 16
<div class="sections">
…
58 unchanged lines
73 75
  <form method="post" action="/orgs/{{ org.name }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for the organisation</button></form>
74 76
</section>
75 77
{% call w::panel("/orgs/" ~ org.name ~ "/settings", hooks, shell.csrf, "These hear every repository of the organisation, as well as each repository's own webhooks.") %}
78 +
{% call ai::panel("/orgs/" ~ org.name ~ "/settings", assistants, shell.csrf, "Every repository of the organisation may ask these, beside the site's and its own.") %}
76 79
</div>
77 80
</div>
78 81
{% endblock %}
modified templates/repo_settings.html +3 −0
1 1
{% extends "base.html" %}
2 2
{% import "webhooks_panel.html" as w %}
3 +
{% import "assistants_panel.html" as ai %}
3 4
{% import "repo_tabs.html" as m %}
4 5
{% block title %}Settings · {{ repo.owner }}/{{ repo.name }}{% endblock %}
5 6
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
…
10 unchanged lines
16 17
  <a href="#checks">Required checks</a>
17 18
  <a href="#flow">OxygenFlow</a>
18 19
  {% if role == crate::auth::Role::Admin %}<a href="#webhooks">Webhooks</a>
20 +
  <a href="#assistants">AI assistants</a>
19 21
  <a href="#collaborators">Collaborators</a>
20 22
  <a href="#danger">Delete</a>{% endif %}
21 23
</nav>
…
67 unchanged lines
89 91
  <form method="post" action="{{ repo.url }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for this repository</button></form>
90 92
</section>
91 93
{% call w::panel(repo.url ~ "/settings", hooks, shell.csrf, "Each delivery is signed with the secret (X-Oxyhub-Signature) and retried with backoff for a day.") %}
94 +
{% call ai::panel(repo.url ~ "/settings", assistants, shell.csrf, "This repository may ask these, beside the ones the site and its owner provide.") %}
92 95
<section class="panel" id="collaborators">
93 96
  <h2>Collaborators</h2>
94 97
  {% if collaborators.is_empty() %}<p class="mute small">Nobody beyond the owner{% if !teams.is_empty() %} and the organisation's members{% endif %}.</p>{% else %}
…
29 unchanged lines