Skip to content

Commit 292e5f2

Browse files
authored
fix(db): repair legacy handoff schema drift (#516)
## Summary - Add a shared legacy handoff schema contract for startup repair of DB columns required by the AionUi handoff path. - Run schema repair during database initialization without adding a versioned migration or recopying existing backend databases. - Cover raw legacy DB repair, existing backend in-place repair, upgraded no-op behavior, and startup stage mapping with tests. ## Test Plan - [x] just push -u origin codex/legacy-db-handoff-contract - [x] Local dev validation with corrupted ~/.aionui-dev DBs: repaired missing teams.session_mode and restarted as no-op --------- Co-authored-by: zynx <>
1 parent ba76273 commit 292e5f2

5 files changed

Lines changed: 325 additions & 34 deletions

File tree

crates/aionui-app/src/bootstrap/environment.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,14 @@ mod tests {
120120

121121
assert_eq!(err.stage(), "database.migration");
122122
}
123+
124+
#[test]
125+
fn database_schema_repair_stage_comes_from_db_boundary_error() {
126+
let err = aionui_db::DatabaseInitError::new(
127+
"database.schema_repair",
128+
aionui_db::DbError::Init("repair failed".into()),
129+
);
130+
131+
assert_eq!(err.stage(), "database.schema_repair");
132+
}
123133
}

