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
@@ -9,6 +9,9 @@ CODECRAFT_ADMIN_PASSWORD=
|
||||
# Directory with your projects, mounted as /workspace.
|
||||
WORKSPACE=/path/to/your/projects
|
||||
|
||||
# Directory of the software factory (clones it works in), mounted as /factory.
|
||||
FACTORY_DIR=./factory
|
||||
|
||||
# Host port and address (127.0.0.1 = only reachable through a local reverse proxy).
|
||||
PORT=8787
|
||||
BIND_ADDR=127.0.0.1
|
||||
|
||||
@@ -3,6 +3,28 @@
|
||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||
until 1.0, minor versions may contain breaking changes.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Software factory** (sidebar → Factory): specs arrive as issues (label `factory`) on Gitea, GitHub or
|
||||
GitLab and are worked off by agents in phases, each phase with its own model, agent, permission mode
|
||||
and time limit. This version contains the **review phase**: an agent reads the spec and the code and
|
||||
decides *approved*, *questions* (posted as an issue comment; your answer restarts the review) or
|
||||
*rejected*, and writes acceptance scenarios that stay in the database, away from the builder. Build and
|
||||
acceptance follow.
|
||||
- **Connections per user**: every user sets up their own accounts (Gitea, GitHub, GitLab) with their own
|
||||
token; repositories use the token of their connection or one of their own. Models are free per user,
|
||||
project and phase.
|
||||
- **New project with a model**: *Factory → New project* opens a spec chat. When the spec is done, the
|
||||
*Create project* bar picks name, connection and owner, creates the repository (private), commits the
|
||||
spec as `docs/specs/<name>.md` and opens the issue that starts the workflow.
|
||||
- **Overview**: board with all issues by state, a tile per project, and per run the model, status,
|
||||
duration, tokens and cost, with a link into the chat of that run.
|
||||
- **Separate factory area**: repositories are cloned into their own mount `/factory` (`FACTORY_DIR` in
|
||||
`.env`), not into the workspace. The API token is handed to git only as a header and never reaches
|
||||
agents.
|
||||
|
||||
## [v202611-00040] – 2026-11-15
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -34,6 +34,8 @@ services:
|
||||
- codecraft-data:/data
|
||||
# The projects the agents may work on.
|
||||
- ${WORKSPACE:-./workspace}:/workspace
|
||||
# Separate area for the software factory (clones of the watched repositories).
|
||||
- ${FACTORY_DIR:-./factory}:/factory
|
||||
# Reach self-hosted models (Ollama, vLLM, ...) on the host.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
- codecraft-data:/data
|
||||
# The projects the agents may work on.
|
||||
- ${WORKSPACE:-./workspace}:/workspace
|
||||
# Separate area for the software factory (clones of the watched repositories).
|
||||
- ${FACTORY_DIR:-./factory}:/factory
|
||||
# Reach self-hosted models (Ollama, vLLM, ...) on the host.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -13,6 +13,7 @@ import { NewSession } from "./components/NewSession";
|
||||
import { Settings } from "./components/Settings";
|
||||
import { Stats } from "./components/Stats";
|
||||
import { Images } from "./components/Images";
|
||||
import { Factory } from "./components/Factory";
|
||||
import { Jobs } from "./components/Jobs";
|
||||
import { JobEditor } from "./components/JobEditor";
|
||||
import { Spinner, useTitleActivity } from "./components/Activity";
|
||||
@@ -110,6 +111,7 @@ export function App() {
|
||||
);
|
||||
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "p" && arg) content = <ProjectPage key={arg} cwd={decodeURIComponent(arg)} onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "factory") content = <Factory onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DraftBar, isDraftChat } from "./FactoryDraft";
|
||||
import { takePrefill } from "../share";
|
||||
import { Icon } from "./Icon";
|
||||
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||||
@@ -1089,6 +1090,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
||||
{!readOnly && isDraftChat(session.cwd) && <DraftBar sessionId={sessionId} />}
|
||||
{!readOnly && <OutboxBar sessionId={sessionId} />}
|
||||
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
|
||||
{readOnly ? (
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { get, post, put } from "../api";
|
||||
import { fmtCompact, fmtCost, fmtRelative, useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { confirmDialog, toast } from "../dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
import { Spinner } from "./Activity";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
|
||||
type Phase = "review" | "build" | "qa";
|
||||
const PHASES: Phase[] = ["review", "build", "qa"];
|
||||
|
||||
interface PhaseCfg {
|
||||
model: string;
|
||||
agent: string;
|
||||
permission_mode: string;
|
||||
timeout_minutes: number;
|
||||
extra_prompt: string;
|
||||
}
|
||||
interface RepoCfg {
|
||||
url: string;
|
||||
kind: string;
|
||||
connection: string;
|
||||
token: string;
|
||||
label: string;
|
||||
max_parallel: number;
|
||||
allowed_users: string[];
|
||||
enabled: boolean;
|
||||
review: PhaseCfg;
|
||||
build: PhaseCfg;
|
||||
qa: PhaseCfg;
|
||||
}
|
||||
interface Connection {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "gitea" | "github" | "gitlab";
|
||||
url: string;
|
||||
token: string;
|
||||
owner: string;
|
||||
}
|
||||
interface Defaults {
|
||||
spec_model: string;
|
||||
review: PhaseCfg;
|
||||
build: PhaseCfg;
|
||||
qa: PhaseCfg;
|
||||
}
|
||||
interface Settings {
|
||||
connections: Connection[];
|
||||
defaults: Defaults;
|
||||
repos: RepoCfg[];
|
||||
}
|
||||
interface Issue {
|
||||
repo: string;
|
||||
number: number;
|
||||
title: string;
|
||||
author: string;
|
||||
state: string;
|
||||
note: string;
|
||||
pr_url: string;
|
||||
rounds: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
interface Run {
|
||||
id: string;
|
||||
repo: string;
|
||||
issue: number;
|
||||
phase: Phase;
|
||||
round: number;
|
||||
session_id: string;
|
||||
model: string;
|
||||
started_at: number;
|
||||
ended_at: number | null;
|
||||
status: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cost: number;
|
||||
}
|
||||
interface RepoInfo {
|
||||
url: string;
|
||||
kind: string;
|
||||
enabled: boolean;
|
||||
label: string;
|
||||
max_parallel: number;
|
||||
has_token: boolean;
|
||||
models: Record<Phase, string>;
|
||||
running: number;
|
||||
}
|
||||
interface Overview {
|
||||
area: boolean;
|
||||
repos: RepoInfo[];
|
||||
issues: Issue[];
|
||||
runs: Run[];
|
||||
}
|
||||
|
||||
/** Board columns: a column collects one or more issue states. */
|
||||
const COLUMNS: { id: string; states: string[] }[] = [
|
||||
{ id: "review", states: ["new", "review"] },
|
||||
{ id: "question", states: ["question"] },
|
||||
{ id: "approved", states: ["approved"] },
|
||||
{ id: "build", states: ["building", "built"] },
|
||||
{ id: "qa", states: ["qa"] },
|
||||
{ id: "done", states: ["done"] },
|
||||
{ id: "failed", states: ["failed", "rejected"] },
|
||||
];
|
||||
|
||||
const KIND_NAME: Record<string, string> = { gitea: "Gitea", github: "GitHub", gitlab: "GitLab" };
|
||||
const repoName = (url: string) => url.replace(/\.git$/, "").split("/").slice(-2).join("/");
|
||||
const issueUrl = (repo: string, n: number) => `${repo.replace(/\.git$/, "")}/issues/${n}`;
|
||||
|
||||
function fmtDuration(ms: number) {
|
||||
const s = Math.max(0, Math.round(ms / 1000));
|
||||
if (s < 60) return `${s} s`;
|
||||
const m = Math.floor(s / 60);
|
||||
return m < 60 ? `${m} min ${s % 60} s` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
||||
}
|
||||
|
||||
const runMs = (r: Run, now: number) => (r.ended_at ?? now) - r.started_at;
|
||||
|
||||
/** Software factory: spec issues in Gitea are reviewed, built and accepted by agents with a model per phase. */
|
||||
export function Factory({ onMenu }: { onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const [tab, setTab] = useState<"overview" | "settings">("overview");
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>{t("factory.title")}</h1>
|
||||
</div>
|
||||
<div className="segmented">
|
||||
{(["overview", "settings"] as const).map((x) => (
|
||||
<button key={x} className={tab === x ? "active" : ""} onClick={() => setTab(x)}>
|
||||
{t(`factory.tab.${x}` as any)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="page">
|
||||
<div className="page-inner" style={{ maxWidth: 1280 }}>
|
||||
{tab === "overview" ? <FactoryOverview onSettings={() => setTab("settings")} /> : <FactorySettings />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FactoryOverview({ onSettings }: { onSettings: () => void }) {
|
||||
const t = useT();
|
||||
const [data, setData] = useState<Overview>();
|
||||
const [repo, setRepo] = useState<string>("");
|
||||
const [sel, setSel] = useState<string>("");
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const reload = () =>
|
||||
get<Overview>("/factory/overview")
|
||||
.then(setData)
|
||||
.catch((e) => toast(e.message));
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const h = setInterval(() => {
|
||||
void reload();
|
||||
setNow(Date.now());
|
||||
}, 5000);
|
||||
return () => clearInterval(h);
|
||||
}, []);
|
||||
|
||||
const newProject = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const s = await post<{ id: string }>("/factory/drafts", {});
|
||||
navigate(`/s/${s.id}`);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const issues = useMemo(() => (data?.issues ?? []).filter((i) => !repo || i.repo === repo), [data, repo]);
|
||||
const runs = useMemo(() => (data?.runs ?? []).filter((r) => !repo || r.repo === repo), [data, repo]);
|
||||
if (!data) return <div className="empty"><Spinner /></div>;
|
||||
if (data.repos.length === 0)
|
||||
return (
|
||||
<div className="empty">
|
||||
<p>{t("factory.empty")}</p>
|
||||
<div className="row" style={{ gap: 8, justifyContent: "center" }}>
|
||||
<button className="primary" onClick={newProject} disabled={busy}>
|
||||
<Icon name="plus" /> {t("factory.newProject")}
|
||||
</button>
|
||||
<button onClick={onSettings}>{t("factory.tab.settings")}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const sum = (f: (r: Run) => number) => runs.reduce((a, r) => a + f(r), 0);
|
||||
const finished = runs.filter((r) => r.ended_at);
|
||||
const running = runs.filter((r) => !r.ended_at);
|
||||
const done = issues.filter((i) => i.state === "done").length;
|
||||
const failed = issues.filter((i) => ["failed", "rejected"].includes(i.state)).length;
|
||||
const runsOf = (i: Issue) => data.runs.filter((r) => r.repo === i.repo && r.issue === i.number);
|
||||
const selected = issues.find((i) => `${i.repo}#${i.number}` === sel);
|
||||
const shown = selected ? runsOf(selected) : runs.slice(0, 12);
|
||||
|
||||
const poll = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await post<{ errors: string[] }>("/factory/poll", {});
|
||||
if (r.errors.length) toast(r.errors.join("\n"));
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!data.area && <div className="notice err">{t("factory.noArea")}</div>}
|
||||
<div className="kpis">
|
||||
<div className="kpi">
|
||||
<div className="k-label">{t("factory.kpi.running")}</div>
|
||||
<div className="k-value">{running.length}</div>
|
||||
<div className="k-sub">{t("factory.kpi.issues", { n: issues.length })}</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="k-label">{t("factory.kpi.done")}</div>
|
||||
<div className="k-value">{done}</div>
|
||||
<div className="k-sub">{t("factory.kpi.failed", { n: failed })}</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="k-label">{t("factory.kpi.tokens")}</div>
|
||||
<div className="k-value">{fmtCompact(sum((r) => r.input_tokens + r.output_tokens))}</div>
|
||||
<div className="k-sub">
|
||||
{fmtCompact(sum((r) => r.input_tokens))} {t("factory.in")} · {fmtCompact(sum((r) => r.output_tokens))} {t("factory.out")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="k-label">{t("factory.kpi.cost")}</div>
|
||||
<div className="k-value">{fmtCost(sum((r) => r.cost), "USD")}</div>
|
||||
<div className="k-sub">{t("factory.kpi.runs", { n: runs.length })}</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="k-label">{t("factory.kpi.time")}</div>
|
||||
<div className="k-value">{finished.length ? fmtDuration(sum((r) => runMs(r, now)) / finished.length) : "–"}</div>
|
||||
<div className="k-sub">{t("factory.kpi.perRun")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="factory-repos">
|
||||
<button className={`factory-repo ${repo === "" ? "active" : ""}`} onClick={() => setRepo("")}>
|
||||
<b>{t("factory.allRepos")}</b>
|
||||
<span className="hint">{t("factory.kpi.issues", { n: data.issues.length })}</span>
|
||||
</button>
|
||||
{data.repos.map((r) => {
|
||||
const mine = data.issues.filter((i) => i.repo === r.url);
|
||||
return (
|
||||
<button key={r.url} className={`factory-repo ${repo === r.url ? "active" : ""} ${r.enabled ? "" : "off"}`} onClick={() => setRepo(r.url)}>
|
||||
<b>{repoName(r.url)}</b>
|
||||
<span className="chip">{KIND_NAME[r.kind] ?? r.kind}</span>
|
||||
<span className="hint">
|
||||
{r.running > 0 && <span className="factory-pulse" />}
|
||||
{t("factory.repoLine", { n: mine.length, run: r.running })}
|
||||
</span>
|
||||
<span className="hint mono">{PHASES.map((p) => r.models[p] || "·").map((m) => m.split("/").pop()).join(" → ")}</span>
|
||||
{!r.has_token && <span className="chip warn">{t("factory.noToken")}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button className="small primary" onClick={newProject} disabled={busy}>
|
||||
<Icon name="plus" /> {t("factory.newProject")}
|
||||
</button>
|
||||
<button className="small" onClick={poll} disabled={busy} title={t("factory.pollNow")}>
|
||||
<Icon name="refresh" /> {t("factory.pollNow")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="factory-board">
|
||||
{COLUMNS.map((c) => {
|
||||
const cards = issues.filter((i) => c.states.includes(i.state));
|
||||
return (
|
||||
<div key={c.id} className="factory-col">
|
||||
<h4>
|
||||
{t(`factory.col.${c.id}` as any)} <span className="chip">{cards.length}</span>
|
||||
</h4>
|
||||
{cards.map((i) => {
|
||||
const rs = runsOf(i);
|
||||
const active = rs.find((r) => !r.ended_at);
|
||||
const latest = active ?? rs[0];
|
||||
const tokens = rs.reduce((a, r) => a + r.input_tokens + r.output_tokens, 0);
|
||||
return (
|
||||
<button key={`${i.repo}#${i.number}`} className={`factory-card ${sel === `${i.repo}#${i.number}` ? "active" : ""} ${active ? "running" : ""}`} onClick={() => setSel(sel === `${i.repo}#${i.number}` ? "" : `${i.repo}#${i.number}`)}>
|
||||
<span className="hint">
|
||||
{repoName(i.repo)} #{i.number}
|
||||
</span>
|
||||
<b>{i.title || `#${i.number}`}</b>
|
||||
{latest && (
|
||||
<span className="hint">
|
||||
{active && <span className="factory-pulse" />}
|
||||
{t(`factory.phase.${latest.phase}` as any)} · {(latest.model || "").split("/").pop()} · {fmtDuration(runMs(latest, now))}
|
||||
</span>
|
||||
)}
|
||||
{tokens > 0 && <span className="hint">{fmtCompact(tokens)} {t("factory.tokens")}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ marginTop: 16 }}>
|
||||
<div className="row">
|
||||
<h3 style={{ margin: 0 }}>{selected ? `${repoName(selected.repo)} #${selected.number} – ${selected.title}` : t("factory.latestRuns")}</h3>
|
||||
<span className="spacer" />
|
||||
{selected && (
|
||||
<>
|
||||
<a className="small ghost button-link" href={issueUrl(selected.repo, selected.number)} target="_blank" rel="noreferrer">
|
||||
<Icon name="external" /> Gitea
|
||||
</a>
|
||||
{selected.pr_url && (
|
||||
<a className="small ghost button-link" href={selected.pr_url} target="_blank" rel="noreferrer">
|
||||
<Icon name="external" /> {t("factory.pr")}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{selected?.note && <div className="hint" style={{ margin: "6px 0" }}>{selected.note}</div>}
|
||||
{shown.length === 0 && <div className="hint">{t("factory.noRuns")}</div>}
|
||||
{shown.length > 0 && (
|
||||
<div className="table-wrap">
|
||||
<table className="grid factory-runs">
|
||||
<thead>
|
||||
<tr>
|
||||
{!selected && <th>{t("factory.issue")}</th>}
|
||||
<th>{t("factory.phase")}</th>
|
||||
<th>{t("factory.model")}</th>
|
||||
<th>{t("factory.status")}</th>
|
||||
<th>{t("factory.started")}</th>
|
||||
<th>{t("factory.duration")}</th>
|
||||
<th>{t("factory.tokens")}</th>
|
||||
<th>{t("factory.cost")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((r) => (
|
||||
<tr key={r.id}>
|
||||
{!selected && (
|
||||
<td>
|
||||
{repoName(r.repo)} #{r.issue}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
{t(`factory.phase.${r.phase}` as any)}
|
||||
{r.round > 0 && <span className="hint"> · {t("factory.round", { n: r.round })}</span>}
|
||||
</td>
|
||||
<td className="mono">{r.model}</td>
|
||||
<td>
|
||||
<span className={`chip ${r.status === "done" ? "ok" : r.status === "running" ? "" : "warn"}`}>
|
||||
{r.status === "running" && <span className="factory-pulse" />}
|
||||
{t(`factory.runStatus.${r.status}` as any) || r.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{fmtRelative(r.started_at)}</td>
|
||||
<td>{fmtDuration(runMs(r, now))}</td>
|
||||
<td title={`${r.input_tokens} / ${r.output_tokens}`}>
|
||||
{fmtCompact(r.input_tokens)} / {fmtCompact(r.output_tokens)}
|
||||
</td>
|
||||
<td>{fmtCost(r.cost, "USD")}</td>
|
||||
<td>
|
||||
<button className="small" onClick={() => navigate(`/s/${r.session_id}`)}>
|
||||
<Icon name="eye" /> {t("factory.openChat")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const blankPhase = (): PhaseCfg => ({ model: "", agent: "codecraft", permission_mode: "auto", timeout_minutes: 60, extra_prompt: "" });
|
||||
const blankRepo = (connection = "", kind = "gitea"): RepoCfg => ({ url: "", kind, connection, token: "", label: "factory", max_parallel: 1, allowed_users: [], enabled: true, review: blankPhase(), build: blankPhase(), qa: blankPhase() });
|
||||
const blankConn = (): Connection => ({ id: "", name: "", kind: "gitea", url: "", token: "", owner: "" });
|
||||
const MODES = ["default", "auto", "accept_edits", "bypass"];
|
||||
|
||||
function PhaseForm({ phase, value, onChange }: { phase: Phase; value: PhaseCfg; onChange: (patch: Partial<PhaseCfg>) => void }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="factory-phase">
|
||||
<h4>{t(`factory.phase.${phase}` as any)}</h4>
|
||||
<label>
|
||||
<span>{t("factory.model")}</span>
|
||||
<ModelSelect value={value.model} onChange={(model) => onChange({ model })} emptyLabel={t("jobEditor.defaultModel")} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("factory.agent")}</span>
|
||||
<select value={value.agent} onChange={(e) => onChange({ agent: e.target.value })}>
|
||||
<option value="codecraft">CodeCraft</option>
|
||||
<option value="claude-code">Claude Code</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("factory.mode")}</span>
|
||||
<select value={value.permission_mode} onChange={(e) => onChange({ permission_mode: e.target.value })}>
|
||||
{MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{t(`permissionMode.${m}` as any)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("factory.timeout")}</span>
|
||||
<input type="number" min={1} value={value.timeout_minutes} onChange={(e) => onChange({ timeout_minutes: Number(e.target.value) || 60 })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("factory.extraPrompt")}</span>
|
||||
<textarea rows={3} value={value.extra_prompt} onChange={(e) => onChange({ extra_prompt: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const URL_PLACEHOLDER: Record<string, string> = { gitea: "https://git.example.org", github: "https://github.com", gitlab: "https://gitlab.com" };
|
||||
|
||||
function FactorySettings() {
|
||||
const t = useT();
|
||||
const [st, setSt] = useState<Settings>();
|
||||
const [busy, setBusy] = useState<string>();
|
||||
const [orgs, setOrgs] = useState<Record<string, string[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
get<Settings>("/account/factory")
|
||||
.then(setSt)
|
||||
.catch((e) => toast(e.message));
|
||||
}, []);
|
||||
if (!st) return <div className="empty"><Spinner /></div>;
|
||||
const { connections: conns, repos } = st;
|
||||
|
||||
const setConns = (connections: Connection[]) => setSt({ ...st, connections });
|
||||
const setRepos = (r: RepoCfg[]) => setSt({ ...st, repos: r });
|
||||
const upConn = (i: number, patch: Partial<Connection>) => setConns(conns.map((c, k) => (k === i ? { ...c, ...patch } : c)));
|
||||
const up = (i: number, patch: Partial<RepoCfg>) => setRepos(repos.map((r, k) => (k === i ? { ...r, ...patch } : r)));
|
||||
const upPhase = (i: number, p: Phase, patch: Partial<PhaseCfg>) => up(i, { [p]: { ...repos[i][p], ...patch } } as any);
|
||||
const upDefault = (p: Phase, patch: Partial<PhaseCfg>) => setSt({ ...st, defaults: { ...st.defaults, [p]: { ...st.defaults[p], ...patch } } });
|
||||
|
||||
const save = async () => {
|
||||
const s = await put<Settings>("/account/factory", st);
|
||||
setSt(s);
|
||||
return s;
|
||||
};
|
||||
const act = async (key: string, f: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await f();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setBusy(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="notice">{t("factory.settingsHint")}</div>
|
||||
|
||||
<h3 className="factory-h">{t("factory.connections")}</h3>
|
||||
<div className="hint" style={{ marginBottom: 8 }}>{t("factory.connectionsHint")}</div>
|
||||
{conns.map((c, i) => (
|
||||
<div key={i} className="panel">
|
||||
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
|
||||
<h3 style={{ margin: 0 }}>{c.name || KIND_NAME[c.kind]}</h3>
|
||||
<span className="spacer" />
|
||||
<button
|
||||
className="small"
|
||||
disabled={busy !== undefined}
|
||||
onClick={() =>
|
||||
act(`c${i}`, async () => {
|
||||
const saved = await save();
|
||||
const id = saved.connections[i]?.id;
|
||||
const r = await post<{ login: string; orgs: string[] }>("/account/factory/connection/test", { id });
|
||||
setOrgs({ ...orgs, [id]: r.orgs });
|
||||
toast(t("factory.connOk", { login: r.login, n: r.orgs.length }), "success");
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === `c${i}` ? <Spinner small /> : <Icon name="plug" />} {t("factory.testConn")}
|
||||
</button>
|
||||
<button
|
||||
className="small"
|
||||
title={t("common.delete")}
|
||||
aria-label={t("common.delete")}
|
||||
onClick={async () => {
|
||||
if (await confirmDialog({ message: t("factory.connDeleteConfirm", { name: c.name || KIND_NAME[c.kind] }), danger: true })) setConns(conns.filter((_, k) => k !== i));
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="factory-form">
|
||||
<label>
|
||||
<span>{t("factory.connName")}</span>
|
||||
<input value={c.name} onChange={(e) => upConn(i, { name: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("factory.kind")}</span>
|
||||
<select value={c.kind} onChange={(e) => upConn(i, { kind: e.target.value as Connection["kind"] })}>
|
||||
{Object.entries(KIND_NAME).map(([k, n]) => (
|
||||
<option key={k} value={k}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("factory.serviceUrl")}</span>
|
||||
<input value={c.url} placeholder={URL_PLACEHOLDER[c.kind]} onChange={(e) => upConn(i, { url: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("factory.token")}</span>
|
||||
<input type="password" autoComplete="new-password" value={c.token} placeholder={t("factory.tokenPlaceholder")} onChange={(e) => upConn(i, { token: e.target.value })} onFocus={(e) => c.token.startsWith("•") && e.currentTarget.select()} />
|
||||
<span className="hint">{c.token.startsWith("•") ? t("factory.tokenSet") : t(`factory.tokenHint.${c.kind}` as any)}</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("factory.defaultOwner")}</span>
|
||||
<input list={`orgs-${c.id}`} value={c.owner} placeholder={t("draft.ownerPlaceholder")} onChange={(e) => upConn(i, { owner: e.target.value })} />
|
||||
<datalist id={`orgs-${c.id}`}>
|
||||
{(orgs[c.id] ?? []).map((o) => (
|
||||
<option key={o} value={o} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => setConns([...conns, blankConn()])}>
|
||||
<Icon name="plus" /> {t("factory.addConn")}
|
||||
</button>
|
||||
|
||||
<h3 className="factory-h">{t("factory.defaults")}</h3>
|
||||
<div className="hint" style={{ marginBottom: 8 }}>{t("factory.defaultsHint")}</div>
|
||||
<div className="panel">
|
||||
<label className="factory-form" style={{ margin: 0 }}>
|
||||
<span style={{ fontSize: 12 }}>{t("factory.specModel")}</span>
|
||||
<ModelSelect value={st.defaults.spec_model} onChange={(spec_model) => setSt({ ...st, defaults: { ...st.defaults, spec_model } })} emptyLabel={t("jobEditor.defaultModel")} />
|
||||
</label>
|
||||
<div className="factory-phases" style={{ marginTop: 10 }}>
|
||||
{PHASES.map((p) => (
|
||||
<PhaseForm key={p} phase={p} value={st.defaults[p]} onChange={(patch) => upDefault(p, patch)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="factory-h">{t("factory.repos")}</h3>
|
||||
{repos.map((r, i) => (
|
||||
<div key={i} className="panel">
|
||||
<div className="row" style={{ flexWrap: "wrap", gap: 8 }}>
|
||||
<h3 style={{ margin: 0 }}>{r.url ? repoName(r.url) : t("factory.newRepo")}</h3>
|
||||
<span className="chip">{KIND_NAME[conns.find((c) => c.id === r.connection)?.kind ?? r.kind] ?? r.kind}</span>
|
||||
<span className="spacer" />
|
||||
<label className="switch">
|
||||
<input type="checkbox" checked={r.enabled} onChange={(e) => up(i, { enabled: e.target.checked })} />
|
||||
<span>{t(r.enabled ? "jobs.on" : "jobs.off")}</span>
|
||||
</label>
|
||||
<button
|
||||
className="small"
|
||||
disabled={busy !== undefined}
|
||||
onClick={() =>
|
||||
act(`t${i}`, async () => {
|
||||
await save();
|
||||
const res = await post<{ info: { login: string; repo: string; can_push: boolean }; labels_created: string[]; folder: string }>("/account/factory/test", { url: r.url });
|
||||
toast(t("factory.testOk", { login: res.info.login, repo: res.info.repo, n: res.labels_created.length }), "success");
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === `t${i}` ? <Spinner small /> : <Icon name="plug" />} {t("factory.test")}
|
||||
</button>
|
||||
<button
|
||||
className="small"
|
||||
title={t("common.delete")}
|
||||
aria-label={t("common.delete")}
|
||||
onClick={async () => {
|
||||
if (await confirmDialog({ message: t("factory.deleteConfirm", { name: repoName(r.url) || "?" }), danger: true })) setRepos(repos.filter((_, k) => k !== i));
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="factory-form">
|
||||
<label>
|
||||
<span>{t("factory.connection")}</span>
|
||||
<select
|
||||
value={r.connection}
|
||||
onChange={(e) => up(i, { connection: e.target.value, kind: conns.find((c) => c.id === e.target.value)?.kind ?? r.kind })}
|
||||
>
|
||||
<option value="">{t("factory.noConnection")}</option>
|
||||
{conns
|
||||
.filter((c) => c.id)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name || KIND_NAME[c.kind]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{!r.connection && (
|
||||
<label>
|
||||
<span>{t("factory.kind")}</span>
|
||||
<select value={r.kind} onChange={(e) => up(i, { kind: e.target.value })}>
|
||||
{Object.entries(KIND_NAME).map(([k, n]) => (
|
||||
<option key={k} value={k}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
<span>{t("factory.url")}</span>
|
||||
<input value={r.url} placeholder="https://host/owner/repo.git" onChange={(e) => up(i, { url: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("factory.repoToken")}</span>
|
||||
<input type="password" autoComplete="new-password" value={r.token} placeholder={t("factory.repoTokenPlaceholder")} onChange={(e) => up(i, { token: e.target.value })} onFocus={(e) => r.token.startsWith("•") && e.currentTarget.select()} />
|
||||
<span className="hint">{r.token.startsWith("•") ? t("factory.tokenSet") : t("factory.repoTokenHint")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("factory.label")}</span>
|
||||
<input value={r.label} onChange={(e) => up(i, { label: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("factory.parallel")}</span>
|
||||
<input type="number" min={1} max={4} value={r.max_parallel} onChange={(e) => up(i, { max_parallel: Number(e.target.value) || 1 })} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("factory.allowed")}</span>
|
||||
<input value={r.allowed_users.join(", ")} placeholder={t("factory.allowedPlaceholder")} onChange={(e) => up(i, { allowed_users: e.target.value.split(",").map((u) => u.trim()).filter(Boolean) })} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="factory-phases">
|
||||
{PHASES.map((p) => (
|
||||
<PhaseForm key={p} phase={p} value={r[p]} onChange={(patch) => upPhase(i, p, patch)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="row" style={{ gap: 8, marginTop: 12 }}>
|
||||
<button onClick={() => setRepos([...repos, blankRepo(conns.find((c) => c.id)?.id ?? "", conns.find((c) => c.id)?.kind ?? "gitea")])}>
|
||||
<Icon name="plus" /> {t("factory.addRepo")}
|
||||
</button>
|
||||
<button
|
||||
className="primary"
|
||||
disabled={busy !== undefined}
|
||||
onClick={() =>
|
||||
act("save", async () => {
|
||||
await save();
|
||||
toast(t("factory.saved"), "success");
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === "save" ? <Spinner small /> : <Icon name="check" />} {t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get, post } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { toast } from "../dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
import { Spinner } from "./Activity";
|
||||
|
||||
interface Draft {
|
||||
spec: string;
|
||||
proposal: { name?: string; slug?: string; description?: string } | null;
|
||||
connections: { id: string; name: string; kind: string; owner: string }[];
|
||||
}
|
||||
|
||||
export const isDraftChat = (cwd: string) => /\/_drafts\/[^/]+$/.test(cwd);
|
||||
|
||||
const slugify = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
|
||||
/** Bar above the composer of a spec chat: file the spec as a new repository and start the workflow. */
|
||||
export function DraftBar({ sessionId }: { sessionId: string }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<div className="draft-bar">
|
||||
<Icon name="scroll" />
|
||||
<span>{t("draft.hint")}</span>
|
||||
<span className="spacer" />
|
||||
<button className="small primary" onClick={() => setOpen(true)}>
|
||||
<Icon name="check" /> {t("draft.create")}
|
||||
</button>
|
||||
</div>
|
||||
{open && <CreateProjectDialog sessionId={sessionId} onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateProjectDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) {
|
||||
const t = useT();
|
||||
const [draft, setDraft] = useState<Draft>();
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
const [connection, setConnection] = useState("");
|
||||
const [owner, setOwner] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
get<Draft>(`/factory/drafts/${sessionId}`)
|
||||
.then((d) => {
|
||||
setDraft(d);
|
||||
setName(d.proposal?.name ?? "");
|
||||
setSlug(d.proposal?.slug ? slugify(d.proposal.slug) : "");
|
||||
setSlugTouched(!!d.proposal?.slug);
|
||||
setDescription(d.proposal?.description ?? "");
|
||||
setConnection(d.connections[0]?.id ?? "");
|
||||
setOwner(d.connections[0]?.owner ?? "");
|
||||
})
|
||||
.catch((e) => {
|
||||
toast(e.message);
|
||||
onClose();
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
if (!draft)
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<div className="modal">
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const conn = draft.connections.find((c) => c.id === connection);
|
||||
const ready = draft.spec.trim() && name.trim() && slug.length >= 2 && connection;
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await post<{ repo: string; issue: number }>(`/factory/drafts/${sessionId}/create`, { connection, name, slug, description, owner });
|
||||
await post("/factory/poll", {}).catch(() => undefined);
|
||||
toast(t("draft.created", { repo: r.repo, n: r.issue }), "success");
|
||||
onClose();
|
||||
navigate("/factory");
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={busy ? undefined : onClose}>
|
||||
<div className="modal draft-modal" role="dialog" aria-label={t("draft.create")} onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t("draft.create")}</h3>
|
||||
{draft.connections.length === 0 ? (
|
||||
<div className="notice err">
|
||||
{t("draft.noConnection")}{" "}
|
||||
<button className="link-btn" onClick={() => navigate("/factory")}>
|
||||
{t("factory.tab.settings")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!draft.spec.trim() && <div className="notice err">{t("draft.noSpec")}</div>}
|
||||
<div className="factory-form">
|
||||
<label>
|
||||
<span>{t("draft.name")}</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
if (!slugTouched) setSlug(slugify(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("draft.slug")}</span>
|
||||
<input
|
||||
value={slug}
|
||||
className="mono"
|
||||
onChange={(e) => {
|
||||
setSlug(slugify(e.target.value));
|
||||
setSlugTouched(true);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ gridColumn: "1 / -1" }}>
|
||||
<span>{t("draft.description")}</span>
|
||||
<input value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("draft.connection")}</span>
|
||||
<select
|
||||
value={connection}
|
||||
onChange={(e) => {
|
||||
setConnection(e.target.value);
|
||||
setOwner(draft.connections.find((c) => c.id === e.target.value)?.owner ?? "");
|
||||
}}
|
||||
>
|
||||
{draft.connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name || c.kind} ({c.kind})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("draft.owner")}</span>
|
||||
<input value={owner} placeholder={t("draft.ownerPlaceholder")} onChange={(e) => setOwner(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="hint" style={{ margin: "6px 0" }}>
|
||||
{t("draft.private", { conn: conn?.name || "" })}
|
||||
</div>
|
||||
<details>
|
||||
<summary>{t("draft.specPreview")}</summary>
|
||||
<pre className="draft-spec">{draft.spec || "–"}</pre>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
<div className="row" style={{ justifyContent: "flex-end", gap: 8, marginTop: 12 }}>
|
||||
<button onClick={onClose} disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button className="primary" onClick={create} disabled={!ready || busy}>
|
||||
{busy ? <Spinner small /> : <Icon name="check" />} {t("draft.createGo")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -491,6 +491,10 @@ export function Sidebar({ open, activeIds, section }: { open: boolean; activeIds
|
||||
<Icon name="clock" className="nav-icon" />
|
||||
{t("nav.jobs")}
|
||||
</button>
|
||||
<button className={`ghost ${section === "factory" ? "active" : ""}`} onClick={() => navigate("/factory")}>
|
||||
<Icon name="toolbox" className="nav-icon" />
|
||||
{t("nav.factory")}
|
||||
</button>
|
||||
<button className={`ghost ${section === "stats" ? "active" : ""}`} onClick={() => navigate("/stats")}>
|
||||
<Icon name="chart" className="nav-icon" />
|
||||
{t("nav.stats")}
|
||||
|
||||
@@ -1168,4 +1168,110 @@ export const de: Messages = {
|
||||
"chat.folderFiles": "{n} Dateien",
|
||||
"chat.folderHasGit": "Der Ordner enthält ein Git-Repository – nicht gepushte Commits und Branches gehen verloren.",
|
||||
"chat.folderDeleted": "Chat und Ordner gelöscht",
|
||||
"nav.factory": "Factory",
|
||||
"factory.title": "Software-Factory",
|
||||
"factory.tab.overview": "Übersicht",
|
||||
"factory.tab.settings": "Einstellungen",
|
||||
"factory.empty": "Noch kein Repository verbunden. Füge in den Einstellungen eines hinzu: Issues mit dem Factory-Label werden dann von Agenten geprüft, gebaut und abgenommen.",
|
||||
"factory.addRepo": "Repository hinzufügen",
|
||||
"factory.newRepo": "Neues Repository",
|
||||
"factory.noArea": "Der Factory-Bereich ist nicht eingebunden (FACTORY_DIR → /factory). Repositories können nicht geklont werden.",
|
||||
"factory.kpi.running": "Läuft gerade",
|
||||
"factory.kpi.issues": "{n} Issues",
|
||||
"factory.kpi.done": "Fertig",
|
||||
"factory.kpi.failed": "{n} fehlgeschlagen oder abgelehnt",
|
||||
"factory.kpi.tokens": "Tokens",
|
||||
"factory.kpi.cost": "Kosten",
|
||||
"factory.kpi.runs": "{n} Läufe",
|
||||
"factory.kpi.time": "Ø Laufzeit",
|
||||
"factory.kpi.perRun": "pro beendetem Lauf",
|
||||
"factory.in": "ein",
|
||||
"factory.out": "aus",
|
||||
"factory.allRepos": "Alle Projekte",
|
||||
"factory.repoLine": "{n} Issues · {run} laufen",
|
||||
"factory.noToken": "kein Token",
|
||||
"factory.pollNow": "Jetzt prüfen",
|
||||
"factory.col.review": "Prüfung",
|
||||
"factory.col.question": "Rückfragen",
|
||||
"factory.col.approved": "Freigegeben",
|
||||
"factory.col.build": "Bau",
|
||||
"factory.col.qa": "Abnahme",
|
||||
"factory.col.done": "Fertig",
|
||||
"factory.col.failed": "Fehlgeschlagen / abgelehnt",
|
||||
"factory.phase": "Phase",
|
||||
"factory.phase.review": "Prüfung",
|
||||
"factory.phase.build": "Bau",
|
||||
"factory.phase.qa": "Abnahme",
|
||||
"factory.latestRuns": "Letzte Läufe",
|
||||
"factory.noRuns": "Noch keine Läufe.",
|
||||
"factory.issue": "Issue",
|
||||
"factory.model": "Modell",
|
||||
"factory.status": "Status",
|
||||
"factory.started": "Gestartet",
|
||||
"factory.duration": "Dauer",
|
||||
"factory.tokens": "Tokens",
|
||||
"factory.cost": "Kosten",
|
||||
"factory.pr": "Pull Request",
|
||||
"factory.round": "Runde {n}",
|
||||
"factory.openChat": "Chat öffnen",
|
||||
"factory.runStatus.running": "läuft",
|
||||
"factory.runStatus.done": "fertig",
|
||||
"factory.runStatus.timeout": "Zeitlimit",
|
||||
"factory.runStatus.error": "Fehler",
|
||||
"factory.runStatus.no-result": "kein Ergebnis",
|
||||
"factory.runStatus.interrupted": "unterbrochen",
|
||||
"factory.settingsHint": "Specs kommen als Gitea-Issues mit dem Factory-Label (\"Spec: <Pfad>\" im Text). Jede Phase läuft als eigener Chat in der Sandbox mit eigenem Modell. Repositories werden in den separaten Factory-Bereich geklont, nicht in den Workspace; die Agenten sehen den API-Token nie.",
|
||||
"factory.url": "Repository-URL",
|
||||
"factory.token": "Gitea-API-Token",
|
||||
"factory.tokenPlaceholder": "Token mit Rechten für Issues und Pull Requests",
|
||||
"factory.tokenSet": "Ein Token ist gespeichert. Gib einen neuen ein, um ihn zu ersetzen.",
|
||||
"factory.label": "Issue-Label",
|
||||
"factory.parallel": "Parallele Läufe",
|
||||
"factory.allowed": "Erlaubte Issue-Autoren",
|
||||
"factory.allowedPlaceholder": "leer = nur der Token-Besitzer",
|
||||
"factory.agent": "Agent",
|
||||
"factory.mode": "Berechtigungsmodus",
|
||||
"factory.timeout": "Zeitlimit (Minuten)",
|
||||
"factory.extraPrompt": "Zusätzliche Anweisungen",
|
||||
"factory.test": "Verbindung testen",
|
||||
"factory.testOk": "Verbunden als {login} mit {repo}; {n} Labels angelegt, Repository geklont.",
|
||||
"factory.deleteConfirm": "{name} aus der Factory entfernen? Issues und Chats bleiben.",
|
||||
"factory.newProject": "Neues Projekt",
|
||||
"factory.connections": "Verbindungen",
|
||||
"factory.connectionsHint": "Deine Konten bei Gitea, GitHub oder GitLab. Jeder Nutzer richtet seine eigenen ein; Repositories nutzen den Token ihrer Verbindung, sofern sie keinen eigenen haben.",
|
||||
"factory.connName": "Name",
|
||||
"factory.kind": "Dienst",
|
||||
"factory.serviceUrl": "Adresse des Dienstes",
|
||||
"factory.defaultOwner": "Standard-Besitzer für neue Repositories",
|
||||
"factory.addConn": "Verbindung hinzufügen",
|
||||
"factory.testConn": "Testen",
|
||||
"factory.connOk": "Angemeldet als {login}; {n} Organisationen oder Gruppen gefunden.",
|
||||
"factory.connDeleteConfirm": "Verbindung {name} entfernen? Repositories funktionieren danach nur mit eigenem Token.",
|
||||
"factory.tokenHint.gitea": "Scopes: write:repository, write:issue, read:user – zum Anlegen von Repositories zusätzlich write:user (oder write:organization).",
|
||||
"factory.tokenHint.github": "Classic-Token mit Scope \"repo\" oder fein abgestufter Token mit Contents, Issues und Pull requests (Lesen/Schreiben).",
|
||||
"factory.tokenHint.gitlab": "Persönlicher oder Projekt-Zugriffstoken mit Scope \"api\".",
|
||||
"factory.defaults": "Standard für neue Projekte",
|
||||
"factory.defaultsHint": "Modelle und Grenzen, mit denen ein Projekt startet; du kannst sie danach pro Projekt ändern.",
|
||||
"factory.specModel": "Modell des Spec-Chats",
|
||||
"factory.repos": "Repositories",
|
||||
"factory.connection": "Verbindung",
|
||||
"factory.noConnection": "– keine (eigener Token) –",
|
||||
"factory.repoToken": "Eigener Token (optional)",
|
||||
"factory.repoTokenPlaceholder": "leer = Token der Verbindung",
|
||||
"factory.repoTokenHint": "Nur nötig, wenn dieses Repository einen anderen Token nutzen soll.",
|
||||
"draft.hint": "Spec-Chat: Wenn die Spec fertig ist, lege sie als neues Repository ab.",
|
||||
"draft.create": "Projekt anlegen",
|
||||
"draft.createGo": "Repository anlegen und starten",
|
||||
"draft.name": "Projektname",
|
||||
"draft.slug": "Repository-Name",
|
||||
"draft.description": "Kurzbeschreibung",
|
||||
"draft.connection": "Verbindung",
|
||||
"draft.owner": "Besitzer",
|
||||
"draft.ownerPlaceholder": "leer = dein eigenes Konto",
|
||||
"draft.private": "Das Repository wird privat angelegt. Die Spec wird als docs/specs/<name>.md committet und ein Issue startet den Workflow.",
|
||||
"draft.specPreview": "Spec anzeigen",
|
||||
"draft.noSpec": "In diesem Chat gibt es noch keine spec.md – lass sie zuerst vom Modell schreiben.",
|
||||
"draft.noConnection": "Richte zuerst eine Verbindung (Gitea, GitHub oder GitLab) ein:",
|
||||
"draft.created": "{repo} angelegt, Issue #{n} eröffnet – die Prüfung startet gleich.",
|
||||
"factory.saved": "Gespeichert",
|
||||
};
|
||||
@@ -1167,6 +1167,112 @@ export const en = {
|
||||
"chat.folderFiles": "{n} files",
|
||||
"chat.folderHasGit": "The folder contains a git repository – commits and branches that were not pushed are lost.",
|
||||
"chat.folderDeleted": "Chat and folder deleted",
|
||||
"nav.factory": "Factory",
|
||||
"factory.title": "Software factory",
|
||||
"factory.tab.overview": "Overview",
|
||||
"factory.tab.settings": "Settings",
|
||||
"factory.empty": "No repository connected yet. Add one in the settings: issues with the factory label are then reviewed, built and checked by agents.",
|
||||
"factory.addRepo": "Add repository",
|
||||
"factory.newRepo": "New repository",
|
||||
"factory.noArea": "The factory area is not mounted (FACTORY_DIR → /factory). Repositories cannot be cloned.",
|
||||
"factory.kpi.running": "Running now",
|
||||
"factory.kpi.issues": "{n} issues",
|
||||
"factory.kpi.done": "Done",
|
||||
"factory.kpi.failed": "{n} failed or rejected",
|
||||
"factory.kpi.tokens": "Tokens",
|
||||
"factory.kpi.cost": "Cost",
|
||||
"factory.kpi.runs": "{n} runs",
|
||||
"factory.kpi.time": "Avg. run time",
|
||||
"factory.kpi.perRun": "per finished run",
|
||||
"factory.in": "in",
|
||||
"factory.out": "out",
|
||||
"factory.allRepos": "All projects",
|
||||
"factory.repoLine": "{n} issues · {run} running",
|
||||
"factory.noToken": "no token",
|
||||
"factory.pollNow": "Check now",
|
||||
"factory.col.review": "Review",
|
||||
"factory.col.question": "Questions",
|
||||
"factory.col.approved": "Approved",
|
||||
"factory.col.build": "Build",
|
||||
"factory.col.qa": "Acceptance",
|
||||
"factory.col.done": "Done",
|
||||
"factory.col.failed": "Failed / rejected",
|
||||
"factory.phase": "Phase",
|
||||
"factory.phase.review": "Review",
|
||||
"factory.phase.build": "Build",
|
||||
"factory.phase.qa": "Acceptance",
|
||||
"factory.latestRuns": "Latest runs",
|
||||
"factory.noRuns": "No runs yet.",
|
||||
"factory.issue": "Issue",
|
||||
"factory.model": "Model",
|
||||
"factory.status": "Status",
|
||||
"factory.started": "Started",
|
||||
"factory.duration": "Duration",
|
||||
"factory.tokens": "Tokens",
|
||||
"factory.cost": "Cost",
|
||||
"factory.pr": "Pull request",
|
||||
"factory.round": "round {n}",
|
||||
"factory.openChat": "Open chat",
|
||||
"factory.runStatus.running": "running",
|
||||
"factory.runStatus.done": "done",
|
||||
"factory.runStatus.timeout": "timeout",
|
||||
"factory.runStatus.error": "error",
|
||||
"factory.runStatus.no-result": "no result",
|
||||
"factory.runStatus.interrupted": "interrupted",
|
||||
"factory.settingsHint": "Specs arrive as Gitea issues with the factory label (\"Spec: <path>\" in the text). Each phase runs as its own chat in the sandbox with its own model. Repositories are cloned into the separate factory area, not into the workspace; agents never see the API token.",
|
||||
"factory.url": "Repository URL",
|
||||
"factory.token": "Gitea API token",
|
||||
"factory.tokenPlaceholder": "Token with rights for issues and pull requests",
|
||||
"factory.tokenSet": "A token is stored. Type a new one to replace it.",
|
||||
"factory.label": "Issue label",
|
||||
"factory.parallel": "Parallel runs",
|
||||
"factory.allowed": "Allowed issue authors",
|
||||
"factory.allowedPlaceholder": "empty = only the token's owner",
|
||||
"factory.agent": "Agent",
|
||||
"factory.mode": "Permission mode",
|
||||
"factory.timeout": "Timeout (minutes)",
|
||||
"factory.extraPrompt": "Extra instructions",
|
||||
"factory.test": "Test connection",
|
||||
"factory.testOk": "Connected as {login} to {repo}; {n} labels created, repository cloned.",
|
||||
"factory.deleteConfirm": "Remove {name} from the factory? Issues and chats stay.",
|
||||
"factory.newProject": "New project",
|
||||
"factory.connections": "Connections",
|
||||
"factory.connectionsHint": "Your accounts at Gitea, GitHub or GitLab. Each user sets up their own; repositories use the token of their connection unless they have a token of their own.",
|
||||
"factory.connName": "Name",
|
||||
"factory.kind": "Service",
|
||||
"factory.serviceUrl": "Address of the service",
|
||||
"factory.defaultOwner": "Default owner for new repositories",
|
||||
"factory.addConn": "Add connection",
|
||||
"factory.testConn": "Test",
|
||||
"factory.connOk": "Signed in as {login}; {n} organisations or groups found.",
|
||||
"factory.connDeleteConfirm": "Remove the connection {name}? Repositories keep working only with a token of their own.",
|
||||
"factory.tokenHint.gitea": "Scopes: write:repository, write:issue, read:user – plus write:user (or write:organization) to create repositories.",
|
||||
"factory.tokenHint.github": "Classic token with scope \"repo\", or a fine-grained token with Contents, Issues and Pull requests (read/write).",
|
||||
"factory.tokenHint.gitlab": "Personal or project access token with scope \"api\".",
|
||||
"factory.defaults": "Defaults for new projects",
|
||||
"factory.defaultsHint": "Models and limits a project starts with; you can change them per project afterwards.",
|
||||
"factory.specModel": "Model of the spec chat",
|
||||
"factory.repos": "Repositories",
|
||||
"factory.connection": "Connection",
|
||||
"factory.noConnection": "– none (own token) –",
|
||||
"factory.repoToken": "Own token (optional)",
|
||||
"factory.repoTokenPlaceholder": "empty = token of the connection",
|
||||
"factory.repoTokenHint": "Only needed when this repository should use a different token.",
|
||||
"draft.hint": "Spec chat: when the spec is done, file it as a new repository.",
|
||||
"draft.create": "Create project",
|
||||
"draft.createGo": "Create repository and start",
|
||||
"draft.name": "Project name",
|
||||
"draft.slug": "Repository name",
|
||||
"draft.description": "Short description",
|
||||
"draft.connection": "Connection",
|
||||
"draft.owner": "Owner",
|
||||
"draft.ownerPlaceholder": "empty = your own account",
|
||||
"draft.private": "The repository is created private. The spec is committed as docs/specs/<name>.md and an issue starts the workflow.",
|
||||
"draft.specPreview": "Show spec",
|
||||
"draft.noSpec": "There is no spec.md in this chat yet – let the model write it first.",
|
||||
"draft.noConnection": "Set up a connection (Gitea, GitHub or GitLab) first:",
|
||||
"draft.created": "{repo} created, issue #{n} opened – the review starts shortly.",
|
||||
"factory.saved": "Saved",
|
||||
};
|
||||
|
||||
export type Messages = Record<keyof typeof en, string>;
|
||||
@@ -5020,3 +5020,153 @@ td.fold-indent {
|
||||
.ssh-key input.mono { width: 100%; font-size: 12px; }
|
||||
.dialog-message { overflow-wrap: anywhere; }
|
||||
.dialog-choices { flex-wrap: wrap; }
|
||||
|
||||
/* --- software factory ------------------------------------------------------ */
|
||||
|
||||
.button-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--fg1);
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.factory-repos {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.factory-repo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg2);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
min-width: 150px;
|
||||
}
|
||||
.factory-repo.active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.factory-repo.off {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.factory-board {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(200px, 1fr);
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.factory-col {
|
||||
background: var(--bg1);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.factory-col h4 {
|
||||
margin: 0 0 2px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.factory-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
background: var(--bg0);
|
||||
border: 1px solid var(--bg2);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.factory-card.active {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.factory-card.running {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.factory-pulse {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
margin-right: 5px;
|
||||
animation: factory-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes factory-pulse {
|
||||
50% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
.factory-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.factory-form label,
|
||||
.factory-phase label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--fg3);
|
||||
}
|
||||
.factory-phases {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.factory-phase {
|
||||
background: var(--bg1);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.factory-phase h4 {
|
||||
margin: 0;
|
||||
}
|
||||
.factory-h {
|
||||
margin: 22px 0 6px;
|
||||
}
|
||||
.draft-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg1);
|
||||
border-top: 1px solid var(--bg2);
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.draft-modal {
|
||||
width: min(640px, calc(100vw - 32px));
|
||||
}
|
||||
.draft-spec {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
background: var(--bg1);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
unreleased: {
|
||||
en: ["New: software factory – write a spec with a model, create the repository (Gitea, GitHub or GitLab) and let agents review it, a model per phase", "Factory overview with board, tokens, run time and links into the runs"],
|
||||
de: ["Neu: Software-Factory – Spec mit einem Modell schreiben, Repository anlegen (Gitea, GitHub oder GitLab) und von Agenten prüfen lassen, ein Modell pro Phase", "Factory-Übersicht mit Board, Tokens, Laufzeit und Links in die Läufe"],
|
||||
},
|
||||
"202611-00040": {
|
||||
en: ["Images you upload are now visible to Claude Code (they arrived empty before)"],
|
||||
de: ["Hochgeladene Bilder sind für Claude Code jetzt sichtbar (sie kamen vorher leer an)"],
|
||||
|
||||
Reference in new issue
Block a user