1use anyhow::Result;
60
61use crate::config::Config;
62use crate::db;
63
64pub async fn run_migrations(config: &Config) -> Result<()> {
83 let pool = db::connect(config).await?;
84
85 sqlx::query(
87 r#"
88 CREATE TABLE IF NOT EXISTS documents (
89 id TEXT PRIMARY KEY,
90 source TEXT NOT NULL,
91 source_id TEXT NOT NULL,
92 source_url TEXT,
93 title TEXT,
94 author TEXT,
95 created_at INTEGER NOT NULL,
96 updated_at INTEGER NOT NULL,
97 content_type TEXT NOT NULL DEFAULT 'text/plain',
98 body TEXT NOT NULL,
99 metadata_json TEXT NOT NULL DEFAULT '{}',
100 raw_json TEXT,
101 dedup_hash TEXT NOT NULL,
102 UNIQUE(source, source_id)
103 )
104 "#,
105 )
106 .execute(&pool)
107 .await?;
108
109 sqlx::query(
111 r#"
112 CREATE TABLE IF NOT EXISTS chunks (
113 id TEXT PRIMARY KEY,
114 document_id TEXT NOT NULL,
115 chunk_index INTEGER NOT NULL,
116 text TEXT NOT NULL,
117 hash TEXT NOT NULL,
118 UNIQUE(document_id, chunk_index),
119 FOREIGN KEY (document_id) REFERENCES documents(id)
120 )
121 "#,
122 )
123 .execute(&pool)
124 .await?;
125
126 sqlx::query(
128 r#"
129 CREATE TABLE IF NOT EXISTS checkpoints (
130 source TEXT PRIMARY KEY,
131 cursor TEXT NOT NULL,
132 updated_at INTEGER NOT NULL
133 )
134 "#,
135 )
136 .execute(&pool)
137 .await?;
138
139 let fts_exists: bool = sqlx::query_scalar(
141 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='chunks_fts'",
142 )
143 .fetch_one(&pool)
144 .await?;
145
146 if !fts_exists {
147 sqlx::query(
148 r#"
149 CREATE VIRTUAL TABLE chunks_fts USING fts5(
150 chunk_id UNINDEXED,
151 document_id UNINDEXED,
152 text
153 )
154 "#,
155 )
156 .execute(&pool)
157 .await?;
158 }
159
160 sqlx::query(
162 r#"
163 CREATE TABLE IF NOT EXISTS embeddings (
164 chunk_id TEXT PRIMARY KEY,
165 model TEXT NOT NULL,
166 dims INTEGER NOT NULL,
167 created_at INTEGER NOT NULL,
168 hash TEXT NOT NULL,
169 FOREIGN KEY (chunk_id) REFERENCES chunks(id)
170 )
171 "#,
172 )
173 .execute(&pool)
174 .await?;
175
176 sqlx::query(
178 r#"
179 CREATE TABLE IF NOT EXISTS chunk_vectors (
180 chunk_id TEXT PRIMARY KEY,
181 document_id TEXT NOT NULL,
182 embedding BLOB NOT NULL,
183 FOREIGN KEY (chunk_id) REFERENCES chunks(id),
184 FOREIGN KEY (document_id) REFERENCES documents(id)
185 )
186 "#,
187 )
188 .execute(&pool)
189 .await?;
190
191 sqlx::query("CREATE INDEX IF NOT EXISTS idx_chunks_document_id ON chunks(document_id)")
193 .execute(&pool)
194 .await?;
195 sqlx::query("CREATE INDEX IF NOT EXISTS idx_documents_source ON documents(source)")
196 .execute(&pool)
197 .await?;
198 sqlx::query(
199 "CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at DESC)",
200 )
201 .execute(&pool)
202 .await?;
203 sqlx::query(
204 "CREATE INDEX IF NOT EXISTS idx_chunk_vectors_document_id ON chunk_vectors(document_id)",
205 )
206 .execute(&pool)
207 .await?;
208
209 let mut tx = pool.begin().await?;
211 for statement in include_str!("agent_store/schema.sql").split(';') {
214 if !statement.trim().is_empty() {
215 sqlx::query(statement).execute(&mut *tx).await?;
216 }
217 }
218 let columns: Vec<String> =
219 sqlx::query_scalar("SELECT name FROM pragma_table_info('agent_runs')")
220 .fetch_all(&mut *tx)
221 .await?;
222 for (name, definition) in [
223 (
224 "lifecycle",
225 "TEXT NOT NULL DEFAULT 'active' CHECK (lifecycle IN ('active', 'suspended', 'terminal'))",
226 ),
227 (
228 "outcome",
229 "TEXT CHECK (outcome IS NULL OR outcome IN ('completed', 'blocked', 'needs_user_input', 'failed', 'limit_exceeded', 'cancelled'))",
230 ),
231 ("reason_code", "TEXT"),
232 (
233 "reason_detail",
234 "TEXT CHECK (reason_detail IS NULL OR json_valid(reason_detail))",
235 ),
236 ("budgets", "TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(budgets))"),
237 (
238 "usage",
239 "TEXT NOT NULL DEFAULT '{\"model_turns\":0,\"input_tokens\":null,\"output_tokens\":null,\"total_tokens\":null,\"responses_with_usage\":0,\"responses_without_usage\":0,\"tool_calls\":0,\"token_accounting\":\"unavailable\"}' CHECK (json_valid(usage))",
240 ),
241 (
242 "suspended_ms",
243 "INTEGER NOT NULL DEFAULT 0 CHECK (suspended_ms >= 0)",
244 ),
245 ] {
246 if !columns.iter().any(|column| column == name) {
247 sqlx::query(&format!("ALTER TABLE agent_runs ADD COLUMN {name} {definition}"))
248 .execute(&mut *tx)
249 .await?;
250 }
251 }
252 let tool_columns: Vec<String> =
253 sqlx::query_scalar("SELECT name FROM pragma_table_info('tool_invocations')")
254 .fetch_all(&mut *tx)
255 .await?;
256 for (name, definition) in [
257 ("result_content", "TEXT"),
258 (
259 "outcome_class",
260 "TEXT CHECK (outcome_class IS NULL OR outcome_class IN ('completed', 'denied', 'recoverable_error', 'terminal_error', 'uncertain_side_effect', 'cancelled'))",
261 ),
262 ("error_code", "TEXT"),
263 ("repetition_key", "TEXT"),
264 (
265 "consecutive_count",
266 "INTEGER CHECK (consecutive_count IS NULL OR consecutive_count > 0)",
267 ),
268 ] {
269 if !tool_columns.iter().any(|column| column == name) {
270 sqlx::query(&format!(
271 "ALTER TABLE tool_invocations ADD COLUMN {name} {definition}"
272 ))
273 .execute(&mut *tx)
274 .await?;
275 }
276 }
277 sqlx::query(
278 "UPDATE agent_runs SET lifecycle = CASE status WHEN 'running' THEN 'active' ELSE 'terminal' END, outcome = CASE status WHEN 'completed' THEN 'completed' WHEN 'failed' THEN 'failed' WHEN 'cancelled' THEN 'cancelled' END, reason_code = CASE status WHEN 'completed' THEN 'legacy_completed' WHEN 'failed' THEN 'legacy_failure' WHEN 'cancelled' THEN 'legacy_cancelled' END, reason_detail = CASE WHEN status = 'running' THEN NULL ELSE '{}' END WHERE reason_code IS NULL",
279 )
280 .execute(&mut *tx)
281 .await?;
282 tx.commit().await?;
283
284 pool.close().await;
285 Ok(())
286}