crates/aionui-db/src/database.rs

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -424,40 +424,7 @@ impl Drop for MigrateLockGuard {
424424
/// safely adds any missing columns via `ALTER TABLE ADD COLUMN`.
425425
async fn ensure_schema_columns(pool: &SqlitePool) -> Result<(), DbError> {
426426
reconcile_mcp_server_schema(pool).await?;
427-
428-
let expected: &[(&str, &str, &str)] = &[
429-
("cron_jobs", "skill_content", "TEXT"),
430-
("cron_jobs", "description", "TEXT"),
431-
("conversations", "pinned", "INTEGER NOT NULL DEFAULT 0"),
432-
("conversations", "pinned_at", "INTEGER"),
433-
("teams", "agents_version", "TEXT NOT NULL DEFAULT '1.0.0'"),
434-
];
435-
436-
for &(table, column, col_def) in expected {
437-
let table_exists: bool =
438-
sqlx::query_scalar("SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name=?")
439-
.bind(table)
440-
.fetch_one(pool)
441-
.await
442-
.map_err(DbError::Query)?;
443-
444-
if !table_exists {
445-
continue;
446-
}
447-
448-
let col_exists: bool = sqlx::query_scalar("SELECT COUNT(*) > 0 FROM pragma_table_info(?) WHERE name = ?")
449-
.bind(table)
450-
.bind(column)
451-
.fetch_one(pool)
452-
.await
453-
.map_err(DbError::Query)?;
454-
455-
if !col_exists {
456-
let sql = format!("ALTER TABLE {table} ADD COLUMN {column} {col_def}");
457-
sqlx::query(&sql).execute(pool).await.map_err(DbError::Query)?;
458-
info!("Added missing column {table}.{column}");
459-
}
460-
}
427+
crate::legacy_handoff::ensure_legacy_handoff_schema(pool).await?;
461428
Ok(())
462429
}
463430

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
use sqlx::SqlitePool;
2+
use tracing::info;
3+
4+
use crate::error::DbError;
5+
6+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7+
pub(crate) struct LegacyHandoffColumn {
8+
pub(crate) table: &'static str,
9+
pub(crate) column: &'static str,
10+
pub(crate) definition: &'static str,
11+
}
12+
13+
pub(crate) const LEGACY_HANDOFF_COLUMNS: &[LegacyHandoffColumn] = &[
14+
LegacyHandoffColumn {
15+
table: "cron_jobs",
16+
column: "skill_content",
17+
definition: "TEXT",
18+
},
19+
LegacyHandoffColumn {
20+
table: "cron_jobs",
21+
column: "description",
22+
definition: "TEXT",
23+
},
24+
LegacyHandoffColumn {
25+
table: "conversations",
26+
column: "pinned",
27+
definition: "INTEGER NOT NULL DEFAULT 0",
28+
},
29+
LegacyHandoffColumn {
30+
table: "conversations",
31+
column: "pinned_at",
32+
definition: "INTEGER",
33+
},
34+
LegacyHandoffColumn {
35+
table: "teams",
36+
column: "session_mode",
37+
definition: "TEXT",
38+
},
39+
LegacyHandoffColumn {
40+
table: "teams",
41+
column: "agents_version",
42+
definition: "TEXT NOT NULL DEFAULT '1.0.0'",
43+
},
44+
];
45+
46+
pub(crate) async fn ensure_legacy_handoff_schema(pool: &SqlitePool) -> Result<(), DbError> {
47+
for column in LEGACY_HANDOFF_COLUMNS {
48+
ensure_legacy_handoff_column(pool, *column).await?;
49+
}
50+
Ok(())
51+
}
52+
53+
async fn ensure_legacy_handoff_column(pool: &SqlitePool, column: LegacyHandoffColumn) -> Result<(), DbError> {
54+
let table_exists: bool = sqlx::query_scalar("SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name=?")
55+
.bind(column.table)
56+
.fetch_one(pool)
57+
.await
58+
.map_err(DbError::Query)?;
59+
60+
if !table_exists {
61+
return Ok(());
62+
}
63+
64+
let column_exists: bool = sqlx::query_scalar("SELECT COUNT(*) > 0 FROM pragma_table_info(?) WHERE name = ?")
65+
.bind(column.table)
66+
.bind(column.column)
67+
.fetch_one(pool)
68+
.await
69+
.map_err(DbError::Query)?;
70+
71+
if column_exists {
72+
return Ok(());
73+
}
74+
75+
let sql = format!(
76+
"ALTER TABLE {} ADD COLUMN {} {}",
77+
column.table, column.column, column.definition
78+
);
79+
sqlx::query(&sql).execute(pool).await.map_err(DbError::Query)?;
80+
info!(
81+
table = column.table,
82+
column = column.column,
83+
"added missing legacy handoff column"
84+
);
85+
86+
Ok(())
87+
}
88+
89+
#[cfg(test)]
90+
mod tests {
91+
use super::*;
92+
93+
#[test]
94+
fn contract_contains_initial_handoff_columns() {
95+
let actual: Vec<_> = LEGACY_HANDOFF_COLUMNS
96+
.iter()
97+
.map(|column| (column.table, column.column, column.definition))
98+
.collect();
99+
100+
assert_eq!(
101+
actual,
102+
vec![
103+
("cron_jobs", "skill_content", "TEXT"),
104+
("cron_jobs", "description", "TEXT"),
105+
("conversations", "pinned", "INTEGER NOT NULL DEFAULT 0"),
106+
("conversations", "pinned_at", "INTEGER"),
107+
("teams", "session_mode", "TEXT"),
108+
("teams", "agents_version", "TEXT NOT NULL DEFAULT '1.0.0'"),
109+
]
110+
);
111+
}
112+
113+
#[test]
114+
fn migration_002_direct_legacy_reads_are_audited() {
115+
let repaired_by_handoff_contract = [
116+
("cron_jobs", "skill_content"),
117+
("cron_jobs", "description"),
118+
("conversations", "pinned"),
119+
("conversations", "pinned_at"),
120+
("teams", "session_mode"),
121+
("teams", "agents_version"),
122+
];
123+
124+
for (table, column) in repaired_by_handoff_contract {
125+
assert!(
126+
LEGACY_HANDOFF_COLUMNS
127+
.iter()
128+
.any(|entry| entry.table == table && entry.column == column),
129+
"migration 002 reads {table}.{column}; it must stay in the handoff repair contract"
130+
);
131+
}
132+
133+
// These columns are also directly read by migration 002, but they are
134+
// not part of this repair contract because current evidence does not
135+
// show compatible drift for them. Keep them documented here so review
136+
// of future migration-002 edits has an explicit contract decision
137+
// point instead of a hidden assumption.
138+
let documented_non_contract_reads = [
139+
("messages", "hidden", "AionUi v22 adds it before v23-v26 issue path"),
140+
(
141+
"conversations",
142+
"source",
143+
"AionUi v8 baseline before v23-v26 issue path",
144+
),
145+
(
146+
"conversations",
147+
"channel_chat_id",
148+
"AionUi v14 baseline before v23-v26 issue path",
149+
),
150+
("mailbox", "files", "AionUi v25 adds it on the observed v23->v26 path"),
151+
(
152+
"remote_agents",
153+
"allow_insecure",
154+
"AionUi v18 baseline before v23-v26 issue path",
155+
),
156+
(
157+
"cron_jobs",
158+
"execution_mode",
159+
"AionUi v22 baseline before v23-v26 issue path",
160+
),
161+
(
162+
"cron_jobs",
163+
"agent_config",
164+
"AionUi v22 baseline before v23-v26 issue path",
165+
),
166+
];
167+
168+
let migration_002 = include_str!("../migrations/002_legacy_data_normalize.sql");
169+
for (table, column, reason) in documented_non_contract_reads {
170+
assert!(
171+
migration_002.contains(column),
172+
"documented migration 002 read {table}.{column} disappeared or was renamed; revisit the audit entry: {reason}"
173+
);
174+
}
175+
}
176+
}

crates/aionui-db/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! SQLite database layer: init, migrations, repository traits, and implementations.
44
mod database;
55
mod error;
6+
mod legacy_handoff;
67
pub mod models;
78
mod repository;
89

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
use std::path::Path;
2+
use std::str::FromStr;
3+
use std::time::Duration;
4+
5+
use aionui_db::{init_database, init_database_staged, maybe_copy_legacy_database};
6+
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
7+
use sqlx::{Row, SqlitePool};
8+
9+
async fn create_raw_legacy_backend_missing_team_session_mode(path: &Path, user_id: &str) {
10+
let pool = open_raw_create(path).await;
11+
let statements = [
12+
"CREATE TABLE users (id TEXT PRIMARY KEY NOT NULL, username TEXT NOT NULL UNIQUE, email TEXT UNIQUE, password_hash TEXT NOT NULL, avatar_path TEXT, jwt_secret TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login INTEGER)",
13+
"CREATE TABLE conversations (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL, extra TEXT NOT NULL DEFAULT '{}', model TEXT, status TEXT NOT NULL DEFAULT 'pending', source TEXT, channel_chat_id TEXT, pinned INTEGER NOT NULL DEFAULT 0, pinned_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)",
14+
"CREATE TABLE messages (id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, msg_id TEXT, type TEXT NOT NULL, content TEXT NOT NULL DEFAULT '{}', position TEXT, status TEXT, hidden INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL)",
15+
"CREATE TABLE assistant_sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, agent_type TEXT NOT NULL, conversation_id TEXT, workspace TEXT, chat_id TEXT, created_at INTEGER NOT NULL, last_activity INTEGER NOT NULL)",
16+
"CREATE TABLE teams (id TEXT PRIMARY KEY NOT NULL, user_id TEXT NOT NULL DEFAULT 'system_default_user', name TEXT NOT NULL, workspace TEXT NOT NULL DEFAULT '', workspace_mode TEXT NOT NULL DEFAULT 'shared', agents TEXT NOT NULL DEFAULT '[]', lead_agent_id TEXT, agents_version TEXT NOT NULL DEFAULT '1.0.0', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)",
17+
"CREATE TABLE mailbox (id TEXT PRIMARY KEY NOT NULL, team_id TEXT NOT NULL, to_agent_id TEXT NOT NULL, from_agent_id TEXT NOT NULL, type TEXT NOT NULL, content TEXT NOT NULL, summary TEXT, files TEXT, read INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL)",
18+
"CREATE TABLE team_tasks (id TEXT PRIMARY KEY NOT NULL, team_id TEXT NOT NULL, subject TEXT NOT NULL, description TEXT, status TEXT NOT NULL DEFAULT 'pending', owner TEXT, blocked_by TEXT NOT NULL DEFAULT '[]', blocks TEXT NOT NULL DEFAULT '[]', metadata TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)",
19+
"CREATE TABLE assistant_plugins (id TEXT PRIMARY KEY NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 0, config TEXT NOT NULL, status TEXT, last_connected INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)",
20+
"CREATE TABLE remote_agents (id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, protocol TEXT NOT NULL, url TEXT NOT NULL, auth_type TEXT NOT NULL, auth_token TEXT, allow_insecure INTEGER NOT NULL DEFAULT 0, avatar TEXT, description TEXT, device_id TEXT, device_public_key TEXT, device_private_key TEXT, device_token TEXT, status TEXT NOT NULL DEFAULT 'unknown', last_connected_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)",
21+
"CREATE TABLE cron_jobs (id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, schedule_kind TEXT NOT NULL, schedule_value TEXT NOT NULL, schedule_tz TEXT, schedule_description TEXT, payload_message TEXT NOT NULL, execution_mode TEXT NOT NULL DEFAULT 'existing', agent_config TEXT, conversation_id TEXT NOT NULL, conversation_title TEXT, agent_type TEXT NOT NULL, created_by TEXT NOT NULL, skill_content TEXT, description TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, next_run_at INTEGER, last_run_at INTEGER, last_status TEXT, last_error TEXT, run_count INTEGER NOT NULL DEFAULT 0, retry_count INTEGER NOT NULL DEFAULT 0, max_retries INTEGER NOT NULL DEFAULT 3)",
22+
"CREATE TABLE acp_session (conversation_id TEXT PRIMARY KEY, agent_backend TEXT NOT NULL, agent_source TEXT NOT NULL, agent_id TEXT NOT NULL, session_id TEXT, session_status TEXT NOT NULL DEFAULT 'idle', session_config TEXT NOT NULL DEFAULT '{}', last_active_at INTEGER, suspended_at INTEGER)",
23+
];
24+
for statement in statements {
25+
sqlx::query(statement).execute(&pool).await.unwrap();
26+
}
27+
sqlx::query(
28+
"INSERT INTO users (id, username, email, password_hash, created_at, updated_at) VALUES (?, ?, NULL, '', 1, 1)",
29+
)
30+
.bind(user_id)
31+
.bind(user_id)
32+
.execute(&pool)
33+
.await
34+
.unwrap();
35+
sqlx::query(
36+
"INSERT INTO teams (id, user_id, name, workspace, workspace_mode, agents, lead_agent_id, agents_version, created_at, updated_at) VALUES ('team_1', ?, 'Legacy Team', '', 'shared', '[]', NULL, '1.0.0', 1, 1)",
37+
)
38+
.bind(user_id)
39+
.execute(&pool)
40+
.await
41+
.unwrap();
42+
pool.close().await;
43+
}
44+
45+
async fn open_raw_create(path: &Path) -> SqlitePool {
46+
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))
47+
.unwrap()
48+
.create_if_missing(true)
49+
.foreign_keys(false)
50+
.busy_timeout(Duration::from_millis(5000))
51+
.journal_mode(SqliteJournalMode::Wal);
52+
53+
SqlitePool::connect_with(opts).await.unwrap()
54+
}
55+
56+
async fn has_column(pool: &SqlitePool, table: &str, column: &str) -> bool {
57+
sqlx::query_scalar("SELECT COUNT(*) > 0 FROM pragma_table_info(?) WHERE name = ?")
58+
.bind(table)
59+
.bind(column)
60+
.fetch_one(pool)
61+
.await
62+
.unwrap()
63+
}
64+
65+
#[tokio::test]
66+
async fn advanced_legacy_db_missing_team_session_mode_still_initializes() {
67+
let dir = tempfile::tempdir().unwrap();
68+
let path = dir.path().join("aionui-backend.db");
69+
70+
create_raw_legacy_backend_missing_team_session_mode(&path, "system_default_user").await;
71+
72+
let repaired = init_database_staged(&path).await.unwrap();
73+
assert!(has_column(repaired.pool(), "teams", "session_mode").await);
74+
75+
let row = sqlx::query("SELECT name, session_mode FROM teams WHERE id = 'team_1'")
76+
.fetch_one(repaired.pool())
77+
.await
78+
.unwrap();
79+
assert_eq!(row.get::<String, _>("name"), "Legacy Team");
80+
assert!(row.get::<Option<String>, _>("session_mode").is_none());
81+
repaired.close().await;
82+
}
83+
84+
#[tokio::test]
85+
async fn existing_backend_db_is_repaired_in_place_without_recopied_legacy_source() {
86+
let dir = tempfile::tempdir().unwrap();
87+
let target = dir.path().join("aionui-backend.db");
88+
let legacy = dir.path().join("aionui.db");
89+
90+
create_raw_legacy_backend_missing_team_session_mode(&legacy, "legacy_only").await;
91+
create_raw_legacy_backend_missing_team_session_mode(&target, "backend_only").await;
92+
93+
maybe_copy_legacy_database(&target).unwrap();
94+
let repaired = init_database_staged(&target).await.unwrap();
95+
96+
let backend_user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = 'backend_only'")
97+
.fetch_one(repaired.pool())
98+
.await
99+
.unwrap();
100+
let legacy_user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = 'legacy_only'")
101+
.fetch_one(repaired.pool())
102+
.await
103+
.unwrap();
104+
105+
assert_eq!(backend_user_count, 1, "existing backend DB must be preserved");
106+
assert_eq!(
107+
legacy_user_count, 0,
108+
"existing backend DB must not be overwritten from aionui.db"
109+
);
110+
assert!(has_column(repaired.pool(), "teams", "session_mode").await);
111+
repaired.close().await;
112+
}
113+
114+
#[tokio::test]
115+
async fn upgraded_backend_db_reinit_is_noop_for_handoff_repair() {
116+
let dir = tempfile::tempdir().unwrap();
117+
let path = dir.path().join("aionui-backend.db");
118+
119+
let db = init_database(&path).await.unwrap();
120+
sqlx::query(
121+
"INSERT INTO users (id, username, password_hash, created_at, updated_at) VALUES ('stable', 'stable', '', 1, 1)",
122+
)
123+
.execute(db.pool())
124+
.await
125+
.unwrap();
126+
db.close().await;
127+
128+
let reopened = init_database_staged(&path).await.unwrap();
129+
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = 'stable'")
130+
.fetch_one(reopened.pool())
131+
.await
132+
.unwrap();
133+
134+
assert_eq!(count, 1);
135+
assert!(has_column(reopened.pool(), "teams", "session_mode").await);
136+
reopened.close().await;
137+
}

0 commit comments

Comments
 (0)