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:
1 parent
b5e2ef146c
commit
4000084560
18 files changed
+2771
-1
No files matched your search
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
@@ -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(())
|
||||
|
||||
Reference in new issue
Block a user