From 19d539689782d3eedc68bb80e4872695625aa7ff Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 24 Jul 2026 04:27:56 +0900 Subject: [PATCH] migrate: move workspace data to XDG SQLite stores --- .yoi/workspace-backend.local.toml | 4 - Cargo.lock | 1 + crates/manifest/src/paths.rs | 37 +- crates/ticket/Cargo.toml | 2 + crates/ticket/src/lib.rs | 1061 ++++++++++++++++++ crates/tui/src/picker.rs | 4 +- crates/worker-runtime/src/worker_backend.rs | 2 +- crates/worker/src/entrypoint.rs | 6 +- crates/workspace-server/src/config.rs | 174 ++- crates/workspace-server/src/lib.rs | 5 +- crates/workspace-server/src/main.rs | 51 +- crates/workspace-server/src/records.rs | 82 +- crates/workspace-server/src/repositories.rs | 2 +- crates/workspace-server/src/server.rs | 245 ++-- crates/workspace-server/src/store.rs | 121 ++ crates/yoi/src/main.rs | 40 +- crates/yoi/src/ticket_cli.rs | 230 ++-- resources/workspace-backend.default.toml | 10 +- tmp-migrate-home-yoi-data-dir.sh | 77 ++ tmp-migrate-workspace-runtime-and-tickets.sh | 88 ++ tmp-remove-legacy-yoi-files.sh | 43 + 21 files changed, 2039 insertions(+), 246 deletions(-) create mode 100644 tmp-migrate-home-yoi-data-dir.sh create mode 100644 tmp-migrate-workspace-runtime-and-tickets.sh create mode 100644 tmp-remove-legacy-yoi-files.sh diff --git a/.yoi/workspace-backend.local.toml b/.yoi/workspace-backend.local.toml index 76a1fcb4..9b8c6517 100644 --- a/.yoi/workspace-backend.local.toml +++ b/.yoi/workspace-backend.local.toml @@ -11,7 +11,3 @@ uri = "." display_name = "Yoi" default_selector = "HEAD" -[[runtimes.remote]] -id = "arc" -endpoint = "http://127.0.0.1:38800" -display_name = "arc" diff --git a/Cargo.lock b/Cargo.lock index 596e68b3..316e171c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4294,6 +4294,7 @@ dependencies = [ "fs4", "llm-engine", "project-record", + "rusqlite", "schemars", "serde", "serde_json", diff --git a/crates/manifest/src/paths.rs b/crates/manifest/src/paths.rs index 96f77cb6..d227ff4c 100644 --- a/crates/manifest/src/paths.rs +++ b/crates/manifest/src/paths.rs @@ -13,7 +13,7 @@ //! | base | 1. `YOI__DIR` | 2. `YOI_HOME` | 3. `XDG_*` | 4. 既定 | //! |---|---|---|---|---| //! | config | `YOI_CONFIG_DIR` | `$YOI_HOME/config` | `$XDG_CONFIG_HOME/yoi` | `$HOME/.config/yoi` | -//! | data | `YOI_DATA_DIR` | `$YOI_HOME` | — | `$HOME/.yoi` | +//! | data | `YOI_DATA_DIR` | `$YOI_HOME` | `$XDG_DATA_HOME/yoi` | `$HOME/.local/share/yoi` | //! | runtime | `YOI_RUNTIME_DIR` | `$YOI_HOME/run` | `$XDG_RUNTIME_DIR/yoi` | `$HOME/.yoi/run` | //! //! `YOI_HOME=$X` のとき config は `$X/config`、data は `$X` 直下、 @@ -42,6 +42,7 @@ pub fn data_dir() -> Option { resolve_data_dir_from_parts( env_path("YOI_DATA_DIR"), env_path("YOI_HOME"), + env_path("XDG_DATA_HOME"), env_path("HOME"), ) } @@ -131,6 +132,7 @@ fn resolve_config_dir_from_parts( fn resolve_data_dir_from_parts( yoi_data_dir: Option, yoi_home: Option, + xdg_data_home: Option, home: Option, ) -> Option { if let Some(p) = yoi_data_dir { @@ -139,7 +141,10 @@ fn resolve_data_dir_from_parts( if let Some(p) = yoi_home { return Some(p); } - Some(home?.join(".yoi")) + if let Some(p) = xdg_data_home { + return Some(p.join("yoi")); + } + Some(home?.join(".local").join("share").join("yoi")) } fn resolve_runtime_dir_from_parts( @@ -268,20 +273,35 @@ mod tests { } #[test] - fn data_dir_default_is_dot_yoi() { + fn data_dir_falls_back_to_home_local_share() { assert_eq!( - resolve_data_dir_from_parts(None, None, Some(PathBuf::from("/h"))).unwrap(), - PathBuf::from("/h/.yoi") + resolve_data_dir_from_parts(None, None, None, Some(PathBuf::from("/h"))).unwrap(), + PathBuf::from("/h/.local/share/yoi") ); } #[test] - fn data_dir_yoi_home_is_data_dir_itself() { + fn data_dir_uses_xdg_when_set() { + assert_eq!( + resolve_data_dir_from_parts( + None, + None, + Some(PathBuf::from("/xdg-data")), + Some(PathBuf::from("/h")), + ) + .unwrap(), + PathBuf::from("/xdg-data/yoi") + ); + } + + #[test] + fn data_dir_yoi_home_outranks_xdg() { assert_eq!( resolve_data_dir_from_parts( None, Some(PathBuf::from("/sand")), - Some(PathBuf::from("/h")) + Some(PathBuf::from("/xdg-data")), + Some(PathBuf::from("/h")), ) .unwrap(), PathBuf::from("/sand") @@ -294,6 +314,7 @@ mod tests { resolve_data_dir_from_parts( Some(PathBuf::from("/explicit-data")), Some(PathBuf::from("/sand")), + Some(PathBuf::from("/xdg-data")), Some(PathBuf::from("/h")), ) .unwrap(), @@ -365,7 +386,7 @@ mod tests { #[test] fn returns_none_when_nothing_set() { assert!(resolve_config_dir_from_parts(None, None, None, None).is_none()); - assert!(resolve_data_dir_from_parts(None, None, None).is_none()); + assert!(resolve_data_dir_from_parts(None, None, None, None).is_none()); assert!(resolve_runtime_dir_from_parts(None, None, None, None).is_none()); } diff --git a/crates/ticket/Cargo.toml b/crates/ticket/Cargo.toml index fd4d318f..3dbff58a 100644 --- a/crates/ticket/Cargo.toml +++ b/crates/ticket/Cargo.toml @@ -14,7 +14,9 @@ schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_yaml = "0.9.34" +rusqlite.workspace = true thiserror.workspace = true +tempfile.workspace = true toml = { workspace = true } [dev-dependencies] diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index b3c04dcb..c8cfb34f 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -13,6 +13,7 @@ use std::path::{Component, Path, PathBuf}; use chrono::Utc; use fs4::fs_std::FileExt; use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_id}; +use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; use serde_yaml::{Mapping as YamlMapping, Value as YamlValue}; use thiserror::Error; @@ -71,6 +72,8 @@ pub enum TicketError { Locked { path: PathBuf }, #[error("ticket conflict: {0}")] Conflict(String), + #[error("SQLite ticket backend error: {0}")] + Sqlite(String), #[error("ticket parse error in {path}: {message}")] Parse { path: PathBuf, message: String }, } @@ -82,6 +85,10 @@ fn io_err(path: impl Into, source: io::Error) -> TicketError { } } +fn sqlite_err(error: impl std::fmt::Display) -> TicketError { + TicketError::Sqlite(error.to_string()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum TicketStatus { Open, @@ -2127,6 +2134,993 @@ impl LocalTicketBackend { } } +#[derive(Debug, Clone)] +pub struct SqliteTicketBackend { + db_path: PathBuf, + workspace_id: String, + record_language: Option, +} + +impl SqliteTicketBackend { + pub fn new(db_path: impl Into, workspace_id: impl Into) -> Self { + Self { + db_path: db_path.into(), + workspace_id: workspace_id.into(), + record_language: None, + } + } + + pub fn with_record_language(mut self, language: Option<&str>) -> Self { + self.record_language = language.and_then(normalized_record_language); + self + } + + pub fn db_path(&self) -> &Path { + self.db_path.as_path() + } + pub fn workspace_id(&self) -> &str { + self.workspace_id.as_str() + } + pub fn record_language(&self) -> Option<&str> { + self.record_language.as_deref() + } + + pub fn import_from_local_backend(&self, local: &LocalTicketBackend) -> Result<()> { + let conn = self.open_connection()?; + self.ensure_schema(&conn)?; + conn.execute_batch("BEGIN IMMEDIATE").map_err(sqlite_err)?; + let result = (|| { + for summary in local.list(TicketListQuery::all())? { + let ticket = local.show(TicketIdOrSlug::Id(summary.id.clone()))?; + self.store_ticket(&conn, &ticket)?; + self.import_artifact_content(&conn, local.root(), &ticket)?; + } + Ok(()) + })(); + finish_sqlite_transaction(&conn, result) + } + + fn open_connection(&self) -> Result { + if let Some(parent) = self.db_path.parent() { + fs::create_dir_all(parent).map_err(|error| io_err(parent, error))?; + } + let conn = Connection::open(&self.db_path).map_err(sqlite_err)?; + conn.pragma_update(None, "foreign_keys", "ON") + .map_err(sqlite_err)?; + Ok(conn) + } + + fn ensure_schema(&self, conn: &Connection) -> Result<()> { + conn.execute_batch(r#" +CREATE TABLE IF NOT EXISTS typed_tickets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + kind TEXT NOT NULL, + priority TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT, + updated_at TEXT, + assignee TEXT, + readiness TEXT, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + queued_by TEXT, + queued_at TEXT, + resolution TEXT, + PRIMARY KEY (workspace_id, ticket_id) +); +CREATE TABLE IF NOT EXISTS typed_ticket_labels ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS typed_ticket_risk_flags ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS typed_ticket_raw_frontmatter ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, key), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS typed_ticket_events ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + event_index INTEGER NOT NULL, + kind TEXT NOT NULL, + author TEXT, + at TEXT, + status TEXT, + from_state TEXT, + to_state TEXT, + reason TEXT, + state_field TEXT, + heading TEXT, + body TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS typed_ticket_event_references ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal), + FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS typed_ticket_event_attributes ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, key), + FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS typed_ticket_relations ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, kind, target), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS typed_ticket_orchestration_plans ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL, + related_ticket TEXT, + note TEXT, + accepted_summary TEXT, + accepted_branch TEXT, + accepted_worktree TEXT, + accepted_role_plan TEXT, + author TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, record_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS typed_ticket_artifacts ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, relative_path), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +"#).map_err(sqlite_err) + } + + fn with_write(&self, op: impl FnOnce(&Connection) -> Result) -> Result { + let conn = self.open_connection()?; + self.ensure_schema(&conn)?; + conn.execute_batch("BEGIN IMMEDIATE").map_err(sqlite_err)?; + finish_sqlite_transaction(&conn, op(&conn)) + } + + fn with_read(&self, op: impl FnOnce(&Connection) -> Result) -> Result { + let conn = self.open_connection()?; + self.ensure_schema(&conn)?; + op(&conn) + } + + fn created_event_body(&self) -> &'static str { + if is_japanese_record_language(self.record_language()) { + "Ticket が作成されました。" + } else { + "Ticket created." + } + } + + fn default_item_body(&self) -> &'static str { + if is_japanese_record_language(self.record_language()) { + "## 背景\n\n## 要件\n\n## 受け入れ条件\n" + } else { + DEFAULT_TICKET_BODY + } + } + + fn resolve_ticket_id(&self, conn: &Connection, id: TicketIdOrSlug) -> Result { + let query = id.as_query().to_string(); + let mut stmt = conn.prepare("SELECT ticket_id FROM typed_tickets WHERE workspace_id = ?1 AND (ticket_id = ?2 OR slug = ?2) ORDER BY ticket_id").map_err(sqlite_err)?; + let rows = stmt + .query_map(params![self.workspace_id, query], |row| { + row.get::<_, String>(0) + }) + .map_err(sqlite_err)?; + let matches = rows + .collect::, _>>() + .map_err(sqlite_err)?; + match matches.as_slice() { + [one] => Ok(one.clone()), + [] => Err(TicketError::NotFound(id.as_query().to_string())), + _ => Err(TicketError::Ambiguous { + query: id.as_query().to_string(), + matches: matches.into_iter().map(PathBuf::from).collect(), + }), + } + } + + fn ticket_exists(&self, conn: &Connection, id: &str) -> Result { + Ok(conn + .query_row( + "SELECT 1 FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2", + params![self.workspace_id, id], + |_| Ok(()), + ) + .optional() + .map_err(sqlite_err)? + .is_some()) + } + + fn insert_event(&self, conn: &Connection, ticket_id: &str, event: &TicketEvent) -> Result<()> { + let next_index: i64 = conn.query_row( + "SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events WHERE workspace_id = ?1 AND ticket_id = ?2", + params![self.workspace_id, ticket_id], + |row| row.get(0), + ).map_err(sqlite_err)?; + conn.execute(r#"INSERT INTO typed_ticket_events + (workspace_id, ticket_id, event_index, kind, author, at, status, from_state, to_state, reason, state_field, heading, body) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"#, + params![self.workspace_id, ticket_id, next_index, event.kind.as_str(), event.author, event.at, event.status, event.from, event.to, event.reason, event.state_field, event.heading, event.body.as_str()] + ).map_err(sqlite_err)?; + for (ordinal, reference) in event.references.iter().enumerate() { + conn.execute("INSERT INTO typed_ticket_event_references (workspace_id, ticket_id, event_index, ordinal, kind, target) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![self.workspace_id, ticket_id, next_index, ordinal as i64, reference.kind, reference.target]).map_err(sqlite_err)?; + } + for (key, value) in &event.attributes { + conn.execute("INSERT INTO typed_ticket_event_attributes (workspace_id, ticket_id, event_index, key, value) VALUES (?1, ?2, ?3, ?4, ?5)", + params![self.workspace_id, ticket_id, next_index, key, value]).map_err(sqlite_err)?; + } + Ok(()) + } + + fn touch_ticket(&self, conn: &Connection, ticket_id: &str, updated_at: &str) -> Result<()> { + conn.execute( + "UPDATE typed_tickets SET updated_at = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2", + params![self.workspace_id, ticket_id, updated_at], + ) + .map_err(sqlite_err)?; + Ok(()) + } + + fn store_ticket(&self, conn: &Connection, ticket: &Ticket) -> Result<()> { + conn.execute( + "DELETE FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2", + params![self.workspace_id, ticket.meta.id], + ) + .map_err(sqlite_err)?; + self.insert_ticket(conn, ticket) + } + + fn insert_ticket(&self, conn: &Connection, ticket: &Ticket) -> Result<()> { + conn.execute(r#"INSERT INTO typed_tickets + (workspace_id, ticket_id, slug, title, status, kind, priority, body, created_at, updated_at, assignee, readiness, workflow_state, workflow_state_explicit, queued_by, queued_at, resolution) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)"#, + params![self.workspace_id, ticket.meta.id, ticket.meta.slug, ticket.meta.title, ticket.meta.status.as_str(), ticket.meta.kind, ticket.meta.priority, ticket.document.body.as_str(), ticket.meta.created_at, ticket.meta.updated_at, ticket.meta.assignee, ticket.meta.readiness, ticket.meta.workflow_state.as_str(), if ticket.meta.workflow_state_explicit { 1 } else { 0 }, ticket.meta.queued_by, ticket.meta.queued_at, ticket.resolution.as_ref().map(|body| body.as_str())] + ).map_err(sqlite_err)?; + self.insert_ordered_values( + conn, + "typed_ticket_labels", + "label", + &ticket.meta.id, + &ticket.meta.labels, + )?; + self.insert_ordered_values( + conn, + "typed_ticket_risk_flags", + "risk_flag", + &ticket.meta.id, + &ticket.meta.risk_flags, + )?; + for (key, value) in &ticket.meta.raw { + conn.execute("INSERT INTO typed_ticket_raw_frontmatter (workspace_id, ticket_id, key, value) VALUES (?1, ?2, ?3, ?4)", params![self.workspace_id, ticket.meta.id, key, value]).map_err(sqlite_err)?; + } + for event in &ticket.events { + self.insert_event(conn, &ticket.meta.id, event)?; + } + for relation in &ticket.relations.outgoing { + self.insert_relation(conn, relation)?; + } + Ok(()) + } + + fn insert_ordered_values( + &self, + conn: &Connection, + table: &str, + column: &str, + ticket_id: &str, + values: &[String], + ) -> Result<()> { + for (ordinal, value) in values.iter().enumerate() { + let sql = format!( + "INSERT INTO {table} (workspace_id, ticket_id, ordinal, {column}) VALUES (?1, ?2, ?3, ?4)" + ); + conn.execute( + &sql, + params![self.workspace_id, ticket_id, ordinal as i64, value], + ) + .map_err(sqlite_err)?; + } + Ok(()) + } + + fn import_artifact_content( + &self, + conn: &Connection, + local_root: &Path, + ticket: &Ticket, + ) -> Result<()> { + for artifact in &ticket.artifacts { + validate_artifact_relative_path(&artifact.relative_path)?; + let path = local_root + .join(&ticket.meta.id) + .join("artifacts") + .join(&artifact.relative_path); + let content = fs::read(&path).map_err(|error| io_err(&path, error))?; + conn.execute("INSERT OR REPLACE INTO typed_ticket_artifacts (workspace_id, ticket_id, relative_path, content) VALUES (?1, ?2, ?3, ?4)", + params![self.workspace_id, ticket.meta.id, artifact.relative_path.to_string_lossy(), content]).map_err(sqlite_err)?; + } + Ok(()) + } + + fn ticket_meta_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let state_raw: String = row.get(12)?; + Ok(TicketMeta { + id: row.get(0)?, + slug: row.get(1)?, + title: row.get(2)?, + status: ExtensibleTicketStatus::from(row.get::<_, String>(3)?.as_str()), + kind: row.get(4)?, + priority: row.get(5)?, + labels: Vec::new(), + created_at: row.get(6)?, + updated_at: row.get(7)?, + assignee: row.get(8)?, + readiness: row.get(9)?, + risk_flags: Vec::new(), + workflow_state: TicketWorkflowState::parse(&state_raw) + .unwrap_or(TicketWorkflowState::Planning), + workflow_state_explicit: row.get::<_, i64>(13)? != 0, + queued_by: row.get(14)?, + queued_at: row.get(15)?, + raw: BTreeMap::new(), + }) + } + + fn load_ticket(&self, conn: &Connection, ticket_id: &str) -> Result { + let (mut meta, body, resolution): (TicketMeta, String, Option) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#, + params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?; + meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?; + meta.risk_flags = + self.load_ordered_values(conn, "typed_ticket_risk_flags", "risk_flag", ticket_id)?; + meta.raw = self.load_key_values(conn, "typed_ticket_raw_frontmatter", ticket_id)?; + let events = self.load_events(conn, ticket_id)?; + let artifacts = self.load_artifacts(conn, ticket_id)?; + let relations = self.relation_view_for_meta(conn, &meta)?; + Ok(Ticket { + meta, + document: TicketDocument { + body: MarkdownText::new(body), + raw_frontmatter: BTreeMap::new(), + }, + events, + artifacts, + relations, + resolution: resolution.map(MarkdownText::new), + }) + } + + fn load_ordered_values( + &self, + conn: &Connection, + table: &str, + column: &str, + ticket_id: &str, + ) -> Result> { + let sql = format!( + "SELECT {column} FROM {table} WHERE workspace_id = ?1 AND ticket_id = ?2 ORDER BY ordinal ASC" + ); + let mut stmt = conn.prepare(&sql).map_err(sqlite_err)?; + let rows = stmt + .query_map(params![self.workspace_id, ticket_id], |row| { + row.get::<_, String>(0) + }) + .map_err(sqlite_err)?; + rows.collect::, _>>() + .map_err(sqlite_err) + } + + fn load_key_values( + &self, + conn: &Connection, + table: &str, + ticket_id: &str, + ) -> Result> { + let sql = format!( + "SELECT key, value FROM {table} WHERE workspace_id = ?1 AND ticket_id = ?2 ORDER BY key ASC" + ); + let mut stmt = conn.prepare(&sql).map_err(sqlite_err)?; + let rows = stmt + .query_map(params![self.workspace_id, ticket_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(sqlite_err)?; + let mut map = BTreeMap::new(); + for row in rows { + let (key, value) = row.map_err(sqlite_err)?; + map.insert(key, value); + } + Ok(map) + } + + fn load_events(&self, conn: &Connection, ticket_id: &str) -> Result> { + let mut stmt = conn.prepare(r#"SELECT event_index, kind, author, at, status, from_state, to_state, reason, state_field, heading, body FROM typed_ticket_events WHERE workspace_id = ?1 AND ticket_id = ?2 ORDER BY event_index ASC"#).map_err(sqlite_err)?; + let rows = stmt + .query_map(params![self.workspace_id, ticket_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, String>(10)?, + )) + }) + .map_err(sqlite_err)?; + let mut events = Vec::new(); + for row in rows { + let (index, kind, author, at, status, from, to, reason, state_field, heading, body) = + row.map_err(sqlite_err)?; + events.push(TicketEvent { + kind: TicketEventKind::from(kind.as_str()), + author, + at, + status, + from, + to, + reason, + state_field, + heading, + body: MarkdownText::new(body), + references: self.load_event_references(conn, ticket_id, index)?, + attributes: self.load_event_attributes(conn, ticket_id, index)?, + }); + } + Ok(events) + } + + fn load_event_references( + &self, + conn: &Connection, + ticket_id: &str, + event_index: i64, + ) -> Result> { + let mut stmt = conn.prepare("SELECT kind, target FROM typed_ticket_event_references WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = ?3 ORDER BY ordinal ASC").map_err(sqlite_err)?; + let rows = stmt + .query_map(params![self.workspace_id, ticket_id, event_index], |row| { + Ok(TicketReference { + kind: row.get(0)?, + target: row.get(1)?, + }) + }) + .map_err(sqlite_err)?; + rows.collect::, _>>() + .map_err(sqlite_err) + } + + fn load_event_attributes( + &self, + conn: &Connection, + ticket_id: &str, + event_index: i64, + ) -> Result> { + let mut stmt = conn.prepare("SELECT key, value FROM typed_ticket_event_attributes WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = ?3 ORDER BY key ASC").map_err(sqlite_err)?; + let rows = stmt + .query_map(params![self.workspace_id, ticket_id, event_index], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(sqlite_err)?; + let mut map = BTreeMap::new(); + for row in rows { + let (key, value) = row.map_err(sqlite_err)?; + map.insert(key, value); + } + Ok(map) + } + + fn load_artifacts(&self, conn: &Connection, ticket_id: &str) -> Result> { + let mut stmt = conn.prepare("SELECT relative_path FROM typed_ticket_artifacts WHERE workspace_id = ?1 AND ticket_id = ?2 ORDER BY relative_path ASC").map_err(sqlite_err)?; + let rows = stmt + .query_map(params![self.workspace_id, ticket_id], |row| { + Ok(TicketArtifactRef { + relative_path: PathBuf::from(row.get::<_, String>(0)?), + }) + }) + .map_err(sqlite_err)?; + rows.collect::, _>>() + .map_err(sqlite_err) + } + + fn list_summaries( + &self, + conn: &Connection, + filter: TicketListQuery, + ) -> Result> { + let mut stmt = conn.prepare(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at FROM typed_tickets WHERE workspace_id = ?1 ORDER BY ticket_id ASC"#).map_err(sqlite_err)?; + let rows = stmt + .query_map(params![self.workspace_id], Self::ticket_meta_from_row) + .map_err(sqlite_err)?; + let mut summaries = Vec::new(); + for row in rows { + let mut meta = row.map_err(sqlite_err)?; + if !filter.matches_state(meta.workflow_state) { + continue; + } + meta.labels = + self.load_ordered_values(conn, "typed_ticket_labels", "label", &meta.id)?; + summaries.push(ticket_summary_from_meta(meta)); + } + Ok(summaries) + } + + fn state_index(&self, conn: &Connection) -> Result> { + let mut stmt = conn + .prepare("SELECT ticket_id, workflow_state FROM typed_tickets WHERE workspace_id = ?1") + .map_err(sqlite_err)?; + let rows = stmt + .query_map(params![self.workspace_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(sqlite_err)?; + let mut states = HashMap::new(); + for row in rows { + let (id, state) = row.map_err(sqlite_err)?; + states.insert( + id, + TicketWorkflowState::parse(&state).unwrap_or(TicketWorkflowState::Planning), + ); + } + Ok(states) + } + + fn all_relations(&self, conn: &Connection) -> Result> { + let mut stmt = conn.prepare(r#"SELECT ticket_id, kind, target, note, author, at FROM typed_ticket_relations WHERE workspace_id = ?1 ORDER BY ticket_id ASC, kind ASC, target ASC"#).map_err(sqlite_err)?; + let rows = stmt + .query_map(params![self.workspace_id], |row| { + Ok(TicketRelation { + ticket_id: row.get(0)?, + kind: TicketRelationKind::parse(&row.get::<_, String>(1)?) + .unwrap_or(TicketRelationKind::Related), + target: row.get(2)?, + note: row.get(3)?, + author: row.get(4)?, + at: row.get(5)?, + }) + }) + .map_err(sqlite_err)?; + rows.collect::, _>>() + .map_err(sqlite_err) + } + + fn insert_relation(&self, conn: &Connection, relation: &TicketRelation) -> Result<()> { + conn.execute(r#"INSERT OR REPLACE INTO typed_ticket_relations (workspace_id, ticket_id, kind, target, note, author, at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"#, + params![self.workspace_id, relation.ticket_id, relation.kind.as_str(), relation.target, relation.note, relation.author, relation.at]).map_err(sqlite_err)?; + Ok(()) + } + + fn relation_view_for_meta( + &self, + conn: &Connection, + meta: &TicketMeta, + ) -> Result { + let relations = self.all_relations(conn)?; + let states = self.state_index(conn)?; + Ok(relation_view_from_records(meta, &relations, &states)) + } +} + +fn finish_sqlite_transaction(conn: &Connection, result: Result) -> Result { + match result { + Ok(output) => { + conn.execute_batch("COMMIT").map_err(sqlite_err)?; + Ok(output) + } + Err(error) => { + let _ = conn.execute_batch("ROLLBACK"); + Err(error) + } + } +} + +fn validate_artifact_relative_path(path: &Path) -> Result<()> { + if path.as_os_str().is_empty() || path.is_absolute() { + return Err(TicketError::PathEscapesRoot { + path: path.to_path_buf(), + }); + } + for component in path.components() { + match component { + Component::Normal(_) => {} + _ => { + return Err(TicketError::PathEscapesRoot { + path: path.to_path_buf(), + }); + } + } + } + Ok(()) +} + +impl TicketBackend for SqliteTicketBackend { + fn default_intake_ready_state_change_body(&self, from: &str) -> String { + if is_japanese_record_language(self.record_language()) { + format!("Ticket planning が完了しました。state {from} -> ready。\n") + } else { + format!("Ticket planning complete; state {from} -> ready.\n") + } + } + + fn list(&self, filter: TicketListQuery) -> Result> { + self.with_read(|conn| self.list_summaries(conn, filter)) + } + + fn show(&self, id: TicketIdOrSlug) -> Result { + self.with_read(|conn| { + let ticket_id = self.resolve_ticket_id(conn, id)?; + self.load_ticket(conn, &ticket_id) + }) + } + + fn create(&self, input: NewTicket) -> Result { + self.with_write(|conn| { + if input.title.trim().is_empty() { + return Err(TicketError::Conflict( + "ticket title must not be empty".to_string(), + )); + } + let base_millis = unix_epoch_millis_now().map_err(|err| { + TicketError::Conflict(format!("failed to read ticket id timestamp: {err}")) + })?; + let id = allocate_record_id(base_millis, |candidate| { + self.ticket_exists(conn, candidate).unwrap_or(true) + }) + .map_err(|err| { + TicketError::Conflict(format!("failed to allocate unique ticket id: {err}")) + })?; + let now = now_utc(); + let state = input + .workflow_state + .unwrap_or(TicketWorkflowState::Planning); + let status = if state == TicketWorkflowState::Closed { + ExtensibleTicketStatus::Closed + } else { + ExtensibleTicketStatus::Open + }; + let meta = TicketMeta { + id: id.clone(), + slug: input.slug.clone().unwrap_or_else(|| id.clone()), + title: input.title, + status, + kind: input.kind, + priority: input.priority, + labels: input.labels, + created_at: Some(now.clone()), + updated_at: Some(now.clone()), + assignee: input.assignee, + readiness: input.readiness, + risk_flags: input.risk_flags, + workflow_state: state, + workflow_state_explicit: true, + queued_by: input.queued_by, + queued_at: input.queued_at, + raw: BTreeMap::new(), + }; + let body = if input.body.as_str() == DEFAULT_TICKET_BODY { + MarkdownText::new(self.default_item_body()) + } else { + input.body + }; + let author = input + .author + .unwrap_or_else(|| "SqliteTicketBackend".to_string()); + let ticket = Ticket { + meta, + document: TicketDocument { + body, + raw_frontmatter: BTreeMap::new(), + }, + events: vec![TicketEvent { + kind: TicketEventKind::Create, + author: Some(author), + at: Some(now), + status: None, + from: None, + to: None, + reason: None, + state_field: None, + heading: Some(TicketEventKind::Create.heading()), + body: MarkdownText::new(self.created_event_body()), + references: Vec::new(), + attributes: BTreeMap::new(), + }], + artifacts: Vec::new(), + relations: TicketRelationView::default(), + resolution: None, + }; + self.insert_ticket(conn, &ticket)?; + Ok(TicketRef { + id: id.clone(), + slug: id, + status: TicketStatus::Open, + }) + }) + } + + fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> Result { + self.with_write(|conn| { + if edit.title.is_none() && edit.body.is_none() { return Err(TicketError::Conflict("TicketEditItem requires at least one of title or body".to_string())); } + let ticket_id = self.resolve_ticket_id(conn, id)?; + let now = now_utc(); + if let Some(title) = edit.title.as_ref() { + validate_required_event_value("title", title)?; + conn.execute("UPDATE typed_tickets SET title = ?3, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, title, now]).map_err(sqlite_err)?; + } + if let Some(body) = edit.body.as_ref() { + conn.execute("UPDATE typed_tickets SET body = ?3, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, body.as_str(), now]).map_err(sqlite_err)?; + } + let event = TicketEvent { kind: TicketEventKind::Other("item_edit".to_string()), author: Some(edit.author.unwrap_or_else(default_author)), at: Some(now), status: None, from: None, to: None, reason: None, state_field: None, heading: Some("Item edit".to_string()), body: edit.body.unwrap_or_else(|| MarkdownText::new("Ticket item metadata updated.")), references: Vec::new(), attributes: BTreeMap::new() }; + self.insert_event(conn, &ticket_id, &event)?; + self.load_ticket(conn, &ticket_id) + }) + } + + fn dependency_check(&self, id: TicketIdOrSlug) -> Result { + let ticket = self.show(id)?; + let blockers = ticket.relations.blockers.clone(); + let summary = ticket_summary_from_meta(ticket.meta.clone()); + let projection = project_ticket_workspace_item(&summary, &blockers, None); + Ok(TicketDependencyCheck { + ticket: summary, + blockers, + queue_guard: projection.queue_guard, + recommended_action: projection + .next_action + .unwrap_or(TicketWorkspaceNextAction::WaitForOrchestrator), + }) + } + + fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()> { + self.with_write(|conn| { + let ticket_id = self.resolve_ticket_id(conn, id)?; + let at = now_utc(); + self.insert_event( + conn, + &ticket_id, + &TicketEvent { + kind: event.kind.clone(), + author: event.author.or_else(|| Some(default_author())), + at: Some(at.clone()), + status: None, + from: None, + to: None, + reason: None, + state_field: None, + heading: Some(event.kind.heading()), + body: event.body, + references: event.references, + attributes: BTreeMap::new(), + }, + )?; + self.touch_ticket(conn, &ticket_id, &at) + }) + } + + fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> { + self.set_workflow_state(id, change) + } + + fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()> { + self.with_write(|conn| { + validate_intake_summary(&summary)?; + let ticket_id = self.resolve_ticket_id(conn, id)?; + let at = now_utc(); + self.insert_event( + conn, + &ticket_id, + &TicketEvent { + kind: TicketEventKind::IntakeSummary, + author: Some(summary.author.unwrap_or_else(default_author)), + at: Some(at.clone()), + status: None, + from: None, + to: None, + reason: None, + state_field: None, + heading: Some(TicketEventKind::IntakeSummary.heading()), + body: summary.body, + references: summary.references, + attributes: BTreeMap::new(), + }, + )?; + self.touch_ticket(conn, &ticket_id, &at) + }) + } + + fn set_state_field( + &self, + id: TicketIdOrSlug, + _field: &str, + change: TicketStateChange, + ) -> Result<()> { + self.set_workflow_state(id, change) + } + + fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> { + self.with_write(|conn| { + validate_state_change(&change)?; + let ticket_id = self.resolve_ticket_id(conn, id)?; + let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| TicketError::Conflict(format!("unknown workflow_state '{}':", change.to)))?; + let at = now_utc(); + self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(change.author.clone().unwrap_or_else(default_author)), at: Some(at.clone()), status: None, from: Some(change.from), to: Some(change.to), reason: Some(change.reason), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: change.body, references: change.references, attributes: BTreeMap::new() })?; + conn.execute("UPDATE typed_tickets SET workflow_state = ?3, workflow_state_explicit = 1, updated_at = ?4, status = CASE WHEN ?3 = 'closed' THEN 'closed' ELSE status END WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, to.as_str(), at]).map_err(sqlite_err)?; + Ok(()) + }) + } + + fn mark_intake_ready( + &self, + id: TicketIdOrSlug, + summary: TicketIntakeSummary, + change: TicketStateChange, + ) -> Result<()> { + self.add_intake_summary(id.clone(), summary)?; + self.set_workflow_state(id, change) + } + + fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result<()> { + self.with_write(|conn| { + let ticket_id = self.resolve_ticket_id(conn, id)?; + let ticket = self.load_ticket(conn, &ticket_id)?; + if ticket.meta.workflow_state != TicketWorkflowState::Ready { return Err(TicketError::Conflict(format!("Ticket state is {}; only ready Tickets can be queued", ticket.meta.workflow_state.as_str()))); } + let at = now_utc(); + conn.execute("UPDATE typed_tickets SET workflow_state = 'queued', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, queued_by, at]).map_err(sqlite_err)?; + self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(queued_by.to_string()), at: Some(at), status: None, from: Some("ready".to_string()), to: Some("queued".to_string()), reason: Some("queued".to_string()), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!("Queued for Orchestrator by {queued_by}.")), references: Vec::new(), attributes: BTreeMap::new() }) + }) + } + + fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> { + self.with_write(|conn| { + let ticket_id = self.resolve_ticket_id(conn, id)?; + let at = now_utc(); + let mut attributes = BTreeMap::new(); + attributes.insert("result".to_string(), review.result.as_str().to_string()); + self.insert_event( + conn, + &ticket_id, + &TicketEvent { + kind: TicketEventKind::Review, + author: Some(review.author.unwrap_or_else(default_author)), + at: Some(at.clone()), + status: Some(review.result.as_str().to_string()), + from: None, + to: None, + reason: None, + state_field: None, + heading: Some(review.result.heading()), + body: review.body, + references: Vec::new(), + attributes, + }, + )?; + self.touch_ticket(conn, &ticket_id, &at) + }) + } + + fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> { + self.with_write(|conn| { + let ticket_id = self.resolve_ticket_id(conn, id)?; + let at = now_utc(); + conn.execute("UPDATE typed_tickets SET status = 'closed', workflow_state = 'closed', workflow_state_explicit = 1, updated_at = ?3, resolution = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, at, resolution.as_str()]).map_err(sqlite_err)?; + self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::Close, author: Some(default_author()), at: Some(at), status: Some("closed".to_string()), from: None, to: Some("closed".to_string()), reason: None, state_field: Some("state".to_string()), heading: Some(TicketEventKind::Close.heading()), body: resolution, references: Vec::new(), attributes: BTreeMap::new() }) + }) + } + + fn add_ticket_relation( + &self, + id: TicketIdOrSlug, + relation: NewTicketRelation, + ) -> Result { + self.with_write(|conn| { + let ticket_id = self.resolve_ticket_id(conn, id)?; + let target = + self.resolve_ticket_id(conn, TicketIdOrSlug::Query(relation.target.clone()))?; + let output = TicketRelation { + ticket_id, + kind: relation.kind, + target, + note: relation.note, + author: relation.author.unwrap_or_else(default_author), + at: now_utc(), + }; + self.insert_relation(conn, &output)?; + Ok(output) + }) + } + + fn query_ticket_relations( + &self, + ticket: Option, + kind: Option, + ) -> Result> { + self.with_read(|conn| { + let ticket_id = ticket + .map(|id| self.resolve_ticket_id(conn, id)) + .transpose()?; + let mut relations = self.all_relations(conn)?; + if let Some(ticket_id) = ticket_id { + relations.retain(|relation| { + relation.ticket_id == ticket_id || relation.target == ticket_id + }); + } + if let Some(kind) = kind { + relations.retain(|relation| relation.kind == kind); + } + sort_ticket_relations(&mut relations); + Ok(relations) + }) + } + + fn relation_view(&self, id: TicketIdOrSlug) -> Result { + self.with_read(|conn| { + let ticket_id = self.resolve_ticket_id(conn, id)?; + Ok(self.load_ticket(conn, &ticket_id)?.relations) + }) + } + + fn add_orchestration_plan_record( + &self, + id: TicketIdOrSlug, + record: NewOrchestrationPlanRecord, + ) -> Result { + self.with_write(|conn| { + let ticket_id = self.resolve_ticket_id(conn, id)?; + let meta = self.load_ticket(conn, &ticket_id)?.meta; + let output = OrchestrationPlanRecord { id: allocate_record_id(unix_epoch_millis_now().unwrap_or(0), |_| false).map_err(|err| TicketError::Conflict(format!("failed to allocate orchestration plan id: {err}")))?, ticket_id, kind: record.kind, related_ticket: record.related_ticket, note: record.note, accepted_plan: record.accepted_plan, author: record.author.unwrap_or_else(default_author), at: now_utc() }; + validate_orchestration_plan_record(&output, Some(&meta))?; + conn.execute(r#"INSERT INTO typed_ticket_orchestration_plans (workspace_id, ticket_id, record_id, kind, related_ticket, note, accepted_summary, accepted_branch, accepted_worktree, accepted_role_plan, author, at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)"#, + params![self.workspace_id, output.ticket_id, output.id, output.kind.as_str(), output.related_ticket, output.note, output.accepted_plan.as_ref().map(|plan| plan.summary.as_str()), output.accepted_plan.as_ref().and_then(|plan| plan.branch.as_deref()), output.accepted_plan.as_ref().and_then(|plan| plan.worktree.as_deref()), output.accepted_plan.as_ref().and_then(|plan| plan.role_plan.as_deref()), output.author, output.at]).map_err(sqlite_err)?; + Ok(output) + }) + } + + fn query_orchestration_plan_records( + &self, + ticket: Option, + kind: Option, + ) -> Result> { + self.with_read(|conn| { + let ticket_id = ticket.map(|id| self.resolve_ticket_id(conn, id)).transpose()?; + let mut stmt = conn.prepare("SELECT ticket_id, record_id, kind, related_ticket, note, accepted_summary, accepted_branch, accepted_worktree, accepted_role_plan, author, at FROM typed_ticket_orchestration_plans WHERE workspace_id = ?1 ORDER BY at ASC, record_id ASC").map_err(sqlite_err)?; + let rows = stmt.query_map(params![self.workspace_id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, Option>(3)?, row.get::<_, Option>(4)?, row.get::<_, Option>(5)?, row.get::<_, Option>(6)?, row.get::<_, Option>(7)?, row.get::<_, Option>(8)?, row.get::<_, String>(9)?, row.get::<_, String>(10)?))).map_err(sqlite_err)?; + let mut records = Vec::new(); + for row in rows { + let (record_ticket, id, kind_raw, related_ticket, note, summary, branch, worktree, role_plan, author, at) = row.map_err(sqlite_err)?; + if ticket_id.as_ref().is_some_and(|ticket_id| ticket_id != &record_ticket) { continue; } + let Some(record_kind) = OrchestrationPlanKind::parse(&kind_raw) else { continue; }; + if kind.is_some_and(|expected| expected != record_kind) { continue; } + records.push(OrchestrationPlanRecord { ticket_id: record_ticket, id, kind: record_kind, related_ticket, note, accepted_plan: summary.map(|summary| AcceptedOrchestrationPlan { summary, branch, worktree, role_plan }), author, at }); + } + Ok(records) + }) + } + + fn doctor(&self) -> Result { + self.with_read(|conn| { + let _ = self.list_summaries(conn, TicketListQuery::all())?; + Ok(TicketDoctorReport::default()) + }) + } +} + impl TicketBackend for LocalTicketBackend { fn default_intake_ready_state_change_body(&self, from: &str) -> String { self.default_intake_ready_state_change_body(from) @@ -3198,6 +4192,7 @@ fn invalid_ticket_record_reason(error: &TicketError) -> &'static str { "invalid ticket record identity" } TicketError::Locked { .. } => "ticket backend is locked", + TicketError::Sqlite(_) => "could not read ticket record", TicketError::NotFound(_) => "ticket record is missing", TicketError::Ambiguous { .. } | TicketError::Conflict(_) => { "invalid ticket record metadata" @@ -4815,6 +5810,72 @@ state: planning assert!(report.is_ok(), "{:?}", report.diagnostics); } + #[test] + fn sqlite_backend_persists_core_ticket_operations() { + let tmp = TempDir::new().unwrap(); + let backend = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test"); + let created = backend.create(NewTicket::new("SQLite Ticket")).unwrap(); + backend + .add_event( + TicketIdOrSlug::Id(created.id.clone()), + NewTicketEvent::new(TicketEventKind::Comment, "Imported into SQLite."), + ) + .unwrap(); + backend + .review( + TicketIdOrSlug::Id(created.id.clone()), + TicketReview::approve("Looks good."), + ) + .unwrap(); + backend + .close( + TicketIdOrSlug::Id(created.id.clone()), + MarkdownText::new("Done."), + ) + .unwrap(); + + let reopened = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test"); + let list = reopened.list(TicketListQuery::all()).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].id, created.id); + assert_eq!(list[0].workflow_state, TicketWorkflowState::Closed); + let ticket = reopened.show(TicketIdOrSlug::Id(created.id)).unwrap(); + assert_eq!(ticket.meta.title, "SQLite Ticket"); + assert!(ticket.events.iter().any(|event| { + event.kind == TicketEventKind::Comment && event.body.0.contains("Imported into SQLite") + })); + assert!( + ticket + .events + .iter() + .any(|event| event.kind == TicketEventKind::Review + && event.body.0.contains("Looks good")) + ); + assert!( + ticket + .resolution + .as_ref() + .is_some_and(|resolution| resolution.0.contains("Done")) + ); + } + + #[test] + fn sqlite_backend_imports_legacy_local_layout_explicitly() { + let tmp = TempDir::new().unwrap(); + let local = backend(&tmp); + let created = local.create(NewTicket::new("Legacy Ticket")).unwrap(); + let db = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test"); + db.import_from_local_backend(&local).unwrap(); + + let ticket = db.show(TicketIdOrSlug::Id(created.id.clone())).unwrap(); + assert_eq!(ticket.meta.id, created.id); + assert_eq!(ticket.meta.title, "Legacy Ticket"); + + fs::remove_dir_all(local.root()).unwrap(); + let ticket = db.show(TicketIdOrSlug::Id(created.id)).unwrap(); + assert_eq!(ticket.meta.title, "Legacy Ticket"); + } + #[test] fn partial_list_and_show_keep_valid_tickets_when_peer_record_is_invalid() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tui/src/picker.rs b/crates/tui/src/picker.rs index 14c6d638..3c9690e4 100644 --- a/crates/tui/src/picker.rs +++ b/crates/tui/src/picker.rs @@ -219,7 +219,7 @@ fn default_store_dir() -> Result { PickerError::Io(io::Error::new( io::ErrorKind::NotFound, "could not resolve sessions directory \ - (set YOI_HOME, YOI_DATA_DIR, or HOME)", + (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)", )) }) } @@ -231,7 +231,7 @@ fn default_worker_metadata_dir() -> Result { PickerError::Io(io::Error::new( io::ErrorKind::NotFound, "could not resolve worker state directory \ - (set YOI_HOME, YOI_DATA_DIR, or HOME)", + (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)", )) }) } diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 360d8089..aa9230f5 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -121,7 +121,7 @@ impl ProfileRuntimeWorkerFactory { .clone() .or_else(paths::sessions_dir) .ok_or_else(|| { - "could not resolve sessions directory (set YOI_HOME, YOI_DATA_DIR, or HOME)" + "could not resolve sessions directory (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)" .to_string() }) } diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 3134d8cf..7c6d3823 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -449,8 +449,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { } }; // Initialize persistent store. `paths::sessions_dir()` only - // returns None when none of YOI_HOME / YOI_DATA_DIR / - // HOME is set — surface that as a hard error to match the + // returns None when none of YOI_DATA_DIR / YOI_HOME / + // XDG_DATA_HOME / HOME is set — surface that as a hard error to match the // runtime-dir resolution below, rather than silently writing to a // relative path under cwd. let store_dir = match cli.store.clone() { @@ -460,7 +460,7 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { None => { eprintln!( "error: could not resolve sessions directory \ - (set --store, YOI_HOME, YOI_DATA_DIR, or HOME)" + (set --store, YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)" ); return ExitCode::FAILURE; } diff --git a/crates/workspace-server/src/config.rs b/crates/workspace-server/src/config.rs index 19e9701c..57e06eb4 100644 --- a/crates/workspace-server/src/config.rs +++ b/crates/workspace-server/src/config.rs @@ -11,6 +11,7 @@ use crate::server::{AuthConfig, ServerConfig}; use crate::{Error, Result}; pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml"; +pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml"; pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str = include_str!("../../../resources/workspace-backend.default.toml"); const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; @@ -49,6 +50,11 @@ pub struct WorkspaceBackendConfigFile { pub auth: WorkspaceBackendAuthConfig, #[serde(default)] pub repositories: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct BackendRuntimesConfigFile { #[serde(default)] pub runtimes: WorkspaceBackendRuntimesConfig, } @@ -198,6 +204,74 @@ pub struct ResolvedWorkspaceBackendConfig { pub database_path: PathBuf, } +impl BackendRuntimesConfigFile { + pub fn path_for_config_dir(config_dir: impl AsRef) -> PathBuf { + config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME) + } + + pub fn default_path() -> Option { + manifest::paths::config_dir().map(Self::path_for_config_dir) + } + + pub fn load_default() -> Result { + match Self::default_path() { + Some(path) => Self::load_from_path(path), + None => Ok(Self::default()), + } + } + + pub fn load_from_config_dir(config_dir: impl AsRef) -> Result { + Self::load_from_path(Self::path_for_config_dir(config_dir)) + } + + pub fn load_from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + match fs::read_to_string(path) { + Ok(raw) => Self::parse_str(&raw, path), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()), + Err(error) => Err(Error::Io(error)), + } + } + + pub fn write_default(&self) -> Result { + let path = Self::default_path().ok_or_else(|| { + Error::Config( + "YOI_CONFIG_DIR, YOI_HOME, XDG_CONFIG_HOME, or HOME is required to write Backend runtimes config" + .to_string(), + ) + })?; + self.write_to_path(&path)?; + Ok(path) + } + + pub fn write_to_config_dir(&self, config_dir: impl AsRef) -> Result<()> { + self.write_to_path(Self::path_for_config_dir(config_dir)) + } + + pub fn write_to_path(&self, path: impl AsRef) -> Result<()> { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let raw = toml::to_string_pretty(self).map_err(|error| { + Error::Config(format!( + "failed to serialize Backend runtimes config: {error}" + )) + })?; + fs::write(path, raw)?; + Ok(()) + } + + pub fn parse_str(raw: &str, path: impl AsRef) -> Result { + toml::from_str(raw).map_err(|error| { + Error::Config(format!( + "failed to parse Backend runtimes config `{}`: {error}", + path.as_ref().display() + )) + }) + } +} + impl WorkspaceBackendConfigFile { pub fn path_for_workspace(workspace_root: impl AsRef) -> PathBuf { workspace_root @@ -276,6 +350,19 @@ impl WorkspaceBackendConfigFile { &self, workspace_root: impl AsRef, identity: WorkspaceIdentity, + ) -> Result { + self.resolve_with_runtime_config( + workspace_root, + identity, + &BackendRuntimesConfigFile::default(), + ) + } + + pub fn resolve_with_runtime_config( + &self, + workspace_root: impl AsRef, + identity: WorkspaceIdentity, + runtime_config: &BackendRuntimesConfigFile, ) -> Result { let workspace_root = workspace_root.as_ref(); let data_root = self @@ -312,6 +399,7 @@ impl WorkspaceBackendConfigFile { })?; let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity); + server.database_path = database_path.clone(); server.frontend_url = self .server .frontend_url @@ -329,7 +417,7 @@ impl WorkspaceBackendConfigFile { .iter() .map(|repository| resolve_repository(workspace_root, repository)) .collect::>>()?; - server.remote_runtime_sources = self + server.remote_runtime_sources = runtime_config .runtimes .remote .iter() @@ -352,7 +440,9 @@ impl WorkspaceBackendConfigFile { impl ResolvedWorkspaceBackendConfig { pub fn with_database_path(mut self, path: impl Into) -> Self { - self.database_path = path.into(); + let path = path.into(); + self.database_path = path.clone(); + self.server.database_path = path; self } @@ -663,15 +753,80 @@ uri = "https://example.com/org/repo.git" } #[test] - fn token_value_field_is_not_in_schema() { + fn backend_runtimes_config_loads_from_config_dir() { + let dir = tempfile::tempdir().unwrap(); + let config = BackendRuntimesConfigFile { + runtimes: WorkspaceBackendRuntimesConfig { + remote: vec![RemoteRuntimeConfigFile { + id: "arc".to_string(), + endpoint: "http://127.0.0.1:38800".to_string(), + display_name: Some("arc".to_string()), + token_ref: None, + }], + }, + }; + config.write_to_config_dir(dir.path()).unwrap(); + let loaded = BackendRuntimesConfigFile::load_from_config_dir(dir.path()).unwrap(); + assert_eq!(loaded, config); + assert_eq!( + BackendRuntimesConfigFile::path_for_config_dir(dir.path()), + dir.path().join("runtimes.toml") + ); + } + + #[test] + fn workspace_backend_config_rejects_runtime_entries() { let error = WorkspaceBackendConfigFile::parse_str( r#" [[runtimes.remote]] +id = "arc" +endpoint = "http://legacy.example.test" +display_name = "legacy arc" +"#, + "test", + ) + .unwrap_err(); + assert!( + error.to_string().contains("unknown field `runtimes`"), + "unexpected error: {error}" + ); + } + + #[test] + fn backend_runtimes_config_is_the_only_runtime_source() { + let dir = tempfile::tempdir().unwrap(); + let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap(); + let runtime_config = BackendRuntimesConfigFile::parse_str( + r#" +[[runtimes.remote]] +id = "arc" +endpoint = "http://xdg.example.test" +display_name = "xdg arc" +"#, + "runtimes.toml", + ) + .unwrap(); + let resolved = workspace_config + .resolve_with_runtime_config(dir.path(), identity(), &runtime_config) + .unwrap(); + assert_eq!(resolved.server.remote_runtime_sources.len(), 1); + assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc"); + assert_eq!( + resolved.server.remote_runtime_sources[0].base_url.as_str(), + "http://xdg.example.test" + ); + } + + #[test] + fn token_value_field_is_not_in_runtime_schema() { + let error = BackendRuntimesConfigFile::parse_str( + r#" +[[runtimes.remote]] id = "remote" endpoint = "http://127.0.0.1:8790" token = "secret" "#, - "test", + "runtimes.toml", ) .unwrap_err(); assert!( @@ -683,17 +838,22 @@ token = "secret" #[test] fn token_ref_fails_closed_until_secret_resolution_exists() { let dir = tempfile::tempdir().unwrap(); - let config = WorkspaceBackendConfigFile::parse_str( + let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap(); + let runtime_config = BackendRuntimesConfigFile::parse_str( r#" [[runtimes.remote]] id = "remote" endpoint = "http://127.0.0.1:8790" token_ref = "local:remote-token" "#, - "test", + "runtimes.toml", ) .unwrap(); - let error = match config.resolve(dir.path(), identity()) { + let error = match workspace_config.resolve_with_runtime_config( + dir.path(), + identity(), + &runtime_config, + ) { Ok(_) => panic!("token_ref should fail closed until secret resolution exists"), Err(error) => error, }; diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 02dbb199..528da8cc 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -19,8 +19,9 @@ pub mod skills; pub mod store; pub use config::{ - ConfigDiff, ResolvedWorkspaceBackendConfig, WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, - WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, + BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig, + WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE, + WorkspaceBackendConfigFile, }; pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use records::{ diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index 57d7a78f..5ab1d444 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use tokio::net::TcpListener; use yoi_workspace_server::{ - SqliteWorkspaceStore, WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, - WorkspaceIdentity, serve, + BackendRuntimesConfigFile, SqliteWorkspaceStore, WORKSPACE_BACKEND_CONFIG_TEMPLATE, + WorkspaceBackendConfigFile, WorkspaceIdentity, serve, }; #[derive(Debug)] @@ -169,7 +169,9 @@ fn run_skills(command: SkillsCommand) -> Result<(), Box> async fn run_serve(options: ServeOptions) -> Result<(), Box> { let identity = WorkspaceIdentity::load_required(&options.workspace)?; let config_file = WorkspaceBackendConfigFile::load_for_workspace(&options.workspace)?; - let mut resolved = config_file.resolve(&options.workspace, identity)?; + let runtime_config = BackendRuntimesConfigFile::load_default()?; + let mut resolved = + config_file.resolve_with_runtime_config(&options.workspace, identity, &runtime_config)?; if let Some(db) = options.db { resolved = resolved.with_database_path(db); } @@ -311,8 +313,7 @@ fn parse_init_options(args: &[String]) -> Result { } fn parse_serve_options(args: &[String]) -> Result { - let mut workspace = std::env::current_dir() - .map_err(|error| CliError(format!("failed to resolve current directory: {error}")))?; + let mut workspace = None; let mut db = None; let mut frontend = None; let mut listen = None; @@ -326,7 +327,7 @@ fn parse_serve_options(args: &[String]) -> Result { let value = args .get(index) .ok_or_else(|| CliError("--workspace requires a value".to_string()))?; - workspace = PathBuf::from(value); + workspace = Some(PathBuf::from(value)); } "--db" => { index += 1; @@ -350,7 +351,7 @@ fn parse_serve_options(args: &[String]) -> Result { listen = Some(parse_listen(value)?); } _ if arg.starts_with("--workspace=") => { - workspace = PathBuf::from(value_after_equals(arg, "--workspace")?); + workspace = Some(PathBuf::from(value_after_equals(arg, "--workspace")?)); } _ if arg.starts_with("--db=") => { db = Some(PathBuf::from(value_after_equals(arg, "--db")?)); @@ -373,6 +374,8 @@ fn parse_serve_options(args: &[String]) -> Result { index += 1; } + let workspace = workspace + .ok_or_else(|| CliError("serve requires --workspace ; the current directory is no longer used as an implicit workspace".to_string()))?; let workspace = workspace.canonicalize().map_err(|error| { CliError(format!( "failed to canonicalize workspace `{}`: {error}", @@ -431,7 +434,7 @@ fn print_skills_help() { fn print_serve_help() { println!( - "yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nDescription:\n Serves an already initialized Workspace. Run `yoi workspace init` first.\n\nOptions:\n --workspace Workspace root containing .yoi project records (defaults to cwd)\n --db SQLite database path (legacy dev override)\n --frontend Static SPA build directory to serve (legacy dev override)\n --listen Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help" + "yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve --workspace [OPTIONS]\n\nDescription:\n Serves an already initialized Workspace. Run `yoi workspace init --workspace ` first. Backend serve no longer treats the process current directory as an implicit Workspace.\n\nOptions:\n --workspace Workspace root containing .yoi project records (required)\n --db SQLite database path (legacy dev override)\n --frontend Static SPA build directory to serve (legacy dev override)\n --listen Listen address (legacy dev override; default 127.0.0.1:8787)\n -h, --help Print help" ); } @@ -451,6 +454,38 @@ mod tests { assert_eq!(options.workspace, temp.path().canonicalize().unwrap()); } + #[test] + fn parse_serve_rejects_missing_workspace() { + let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()]; + let error = parse_serve_options(&args).unwrap_err(); + assert_eq!( + error.to_string(), + "serve requires --workspace ; the current directory is no longer used as an implicit workspace" + ); + } + + #[test] + fn parse_serve_requires_explicit_workspace() { + let temp = tempfile::tempdir().unwrap(); + let args = vec![ + "--workspace".to_string(), + temp.path().display().to_string(), + "--listen".to_string(), + "127.0.0.1:0".to_string(), + ]; + let options = parse_serve_options(&args).unwrap(); + assert_eq!(options.workspace, temp.path().canonicalize().unwrap()); + assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap()); + } + + #[test] + fn parse_serve_accepts_equals_workspace() { + let temp = tempfile::tempdir().unwrap(); + let args = vec![format!("--workspace={}", temp.path().display())]; + let options = parse_serve_options(&args).unwrap(); + assert_eq!(options.workspace, temp.path().canonicalize().unwrap()); + } + #[test] fn init_creates_identity_and_local_config_only() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 800bc7e6..ea3297b4 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -3,8 +3,7 @@ use std::path::{Path, PathBuf}; use project_record::validate_record_id; use serde::{Deserialize, Serialize}; -use ticket::config::TicketConfig; -use ticket::{LocalTicketBackend, TicketIdOrSlug, TicketListQuery}; +use ticket::{SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery}; use crate::{Error, Result}; @@ -14,19 +13,19 @@ const SUMMARY_BODY_LIMIT: usize = 240; #[derive(Debug, Clone)] pub struct LocalProjectRecordReader { workspace_root: PathBuf, - ticket_backend: LocalTicketBackend, + ticket_backend: SqliteTicketBackend, } impl LocalProjectRecordReader { - pub fn new(workspace_root: impl Into) -> Result { + pub fn new( + workspace_root: impl Into, + database_path: impl Into, + workspace_id: impl Into, + ) -> Result { let workspace_root = workspace_root.into(); - let ticket_config = TicketConfig::load_workspace(&workspace_root) - .map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?; - let ticket_backend = LocalTicketBackend::new(ticket_config.backend_root().to_path_buf()) - .with_record_language(ticket_config.ticket_record_language()); Ok(Self { workspace_root, - ticket_backend, + ticket_backend: SqliteTicketBackend::new(database_path, workspace_id), }) } @@ -35,9 +34,9 @@ impl LocalProjectRecordReader { } pub fn list_tickets(&self, limit: usize) -> Result> { - let partial = self.ticket_backend.list_partial(TicketListQuery::all())?; - let mut items = partial - .tickets + let mut items = self + .ticket_backend + .list(TicketListQuery::all())? .into_iter() .map(|item| TicketSummary { id: item.id, @@ -47,7 +46,7 @@ impl LocalProjectRecordReader { updated_at: item.updated_at, queued_by: item.queued_by, queued_at: item.queued_at, - record_source: "local_yoi_ticket".to_string(), + record_source: "sqlite_yoi_ticket".to_string(), }) .collect::>(); items.sort_by(|a, b| { @@ -58,24 +57,16 @@ impl LocalProjectRecordReader { items.truncate(limit.min(200)); Ok(ProjectRecordList { items, - invalid_records: partial - .invalid_records - .into_iter() - .map(|record| InvalidProjectRecord { - label: record.label, - reason: record.reason, - }) - .collect(), + invalid_records: Vec::new(), record_authority: "local_yoi_project_records".to_string(), }) } pub fn ticket(&self, id: &str) -> Result { validate_project_id(id)?; - let partial = self + let ticket = self .ticket_backend - .show_partial(TicketIdOrSlug::Id(id.to_string()))?; - let ticket = partial.ticket; + .show(TicketIdOrSlug::Id(id.to_string()))?; let (body, body_truncated) = truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT); Ok(TicketDetail { @@ -92,7 +83,7 @@ impl LocalProjectRecordReader { body_truncated, event_count: ticket.events.len(), artifact_count: ticket.artifacts.len(), - record_source: "local_yoi_ticket".to_string(), + record_source: "sqlite_yoi_ticket".to_string(), }) } @@ -296,14 +287,21 @@ mod tests { use super::*; #[test] - fn reads_local_yoi_ticket_and_objective_records_without_migration() { + fn reads_sqlite_yoi_ticket_and_objective_records_without_migration() { let dir = tempfile::tempdir().unwrap(); write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready"); + let db_path = dir.path().join("workspace.db"); + SqliteTicketBackend::new(&db_path, "workspace-test") + .import_from_local_backend(&ticket::LocalTicketBackend::new( + dir.path().join(".yoi/tickets"), + )) + .unwrap(); write_objective(dir.path(), "00000000001J3", "Control plane", "active"); - let reader = LocalProjectRecordReader::new(dir.path()).unwrap(); + let reader = LocalProjectRecordReader::new(dir.path(), &db_path, "workspace-test").unwrap(); let tickets = reader.list_tickets(20).unwrap(); assert_eq!(tickets.record_authority, "local_yoi_project_records"); + assert_eq!(tickets.items[0].record_source, "sqlite_yoi_ticket"); assert_eq!(tickets.items[0].id, "00000000001J2"); assert_eq!(tickets.items[0].state, "ready"); @@ -318,34 +316,16 @@ mod tests { assert!(objective.body.contains("Objective body")); } #[test] - fn reads_tickets_from_workspace_settings_backend_root() { + fn does_not_read_legacy_ticket_files_without_sqlite_import() { let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".yoi")).unwrap(); - fs::write( - dir.path().join(".yoi/workspace.toml"), - r#" -[ticket] -language = "Japanese" + write_ticket(dir.path(), "00000000001J5", "Legacy file", "ready"); + let db_path = dir.path().join("workspace.db"); -[ticket.backend] -provider = "builtin:yoi_local" -root = "project-records/tickets" -"#, - ) - .unwrap(); - write_ticket_at( - &dir.path().join("project-records/tickets"), - "00000000001J4", - "Configured root", - "ready", - ); - write_ticket(dir.path(), "00000000001J5", "Default root", "ready"); - - let reader = LocalProjectRecordReader::new(dir.path()).unwrap(); + let reader = LocalProjectRecordReader::new(dir.path(), &db_path, "workspace-test").unwrap(); let tickets = reader.list_tickets(20).unwrap(); - assert_eq!(tickets.items.len(), 1); - assert_eq!(tickets.items[0].id, "00000000001J4"); + assert!(tickets.items.is_empty()); } + fn write_ticket(root: &Path, id: &str, title: &str, state: &str) { write_ticket_at(&root.join(".yoi/tickets"), id, title, state); } diff --git a/crates/workspace-server/src/repositories.rs b/crates/workspace-server/src/repositories.rs index 1f318b46..9dfaea4c 100644 --- a/crates/workspace-server/src/repositories.rs +++ b/crates/workspace-server/src/repositories.rs @@ -209,7 +209,7 @@ impl RepositoryRegistryReader { kind: repository.provider.clone(), provider: repository.provider.clone(), default_selector: repository.default_selector.clone(), - record_authority: "workspace-backend-config".to_string(), + record_authority: "workspace-control-plane".to_string(), git, diagnostics, } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index cd3990da..fac540e5 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -19,7 +19,7 @@ use protocol::stream::{decode_method, encode_event}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use ticket::{ - LocalTicketBackend, TicketBackendHttpResponse, TicketBackendOperation, + SqliteTicketBackend, TicketBackendHttpResponse, TicketBackendOperation, execute_ticket_backend_operation, }; use tokio::net::TcpListener; @@ -45,7 +45,7 @@ use crate::companion::{ CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse, CompanionTranscriptProjection, }; -use crate::config::{RemoteRuntimeConfigFile, WorkspaceBackendConfigFile, resolve_remote_runtime}; +use crate::config::{BackendRuntimesConfigFile, RemoteRuntimeConfigFile, resolve_remote_runtime}; use crate::hosts::{ ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, RuntimeDiagnostic, RuntimeRegistry, @@ -77,8 +77,8 @@ use crate::resource_broker::BackendResourceBroker; use crate::skills; use crate::store::{ AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore, - DeviceLoginFlowRecord, PasskeyCredentialRecord, UserRecord, WorkdirRegistryRecord, - WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, + DeviceLoginFlowRecord, PasskeyCredentialRecord, RepositoryRecord, UserRecord, + WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, }; use crate::{Error, Result}; use worker_runtime::catalog::{ @@ -120,6 +120,7 @@ pub struct ServerConfig { pub workspace_display_name: String, pub workspace_created_at: String, pub workspace_root: PathBuf, + pub database_path: PathBuf, pub frontend_url: String, pub embedded_runtime_store_root: PathBuf, pub static_assets_dir: Option, @@ -128,6 +129,7 @@ pub struct ServerConfig { pub repositories: Vec, pub runtime_event_sources: Vec, pub remote_runtime_sources: Vec, + pub runtime_config_path: Option, pub backend_base_url: Option, } @@ -136,11 +138,14 @@ impl ServerConfig { let workspace_root = workspace_root.into(); let workspace_id = identity.workspace_id; let embedded_runtime_store_root = Self::default_embedded_runtime_store_root(&workspace_id); + let database_path = + Self::default_workspace_backend_data_root(&workspace_id).join("workspace.db"); Self { workspace_id, workspace_display_name: identity.display_name, workspace_created_at: identity.created_at, workspace_root, + database_path, frontend_url: "http://127.0.0.1:5173".to_string(), embedded_runtime_store_root, static_assets_dir: None, @@ -154,6 +159,7 @@ impl ServerConfig { repositories: Vec::new(), runtime_event_sources: Vec::new(), remote_runtime_sources: Vec::new(), + runtime_config_path: BackendRuntimesConfigFile::default_path(), backend_base_url: None, } } @@ -246,7 +252,7 @@ impl WorkspaceApi { } async fn new_with_execution_backend_and_broker( - config: ServerConfig, + mut config: ServerConfig, store: Arc, execution_backend: Arc, resource_broker: BackendResourceBroker, @@ -261,6 +267,8 @@ impl WorkspaceApi { updated_at: config.workspace_created_at.clone(), }) .await?; + import_configured_repositories(store.as_ref(), &config)?; + config.repositories = load_configured_repositories_from_store(store.as_ref(), &config)?; let runtime = RuntimeRegistry::for_workspace( EmbeddedWorkerRuntime::new_fs_store_with_execution_backend( config.workspace_id.clone(), @@ -298,7 +306,11 @@ impl WorkspaceApi { let companion = Arc::new(CompanionConsole::disabled()); let observation_proxy = BackendObservationProxy::new(config.runtime_event_sources.clone()); Ok(Self { - records: LocalProjectRecordReader::new(config.workspace_root.clone())?, + records: LocalProjectRecordReader::new( + config.workspace_root.clone(), + config.database_path.clone(), + config.workspace_id.clone(), + )?, config, store, runtime, @@ -317,6 +329,76 @@ impl WorkspaceApi { } } +fn import_configured_repositories( + store: &dyn ControlPlaneStore, + config: &ServerConfig, +) -> Result<()> { + if config.repositories.is_empty() { + return Ok(()); + } + let now = crate::auth::now_rfc3339(); + for repository in &config.repositories { + store.upsert_repository(&RepositoryRecord { + workspace_id: config.workspace_id.clone(), + repository_id: repository.id.clone(), + name: repository + .display_name + .clone() + .unwrap_or_else(|| repository.id.clone()), + kind: repository.provider.clone(), + provider: Some(repository.provider.clone()), + uri: repository.uri.clone(), + default_ref: repository.default_selector.clone(), + auth_ref_kind: None, + auth_ref_key: None, + created_at: now.clone(), + updated_at: now.clone(), + })?; + } + Ok(()) +} + +fn load_configured_repositories_from_store( + store: &dyn ControlPlaneStore, + config: &ServerConfig, +) -> Result> { + store + .list_repositories(&config.workspace_id)? + .into_iter() + .map(|record| configured_repository_from_record(&config.workspace_root, record)) + .collect() +} + +fn configured_repository_from_record( + workspace_root: &Path, + record: RepositoryRecord, +) -> Result { + let provider = record.provider.unwrap_or_else(|| record.kind.clone()); + if record.uri.contains("://") { + return Err(Error::Config(format!( + "repository `{}` uses a remote URI, but remote repository materialization is not implemented", + record.repository_id + ))); + } + let path = resolve_backend_path(workspace_root, Path::new(&record.uri)); + Ok(ConfiguredRepository { + id: record.repository_id, + provider, + uri: record.uri, + path, + display_name: Some(record.name), + default_selector: record.default_ref, + }) +} + +fn resolve_backend_path(workspace_root: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + workspace_root.join(path) + } +} + pub fn build_router(api: WorkspaceApi) -> Router { Router::new() .route("/api/auth/config", get(get_auth_config)) @@ -1316,8 +1398,11 @@ async fn scoped_ticket_backend_operation( validate_workspace_scope(&api, &path.workspace_id)?; let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root) .map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?; - let backend = LocalTicketBackend::new(config.backend_root().to_path_buf()) - .with_record_language(config.ticket_record_language()); + let backend = SqliteTicketBackend::new( + api.config.database_path.clone(), + api.config.workspace_id.clone(), + ) + .with_record_language(config.ticket_record_language()); let response = match execute_ticket_backend_operation(&backend, operation) { Ok(result) => TicketBackendHttpResponse::Ok { result }, Err(error) => TicketBackendHttpResponse::Error { @@ -3358,7 +3443,7 @@ async fn list_repositories( Ok(Json(RepositoryListResponse { workspace_id: api.config.workspace_id, items, - source: "workspace_backend_config".to_string(), + source: "workspace-control-plane".to_string(), diagnostics: repository_diagnostics(diagnostics), })) } @@ -3371,7 +3456,7 @@ async fn repository_detail( Ok(Json(RepositoryDetailResponse { workspace_id: api.config.workspace_id.clone(), item, - source: "workspace_backend_config".to_string(), + source: "workspace-control-plane".to_string(), })) } @@ -3466,10 +3551,10 @@ async fn list_workers( async fn get_runtime_connection_settings( State(api): State, ) -> ApiResult> { - let local_config = load_workspace_backend_config_for_settings(&api)?; + let runtime_config = load_backend_runtimes_config_for_settings(&api)?; Ok(Json(runtime_connection_settings_response( &api, - &local_config, + &runtime_config, ))) } @@ -3478,7 +3563,7 @@ async fn add_remote_runtime_connection( Json(request): Json, ) -> ApiResult> { validate_runtime_connection_request(&request)?; - let mut local_config = load_workspace_backend_config_for_settings(&api)?; + let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?; let id = request.runtime_id.trim().to_string(); if id == EMBEDDED_WORKER_RUNTIME_ID { return Err(settings_bad_request( @@ -3496,7 +3581,7 @@ async fn add_remote_runtime_connection( "remote Runtime token_ref persistence is not supported by this v0 browser settings surface", )); } - if local_config + if runtime_config .runtimes .remote .iter() @@ -3538,12 +3623,12 @@ async fn add_remote_runtime_connection( ) .map(|host| host.with_resource_broker(api.resource_broker.clone())) .map_err(|err| err.into_error())?; - local_config.runtimes.remote.push(remote_config); - write_workspace_backend_config_for_settings(&api, &local_config)?; + runtime_config.runtimes.remote.push(remote_config); + write_backend_runtimes_config_for_settings(&api, &runtime_config)?; api.runtime.register_or_replace(active_runtime); let mut response = runtime_connection_mutation_response( &api, - &local_config, + &runtime_config, vec![settings_diagnostic( "runtime_registry_applied", DiagnosticSeverity::Info, @@ -3551,9 +3636,9 @@ async fn add_remote_runtime_connection( )], ); response.diagnostics.push(settings_diagnostic( - "workspace_backend_config_rewritten", + "backend_runtimes_config_rewritten", DiagnosticSeverity::Info, - "Local Runtime connection config was rewritten from the typed schema; comments and formatting are not preserved in v0.", + "Backend runtimes config was rewritten from the typed schema; comments and formatting are not preserved in v0.", )); Ok(Json(response)) } @@ -3568,13 +3653,13 @@ async fn delete_remote_runtime_connection( "the embedded Runtime is built in and cannot be deleted from remote Runtime config", )); } - let mut local_config = load_workspace_backend_config_for_settings(&api)?; - let before = local_config.runtimes.remote.len(); - local_config + let mut runtime_config = load_backend_runtimes_config_for_settings(&api)?; + let before = runtime_config.runtimes.remote.len(); + runtime_config .runtimes .remote .retain(|remote| remote.id != runtime_id); - if before == local_config.runtimes.remote.len() { + if before == runtime_config.runtimes.remote.len() { return Err(Error::UnknownRuntime(runtime_id).into()); } match api @@ -3605,10 +3690,10 @@ async fn delete_remote_runtime_connection( )); } } - write_workspace_backend_config_for_settings(&api, &local_config)?; + write_backend_runtimes_config_for_settings(&api, &runtime_config)?; let mut response = runtime_connection_mutation_response( &api, - &local_config, + &runtime_config, vec![settings_diagnostic( "runtime_registry_applied", DiagnosticSeverity::Info, @@ -3616,9 +3701,9 @@ async fn delete_remote_runtime_connection( )], ); response.diagnostics.push(settings_diagnostic( - "workspace_backend_config_rewritten", + "backend_runtimes_config_rewritten", DiagnosticSeverity::Info, - "Local Runtime connection config was rewritten from the typed schema; comments and formatting are not preserved in v0.", + "Backend runtimes config was rewritten from the typed schema; comments and formatting are not preserved in v0.", )); Ok(Json(response)) } @@ -3627,8 +3712,8 @@ async fn test_remote_runtime_connection( State(api): State, AxumPath(runtime_id): AxumPath, ) -> ApiResult> { - let local_config = load_workspace_backend_config_for_settings(&api)?; - let remote = local_config + let runtime_config = load_backend_runtimes_config_for_settings(&api)?; + let remote = runtime_config .runtimes .remote .iter() @@ -4483,54 +4568,64 @@ fn workers_response(api: WorkspaceApi) -> ApiResult ApiResult { - WorkspaceBackendConfigFile::load_for_workspace(&api.config.workspace_root).map_err(|error| { +) -> ApiResult { + api.config + .runtime_config_path + .as_ref() + .map(BackendRuntimesConfigFile::load_from_path) + .transpose() + .map_err(|error| { + Error::Config(format!( + "failed to read Backend runtimes config for Runtime connections: {}", + sanitize_backend_error(&error.to_string()) + )) + .into() + }) + .map(|config| config.unwrap_or_default()) +} + +fn write_backend_runtimes_config_for_settings( + api: &WorkspaceApi, + runtime_config: &BackendRuntimesConfigFile, +) -> ApiResult<()> { + let path = api.config.runtime_config_path.as_ref().ok_or_else(|| { + Error::Config( + "Backend runtimes config path is unavailable; set YOI_CONFIG_DIR, YOI_HOME, XDG_CONFIG_HOME, or HOME" + .to_string(), + ) + })?; + runtime_config.write_to_path(path).map_err(|error| { Error::Config(format!( - "failed to read workspace backend local config for Runtime connections: {}", + "failed to write Backend runtimes config for Runtime connections: {}", sanitize_backend_error(&error.to_string()) )) .into() }) } -fn write_workspace_backend_config_for_settings( - api: &WorkspaceApi, - local_config: &WorkspaceBackendConfigFile, -) -> ApiResult<()> { - local_config - .write_for_workspace(&api.config.workspace_root) - .map_err(|error| { - Error::Config(format!( - "failed to write workspace backend local config for Runtime connections: {}", - sanitize_backend_error(&error.to_string()) - )) - .into() - }) -} - fn runtime_connection_settings_response( api: &WorkspaceApi, - local_config: &WorkspaceBackendConfigFile, + runtime_config: &BackendRuntimesConfigFile, ) -> RuntimeConnectionSettingsResponse { RuntimeConnectionSettingsResponse { workspace_id: api.config.workspace_id.clone(), embedded: embedded_runtime_connection_summary(api), - remotes: remote_runtime_connection_summaries(api, local_config, false), + remotes: remote_runtime_connection_summaries(api, runtime_config, false), diagnostics: Vec::new(), } } fn runtime_connection_mutation_response( api: &WorkspaceApi, - local_config: &WorkspaceBackendConfigFile, + runtime_config: &BackendRuntimesConfigFile, diagnostics: Vec, ) -> RuntimeConnectionMutationResponse { RuntimeConnectionMutationResponse { workspace_id: api.config.workspace_id.clone(), restart_required: false, - remotes: remote_runtime_connection_summaries(api, local_config, false), + remotes: remote_runtime_connection_summaries(api, runtime_config, false), diagnostics, } } @@ -4576,14 +4671,14 @@ fn embedded_runtime_connection_summary(api: &WorkspaceApi) -> RuntimeConnectionS fn remote_runtime_connection_summaries( api: &WorkspaceApi, - local_config: &WorkspaceBackendConfigFile, + runtime_config: &BackendRuntimesConfigFile, restart_required: bool, ) -> Vec { let live_runtimes = api .runtime .list_runtimes(api.config.max_records.min(200)) .items; - local_config + runtime_config .runtimes .remote .iter() @@ -6191,6 +6286,7 @@ impl IntoResponse for ApiError { #[cfg(test)] mod tests { use super::*; + use crate::config::WorkspaceBackendRuntimesConfig; use axum::body::{Body, to_bytes}; use axum::http::Request; use futures::{SinkExt, StreamExt}; @@ -6750,6 +6846,7 @@ mod tests { let store_root = workspace_root.join(".test-embedded-runtime-store"); let mut config = ServerConfig::local_dev(workspace_root.clone(), test_identity()) .with_embedded_runtime_store_root(store_root); + config.runtime_config_path = Some(workspace_root.join(".test-config/runtimes.toml")); config.repositories = vec![ConfiguredRepository { id: TEST_REPOSITORY_ID.to_string(), provider: "git".to_string(), @@ -6796,7 +6893,7 @@ mod tests { } #[tokio::test] - async fn ticket_backend_endpoint_uses_workspace_settings_backend_root() { + async fn ticket_backend_endpoint_uses_workspace_sqlite_backend() { let dir = tempfile::tempdir().unwrap(); fs::create_dir_all(dir.path().join(".yoi")).unwrap(); fs::write( @@ -6809,7 +6906,7 @@ mod tests { let api = test_api(dir.path()).await; let Json(response) = scoped_ticket_backend_operation( - State(api), + State(api.clone()), AxumPath(ScopedWorkspacePath { workspace_id: TEST_WORKSPACE_ID.to_string(), }), @@ -6826,12 +6923,13 @@ mod tests { } => ticket_ref, other => panic!("unexpected ticket backend response: {other:?}"), }; + assert!(api.config.database_path.is_file()); assert!( - dir.path() + !dir.path() .join("server-tickets") .join(&ticket_ref.id) .join("item.md") - .is_file() + .exists() ); assert!( !dir.path() @@ -7423,7 +7521,10 @@ mod tests { let projected = serde_json::to_string(&added).unwrap(); assert!(!projected.contains("runtime.example.invalid")); - let persisted = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap(); + let persisted = BackendRuntimesConfigFile::load_from_path( + dir.path().join(".test-config/runtimes.toml"), + ) + .unwrap(); assert_eq!(persisted.runtimes.remote.len(), 1); assert_eq!(persisted.runtimes.remote[0].id, "team-runtime"); assert_eq!( @@ -7462,7 +7563,10 @@ mod tests { .iter() .any(|runtime| runtime["runtime_id"] == "team-runtime") ); - let persisted = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap(); + let persisted = BackendRuntimesConfigFile::load_from_path( + dir.path().join(".test-config/runtimes.toml"), + ) + .unwrap(); assert!(persisted.runtimes.remote.is_empty()); } @@ -7524,7 +7628,10 @@ mod tests { .iter() .any(|diagnostic| { diagnostic["code"] == "remote_runtime_delete_blocked" }) ); - let persisted = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap(); + let persisted = BackendRuntimesConfigFile::load_from_path( + dir.path().join(".test-config/runtimes.toml"), + ) + .unwrap(); assert_eq!(persisted.runtimes.remote.len(), 1); } @@ -7545,8 +7652,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let endpoint = format!("http://{runtime_addr}"); - WorkspaceBackendConfigFile { - runtimes: crate::config::WorkspaceBackendRuntimesConfig { + BackendRuntimesConfigFile { + runtimes: WorkspaceBackendRuntimesConfig { remote: vec![RemoteRuntimeConfigFile { id: "probe-runtime".to_string(), endpoint: endpoint.clone(), @@ -7554,9 +7661,8 @@ mod tests { token_ref: None, }], }, - ..WorkspaceBackendConfigFile::default() } - .write_for_workspace(dir.path()) + .write_to_path(dir.path().join(".test-config/runtimes.toml")) .unwrap(); let app = test_app(dir.path()).await; @@ -7610,8 +7716,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let endpoint = format!("http://{runtime_addr}"); - WorkspaceBackendConfigFile { - runtimes: crate::config::WorkspaceBackendRuntimesConfig { + BackendRuntimesConfigFile { + runtimes: WorkspaceBackendRuntimesConfig { remote: vec![RemoteRuntimeConfigFile { id: "control-only-runtime".to_string(), display_name: Some("Control-only Runtime".to_string()), @@ -7619,9 +7725,8 @@ mod tests { token_ref: None, }], }, - ..WorkspaceBackendConfigFile::default() } - .write_for_workspace(dir.path()) + .write_to_path(dir.path().join(".test-config/runtimes.toml")) .unwrap(); let app = test_app(dir.path()).await; @@ -7963,7 +8068,7 @@ mod tests { assert_eq!(repositories["items"][0]["kind"], "git"); assert_eq!( repositories["items"][0]["record_authority"], - "workspace-backend-config" + "workspace-control-plane" ); assert!( repositories diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index a48ffaea..2aced935 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -82,6 +82,21 @@ pub struct WorkspaceRecord { pub updated_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RepositoryRecord { + pub workspace_id: String, + pub repository_id: String, + pub name: String, + pub kind: String, + pub provider: Option, + pub uri: String, + pub default_ref: Option, + pub auth_ref_kind: Option, + pub auth_ref_key: Option, + pub created_at: String, + pub updated_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AccountRecord { pub account_id: String, @@ -211,6 +226,8 @@ pub trait ControlPlaneStore: Send + Sync { async fn schema_version(&self) -> Result; async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>; async fn get_workspace(&self, workspace_id: &str) -> Result>; + fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()>; + fn list_repositories(&self, workspace_id: &str) -> Result>; fn upsert_account(&self, record: &AccountRecord) -> Result<()>; fn get_account(&self, account_id: &str) -> Result>; @@ -399,6 +416,56 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()> { + self.with_conn(|conn| { + conn.execute( + r#"INSERT INTO repositories ( + workspace_id, repository_id, name, kind, provider, uri, default_ref, + auth_ref_kind, auth_ref_key, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ON CONFLICT(repository_id) DO UPDATE SET + workspace_id = excluded.workspace_id, + name = excluded.name, + kind = excluded.kind, + provider = excluded.provider, + uri = excluded.uri, + default_ref = excluded.default_ref, + auth_ref_kind = excluded.auth_ref_kind, + auth_ref_key = excluded.auth_ref_key, + updated_at = excluded.updated_at"#, + params![ + record.workspace_id, + record.repository_id, + record.name, + record.kind, + record.provider, + record.uri, + record.default_ref, + record.auth_ref_kind, + record.auth_ref_key, + record.created_at, + record.updated_at, + ], + )?; + Ok(()) + }) + } + + fn list_repositories(&self, workspace_id: &str) -> Result> { + self.with_conn(|conn| { + let mut stmt = conn.prepare( + r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref, + auth_ref_kind, auth_ref_key, created_at, updated_at + FROM repositories + WHERE workspace_id = ?1 + ORDER BY repository_id ASC"#, + )?; + let rows = stmt.query_map(params![workspace_id], read_repository_record)?; + rows.collect::, _>>() + .map_err(Error::from) + }) + } + fn upsert_account(&self, record: &AccountRecord) -> Result<()> { self.with_conn(|conn| { conn.execute( @@ -1019,6 +1086,22 @@ fn read_workspace_record(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { + Ok(RepositoryRecord { + workspace_id: row.get(0)?, + repository_id: row.get(1)?, + name: row.get(2)?, + kind: row.get(3)?, + provider: row.get(4)?, + uri: row.get(5)?, + default_ref: row.get(6)?, + auth_ref_kind: row.get(7)?, + auth_ref_key: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + }) +} + fn account_select_sql(where_clause: &str) -> String { format!( "SELECT account_id, kind, handle, display_name, created_at, updated_at FROM accounts {where_clause}" @@ -2274,6 +2357,44 @@ mod tests { ); } + #[tokio::test] + async fn repository_records_round_trip() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + assert_eq!(store.schema_version().await.unwrap(), 9); + let workspace = WorkspaceRecord { + workspace_id: "local-dev".to_string(), + owner_account_id: None, + display_name: "Local Dev".to_string(), + state: "active".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }; + store.upsert_workspace(&workspace).await.unwrap(); + + let repository = RepositoryRecord { + workspace_id: "local-dev".to_string(), + repository_id: "main".to_string(), + name: "Yoi".to_string(), + kind: "git".to_string(), + provider: Some("git".to_string()), + uri: ".".to_string(), + default_ref: Some("HEAD".to_string()), + auth_ref_kind: None, + auth_ref_key: None, + created_at: "2".to_string(), + updated_at: "2".to_string(), + }; + store.upsert_repository(&repository).unwrap(); + assert_eq!( + store.list_repositories("local-dev").unwrap(), + vec![repository] + ); + assert_eq!( + store.list_repositories("other-workspace").unwrap(), + Vec::new() + ); + } + #[tokio::test] async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index a9786394..ccfa450e 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -1053,6 +1053,12 @@ fn parse_workspace_args(args: &[String]) -> Result { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { return Ok(Mode::WorkspaceHelp); } + if !has_workspace_option(rest) { + return Err(ParseError( + "yoi workspace serve requires --workspace ; cwd is no longer forwarded as an implicit workspace" + .to_string(), + )); + } Ok(Mode::WorkspaceServer { subcommand: "serve".to_string(), args: rest.to_vec(), @@ -1065,6 +1071,11 @@ fn parse_workspace_args(args: &[String]) -> Result { } } +fn has_workspace_option(args: &[String]) -> bool { + args.iter() + .any(|arg| arg == "--workspace" || arg.starts_with("--workspace=")) +} + fn run_workspace_server(subcommand: &str, args: Vec) -> ExitCode { let command = match resolve_workspace_server_command() { Ok(command) => command, @@ -1442,13 +1453,13 @@ fn print_help() { println!( "yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace ] [--all]\n yoi workers [--workspace ] [--workspace-id ] [--backend ] [--runtime-id ]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi worker [WORKER_OPTIONS]\n yoi worker delete [--force] [--dry-run]\n yoi worker prune --older-than [--force] [--dry-run]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi session prune --unreferenced [--older-than ] [--force] [--dry-run]\n yoi ticket [OPTIONS]\n yoi workspace init [OPTIONS] yoi workspace config [OPTIONS] - yoi workspace serve [OPTIONS]\n yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id Workspace identity for Backend scoped routes\n --backend Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id Backend Runtime identity for attach/list\n --worker Open the Worker Console by name (attach/restore/create)\n --socket Attach a Worker Console to a specific socket with --worker\n --session Resume a specific session segment in the Worker Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + yoi workspace serve --workspace [OPTIONS]\n yoi plugin new [--json]\n yoi plugin check [--json]\n yoi plugin pack [--output ] [--json]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi mcp list [--workspace ] [--profile ] [--json]\n yoi mcp show [--workspace ] [--profile ] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Worker chat/client surface (default, --worker, yoi resume, Backend Runtime target)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace Runtime workspace root for default Console/--worker/workers (defaults to cwd)\n --workspace-id Workspace identity for Backend scoped routes\n --backend Workspace Backend API URL for Backend Runtime attach/list\n --runtime-id Backend Runtime identity for attach/list\n --worker Open the Worker Console by name (attach/restore/create)\n --socket Attach a Worker Console to a specific socket with --worker\n --session Resume a specific session segment in the Worker Console\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } fn print_workspace_help() { println!( - "yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config [OPTIONS]\n yoi workspace serve [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate.\n\nSubcommands:\n init Initialize .yoi/workspace.toml and .yoi/workspace-backend.local.toml\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n serve Serve an already initialized Workspace\n\nOptions forwarded to init/config diff/serve:\n --workspace Workspace root (defaults to cwd)\n\nLegacy dev options forwarded to serve:\n --db SQLite database path override\n --frontend Static SPA build directory to serve\n --listen Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n" + "yoi workspace\n\nUsage:\n yoi workspace init [OPTIONS]\n yoi workspace config [OPTIONS]\n yoi workspace serve --workspace [OPTIONS]\n\nDescription:\n Launches the separate yoi-workspace-server executable. The yoi binary does not link the workspace server crate. `serve` requires an explicit workspace path and never forwards cwd as an implicit backend workspace.\n\nSubcommands:\n init Initialize .yoi/workspace.toml and .yoi/workspace-backend.local.toml\n config default Print the latest packaged Backend config template\n config diff Compare workspace local config with the packaged template\n serve Serve an explicitly selected Workspace\n\nOptions forwarded to init/config diff:\n --workspace Workspace root (defaults to cwd)\n\nOptions forwarded to serve:\n --workspace Workspace root (required)\n\nLegacy dev options forwarded to serve:\n --db SQLite database path override\n --frontend Static SPA build directory to serve\n --listen Listen address override\n -h, --help Print help\n\nEnvironment:\n YOI_WORKSPACE_SERVER_COMMAND Path to yoi-workspace-server executable override\n" ); } @@ -1689,15 +1700,36 @@ mod tests { #[test] fn parse_workspace_serve_passthrough() { - match parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap() { + match parse_args_from([ + "workspace", + "serve", + "--workspace", + "/tmp/ws", + "--listen", + "127.0.0.1:0", + ]) + .unwrap() + { Mode::WorkspaceServer { subcommand, args } => { assert_eq!(subcommand, "serve"); - assert_eq!(args, vec!["--listen", "127.0.0.1:0"]); + assert_eq!( + args, + vec!["--workspace", "/tmp/ws", "--listen", "127.0.0.1:0"] + ); } other => panic!("unexpected mode: {other:?}"), } } + #[test] + fn parse_workspace_serve_requires_explicit_workspace() { + let err = parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap_err(); + assert_eq!( + err.to_string(), + "yoi workspace serve requires --workspace ; cwd is no longer forwarded as an implicit workspace" + ); + } + #[test] fn parse_workspace_init_passthrough() { match parse_args_from(["workspace", "init", "--workspace", "/tmp/ws"]).unwrap() { diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index 3d8312df..d6729c4d 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -6,14 +6,14 @@ use std::path::{Path, PathBuf}; use chrono::{SecondsFormat, Utc}; use ticket::config::{ - DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, - WORKSPACE_SETTINGS_RELATIVE_PATH, ticket_config_scaffold, + TICKET_CONFIG_RELATIVE_PATH, TicketConfig, WORKSPACE_SETTINGS_RELATIVE_PATH, + ticket_config_scaffold, }; use ticket::{ - LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, - TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery, - TicketListState, TicketRelationKind, TicketReview, TicketReviewResult, TicketSummary, - TicketWorkflowState, + LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, + SqliteTicketBackend, TicketBackend, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, + TicketIntakeSummary, TicketListQuery, TicketListState, TicketRelationKind, TicketReview, + TicketReviewResult, TicketSummary, TicketWorkflowState, }; const DEFAULT_LIST_LIMIT: usize = 50; @@ -30,6 +30,7 @@ pub enum TicketCli { #[derive(Debug, Clone, PartialEq, Eq)] pub enum TicketCommand { Init, + ImportLocal, Create(CreateOptions), List(ListOptions), Show { query: String }, @@ -179,6 +180,14 @@ pub fn parse_ticket_args(args: &[String]) -> Result { } TicketCommand::Init } + "import-local" => { + if args.len() != 1 { + return Err(TicketCliError::new( + "ticket import-local takes no arguments", + )); + } + TicketCommand::ImportLocal + } "create" => TicketCommand::Create(parse_create(&args[1..])?), "list" => TicketCommand::List(parse_list(&args[1..])?), "show" => TicketCommand::Show { @@ -232,19 +241,22 @@ fn run_command( ) -> Result { match command { TicketCommand::Init => init(workspace), + TicketCommand::ImportLocal => import_local(workspace), command => { let backend = backend_for_workspace(workspace)?; match command { - TicketCommand::Create(options) => create(&backend, options), - TicketCommand::List(options) => list(&backend, options), - TicketCommand::Show { query } => show(&backend, query), - TicketCommand::Comment(options) => comment(&backend, options), - TicketCommand::Review(options) => review(&backend, options), - TicketCommand::State(options) => state(&backend, options), - TicketCommand::Close(options) => close(&backend, options), - TicketCommand::Relation(options) => relation(&backend, options), - TicketCommand::Doctor => doctor(&backend), - TicketCommand::Init => unreachable!("init handled before backend setup"), + TicketCommand::Create(options) => create(backend.as_ref(), options), + TicketCommand::List(options) => list(backend.as_ref(), options), + TicketCommand::Show { query } => show(backend.as_ref(), query), + TicketCommand::Comment(options) => comment(backend.as_ref(), options), + TicketCommand::Review(options) => review(backend.as_ref(), options), + TicketCommand::State(options) => state(backend.as_ref(), options), + TicketCommand::Close(options) => close(backend.as_ref(), options), + TicketCommand::Relation(options) => relation(backend.as_ref(), options), + TicketCommand::Doctor => doctor(backend.as_ref()), + TicketCommand::Init | TicketCommand::ImportLocal => { + unreachable!("handled before backend setup") + } } } } @@ -262,9 +274,6 @@ fn init(workspace: &Path) -> Result { let yoi_dir = workspace.join(".yoi"); fs::create_dir_all(&yoi_dir)?; - let tickets_dir = workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH); - fs::create_dir_all(&tickets_dir)?; - fs::write(tickets_dir.join(".gitkeep"), b"")?; let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH); let scaffold = ticket_config_scaffold(); @@ -311,8 +320,8 @@ fn init(workspace: &Path) -> Result { "updated" }; Ok(success(format!( - "{verb}\t{}\nensured\t{}\n", - WORKSPACE_SETTINGS_RELATIVE_PATH, DEFAULT_TICKET_BACKEND_RELATIVE_PATH + "{verb}\t{}\nbackend\tworkspace-sqlite\n", + WORKSPACE_SETTINGS_RELATIVE_PATH ))) } @@ -356,14 +365,92 @@ fn workspace_settings_uuid_v7(workspace: &Path) -> String { ) } -fn backend_for_workspace(workspace: &Path) -> Result { +fn backend_for_workspace(workspace: &Path) -> Result, TicketCliError> { let config = TicketConfig::load_workspace(workspace)?; - Ok(LocalTicketBackend::new(config.backend_root().to_path_buf()) - .with_record_language(config.ticket_record_language())) + let workspace_id = workspace_id_for_workspace(workspace)?; + let db_path = workspace_ticket_database_path(workspace, &workspace_id)?; + Ok(Box::new( + SqliteTicketBackend::new(db_path, workspace_id) + .with_record_language(config.ticket_record_language()), + )) +} + +fn import_local(workspace: &Path) -> Result { + let config = TicketConfig::load_workspace(workspace)?; + let local = LocalTicketBackend::new(config.backend_root().to_path_buf()) + .with_record_language(config.ticket_record_language()); + let workspace_id = workspace_id_for_workspace(workspace)?; + let db_path = workspace_ticket_database_path(workspace, &workspace_id)?; + let sqlite = SqliteTicketBackend::new(db_path.clone(), workspace_id) + .with_record_language(config.ticket_record_language()); + sqlite.import_from_local_backend(&local)?; + Ok(success(format!( + "imported\t{}\nbackend\t{}\n", + config.backend_root().display(), + db_path.display() + ))) +} + +fn workspace_id_for_workspace(workspace: &Path) -> Result { + let settings_path = workspace.join(WORKSPACE_SETTINGS_RELATIVE_PATH); + let raw = fs::read_to_string(&settings_path).map_err(|error| { + TicketCliError::new(format!( + "failed to read workspace settings {}: {error}", + settings_path.display() + )) + })?; + let value: toml::Value = toml::from_str(&raw).map_err(|error| { + TicketCliError::new(format!( + "failed to parse workspace settings {}: {error}", + settings_path.display() + )) + })?; + value + .get("workspace_id") + .and_then(toml::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + TicketCliError::new(format!( + "workspace settings {} must contain workspace_id for SQLite Ticket backend", + settings_path.display() + )) + }) +} + +fn workspace_ticket_database_path( + workspace: &Path, + workspace_id: &str, +) -> Result { + #[cfg(not(test))] + let _ = workspace; + + #[cfg(test)] + { + return Ok(workspace + .join(".test-yoi-data") + .join("workspace-server") + .join(workspace_id) + .join("workspace.db")); + } + + #[cfg(not(test))] + { + let data_dir = manifest::paths::data_dir().ok_or_else(|| { + TicketCliError::new( + "could not resolve Yoi data directory for SQLite Ticket backend (set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)", + ) + })?; + Ok(data_dir + .join("workspace-server") + .join(workspace_id) + .join("workspace.db")) + } } fn create( - backend: &LocalTicketBackend, + backend: &dyn TicketBackend, options: CreateOptions, ) -> Result { let mut input = NewTicket::new(options.title); @@ -374,7 +461,7 @@ fn create( } fn list( - backend: &LocalTicketBackend, + backend: &dyn TicketBackend, options: ListOptions, ) -> Result { let filter = match options.state { @@ -410,7 +497,7 @@ fn list( Ok(success(stdout)) } -fn show(backend: &LocalTicketBackend, query: String) -> Result { +fn show(backend: &dyn TicketBackend, query: String) -> Result { let ticket = backend.show(TicketIdOrSlug::Query(query))?; let mut stdout = String::new(); stdout.push_str(&format!("# {}\n\n", ticket.meta.title)); @@ -545,7 +632,7 @@ fn is_obsolete_ticket_frontmatter_key(key: &str) -> bool { } fn comment( - backend: &LocalTicketBackend, + backend: &dyn TicketBackend, options: CommentOptions, ) -> Result { let role = options.role.as_str().to_string(); @@ -556,7 +643,7 @@ fn comment( } fn review( - backend: &LocalTicketBackend, + backend: &dyn TicketBackend, options: ReviewOptions, ) -> Result { let result = options.result.as_str().to_string(); @@ -573,7 +660,7 @@ fn review( } fn state( - backend: &LocalTicketBackend, + backend: &dyn TicketBackend, options: StateOptions, ) -> Result { let id = TicketIdOrSlug::Query(options.query.clone()); @@ -626,7 +713,7 @@ fn state( } fn close( - backend: &LocalTicketBackend, + backend: &dyn TicketBackend, options: CloseOptions, ) -> Result { backend.close( @@ -637,7 +724,7 @@ fn close( } fn relation( - backend: &LocalTicketBackend, + backend: &dyn TicketBackend, options: RelationOptions, ) -> Result { match options.action { @@ -683,7 +770,7 @@ fn relation( } } -fn doctor(backend: &LocalTicketBackend) -> Result { +fn doctor(backend: &dyn TicketBackend) -> Result { let report = backend.doctor()?; let mut stdout = String::new(); if report.is_ok() { @@ -1166,14 +1253,13 @@ fn default_author() -> String { } fn help_text() -> &'static str { - "yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket create --title \n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml.\n Uses workspace Ticket settings from .yoi/workspace.toml [ticket] when present; .yoi/ticket.config.toml is a read-only migration fallback only.\n Supported provider: builtin:yoi_local.\n Without configured Ticket settings, the local backend root is <cwd>/.yoi/tickets.\n" + "yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket review <id> (--approve|--request-changes) (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|done|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Tickets are stored in the workspace SQLite DB under the Yoi data directory.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n" } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; - use ticket::TicketEventKind; use ticket::config::TicketRole; fn args(items: &[&str]) -> Vec<String> { @@ -1181,6 +1267,13 @@ mod tests { } fn run(temp: &TempDir, items: &[&str]) -> TicketCliOutput { + if items.first().copied() != Some("init") + && !temp.path().join(WORKSPACE_SETTINGS_RELATIVE_PATH).exists() + { + let init_cli = parse_ticket_args(&args(&["init"])).unwrap(); + let init = run_in_workspace(init_cli, temp.path()).unwrap(); + assert_eq!(init.status, TicketCliStatus::Success); + } let cli = parse_ticket_args(&args(items)).unwrap(); run_in_workspace(cli, temp.path()).unwrap() } @@ -1201,9 +1294,8 @@ mod tests { let initialized = run(&temp, &["init"]); assert_eq!(initialized.status, TicketCliStatus::Success); assert!(initialized.stdout.contains("created\t.yoi/workspace.toml")); - assert!(initialized.stdout.contains("ensured\t.yoi/tickets")); - assert!(temp.path().join(".yoi/tickets").exists()); - assert!(temp.path().join(".yoi/tickets/.gitkeep").exists()); + assert!(initialized.stdout.contains("backend\tworkspace-sqlite")); + assert!(!temp.path().join(".yoi/tickets").exists()); let config = fs::read_to_string(temp.path().join(".yoi/workspace.toml")).unwrap(); assert!(config.contains("workspace_id = \"")); @@ -1299,21 +1391,8 @@ mod tests { assert_eq!(created.status, TicketCliStatus::Success); assert!(created.stdout.contains("created\t")); let ticket_id = created_id(&created); - assert!(temp.path().join(".yoi/tickets").join(&ticket_id).exists()); + assert!(!temp.path().join(".yoi/tickets").exists()); assert!(!temp.path().join("work-items").exists()); - let created_item = fs::read_to_string( - temp.path() - .join(".yoi/tickets") - .join(&ticket_id) - .join("item.md"), - ) - .unwrap(); - assert!(created_item.contains("state:")); - assert!(created_item.contains("planning")); - assert!(!created_item.contains("legacy_ticket:")); - assert!(!created_item.contains("needs_preflight:")); - assert!(!created_item.contains("slug:")); - assert!(!created_item.contains("workflow_state:")); let listed = run(&temp, &["list", "--state", "planning"]); assert!(listed.stdout.contains("state\tid\ttitle")); @@ -1395,29 +1474,21 @@ mod tests { assert_eq!(doctor.status, TicketCliStatus::Success); assert_eq!(doctor.stdout, "doctor: ok\n"); - let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); - let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap(); - assert!(ticket.resolution.is_some()); - assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Closed); - assert!( - ticket - .events - .iter() - .any(|event| event.kind == TicketEventKind::ImplementationReport) - ); - assert!( - ticket - .events - .iter() - .any(|event| event.kind == TicketEventKind::Review) - ); + let final_show = run(&temp, &["show", &ticket_id]); + assert!(final_show.stdout.contains("State: closed")); + assert!(final_show.stdout.contains("Done via yoi ticket.")); + assert!(final_show.stdout.contains("implementation_report")); + assert!(final_show.stdout.contains("review")); } #[test] fn ticket_cli_show_omits_obsolete_overlay_fields_from_legacy_frontmatter() { let temp = TempDir::new().unwrap(); - let created = run(&temp, &["create", "--title", "Legacy Overlay"]); - let ticket_id = created_id(&created); + let initialized = run(&temp, &["init"]); + assert_eq!(initialized.status, TicketCliStatus::Success); + let local = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let created = local.create(NewTicket::new("Legacy Overlay")).unwrap(); + let ticket_id = created.id; let item_path = temp .path() .join(".yoi/tickets") @@ -1434,6 +1505,9 @@ mod tests { ) .unwrap(); + let imported = run(&temp, &["import-local"]); + assert_eq!(imported.status, TicketCliStatus::Success); + let shown = run(&temp, &["show", &ticket_id]); assert_eq!(shown.status, TicketCliStatus::Success); assert!(shown.stdout.contains("# Legacy Overlay")); @@ -1496,20 +1570,22 @@ mod tests { } #[test] - fn ticket_cli_uses_configured_backend_root() { + fn ticket_cli_import_local_uses_configured_backend_root() { let temp = TempDir::new().unwrap(); fs::create_dir_all(temp.path().join(".yoi")).unwrap(); fs::write( temp.path().join(".yoi/workspace.toml"), - "[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n", + "workspace_id = \"workspace-test\"\n\n[ticket]\nlanguage = \"Japanese\"\n\n[ticket.backend]\nprovider = \"builtin:yoi_local\"\nroot = \"custom-tickets\"\n", ) .unwrap(); + let local = LocalTicketBackend::new(temp.path().join("custom-tickets")); + let created = local.create(NewTicket::new("Configured Root")).unwrap(); - let created = run(&temp, &["create", "--title", "Configured Root"]); - let ticket_id = created_id(&created); - - assert!(temp.path().join("custom-tickets").join(ticket_id).exists()); - assert!(!temp.path().join("custom-tickets/open").exists()); + let imported = run(&temp, &["import-local"]); + assert_eq!(imported.status, TicketCliStatus::Success); + let shown = run(&temp, &["show", &created.id]); + assert_eq!(shown.status, TicketCliStatus::Success); + assert!(shown.stdout.contains("# Configured Root")); assert!(!temp.path().join("work-items").exists()); } diff --git a/resources/workspace-backend.default.toml b/resources/workspace-backend.default.toml index b15c25d8..11b44bb7 100644 --- a/resources/workspace-backend.default.toml +++ b/resources/workspace-backend.default.toml @@ -60,11 +60,5 @@ cookie_name = "yoi_workspace_session" # display_name = "Main repository" # default_selector = "HEAD" -# Remote Runtime sources. Token values must not be written here. -# Use token_ref only after secret-ref resolution is implemented for this config. -# -# [[runtimes.remote]] -# id = "example" -# endpoint = "http://127.0.0.1:38800" -# display_name = "Example Runtime" -# token_ref = "local:example-runtime-token" +# Runtime registrations live in `$XDG_CONFIG_HOME/yoi/runtimes.toml` +# or `YOI_CONFIG_DIR/runtimes.toml`, not in workspace backend config. diff --git a/tmp-migrate-home-yoi-data-dir.sh b/tmp-migrate-home-yoi-data-dir.sh new file mode 100644 index 00000000..8ea910bf --- /dev/null +++ b/tmp-migrate-home-yoi-data-dir.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env sh +set -eu + +# Move the legacy home data root (`$HOME/.yoi`) to the current Yoi data dir. +# +# IMPORTANT: +# This moves the directory that currently contains live sessions, worker +# runtime state, workdirs, and this checkout's parent path. Do not run while +# Yoi backend/runtime/workers/TUI/web sessions are running. +# +# Recommended usage after shutdown: +# cd /tmp +# RUN_YOI_HOME_MIGRATION=1 sh /path/to/tmp-migrate-home-yoi-data-dir.sh +# +# Destination resolution matches manifest::paths::data_dir(): +# 1. YOI_DATA_DIR +# 2. YOI_HOME +# 3. XDG_DATA_HOME/yoi +# 4. HOME/.local/share/yoi +# +# This script intentionally refuses to merge into a non-empty destination. If +# the destination already has data, inspect and merge manually. + +if [ "${RUN_YOI_HOME_MIGRATION:-}" != "1" ]; then + echo "Refusing to migrate. Set RUN_YOI_HOME_MIGRATION=1 after stopping all Yoi processes." >&2 + exit 1 +fi + +HOME_DIR=${HOME:?HOME is required} +OLD_DIR=${OLD_YOI_DATA_DIR:-"$HOME_DIR/.yoi"} + +if [ -n "${YOI_DATA_DIR:-}" ]; then + NEW_DIR=$YOI_DATA_DIR +elif [ -n "${YOI_HOME:-}" ]; then + NEW_DIR=$YOI_HOME +elif [ -n "${XDG_DATA_HOME:-}" ]; then + NEW_DIR=$XDG_DATA_HOME/yoi +else + NEW_DIR=$HOME_DIR/.local/share/yoi +fi + +if [ ! -d "$OLD_DIR" ]; then + echo "Legacy data dir does not exist: $OLD_DIR" >&2 + exit 1 +fi + +case "$NEW_DIR" in + "$OLD_DIR"|"$OLD_DIR"/*) + echo "Refusing to move into the old data dir or one of its children: $NEW_DIR" >&2 + exit 1 + ;; +esac + +CURRENT_DIR=$(pwd -P) +case "$CURRENT_DIR" in + "$OLD_DIR"|"$OLD_DIR"/*) + echo "Refusing to run from inside $OLD_DIR. cd outside it first, e.g. cd /tmp." >&2 + exit 1 + ;; +esac + +if [ -e "$NEW_DIR" ]; then + if [ -d "$NEW_DIR" ] && [ -z "$(find "$NEW_DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + rmdir "$NEW_DIR" + else + echo "Destination already exists and is not empty: $NEW_DIR" >&2 + echo "Inspect and merge manually; this script will not overwrite or merge data." >&2 + exit 1 + fi +fi + +mkdir -p "$(dirname "$NEW_DIR")" +mv "$OLD_DIR" "$NEW_DIR" + +echo "Moved legacy Yoi data dir:" +echo " from: $OLD_DIR" +echo " to: $NEW_DIR" diff --git a/tmp-migrate-workspace-runtime-and-tickets.sh b/tmp-migrate-workspace-runtime-and-tickets.sh new file mode 100644 index 00000000..c3e93a04 --- /dev/null +++ b/tmp-migrate-workspace-runtime-and-tickets.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env sh +set -eu + +# Migration helper for workspace-local runtime config and legacy Ticket files. +# +# It performs: +# 1. Extract `[[runtimes.remote]]` blocks from `.yoi/workspace-backend.local.toml`. +# 2. Write them to the resolved config-dir `runtimes.toml`. +# 3. Remove those runtime blocks from `.yoi/workspace-backend.local.toml` so the +# updated backend no longer rejects the workspace config. +# 4. Run `cargo run --bin yoi -- ticket import-local` to import `.yoi/tickets` +# into the workspace SQLite DB. +# +# It intentionally does NOT delete legacy files. Run +# `tmp-remove-legacy-yoi-files.sh` only after repository DB import, ticket import, +# and DB location migration have all been verified. +# +# Preconditions: +# - Run from the repository/workspace root. +# - Stop Yoi backend/runtime/workers before running. +# - Use the updated code in this worktree. +# +# Run explicitly with: +# RUN_YOI_WORKSPACE_RUNTIME_TICKET_MIGRATION=1 sh ./tmp-migrate-workspace-runtime-and-tickets.sh + +if [ "${RUN_YOI_WORKSPACE_RUNTIME_TICKET_MIGRATION:-}" != "1" ]; then + echo "Refusing to migrate. Set RUN_YOI_WORKSPACE_RUNTIME_TICKET_MIGRATION=1 after stopping Yoi processes." >&2 + exit 1 +fi + +if [ ! -f ".yoi/workspace.toml" ]; then + echo "Refusing to run outside a Yoi workspace root: .yoi/workspace.toml not found." >&2 + exit 1 +fi + +CONFIG_FILE=.yoi/workspace-backend.local.toml +if [ ! -f "$CONFIG_FILE" ]; then + echo "Workspace backend local config not found: $CONFIG_FILE" >&2 + exit 1 +fi + +if [ -n "${YOI_CONFIG_DIR:-}" ]; then + CONFIG_DIR=$YOI_CONFIG_DIR +elif [ -n "${YOI_HOME:-}" ]; then + CONFIG_DIR=$YOI_HOME/config +elif [ -n "${XDG_CONFIG_HOME:-}" ]; then + CONFIG_DIR=$XDG_CONFIG_HOME/yoi +else + HOME_DIR=${HOME:?HOME is required when YOI_CONFIG_DIR, YOI_HOME, and XDG_CONFIG_HOME are unset} + CONFIG_DIR=$HOME_DIR/.config/yoi +fi + +RUNTIMES_TOML=$CONFIG_DIR/runtimes.toml +RUNTIME_BLOCKS=$(mktemp) +STRIPPED_CONFIG=$(mktemp) +trap 'rm -f "$RUNTIME_BLOCKS" "$STRIPPED_CONFIG"' EXIT + +awk ' + /^\[\[runtimes\.remote\]\]/ { in_runtime=1; print; next } + in_runtime && /^\[/ && $0 !~ /^\[\[runtimes\.remote\]\]/ { in_runtime=0 } + in_runtime { print } +' "$CONFIG_FILE" > "$RUNTIME_BLOCKS" + +if [ -s "$RUNTIME_BLOCKS" ]; then + mkdir -p "$CONFIG_DIR" + if [ -e "$RUNTIMES_TOML" ] && [ -s "$RUNTIMES_TOML" ]; then + echo "Refusing to overwrite or merge existing non-empty runtimes config: $RUNTIMES_TOML" >&2 + echo "Move runtime entries manually or empty the file after inspection." >&2 + exit 1 + fi + cp "$RUNTIME_BLOCKS" "$RUNTIMES_TOML" + echo "Wrote runtime registrations to $RUNTIMES_TOML" + + awk ' + /^\[\[runtimes\.remote\]\]/ { skip=1; next } + skip && /^\[/ && $0 !~ /^\[\[runtimes\.remote\]\]/ { skip=0 } + !skip { print } + ' "$CONFIG_FILE" > "$STRIPPED_CONFIG" + cp "$STRIPPED_CONFIG" "$CONFIG_FILE" + echo "Removed runtime registrations from $CONFIG_FILE" +else + echo "No [[runtimes.remote]] blocks found in $CONFIG_FILE; leaving runtimes config unchanged." +fi + +cargo run --bin yoi -- ticket import-local + +echo "Imported legacy .yoi/tickets into the workspace SQLite Ticket backend." +echo "Next: start the backend once to import repositories, verify data, then run tmp-remove-legacy-yoi-files.sh if safe." diff --git a/tmp-remove-legacy-yoi-files.sh b/tmp-remove-legacy-yoi-files.sh new file mode 100644 index 00000000..b12b3685 --- /dev/null +++ b/tmp-remove-legacy-yoi-files.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env sh +set -eu + +# Temporary cleanup script for files that should stop being used after the +# workspace DB/runtime-config migration. +# +# Preconditions before running: +# 1. The backend/server process is stopped. +# 2. `[[runtimes.remote]]` from `.yoi/workspace-backend.local.toml` has been +# copied to the resolved config-dir `runtimes.toml`, e.g. +# `$XDG_CONFIG_HOME/yoi/runtimes.toml` when no YOI_CONFIG_DIR/YOI_HOME is set. +# 3. Repository records from `.yoi/workspace-backend.local.toml` have been +# imported into the workspace control-plane DB. +# 4. Legacy `.yoi/tickets` has been imported with `yoi ticket import-local`. +# 5. `.yoi/workspace.db` has been copied/moved to the resolved XDG data path. +# +# This intentionally does NOT remove: +# - `.yoi/workspace.toml` +# - `.yoi/workflow/**` +# +# Run explicitly with: +# RUN_YOI_LEGACY_CLEANUP=1 sh ./tmp-remove-legacy-yoi-files.sh + +if [ "${RUN_YOI_LEGACY_CLEANUP:-}" != "1" ]; then + echo "Refusing to remove files. Set RUN_YOI_LEGACY_CLEANUP=1 after completing migration preconditions." >&2 + exit 1 +fi + +if [ ! -f ".yoi/workspace.toml" ]; then + echo "Refusing to run outside a Yoi workspace root: .yoi/workspace.toml not found." >&2 + exit 1 +fi + +rm -rf \ + .yoi/tickets + +rm -f \ + .yoi/workspace-backend.local.toml \ + .yoi/workspace.db \ + .yoi/workspace.db-shm \ + .yoi/workspace.db-wal + +echo "Removed legacy workspace-local backend config, Ticket files, and workspace DB files."