Software factory (review phase): per-user Gitea/GitHub/GitLab connections, spec chat, project creation, factory page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Sonnet 5.5 committed 2026-11-15 22:19:45 +01:00
1 parent b5e2ef146c
commit 4000084560
18 files changed
+2771 -1

No files matched your search

+134
View File
@@ -159,6 +159,14 @@ pub fn router(app: AppState) -> Router {
.route("/images/{id}", delete(delete_image))
.route("/images/{id}/file", get(image_file))
.route("/account/git", get(get_git_settings).put(put_git_settings))
.route("/account/factory", get(get_factory).put(put_factory))
.route("/account/factory/test", post(test_factory))
.route("/factory/overview", get(factory_overview))
.route("/factory/poll", post(factory_poll))
.route("/account/factory/connection/test", post(test_factory_connection))
.route("/factory/drafts", post(factory_new_draft))
.route("/factory/drafts/{id}", get(factory_draft))
.route("/factory/drafts/{id}/create", post(factory_create_project))
.route("/account/ssh", get(get_ssh_keys).post(add_ssh_key))
.route("/account/ssh/{id}", delete(delete_ssh_key))
.route("/account/snippets", get(crate::cli_api::get_snippets).put(crate::cli_api::put_snippets))
@@ -2584,6 +2592,132 @@ async fn put_git_settings(Extension(user): Extension<User>, State(app): State<Ap
Ok(Json(json!(crate::gitcreds::masked(&new))))
}
// --- software factory ---------------------------------------------------------------
async fn get_factory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
Ok(Json(json!(crate::factory::masked(&s))))
}
async fn put_factory(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<crate::factory::FactorySettings>) -> ApiResult {
let old: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let mut new = crate::factory::merge(&old, b);
for r in &mut new.repos {
r.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{}: {e:#}", r.url)))?;
}
app.store.set_user_setting(&user.id, "factory", &new)?;
let urls: Vec<&str> = new.repos.iter().map(|r| r.url.as_str()).collect();
app.audit(Some(&user), "factory.settings", urls.join(", "), &crate::auth::client_ip(&headers));
Ok(Json(json!(crate::factory::masked(&new))))
}
#[derive(Deserialize)]
struct FactoryTest {
url: String,
}
/// Check token and repository and create the missing status labels.
async fn test_factory(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<FactoryTest>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let cfg = s.repos.iter().find(|r| r.url == b.url.trim()).map(|r| s.resolve(r)).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "save the repository first".into()))?;
let cfg = &cfg;
let g = crate::factory::Forge::new(cfg).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let info = g.check().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
let created = g.ensure_labels().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
let dir = crate::factory::checkout(&app, &user, cfg).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
Ok(Json(json!({"ok": true, "info": info, "labels_created": created, "folder": dir.display().to_string()})))
}
/// Everything the factory page shows: repositories, issues with state, runs with tokens/cost.
async fn factory_overview(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let repos: Vec<Value> = s.repos.iter().map(|r| json!({"url": r.url, "enabled": r.enabled, "label": r.label, "max_parallel": r.max_parallel, "kind": s.resolve(r).kind, "has_token": !s.resolve(r).token.is_empty(), "models": {"review": r.review.model, "build": r.build.model, "qa": r.qa.model}, "running": app.store.factory_running(&r.url).unwrap_or(0)})).collect();
Ok(Json(json!({
"area": app.factory_dir().is_some(),
"repos": repos,
"issues": app.store.factory_issues(&user.id)?,
"runs": app.store.factory_runs(&user.id, None, None, 300)?,
})))
}
/// Look for new issues right now instead of waiting for the next poll.
async fn factory_poll(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let mut errors = Vec::new();
for cfg in s.repos.iter().filter(|r| r.enabled).map(|r| s.resolve(r)).filter(|r| !r.token.is_empty()) {
if let Err(e) = crate::factory::poll_repo(&app, &user, &cfg).await {
errors.push(format!("{}: {e:#}", cfg.url));
}
}
Ok(Json(json!({"errors": errors})))
}
#[derive(Deserialize)]
struct ConnTest {
id: String,
}
/// Test a connection: who the token belongs to and which organisations/groups it can use.
async fn test_factory_connection(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<ConnTest>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let conn = s.connection(&b.id).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "save the connection first".into()))?;
let g = crate::factory::Forge::for_conn(conn).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
let login = g.login().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
let orgs = g.orgs().await.unwrap_or_default();
Ok(Json(json!({"login": login, "orgs": orgs})))
}
#[derive(Deserialize)]
struct NewDraft {
#[serde(default)]
model: Option<String>,
}
/// Start a chat in which the user writes a spec with a model.
async fn factory_new_draft(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<NewDraft>) -> ApiResult {
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let model = b.model.filter(|m| !m.is_empty()).unwrap_or(s.defaults.spec_model.clone());
let session = crate::factory::start_spec_chat(&app, &user, &model).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
Ok(Json(json!(session)))
}
fn draft_session(app: &AppState, user: &User, id: &str) -> Result<(crate::types::Session, std::path::PathBuf), ApiError> {
let session = app.session(id).map_err(|_| ApiError(StatusCode::NOT_FOUND, "unknown chat".into()))?;
let dir = crate::factory::draft_of(app, user, &session).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "not a spec chat".into()))?;
Ok((session, dir))
}
/// The state of a spec chat: spec text, the model's name proposal, connections to choose from.
async fn factory_draft(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let (_, dir) = draft_session(&app, &user, &id)?;
let spec = std::fs::read_to_string(dir.join("spec.md")).unwrap_or_default();
let s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let conns: Vec<Value> = s.connections.iter().map(|c| json!({"id": c.id, "name": c.name, "kind": c.kind, "owner": c.owner})).collect();
Ok(Json(json!({"spec": spec, "proposal": crate::factory::proposal(&app, &id), "connections": conns})))
}
#[derive(Deserialize)]
struct CreateProject {
connection: String,
name: String,
slug: String,
#[serde(default)]
description: String,
#[serde(default)]
owner: String,
}
/// Create the repository, file the spec and open the issue that starts the workflow.
async fn factory_create_project(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>, Json(b): Json<CreateProject>) -> ApiResult {
let (_, dir) = draft_session(&app, &user, &id)?;
let spec = std::fs::read_to_string(dir.join("spec.md")).unwrap_or_default();
let mut s: crate::factory::FactorySettings = app.store.user_setting(&user.id, "factory")?;
let res = crate::factory::create_project(&app, &user, &mut s, &spec, &b.connection, &b.name, &b.slug, &b.description, &b.owner).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
app.store.set_user_setting(&user.id, "factory", &s)?;
app.audit(Some(&user), "factory.project.create", res["repo"].as_str().unwrap_or(""), &crate::auth::client_ip(&headers));
Ok(Json(res))
}
// --- web terminal ------------------------------------------------------------------
#[derive(Deserialize)]
+18 -1
View File
@@ -189,6 +189,20 @@ impl App {
std::fs::canonicalize(&ws).unwrap_or(ws)
}
/// Separate area of the software factory (mounted outside the workspace); None when absent.
pub fn factory_dir(&self) -> Option<PathBuf> {
let p = std::env::var("CODECRAFT_FACTORY_DIR").ok().filter(|p| !p.is_empty()).unwrap_or_else(|| "/factory".into());
let p = PathBuf::from(p);
p.is_dir().then(|| std::fs::canonicalize(&p).unwrap_or(p))
}
/// The user's own folder in the factory area (created on demand).
pub fn factory_home(&self, user: &crate::store::User) -> Option<PathBuf> {
let dir = self.factory_dir()?.join(&user.username);
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
/// Directories a session owner may work in: admins (and legacy sessions
/// without owner) the whole workspace, users their own folder plus shared folders.
pub fn user_roots(&self, owner_id: Option<&str>) -> Vec<PathBuf> {
@@ -196,12 +210,15 @@ impl App {
let Some(user) = owner_id.and_then(|id| self.store.user(id).ok().flatten()) else {
return vec![ws];
};
// The user's factory folder (if the area is mounted) is an additional root.
let factory = self.factory_dir().map(|d| d.join(&user.username)).filter(|d| d.is_dir());
if user.is_admin() {
return vec![ws];
return std::iter::once(ws).chain(factory).collect();
}
let own = ws.join(&user.username);
let _ = std::fs::create_dir_all(&own);
let mut roots = vec![own];
roots.extend(factory);
for p in self.store.folders_for_user(&user.id).unwrap_or_default() {
let p = PathBuf::from(p);
if p.starts_with(&ws) && p.is_dir() {
File diff suppressed because it is too large. Load diff
+2
View File
@@ -13,6 +13,7 @@ mod config;
mod digest;
mod docs;
mod export;
mod factory;
mod files;
mod git;
mod health;
@@ -78,6 +79,7 @@ async fn main() -> Result<()> {
push::spawn_listener(app.clone());
terminal::spawn_reaper(app.clone());
jobs::spawn_scheduler(app.clone());
factory::spawn(app.clone());
update::spawn_checker(app.clone());
health::spawn_checker(app.clone());
digest::spawn(app.clone());
+124
View File
@@ -171,6 +171,39 @@ impl Store {
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS share_links_session ON share_links(session_id);
CREATE TABLE IF NOT EXISTS factory_issues (
repo_url TEXT NOT NULL,
number INTEGER NOT NULL,
owner_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'new',
attempts INTEGER NOT NULL DEFAULT 0,
rounds INTEGER NOT NULL DEFAULT 0,
last_comment INTEGER NOT NULL DEFAULT 0,
scenarios TEXT NOT NULL DEFAULT '',
branch TEXT NOT NULL DEFAULT '',
pr_url TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (repo_url, number)
);
CREATE TABLE IF NOT EXISTS factory_runs (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
repo_url TEXT NOT NULL,
issue INTEGER NOT NULL,
phase TEXT NOT NULL,
round INTEGER NOT NULL DEFAULT 0,
session_id TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
started_at INTEGER NOT NULL,
ended_at INTEGER,
status TEXT NOT NULL DEFAULT 'running',
result TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS factory_runs_issue ON factory_runs(repo_url, issue);
CREATE TABLE IF NOT EXISTS project_defaults (
cwd TEXT PRIMARY KEY,
data TEXT NOT NULL
@@ -1427,6 +1460,97 @@ impl Store {
Ok(())
}
// --- software factory ---------------------------------------------------------
pub fn factory_issue(&self, repo: &str, number: i64) -> Result<Option<Value>> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row("SELECT title, author, state, attempts, rounds, last_comment, scenarios, branch, pr_url, note, created_at, updated_at, owner_id FROM factory_issues WHERE repo_url = ?1 AND number = ?2", params![repo, number], |r| {
Ok(json!({"repo": repo, "number": number, "title": r.get::<_, String>(0)?, "author": r.get::<_, String>(1)?, "state": r.get::<_, String>(2)?, "attempts": r.get::<_, i64>(3)?, "rounds": r.get::<_, i64>(4)?, "last_comment": r.get::<_, i64>(5)?, "scenarios": r.get::<_, String>(6)?, "branch": r.get::<_, String>(7)?, "pr_url": r.get::<_, String>(8)?, "note": r.get::<_, String>(9)?, "created_at": r.get::<_, i64>(10)?, "updated_at": r.get::<_, i64>(11)?, "owner_id": r.get::<_, String>(12)?}))
})
.optional()?)
}
pub fn factory_issue_add(&self, repo: &str, number: i64, owner: &str, title: &str, author: &str) -> Result<()> {
let now = crate::types::now_ms();
self.conn.lock().unwrap().execute(
"INSERT OR IGNORE INTO factory_issues (repo_url, number, owner_id, title, author, created_at, updated_at) VALUES (?1,?2,?3,?4,?5,?6,?6)",
params![repo, number, owner, title, author, now],
)?;
Ok(())
}
/// Update the given columns (whitelisted) of an issue.
pub fn factory_issue_set(&self, repo: &str, number: i64, fields: &[(&str, Value)]) -> Result<()> {
const COLS: [&str; 10] = ["title", "state", "attempts", "rounds", "last_comment", "scenarios", "branch", "pr_url", "note", "author"];
let conn = self.conn.lock().unwrap();
for (k, v) in fields {
if !COLS.contains(k) {
anyhow::bail!("unknown column {k}");
}
let sql = format!("UPDATE factory_issues SET {k} = ?3, updated_at = ?4 WHERE repo_url = ?1 AND number = ?2");
let now = crate::types::now_ms();
match v {
Value::Number(n) => conn.execute(&sql, params![repo, number, n.as_i64().unwrap_or(0), now])?,
other => conn.execute(&sql, params![repo, number, other.as_str().unwrap_or(""), now])?,
};
}
Ok(())
}
pub fn factory_issues(&self, owner: &str) -> Result<Vec<Value>> {
let nums: Vec<(String, i64)> = {
let conn = self.conn.lock().unwrap();
let mut st = conn.prepare("SELECT repo_url, number FROM factory_issues WHERE owner_id = ?1 ORDER BY updated_at DESC")?;
st.query_map([owner], |r| Ok((r.get(0)?, r.get(1)?)))?.filter_map(|r| r.ok()).collect()
};
Ok(nums.into_iter().filter_map(|(r, n)| self.factory_issue(&r, n).ok().flatten()).collect())
}
pub fn factory_run_add(&self, id: &str, owner: &str, repo: &str, issue: i64, phase: &str, round: i64, session: &str, model: &str) -> Result<()> {
self.conn.lock().unwrap().execute(
"INSERT INTO factory_runs (id, owner_id, repo_url, issue, phase, round, session_id, model, started_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
params![id, owner, repo, issue, phase, round, session, model, crate::types::now_ms()],
)?;
Ok(())
}
pub fn factory_run_end(&self, id: &str, status: &str, result: &str) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE factory_runs SET ended_at = ?2, status = ?3, result = ?4 WHERE id = ?1", params![id, crate::types::now_ms(), status, result])?;
Ok(())
}
pub fn factory_running(&self, repo: &str) -> Result<i64> {
Ok(self.conn.lock().unwrap().query_row("SELECT COUNT(*) FROM factory_runs WHERE repo_url = ?1 AND status = 'running'", [repo], |r| r.get(0))?)
}
/// Runs with token and cost totals (joined from the usage table).
pub fn factory_runs(&self, owner: &str, repo: Option<&str>, issue: Option<i64>, limit: i64) -> Result<Vec<Value>> {
let conn = self.conn.lock().unwrap();
let mut st = conn.prepare(
"SELECT f.id, f.repo_url, f.issue, f.phase, f.round, f.session_id, f.model, f.started_at, f.ended_at, f.status, f.result,
COALESCE((SELECT SUM(input_tokens + cache_read_tokens + cache_write_tokens) FROM usage WHERE session_id = f.session_id),0),
COALESCE((SELECT SUM(output_tokens) FROM usage WHERE session_id = f.session_id),0),
COALESCE((SELECT SUM(cost) FROM usage WHERE session_id = f.session_id),0.0)
FROM factory_runs f WHERE f.owner_id = ?1 AND (?2 IS NULL OR f.repo_url = ?2) AND (?3 IS NULL OR f.issue = ?3)
ORDER BY f.started_at DESC LIMIT ?4",
)?;
let rows = st.query_map(params![owner, repo, issue, limit], |r| {
Ok(json!({"id": r.get::<_, String>(0)?, "repo": r.get::<_, String>(1)?, "issue": r.get::<_, i64>(2)?, "phase": r.get::<_, String>(3)?, "round": r.get::<_, i64>(4)?, "session_id": r.get::<_, String>(5)?, "model": r.get::<_, String>(6)?, "started_at": r.get::<_, i64>(7)?, "ended_at": r.get::<_, Option<i64>>(8)?, "status": r.get::<_, String>(9)?, "result": r.get::<_, String>(10)?, "input_tokens": r.get::<_, i64>(11)?, "output_tokens": r.get::<_, i64>(12)?, "cost": r.get::<_, f64>(13)?}))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// After a restart: runs that were still going are over; their issues go back one step.
pub fn factory_recover(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let n = conn.execute("UPDATE factory_runs SET status = 'interrupted', ended_at = ?1 WHERE status = 'running'", [crate::types::now_ms()])?;
conn.execute("UPDATE factory_issues SET state = 'new' WHERE state = 'review'", [])?;
conn.execute("UPDATE factory_issues SET state = 'approved' WHERE state = 'building'", [])?;
conn.execute("UPDATE factory_issues SET state = 'built' WHERE state = 'qa'", [])?;
Ok(n)
}
pub fn job_touch(&self, id: &str, ts: i64) -> Result<()> {
self.conn.lock().unwrap().execute("UPDATE jobs SET last_run = ?2 WHERE id = ?1", rusqlite::params![id, ts])?;
Ok(())