From 5149ab703f63a6c2089d293cdd2e7b56b8747588 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 22 Jun 2026 18:01:38 +0900 Subject: [PATCH] workspace: implement db schema v0 bootstrap --- .../00001KVNKD56W/artifacts/schema-v0.md | 6 + crates/workspace-server/src/server.rs | 32 +- crates/workspace-server/src/store.rs | 684 ++++++++++++++---- web/workspace/src/routes/+page.svelte | 1 - 4 files changed, 581 insertions(+), 142 deletions(-) diff --git a/.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md b/.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md index e785c109..532b9272 100644 --- a/.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md +++ b/.yoi/tickets/00001KVNKD56W/artifacts/schema-v0.md @@ -512,3 +512,9 @@ If implementation is included in this Ticket, prefer a small non-breaking migrat - Do not create a full `actors` table in v0. - Do not create `hosts` / `workers` canonical tables in v0. - Do not create a separate `runs` table in v0; use structured Ticket events and TicketWorkerLink relationships. + +## Implementation alignment notes + +The `yoi-workspace-server` SQLite bootstrap migration implements this v0 schema as schema version 2. Fresh databases create the typed tables listed above and deliberately do not create canonical `runs`, `hosts`, `workers`, `actors`, or check/validation result tables. Host and Worker HTTP read APIs remain live runtime views backed by local inspection, not DB tables. + +For databases created by the earlier workspace-server bootstrap, migration version 2 preserves old `repositories`, `runs`, `artifacts`, `ticket_projections`, and `objective_projections` data by renaming those tables to `legacy_repositories`, `legacy_runs`, `legacy_artifacts`, `legacy_ticket_projections`, and `legacy_objective_projections`, then creating the v0 typed tables. The legacy names are compatibility preservation only and are not canonical schema tables or active write authority. diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 0f1a2649..f687e977 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -15,7 +15,7 @@ use crate::records::{ LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, }; use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary}; -use crate::store::{ControlPlaneStore, RunSummary, WorkspaceRecord}; +use crate::store::{ControlPlaneStore, WorkspaceRecord}; use crate::{Error, Result}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -74,8 +74,7 @@ impl WorkspaceApi { .upsert_workspace(&WorkspaceRecord { workspace_id: config.workspace_id.clone(), display_name, - local_root: config.workspace_root.clone(), - record_authority: "local_yoi_project_records".to_string(), + state: "active".to_string(), created_at: "1970-01-01T00:00:00Z".to_string(), updated_at: "1970-01-01T00:00:00Z".to_string(), }) @@ -127,7 +126,6 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/repositories/{repository_id}/tickets", get(repository_tickets), ) - .route("/api/runs", get(list_runs)) .route("/api/hosts", get(list_hosts)) .route("/api/workers", get(list_workers)) .route("/api/hosts/{host_id}/workers", get(list_host_workers)) @@ -398,20 +396,6 @@ async fn repository_tickets( })) } -async fn list_runs( - State(api): State, -) -> ApiResult>> { - let limit = api.config.max_records.min(200); - let items = api.store.list_runs(api.workspace_id(), limit).await?; - Ok(Json(RuntimeListResponse { - workspace_id: api.config.workspace_id, - limit, - items, - source: "sqlite_runtime_tables".to_string(), - diagnostics: Vec::new(), - })) -} - async fn list_hosts( State(api): State, ) -> ApiResult>> { @@ -729,6 +713,18 @@ mod tests { let host_workers = get_json(app.clone(), "/api/hosts/local-local-test/workers").await; assert!(host_workers["items"].as_array().unwrap().is_empty()); + let runs_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/runs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(runs_response.status(), StatusCode::NOT_FOUND); + let runners_response = app .clone() .oneshot( diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 7c0c4ee6..055a0084 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -8,92 +8,30 @@ use serde::{Deserialize, Serialize}; use crate::{Error, Result}; -const MIGRATIONS: &[Migration] = &[Migration { - version: 1, - name: "bootstrap workspace control plane", - sql: r#" -CREATE TABLE IF NOT EXISTS workspaces ( - workspace_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - local_root TEXT NOT NULL, - record_authority TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS repositories ( - repository_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - local_root TEXT NOT NULL, - role TEXT NOT NULL, - created_at TEXT NOT NULL -); - --- Projection tables are intentionally empty in this bootstrap: `.yoi/tickets` --- and `.yoi/objectives` remain canonical, but the tables reserve a future --- projection/cache seam without migrating authority. -CREATE TABLE IF NOT EXISTS ticket_projections ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL, - title TEXT NOT NULL, - state TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, ticket_id) -); - -CREATE TABLE IF NOT EXISTS objective_projections ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - objective_id TEXT NOT NULL, - title TEXT NOT NULL, - state TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, objective_id) -); - -CREATE TABLE IF NOT EXISTS runs ( - run_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - subject_kind TEXT NOT NULL, - subject_id TEXT NOT NULL, - status TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS artifacts ( - artifact_id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - run_id TEXT REFERENCES runs(run_id) ON DELETE SET NULL, - path TEXT NOT NULL, - content_type TEXT, - created_at TEXT NOT NULL -); -"#, -}]; +const MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + name: "workspace db canonical schema v0 bootstrap", + apply: create_schema_v0_tables, + }, + Migration { + version: 2, + name: "align legacy workspace bootstrap with schema v0", + apply: align_legacy_bootstrap_schema, + }, +]; struct Migration { version: i64, name: &'static str, - sql: &'static str, + apply: fn(&Connection) -> Result<()>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspaceRecord { pub workspace_id: String, pub display_name: String, - pub local_root: PathBuf, - pub record_authority: String, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RunSummary { - pub run_id: String, - pub workspace_id: String, - pub subject_kind: String, - pub subject_id: String, - pub status: String, + pub state: String, pub created_at: String, pub updated_at: String, } @@ -103,7 +41,6 @@ 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>; - async fn list_runs(&self, workspace_id: &str, limit: usize) -> Result>; } #[derive(Clone)] @@ -148,18 +85,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { conn.execute( r#"INSERT INTO workspaces ( - workspace_id, display_name, local_root, record_authority, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + workspace_id, display_name, state, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(workspace_id) DO UPDATE SET display_name = excluded.display_name, - local_root = excluded.local_root, - record_authority = excluded.record_authority, + state = excluded.state, updated_at = excluded.updated_at"#, params![ record.workspace_id, record.display_name, - record.local_root.to_string_lossy(), - record.record_authority, + record.state, record.created_at, record.updated_at, ], @@ -171,17 +106,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { async fn get_workspace(&self, workspace_id: &str) -> Result> { self.with_conn(|conn| { conn.query_row( - r#"SELECT workspace_id, display_name, local_root, record_authority, created_at, updated_at + r#"SELECT workspace_id, display_name, state, created_at, updated_at FROM workspaces WHERE workspace_id = ?1"#, params![workspace_id], |row| { Ok(WorkspaceRecord { workspace_id: row.get(0)?, display_name: row.get(1)?, - local_root: PathBuf::from(row.get::<_, String>(2)?), - record_authority: row.get(3)?, - created_at: row.get(4)?, - updated_at: row.get(5)?, + state: row.get(2)?, + created_at: row.get(3)?, + updated_at: row.get(4)?, }) }, ) @@ -189,28 +123,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { .map_err(Error::from) }) } - - async fn list_runs(&self, workspace_id: &str, limit: usize) -> Result> { - self.with_conn(|conn| { - let limit = limit.min(200) as i64; - let mut stmt = conn.prepare( - r#"SELECT run_id, workspace_id, subject_kind, subject_id, status, created_at, updated_at - FROM runs WHERE workspace_id = ?1 ORDER BY updated_at DESC, run_id DESC LIMIT ?2"#, - )?; - let rows = stmt.query_map(params![workspace_id, limit], |row| { - Ok(RunSummary { - run_id: row.get(0)?, - workspace_id: row.get(1)?, - subject_kind: row.get(2)?, - subject_id: row.get(3)?, - status: row.get(4)?, - created_at: row.get(5)?, - updated_at: row.get(6)?, - }) - })?; - rows.collect::>>().map_err(Error::from) - }) - } } fn configure_sqlite(conn: &Connection) -> Result<()> { @@ -246,7 +158,7 @@ fn apply_migrations(conn: &Connection) -> Result<()> { .filter(|migration| migration.version > current) { let tx = conn.unchecked_transaction()?; - tx.execute_batch(migration.sql)?; + (migration.apply)(&tx)?; tx.execute( "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", params![migration.version, migration.name], @@ -256,9 +168,269 @@ fn apply_migrations(conn: &Connection) -> Result<()> { Ok(()) } +fn align_legacy_bootstrap_schema(conn: &Connection) -> Result<()> { + if table_exists(conn, "repositories")? + && column_exists(conn, "repositories", "local_root")? + && !column_exists(conn, "repositories", "uri")? + { + rename_legacy_table(conn, "repositories", "legacy_repositories")?; + } + if table_exists(conn, "runs")? { + rename_legacy_table(conn, "runs", "legacy_runs")?; + } + if table_exists(conn, "artifacts")? + && (column_exists(conn, "artifacts", "run_id")? + || column_exists(conn, "artifacts", "path")? + || !column_exists(conn, "artifacts", "uri")?) + { + rename_legacy_table(conn, "artifacts", "legacy_artifacts")?; + } + if table_exists(conn, "ticket_projections")? { + rename_legacy_table(conn, "ticket_projections", "legacy_ticket_projections")?; + } + if table_exists(conn, "objective_projections")? { + rename_legacy_table( + conn, + "objective_projections", + "legacy_objective_projections", + )?; + } + if table_exists(conn, "workspaces")? && !column_exists(conn, "workspaces", "state")? { + conn.execute_batch( + "ALTER TABLE workspaces ADD COLUMN state TEXT NOT NULL DEFAULT 'active';", + )?; + } + create_schema_v0_tables(conn) +} + +fn rename_legacy_table(conn: &Connection, table_name: &str, legacy_name: &str) -> Result<()> { + if table_exists(conn, legacy_name)? { + return Err(Error::Store(format!( + "cannot preserve legacy table `{table_name}` because `{legacy_name}` already exists" + ))); + } + conn.execute_batch(&format!( + "ALTER TABLE {table_name} RENAME TO {legacy_name};" + ))?; + Ok(()) +} + +fn create_schema_v0_tables(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" +CREATE TABLE IF NOT EXISTS workspaces ( + workspace_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS tickets ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + ticket_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + state TEXT NOT NULL, + priority TEXT, + assignee_kind TEXT, + assignee_key TEXT, + assignee_display TEXT, + body_md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + closed_at TEXT, + resolution_event_id TEXT +); + +CREATE TABLE IF NOT EXISTS ticket_events ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + event_id TEXT PRIMARY KEY, + ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + event_seq INTEGER NOT NULL, + kind TEXT NOT NULL, + activity_id TEXT, + author_kind TEXT NOT NULL, + author_key TEXT NOT NULL, + author_display TEXT NOT NULL, + author_source_kind TEXT, + author_source_key TEXT, + created_at TEXT NOT NULL, + body_md TEXT, + subject_kind TEXT, + subject_id TEXT, + previous_state TEXT, + new_state TEXT, + status TEXT, + artifact_id TEXT, + worker_ref_kind TEXT, + worker_ref_key TEXT, + worker_display TEXT, + host_ref_kind TEXT, + host_ref_key TEXT, + host_display TEXT, + repository_id TEXT, + caused_by_event_id TEXT, + UNIQUE (ticket_id, event_seq) +); + +CREATE TABLE IF NOT EXISTS ticket_relations ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + source_ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + target_ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + kind TEXT NOT NULL, + created_at TEXT NOT NULL, + author_kind TEXT NOT NULL, + author_key TEXT NOT NULL, + author_display TEXT NOT NULL, + author_source_kind TEXT, + author_source_key TEXT, + note TEXT, + PRIMARY KEY (source_ticket_id, target_ticket_id, kind) +); + +CREATE TABLE IF NOT EXISTS objectives ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + objective_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + state TEXT NOT NULL, + body_md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS objective_ticket_links ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE, + ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + kind TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (objective_id, ticket_id, kind) +); + +CREATE TABLE IF NOT EXISTS repositories ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + repository_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL, + provider TEXT, + uri TEXT NOT NULL, + default_ref TEXT, + auth_ref_kind TEXT, + auth_ref_key TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS ticket_targets ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + target_id TEXT NOT NULL, + repository_id TEXT NOT NULL REFERENCES repositories(repository_id) ON DELETE CASCADE, + role TEXT NOT NULL, + intent TEXT NOT NULL, + ref_selector TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (ticket_id, target_id) +); + +CREATE TABLE IF NOT EXISTS ticket_target_paths ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + ticket_id TEXT NOT NULL, + target_id TEXT NOT NULL, + path TEXT NOT NULL, + PRIMARY KEY (ticket_id, target_id, path), + FOREIGN KEY (ticket_id, target_id) REFERENCES ticket_targets(ticket_id, target_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS ticket_worker_links ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, + worker_ref_kind TEXT NOT NULL, + worker_ref_key TEXT NOT NULL, + worker_display TEXT, + role TEXT NOT NULL, + status TEXT NOT NULL, + activity_id TEXT, + assigned_at TEXT, + released_at TEXT, + last_event_id TEXT, + PRIMARY KEY (ticket_id, worker_ref_kind, worker_ref_key, role) +); + +CREATE TABLE IF NOT EXISTS artifacts ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + artifact_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + uri TEXT NOT NULL, + media_type TEXT, + sha256 TEXT, + size_bytes INTEGER, + summary TEXT, + created_at TEXT NOT NULL, + created_by_kind TEXT NOT NULL, + created_by_key TEXT NOT NULL, + created_by_display TEXT NOT NULL, + created_by_source_kind TEXT, + created_by_source_key TEXT, + ticket_id TEXT, + objective_id TEXT, + event_id TEXT, + worker_ref_kind TEXT, + worker_ref_key TEXT, + worker_display TEXT, + repository_id TEXT, + source_kind TEXT, + source_revision TEXT +); + +CREATE TABLE IF NOT EXISTS audit_events ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + audit_event_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + actor_kind TEXT NOT NULL, + actor_key TEXT NOT NULL, + actor_display TEXT NOT NULL, + actor_source_kind TEXT, + actor_source_key TEXT, + action TEXT NOT NULL, + target_kind TEXT NOT NULL, + target_id TEXT, + outcome TEXT NOT NULL, + request_id TEXT, + summary TEXT +); +"#, + )?; + Ok(()) +} + +fn table_exists(conn: &Connection, table_name: &str) -> Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1)", + params![table_name], + |row| row.get::<_, bool>(0), + ) + .map_err(Error::from) +} + +fn column_exists(conn: &Connection, table_name: &str, column_name: &str) -> Result { + Ok(table_columns(conn, table_name)? + .iter() + .any(|column| column == column_name)) +} + +fn table_columns(conn: &Connection, table_name: &str) -> Result> { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({table_name})"))?; + let rows = stmt.query_map([], |row| row.get::<_, String>(1))?; + rows.collect::>>() + .map_err(Error::from) +} + #[cfg(test)] mod tests { use super::*; + use std::collections::BTreeSet; #[tokio::test] async fn migrates_sqlite_and_preserves_workspace_record() { @@ -266,30 +438,296 @@ mod tests { let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 1); + assert_eq!(store.schema_version().await.unwrap(), 2); let record = WorkspaceRecord { workspace_id: "local-dev".to_string(), display_name: "Yoi Dev".to_string(), - local_root: dir.path().to_path_buf(), - record_authority: "local_yoi_project_records".to_string(), + state: "active".to_string(), created_at: "2026-01-01T00:00:00Z".to_string(), updated_at: "2026-01-01T00:00:00Z".to_string(), }; store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 1); + assert_eq!(reopened.schema_version().await.unwrap(), 2); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) ); - assert!( - reopened - .list_runs("local-dev", 20) - .await - .unwrap() - .is_empty() - ); } + + #[test] + fn fresh_schema_matches_workspace_db_v0_boundaries() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations(&conn).unwrap(); + + let tables = table_names(&conn); + for expected in [ + "workspaces", + "tickets", + "ticket_events", + "ticket_relations", + "objectives", + "objective_ticket_links", + "repositories", + "ticket_targets", + "ticket_target_paths", + "ticket_worker_links", + "artifacts", + "audit_events", + ] { + assert!( + tables.contains(expected), + "missing expected v0 table {expected}" + ); + } + for forbidden in [ + "runs", + "hosts", + "workers", + "actors", + "validation_results", + "ci_results", + ] { + assert!( + !tables.contains(forbidden), + "fresh v0 schema must not create forbidden table {forbidden}" + ); + } + assert!( + !tables.iter().any(|table| table.starts_with("legacy_")), + "fresh v0 schema should not create legacy compatibility tables: {tables:?}" + ); + + assert_columns( + &conn, + "workspaces", + [ + "workspace_id", + "display_name", + "state", + "created_at", + "updated_at", + ], + ); + assert_columns( + &conn, + "repositories", + [ + "workspace_id", + "repository_id", + "name", + "kind", + "provider", + "uri", + "default_ref", + "auth_ref_kind", + "auth_ref_key", + "created_at", + "updated_at", + ], + ); + assert_columns( + &conn, + "ticket_events", + [ + "workspace_id", + "event_id", + "ticket_id", + "event_seq", + "kind", + "activity_id", + "author_kind", + "author_key", + "author_display", + "author_source_kind", + "author_source_key", + "created_at", + "body_md", + "subject_kind", + "subject_id", + "previous_state", + "new_state", + "status", + "artifact_id", + "worker_ref_kind", + "worker_ref_key", + "worker_display", + "host_ref_kind", + "host_ref_key", + "host_display", + "repository_id", + "caused_by_event_id", + ], + ); + assert_columns( + &conn, + "artifacts", + [ + "workspace_id", + "artifact_id", + "kind", + "uri", + "media_type", + "sha256", + "size_bytes", + "summary", + "created_at", + "created_by_kind", + "created_by_key", + "created_by_display", + "created_by_source_kind", + "created_by_source_key", + "ticket_id", + "objective_id", + "event_id", + "worker_ref_kind", + "worker_ref_key", + "worker_display", + "repository_id", + "source_kind", + "source_revision", + ], + ); + + for table in ["workspaces", "repositories", "ticket_events", "artifacts"] { + let columns = table_columns(&conn, table).unwrap(); + for forbidden_column in [ + "payload", + "payload_json", + "metadata", + "metadata_json", + "diagnostics_json", + "run_id", + "local_root", + "record_authority", + ] { + assert!( + !columns.iter().any(|column| column == forbidden_column), + "{table} must not contain obsolete/generic column {forbidden_column}" + ); + } + } + } + + #[tokio::test] + async fn upgrades_legacy_bootstrap_without_canonical_runs_table() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch(LEGACY_BOOTSTRAP_SQL).unwrap(); + conn.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (1, 'bootstrap workspace control plane')", + [], + ) + .unwrap(); + + let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); + assert_eq!(store.schema_version().await.unwrap(), 2); + + store + .with_conn(|conn| { + let tables = table_names(conn); + for expected in [ + "workspaces", + "repositories", + "tickets", + "ticket_events", + "ticket_worker_links", + "artifacts", + "audit_events", + "legacy_repositories", + "legacy_runs", + "legacy_artifacts", + "legacy_ticket_projections", + "legacy_objective_projections", + ] { + assert!( + tables.contains(expected), + "missing {expected} after upgrade" + ); + } + for forbidden in ["runs", "hosts", "workers", "actors", "validation_results"] { + assert!( + !tables.contains(forbidden), + "upgraded schema must not retain forbidden canonical table {forbidden}" + ); + } + let workspace_columns = table_columns(conn, "workspaces")?; + assert!(workspace_columns.iter().any(|column| column == "state")); + let artifact_columns = table_columns(conn, "artifacts")?; + assert!(artifact_columns.iter().any(|column| column == "uri")); + assert!(!artifact_columns.iter().any(|column| column == "run_id")); + Ok(()) + }) + .unwrap(); + } + + fn table_names(conn: &Connection) -> BTreeSet { + let mut stmt = conn + .prepare( + "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + ) + .unwrap(); + let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap(); + rows.collect::>>().unwrap() + } + + fn assert_columns(conn: &Connection, table: &str, expected: [&str; N]) { + let columns = table_columns(conn, table).unwrap(); + let expected = expected.map(str::to_string).to_vec(); + assert_eq!(columns, expected, "unexpected columns for {table}"); + } + + const LEGACY_BOOTSTRAP_SQL: &str = r#" +CREATE TABLE workspaces ( + workspace_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + local_root TEXT NOT NULL, + record_authority TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE repositories ( + repository_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + local_root TEXT NOT NULL, + role TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE ticket_projections ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + ticket_id TEXT NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id) +); +CREATE TABLE objective_projections ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + objective_id TEXT NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id) +); +CREATE TABLE runs ( + run_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + subject_kind TEXT NOT NULL, + subject_id TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE artifacts ( + artifact_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + run_id TEXT REFERENCES runs(run_id) ON DELETE SET NULL, + path TEXT NOT NULL, + content_type TEXT, + created_at TEXT NOT NULL +); +"#; } diff --git a/web/workspace/src/routes/+page.svelte b/web/workspace/src/routes/+page.svelte index 32f89cbb..efcb90d9 100644 --- a/web/workspace/src/routes/+page.svelte +++ b/web/workspace/src/routes/+page.svelte @@ -27,7 +27,6 @@ { label: 'Repositories', path: '/api/repositories' }, { label: 'Repository log', path: '/api/repositories/local/log' }, { label: 'Repository tickets', path: '/api/repositories/local/tickets' }, - { label: 'Runs', path: '/api/runs' }, { label: 'Hosts', path: '/api/hosts' }, { label: 'Workers', path: '/api/workers' } ];