From c4545594def1d6959e4bd3e6e9ff6b62d346d429 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 13 Jun 2026 14:10:01 +0800 Subject: [PATCH 001/157] feat(db): add loop engineering tables and migration 10 tables (space/issue/artifact/revision/link/criterion/iteration/ validation_run/inbox_item/memory) with dispatch-lease partial unique indexes and pending-inbox dedupe. Pin the kind-backfill test to its own migration index so appending migrations no longer shifts its cut point. --- ...0260612_000001_conversation_folder_kind.rs | 12 +- .../migration/m20260613_000001_loop_tables.rs | 250 ++++++++++++++++++ src-tauri/src/db/migration/mod.rs | 2 + 3 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/db/migration/m20260613_000001_loop_tables.rs diff --git a/src-tauri/src/db/migration/m20260612_000001_conversation_folder_kind.rs b/src-tauri/src/db/migration/m20260612_000001_conversation_folder_kind.rs index e507924815..a8dbbe5569 100644 --- a/src-tauri/src/db/migration/m20260612_000001_conversation_folder_kind.rs +++ b/src-tauri/src/db/migration/m20260612_000001_conversation_folder_kind.rs @@ -146,7 +146,7 @@ enum Conversation { #[cfg(test)] mod tests { use sea_orm::{ConnectionTrait, Database, DbBackend, Statement}; - use sea_orm_migration::MigratorTrait; + use sea_orm_migration::{MigrationName, MigratorTrait}; use crate::db::migration::Migrator; @@ -171,8 +171,14 @@ mod tests { #[tokio::test] async fn backfills_folder_and_conversation_kind() { let conn = Database::connect("sqlite::memory:").await.expect("db"); - let total = ::migrations().len() as u32; - Migrator::up(&conn, Some(total - 1)) + // Run every migration strictly *before* this one, then seed legacy rows. + // Pin to this migration's own index by name so appending later + // migrations (e.g. the loop tables) never shifts the cut point. + let idx = ::migrations() + .iter() + .position(|m| m.name() == "m20260612_000001_conversation_folder_kind") + .expect("migration present") as u32; + Migrator::up(&conn, Some(idx)) .await .expect("legacy migrations"); diff --git a/src-tauri/src/db/migration/m20260613_000001_loop_tables.rs b/src-tauri/src/db/migration/m20260613_000001_loop_tables.rs new file mode 100644 index 0000000000..78cdbeaeff --- /dev/null +++ b/src-tauri/src/db/migration/m20260613_000001_loop_tables.rs @@ -0,0 +1,250 @@ +//! Loop engineering schema (M2): 10 tables backing spaces, issues, the per-issue +//! artifact DAG, iterations (agent runs), deterministic validation runs, the +//! two-category inbox and the memory layer. +//! +//! Raw SQLite DDL is used deliberately: the project is SQLite-only, the four +//! *partial* unique indexes (the dispatch leases + pending-inbox dedupe) cannot +//! be expressed through SeaORM's `Index` builder, and 10 tables read far more +//! clearly as DDL than as builder chains. Cross-subsystem refs (`folder_id`, +//! `conversation_id`) are plain columns. The artifact↔iteration cycle +//! (`produced_by_iteration_id` / `target_artifact_id`) is intentionally left +//! without FK constraints to avoid a circular dependency; every other loop-table +//! reference is a real FK enforced in the test pool. + +use sea_orm::ConnectionTrait; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const UP: &[&str] = &[ + "CREATE TABLE loop_space ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + folder_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + "CREATE TABLE loop_issue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + seq_no INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + priority TEXT NOT NULL DEFAULT 'medium', + status TEXT NOT NULL DEFAULT 'pending', + pause_reason TEXT, + route TEXT NOT NULL DEFAULT 'undecided', + config TEXT NOT NULL, + worktree_folder_id INTEGER, + base_branch TEXT, + base_commit TEXT, + active_task_artifact_id INTEGER, + token_used BIGINT NOT NULL DEFAULT 0, + token_budget BIGINT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + triggered_at TEXT, + ended_at TEXT + )", + "CREATE UNIQUE INDEX uniq_loop_issue_seq ON loop_issue(space_id, seq_no)", + "CREATE INDEX idx_loop_issue_space_status ON loop_issue(space_id, status)", + "CREATE TABLE loop_artifact ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + issue_id INTEGER NOT NULL REFERENCES loop_issue(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + origin TEXT NOT NULL, + produced_by_iteration_id INTEGER, + verdict TEXT, + attempt INTEGER NOT NULL DEFAULT 0, + last_failure_sig TEXT, + sort INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", + "CREATE INDEX idx_loop_artifact_issue_kind ON loop_artifact(issue_id, kind)", + "CREATE INDEX idx_loop_artifact_space ON loop_artifact(space_id)", + "CREATE TABLE loop_artifact_revision ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artifact_id INTEGER NOT NULL REFERENCES loop_artifact(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + content TEXT NOT NULL, + actor_kind TEXT NOT NULL, + iteration_id INTEGER, + created_at TEXT NOT NULL + )", + "CREATE UNIQUE INDEX uniq_loop_revision ON loop_artifact_revision(artifact_id, seq)", + "CREATE TABLE loop_link ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + from_artifact_id INTEGER NOT NULL REFERENCES loop_artifact(id) ON DELETE CASCADE, + to_artifact_id INTEGER NOT NULL REFERENCES loop_artifact(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + created_at TEXT NOT NULL + )", + "CREATE UNIQUE INDEX uniq_loop_link ON loop_link(from_artifact_id, to_artifact_id, kind)", + "CREATE TABLE loop_criterion ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artifact_id INTEGER NOT NULL REFERENCES loop_artifact(id) ON DELETE CASCADE, + label TEXT NOT NULL, + text TEXT NOT NULL, + sort INTEGER NOT NULL DEFAULT 0 + )", + "CREATE TABLE loop_iteration ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + issue_id INTEGER NOT NULL REFERENCES loop_issue(id) ON DELETE CASCADE, + stage TEXT NOT NULL, + target_artifact_id INTEGER, + slot_no INTEGER, + conversation_id INTEGER, + capability_token TEXT NOT NULL, + status TEXT NOT NULL, + launched_by TEXT NOT NULL, + attempt INTEGER NOT NULL DEFAULT 0, + tokens_used BIGINT NOT NULL DEFAULT 0, + context_manifest TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + ended_at TEXT + )", + "CREATE UNIQUE INDEX uniq_loop_iteration_token ON loop_iteration(capability_token)", + "CREATE INDEX idx_loop_iteration_issue ON loop_iteration(issue_id)", + "CREATE INDEX idx_loop_iteration_space ON loop_iteration(space_id)", + "CREATE INDEX idx_loop_iteration_conv ON loop_iteration(conversation_id)", + // Dispatch leases (DB-authoritative double-dispatch guards). Partial unique + // indexes — SeaORM's Index builder can't express the WHERE clause. + "CREATE UNIQUE INDEX uniq_active_write ON loop_iteration(issue_id) \ + WHERE stage IN ('implement','finalize') AND status IN ('queued','running')", + "CREATE UNIQUE INDEX uniq_active_node ON loop_iteration(target_artifact_id, stage) \ + WHERE status IN ('queued','running') AND stage <> 'review'", + "CREATE UNIQUE INDEX uniq_review_slot ON loop_iteration(target_artifact_id, slot_no) \ + WHERE stage = 'review' AND status IN ('queued','running')", + "CREATE TABLE loop_validation_run ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + issue_id INTEGER NOT NULL REFERENCES loop_issue(id) ON DELETE CASCADE, + task_artifact_id INTEGER NOT NULL REFERENCES loop_artifact(id) ON DELETE CASCADE, + iteration_id INTEGER, + commands TEXT NOT NULL, + exit_codes TEXT NOT NULL, + output TEXT NOT NULL, + passed BOOLEAN NOT NULL, + created_at TEXT NOT NULL + )", + "CREATE TABLE loop_inbox_item ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + issue_id INTEGER NOT NULL REFERENCES loop_issue(id) ON DELETE CASCADE, + iteration_id INTEGER, + kind TEXT NOT NULL, + subject_key TEXT NOT NULL, + payload TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + resolution TEXT, + created_at TEXT NOT NULL, + handled_at TEXT + )", + "CREATE UNIQUE INDEX uniq_inbox_pending ON loop_inbox_item(issue_id, kind, subject_key) \ + WHERE status = 'pending'", + "CREATE TABLE loop_memory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + space_id INTEGER NOT NULL REFERENCES loop_space(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + source TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )", +]; + +/// Reverse dependency order (children before parents). +const DOWN: &[&str] = &[ + "DROP TABLE IF EXISTS loop_validation_run", + "DROP TABLE IF EXISTS loop_inbox_item", + "DROP TABLE IF EXISTS loop_memory", + "DROP TABLE IF EXISTS loop_criterion", + "DROP TABLE IF EXISTS loop_link", + "DROP TABLE IF EXISTS loop_artifact_revision", + "DROP TABLE IF EXISTS loop_iteration", + "DROP TABLE IF EXISTS loop_artifact", + "DROP TABLE IF EXISTS loop_issue", + "DROP TABLE IF EXISTS loop_space", +]; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + for stmt in UP { + db.execute_unprepared(stmt).await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + for stmt in DOWN { + db.execute_unprepared(stmt).await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use sea_orm::{ConnectionTrait, Database, DbBackend, Statement}; + use sea_orm_migration::MigratorTrait; + + use crate::db::migration::Migrator; + + fn sql(s: &str) -> Statement { + Statement::from_string(DbBackend::Sqlite, s.to_owned()) + } + + async fn count(conn: &sea_orm::DatabaseConnection, kind: &str, name: &str) -> i32 { + let row = conn + .query_one(sql(&format!( + "SELECT COUNT(*) AS n FROM sqlite_master WHERE type='{kind}' AND name='{name}'" + ))) + .await + .expect("query") + .expect("row"); + row.try_get::("", "n").expect("n") + } + + #[tokio::test] + async fn creates_all_loop_tables_and_partial_indexes() { + let conn = Database::connect("sqlite::memory:").await.expect("db"); + Migrator::up(&conn, None).await.expect("migrations"); + + for table in [ + "loop_space", + "loop_issue", + "loop_artifact", + "loop_artifact_revision", + "loop_link", + "loop_criterion", + "loop_iteration", + "loop_validation_run", + "loop_inbox_item", + "loop_memory", + ] { + assert_eq!(count(&conn, "table", table).await, 1, "table {table} missing"); + } + + for index in [ + "uniq_active_write", + "uniq_active_node", + "uniq_review_slot", + "uniq_inbox_pending", + ] { + assert_eq!(count(&conn, "index", index).await, 1, "index {index} missing"); + } + } +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 0dea0785fa..a452c07b80 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -22,6 +22,7 @@ mod m20260608_000001_conversation_title_locked; mod m20260610_000001_conversation_pinned_at; mod m20260611_000001_folder_is_chat; mod m20260612_000001_conversation_folder_kind; +mod m20260613_000001_loop_tables; pub struct Migrator; #[async_trait::async_trait] @@ -50,6 +51,7 @@ impl MigratorTrait for Migrator { Box::new(m20260610_000001_conversation_pinned_at::Migration), Box::new(m20260611_000001_folder_is_chat::Migration), Box::new(m20260612_000001_conversation_folder_kind::Migration), + Box::new(m20260613_000001_loop_tables::Migration), ] } } From ae82faa65b673016c7436fdc5f58cd94cb1845aa Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 13 Jun 2026 14:15:46 +0800 Subject: [PATCH 002/157] feat(db): add loop engineering entities and LoopWorktree folder kind --- src-tauri/src/db/entities/folder.rs | 8 +- src-tauri/src/db/entities/loop_artifact.rs | 83 ++++++++++++++ .../src/db/entities/loop_artifact_revision.rs | 35 ++++++ src-tauri/src/db/entities/loop_criterion.rs | 19 ++++ src-tauri/src/db/entities/loop_inbox_item.rs | 55 +++++++++ src-tauri/src/db/entities/loop_issue.rs | 104 ++++++++++++++++++ src-tauri/src/db/entities/loop_iteration.rs | 88 +++++++++++++++ src-tauri/src/db/entities/loop_link.rs | 37 +++++++ src-tauri/src/db/entities/loop_memory.rs | 52 +++++++++ src-tauri/src/db/entities/loop_space.rs | 19 ++++ .../src/db/entities/loop_validation_run.rs | 27 +++++ src-tauri/src/db/entities/mod.rs | 10 ++ 12 files changed, 535 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/db/entities/loop_artifact.rs create mode 100644 src-tauri/src/db/entities/loop_artifact_revision.rs create mode 100644 src-tauri/src/db/entities/loop_criterion.rs create mode 100644 src-tauri/src/db/entities/loop_inbox_item.rs create mode 100644 src-tauri/src/db/entities/loop_issue.rs create mode 100644 src-tauri/src/db/entities/loop_iteration.rs create mode 100644 src-tauri/src/db/entities/loop_link.rs create mode 100644 src-tauri/src/db/entities/loop_memory.rs create mode 100644 src-tauri/src/db/entities/loop_space.rs create mode 100644 src-tauri/src/db/entities/loop_validation_run.rs diff --git a/src-tauri/src/db/entities/folder.rs b/src-tauri/src/db/entities/folder.rs index a9498fa8c5..65f45c5893 100644 --- a/src-tauri/src/db/entities/folder.rs +++ b/src-tauri/src/db/entities/folder.rs @@ -4,8 +4,10 @@ use serde::{Deserialize, Serialize}; /// Folder classification. `regular` folders are user-facing; `chat` folders /// are hidden per-conversation scratch dirs backing folderless chat mode /// (excluded from folder lists; their conversations route to the sidebar -/// "Chat" group). A `loop_worktree` variant is reserved for M2+ engine-created -/// worktrees — add it then. Written once at insert, never updated. +/// "Chat" group). `loop_worktree` folders back per-issue engine worktrees: like +/// `chat` they are hidden from the user-facing folder lists, but their path is a +/// real git worktree the loop engine drives. Written once at insert, never +/// updated. #[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] #[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] #[serde(rename_all = "snake_case")] @@ -14,6 +16,8 @@ pub enum FolderKind { Regular, #[sea_orm(string_value = "chat")] Chat, + #[sea_orm(string_value = "loop_worktree")] + LoopWorktree, } #[derive(Clone, Debug, PartialEq, DeriveEntityModel)] diff --git a/src-tauri/src/db/entities/loop_artifact.rs b/src-tauri/src/db/entities/loop_artifact.rs new file mode 100644 index 0000000000..98c9e1a057 --- /dev/null +++ b/src-tauri/src/db/entities/loop_artifact.rs @@ -0,0 +1,83 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +use super::loop_artifact_revision::ActorKind; + +/// DAG node kind = column in the per-issue lineage graph. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum ArtifactKind { + #[sea_orm(string_value = "issue")] + Issue, + #[sea_orm(string_value = "requirement")] + Requirement, + #[sea_orm(string_value = "design")] + Design, + #[sea_orm(string_value = "task")] + Task, + #[sea_orm(string_value = "review")] + Review, + #[sea_orm(string_value = "result")] + Result, +} + +/// Engine-driven node status (humans never hand-edit these except via gates). +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum ArtifactStatus { + #[sea_orm(string_value = "pending")] + Pending, + #[sea_orm(string_value = "in_progress")] + InProgress, + #[sea_orm(string_value = "awaiting_approval")] + AwaitingApproval, + #[sea_orm(string_value = "done")] + Done, + #[sea_orm(string_value = "blocked")] + Blocked, + #[sea_orm(string_value = "superseded")] + Superseded, + #[sea_orm(string_value = "cancelled")] + Cancelled, +} + +/// Verdict carried only by `kind = review` artifacts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum ReviewVerdict { + #[sea_orm(string_value = "pass")] + Pass, + #[sea_orm(string_value = "fail")] + Fail, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_artifact")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub issue_id: i32, + pub kind: ArtifactKind, + pub title: String, + pub status: ArtifactStatus, + pub origin: ActorKind, + /// Iteration that produced this node (plain column, no FK — cycle break). + pub produced_by_iteration_id: Option, + /// Only set for `kind = review`. + pub verdict: Option, + /// Node-level rework counter (no-progress circuit breaker reads this). + pub attempt: i32, + pub last_failure_sig: Option, + pub sort: i32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_artifact_revision.rs b/src-tauri/src/db/entities/loop_artifact_revision.rs new file mode 100644 index 0000000000..e04d93b00b --- /dev/null +++ b/src-tauri/src/db/entities/loop_artifact_revision.rs @@ -0,0 +1,35 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Who authored a write — a human or an agent. Shared across +/// `loop_artifact.origin`, `loop_artifact_revision.actor_kind` and +/// `loop_memory.source`. (Distinct from `loop_iteration::LaunchedBy`.) +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum ActorKind { + #[sea_orm(string_value = "human")] + Human, + #[sea_orm(string_value = "agent")] + Agent, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_artifact_revision")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub artifact_id: i32, + pub seq: i32, + pub content: String, + pub actor_kind: ActorKind, + /// Iteration that produced this revision (plain column, no FK — breaks the + /// artifact↔iteration cycle). + pub iteration_id: Option, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_criterion.rs b/src-tauri/src/db/entities/loop_criterion.rs new file mode 100644 index 0000000000..4bafd5f15a --- /dev/null +++ b/src-tauri/src/db/entities/loop_criterion.rs @@ -0,0 +1,19 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_criterion")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + /// Owning artifact (design or task). Reviews judge these criteria. + pub artifact_id: i32, + /// Auto-assigned label like `AC-1`. + pub label: String, + pub text: String, + pub sort: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_inbox_item.rs b/src-tauri/src/db/entities/loop_inbox_item.rs new file mode 100644 index 0000000000..b70ddf010b --- /dev/null +++ b/src-tauri/src/db/entities/loop_inbox_item.rs @@ -0,0 +1,55 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Inbox category. Blocking ops = `approval` / `blocked` / `budget_exhausted`; +/// the second pane is `question` (an agent's AskUserQuestion). +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum InboxKind { + #[sea_orm(string_value = "approval")] + Approval, + #[sea_orm(string_value = "blocked")] + Blocked, + #[sea_orm(string_value = "budget_exhausted")] + BudgetExhausted, + #[sea_orm(string_value = "question")] + Question, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum InboxStatus { + #[sea_orm(string_value = "pending")] + Pending, + #[sea_orm(string_value = "handled")] + Handled, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_inbox_item")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub issue_id: i32, + /// Set for `question` items (the asking iteration; plain column). + pub iteration_id: Option, + pub kind: InboxKind, + /// Stable dedupe key; a partial unique index forbids two pending items with + /// the same `(issue_id, kind, subject_key)`. + pub subject_key: String, + /// JSON payload (shape depends on `kind`). + pub payload: String, + pub status: InboxStatus, + /// JSON resolution recorded when handled. + pub resolution: Option, + pub created_at: DateTimeUtc, + pub handled_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_issue.rs b/src-tauri/src/db/entities/loop_issue.rs new file mode 100644 index 0000000000..58b74f697e --- /dev/null +++ b/src-tauri/src/db/entities/loop_issue.rs @@ -0,0 +1,104 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Human-set urgency hint; influences (but does not strictly order) which issues +/// the engine surfaces first. +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum IssuePriority { + #[sea_orm(string_value = "high")] + High, + #[sea_orm(string_value = "medium")] + Medium, + #[sea_orm(string_value = "low")] + Low, +} + +/// Issue lifecycle. `pending` = created but not triggered (the explicit human +/// gate); `running` = driver active; `paused` = stopped dispatching (see +/// `pause_reason`); `blocked` = needs a human via the inbox; terminal `done` / +/// `cancelled`. +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum IssueStatus { + #[sea_orm(string_value = "pending")] + Pending, + #[sea_orm(string_value = "running")] + Running, + #[sea_orm(string_value = "paused")] + Paused, + #[sea_orm(string_value = "blocked")] + Blocked, + #[sea_orm(string_value = "done")] + Done, + #[sea_orm(string_value = "cancelled")] + Cancelled, +} + +/// Distinguishes a manual pause from a budget circuit-breaker pause. Only +/// meaningful while `status = paused`. +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum PauseReason { + #[sea_orm(string_value = "manual")] + Manual, + #[sea_orm(string_value = "budget")] + Budget, +} + +/// Pipeline route decided by triage (or forced via config). `full` runs +/// refine→design→plan; `skip_design` skips design; `direct` skips both refine +/// and design (issue→plan). `undecided` until triage runs. +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum IssueRoute { + #[sea_orm(string_value = "undecided")] + Undecided, + #[sea_orm(string_value = "full")] + Full, + #[sea_orm(string_value = "skip_design")] + SkipDesign, + #[sea_orm(string_value = "direct")] + Direct, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_issue")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub seq_no: i32, + pub title: String, + pub description: String, + pub priority: IssuePriority, + pub status: IssueStatus, + pub pause_reason: Option, + pub route: IssueRoute, + /// JSON-encoded `models::loops::IssueConfig`. + pub config: String, + /// Engine-created worktree folder (`folder.id`, plain column). + pub worktree_folder_id: Option, + /// Merge baseline recorded at trigger time. + pub base_branch: Option, + pub base_commit: Option, + /// Per-issue serial-task pipeline gate: the task whose implement/review + /// cycle currently holds the issue worktree. NULL = a new task may start. + pub active_task_artifact_id: Option, + pub token_used: i64, + /// NULL = unlimited (no artificial budget cap by default). + pub token_budget: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub triggered_at: Option, + pub ended_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_iteration.rs b/src-tauri/src/db/entities/loop_iteration.rs new file mode 100644 index 0000000000..797e80e971 --- /dev/null +++ b/src-tauri/src/db/entities/loop_iteration.rs @@ -0,0 +1,88 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// What an iteration's agent run does. (Note: `verify` is NOT a stage — it is a +/// deterministic engine step run between implement and review.) +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum Stage { + #[sea_orm(string_value = "triage")] + Triage, + #[sea_orm(string_value = "refine")] + Refine, + #[sea_orm(string_value = "design")] + Design, + #[sea_orm(string_value = "plan")] + Plan, + #[sea_orm(string_value = "implement")] + Implement, + #[sea_orm(string_value = "review")] + Review, + #[sea_orm(string_value = "finalize")] + Finalize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum IterationStatus { + #[sea_orm(string_value = "queued")] + Queued, + #[sea_orm(string_value = "running")] + Running, + #[sea_orm(string_value = "succeeded")] + Succeeded, + #[sea_orm(string_value = "failed")] + Failed, + #[sea_orm(string_value = "interrupted")] + Interrupted, + #[sea_orm(string_value = "cancelled")] + Cancelled, +} + +/// Who launched the iteration. Engine-driven by default; `human` covers extra +/// turns a person injects while observing. (Distinct from `ActorKind`.) +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum LaunchedBy { + #[sea_orm(string_value = "engine")] + Engine, + #[sea_orm(string_value = "human")] + Human, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_iteration")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub issue_id: i32, + pub stage: Stage, + /// Node being advanced/reviewed (plain column, no FK — cycle break). + pub target_artifact_id: Option, + /// Review slot `[0, reviewer_count)`; NULL for non-review stages. + pub slot_no: Option, + /// Backing loop conversation (`conversation.id`, plain column). NULL between + /// lease acquisition and conversation creation. + pub conversation_id: Option, + /// Unique secret injected into codeg-mcp; the host reverse-looks-up this + /// iteration's context from it (never trusts agent-supplied ids). + pub capability_token: String, + pub status: IterationStatus, + pub launched_by: LaunchedBy, + pub attempt: i32, + pub tokens_used: i64, + /// JSON-encoded briefing manifest (audit). + pub context_manifest: Option, + pub created_at: DateTimeUtc, + pub started_at: Option, + pub ended_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_link.rs b/src-tauri/src/db/entities/loop_link.rs new file mode 100644 index 0000000000..22ed6c735c --- /dev/null +++ b/src-tauri/src/db/entities/loop_link.rs @@ -0,0 +1,37 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// DAG edge kind. Canonical direction: `from` = the dependent node +/// (derived/review/result), `to` = the referenced node (its source/parent/ +/// subject). So `derives_from`: child→parent; `skips_to`: reached-node→ +/// skipped-over ancestor; `reviews`: review→task; `results_from`: result→task. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum LinkKind { + #[sea_orm(string_value = "derives_from")] + DerivesFrom, + #[sea_orm(string_value = "skips_to")] + SkipsTo, + #[sea_orm(string_value = "reviews")] + Reviews, + #[sea_orm(string_value = "results_from")] + ResultsFrom, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_link")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub from_artifact_id: i32, + pub to_artifact_id: i32, + pub kind: LinkKind, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_memory.rs b/src-tauri/src/db/entities/loop_memory.rs new file mode 100644 index 0000000000..998e0a1392 --- /dev/null +++ b/src-tauri/src/db/entities/loop_memory.rs @@ -0,0 +1,52 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +use super::loop_artifact_revision::ActorKind; + +/// Memory category. `constitution` carries the space-level charter; the rest are +/// learnings injected per stage (see the briefing matrix). +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum MemoryKind { + #[sea_orm(string_value = "constitution")] + Constitution, + #[sea_orm(string_value = "constraint")] + Constraint, + #[sea_orm(string_value = "decision")] + Decision, + #[sea_orm(string_value = "preference")] + Preference, + #[sea_orm(string_value = "pitfall")] + Pitfall, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[serde(rename_all = "snake_case")] +pub enum MemoryStatus { + #[sea_orm(string_value = "active")] + Active, + #[sea_orm(string_value = "archived")] + Archived, +} + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_memory")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub kind: MemoryKind, + pub source: ActorKind, + pub title: String, + pub content: String, + pub status: MemoryStatus, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_space.rs b/src-tauri/src/db/entities/loop_space.rs new file mode 100644 index 0000000000..eb57327d4d --- /dev/null +++ b/src-tauri/src/db/entities/loop_space.rs @@ -0,0 +1,19 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_space")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + /// Bound root folder (must be a git repo). Plain column — cross-subsystem + /// reference to `folder.id`, no FK. + pub folder_id: i32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/loop_validation_run.rs b/src-tauri/src/db/entities/loop_validation_run.rs new file mode 100644 index 0000000000..413c99cebc --- /dev/null +++ b/src-tauri/src/db/entities/loop_validation_run.rs @@ -0,0 +1,27 @@ +use sea_orm::entity::prelude::*; + +/// One deterministic validation pass (the issue's `validation_commands` run in +/// the worktree). Engine-run, no agent/conversation. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "loop_validation_run")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub space_id: i32, + pub issue_id: i32, + pub task_artifact_id: i32, + /// The implement iteration that triggered this run (plain column). + pub iteration_id: Option, + /// JSON array of commands. + pub commands: String, + /// JSON array of exit codes. + pub exit_codes: String, + pub output: String, + pub passed: bool, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index 19aba98930..7839062a0b 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -6,6 +6,16 @@ pub mod chat_channel_sender_context; pub mod conversation; pub mod folder; pub mod folder_command; +pub mod loop_artifact; +pub mod loop_artifact_revision; +pub mod loop_criterion; +pub mod loop_inbox_item; +pub mod loop_iteration; +pub mod loop_issue; +pub mod loop_link; +pub mod loop_memory; +pub mod loop_space; +pub mod loop_validation_run; pub mod model_provider; pub mod opened_tab; pub mod prelude; From 11c5f5447bda11bb332d8dbc64ac9f419524770f Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 13 Jun 2026 14:18:07 +0800 Subject: [PATCH 003/157] feat(loop): add loop engineering DTOs --- src-tauri/src/models/loops.rs | 231 ++++++++++++++++++++++++++++++++++ src-tauri/src/models/mod.rs | 1 + 2 files changed, 232 insertions(+) create mode 100644 src-tauri/src/models/loops.rs diff --git a/src-tauri/src/models/loops.rs b/src-tauri/src/models/loops.rs new file mode 100644 index 0000000000..caa1e37438 --- /dev/null +++ b/src-tauri/src/models/loops.rs @@ -0,0 +1,231 @@ +//! DTOs for the loop engineering subsystem. Field names are snake_case so the +//! serialized JSON matches the TypeScript mirrors in `src/lib/types.ts`. Entity +//! enums are reused directly (single source of truth for the wire vocabulary). + +use std::collections::BTreeMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::agent::AgentType; +use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus, ReviewVerdict}; +use crate::db::entities::loop_artifact_revision::ActorKind; +use crate::db::entities::loop_inbox_item::{InboxKind, InboxStatus}; +use crate::db::entities::loop_issue::{IssuePriority, IssueRoute, IssueStatus, PauseReason}; +use crate::db::entities::loop_iteration::{IterationStatus, LaunchedBy, Stage}; +use crate::db::entities::loop_link::LinkKind; +use crate::db::entities::loop_memory::{MemoryKind, MemoryStatus}; + +fn config_version() -> u32 { + 1 +} + +/// Per-issue Loop Contract knobs (stored JSON-encoded in `loop_issue.config`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IssueConfig { + #[serde(default = "config_version")] + pub v: u32, + /// Agent per stage; the `"default"` key is the fallback. Stage-specific keys + /// (e.g. `"review"`) override it. + pub agents: BTreeMap, + /// Deterministic verification commands, run in the worktree after implement. + pub validation_commands: Vec, + /// Concurrent reviewer agents per task. + pub reviewer_count: u32, + /// `"unanimous"` (any fail → rework) or `"majority"`. + pub review_pass_rule: String, + /// Node rework cap before the no-progress breaker trips. + pub max_attempts: u32, + /// When false (default), result merge requires human approval. + pub auto_merge: bool, + /// Human override of the triage-decided route, if any. + pub force_route: Option, + /// Optional per-iteration wall-clock cap (none = unlimited). + pub iteration_timeout_secs: Option, + /// Optional per-turn token soft cap (none = unlimited). + pub token_budget_per_turn: Option, +} + +impl Default for IssueConfig { + fn default() -> Self { + let mut agents = BTreeMap::new(); + agents.insert("default".to_string(), AgentType::ClaudeCode); + Self { + v: 1, + agents, + validation_commands: Vec::new(), + reviewer_count: 1, + review_pass_rule: "unanimous".to_string(), + max_attempts: 6, + auto_merge: false, + force_route: None, + iteration_timeout_secs: None, + token_budget_per_turn: None, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopSpaceSummary { + pub id: i32, + pub name: String, + pub folder_id: i32, + pub folder_path: Option, + /// True when the bound folder is soft-deleted or missing (read-only space). + pub detached: bool, + pub issue_count: i64, + pub running_count: i64, + pub last_activity_at: Option>, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopIssueRow { + pub id: i32, + pub space_id: i32, + pub seq_no: i32, + pub title: String, + pub priority: IssuePriority, + pub status: IssueStatus, + pub pause_reason: Option, + pub route: IssueRoute, + pub token_used: i64, + pub token_budget: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopIssueDetail { + #[serde(flatten)] + pub row: LoopIssueRow, + pub description: String, + pub config: IssueConfig, + pub worktree_folder_id: Option, + pub base_branch: Option, + pub base_commit: Option, + pub active_task_artifact_id: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopArtifactRow { + pub id: i32, + pub issue_id: i32, + pub issue_seq: i32, + pub kind: ArtifactKind, + pub title: String, + pub status: ArtifactStatus, + pub origin: ActorKind, + pub produced_by_iteration_id: Option, + pub verdict: Option, + pub attempt: i32, + pub sort: i32, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopRevision { + pub id: i32, + pub seq: i32, + pub content: String, + pub actor_kind: ActorKind, + pub iteration_id: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopCriterionRow { + pub id: i32, + pub label: String, + pub text: String, + pub sort: i32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopLinkRow { + pub id: i32, + pub from_artifact_id: i32, + pub to_artifact_id: i32, + pub kind: LinkKind, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopArtifactDetail { + #[serde(flatten)] + pub row: LoopArtifactRow, + pub revisions: Vec, + pub criteria: Vec, + pub links: Vec, +} + +/// Per-issue DAG payload (nodes + edges) for the graph/board views. +#[derive(Debug, Clone, Serialize)] +pub struct LoopDagView { + pub artifacts: Vec, + pub links: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopIterationRow { + pub id: i32, + pub issue_id: i32, + pub issue_seq: i32, + pub stage: Stage, + pub target_artifact_id: Option, + pub target_title: Option, + pub conversation_id: Option, + pub status: IterationStatus, + pub launched_by: LaunchedBy, + pub attempt: i32, + pub tokens_used: i64, + pub created_at: DateTime, + pub started_at: Option>, + pub ended_at: Option>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopValidationRunRow { + pub id: i32, + pub task_artifact_id: i32, + pub iteration_id: Option, + pub commands: Vec, + pub exit_codes: Vec, + pub passed: bool, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopInboxItemRow { + pub id: i32, + pub issue_id: i32, + pub issue_seq: i32, + pub iteration_id: Option, + pub kind: InboxKind, + pub subject_key: String, + pub payload: serde_json::Value, + pub status: InboxStatus, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoopMemoryRow { + pub id: i32, + pub kind: MemoryKind, + pub source: ActorKind, + pub title: String, + pub content: String, + pub status: MemoryStatus, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Coarse cache-invalidation event (`loop://changed`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoopChanged { + pub v: u32, + pub space_id: i32, + pub issue_id: Option, + pub subject_kind: String, + pub subject_id: i32, + pub kind: String, +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index e81c669b78..6107bd8bc4 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -2,6 +2,7 @@ pub mod agent; pub mod chat_channel; pub mod conversation; pub mod folder; +pub mod loops; pub mod message; pub mod model_provider; pub mod pet; From 2e30878746d4b2f21ef127246e0939810b6acb8a Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 13 Jun 2026 14:20:49 +0800 Subject: [PATCH 004/157] feat(loop): add LoopError type --- src-tauri/src/lib.rs | 1 + src-tauri/src/loop_engine/error.rs | 61 ++++++++++++++++++++++++++++++ src-tauri/src/loop_engine/mod.rs | 9 +++++ 3 files changed, 71 insertions(+) create mode 100644 src-tauri/src/loop_engine/error.rs create mode 100644 src-tauri/src/loop_engine/mod.rs diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 60877cb437..efb3bd39c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ pub mod db; pub mod git_credential; pub mod git_repo; pub mod keyring_store; +pub mod loop_engine; pub mod models; mod network; pub mod parsers; diff --git a/src-tauri/src/loop_engine/error.rs b/src-tauri/src/loop_engine/error.rs new file mode 100644 index 0000000000..1722367580 --- /dev/null +++ b/src-tauri/src/loop_engine/error.rs @@ -0,0 +1,61 @@ +use std::collections::BTreeMap; + +use crate::app_error::{AppCommandError, AppErrorCode}; + +/// Errors raised by the loop engine and its services. `Conflict` is the +/// compare-and-swap miss (concurrent state change) that the frontend retries. +#[derive(Debug, thiserror::Error)] +pub enum LoopError { + #[error("not found: {0}")] + NotFound(String), + #[error("illegal loop state transition")] + IllegalTransition, + #[error("conflicting concurrent update")] + Conflict, + #[error("loop space is detached from its folder")] + Detached, + #[error("folder is not a git repository")] + NotGitRepo, + #[error("merge conflict")] + MergeConflict, + #[error("invalid input: {0}")] + InvalidInput(String), + #[error("acp error: {0}")] + Acp(String), + #[error(transparent)] + Db(#[from] sea_orm::DbErr), +} + +impl From for AppCommandError { + fn from(e: LoopError) -> Self { + match e { + LoopError::NotFound(m) => AppCommandError::not_found(m), + LoopError::IllegalTransition => { + AppCommandError::new(AppErrorCode::InvalidInput, "Illegal loop state transition") + } + // Surfaced as a retryable conflict (HTTP 409 via TurnInProgress); the + // frontend renders the localized `Loops.conflictRetry` toast. + LoopError::Conflict => AppCommandError::new( + AppErrorCode::TurnInProgress, + "Loop state changed concurrently; retry", + ) + .with_i18n("Loops.conflictRetry", BTreeMap::new()), + LoopError::Detached => AppCommandError::new( + AppErrorCode::InvalidInput, + "Loop space is detached from its folder", + ), + LoopError::NotGitRepo => { + AppCommandError::not_a_git_repository("Loop space folder is not a git repository") + } + LoopError::MergeConflict => AppCommandError::new( + AppErrorCode::ExternalCommandFailed, + "Merge conflict while integrating the issue branch", + ), + LoopError::InvalidInput(m) => AppCommandError::invalid_input(m), + LoopError::Acp(m) => AppCommandError::task_execution_failed(m), + LoopError::Db(err) => { + AppCommandError::database_error("Database operation failed").with_detail(err.to_string()) + } + } + } +} diff --git a/src-tauri/src/loop_engine/mod.rs b/src-tauri/src/loop_engine/mod.rs new file mode 100644 index 0000000000..bae9802c2f --- /dev/null +++ b/src-tauri/src/loop_engine/mod.rs @@ -0,0 +1,9 @@ +//! Loop engineering engine: drives each running issue through triage → refine → +//! design → plan → implement → verify → review → finalize, autonomously. +//! +//! M2.0 lands only the error type; the driver, dispatch, gates, worktree +//! lifecycle, briefing, recovery and MCP ingest modules arrive in later phases. + +pub mod error; + +pub use error::LoopError; From 8db0813f3e975ec4e21932122f08338331b7a8a7 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 13 Jun 2026 14:29:23 +0800 Subject: [PATCH 005/157] feat(loop): add loop_service DB layer Space/issue/artifact/revision/criterion/link/iteration/validation/inbox/ memory CRUD and read models. create_issue seeds the kind=issue root artifact in one transaction; links and pending inbox items dedupe. --- .../src/db/service/loop_service/artifact.rs | 257 ++++++++++++++++++ .../src/db/service/loop_service/inbox.rs | 112 ++++++++ .../src/db/service/loop_service/issue.rs | 196 +++++++++++++ .../src/db/service/loop_service/iteration.rs | 111 ++++++++ src-tauri/src/db/service/loop_service/link.rs | 45 +++ .../src/db/service/loop_service/memory.rs | 132 +++++++++ src-tauri/src/db/service/loop_service/mod.rs | 222 +++++++++++++++ .../src/db/service/loop_service/space.rs | 122 +++++++++ .../src/db/service/loop_service/validation.rs | 60 ++++ src-tauri/src/db/service/mod.rs | 1 + 10 files changed, 1258 insertions(+) create mode 100644 src-tauri/src/db/service/loop_service/artifact.rs create mode 100644 src-tauri/src/db/service/loop_service/inbox.rs create mode 100644 src-tauri/src/db/service/loop_service/issue.rs create mode 100644 src-tauri/src/db/service/loop_service/iteration.rs create mode 100644 src-tauri/src/db/service/loop_service/link.rs create mode 100644 src-tauri/src/db/service/loop_service/memory.rs create mode 100644 src-tauri/src/db/service/loop_service/mod.rs create mode 100644 src-tauri/src/db/service/loop_service/space.rs create mode 100644 src-tauri/src/db/service/loop_service/validation.rs diff --git a/src-tauri/src/db/service/loop_service/artifact.rs b/src-tauri/src/db/service/loop_service/artifact.rs new file mode 100644 index 0000000000..552cb2a0f9 --- /dev/null +++ b/src-tauri/src/db/service/loop_service/artifact.rs @@ -0,0 +1,257 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, +}; + +use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; +use crate::db::entities::loop_artifact_revision::ActorKind; +use crate::db::entities::{ + loop_artifact, loop_artifact_revision, loop_criterion, loop_issue, loop_link, +}; +use crate::db::error::DbError; +use crate::models::loops::{ + LoopArtifactDetail, LoopArtifactRow, LoopCriterionRow, LoopDagView, LoopLinkRow, LoopRevision, +}; + +use super::link::to_link_row; + +pub fn to_artifact_row(m: &loop_artifact::Model, issue_seq: i32) -> LoopArtifactRow { + LoopArtifactRow { + id: m.id, + issue_id: m.issue_id, + issue_seq, + kind: m.kind, + title: m.title.clone(), + status: m.status, + origin: m.origin, + produced_by_iteration_id: m.produced_by_iteration_id, + verdict: m.verdict, + attempt: m.attempt, + sort: m.sort, + updated_at: m.updated_at, + } +} + +fn to_revision(m: loop_artifact_revision::Model) -> LoopRevision { + LoopRevision { + id: m.id, + seq: m.seq, + content: m.content, + actor_kind: m.actor_kind, + iteration_id: m.iteration_id, + created_at: m.created_at, + } +} + +fn to_criterion_row(m: loop_criterion::Model) -> LoopCriterionRow { + LoopCriterionRow { + id: m.id, + label: m.label, + text: m.text, + sort: m.sort, + } +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_artifact( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + issue_id: i32, + kind: ArtifactKind, + title: &str, + status: ArtifactStatus, + origin: ActorKind, + produced_by_iteration_id: Option, +) -> Result { + let now = Utc::now(); + let sort = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue_id)) + .filter(loop_artifact::Column::Kind.eq(kind)) + .order_by_desc(loop_artifact::Column::Sort) + .one(conn) + .await? + .map(|m| m.sort + 1) + .unwrap_or(0); + Ok(loop_artifact::ActiveModel { + space_id: Set(space_id), + issue_id: Set(issue_id), + kind: Set(kind), + title: Set(title.to_string()), + status: Set(status), + origin: Set(origin), + produced_by_iteration_id: Set(produced_by_iteration_id), + verdict: Set(None), + attempt: Set(0), + last_failure_sig: Set(None), + sort: Set(sort), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + } + .insert(conn) + .await?) +} + +pub async fn add_revision( + conn: &sea_orm::DatabaseConnection, + artifact_id: i32, + content: &str, + actor_kind: ActorKind, + iteration_id: Option, +) -> Result { + let seq = loop_artifact_revision::Entity::find() + .filter(loop_artifact_revision::Column::ArtifactId.eq(artifact_id)) + .order_by_desc(loop_artifact_revision::Column::Seq) + .one(conn) + .await? + .map(|m| m.seq + 1) + .unwrap_or(1); + Ok(loop_artifact_revision::ActiveModel { + artifact_id: Set(artifact_id), + seq: Set(seq), + content: Set(content.to_string()), + actor_kind: Set(actor_kind), + iteration_id: Set(iteration_id), + created_at: Set(Utc::now()), + ..Default::default() + } + .insert(conn) + .await?) +} + +/// Auto-labels `AC-{n}` and appends at the end. +pub async fn add_criterion( + conn: &sea_orm::DatabaseConnection, + artifact_id: i32, + text: &str, +) -> Result { + let next = loop_criterion::Entity::find() + .filter(loop_criterion::Column::ArtifactId.eq(artifact_id)) + .order_by_desc(loop_criterion::Column::Sort) + .one(conn) + .await? + .map(|m| m.sort + 1) + .unwrap_or(0); + Ok(loop_criterion::ActiveModel { + artifact_id: Set(artifact_id), + label: Set(format!("AC-{}", next + 1)), + text: Set(text.to_string()), + sort: Set(next), + ..Default::default() + } + .insert(conn) + .await?) +} + +pub async fn get_artifact_detail( + conn: &sea_orm::DatabaseConnection, + id: i32, +) -> Result, DbError> { + let Some(artifact) = loop_artifact::Entity::find_by_id(id).one(conn).await? else { + return Ok(None); + }; + let issue_seq = loop_issue::Entity::find_by_id(artifact.issue_id) + .one(conn) + .await? + .map(|i| i.seq_no) + .unwrap_or(0); + + let revisions = loop_artifact_revision::Entity::find() + .filter(loop_artifact_revision::Column::ArtifactId.eq(id)) + .order_by_asc(loop_artifact_revision::Column::Seq) + .all(conn) + .await? + .into_iter() + .map(to_revision) + .collect(); + + let criteria = loop_criterion::Entity::find() + .filter(loop_criterion::Column::ArtifactId.eq(id)) + .order_by_asc(loop_criterion::Column::Sort) + .all(conn) + .await? + .into_iter() + .map(to_criterion_row) + .collect(); + + // Edges touching this node in either direction. + let links: Vec = loop_link::Entity::find() + .filter( + loop_link::Column::FromArtifactId + .eq(id) + .or(loop_link::Column::ToArtifactId.eq(id)), + ) + .all(conn) + .await? + .into_iter() + .map(to_link_row) + .collect(); + + Ok(Some(LoopArtifactDetail { + row: to_artifact_row(&artifact, issue_seq), + revisions, + criteria, + links, + })) +} + +pub async fn list_dag( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, +) -> Result { + let issue_seq = loop_issue::Entity::find_by_id(issue_id) + .one(conn) + .await? + .map(|i| i.seq_no) + .unwrap_or(0); + + let artifact_models = loop_artifact::Entity::find() + .filter(loop_artifact::Column::IssueId.eq(issue_id)) + .order_by_asc(loop_artifact::Column::Id) + .all(conn) + .await?; + let artifact_ids: Vec = artifact_models.iter().map(|m| m.id).collect(); + let artifacts = artifact_models + .iter() + .map(|m| to_artifact_row(m, issue_seq)) + .collect(); + + // Every edge of this issue's DAG has its `from` node inside the issue. + let links = if artifact_ids.is_empty() { + Vec::new() + } else { + loop_link::Entity::find() + .filter(loop_link::Column::FromArtifactId.is_in(artifact_ids)) + .all(conn) + .await? + .into_iter() + .map(to_link_row) + .collect() + }; + + Ok(LoopDagView { artifacts, links }) +} + +pub async fn list_artifacts_for_space( + conn: &sea_orm::DatabaseConnection, + space_id: i32, +) -> Result, DbError> { + let seqs: HashMap = loop_issue::Entity::find() + .filter(loop_issue::Column::SpaceId.eq(space_id)) + .all(conn) + .await? + .into_iter() + .map(|i| (i.id, i.seq_no)) + .collect(); + + Ok(loop_artifact::Entity::find() + .filter(loop_artifact::Column::SpaceId.eq(space_id)) + .order_by_desc(loop_artifact::Column::Id) + .all(conn) + .await? + .iter() + .map(|m| to_artifact_row(m, *seqs.get(&m.issue_id).unwrap_or(&0))) + .collect()) +} diff --git a/src-tauri/src/db/service/loop_service/inbox.rs b/src-tauri/src/db/service/loop_service/inbox.rs new file mode 100644 index 0000000000..5be9f50377 --- /dev/null +++ b/src-tauri/src/db/service/loop_service/inbox.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set, +}; + +use crate::db::entities::loop_inbox_item::{self, InboxKind, InboxStatus}; +use crate::db::entities::loop_issue; +use crate::db::error::DbError; +use crate::models::loops::LoopInboxItemRow; + +fn to_row(m: loop_inbox_item::Model, issue_seq: i32) -> LoopInboxItemRow { + LoopInboxItemRow { + id: m.id, + issue_id: m.issue_id, + issue_seq, + iteration_id: m.iteration_id, + kind: m.kind, + subject_key: m.subject_key, + payload: serde_json::from_str(&m.payload).unwrap_or(serde_json::Value::Null), + status: m.status, + created_at: m.created_at, + } +} + +/// Insert a pending inbox item, or return the existing pending one with the same +/// `(issue_id, kind, subject_key)` — recovery and repeated ticks must not stack +/// duplicate cards (also guarded by `uniq_inbox_pending`). +pub async fn upsert_inbox( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + issue_id: i32, + iteration_id: Option, + kind: InboxKind, + subject_key: &str, + payload: serde_json::Value, +) -> Result { + if let Some(existing) = loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::IssueId.eq(issue_id)) + .filter(loop_inbox_item::Column::Kind.eq(kind)) + .filter(loop_inbox_item::Column::SubjectKey.eq(subject_key)) + .filter(loop_inbox_item::Column::Status.eq(InboxStatus::Pending)) + .one(conn) + .await? + { + return Ok(existing); + } + Ok(loop_inbox_item::ActiveModel { + space_id: Set(space_id), + issue_id: Set(issue_id), + iteration_id: Set(iteration_id), + kind: Set(kind), + subject_key: Set(subject_key.to_string()), + payload: Set(payload.to_string()), + status: Set(InboxStatus::Pending), + resolution: Set(None), + created_at: Set(Utc::now()), + handled_at: Set(None), + ..Default::default() + } + .insert(conn) + .await?) +} + +pub async fn list_inbox( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + status: Option, +) -> Result, DbError> { + let seqs: HashMap = loop_issue::Entity::find() + .filter(loop_issue::Column::SpaceId.eq(space_id)) + .all(conn) + .await? + .into_iter() + .map(|i| (i.id, i.seq_no)) + .collect(); + let mut query = loop_inbox_item::Entity::find() + .filter(loop_inbox_item::Column::SpaceId.eq(space_id)) + .order_by_desc(loop_inbox_item::Column::Id); + if let Some(status) = status { + query = query.filter(loop_inbox_item::Column::Status.eq(status)); + } + Ok(query + .all(conn) + .await? + .into_iter() + .map(|m| { + let seq = *seqs.get(&m.issue_id).unwrap_or(&0); + to_row(m, seq) + }) + .collect()) +} + +pub async fn handle_inbox( + conn: &sea_orm::DatabaseConnection, + id: i32, + resolution: serde_json::Value, +) -> Result<(), DbError> { + let row = loop_inbox_item::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| { + DbError::Database(sea_orm::DbErr::RecordNotFound(format!("loop_inbox_item {id}"))) + })?; + let mut active = row.into_active_model(); + active.status = Set(InboxStatus::Handled); + active.resolution = Set(Some(resolution.to_string())); + active.handled_at = Set(Some(Utc::now())); + active.update(conn).await?; + Ok(()) +} diff --git a/src-tauri/src/db/service/loop_service/issue.rs b/src-tauri/src/db/service/loop_service/issue.rs new file mode 100644 index 0000000000..55be14a5ed --- /dev/null +++ b/src-tauri/src/db/service/loop_service/issue.rs @@ -0,0 +1,196 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set, + TransactionTrait, +}; + +use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; +use crate::db::entities::loop_artifact_revision::ActorKind; +use crate::db::entities::loop_issue::{IssuePriority, IssueRoute, IssueStatus}; +use crate::db::entities::{loop_artifact, loop_artifact_revision, loop_issue}; +use crate::db::error::DbError; +use crate::models::loops::{IssueConfig, LoopIssueDetail, LoopIssueRow}; + +fn not_found(id: i32) -> DbError { + DbError::Database(sea_orm::DbErr::RecordNotFound(format!("loop_issue {id}"))) +} + +fn parse_config(raw: &str) -> IssueConfig { + serde_json::from_str(raw).unwrap_or_default() +} + +pub fn to_issue_row(m: &loop_issue::Model) -> LoopIssueRow { + LoopIssueRow { + id: m.id, + space_id: m.space_id, + seq_no: m.seq_no, + title: m.title.clone(), + priority: m.priority.clone(), + status: m.status.clone(), + pause_reason: m.pause_reason.clone(), + route: m.route.clone(), + token_used: m.token_used, + token_budget: m.token_budget, + created_at: m.created_at, + updated_at: m.updated_at, + } +} + +pub fn to_issue_detail(m: loop_issue::Model) -> LoopIssueDetail { + let config = parse_config(&m.config); + let row = to_issue_row(&m); + LoopIssueDetail { + row, + description: m.description, + config, + worktree_folder_id: m.worktree_folder_id, + base_branch: m.base_branch, + base_commit: m.base_commit, + active_task_artifact_id: m.active_task_artifact_id, + } +} + +/// Create an issue and its root `kind = issue` artifact (with a first revision +/// holding the description) in one transaction. The issue starts `pending` +/// (awaiting an explicit human trigger) with route `undecided`. +pub async fn create_issue( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + title: &str, + description: &str, + priority: IssuePriority, + config: &IssueConfig, +) -> Result { + let now = Utc::now(); + let config_json = serde_json::to_string(config).unwrap_or_else(|_| "{}".to_string()); + + let txn = conn.begin().await?; + + let seq_no = loop_issue::Entity::find() + .filter(loop_issue::Column::SpaceId.eq(space_id)) + .order_by_desc(loop_issue::Column::SeqNo) + .one(&txn) + .await? + .map(|m| m.seq_no + 1) + .unwrap_or(1); + + let issue = loop_issue::ActiveModel { + space_id: Set(space_id), + seq_no: Set(seq_no), + title: Set(title.to_string()), + description: Set(description.to_string()), + priority: Set(priority), + status: Set(IssueStatus::Pending), + pause_reason: Set(None), + route: Set(IssueRoute::Undecided), + config: Set(config_json), + worktree_folder_id: Set(None), + base_branch: Set(None), + base_commit: Set(None), + active_task_artifact_id: Set(None), + token_used: Set(0), + token_budget: Set(None), + created_at: Set(now), + updated_at: Set(now), + triggered_at: Set(None), + ended_at: Set(None), + ..Default::default() + } + .insert(&txn) + .await?; + + let root = loop_artifact::ActiveModel { + space_id: Set(space_id), + issue_id: Set(issue.id), + kind: Set(ArtifactKind::Issue), + title: Set(title.to_string()), + status: Set(ArtifactStatus::Done), + origin: Set(ActorKind::Human), + produced_by_iteration_id: Set(None), + verdict: Set(None), + attempt: Set(0), + last_failure_sig: Set(None), + sort: Set(0), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + } + .insert(&txn) + .await?; + + loop_artifact_revision::ActiveModel { + artifact_id: Set(root.id), + seq: Set(1), + content: Set(description.to_string()), + actor_kind: Set(ActorKind::Human), + iteration_id: Set(None), + created_at: Set(now), + ..Default::default() + } + .insert(&txn) + .await?; + + txn.commit().await?; + Ok(to_issue_detail(issue)) +} + +pub async fn get_issue( + conn: &sea_orm::DatabaseConnection, + id: i32, +) -> Result, DbError> { + Ok(loop_issue::Entity::find_by_id(id).one(conn).await?) +} + +pub async fn get_issue_detail( + conn: &sea_orm::DatabaseConnection, + id: i32, +) -> Result, DbError> { + Ok(loop_issue::Entity::find_by_id(id) + .one(conn) + .await? + .map(to_issue_detail)) +} + +pub async fn list_issues( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + statuses: Option>, +) -> Result, DbError> { + let mut query = loop_issue::Entity::find() + .filter(loop_issue::Column::SpaceId.eq(space_id)) + .order_by_desc(loop_issue::Column::SeqNo); + if let Some(statuses) = statuses { + if !statuses.is_empty() { + query = query.filter(loop_issue::Column::Status.is_in(statuses)); + } + } + Ok(query + .all(conn) + .await? + .iter() + .map(to_issue_row) + .collect()) +} + +pub async fn delete_issue(conn: &sea_orm::DatabaseConnection, id: i32) -> Result<(), DbError> { + loop_issue::Entity::delete_by_id(id).exec(conn).await?; + Ok(()) +} + +pub async fn update_issue_config( + conn: &sea_orm::DatabaseConnection, + id: i32, + config: &IssueConfig, + token_budget: Option, +) -> Result<(), DbError> { + let row = loop_issue::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| not_found(id))?; + let mut active = row.into_active_model(); + active.config = Set(serde_json::to_string(config).unwrap_or_else(|_| "{}".to_string())); + active.token_budget = Set(token_budget); + active.updated_at = Set(Utc::now()); + active.update(conn).await?; + Ok(()) +} diff --git a/src-tauri/src/db/service/loop_service/iteration.rs b/src-tauri/src/db/service/loop_service/iteration.rs new file mode 100644 index 0000000000..3c387970cd --- /dev/null +++ b/src-tauri/src/db/service/loop_service/iteration.rs @@ -0,0 +1,111 @@ +use std::collections::HashMap; + +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; + +use crate::db::entities::{loop_artifact, loop_issue, loop_iteration}; +use crate::db::error::DbError; +use crate::models::loops::LoopIterationRow; + +fn to_iteration_row( + m: &loop_iteration::Model, + issue_seq: i32, + target_title: Option, +) -> LoopIterationRow { + LoopIterationRow { + id: m.id, + issue_id: m.issue_id, + issue_seq, + stage: m.stage, + target_artifact_id: m.target_artifact_id, + target_title, + conversation_id: m.conversation_id, + status: m.status, + launched_by: m.launched_by, + attempt: m.attempt, + tokens_used: m.tokens_used, + created_at: m.created_at, + started_at: m.started_at, + ended_at: m.ended_at, + } +} + +pub async fn get_iteration( + conn: &sea_orm::DatabaseConnection, + id: i32, +) -> Result, DbError> { + Ok(loop_iteration::Entity::find_by_id(id).one(conn).await?) +} + +async fn target_titles( + conn: &sea_orm::DatabaseConnection, + iterations: &[loop_iteration::Model], +) -> Result, DbError> { + let ids: Vec = iterations + .iter() + .filter_map(|i| i.target_artifact_id) + .collect(); + if ids.is_empty() { + return Ok(HashMap::new()); + } + Ok(loop_artifact::Entity::find() + .filter(loop_artifact::Column::Id.is_in(ids)) + .all(conn) + .await? + .into_iter() + .map(|a| (a.id, a.title)) + .collect()) +} + +pub async fn list_iterations( + conn: &sea_orm::DatabaseConnection, + issue_id: i32, +) -> Result, DbError> { + let issue_seq = loop_issue::Entity::find_by_id(issue_id) + .one(conn) + .await? + .map(|i| i.seq_no) + .unwrap_or(0); + let rows = loop_iteration::Entity::find() + .filter(loop_iteration::Column::IssueId.eq(issue_id)) + .order_by_desc(loop_iteration::Column::Id) + .all(conn) + .await?; + let titles = target_titles(conn, &rows).await?; + Ok(rows + .iter() + .map(|m| { + let title = m + .target_artifact_id + .and_then(|tid| titles.get(&tid).cloned()); + to_iteration_row(m, issue_seq, title) + }) + .collect()) +} + +pub async fn list_iterations_for_space( + conn: &sea_orm::DatabaseConnection, + space_id: i32, +) -> Result, DbError> { + let seqs: HashMap = loop_issue::Entity::find() + .filter(loop_issue::Column::SpaceId.eq(space_id)) + .all(conn) + .await? + .into_iter() + .map(|i| (i.id, i.seq_no)) + .collect(); + let rows = loop_iteration::Entity::find() + .filter(loop_iteration::Column::SpaceId.eq(space_id)) + .order_by_desc(loop_iteration::Column::Id) + .all(conn) + .await?; + let titles = target_titles(conn, &rows).await?; + Ok(rows + .iter() + .map(|m| { + let title = m + .target_artifact_id + .and_then(|tid| titles.get(&tid).cloned()); + to_iteration_row(m, *seqs.get(&m.issue_id).unwrap_or(&0), title) + }) + .collect()) +} diff --git a/src-tauri/src/db/service/loop_service/link.rs b/src-tauri/src/db/service/loop_service/link.rs new file mode 100644 index 0000000000..9fe82475bd --- /dev/null +++ b/src-tauri/src/db/service/loop_service/link.rs @@ -0,0 +1,45 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; + +use crate::db::entities::loop_link::{self, LinkKind}; +use crate::db::error::DbError; +use crate::models::loops::LoopLinkRow; + +pub fn to_link_row(m: loop_link::Model) -> LoopLinkRow { + LoopLinkRow { + id: m.id, + from_artifact_id: m.from_artifact_id, + to_artifact_id: m.to_artifact_id, + kind: m.kind, + } +} + +/// Idempotent: a repeated `(from, to, kind)` triple returns the existing edge +/// instead of inserting a duplicate (also guarded by `uniq_loop_link`). +pub async fn create_link( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + from_artifact_id: i32, + to_artifact_id: i32, + kind: LinkKind, +) -> Result { + if let Some(existing) = loop_link::Entity::find() + .filter(loop_link::Column::FromArtifactId.eq(from_artifact_id)) + .filter(loop_link::Column::ToArtifactId.eq(to_artifact_id)) + .filter(loop_link::Column::Kind.eq(kind)) + .one(conn) + .await? + { + return Ok(existing); + } + Ok(loop_link::ActiveModel { + space_id: Set(space_id), + from_artifact_id: Set(from_artifact_id), + to_artifact_id: Set(to_artifact_id), + kind: Set(kind), + created_at: Set(Utc::now()), + ..Default::default() + } + .insert(conn) + .await?) +} diff --git a/src-tauri/src/db/service/loop_service/memory.rs b/src-tauri/src/db/service/loop_service/memory.rs new file mode 100644 index 0000000000..dd60b1d5a0 --- /dev/null +++ b/src-tauri/src/db/service/loop_service/memory.rs @@ -0,0 +1,132 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set, +}; + +use crate::db::entities::loop_artifact_revision::ActorKind; +use crate::db::entities::loop_iteration::Stage; +use crate::db::entities::loop_memory::{self, MemoryKind, MemoryStatus}; +use crate::db::error::DbError; +use crate::models::loops::LoopMemoryRow; + +fn to_row(m: loop_memory::Model) -> LoopMemoryRow { + LoopMemoryRow { + id: m.id, + kind: m.kind, + source: m.source, + title: m.title, + content: m.content, + status: m.status, + created_at: m.created_at, + updated_at: m.updated_at, + } +} + +/// Memory kinds injected for a given stage (briefing §4.8 matrix). `constitution` +/// is handled separately by the briefing assembler, so it is never returned here. +fn kinds_for_stage(stage: Stage) -> Vec { + use MemoryKind::*; + match stage { + Stage::Triage => vec![Constraint, Preference], + Stage::Refine | Stage::Design => vec![Constraint, Decision, Preference], + Stage::Plan => vec![Decision, Constraint], + Stage::Implement => vec![Pitfall, Preference, Constraint], + Stage::Review => vec![Constraint, Decision, Preference, Pitfall], + // finalize summarizes; reuse the review-wide set. + Stage::Finalize => vec![Constraint, Decision, Preference, Pitfall], + } +} + +pub async fn create_memory( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + kind: MemoryKind, + source: ActorKind, + title: &str, + content: &str, +) -> Result { + let now = Utc::now(); + Ok(loop_memory::ActiveModel { + space_id: Set(space_id), + kind: Set(kind), + source: Set(source), + title: Set(title.to_string()), + content: Set(content.to_string()), + status: Set(MemoryStatus::Active), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + } + .insert(conn) + .await?) +} + +pub async fn update_memory( + conn: &sea_orm::DatabaseConnection, + id: i32, + title: &str, + content: &str, + status: MemoryStatus, +) -> Result<(), DbError> { + let row = loop_memory::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| { + DbError::Database(sea_orm::DbErr::RecordNotFound(format!("loop_memory {id}"))) + })?; + let mut active = row.into_active_model(); + active.title = Set(title.to_string()); + active.content = Set(content.to_string()); + active.status = Set(status); + active.updated_at = Set(Utc::now()); + active.update(conn).await?; + Ok(()) +} + +pub async fn delete_memory(conn: &sea_orm::DatabaseConnection, id: i32) -> Result<(), DbError> { + loop_memory::Entity::delete_by_id(id).exec(conn).await?; + Ok(()) +} + +pub async fn list_memory( + conn: &sea_orm::DatabaseConnection, + space_id: i32, +) -> Result, DbError> { + Ok(loop_memory::Entity::find() + .filter(loop_memory::Column::SpaceId.eq(space_id)) + .order_by_desc(loop_memory::Column::Id) + .all(conn) + .await? + .into_iter() + .map(to_row) + .collect()) +} + +/// Active memories to inject for `stage` (excludes constitution). +pub async fn list_active_for_stage( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + stage: Stage, +) -> Result, DbError> { + Ok(loop_memory::Entity::find() + .filter(loop_memory::Column::SpaceId.eq(space_id)) + .filter(loop_memory::Column::Status.eq(MemoryStatus::Active)) + .filter(loop_memory::Column::Kind.is_in(kinds_for_stage(stage))) + .order_by_asc(loop_memory::Column::Id) + .all(conn) + .await?) +} + +/// The space constitution memories (always injected first by the briefing). +pub async fn list_constitution( + conn: &sea_orm::DatabaseConnection, + space_id: i32, +) -> Result, DbError> { + Ok(loop_memory::Entity::find() + .filter(loop_memory::Column::SpaceId.eq(space_id)) + .filter(loop_memory::Column::Status.eq(MemoryStatus::Active)) + .filter(loop_memory::Column::Kind.eq(MemoryKind::Constitution)) + .order_by_asc(loop_memory::Column::Id) + .all(conn) + .await?) +} diff --git a/src-tauri/src/db/service/loop_service/mod.rs b/src-tauri/src/db/service/loop_service/mod.rs new file mode 100644 index 0000000000..89ea87e93f --- /dev/null +++ b/src-tauri/src/db/service/loop_service/mod.rs @@ -0,0 +1,222 @@ +//! DB layer for the loop engineering subsystem. CRUD + read models; the +//! compare-and-swap transitions and dispatch leases live in +//! `loop_engine::transitions`. + +pub mod artifact; +pub mod inbox; +pub mod issue; +pub mod iteration; +pub mod link; +pub mod memory; +pub mod space; +pub mod validation; + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::db::entities::loop_inbox_item::{InboxKind, InboxStatus}; + use crate::db::entities::loop_issue::{IssuePriority, IssueStatus}; + use crate::db::entities::loop_iteration::Stage; + use crate::db::entities::loop_link::LinkKind; + use crate::db::entities::loop_memory::MemoryKind; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::models::loops::IssueConfig; + + #[tokio::test] + async fn create_issue_seeds_root_artifact() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-a").await; + let space = space::create_space(&db.conn, "Pay", folder_id).await.unwrap(); + let detail = issue::create_issue( + &db.conn, + space.id, + "Fix webhook", + "the body", + IssuePriority::High, + &IssueConfig::default(), + ) + .await + .unwrap(); + + assert_eq!(detail.row.seq_no, 1); + assert_eq!(detail.row.status, IssueStatus::Pending); + + let dag = artifact::list_dag(&db.conn, detail.row.id).await.unwrap(); + assert_eq!(dag.artifacts.len(), 1, "root artifact created"); + assert_eq!(dag.artifacts[0].kind, ArtifactKind::Issue); + assert_eq!(dag.artifacts[0].status, ArtifactStatus::Done); + + let det = artifact::get_artifact_detail(&db.conn, dag.artifacts[0].id) + .await + .unwrap() + .unwrap(); + assert_eq!(det.revisions.len(), 1, "description seeded as revision 1"); + assert_eq!(det.revisions[0].content, "the body"); + } + + #[tokio::test] + async fn artifacts_links_idempotent_and_dag() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-b").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "d", + IssuePriority::Medium, + &IssueConfig::default(), + ) + .await + .unwrap(); + let issue_id = issue.row.id; + let root_id = artifact::list_dag(&db.conn, issue_id).await.unwrap().artifacts[0].id; + + let req = artifact::create_artifact( + &db.conn, + space.id, + issue_id, + ArtifactKind::Requirement, + "R1", + ArtifactStatus::Done, + ActorKind::Agent, + None, + ) + .await + .unwrap(); + artifact::add_revision(&db.conn, req.id, "req body", ActorKind::Agent, None) + .await + .unwrap(); + let crit = artifact::add_criterion(&db.conn, req.id, "must do x") + .await + .unwrap(); + assert_eq!(crit.label, "AC-1"); + + // `requirement derives_from issue` — repeated, must dedupe. + let l1 = link::create_link(&db.conn, space.id, req.id, root_id, LinkKind::DerivesFrom) + .await + .unwrap(); + let l2 = link::create_link(&db.conn, space.id, req.id, root_id, LinkKind::DerivesFrom) + .await + .unwrap(); + assert_eq!(l1.id, l2.id, "link is idempotent"); + + let dag = artifact::list_dag(&db.conn, issue_id).await.unwrap(); + assert_eq!(dag.artifacts.len(), 2); + assert_eq!(dag.links.len(), 1); + + let det = artifact::get_artifact_detail(&db.conn, req.id) + .await + .unwrap() + .unwrap(); + assert_eq!(det.revisions.len(), 1); + assert_eq!(det.criteria.len(), 1); + assert_eq!(det.links.len(), 1); + } + + #[tokio::test] + async fn inbox_upsert_dedupes_pending() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-c").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "d", + IssuePriority::Low, + &IssueConfig::default(), + ) + .await + .unwrap(); + + let a = inbox::upsert_inbox( + &db.conn, + space.id, + issue.row.id, + None, + InboxKind::Blocked, + "artifact:1", + serde_json::json!({"reason": "x"}), + ) + .await + .unwrap(); + let b = inbox::upsert_inbox( + &db.conn, + space.id, + issue.row.id, + None, + InboxKind::Blocked, + "artifact:1", + serde_json::json!({"reason": "y"}), + ) + .await + .unwrap(); + assert_eq!(a.id, b.id, "pending inbox item deduped by subject_key"); + + let pending = inbox::list_inbox(&db.conn, space.id, Some(InboxStatus::Pending)) + .await + .unwrap(); + assert_eq!(pending.len(), 1); + + inbox::handle_inbox(&db.conn, a.id, serde_json::json!({"ok": true})) + .await + .unwrap(); + let still_pending = inbox::list_inbox(&db.conn, space.id, Some(InboxStatus::Pending)) + .await + .unwrap(); + assert_eq!(still_pending.len(), 0); + } + + #[tokio::test] + async fn space_summary_and_cascade_delete() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-d").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "d", + IssuePriority::Medium, + &IssueConfig::default(), + ) + .await + .unwrap(); + + let summaries = space::list_spaces(&db.conn).await.unwrap(); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].issue_count, 1); + assert!(!summaries[0].detached, "live folder is not detached"); + + space::delete_space(&db.conn, space.id).await.unwrap(); + // FK cascade removed the issue and its root artifact. + let dag = artifact::list_dag(&db.conn, issue.row.id).await.unwrap(); + assert_eq!(dag.artifacts.len(), 0, "cascade removed artifacts"); + assert!(space::list_spaces(&db.conn).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn memory_crud_and_stage_matrix() { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/repo-e").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + + memory::create_memory(&db.conn, space.id, MemoryKind::Pitfall, ActorKind::Agent, "p", "b") + .await + .unwrap(); + memory::create_memory(&db.conn, space.id, MemoryKind::Decision, ActorKind::Human, "d", "b") + .await + .unwrap(); + assert_eq!(memory::list_memory(&db.conn, space.id).await.unwrap().len(), 2); + + // implement stage injects pitfalls, not decisions. + let injected = memory::list_active_for_stage(&db.conn, space.id, Stage::Implement) + .await + .unwrap(); + assert!(injected.iter().any(|m| m.kind == MemoryKind::Pitfall)); + assert!(!injected.iter().any(|m| m.kind == MemoryKind::Decision)); + } +} diff --git a/src-tauri/src/db/service/loop_service/space.rs b/src-tauri/src/db/service/loop_service/space.rs new file mode 100644 index 0000000000..a37922eaf6 --- /dev/null +++ b/src-tauri/src/db/service/loop_service/space.rs @@ -0,0 +1,122 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set, +}; + +use crate::db::entities::loop_issue::IssueStatus; +use crate::db::entities::{folder, loop_issue, loop_space}; +use crate::db::error::DbError; +use crate::models::loops::LoopSpaceSummary; + +fn not_found(id: i32) -> DbError { + DbError::Database(sea_orm::DbErr::RecordNotFound(format!("loop_space {id}"))) +} + +pub async fn create_space( + conn: &sea_orm::DatabaseConnection, + name: &str, + folder_id: i32, +) -> Result { + let now = Utc::now(); + let active = loop_space::ActiveModel { + name: Set(name.to_string()), + folder_id: Set(folder_id), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + }; + Ok(active.insert(conn).await?) +} + +pub async fn update_space( + conn: &sea_orm::DatabaseConnection, + id: i32, + name: &str, +) -> Result { + let row = loop_space::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| not_found(id))?; + let mut active = row.into_active_model(); + active.name = Set(name.to_string()); + active.updated_at = Set(Utc::now()); + Ok(active.update(conn).await?) +} + +pub async fn get_space( + conn: &sea_orm::DatabaseConnection, + id: i32, +) -> Result, DbError> { + Ok(loop_space::Entity::find_by_id(id).one(conn).await?) +} + +/// Hard-delete a space; loop-table FKs (`ON DELETE CASCADE`) remove every issue, +/// artifact, revision, criterion, link, iteration, validation run, inbox item +/// and memory underneath. Engine worktree cleanup happens at the command layer +/// before this is called. +pub async fn delete_space(conn: &sea_orm::DatabaseConnection, id: i32) -> Result<(), DbError> { + loop_space::Entity::delete_by_id(id).exec(conn).await?; + Ok(()) +} + +pub async fn list_spaces( + conn: &sea_orm::DatabaseConnection, +) -> Result, DbError> { + let spaces = loop_space::Entity::find() + .order_by_desc(loop_space::Column::CreatedAt) + .all(conn) + .await?; + if spaces.is_empty() { + return Ok(Vec::new()); + } + + let space_ids: Vec = spaces.iter().map(|s| s.id).collect(); + let folder_ids: Vec = spaces.iter().map(|s| s.folder_id).collect(); + + let folders: HashMap = folder::Entity::find() + .filter(folder::Column::Id.is_in(folder_ids)) + .all(conn) + .await? + .into_iter() + .map(|f| (f.id, f)) + .collect(); + + let issues = loop_issue::Entity::find() + .filter(loop_issue::Column::SpaceId.is_in(space_ids)) + .all(conn) + .await?; + + let summaries = spaces + .into_iter() + .map(|s| { + let folder = folders.get(&s.folder_id); + // Folder join does NOT filter deleted_at — a soft-deleted/missing + // folder still yields the space (read-only) and flips `detached`. + let detached = folder.map(|f| f.deleted_at.is_some()).unwrap_or(true); + let folder_path = folder.map(|f| f.path.clone()); + let mine: Vec<&loop_issue::Model> = + issues.iter().filter(|i| i.space_id == s.id).collect(); + let issue_count = mine.len() as i64; + let running_count = mine + .iter() + .filter(|i| i.status == IssueStatus::Running) + .count() as i64; + let last_activity_at = mine.iter().map(|i| i.updated_at).max(); + LoopSpaceSummary { + id: s.id, + name: s.name, + folder_id: s.folder_id, + folder_path, + detached, + issue_count, + running_count, + last_activity_at, + created_at: s.created_at, + } + }) + .collect(); + + Ok(summaries) +} diff --git a/src-tauri/src/db/service/loop_service/validation.rs b/src-tauri/src/db/service/loop_service/validation.rs new file mode 100644 index 0000000000..b23d156e52 --- /dev/null +++ b/src-tauri/src/db/service/loop_service/validation.rs @@ -0,0 +1,60 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; + +use crate::db::entities::loop_validation_run; +use crate::db::error::DbError; +use crate::models::loops::LoopValidationRunRow; + +fn to_row(m: loop_validation_run::Model) -> LoopValidationRunRow { + LoopValidationRunRow { + id: m.id, + task_artifact_id: m.task_artifact_id, + iteration_id: m.iteration_id, + commands: serde_json::from_str(&m.commands).unwrap_or_default(), + exit_codes: serde_json::from_str(&m.exit_codes).unwrap_or_default(), + passed: m.passed, + created_at: m.created_at, + } +} + +#[allow(clippy::too_many_arguments)] +pub async fn record_validation_run( + conn: &sea_orm::DatabaseConnection, + space_id: i32, + issue_id: i32, + task_artifact_id: i32, + iteration_id: Option, + commands: &[String], + exit_codes: &[i32], + output: &str, + passed: bool, +) -> Result { + Ok(loop_validation_run::ActiveModel { + space_id: Set(space_id), + issue_id: Set(issue_id), + task_artifact_id: Set(task_artifact_id), + iteration_id: Set(iteration_id), + commands: Set(serde_json::to_string(commands).unwrap_or_else(|_| "[]".to_string())), + exit_codes: Set(serde_json::to_string(exit_codes).unwrap_or_else(|_| "[]".to_string())), + output: Set(output.to_string()), + passed: Set(passed), + created_at: Set(Utc::now()), + ..Default::default() + } + .insert(conn) + .await?) +} + +pub async fn list_for_task( + conn: &sea_orm::DatabaseConnection, + task_artifact_id: i32, +) -> Result, DbError> { + Ok(loop_validation_run::Entity::find() + .filter(loop_validation_run::Column::TaskArtifactId.eq(task_artifact_id)) + .order_by_desc(loop_validation_run::Column::Id) + .all(conn) + .await? + .into_iter() + .map(to_row) + .collect()) +} diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index dc70f1b7d1..f625248f4f 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -6,6 +6,7 @@ pub mod conversation_service; pub mod folder_command_service; pub mod folder_service; pub mod import_service; +pub mod loop_service; pub mod model_provider_service; pub mod quick_message_service; pub mod remote_workspace_connection_service; From 15cb3736df452207fa06f41dc7b96c454a6190ec Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 13 Jun 2026 14:33:57 +0800 Subject: [PATCH 006/157] feat(loop): mint kind=loop conversations and guard sidebar from loop leak --- src-tauri/src/commands/conversations.rs | 38 ++++++++++++++++--- .../src/db/service/conversation_service.rs | 22 +++++++++++ src/contexts/app-workspace-context.test.tsx | 8 ++++ src/contexts/app-workspace-context.tsx | 9 +++-- 4 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 94d76d7d9f..29a6bda75e 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -839,12 +839,15 @@ pub(crate) async fn emit_conversation_upsert( ) { match conversation_service::get_by_id(conn, conversation_id).await { Ok(summary) => { - // Sidebar shows ROOT conversations only — never broadcast a - // delegation child. The frontend also filters `parent_id != null`; - // this is the backend half of that invariant, so callers on agent - // paths (e.g. SessionStarted) can hand us any id without leaking - // child rows into every client's list. - if summary.parent_id.is_some() { + // Sidebar shows ROOT, non-loop conversations only — never broadcast a + // delegation child or a loop-engine iteration. The frontend also + // filters `parent_id != null` and `kind === "loop"`; this is the + // backend half of that invariant, so callers on agent paths (e.g. + // SessionStarted) can hand us any id without leaking hidden rows into + // every client's list. + if summary.parent_id.is_some() + || summary.kind == crate::db::entities::conversation::ConversationKind::Loop + { return; } emit_event( @@ -3082,4 +3085,27 @@ mod tests { "delegation child must not broadcast a sidebar upsert" ); } + + #[tokio::test] + async fn emit_conversation_upsert_skips_loop_iteration() { + // Loop-engine iterations (kind = loop) are never sidebar rows. + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/codeg-sync-loop-skip").await; + let conv = conversation_service::create_loop( + &db.conn, + folder_id, + AgentType::ClaudeCode, + Some("loop run".into()), + None, + ) + .await + .expect("loop conv"); + let (broadcaster, emitter) = sync_test_emitter(); + let mut rx = broadcaster.subscribe(); + emit_conversation_upsert(&emitter, &db.conn, conv.id).await; + assert!( + rx.try_recv().is_err(), + "loop iteration must not broadcast a sidebar upsert" + ); + } } diff --git a/src-tauri/src/db/service/conversation_service.rs b/src-tauri/src/db/service/conversation_service.rs index efaf57b08a..b2fc3f04ab 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -51,6 +51,28 @@ pub async fn create_chat( .await } +/// Mirror of [`create`] for loop-engine iterations: `kind = 'loop'`, so the row +/// is excluded from the sidebar entirely (see `list_all` and +/// `emit_conversation_upsert`). Each iteration's worktree folder backs it. +pub async fn create_loop( + conn: &DatabaseConnection, + folder_id: i32, + agent_type: AgentType, + title: Option, + git_branch: Option, +) -> Result { + create_inner( + conn, + folder_id, + agent_type, + title, + git_branch, + None, + ConversationKind::Loop, + ) + .await +} + /// Mirror of [`create`] plus optional delegation linkage. Used by the /// multi-agent broker when spawning a child sub-session — populates /// `parent_id` / `parent_tool_use_id` / `delegation_call_id` so the lifecycle diff --git a/src/contexts/app-workspace-context.test.tsx b/src/contexts/app-workspace-context.test.tsx index cca44c031b..2445eddc64 100644 --- a/src/contexts/app-workspace-context.test.tsx +++ b/src/contexts/app-workspace-context.test.tsx @@ -194,6 +194,14 @@ describe("AppWorkspaceProvider conversation://changed sync", () => { expect(screen.getByTestId("count")).toHaveTextContent("1") }) + it("ignores loop-engine iterations (kind === loop) — not sidebar rows", async () => { + await mountProvider() + emit({ kind: "upsert", summary: makeSummary({ id: 1 }) }) + emit({ kind: "upsert", summary: makeSummary({ id: 7, kind: "loop" }) }) + expect(screen.getByTestId("ids")).toHaveTextContent("1") + expect(screen.getByTestId("count")).toHaveTextContent("1") + }) + it("removes on deleted and is idempotent for an unknown id", async () => { await mountProvider() emit({ kind: "upsert", summary: makeSummary({ id: 1 }) }) diff --git a/src/contexts/app-workspace-context.tsx b/src/contexts/app-workspace-context.tsx index 6b92dba3a4..e9a7b135f6 100644 --- a/src/contexts/app-workspace-context.tsx +++ b/src/contexts/app-workspace-context.tsx @@ -242,12 +242,13 @@ export function AppWorkspaceProvider({ children }: AppWorkspaceProviderProps) { // reused, so the tombstone is permanent; the set is FIFO-bounded. const deletedIdsRef = useRef>(new Set()) - // Insert-or-replace a conversation by id (create + field updates). Root-only: - // delegation children (parent_id set) are not sidebar rows. New rows prepend - // (most-recent-first); existing rows replace in place to keep their position. + // Insert-or-replace a conversation by id (create + field updates). Root-only, + // non-loop: delegation children (parent_id set) and loop-engine iterations + // (kind === "loop") are not sidebar rows. New rows prepend (most-recent-first); + // existing rows replace in place to keep their position. const applyConversationUpsert = useCallback( (summary: DbConversationSummary) => { - if (summary.parent_id != null) return + if (summary.parent_id != null || summary.kind === "loop") return if (deletedIdsRef.current.has(summary.id)) return setConversations((prev) => { const idx = prev.findIndex((c) => c.id === summary.id) From 2027b0b2ded79e7ca8693bfd1eb5dbeec9a4bf21 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 13 Jun 2026 14:36:55 +0800 Subject: [PATCH 007/157] feat(loop): add CAS state machine, dispatch leases and task gate --- src-tauri/src/loop_engine/mod.rs | 1 + src-tauri/src/loop_engine/transitions.rs | 251 +++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 src-tauri/src/loop_engine/transitions.rs diff --git a/src-tauri/src/loop_engine/mod.rs b/src-tauri/src/loop_engine/mod.rs index bae9802c2f..19465db18f 100644 --- a/src-tauri/src/loop_engine/mod.rs +++ b/src-tauri/src/loop_engine/mod.rs @@ -5,5 +5,6 @@ //! lifecycle, briefing, recovery and MCP ingest modules arrive in later phases. pub mod error; +pub mod transitions; pub use error::LoopError; diff --git a/src-tauri/src/loop_engine/transitions.rs b/src-tauri/src/loop_engine/transitions.rs new file mode 100644 index 0000000000..e4c904b917 --- /dev/null +++ b/src-tauri/src/loop_engine/transitions.rs @@ -0,0 +1,251 @@ +//! The single funnel for loop state changes: compare-and-swap status +//! transitions, the durable dispatch leases (partial unique indexes enforce +//! one active write-iteration per issue, one active iteration per (target, +//! stage) excluding review, and N review slots per task), and the per-issue +//! serial-task pipeline gate. All concurrency safety bottoms out here, not in +//! the in-memory driver registry. + +use chrono::Utc; +use sea_orm::sea_query::Expr; +use sea_orm::{ActiveEnum, ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; + +use crate::db::entities::loop_artifact::{self, ArtifactStatus}; +use crate::db::entities::loop_issue::{self, IssueStatus}; +use crate::db::entities::loop_iteration::{self, IterationStatus, LaunchedBy, Stage}; +use crate::loop_engine::error::LoopError; + +/// A SQLite UNIQUE-constraint failure — i.e. a dispatch lease was already held +/// by a concurrent claimer. Matched on the message because sqlx surfaces it as +/// an opaque `DbErr`. +fn is_unique_violation(e: &sea_orm::DbErr) -> bool { + e.to_string().to_lowercase().contains("unique") +} + +/// CAS an issue's status: write `new` only if it currently equals `expected`. +/// Returns `true` on success, `false` on a miss (the caller maps that to +/// [`LoopError::Conflict`]). +pub async fn cas_issue_status( + conn: &DatabaseConnection, + id: i32, + expected: IssueStatus, + new: IssueStatus, +) -> Result { + let res = loop_issue::Entity::update_many() + .col_expr(loop_issue::Column::Status, Expr::value(new.to_value())) + .col_expr(loop_issue::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_issue::Column::Id.eq(id)) + .filter(loop_issue::Column::Status.eq(expected)) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +/// CAS an artifact's status. +pub async fn cas_artifact_status( + conn: &DatabaseConnection, + id: i32, + expected: ArtifactStatus, + new: ArtifactStatus, +) -> Result { + let res = loop_artifact::Entity::update_many() + .col_expr(loop_artifact::Column::Status, Expr::value(new.to_value())) + .col_expr(loop_artifact::Column::UpdatedAt, Expr::value(Utc::now())) + .filter(loop_artifact::Column::Id.eq(id)) + .filter(loop_artifact::Column::Status.eq(expected)) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +/// Inputs for a dispatch claim. `conversation_id` is intentionally absent — the +/// lease row is inserted first (conversation attached afterwards by the winner). +pub struct IterationClaim { + pub space_id: i32, + pub issue_id: i32, + pub stage: Stage, + pub target_artifact_id: Option, + pub slot_no: Option, + pub capability_token: String, + pub attempt: i32, +} + +/// Attempt to claim a dispatch lease by inserting a `queued` iteration row. The +/// partial unique indexes make this the atomic gate: a lost race surfaces as a +/// UNIQUE violation, returned here as `Ok(None)` (not an error) so the driver +/// simply skips. The winner gets `Ok(Some(row))`. +pub async fn try_claim_iteration( + conn: &DatabaseConnection, + claim: IterationClaim, +) -> Result, LoopError> { + let now = Utc::now(); + let active = loop_iteration::ActiveModel { + space_id: Set(claim.space_id), + issue_id: Set(claim.issue_id), + stage: Set(claim.stage), + target_artifact_id: Set(claim.target_artifact_id), + slot_no: Set(claim.slot_no), + conversation_id: Set(None), + capability_token: Set(claim.capability_token), + status: Set(IterationStatus::Queued), + launched_by: Set(LaunchedBy::Engine), + attempt: Set(claim.attempt), + tokens_used: Set(0), + context_manifest: Set(None), + created_at: Set(now), + started_at: Set(None), + ended_at: Set(None), + ..Default::default() + }; + match active.insert(conn).await { + Ok(model) => Ok(Some(model)), + Err(e) if is_unique_violation(&e) => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// CAS an iteration's status. +pub async fn cas_iteration_status( + conn: &DatabaseConnection, + id: i32, + expected: IterationStatus, + new: IterationStatus, +) -> Result { + let res = loop_iteration::Entity::update_many() + .col_expr(loop_iteration::Column::Status, Expr::value(new.to_value())) + .filter(loop_iteration::Column::Id.eq(id)) + .filter(loop_iteration::Column::Status.eq(expected)) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +/// Acquire the per-issue serial-task pipeline gate for `task_artifact_id`. Wins +/// (`true`) only when no task currently holds it (`active_task_artifact_id IS +/// NULL`). Keeps two tasks of one issue from sharing the worktree. +pub async fn try_acquire_task_gate( + conn: &DatabaseConnection, + issue_id: i32, + task_artifact_id: i32, +) -> Result { + let res = loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::ActiveTaskArtifactId, + Expr::value(task_artifact_id), + ) + .filter(loop_issue::Column::Id.eq(issue_id)) + .filter(loop_issue::Column::ActiveTaskArtifactId.is_null()) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +/// Release the task gate, but only if it is still held by `task_artifact_id`. +pub async fn release_task_gate( + conn: &DatabaseConnection, + issue_id: i32, + task_artifact_id: i32, +) -> Result { + let res = loop_issue::Entity::update_many() + .col_expr( + loop_issue::Column::ActiveTaskArtifactId, + Expr::value(Option::::None), + ) + .filter(loop_issue::Column::Id.eq(issue_id)) + .filter(loop_issue::Column::ActiveTaskArtifactId.eq(task_artifact_id)) + .exec(conn) + .await?; + Ok(res.rows_affected == 1) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::entities::loop_artifact::{ArtifactKind, ArtifactStatus}; + use crate::db::entities::loop_artifact_revision::ActorKind; + use crate::db::entities::loop_issue::IssuePriority; + use crate::db::service::loop_service::{artifact, issue, space}; + use crate::db::test_helpers::{fresh_in_memory_db, seed_folder}; + use crate::models::loops::IssueConfig; + + async fn seed() -> (crate::db::AppDatabase, i32, i32) { + let db = fresh_in_memory_db().await; + let folder_id = seed_folder(&db, "/tmp/trans").await; + let space = space::create_space(&db.conn, "S", folder_id).await.unwrap(); + let issue = issue::create_issue( + &db.conn, + space.id, + "I", + "d", + IssuePriority::Medium, + &IssueConfig::default(), + ) + .await + .unwrap(); + (db, space.id, issue.row.id) + } + + fn claim(space_id: i32, issue_id: i32, stage: Stage, target: Option, slot: Option, token: &str) -> IterationClaim { + IterationClaim { + space_id, + issue_id, + stage, + target_artifact_id: target, + slot_no: slot, + capability_token: token.to_string(), + attempt: 0, + } + } + + #[tokio::test] + async fn cas_issue_status_only_on_expected() { + let (db, _space, issue_id) = seed().await; + assert!( + cas_issue_status(&db.conn, issue_id, IssueStatus::Pending, IssueStatus::Running) + .await + .unwrap() + ); + // Now the row is Running; a Pending→Running CAS must miss. + assert!( + !cas_issue_status(&db.conn, issue_id, IssueStatus::Pending, IssueStatus::Running) + .await + .unwrap() + ); + } + + #[tokio::test] + async fn write_lease_blocks_second_implement_per_issue() { + let (db, space_id, issue_id) = seed().await; + let task = artifact::create_artifact(&db.conn, space_id, issue_id, ArtifactKind::Task, "T", ArtifactStatus::Pending, ActorKind::Agent, None).await.unwrap(); + let first = try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Implement, Some(task.id), None, "tok-a")).await.unwrap(); + assert!(first.is_some()); + // Same issue, another implement → uniq_active_write blocks it. + let second = try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Implement, Some(task.id), None, "tok-b")).await.unwrap(); + assert!(second.is_none(), "second implement on the issue is leased out"); + } + + #[tokio::test] + async fn review_slots_parallel_but_unique_per_slot() { + let (db, space_id, issue_id) = seed().await; + let task = artifact::create_artifact(&db.conn, space_id, issue_id, ArtifactKind::Task, "T", ArtifactStatus::Done, ActorKind::Agent, None).await.unwrap(); + // Two reviews of the same task on distinct slots both claim. + let s0 = try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Review, Some(task.id), Some(0), "r0")).await.unwrap(); + let s1 = try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Review, Some(task.id), Some(1), "r1")).await.unwrap(); + assert!(s0.is_some() && s1.is_some(), "review slots run in parallel"); + // Same slot again → blocked. + let dup = try_claim_iteration(&db.conn, claim(space_id, issue_id, Stage::Review, Some(task.id), Some(0), "r0b")).await.unwrap(); + assert!(dup.is_none(), "duplicate review slot is leased out"); + } + + #[tokio::test] + async fn task_gate_serializes_then_releases() { + let (db, _space, issue_id) = seed().await; + assert!(try_acquire_task_gate(&db.conn, issue_id, 100).await.unwrap()); + // A different task cannot acquire while 100 holds the gate. + assert!(!try_acquire_task_gate(&db.conn, issue_id, 200).await.unwrap()); + // Releasing with the wrong task is a no-op. + assert!(!release_task_gate(&db.conn, issue_id, 200).await.unwrap()); + // Correct release frees the gate. + assert!(release_task_gate(&db.conn, issue_id, 100).await.unwrap()); + assert!(try_acquire_task_gate(&db.conn, issue_id, 200).await.unwrap()); + } +} From eab5ba7f6a26625ad722ccb3e00aa84f5b6619a3 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 13 Jun 2026 14:45:10 +0800 Subject: [PATCH 008/157] feat(loop): wire loop CRUD commands for desktop and server modes --- src-tauri/src/commands/loops.rs | 482 ++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + .../src/db/service/loop_service/memory.rs | 2 +- src-tauri/src/lib.rs | 20 +- src-tauri/src/web/handlers/loops.rs | 305 +++++++++++ src-tauri/src/web/handlers/mod.rs | 1 + src-tauri/src/web/router.rs | 28 + 7 files changed, 837 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/loops.rs create mode 100644 src-tauri/src/web/handlers/loops.rs diff --git a/src-tauri/src/commands/loops.rs b/src-tauri/src/commands/loops.rs new file mode 100644 index 0000000000..e29986fb69 --- /dev/null +++ b/src-tauri/src/commands/loops.rs @@ -0,0 +1,482 @@ +//! Loop engineering commands. `_core` functions hold the business logic shared +//! by the desktop (`#[tauri::command]`) and server (Axum handler) modes; every +//! successful write emits the coarse `loop://changed` event so all clients +//! refetch. M2.0 wires CRUD only — engine actions (trigger/pause/…) arrive in +//! M2.1+. + +use sea_orm::DatabaseConnection; + +use crate::app_error::AppCommandError; +use crate::db::entities::loop_artifact_revision::ActorKind; +use crate::db::entities::loop_inbox_item::InboxStatus; +use crate::db::entities::loop_issue::{IssuePriority, IssueStatus}; +use crate::db::entities::loop_memory::{MemoryKind, MemoryStatus}; +use crate::db::service::folder_service; +use crate::db::service::loop_service::{artifact, inbox, issue, iteration, memory, space}; +use crate::models::loops::{ + IssueConfig, LoopArtifactDetail, LoopArtifactRow, LoopChanged, LoopDagView, LoopInboxItemRow, + LoopIssueDetail, LoopIterationRow, LoopMemoryRow, LoopSpaceSummary, +}; +use crate::web::event_bridge::{emit_event, EventEmitter}; + +#[cfg(feature = "tauri-runtime")] +use crate::db::AppDatabase; + +pub const LOOP_CHANGED_EVENT: &str = "loop://changed"; + +fn emit_loop_changed( + emitter: &EventEmitter, + space_id: i32, + issue_id: Option, + subject_kind: &str, + subject_id: i32, + kind: &str, +) { + emit_event( + emitter, + LOOP_CHANGED_EVENT, + LoopChanged { + v: 1, + space_id, + issue_id, + subject_kind: subject_kind.to_string(), + subject_id, + kind: kind.to_string(), + }, + ); +} + +async fn folder_is_git_repo(path: &str) -> bool { + tokio::process::Command::new("git") + .arg("-C") + .arg(path) + .arg("rev-parse") + .arg("--is-inside-work-tree") + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +// ─── Spaces ────────────────────────────────────────────────────────────── + +pub async fn list_loop_spaces_core( + conn: &DatabaseConnection, +) -> Result, AppCommandError> { + Ok(space::list_spaces(conn).await?) +} + +pub async fn create_loop_space_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + name: String, + folder_id: i32, +) -> Result { + let folder = folder_service::get_folder_by_id(conn, folder_id) + .await? + .ok_or_else(|| AppCommandError::not_found("Folder not found"))?; + if !folder_is_git_repo(&folder.path).await { + return Err(AppCommandError::not_a_git_repository( + "Loop space folder must be a git repository", + )); + } + let created = space::create_space(conn, &name, folder_id).await?; + emit_loop_changed(emitter, created.id, None, "space", created.id, "created"); + summary_for(conn, created.id).await +} + +pub async fn update_loop_space_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + id: i32, + name: String, +) -> Result { + space::update_space(conn, id, &name).await?; + emit_loop_changed(emitter, id, None, "space", id, "updated"); + summary_for(conn, id).await +} + +pub async fn delete_loop_space_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + id: i32, +) -> Result<(), AppCommandError> { + space::delete_space(conn, id).await?; + emit_loop_changed(emitter, id, None, "space", id, "deleted"); + Ok(()) +} + +async fn summary_for( + conn: &DatabaseConnection, + id: i32, +) -> Result { + space::list_spaces(conn) + .await? + .into_iter() + .find(|s| s.id == id) + .ok_or_else(|| AppCommandError::not_found("Loop space not found")) +} + +// ─── Issues ────────────────────────────────────────────────────────────── + +pub async fn list_loop_issues_core( + conn: &DatabaseConnection, + space_id: i32, + statuses: Option>, +) -> Result, AppCommandError> { + Ok(issue::list_issues(conn, space_id, statuses).await?) +} + +pub async fn get_loop_issue_core( + conn: &DatabaseConnection, + id: i32, +) -> Result, AppCommandError> { + Ok(issue::get_issue_detail(conn, id).await?) +} + +pub async fn create_loop_issue_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + space_id: i32, + title: String, + description: String, + priority: IssuePriority, + config: Option, +) -> Result { + let config = config.unwrap_or_default(); + let detail = issue::create_issue(conn, space_id, &title, &description, priority, &config).await?; + emit_loop_changed(emitter, space_id, Some(detail.row.id), "issue", detail.row.id, "created"); + Ok(detail) +} + +pub async fn delete_loop_issue_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + id: i32, +) -> Result<(), AppCommandError> { + let space_id = issue::get_issue(conn, id).await?.map(|i| i.space_id); + issue::delete_issue(conn, id).await?; + if let Some(space_id) = space_id { + emit_loop_changed(emitter, space_id, Some(id), "issue", id, "deleted"); + } + Ok(()) +} + +pub async fn update_loop_issue_config_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + id: i32, + config: IssueConfig, + token_budget: Option, +) -> Result<(), AppCommandError> { + let space_id = issue::get_issue(conn, id).await?.map(|i| i.space_id); + issue::update_issue_config(conn, id, &config, token_budget).await?; + if let Some(space_id) = space_id { + emit_loop_changed(emitter, space_id, Some(id), "issue", id, "updated"); + } + Ok(()) +} + +// ─── Artifacts / DAG ─────────────────────────────────────────────────────── + +pub async fn get_loop_dag_core( + conn: &DatabaseConnection, + issue_id: i32, +) -> Result { + Ok(artifact::list_dag(conn, issue_id).await?) +} + +pub async fn list_loop_artifacts_core( + conn: &DatabaseConnection, + space_id: i32, +) -> Result, AppCommandError> { + Ok(artifact::list_artifacts_for_space(conn, space_id).await?) +} + +pub async fn get_loop_artifact_core( + conn: &DatabaseConnection, + id: i32, +) -> Result, AppCommandError> { + Ok(artifact::get_artifact_detail(conn, id).await?) +} + +// ─── Iterations ──────────────────────────────────────────────────────────── + +pub async fn list_loop_iterations_core( + conn: &DatabaseConnection, + space_id: i32, + issue_id: Option, +) -> Result, AppCommandError> { + Ok(match issue_id { + Some(issue_id) => iteration::list_iterations(conn, issue_id).await?, + None => iteration::list_iterations_for_space(conn, space_id).await?, + }) +} + +// ─── Inbox ───────────────────────────────────────────────────────────────── + +pub async fn list_loop_inbox_core( + conn: &DatabaseConnection, + space_id: i32, + status: Option, +) -> Result, AppCommandError> { + Ok(inbox::list_inbox(conn, space_id, status).await?) +} + +// ─── Memory ──────────────────────────────────────────────────────────────── + +pub async fn list_loop_memory_core( + conn: &DatabaseConnection, + space_id: i32, +) -> Result, AppCommandError> { + Ok(memory::list_memory(conn, space_id).await?) +} + +pub async fn create_loop_memory_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + space_id: i32, + kind: MemoryKind, + title: String, + content: String, +) -> Result { + let m = memory::create_memory(conn, space_id, kind, ActorKind::Human, &title, &content).await?; + emit_loop_changed(emitter, space_id, None, "memory", m.id, "created"); + Ok(memory::to_row(m)) +} + +pub async fn update_loop_memory_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + space_id: i32, + id: i32, + title: String, + content: String, + status: MemoryStatus, +) -> Result<(), AppCommandError> { + memory::update_memory(conn, id, &title, &content, status).await?; + emit_loop_changed(emitter, space_id, None, "memory", id, "updated"); + Ok(()) +} + +pub async fn delete_loop_memory_core( + conn: &DatabaseConnection, + emitter: &EventEmitter, + space_id: i32, + id: i32, +) -> Result<(), AppCommandError> { + memory::delete_memory(conn, id).await?; + emit_loop_changed(emitter, space_id, None, "memory", id, "deleted"); + Ok(()) +} + +// ─── Tauri command wrappers (desktop) ────────────────────────────────────── + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_loop_spaces( + db: tauri::State<'_, AppDatabase>, +) -> Result, AppCommandError> { + list_loop_spaces_core(&db.conn).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn create_loop_space( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + name: String, + folder_id: i32, +) -> Result { + create_loop_space_core(&db.conn, &EventEmitter::Tauri(app), name, folder_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn update_loop_space( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, + name: String, +) -> Result { + update_loop_space_core(&db.conn, &EventEmitter::Tauri(app), id, name).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn delete_loop_space( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result<(), AppCommandError> { + delete_loop_space_core(&db.conn, &EventEmitter::Tauri(app), id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_loop_issues( + db: tauri::State<'_, AppDatabase>, + space_id: i32, + statuses: Option>, +) -> Result, AppCommandError> { + list_loop_issues_core(&db.conn, space_id, statuses).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_loop_issue( + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result, AppCommandError> { + get_loop_issue_core(&db.conn, id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn create_loop_issue( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + space_id: i32, + title: String, + description: String, + priority: IssuePriority, + config: Option, +) -> Result { + create_loop_issue_core( + &db.conn, + &EventEmitter::Tauri(app), + space_id, + title, + description, + priority, + config, + ) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn delete_loop_issue( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result<(), AppCommandError> { + delete_loop_issue_core(&db.conn, &EventEmitter::Tauri(app), id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn update_loop_issue_config( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + id: i32, + config: IssueConfig, + token_budget: Option, +) -> Result<(), AppCommandError> { + update_loop_issue_config_core(&db.conn, &EventEmitter::Tauri(app), id, config, token_budget) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_loop_dag( + db: tauri::State<'_, AppDatabase>, + issue_id: i32, +) -> Result { + get_loop_dag_core(&db.conn, issue_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_loop_artifacts( + db: tauri::State<'_, AppDatabase>, + space_id: i32, +) -> Result, AppCommandError> { + list_loop_artifacts_core(&db.conn, space_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn get_loop_artifact( + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result, AppCommandError> { + get_loop_artifact_core(&db.conn, id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_loop_iterations( + db: tauri::State<'_, AppDatabase>, + space_id: i32, + issue_id: Option, +) -> Result, AppCommandError> { + list_loop_iterations_core(&db.conn, space_id, issue_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_loop_inbox( + db: tauri::State<'_, AppDatabase>, + space_id: i32, + status: Option, +) -> Result, AppCommandError> { + list_loop_inbox_core(&db.conn, space_id, status).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn list_loop_memory( + db: tauri::State<'_, AppDatabase>, + space_id: i32, +) -> Result, AppCommandError> { + list_loop_memory_core(&db.conn, space_id).await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn create_loop_memory( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + space_id: i32, + kind: MemoryKind, + title: String, + content: String, +) -> Result { + create_loop_memory_core(&db.conn, &EventEmitter::Tauri(app), space_id, kind, title, content) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn update_loop_memory( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + space_id: i32, + id: i32, + title: String, + content: String, + status: MemoryStatus, +) -> Result<(), AppCommandError> { + update_loop_memory_core( + &db.conn, + &EventEmitter::Tauri(app), + space_id, + id, + title, + content, + status, + ) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn delete_loop_memory( + app: tauri::AppHandle, + db: tauri::State<'_, AppDatabase>, + space_id: i32, + id: i32, +) -> Result<(), AppCommandError> { + delete_loop_memory_core(&db.conn, &EventEmitter::Tauri(app), space_id, id).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0b30309dcd..3403c37fc3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod feedback; pub mod file_io; pub mod folder_commands; pub mod folders; +pub mod loops; pub mod mcp; pub mod model_provider; #[cfg(feature = "tauri-runtime")] diff --git a/src-tauri/src/db/service/loop_service/memory.rs b/src-tauri/src/db/service/loop_service/memory.rs index dd60b1d5a0..68d06f4fff 100644 --- a/src-tauri/src/db/service/loop_service/memory.rs +++ b/src-tauri/src/db/service/loop_service/memory.rs @@ -9,7 +9,7 @@ use crate::db::entities::loop_memory::{self, MemoryKind, MemoryStatus}; use crate::db::error::DbError; use crate::models::loops::LoopMemoryRow; -fn to_row(m: loop_memory::Model) -> LoopMemoryRow { +pub fn to_row(m: loop_memory::Model) -> LoopMemoryRow { LoopMemoryRow { id: m.id, kind: m.kind, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index efb3bd39c1..ca7900a082 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -47,7 +47,7 @@ mod tauri_app { acp as acp_commands, app_update as app_update_commands, backup, chat_channel as chat_channel_commands, conversations, delegation as delegation_commands, experts as experts_commands, feedback as feedback_commands, file_io, folder_commands, - folders, mcp as mcp_commands, + folders, loops as loops_commands, mcp as mcp_commands, model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, question as question_commands, quick_messages as quick_messages_commands, remote_proxy as remote_proxy_commands, @@ -998,6 +998,24 @@ mod tauri_app { quick_messages_commands::quick_messages_update, quick_messages_commands::quick_messages_delete, quick_messages_commands::quick_messages_reorder, + loops_commands::list_loop_spaces, + loops_commands::create_loop_space, + loops_commands::update_loop_space, + loops_commands::delete_loop_space, + loops_commands::list_loop_issues, + loops_commands::get_loop_issue, + loops_commands::create_loop_issue, + loops_commands::delete_loop_issue, + loops_commands::update_loop_issue_config, + loops_commands::get_loop_dag, + loops_commands::list_loop_artifacts, + loops_commands::get_loop_artifact, + loops_commands::list_loop_iterations, + loops_commands::list_loop_inbox, + loops_commands::list_loop_memory, + loops_commands::create_loop_memory, + loops_commands::update_loop_memory, + loops_commands::delete_loop_memory, terminal_commands::terminal_spawn, terminal_commands::terminal_write, terminal_commands::terminal_resize, diff --git a/src-tauri/src/web/handlers/loops.rs b/src-tauri/src/web/handlers/loops.rs new file mode 100644 index 0000000000..0f8289d394 --- /dev/null +++ b/src-tauri/src/web/handlers/loops.rs @@ -0,0 +1,305 @@ +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::loops as core; +use crate::db::entities::loop_inbox_item::InboxStatus; +use crate::db::entities::loop_issue::{IssuePriority, IssueStatus}; +use crate::db::entities::loop_memory::{MemoryKind, MemoryStatus}; +use crate::models::loops::{ + IssueConfig, LoopArtifactDetail, LoopArtifactRow, LoopDagView, LoopInboxItemRow, + LoopIssueDetail, LoopIssueRow, LoopIterationRow, LoopMemoryRow, LoopSpaceSummary, +}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IdParam { + pub id: i32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpaceIdParam { + pub space_id: i32, +} + +// ─── Spaces ────────────────────────────────────────────────────────────── + +pub async fn list_loop_spaces( + Extension(state): Extension>, +) -> Result>, AppCommandError> { + Ok(Json(core::list_loop_spaces_core(&state.db.conn).await?)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateSpaceParams { + pub name: String, + pub folder_id: i32, +} + +pub async fn create_loop_space( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::create_loop_space_core(&state.db.conn, &state.emitter, p.name, p.folder_id).await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSpaceParams { + pub id: i32, + pub name: String, +} + +pub async fn update_loop_space( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::update_loop_space_core(&state.db.conn, &state.emitter, p.id, p.name).await?, + )) +} + +pub async fn delete_loop_space( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::delete_loop_space_core(&state.db.conn, &state.emitter, p.id).await?; + Ok(Json(())) +} + +// ─── Issues ────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListIssuesParams { + pub space_id: i32, + pub statuses: Option>, +} + +pub async fn list_loop_issues( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::list_loop_issues_core(&state.db.conn, p.space_id, p.statuses).await?, + )) +} + +pub async fn get_loop_issue( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json(core::get_loop_issue_core(&state.db.conn, p.id).await?)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateIssueParams { + pub space_id: i32, + pub title: String, + pub description: String, + pub priority: IssuePriority, + pub config: Option, +} + +pub async fn create_loop_issue( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::create_loop_issue_core( + &state.db.conn, + &state.emitter, + p.space_id, + p.title, + p.description, + p.priority, + p.config, + ) + .await?, + )) +} + +pub async fn delete_loop_issue( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::delete_loop_issue_core(&state.db.conn, &state.emitter, p.id).await?; + Ok(Json(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateIssueConfigParams { + pub id: i32, + pub config: IssueConfig, + pub token_budget: Option, +} + +pub async fn update_loop_issue_config( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::update_loop_issue_config_core( + &state.db.conn, + &state.emitter, + p.id, + p.config, + p.token_budget, + ) + .await?; + Ok(Json(())) +} + +// ─── Artifacts / DAG ─────────────────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueIdParam { + pub issue_id: i32, +} + +pub async fn get_loop_dag( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + Ok(Json(core::get_loop_dag_core(&state.db.conn, p.issue_id).await?)) +} + +pub async fn list_loop_artifacts( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::list_loop_artifacts_core(&state.db.conn, p.space_id).await?, + )) +} + +pub async fn get_loop_artifact( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json(core::get_loop_artifact_core(&state.db.conn, p.id).await?)) +} + +// ─── Iterations ──────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListIterationsParams { + pub space_id: i32, + pub issue_id: Option, +} + +pub async fn list_loop_iterations( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::list_loop_iterations_core(&state.db.conn, p.space_id, p.issue_id).await?, + )) +} + +// ─── Inbox ───────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListInboxParams { + pub space_id: i32, + pub status: Option, +} + +pub async fn list_loop_inbox( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::list_loop_inbox_core(&state.db.conn, p.space_id, p.status).await?, + )) +} + +// ─── Memory ──────────────────────────────────────────────────────────────── + +pub async fn list_loop_memory( + Extension(state): Extension>, + Json(p): Json, +) -> Result>, AppCommandError> { + Ok(Json( + core::list_loop_memory_core(&state.db.conn, p.space_id).await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateMemoryParams { + pub space_id: i32, + pub kind: MemoryKind, + pub title: String, + pub content: String, +} + +pub async fn create_loop_memory( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + Ok(Json( + core::create_loop_memory_core( + &state.db.conn, + &state.emitter, + p.space_id, + p.kind, + p.title, + p.content, + ) + .await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMemoryParams { + pub space_id: i32, + pub id: i32, + pub title: String, + pub content: String, + pub status: MemoryStatus, +} + +pub async fn update_loop_memory( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::update_loop_memory_core( + &state.db.conn, + &state.emitter, + p.space_id, + p.id, + p.title, + p.content, + p.status, + ) + .await?; + Ok(Json(())) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteMemoryParams { + pub space_id: i32, + pub id: i32, +} + +pub async fn delete_loop_memory( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + core::delete_loop_memory_core(&state.db.conn, &state.emitter, p.space_id, p.id).await?; + Ok(Json(())) +} diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 163023d41d..f18f683ddc 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -12,6 +12,7 @@ pub mod files; pub mod folder_commands; pub mod folders; pub mod git; +pub mod loops; pub mod mcp; pub mod model_provider; pub mod pet; diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index c47432ee61..ab1223c4ed 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -56,6 +56,34 @@ pub fn build_router( "/list_child_conversations", post(handlers::conversations::list_child_conversations), ) + // ─── Loop engineering ─── + .route("/list_loop_spaces", post(handlers::loops::list_loop_spaces)) + .route("/create_loop_space", post(handlers::loops::create_loop_space)) + .route("/update_loop_space", post(handlers::loops::update_loop_space)) + .route("/delete_loop_space", post(handlers::loops::delete_loop_space)) + .route("/list_loop_issues", post(handlers::loops::list_loop_issues)) + .route("/get_loop_issue", post(handlers::loops::get_loop_issue)) + .route("/create_loop_issue", post(handlers::loops::create_loop_issue)) + .route("/delete_loop_issue", post(handlers::loops::delete_loop_issue)) + .route( + "/update_loop_issue_config", + post(handlers::loops::update_loop_issue_config), + ) + .route("/get_loop_dag", post(handlers::loops::get_loop_dag)) + .route( + "/list_loop_artifacts", + post(handlers::loops::list_loop_artifacts), + ) + .route("/get_loop_artifact", post(handlers::loops::get_loop_artifact)) + .route( + "/list_loop_iterations", + post(handlers::loops::list_loop_iterations), + ) + .route("/list_loop_inbox", post(handlers::loops::list_loop_inbox)) + .route("/list_loop_memory", post(handlers::loops::list_loop_memory)) + .route("/create_loop_memory", post(handlers::loops::create_loop_memory)) + .route("/update_loop_memory", post(handlers::loops::update_loop_memory)) + .route("/delete_loop_memory", post(handlers::loops::delete_loop_memory)) .route( "/get_delegation_settings", post(handlers::delegation::get_delegation_settings), From f28825c00fc4fc792af663ae900eebdecf5cbf13 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 13 Jun 2026 14:50:02 +0800 Subject: [PATCH 009/157] feat(loop): add frontend types, API client and view context --- src/contexts/loops-view-context.test.tsx | 57 ++++++ src/contexts/loops-view-context.tsx | 84 +++++++++ src/hooks/use-loop-changed.ts | 33 ++++ src/lib/loops-api.ts | 149 ++++++++++++++++ src/lib/types.ts | 210 ++++++++++++++++++++++- 5 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 src/contexts/loops-view-context.test.tsx create mode 100644 src/contexts/loops-view-context.tsx create mode 100644 src/hooks/use-loop-changed.ts create mode 100644 src/lib/loops-api.ts diff --git a/src/contexts/loops-view-context.test.tsx b/src/contexts/loops-view-context.test.tsx new file mode 100644 index 0000000000..6799cb54b9 --- /dev/null +++ b/src/contexts/loops-view-context.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { useState } from "react" +import { beforeEach, describe, expect, it } from "vitest" + +import { LoopsViewProvider, useLoopsView } from "@/contexts/loops-view-context" + +function Probe() { + const { view, setView } = useLoopsView() + return ( +
+ {view} + +
+ ) +} + +function Harness({ initialTab }: { initialTab: string | null }) { + const [tab, setTab] = useState(initialTab) + return ( + + + + + ) +} + +beforeEach(() => { + window.localStorage.clear() +}) + +describe("LoopsViewProvider", () => { + it("defaults to chat", () => { + render() + expect(screen.getByTestId("view")).toHaveTextContent("chat") + }) + + it("switches to loops and persists the choice", () => { + render() + fireEvent.click(screen.getByText("to-loops")) + expect(screen.getByTestId("view")).toHaveTextContent("loops") + expect(window.localStorage.getItem("codeg:loops-view:v1")).toBe("loops") + }) + + it("hydrates the loops view from storage on mount", () => { + window.localStorage.setItem("codeg:loops-view:v1", "loops") + render() + expect(screen.getByTestId("view")).toHaveTextContent("loops") + }) + + it("flips back to chat when the active tab changes", () => { + window.localStorage.setItem("codeg:loops-view:v1", "loops") + render() + expect(screen.getByTestId("view")).toHaveTextContent("loops") + fireEvent.click(screen.getByText("change-tab")) + expect(screen.getByTestId("view")).toHaveTextContent("chat") + }) +}) diff --git a/src/contexts/loops-view-context.tsx b/src/contexts/loops-view-context.tsx new file mode 100644 index 0000000000..b7c7bb6fd9 --- /dev/null +++ b/src/contexts/loops-view-context.tsx @@ -0,0 +1,84 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react" + +export type LoopsView = "chat" | "loops" + +const STORAGE_KEY = "codeg:loops-view:v1" + +interface LoopsViewContextValue { + view: LoopsView + setView: (view: LoopsView) => void +} + +const LoopsViewContext = createContext(null) + +function readStored(): LoopsView { + if (typeof window === "undefined") return "chat" + try { + return window.localStorage.getItem(STORAGE_KEY) === "loops" ? "loops" : "chat" + } catch { + return "chat" + } +} + +/** + * Holds which workspace surface is showing — the normal chat workspace or the + * loop engineering workbench. The choice persists in localStorage. Selecting a + * chat tab (i.e. `activeTabId` changing after hydration) flips back to chat, + * since that gesture means the user wants the chat surface. + */ +export function LoopsViewProvider({ + activeTabId, + children, +}: { + activeTabId: string | null + children: ReactNode +}) { + const [view, setViewState] = useState(readStored) + const prevTabRef = useRef(undefined) + + const setView = useCallback((next: LoopsView) => { + setViewState(next) + try { + window.localStorage.setItem(STORAGE_KEY, next) + } catch { + // ignore (private mode / unavailable storage) + } + }, []) + + useEffect(() => { + // Skip the initial hydration pass so a restored "loops" view survives mount. + if (prevTabRef.current === undefined) { + prevTabRef.current = activeTabId + return + } + if (prevTabRef.current !== activeTabId) { + prevTabRef.current = activeTabId + // eslint-disable-next-line react-hooks/set-state-in-effect -- flip to chat in response to a tab change + setView("chat") + } + }, [activeTabId, setView]) + + return ( + + {children} + + ) +} + +export function useLoopsView(): LoopsViewContextValue { + const ctx = useContext(LoopsViewContext) + if (!ctx) { + throw new Error("useLoopsView must be used within a LoopsViewProvider") + } + return ctx +} diff --git a/src/hooks/use-loop-changed.ts b/src/hooks/use-loop-changed.ts new file mode 100644 index 0000000000..b72ffa2ea5 --- /dev/null +++ b/src/hooks/use-loop-changed.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef } from "react" + +import { subscribe } from "@/lib/platform" +import { LOOP_CHANGED_EVENT, type LoopChanged } from "@/lib/types" + +/** + * Subscribe to the coarse `loop://changed` event. When `spaceId` is given, only + * events for that space invoke `cb`. The callback is held in a ref so the + * subscription is set up once and never re-attaches on every render. + */ +export function useLoopChanged(cb: (event: LoopChanged) => void, spaceId?: number) { + const cbRef = useRef(cb) + cbRef.current = cb + + useEffect(() => { + let disposed = false + let unsub: (() => void) | undefined + + subscribe(LOOP_CHANGED_EVENT, (event) => { + if (disposed) return + if (spaceId != null && event.space_id !== spaceId) return + cbRef.current(event) + }).then((fn) => { + if (disposed) fn() + else unsub = fn + }) + + return () => { + disposed = true + unsub?.() + } + }, [spaceId]) +} diff --git a/src/lib/loops-api.ts b/src/lib/loops-api.ts new file mode 100644 index 0000000000..7728993dad --- /dev/null +++ b/src/lib/loops-api.ts @@ -0,0 +1,149 @@ +import { getTransport } from "./transport" +import type { + IssueConfig, + LoopArtifactDetail, + LoopArtifactRow, + LoopDagView, + LoopInboxItemRow, + LoopInboxStatus, + LoopIssueDetail, + LoopIssuePriority, + LoopIssueRow, + LoopIssueStatus, + LoopIterationRow, + LoopMemoryKind, + LoopMemoryRow, + LoopMemoryStatus, + LoopSpaceSummary, +} from "./types" + +// ─── Spaces ────────────────────────────────────────────────────────────── + +export function listLoopSpaces() { + return getTransport().call("list_loop_spaces", {}) +} + +export function createLoopSpace(name: string, folderId: number) { + return getTransport().call("create_loop_space", { + name, + folderId, + }) +} + +export function updateLoopSpace(id: number, name: string) { + return getTransport().call("update_loop_space", { id, name }) +} + +export function deleteLoopSpace(id: number) { + return getTransport().call("delete_loop_space", { id }) +} + +// ─── Issues ────────────────────────────────────────────────────────────── + +export function listLoopIssues(spaceId: number, statuses?: LoopIssueStatus[]) { + return getTransport().call("list_loop_issues", { + spaceId, + statuses: statuses ?? null, + }) +} + +export function getLoopIssue(id: number) { + return getTransport().call("get_loop_issue", { id }) +} + +export function createLoopIssue(params: { + spaceId: number + title: string + description: string + priority: LoopIssuePriority + config?: IssueConfig +}) { + return getTransport().call("create_loop_issue", { + spaceId: params.spaceId, + title: params.title, + description: params.description, + priority: params.priority, + config: params.config ?? null, + }) +} + +export function deleteLoopIssue(id: number) { + return getTransport().call("delete_loop_issue", { id }) +} + +export function updateLoopIssueConfig( + id: number, + config: IssueConfig, + tokenBudget: number | null +) { + return getTransport().call("update_loop_issue_config", { + id, + config, + tokenBudget, + }) +} + +// ─── Artifacts / DAG ─────────────────────────────────────────────────────── + +export function getLoopDag(issueId: number) { + return getTransport().call("get_loop_dag", { issueId }) +} + +export function listLoopArtifacts(spaceId: number) { + return getTransport().call("list_loop_artifacts", { + spaceId, + }) +} + +export function getLoopArtifact(id: number) { + return getTransport().call("get_loop_artifact", { + id, + }) +} + +// ─── Iterations ──────────────────────────────────────────────────────────── + +export function listLoopIterations(spaceId: number, issueId?: number) { + return getTransport().call("list_loop_iterations", { + spaceId, + issueId: issueId ?? null, + }) +} + +// ─── Inbox ───────────────────────────────────────────────────────────────── + +export function listLoopInbox(spaceId: number, status?: LoopInboxStatus) { + return getTransport().call("list_loop_inbox", { + spaceId, + status: status ?? null, + }) +} + +// ─── Memory ──────────────────────────────────────────────────────────────── + +export function listLoopMemory(spaceId: number) { + return getTransport().call("list_loop_memory", { spaceId }) +} + +export function createLoopMemory(params: { + spaceId: number + kind: LoopMemoryKind + title: string + content: string +}) { + return getTransport().call("create_loop_memory", params) +} + +export function updateLoopMemory(params: { + spaceId: number + id: number + title: string + content: string + status: LoopMemoryStatus +}) { + return getTransport().call("update_loop_memory", params) +} + +export function deleteLoopMemory(spaceId: number, id: number) { + return getTransport().call("delete_loop_memory", { spaceId, id }) +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 495b812252..606d3b2b01 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -395,8 +395,214 @@ export type ConversationStatus = export type ConversationKind = "regular" | "chat" | "loop" | "delegate" /** Mirrors Rust `FolderKind` (src-tauri/src/db/entities/folder.rs). - * `loop_worktree` is reserved for M2+ — add it here when the variant lands. */ -export type FolderKind = "regular" | "chat" + * `loop_worktree` folders back per-issue engine worktrees (hidden like chat). */ +export type FolderKind = "regular" | "chat" | "loop_worktree" + +// ─── Loop engineering (mirrors src-tauri/src/models/loops.rs) ─────────────── + +export const LOOP_CHANGED_EVENT = "loop://changed" + +export type LoopIssuePriority = "high" | "medium" | "low" +export type LoopIssueStatus = + | "pending" + | "running" + | "paused" + | "blocked" + | "done" + | "cancelled" +export type LoopPauseReason = "manual" | "budget" +export type LoopIssueRoute = "undecided" | "full" | "skip_design" | "direct" +export type LoopActorKind = "human" | "agent" +export type LoopLaunchedBy = "engine" | "human" +export type LoopStage = + | "triage" + | "refine" + | "design" + | "plan" + | "implement" + | "review" + | "finalize" +export type LoopIterationStatus = + | "queued" + | "running" + | "succeeded" + | "failed" + | "interrupted" + | "cancelled" +export type LoopArtifactKind = + | "issue" + | "requirement" + | "design" + | "task" + | "review" + | "result" +export type LoopArtifactStatus = + | "pending" + | "in_progress" + | "awaiting_approval" + | "done" + | "blocked" + | "superseded" + | "cancelled" +export type LoopReviewVerdict = "pass" | "fail" +export type LoopLinkKind = "derives_from" | "skips_to" | "reviews" | "results_from" +export type LoopInboxKind = "approval" | "blocked" | "budget_exhausted" | "question" +export type LoopInboxStatus = "pending" | "handled" +export type LoopMemoryKind = + | "constitution" + | "constraint" + | "decision" + | "preference" + | "pitfall" +export type LoopMemoryStatus = "active" | "archived" + +export interface IssueConfig { + v: number + agents: Record + validation_commands: string[] + reviewer_count: number + review_pass_rule: string + max_attempts: number + auto_merge: boolean + force_route: LoopIssueRoute | null + iteration_timeout_secs: number | null + token_budget_per_turn: number | null +} + +export interface LoopSpaceSummary { + id: number + name: string + folder_id: number + folder_path: string | null + detached: boolean + issue_count: number + running_count: number + last_activity_at: string | null + created_at: string +} + +export interface LoopIssueRow { + id: number + space_id: number + seq_no: number + title: string + priority: LoopIssuePriority + status: LoopIssueStatus + pause_reason: LoopPauseReason | null + route: LoopIssueRoute + token_used: number + token_budget: number | null + created_at: string + updated_at: string +} + +/** Rust flattens `row` into the detail, so the row fields appear inline. */ +export interface LoopIssueDetail extends LoopIssueRow { + description: string + config: IssueConfig + worktree_folder_id: number | null + base_branch: string | null + base_commit: string | null + active_task_artifact_id: number | null +} + +export interface LoopArtifactRow { + id: number + issue_id: number + issue_seq: number + kind: LoopArtifactKind + title: string + status: LoopArtifactStatus + origin: LoopActorKind + produced_by_iteration_id: number | null + verdict: LoopReviewVerdict | null + attempt: number + sort: number + updated_at: string +} + +export interface LoopRevision { + id: number + seq: number + content: string + actor_kind: LoopActorKind + iteration_id: number | null + created_at: string +} + +export interface LoopCriterionRow { + id: number + label: string + text: string + sort: number +} + +export interface LoopLinkRow { + id: number + from_artifact_id: number + to_artifact_id: number + kind: LoopLinkKind +} + +export interface LoopArtifactDetail extends LoopArtifactRow { + revisions: LoopRevision[] + criteria: LoopCriterionRow[] + links: LoopLinkRow[] +} + +export interface LoopDagView { + artifacts: LoopArtifactRow[] + links: LoopLinkRow[] +} + +export interface LoopIterationRow { + id: number + issue_id: number + issue_seq: number + stage: LoopStage + target_artifact_id: number | null + target_title: string | null + conversation_id: number | null + status: LoopIterationStatus + launched_by: LoopLaunchedBy + attempt: number + tokens_used: number + created_at: string + started_at: string | null + ended_at: string | null +} + +export interface LoopInboxItemRow { + id: number + issue_id: number + issue_seq: number + iteration_id: number | null + kind: LoopInboxKind + subject_key: string + payload: unknown + status: LoopInboxStatus + created_at: string +} + +export interface LoopMemoryRow { + id: number + kind: LoopMemoryKind + source: LoopActorKind + title: string + content: string + status: LoopMemoryStatus + created_at: string + updated_at: string +} + +export interface LoopChanged { + v: number + space_id: number + issue_id: number | null + subject_kind: string + subject_id: number + kind: string +} export const STATUS_ORDER: ConversationStatus[] = [ "in_progress", From 48a1426a3ea49772d5475ec37bfe7a44d099eeed Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 13 Jun 2026 16:41:12 +0800 Subject: [PATCH 010/157] feat(i18n): add loop engineering base strings --- src/i18n/messages/ar.json | 113 ++++++++++++++++++++++++++++++++++- src/i18n/messages/de.json | 113 ++++++++++++++++++++++++++++++++++- src/i18n/messages/en.json | 113 ++++++++++++++++++++++++++++++++++- src/i18n/messages/es.json | 113 ++++++++++++++++++++++++++++++++++- src/i18n/messages/fr.json | 113 ++++++++++++++++++++++++++++++++++- src/i18n/messages/ja.json | 113 ++++++++++++++++++++++++++++++++++- src/i18n/messages/ko.json | 113 ++++++++++++++++++++++++++++++++++- src/i18n/messages/pt.json | 113 ++++++++++++++++++++++++++++++++++- src/i18n/messages/zh-CN.json | 113 ++++++++++++++++++++++++++++++++++- src/i18n/messages/zh-TW.json | 113 ++++++++++++++++++++++++++++++++++- 10 files changed, 1120 insertions(+), 10 deletions(-) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index e265647e51..e4002ae478 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1004,7 +1004,8 @@ "sectionFolders": "المجلدات", "sectionChats": "محادثة", "noChats": "لا توجد محادثات", - "newChatAction": "محادثة جديدة" + "newChatAction": "محادثة جديدة", + "loops": "Loop Engineering" }, "conversation": { "reloadFailed": "فشل إعادة تحميل المحادثة: {message}", @@ -2895,5 +2896,115 @@ "saved": "تم حفظ إعدادات السؤال", "saveFailed": "فشل حفظ إعدادات السؤال", "loadFailed": "فشل التحميل: {detail}" + }, + "Loops": { + "workbench": { + "title": "Loop Engineering", + "subtitle": "Create issues — the engine drives requirements, design, implementation, validation and review automatically.", + "newSpace": "New space", + "empty": "No loop spaces yet. Create one bound to a git repository to get started.", + "spaceIssues": "{count, plural, one {# issue} other {# issues}}", + "spaceRunning": "{count} running", + "detached": "Folder missing", + "rename": "Rename", + "deleteSpace": "Delete", + "confirmDeleteTitle": "Delete loop space?", + "confirmDeleteDescription": "Delete “{name}”? Its issues, artifacts and memory are permanently removed. This cannot be undone.", + "loadFailed": "Failed to load spaces: {message}" + }, + "spaceForm": { + "createTitle": "New loop space", + "editTitle": "Rename loop space", + "nameLabel": "Name", + "namePlaceholder": "e.g. Payments revamp", + "folderLabel": "Repository folder", + "folderHint": "Must be a git repository.", + "chooseFolder": "Choose folder…", + "noFolder": "No folder selected", + "create": "Create", + "save": "Save", + "cancel": "Cancel" + }, + "spaceDetail": { + "back": "All spaces", + "tabIssues": "Issues", + "tabIterations": "Iterations", + "tabArtifacts": "Artifacts", + "tabInbox": "Inbox", + "tabMemory": "Memory", + "comingSoon": "Coming soon." + }, + "issueList": { + "title": "Issues", + "newIssue": "New issue", + "empty": "No issues. Create one — it stays Pending until you trigger it.", + "filterStatus": "Status", + "filterAll": "All", + "deleteIssue": "Delete", + "confirmDeleteTitle": "Delete issue?", + "confirmDeleteDescription": "This permanently deletes the issue and its artifacts. This cannot be undone.", + "trigger": "Trigger", + "triggerComingSoon": "The engine connects in the next phase.", + "pause": "Pause", + "resume": "Resume" + }, + "issueForm": { + "createTitle": "New issue", + "titleLabel": "Title", + "titlePlaceholder": "What should the loop accomplish?", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Goal, scope, constraints, acceptance criteria…", + "priorityLabel": "Priority", + "create": "Create", + "cancel": "Cancel" + }, + "issueDetail": { + "selectPrompt": "Select an issue to see its loop.", + "tokenUsage": "Tokens", + "tokenWithBudget": "{used} / {budget}", + "settings": "Settings", + "subtabGraph": "Graph", + "subtabBoard": "Board", + "subtabIterations": "Iterations", + "subtabArtifacts": "Artifacts", + "graphPlaceholder": "The DAG expands here once the issue is triggered.", + "boardPlaceholder": "A read-only board view arrives with the engine.", + "rootArtifact": "Issue", + "noIterations": "No iterations yet.", + "noArtifacts": "Only the root issue artifact so far." + }, + "status": { + "pending": "Pending", + "running": "Running", + "paused": "Paused", + "blocked": "Blocked", + "done": "Done", + "cancelled": "Cancelled" + }, + "priority": { + "high": "High", + "medium": "Medium", + "low": "Low" + }, + "route": { + "undecided": "Undecided", + "full": "Full", + "skip_design": "Skip design", + "direct": "Direct" + }, + "toasts": { + "spaceCreated": "Created space {name}", + "spaceCreateFailed": "Failed to create space: {message}", + "spaceUpdateFailed": "Failed to rename space: {message}", + "spaceDeleteFailed": "Failed to delete space: {message}", + "notGitRepo": "That folder is not a git repository. Choose a git repo or run git init first.", + "issueCreated": "Created issue {title}", + "issueCreateFailed": "Failed to create issue: {message}", + "issueDeleteFailed": "Failed to delete issue: {message}" + }, + "common": { + "cancel": "Cancel", + "delete": "Delete" + } } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 9afff4befe..be561d3268 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1004,7 +1004,8 @@ "sectionFolders": "Ordner", "sectionChats": "Chat", "noChats": "Keine Chats", - "newChatAction": "Neuer Chat" + "newChatAction": "Neuer Chat", + "loops": "Loop Engineering" }, "conversation": { "reloadFailed": "Konversation konnte nicht neu geladen werden: {message}", @@ -2895,5 +2896,115 @@ "saved": "Frageeinstellungen gespeichert", "saveFailed": "Frageeinstellungen konnten nicht gespeichert werden", "loadFailed": "Laden fehlgeschlagen: {detail}" + }, + "Loops": { + "workbench": { + "title": "Loop Engineering", + "subtitle": "Create issues — the engine drives requirements, design, implementation, validation and review automatically.", + "newSpace": "New space", + "empty": "No loop spaces yet. Create one bound to a git repository to get started.", + "spaceIssues": "{count, plural, one {# issue} other {# issues}}", + "spaceRunning": "{count} running", + "detached": "Folder missing", + "rename": "Rename", + "deleteSpace": "Delete", + "confirmDeleteTitle": "Delete loop space?", + "confirmDeleteDescription": "Delete “{name}”? Its issues, artifacts and memory are permanently removed. This cannot be undone.", + "loadFailed": "Failed to load spaces: {message}" + }, + "spaceForm": { + "createTitle": "New loop space", + "editTitle": "Rename loop space", + "nameLabel": "Name", + "namePlaceholder": "e.g. Payments revamp", + "folderLabel": "Repository folder", + "folderHint": "Must be a git repository.", + "chooseFolder": "Choose folder…", + "noFolder": "No folder selected", + "create": "Create", + "save": "Save", + "cancel": "Cancel" + }, + "spaceDetail": { + "back": "All spaces", + "tabIssues": "Issues", + "tabIterations": "Iterations", + "tabArtifacts": "Artifacts", + "tabInbox": "Inbox", + "tabMemory": "Memory", + "comingSoon": "Coming soon." + }, + "issueList": { + "title": "Issues", + "newIssue": "New issue", + "empty": "No issues. Create one — it stays Pending until you trigger it.", + "filterStatus": "Status", + "filterAll": "All", + "deleteIssue": "Delete", + "confirmDeleteTitle": "Delete issue?", + "confirmDeleteDescription": "This permanently deletes the issue and its artifacts. This cannot be undone.", + "trigger": "Trigger", + "triggerComingSoon": "The engine connects in the next phase.", + "pause": "Pause", + "resume": "Resume" + }, + "issueForm": { + "createTitle": "New issue", + "titleLabel": "Title", + "titlePlaceholder": "What should the loop accomplish?", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Goal, scope, constraints, acceptance criteria…", + "priorityLabel": "Priority", + "create": "Create", + "cancel": "Cancel" + }, + "issueDetail": { + "selectPrompt": "Select an issue to see its loop.", + "tokenUsage": "Tokens", + "tokenWithBudget": "{used} / {budget}", + "settings": "Settings", + "subtabGraph": "Graph", + "subtabBoard": "Board", + "subtabIterations": "Iterations", + "subtabArtifacts": "Artifacts", + "graphPlaceholder": "The DAG expands here once the issue is triggered.", + "boardPlaceholder": "A read-only board view arrives with the engine.", + "rootArtifact": "Issue", + "noIterations": "No iterations yet.", + "noArtifacts": "Only the root issue artifact so far." + }, + "status": { + "pending": "Pending", + "running": "Running", + "paused": "Paused", + "blocked": "Blocked", + "done": "Done", + "cancelled": "Cancelled" + }, + "priority": { + "high": "High", + "medium": "Medium", + "low": "Low" + }, + "route": { + "undecided": "Undecided", + "full": "Full", + "skip_design": "Skip design", + "direct": "Direct" + }, + "toasts": { + "spaceCreated": "Created space {name}", + "spaceCreateFailed": "Failed to create space: {message}", + "spaceUpdateFailed": "Failed to rename space: {message}", + "spaceDeleteFailed": "Failed to delete space: {message}", + "notGitRepo": "That folder is not a git repository. Choose a git repo or run git init first.", + "issueCreated": "Created issue {title}", + "issueCreateFailed": "Failed to create issue: {message}", + "issueDeleteFailed": "Failed to delete issue: {message}" + }, + "common": { + "cancel": "Cancel", + "delete": "Delete" + } } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 3c7e6e43d0..ad29cfaef5 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1004,7 +1004,8 @@ "sectionFolders": "Folders", "sectionChats": "Chat", "noChats": "No chats", - "newChatAction": "New chat" + "newChatAction": "New chat", + "loops": "Loop Engineering" }, "conversation": { "reloadFailed": "Failed to reload conversation: {message}", @@ -2895,5 +2896,115 @@ "saved": "Question settings saved", "saveFailed": "Failed to save question settings", "loadFailed": "Failed to load: {detail}" + }, + "Loops": { + "workbench": { + "title": "Loop Engineering", + "subtitle": "Create issues — the engine drives requirements, design, implementation, validation and review automatically.", + "newSpace": "New space", + "empty": "No loop spaces yet. Create one bound to a git repository to get started.", + "spaceIssues": "{count, plural, one {# issue} other {# issues}}", + "spaceRunning": "{count} running", + "detached": "Folder missing", + "rename": "Rename", + "deleteSpace": "Delete", + "confirmDeleteTitle": "Delete loop space?", + "confirmDeleteDescription": "Delete “{name}”? Its issues, artifacts and memory are permanently removed. This cannot be undone.", + "loadFailed": "Failed to load spaces: {message}" + }, + "spaceForm": { + "createTitle": "New loop space", + "editTitle": "Rename loop space", + "nameLabel": "Name", + "namePlaceholder": "e.g. Payments revamp", + "folderLabel": "Repository folder", + "folderHint": "Must be a git repository.", + "chooseFolder": "Choose folder…", + "noFolder": "No folder selected", + "create": "Create", + "save": "Save", + "cancel": "Cancel" + }, + "spaceDetail": { + "back": "All spaces", + "tabIssues": "Issues", + "tabIterations": "Iterations", + "tabArtifacts": "Artifacts", + "tabInbox": "Inbox", + "tabMemory": "Memory", + "comingSoon": "Coming soon." + }, + "issueList": { + "title": "Issues", + "newIssue": "New issue", + "empty": "No issues. Create one — it stays Pending until you trigger it.", + "filterStatus": "Status", + "filterAll": "All", + "deleteIssue": "Delete", + "confirmDeleteTitle": "Delete issue?", + "confirmDeleteDescription": "This permanently deletes the issue and its artifacts. This cannot be undone.", + "trigger": "Trigger", + "triggerComingSoon": "The engine connects in the next phase.", + "pause": "Pause", + "resume": "Resume" + }, + "issueForm": { + "createTitle": "New issue", + "titleLabel": "Title", + "titlePlaceholder": "What should the loop accomplish?", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Goal, scope, constraints, acceptance criteria…", + "priorityLabel": "Priority", + "create": "Create", + "cancel": "Cancel" + }, + "issueDetail": { + "selectPrompt": "Select an issue to see its loop.", + "tokenUsage": "Tokens", + "tokenWithBudget": "{used} / {budget}", + "settings": "Settings", + "subtabGraph": "Graph", + "subtabBoard": "Board", + "subtabIterations": "Iterations", + "subtabArtifacts": "Artifacts", + "graphPlaceholder": "The DAG expands here once the issue is triggered.", + "boardPlaceholder": "A read-only board view arrives with the engine.", + "rootArtifact": "Issue", + "noIterations": "No iterations yet.", + "noArtifacts": "Only the root issue artifact so far." + }, + "status": { + "pending": "Pending", + "running": "Running", + "paused": "Paused", + "blocked": "Blocked", + "done": "Done", + "cancelled": "Cancelled" + }, + "priority": { + "high": "High", + "medium": "Medium", + "low": "Low" + }, + "route": { + "undecided": "Undecided", + "full": "Full", + "skip_design": "Skip design", + "direct": "Direct" + }, + "toasts": { + "spaceCreated": "Created space {name}", + "spaceCreateFailed": "Failed to create space: {message}", + "spaceUpdateFailed": "Failed to rename space: {message}", + "spaceDeleteFailed": "Failed to delete space: {message}", + "notGitRepo": "That folder is not a git repository. Choose a git repo or run git init first.", + "issueCreated": "Created issue {title}", + "issueCreateFailed": "Failed to create issue: {message}", + "issueDeleteFailed": "Failed to delete issue: {message}" + }, + "common": { + "cancel": "Cancel", + "delete": "Delete" + } } } diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index ee5c2ffc21..6213292d8a 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1004,7 +1004,8 @@ "sectionFolders": "Carpetas", "sectionChats": "Chat", "noChats": "Sin chats", - "newChatAction": "Nuevo chat" + "newChatAction": "Nuevo chat", + "loops": "Loop Engineering" }, "conversation": { "reloadFailed": "No se pudo recargar la conversación: {message}", @@ -2895,5 +2896,115 @@ "saved": "Ajustes de preguntas guardados", "saveFailed": "No se pudieron guardar los ajustes de preguntas", "loadFailed": "Error al cargar: {detail}" + }, + "Loops": { + "workbench": { + "title": "Loop Engineering", + "subtitle": "Create issues — the engine drives requirements, design, implementation, validation and review automatically.", + "newSpace": "New space", + "empty": "No loop spaces yet. Create one bound to a git repository to get started.", + "spaceIssues": "{count, plural, one {# issue} other {# issues}}", + "spaceRunning": "{count} running", + "detached": "Folder missing", + "rename": "Rename", + "deleteSpace": "Delete", + "confirmDeleteTitle": "Delete loop space?", + "confirmDeleteDescription": "Delete “{name}”? Its issues, artifacts and memory are permanently removed. This cannot be undone.", + "loadFailed": "Failed to load spaces: {message}" + }, + "spaceForm": { + "createTitle": "New loop space", + "editTitle": "Rename loop space", + "nameLabel": "Name", + "namePlaceholder": "e.g. Payments revamp", + "folderLabel": "Repository folder", + "folderHint": "Must be a git repository.", + "chooseFolder": "Choose folder…", + "noFolder": "No folder selected", + "create": "Create", + "save": "Save", + "cancel": "Cancel" + }, + "spaceDetail": { + "back": "All spaces", + "tabIssues": "Issues", + "tabIterations": "Iterations", + "tabArtifacts": "Artifacts", + "tabInbox": "Inbox", + "tabMemory": "Memory", + "comingSoon": "Coming soon." + }, + "issueList": { + "title": "Issues", + "newIssue": "New issue", + "empty": "No issues. Create one — it stays Pending until you trigger it.", + "filterStatus": "Status", + "filterAll": "All", + "deleteIssue": "Delete", + "confirmDeleteTitle": "Delete issue?", + "confirmDeleteDescription": "This permanently deletes the issue and its artifacts. This cannot be undone.", + "trigger": "Trigger", + "triggerComingSoon": "The engine connects in the next phase.", + "pause": "Pause", + "resume": "Resume" + }, + "issueForm": { + "createTitle": "New issue", + "titleLabel": "Title", + "titlePlaceholder": "What should the loop accomplish?", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Goal, scope, constraints, acceptance criteria…", + "priorityLabel": "Priority", + "create": "Create", + "cancel": "Cancel" + }, + "issueDetail": { + "selectPrompt": "Select an issue to see its loop.", + "tokenUsage": "Tokens", + "tokenWithBudget": "{used} / {budget}", + "settings": "Settings", + "subtabGraph": "Graph", + "subtabBoard": "Board", + "subtabIterations": "Iterations", + "subtabArtifacts": "Artifacts", + "graphPlaceholder": "The DAG expands here once the issue is triggered.", + "boardPlaceholder": "A read-only board view arrives with the engine.", + "rootArtifact": "Issue", + "noIterations": "No iterations yet.", + "noArtifacts": "Only the root issue artifact so far." + }, + "status": { + "pending": "Pending", + "running": "Running", + "paused": "Paused", + "blocked": "Blocked", + "done": "Done", + "cancelled": "Cancelled" + }, + "priority": { + "high": "High", + "medium": "Medium", + "low": "Low" + }, + "route": { + "undecided": "Undecided", + "full": "Full", + "skip_design": "Skip design", + "direct": "Direct" + }, + "toasts": { + "spaceCreated": "Created space {name}", + "spaceCreateFailed": "Failed to create space: {message}", + "spaceUpdateFailed": "Failed to rename space: {message}", + "spaceDeleteFailed": "Failed to delete space: {message}", + "notGitRepo": "That folder is not a git repository. Choose a git repo or run git init first.", + "issueCreated": "Created issue {title}", + "issueCreateFailed": "Failed to create issue: {message}", + "issueDeleteFailed": "Failed to delete issue: {message}" + }, + "common": { + "cancel": "Cancel", + "delete": "Delete" + } } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 144ff6fd90..5c833346ea 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1004,7 +1004,8 @@ "sectionFolders": "Dossiers", "sectionChats": "Discussion", "noChats": "Aucune discussion", - "newChatAction": "Nouvelle discussion" + "newChatAction": "Nouvelle discussion", + "loops": "Loop Engineering" }, "conversation": { "reloadFailed": "Échec du rechargement de la conversation : {message}", @@ -2895,5 +2896,115 @@ "saved": "Paramètres de question enregistrés", "saveFailed": "Échec de l'enregistrement des paramètres de question", "loadFailed": "Échec du chargement : {detail}" + }, + "Loops": { + "workbench": { + "title": "Loop Engineering", + "subtitle": "Create issues — the engine drives requirements, design, implementation, validation and review automatically.", + "newSpace": "New space", + "empty": "No loop spaces yet. Create one bound to a git repository to get started.", + "spaceIssues": "{count, plural, one {# issue} other {# issues}}", + "spaceRunning": "{count} running", + "detached": "Folder missing", + "rename": "Rename", + "deleteSpace": "Delete", + "confirmDeleteTitle": "Delete loop space?", + "confirmDeleteDescription": "Delete “{name}”? Its issues, artifacts and memory are permanently removed. This cannot be undone.", + "loadFailed": "Failed to load spaces: {message}" + }, + "spaceForm": { + "createTitle": "New loop space", + "editTitle": "Rename loop space", + "nameLabel": "Name", + "namePlaceholder": "e.g. Payments revamp", + "folderLabel": "Repository folder", + "folderHint": "Must be a git repository.", + "chooseFolder": "Choose folder…", + "noFolder": "No folder selected", + "create": "Create", + "save": "Save", + "cancel": "Cancel" + }, + "spaceDetail": { + "back": "All spaces", + "tabIssues": "Issues", + "tabIterations": "Iterations", + "tabArtifacts": "Artifacts", + "tabInbox": "Inbox", + "tabMemory": "Memory", + "comingSoon": "Coming soon." + }, + "issueList": { + "title": "Issues", + "newIssue": "New issue", + "empty": "No issues. Create one — it stays Pending until you trigger it.", + "filterStatus": "Status", + "filterAll": "All", + "deleteIssue": "Delete", + "confirmDeleteTitle": "Delete issue?", + "confirmDeleteDescription": "This permanently deletes the issue and its artifacts. This cannot be undone.", + "trigger": "Trigger", + "triggerComingSoon": "The engine connects in the next phase.", + "pause": "Pause", + "resume": "Resume" + }, + "issueForm": { + "createTitle": "New issue", + "titleLabel": "Title", + "titlePlaceholder": "What should the loop accomplish?", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Goal, scope, constraints, acceptance criteria…", + "priorityLabel": "Priority", + "create": "Create", + "cancel": "Cancel" + }, + "issueDetail": { + "selectPrompt": "Select an issue to see its loop.", + "tokenUsage": "Tokens", + "tokenWithBudget": "{used} / {budget}", + "settings": "Settings", + "subtabGraph": "Graph", + "subtabBoard": "Board", + "subtabIterations": "Iterations", + "subtabArtifacts": "Artifacts", + "graphPlaceholder": "The DAG expands here once the issue is triggered.", + "boardPlaceholder": "A read-only board view arrives with the engine.", + "rootArtifact": "Issue", + "noIterations": "No iterations yet.", + "noArtifacts": "Only the root issue artifact so far." + }, + "status": { + "pending": "Pending", + "running": "Running", + "paused": "Paused", + "blocked": "Blocked", + "done": "Done", + "cancelled": "Cancelled" + }, + "priority": { + "high": "High", + "medium": "Medium", + "low": "Low" + }, + "route": { + "undecided": "Undecided", + "full": "Full", + "skip_design": "Skip design", + "direct": "Direct" + }, + "toasts": { + "spaceCreated": "Created space {name}", + "spaceCreateFailed": "Failed to create space: {message}", + "spaceUpdateFailed": "Failed to rename space: {message}", + "spaceDeleteFailed": "Failed to delete space: {message}", + "notGitRepo": "That folder is not a git repository. Choose a git repo or run git init first.", + "issueCreated": "Created issue {title}", + "issueCreateFailed": "Failed to create issue: {message}", + "issueDeleteFailed": "Failed to delete issue: {message}" + }, + "common": { + "cancel": "Cancel", + "delete": "Delete" + } } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index e61294c3cc..4eb6955612 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1004,7 +1004,8 @@ "sectionFolders": "フォルダ", "sectionChats": "チャット", "noChats": "チャットがありません", - "newChatAction": "新しいチャット" + "newChatAction": "新しいチャット", + "loops": "Loop Engineering" }, "conversation": { "reloadFailed": "会話の再読み込みに失敗しました: {message}", @@ -2895,5 +2896,115 @@ "saved": "質問設定を保存しました", "saveFailed": "質問設定の保存に失敗しました", "loadFailed": "読み込みに失敗しました:{detail}" + }, + "Loops": { + "workbench": { + "title": "Loop Engineering", + "subtitle": "Create issues — the engine drives requirements, design, implementation, validation and review automatically.", + "newSpace": "New space", + "empty": "No loop spaces yet. Create one bound to a git repository to get started.", + "spaceIssues": "{count, plural, one {# issue} other {# issues}}", + "spaceRunning": "{count} running", + "detached": "Folder missing", + "rename": "Rename", + "deleteSpace": "Delete", + "confirmDeleteTitle": "Delete loop space?", + "confirmDeleteDescription": "Delete “{name}”? Its issues, artifacts and memory are permanently removed. This cannot be undone.", + "loadFailed": "Failed to load spaces: {message}" + }, + "spaceForm": { + "createTitle": "New loop space", + "editTitle": "Rename loop space", + "nameLabel": "Name", + "namePlaceholder": "e.g. Payments revamp", + "folderLabel": "Repository folder", + "folderHint": "Must be a git repository.", + "chooseFolder": "Choose folder…", + "noFolder": "No folder selected", + "create": "Create", + "save": "Save", + "cancel": "Cancel" + }, + "spaceDetail": { + "back": "All spaces", + "tabIssues": "Issues", + "tabIterations": "Iterations", + "tabArtifacts": "Artifacts", + "tabInbox": "Inbox", + "tabMemory": "Memory", + "comingSoon": "Coming soon." + }, + "issueList": { + "title": "Issues", + "newIssue": "New issue", + "empty": "No issues. Create one — it stays Pending until you trigger it.", + "filterStatus": "Status", + "filterAll": "All", + "deleteIssue": "Delete", + "confirmDeleteTitle": "Delete issue?", + "confirmDeleteDescription": "This permanently deletes the issue and its artifacts. This cannot be undone.", + "trigger": "Trigger", + "triggerComingSoon": "The engine connects in the next phase.", + "pause": "Pause", + "resume": "Resume" + }, + "issueForm": { + "createTitle": "New issue", + "titleLabel": "Title", + "titlePlaceholder": "What should the loop accomplish?", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Goal, scope, constraints, acceptance criteria…", + "priorityLabel": "Priority", + "create": "Create", + "cancel": "Cancel" + }, + "issueDetail": { + "selectPrompt": "Select an issue to see its loop.", + "tokenUsage": "Tokens", + "tokenWithBudget": "{used} / {budget}", + "settings": "Settings", + "subtabGraph": "Graph", + "subtabBoard": "Board", + "subtabIterations": "Iterations", + "subtabArtifacts": "Artifacts", + "graphPlaceholder": "The DAG expands here once the issue is triggered.", + "boardPlaceholder": "A read-only board view arrives with the engine.", + "rootArtifact": "Issue", + "noIterations": "No iterations yet.", + "noArtifacts": "Only the root issue artifact so far." + }, + "status": { + "pending": "Pending", + "running": "Running", + "paused": "Paused", + "blocked": "Blocked", + "done": "Done", + "cancelled": "Cancelled" + }, + "priority": { + "high": "High", + "medium": "Medium", + "low": "Low" + }, + "route": { + "undecided": "Undecided", + "full": "Full", + "skip_design": "Skip design", + "direct": "Direct" + }, + "toasts": { + "spaceCreated": "Created space {name}", + "spaceCreateFailed": "Failed to create space: {message}", + "spaceUpdateFailed": "Failed to rename space: {message}", + "spaceDeleteFailed": "Failed to delete space: {message}", + "notGitRepo": "That folder is not a git repository. Choose a git repo or run git init first.", + "issueCreated": "Created issue {title}", + "issueCreateFailed": "Failed to create issue: {message}", + "issueDeleteFailed": "Failed to delete issue: {message}" + }, + "common": { + "cancel": "Cancel", + "delete": "Delete" + } } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 38a659a97d..a3bd6fdef3 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1004,7 +1004,8 @@ "sectionFolders": "폴더", "sectionChats": "채팅", "noChats": "채팅 없음", - "newChatAction": "새 채팅" + "newChatAction": "새 채팅", + "loops": "Loop Engineering" }, "conversation": { "reloadFailed": "대화 다시 불러오기 실패: {message}", @@ -2895,5 +2896,115 @@ "saved": "질문 설정이 저장되었습니다", "saveFailed": "질문 설정 저장 실패", "loadFailed": "불러오기 실패: {detail}" + }, + "Loops": { + "workbench": { + "title": "Loop Engineering", + "subtitle": "Create issues — the engine drives requirements, design, implementation, validation and review automatically.", + "newSpace": "New space", + "empty": "No loop spaces yet. Create one bound to a git repository to get started.", + "spaceIssues": "{count, plural, one {# issue} other {# issues}}", + "spaceRunning": "{count} running", + "detached": "Folder missing", + "rename": "Rename", + "deleteSpace": "Delete", + "confirmDeleteTitle": "Delete loop space?", + "confirmDeleteDescription": "Delete “{name}”? Its issues, artifacts and memory are permanently removed. This cannot be undone.", + "loadFailed": "Failed to load spaces: {message}" + }, + "spaceForm": { + "createTitle": "New loop space", + "editTitle": "Rename loop space", + "nameLabel": "Name", + "namePlaceholder": "e.g. Payments revamp", + "folderLabel": "Repository folder", + "folderHint": "Must be a git repository.", + "chooseFolder": "Choose folder…", + "noFolder": "No folder selected", + "create": "Create", + "save": "Save", + "cancel": "Cancel" + }, + "spaceDetail": { + "back": "All spaces", + "tabIssues": "Issues", + "tabIterations": "Iterations", + "tabArtifacts": "Artifacts", + "tabInbox": "Inbox", + "tabMemory": "Memory", + "comingSoon": "Coming soon." + }, + "issueList": { + "title": "Issues", + "newIssue": "New issue", + "empty": "No issues. Create one — it stays Pending until you trigger it.", + "filterStatus": "Status", + "filterAll": "All", + "deleteIssue": "Delete", + "confirmDeleteTitle": "Delete issue?", + "confirmDeleteDescription": "This permanently deletes the issue and its artifacts. This cannot be undone.", + "trigger": "Trigger", + "triggerComingSoon": "The engine connects in the next phase.", + "pause": "Pause", + "resume": "Resume" + }, + "issueForm": { + "createTitle": "New issue", + "titleLabel": "Title", + "titlePlaceholder": "What should the loop accomplish?", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Goal, scope, constraints, acceptance criteria…", + "priorityLabel": "Priority", + "create": "Create", + "cancel": "Cancel" + }, + "issueDetail": { + "selectPrompt": "Select an issue to see its loop.", + "tokenUsage": "Tokens", + "tokenWithBudget": "{used} / {budget}", + "settings": "Settings", + "subtabGraph": "Graph", + "subtabBoard": "Board", + "subtabIterations": "Iterations", + "subtabArtifacts": "Artifacts", + "graphPlaceholder": "The DAG expands here once the issue is triggered.", + "boardPlaceholder": "A read-only board view arrives with the engine.", + "rootArtifact": "Issue", + "noIterations": "No iterations yet.", + "noArtifacts": "Only the root issue artifact so far." + }, + "status": { + "pending": "Pending", + "running": "Running", + "paused": "Paused", + "blocked": "Blocked", + "done": "Done", + "cancelled": "Cancelled" + }, + "priority": { + "high": "High", + "medium": "Medium", + "low": "Low" + }, + "route": { + "undecided": "Undecided", + "full": "Full", + "skip_design": "Skip design", + "direct": "Direct" + }, + "toasts": { + "spaceCreated": "Created space {name}", + "spaceCreateFailed": "Failed to create space: {message}", + "spaceUpdateFailed": "Failed to rename space: {message}", + "spaceDeleteFailed": "Failed to delete space: {message}", + "notGitRepo": "That folder is not a git repository. Choose a git repo or run git init first.", + "issueCreated": "Created issue {title}", + "issueCreateFailed": "Failed to create issue: {message}", + "issueDeleteFailed": "Failed to delete issue: {message}" + }, + "common": { + "cancel": "Cancel", + "delete": "Delete" + } } } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index c23adfc924..60867e8981 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1004,7 +1004,8 @@ "sectionFolders": "Pastas", "sectionChats": "Chat", "noChats": "Sem chats", - "newChatAction": "Novo chat" + "newChatAction": "Novo chat", + "loops": "Loop Engineering" }, "conversation": { "reloadFailed": "Falha ao recarregar conversa: {message}", @@ -2895,5 +2896,115 @@ "saved": "Configurações de pergunta salvas", "saveFailed": "Falha ao salvar as configurações de pergunta", "loadFailed": "Falha ao carregar: {detail}" + }, + "Loops": { + "workbench": { + "title": "Loop Engineering", + "subtitle": "Create issues — the engine drives requirements, design, implementation, validation and review automatically.", + "newSpace": "New space", + "empty": "No loop spaces yet. Create one bound to a git repository to get started.", + "spaceIssues": "{count, plural, one {# issue} other {# issues}}", + "spaceRunning": "{count} running", + "detached": "Folder missing", + "rename": "Rename", + "deleteSpace": "Delete", + "confirmDeleteTitle": "Delete loop space?", + "confirmDeleteDescription": "Delete “{name}”? Its issues, artifacts and memory are permanently removed. This cannot be undone.", + "loadFailed": "Failed to load spaces: {message}" + }, + "spaceForm": { + "createTitle": "New loop space", + "editTitle": "Rename loop space", + "nameLabel": "Name", + "namePlaceholder": "e.g. Payments revamp", + "folderLabel": "Repository folder", + "folderHint": "Must be a git repository.", + "chooseFolder": "Choose folder…", + "noFolder": "No folder selected", + "create": "Create", + "save": "Save", + "cancel": "Cancel" + }, + "spaceDetail": { + "back": "All spaces", + "tabIssues": "Issues", + "tabIterations": "Iterations", + "tabArtifacts": "Artifacts", + "tabInbox": "Inbox", + "tabMemory": "Memory", + "comingSoon": "Coming soon." + }, + "issueList": { + "title": "Issues", + "newIssue": "New issue", + "empty": "No issues. Create one — it stays Pending until you trigger it.", + "filterStatus": "Status", + "filterAll": "All", + "deleteIssue": "Delete", + "confirmDeleteTitle": "Delete issue?", + "confirmDeleteDescription": "This permanently deletes the issue and its artifacts. This cannot be undone.", + "trigger": "Trigger", + "triggerComingSoon": "The engine connects in the next phase.", + "pause": "Pause", + "resume": "Resume" + }, + "issueForm": { + "createTitle": "New issue", + "titleLabel": "Title", + "titlePlaceholder": "What should the loop accomplish?", + "descriptionLabel": "Description", + "descriptionPlaceholder": "Goal, scope, constraints, acceptance criteria…", + "priorityLabel": "Priority", + "create": "Create", + "cancel": "Cancel" + }, + "issueDetail": { + "selectPrompt": "Select an issue to see its loop.", + "tokenUsage": "Tokens", + "tokenWithBudget": "{used} / {budget}", + "settings": "Settings", + "subtabGraph": "Graph", + "subtabBoard": "Board", + "subtabIterations": "Iterations", + "subtabArtifacts": "Artifacts", + "graphPlaceholder": "The DAG expands here once the issue is triggered.", + "boardPlaceholder": "A read-only board view arrives with the engine.", + "rootArtifact": "Issue", + "noIterations": "No iterations yet.", + "noArtifacts": "Only the root issue artifact so far." + }, + "status": { + "pending": "Pending", + "running": "Running", + "paused": "Paused", + "blocked": "Blocked", + "done": "Done", + "cancelled": "Cancelled" + }, + "priority": { + "high": "High", + "medium": "Medium", + "low": "Low" + }, + "route": { + "undecided": "Undecided", + "full": "Full", + "skip_design": "Skip design", + "direct": "Direct" + }, + "toasts": { + "spaceCreated": "Created space {name}", + "spaceCreateFailed": "Failed to create space: {message}", + "spaceUpdateFailed": "Failed to rename space: {message}", + "spaceDeleteFailed": "Failed to delete space: {message}", + "notGitRepo": "That folder is not a git repository. Choose a git repo or run git init first.", + "issueCreated": "Created issue {title}", + "issueCreateFailed": "Failed to create issue: {message}", + "issueDeleteFailed": "Failed to delete issue: {message}" + }, + "common": { + "cancel": "Cancel", + "delete": "Delete" + } } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 2c891eea13..34c115aff2 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1004,7 +1004,8 @@ "sectionFolders": "文件夹", "sectionChats": "聊天", "noChats": "没有聊天", - "newChatAction": "新建聊天" + "newChatAction": "新建聊天", + "loops": "循环工程" }, "conversation": { "reloadFailed": "会话重新加载失败:{message}", @@ -2895,5 +2896,115 @@ "saved": "提问设置已保存", "saveFailed": "保存提问设置失败", "loadFailed": "加载失败:{detail}" + }, + "Loops": { + "workbench": { + "title": "循环工程", + "subtitle": "只需创建议题,引擎自动驱动需求、设计、实现、验证与评审。", + "newSpace": "新建空间", + "empty": "还没有循环空间。绑定一个 git 仓库创建第一个空间。", + "spaceIssues": "{count, plural, other {# 个议题}}", + "spaceRunning": "{count} 个运行中", + "detached": "文件夹缺失", + "rename": "重命名", + "deleteSpace": "删除", + "confirmDeleteTitle": "删除循环空间?", + "confirmDeleteDescription": "删除「{name}」?其议题、工件与记忆将被永久移除,无法撤销。", + "loadFailed": "加载空间失败:{message}" + }, + "spaceForm": { + "createTitle": "新建循环空间", + "editTitle": "重命名循环空间", + "nameLabel": "名称", + "namePlaceholder": "例如:支付重构", + "folderLabel": "仓库文件夹", + "folderHint": "必须是 git 仓库。", + "chooseFolder": "选择文件夹…", + "noFolder": "未选择文件夹", + "create": "创建", + "save": "保存", + "cancel": "取消" + }, + "spaceDetail": { + "back": "全部空间", + "tabIssues": "议题", + "tabIterations": "迭代", + "tabArtifacts": "工件", + "tabInbox": "收件箱", + "tabMemory": "记忆", + "comingSoon": "即将到来。" + }, + "issueList": { + "title": "议题", + "newIssue": "新建议题", + "empty": "还没有议题。创建后会停在「待触发」,由你点击触发。", + "filterStatus": "状态", + "filterAll": "全部", + "deleteIssue": "删除", + "confirmDeleteTitle": "删除议题?", + "confirmDeleteDescription": "将永久删除该议题及其工件,无法撤销。", + "trigger": "触发", + "triggerComingSoon": "引擎将在下一阶段接入。", + "pause": "暂停", + "resume": "恢复" + }, + "issueForm": { + "createTitle": "新建议题", + "titleLabel": "标题", + "titlePlaceholder": "这个循环要达成什么?", + "descriptionLabel": "描述", + "descriptionPlaceholder": "目标、范围、约束、验收标准…", + "priorityLabel": "优先级", + "create": "创建", + "cancel": "取消" + }, + "issueDetail": { + "selectPrompt": "选择一个议题查看其循环。", + "tokenUsage": "Token", + "tokenWithBudget": "{used} / {budget}", + "settings": "设置", + "subtabGraph": "图", + "subtabBoard": "看板", + "subtabIterations": "迭代", + "subtabArtifacts": "工件", + "graphPlaceholder": "触发议题后,DAG 将在此展开。", + "boardPlaceholder": "只读看板视图将随引擎一并提供。", + "rootArtifact": "议题", + "noIterations": "还没有迭代。", + "noArtifacts": "目前只有根议题工件。" + }, + "status": { + "pending": "待触发", + "running": "运行中", + "paused": "已暂停", + "blocked": "受阻", + "done": "完成", + "cancelled": "已取消" + }, + "priority": { + "high": "高", + "medium": "中", + "low": "低" + }, + "route": { + "undecided": "未定", + "full": "完整", + "skip_design": "跳过设计", + "direct": "直接实现" + }, + "toasts": { + "spaceCreated": "已创建空间 {name}", + "spaceCreateFailed": "创建空间失败:{message}", + "spaceUpdateFailed": "重命名空间失败:{message}", + "spaceDeleteFailed": "删除空间失败:{message}", + "notGitRepo": "该文件夹不是 git 仓库。请选择 git 仓库,或先执行 git init。", + "issueCreated": "已创建议题 {title}", + "issueCreateFailed": "创建议题失败:{message}", + "issueDeleteFailed": "删除议题失败:{message}" + }, + "common": { + "cancel": "取消", + "delete": "删除" + } } } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 92f6c1b172..9f6463d48b 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1004,7 +1004,8 @@ "sectionFolders": "資料夾", "sectionChats": "聊天", "noChats": "沒有聊天", - "newChatAction": "新增聊天" + "newChatAction": "新增聊天", + "loops": "循環工程" }, "conversation": { "reloadFailed": "會話重新載入失敗:{message}", @@ -2895,5 +2896,115 @@ "saved": "提問設定已儲存", "saveFailed": "儲存提問設定失敗", "loadFailed": "載入失敗:{detail}" + }, + "Loops": { + "workbench": { + "title": "循環工程", + "subtitle": "只需建立議題,引擎自動驅動需求、設計、實作、驗證與評審。", + "newSpace": "新增空間", + "empty": "還沒有循環空間。綁定一個 git 儲存庫建立第一個空間。", + "spaceIssues": "{count, plural, other {# 個議題}}", + "spaceRunning": "{count} 個執行中", + "detached": "資料夾遺失", + "rename": "重新命名", + "deleteSpace": "刪除", + "confirmDeleteTitle": "刪除循環空間?", + "confirmDeleteDescription": "刪除「{name}」?其議題、工件與記憶將被永久移除,無法復原。", + "loadFailed": "載入空間失敗:{message}" + }, + "spaceForm": { + "createTitle": "新增循環空間", + "editTitle": "重新命名循環空間", + "nameLabel": "名稱", + "namePlaceholder": "例如:付款重構", + "folderLabel": "儲存庫資料夾", + "folderHint": "必須是 git 儲存庫。", + "chooseFolder": "選擇資料夾…", + "noFolder": "未選擇資料夾", + "create": "建立", + "save": "儲存", + "cancel": "取消" + }, + "spaceDetail": { + "back": "全部空間", + "tabIssues": "議題", + "tabIterations": "迭代", + "tabArtifacts": "工件", + "tabInbox": "收件匣", + "tabMemory": "記憶", + "comingSoon": "即將推出。" + }, + "issueList": { + "title": "議題", + "newIssue": "新增議題", + "empty": "還沒有議題。建立後會停在「待觸發」,由你點擊觸發。", + "filterStatus": "狀態", + "filterAll": "全部", + "deleteIssue": "刪除", + "confirmDeleteTitle": "刪除議題?", + "confirmDeleteDescription": "將永久刪除該議題及其工件,無法復原。", + "trigger": "觸發", + "triggerComingSoon": "引擎將在下一階段接入。", + "pause": "暫停", + "resume": "恢復" + }, + "issueForm": { + "createTitle": "新增議題", + "titleLabel": "標題", + "titlePlaceholder": "這個循環要達成什麼?", + "descriptionLabel": "描述", + "descriptionPlaceholder": "目標、範圍、限制、驗收標準…", + "priorityLabel": "優先順序", + "create": "建立", + "cancel": "取消" + }, + "issueDetail": { + "selectPrompt": "選擇一個議題查看其循環。", + "tokenUsage": "Token", + "tokenWithBudget": "{used} / {budget}", + "settings": "設定", + "subtabGraph": "圖", + "subtabBoard": "看板", + "subtabIterations": "迭代", + "subtabArtifacts": "工件", + "graphPlaceholder": "觸發議題後,DAG 將在此展開。", + "boardPlaceholder": "唯讀看板檢視將隨引擎一併提供。", + "rootArtifact": "議題", + "noIterations": "還沒有迭代。", + "noArtifacts": "目前只有根議題工件。" + }, + "status": { + "pending": "待觸發", + "running": "執行中", + "paused": "已暫停", + "blocked": "受阻", + "done": "完成", + "cancelled": "已取消" + }, + "priority": { + "high": "高", + "medium": "中", + "low": "低" + }, + "route": { + "undecided": "未定", + "full": "完整", + "skip_design": "跳過設計", + "direct": "直接實作" + }, + "toasts": { + "spaceCreated": "已建立空間 {name}", + "spaceCreateFailed": "建立空間失敗:{message}", + "spaceUpdateFailed": "重新命名空間失敗:{message}", + "spaceDeleteFailed": "刪除空間失敗:{message}", + "notGitRepo": "該資料夾不是 git 儲存庫。請選擇 git 儲存庫,或先執行 git init。", + "issueCreated": "已建立議題 {title}", + "issueCreateFailed": "建立議題失敗:{message}", + "issueDeleteFailed": "刪除議題失敗:{message}" + }, + "common": { + "cancel": "取消", + "delete": "刪除" + } } } From 15662bbc9552b85983190c0e2b8dfa7b8f16608d Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 13 Jun 2026 16:41:22 +0800 Subject: [PATCH 011/157] feat(loop): add workbench, space and issue management UI Sidebar entry switches the workspace to a lazy-mounted loop workbench (chat shell kept alive via display:none). Workbench lists spaces with create/rename/delete; space detail has the issue|iteration|artifact| inbox|memory tabs with the issue tab wired to an issue list (status filter, create/delete) and a three-row issue detail skeleton. Trigger is shown disabled until the engine lands. --- src/app/workspace/layout.tsx | 124 ++++-- src/components/layout/sidebar.test.tsx | 4 + src/components/layout/sidebar.tsx | 21 + src/components/loops/issue-badges.tsx | 53 +++ src/components/loops/issue-detail.tsx | 209 +++++++++ src/components/loops/issue-list.tsx | 412 ++++++++++++++++++ src/components/loops/loops-workbench.test.tsx | 100 +++++ src/components/loops/loops-workbench.tsx | 284 ++++++++++++ src/components/loops/space-detail.tsx | 89 ++++ src/components/loops/space-form-dialog.tsx | 191 ++++++++ 10 files changed, 1449 insertions(+), 38 deletions(-) create mode 100644 src/components/loops/issue-badges.tsx create mode 100644 src/components/loops/issue-detail.tsx create mode 100644 src/components/loops/issue-list.tsx create mode 100644 src/components/loops/loops-workbench.test.tsx create mode 100644 src/components/loops/loops-workbench.tsx create mode 100644 src/components/loops/space-detail.tsx create mode 100644 src/components/loops/space-form-dialog.tsx diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index f12ac893ec..cf090ccac4 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -2,12 +2,14 @@ import { Suspense, + lazy, useMemo, useCallback, useEffect, useRef, useState, } from "react" +import { Loader2 } from "lucide-react" import type { ImperativePanelGroupHandle } from "react-resizable-panels" import { FolderTitleBar } from "@/components/layout/folder-title-bar" import { useIsActiveChatMode } from "@/hooks/use-is-active-chat-mode" @@ -30,6 +32,7 @@ import { import { DelegationProvider } from "@/contexts/delegation-context" import { ConversationRuntimeProvider } from "@/contexts/conversation-runtime-context" import { TabProvider, useTabContext } from "@/contexts/tab-context" +import { LoopsViewProvider, useLoopsView } from "@/contexts/loops-view-context" import { SessionStatsProvider } from "@/contexts/session-stats-context" import { SidebarProvider, useSidebarContext } from "@/contexts/sidebar-context" import { SearchDialogProvider } from "@/contexts/search-dialog-context" @@ -78,6 +81,12 @@ function WorkspaceDocumentTitle() { return null } +const LoopsWorkbench = lazy(() => + import("@/components/loops/loops-workbench").then((m) => ({ + default: m.LoopsWorkbench, + })) +) + const TOAST_DURATION_MS = 15000 const WORKSPACE_PANEL_GROUP_ID = "workspace-panel-group" const WORKSPACE_CONVERSATION_PANEL_ID = "workspace-conversation-panel" @@ -396,6 +405,7 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { maxHeight: terminalMaxHeight, setHeight: setTerminalHeight, } = useTerminalContext() + const { view: loopsView } = useLoopsView() const shellGroupRef = useRef(null) const mainGroupRef = useRef(null) @@ -726,43 +736,66 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { ref={mainContainerRef} className="flex h-full min-h-0 flex-col overflow-hidden" > - - - {children} - - - - - -
- -
-
-
+ + {children} + + + + + +
+ +
+
+ + + + {loopsView === "loops" ? ( +
+ + +
+ } + > + + + + ) : null} @@ -790,6 +823,19 @@ function FolderWorkspaceShell({ children }: { children: React.ReactNode }) { ) } +/** + * Bridges the active chat tab into the loops view context so that selecting a + * chat tab (the active id changing) flips the workspace back to the chat + * surface. Lives inside TabProvider so it can read the active tab id, and wraps + * the shell so both the sidebar entry and the workspace gate share the state. + */ +function LoopsViewBridge({ children }: { children: React.ReactNode }) { + const { activeTabId } = useTabContext() + return ( + {children} + ) +} + function FolderLayoutShell({ children }: { children: React.ReactNode }) { const isMobile = useIsMobile() @@ -835,9 +881,11 @@ function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) { - - {children} - + + + {children} + + diff --git a/src/components/layout/sidebar.test.tsx b/src/components/layout/sidebar.test.tsx index de3ddee4ba..88b50d758e 100644 --- a/src/components/layout/sidebar.test.tsx +++ b/src/components/layout/sidebar.test.tsx @@ -11,6 +11,7 @@ const spies = vi.hoisted(() => ({ openNewConversationTab: vi.fn(), openChatModeTab: vi.fn(), setSearchOpen: vi.fn(), + setLoopsView: vi.fn(), })) const mockState = vi.hoisted(() => ({ activeFolder: { id: 7, path: "/x" } as { id: number; path: string } | null, @@ -36,6 +37,9 @@ vi.mock("@/contexts/tab-context", () => ({ vi.mock("@/contexts/search-dialog-context", () => ({ useSearchDialog: () => ({ open: false, setOpen: spies.setSearchOpen }), })) +vi.mock("@/contexts/loops-view-context", () => ({ + useLoopsView: () => ({ view: "chat", setView: spies.setLoopsView }), +})) vi.mock("@/hooks/use-is-mac", () => ({ useIsMac: () => false })) vi.mock("@/hooks/use-shortcut-settings", () => ({ useShortcutSettings: () => ({ diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 3ee02a6b9a..42f8408632 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -8,9 +8,11 @@ import { Funnel, Search, SquarePen, + Workflow, } from "lucide-react" import { useTranslations } from "next-intl" import { useActiveFolder } from "@/contexts/active-folder-context" +import { useLoopsView } from "@/contexts/loops-view-context" import { useSidebarContext } from "@/contexts/sidebar-context" import { useTabContext } from "@/contexts/tab-context" import { useSearchDialog } from "@/contexts/search-dialog-context" @@ -63,6 +65,7 @@ export function Sidebar() { const { activeFolder } = useActiveFolder() const { openNewConversationTab, openChatModeTab } = useTabContext() const { setOpen: setSearchOpen } = useSearchDialog() + const { view: loopsView, setView: setLoopsView } = useLoopsView() const isMac = useIsMac() const { shortcuts } = useShortcutSettings() const isMobile = useIsMobile() @@ -240,6 +243,24 @@ export function Sidebar() { {searchShortcutLabel} ) : null} + {!isMobile ? ( + + ) : null} {/* On mobile, clicking a conversation card auto-closes the Sheet */} diff --git a/src/components/loops/issue-badges.tsx b/src/components/loops/issue-badges.tsx new file mode 100644 index 0000000000..04cfa232bb --- /dev/null +++ b/src/components/loops/issue-badges.tsx @@ -0,0 +1,53 @@ +"use client" + +import { useTranslations } from "next-intl" + +import { Badge } from "@/components/ui/badge" +import type { + LoopIssuePriority, + LoopIssueRoute, + LoopIssueStatus, +} from "@/lib/types" + +type BadgeVariant = + | "default" + | "secondary" + | "destructive" + | "outline" + | "ghost" + +const STATUS_VARIANT: Record = { + pending: "outline", + running: "default", + paused: "secondary", + blocked: "destructive", + done: "secondary", + cancelled: "ghost", +} + +const PRIORITY_VARIANT: Record = { + high: "destructive", + medium: "secondary", + low: "outline", +} + +export function IssueStatusBadge({ status }: { status: LoopIssueStatus }) { + const t = useTranslations("Loops.status") + return {t(status)} +} + +export function IssuePriorityBadge({ + priority, +}: { + priority: LoopIssuePriority +}) { + const t = useTranslations("Loops.priority") + return {t(priority)} +} + +export function IssueRouteBadge({ route }: { route: LoopIssueRoute }) { + const t = useTranslations("Loops.route") + // The undecided route is the default pre-triage state — not worth a chip. + if (route === "undecided") return null + return {t(route)} +} diff --git a/src/components/loops/issue-detail.tsx b/src/components/loops/issue-detail.tsx new file mode 100644 index 0000000000..58f1aee669 --- /dev/null +++ b/src/components/loops/issue-detail.tsx @@ -0,0 +1,209 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { useTranslations } from "next-intl" +import { Loader2, Play, Settings2 } from "lucide-react" + +import { getLoopDag, getLoopIssue } from "@/lib/loops-api" +import type { LoopArtifactRow, LoopIssueDetail } from "@/lib/types" +import { useLoopChanged } from "@/hooks/use-loop-changed" +import { Button } from "@/components/ui/button" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { + IssuePriorityBadge, + IssueRouteBadge, + IssueStatusBadge, +} from "@/components/loops/issue-badges" + +export function IssueDetail({ issueId }: { issueId: number | null }) { + const t = useTranslations("Loops.issueDetail") + const tList = useTranslations("Loops.issueList") + + const [issue, setIssue] = useState(null) + const [artifacts, setArtifacts] = useState([]) + const [loading, setLoading] = useState(false) + + const refresh = useCallback(async () => { + if (issueId == null) { + setIssue(null) + setArtifacts([]) + return + } + setLoading(true) + try { + const [detail, dag] = await Promise.all([ + getLoopIssue(issueId), + getLoopDag(issueId), + ]) + setIssue(detail) + setArtifacts(dag.artifacts) + } finally { + setLoading(false) + } + }, [issueId]) + + useEffect(() => { + void refresh() + }, [refresh]) + + useLoopChanged(() => { + void refresh() + }, issue?.space_id) + + if (issueId == null) { + return ( +
+ {t("selectPrompt")} +
+ ) + } + + if (loading && !issue) { + return ( +
+ +
+ ) + } + + if (!issue) return null + + const budget = issue.token_budget + const tokenText = + budget != null + ? t("tokenWithBudget", { + used: issue.token_used.toLocaleString(), + budget: budget.toLocaleString(), + }) + : issue.token_used.toLocaleString() + + return ( +
+ {/* Row ① — title + token usage + actions */} +
+
+
+ + #{issue.seq_no} + +

{issue.title}

+
+
+ + + +
+
+
+
+
{t("tokenUsage")}
+
{tokenText}
+
+ {/* Engine actions arrive in the next phase; shown disabled for shape. */} + + + + + + + {tList("triggerComingSoon")} + + +
+
+ + {/* Row ② — graph / board */} +
+ + + {t("subtabGraph")} + {t("subtabBoard")} + + +
+ +

+ {t("graphPlaceholder")} +

+
+
+ +

+ {t("boardPlaceholder")} +

+
+
+
+ + {/* Row ③ — this issue's iterations / artifacts */} +
+ + + + {t("subtabIterations")} + + {t("subtabArtifacts")} + + +

{t("noIterations")}

+
+ + {artifacts.length <= 1 ? ( +

+ {t("noArtifacts")} +

+ ) : ( +
    + {artifacts.map((a) => ( +
  • + + {a.kind} + + {a.title} +
  • + ))} +
+ )} +
+
+
+
+ ) +} + +function ArtifactNode({ label, title }: { label: string; title: string }) { + return ( +
+ + {label} + + {title} +
+ ) +} diff --git a/src/components/loops/issue-list.tsx b/src/components/loops/issue-list.tsx new file mode 100644 index 0000000000..296dc82384 --- /dev/null +++ b/src/components/loops/issue-list.tsx @@ -0,0 +1,412 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { Loader2, MoreVertical, Plus, Trash2 } from "lucide-react" + +import { + createLoopIssue, + deleteLoopIssue, + listLoopIssues, +} from "@/lib/loops-api" +import type { + LoopIssuePriority, + LoopIssueRow, + LoopIssueStatus, +} from "@/lib/types" +import { toErrorMessage } from "@/lib/app-error" +import { useLoopChanged } from "@/hooks/use-loop-changed" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { + IssuePriorityBadge, + IssueRouteBadge, + IssueStatusBadge, +} from "@/components/loops/issue-badges" + +const ALL_STATUSES: LoopIssueStatus[] = [ + "pending", + "running", + "paused", + "blocked", + "done", + "cancelled", +] +const DEFAULT_FILTER: LoopIssueStatus[] = ["pending", "running"] + +export function IssueList({ + spaceId, + selectedIssueId, + onSelectIssue, +}: { + spaceId: number + selectedIssueId: number | null + onSelectIssue: (id: number) => void +}) { + const t = useTranslations("Loops.issueList") + const tStatus = useTranslations("Loops.status") + const tCommon = useTranslations("Loops.common") + const tToasts = useTranslations("Loops.toasts") + + const [issues, setIssues] = useState([]) + const [loading, setLoading] = useState(true) + const [filter, setFilter] = useState>( + () => new Set(DEFAULT_FILTER) + ) + const [createOpen, setCreateOpen] = useState(false) + const [deleting, setDeleting] = useState(null) + const [deleteBusy, setDeleteBusy] = useState(false) + + const refresh = useCallback(async () => { + try { + const statuses = filter.size > 0 ? [...filter] : undefined + const list = await listLoopIssues(spaceId, statuses) + setIssues(list) + } catch { + // listing failures are non-fatal here; the empty state covers it + } finally { + setLoading(false) + } + }, [spaceId, filter]) + + useEffect(() => { + void refresh() + }, [refresh]) + + useLoopChanged(() => { + void refresh() + }, spaceId) + + const toggleStatus = (status: LoopIssueStatus) => { + setFilter((prev) => { + const next = new Set(prev) + if (next.has(status)) next.delete(status) + else next.add(status) + return next + }) + } + + const handleDelete = async () => { + if (!deleting) return + setDeleteBusy(true) + try { + await deleteLoopIssue(deleting.id) + setDeleting(null) + await refresh() + } catch (err) { + toast.error( + tToasts("issueDeleteFailed", { message: toErrorMessage(err) }) + ) + } finally { + setDeleteBusy(false) + } + } + + return ( +
+
+ {t("title")} + +
+ +
+ {ALL_STATUSES.map((status) => { + const active = filter.has(status) + return ( + + ) + })} +
+ +
+ {loading ? ( +
+ +
+ ) : issues.length === 0 ? ( +

+ {t("empty")} +

+ ) : ( +
    + {issues.map((issue) => ( +
  • +
    onSelectIssue(issue.id)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onSelectIssue(issue.id) + } + }} + className={cn( + "group flex cursor-pointer flex-col gap-1.5 rounded-md border px-2.5 py-2 outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring", + selectedIssueId === issue.id + ? "border-primary/40 bg-accent" + : "border-transparent" + )} + > +
    + + #{issue.seq_no} + + + {issue.title} + + + + + + e.stopPropagation()} + > + setDeleting(issue)} + className="text-destructive focus:text-destructive" + > + + {t("deleteIssue")} + + + +
    +
    + + + +
    +
    +
  • + ))} +
+ )} +
+ + { + void refresh() + onSelectIssue(issue.id) + }} + /> + + !o && setDeleting(null)} + > + + + {t("confirmDeleteTitle")} + + {t("confirmDeleteDescription")} + + + + + {tCommon("cancel")} + + { + e.preventDefault() + void handleDelete() + }} + disabled={deleteBusy} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleteBusy && } + {tCommon("delete")} + + + + +
+ ) +} + +const PRIORITIES: LoopIssuePriority[] = ["high", "medium", "low"] + +function IssueFormDialog({ + spaceId, + open, + onOpenChange, + onCreated, +}: { + spaceId: number + open: boolean + onOpenChange: (open: boolean) => void + onCreated: (issue: LoopIssueRow) => void +}) { + const t = useTranslations("Loops.issueForm") + const tPriority = useTranslations("Loops.priority") + const tToasts = useTranslations("Loops.toasts") + + const [title, setTitle] = useState("") + const [description, setDescription] = useState("") + const [priority, setPriority] = useState("medium") + const [busy, setBusy] = useState(false) + + useEffect(() => { + if (open) { + setTitle("") + setDescription("") + setPriority("medium") + setBusy(false) + } + }, [open]) + + const handleCreate = async () => { + if (!title.trim() || busy) return + setBusy(true) + try { + const issue = await createLoopIssue({ + spaceId, + title: title.trim(), + description: description.trim(), + priority, + }) + toast.success(tToasts("issueCreated", { title: issue.title })) + onCreated(issue) + onOpenChange(false) + } catch (err) { + toast.error( + tToasts("issueCreateFailed", { message: toErrorMessage(err) }) + ) + } finally { + setBusy(false) + } + } + + return ( + + + + {t("createTitle")} + +
+
+ + setTitle(e.target.value)} + disabled={busy} + autoFocus + /> +
+
+ +