1use super::*;
9
10pub(crate) const SCHEMA_VERSION_CURRENT: i64 = 43;
11
12pub(crate) fn table_has_column(
13 conn: &Connection,
14 table: &str,
15 column: &str,
16) -> Result<bool, MicroClawError> {
17 if !table.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
19 return Err(MicroClawError::Config(format!(
20 "invalid table name: {}",
21 table
22 )));
23 }
24 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
27 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
28 for col in rows {
29 if col? == column {
30 return Ok(true);
31 }
32 }
33 Ok(false)
34}
35
36pub(crate) fn ensure_memory_schema(conn: &Connection) -> Result<(), MicroClawError> {
37 if !table_has_column(conn, "memories", "embedding_model")? {
38 conn.execute("ALTER TABLE memories ADD COLUMN embedding_model TEXT", [])?;
39 }
40 if !table_has_column(conn, "memories", "chat_channel")? {
41 conn.execute("ALTER TABLE memories ADD COLUMN chat_channel TEXT", [])?;
42 }
43 if !table_has_column(conn, "memories", "external_chat_id")? {
44 conn.execute("ALTER TABLE memories ADD COLUMN external_chat_id TEXT", [])?;
45 }
46 if !table_has_column(conn, "memories", "confidence")? {
47 conn.execute("ALTER TABLE memories ADD COLUMN confidence REAL", [])?;
48 }
49 if !table_has_column(conn, "memories", "source")? {
50 conn.execute("ALTER TABLE memories ADD COLUMN source TEXT", [])?;
51 }
52 if !table_has_column(conn, "memories", "last_seen_at")? {
53 conn.execute("ALTER TABLE memories ADD COLUMN last_seen_at TEXT", [])?;
54 }
55 if !table_has_column(conn, "memories", "is_archived")? {
56 conn.execute("ALTER TABLE memories ADD COLUMN is_archived INTEGER", [])?;
57 }
58 if !table_has_column(conn, "memories", "archived_at")? {
59 conn.execute("ALTER TABLE memories ADD COLUMN archived_at TEXT", [])?;
60 }
61 if !table_has_column(conn, "memories", "expires_at")? {
62 conn.execute("ALTER TABLE memories ADD COLUMN expires_at TEXT", [])?;
63 }
64 conn.execute(
65 "UPDATE memories
66 SET confidence = COALESCE(confidence, 0.70),
67 source = COALESCE(NULLIF(source, ''), 'legacy'),
68 last_seen_at = COALESCE(last_seen_at, updated_at, created_at),
69 is_archived = COALESCE(is_archived, 0)
70 WHERE confidence IS NULL
71 OR source IS NULL OR trim(source) = ''
72 OR last_seen_at IS NULL
73 OR is_archived IS NULL",
74 [],
75 )?;
76 let chats_has_channel = table_has_column(conn, "chats", "channel")?;
77 let chats_has_external = table_has_column(conn, "chats", "external_chat_id")?;
78 if chats_has_channel && chats_has_external {
79 conn.execute(
80 "UPDATE memories
81 SET chat_channel = (
82 SELECT c.channel FROM chats c WHERE c.chat_id = memories.chat_id
83 ),
84 external_chat_id = (
85 SELECT c.external_chat_id FROM chats c WHERE c.chat_id = memories.chat_id
86 )
87 WHERE chat_id IS NOT NULL
88 AND (
89 chat_channel IS NULL
90 OR trim(chat_channel) = ''
91 OR external_chat_id IS NULL
92 OR trim(external_chat_id) = ''
93 )",
94 [],
95 )?;
96 }
97 Ok(())
98}
99
100pub(crate) fn infer_channel_from_chat_type(chat_type: &str) -> &'static str {
101 if chat_type.starts_with("telegram_")
102 || matches!(chat_type, "private" | "group" | "supergroup" | "channel")
103 {
104 "telegram"
105 } else if chat_type == "discord" {
106 "discord"
107 } else if chat_type == "web" {
108 "web"
109 } else {
110 "unknown"
111 }
112}
113
114pub(crate) fn ensure_chat_identity_schema(conn: &Connection) -> Result<(), MicroClawError> {
115 if !table_has_column(conn, "chats", "channel")? {
116 conn.execute("ALTER TABLE chats ADD COLUMN channel TEXT", [])?;
117 }
118 if !table_has_column(conn, "chats", "external_chat_id")? {
119 conn.execute("ALTER TABLE chats ADD COLUMN external_chat_id TEXT", [])?;
120 }
121
122 conn.execute(
123 "UPDATE chats
124 SET channel = CASE
125 WHEN chat_type LIKE 'telegram_%' THEN 'telegram'
126 WHEN chat_type IN ('private', 'group', 'supergroup', 'channel') THEN 'telegram'
127 WHEN chat_type = 'discord' THEN 'discord'
128 WHEN chat_type = 'web' THEN 'web'
129 ELSE COALESCE(channel, 'unknown')
130 END
131 WHERE channel IS NULL OR trim(channel) = ''",
132 [],
133 )?;
134 conn.execute(
135 "UPDATE chats
136 SET external_chat_id = CAST(chat_id AS TEXT)
137 WHERE external_chat_id IS NULL OR trim(external_chat_id) = ''",
138 [],
139 )?;
140 conn.execute(
141 "CREATE INDEX IF NOT EXISTS idx_chats_channel_external
142 ON chats(channel, external_chat_id)",
143 [],
144 )?;
145 conn.execute(
146 "CREATE INDEX IF NOT EXISTS idx_chats_channel_title
147 ON chats(channel, chat_title)",
148 [],
149 )?;
150 Ok(())
151}
152
153pub(crate) fn ensure_sessions_schema(conn: &Connection) -> Result<(), MicroClawError> {
154 if !table_has_column(conn, "sessions", "parent_session_key")? {
155 conn.execute(
156 "ALTER TABLE sessions ADD COLUMN parent_session_key TEXT",
157 [],
158 )?;
159 }
160 if !table_has_column(conn, "sessions", "fork_point")? {
161 conn.execute("ALTER TABLE sessions ADD COLUMN fork_point INTEGER", [])?;
162 }
163 if !table_has_column(conn, "sessions", "label")? {
164 conn.execute("ALTER TABLE sessions ADD COLUMN label TEXT", [])?;
165 }
166 if !table_has_column(conn, "sessions", "thinking_level")? {
167 conn.execute("ALTER TABLE sessions ADD COLUMN thinking_level TEXT", [])?;
168 }
169 if !table_has_column(conn, "sessions", "verbose_level")? {
170 conn.execute("ALTER TABLE sessions ADD COLUMN verbose_level TEXT", [])?;
171 }
172 if !table_has_column(conn, "sessions", "reasoning_level")? {
173 conn.execute("ALTER TABLE sessions ADD COLUMN reasoning_level TEXT", [])?;
174 }
175 if table_has_column(conn, "sessions", "parent_session_key")? {
176 conn.execute(
177 "CREATE INDEX IF NOT EXISTS idx_sessions_parent_session_key
178 ON sessions(parent_session_key)",
179 [],
180 )?;
181 }
182 Ok(())
183}
184
185pub(crate) fn ensure_scheduled_tasks_schema(conn: &Connection) -> Result<(), MicroClawError> {
186 if !table_has_column(conn, "scheduled_tasks", "exit_criteria")? {
187 conn.execute(
188 "ALTER TABLE scheduled_tasks ADD COLUMN exit_criteria TEXT",
189 [],
190 )?;
191 }
192 if !table_has_column(conn, "scheduled_tasks", "run_count")? {
193 conn.execute(
194 "ALTER TABLE scheduled_tasks ADD COLUMN run_count INTEGER NOT NULL DEFAULT 0",
195 [],
196 )?;
197 }
198 if !table_has_column(conn, "scheduled_tasks", "max_runs")? {
199 conn.execute(
200 "ALTER TABLE scheduled_tasks ADD COLUMN max_runs INTEGER",
201 [],
202 )?;
203 }
204 if !table_has_column(conn, "scheduled_tasks", "not_after")? {
205 conn.execute("ALTER TABLE scheduled_tasks ADD COLUMN not_after TEXT", [])?;
206 }
207 if !table_has_column(conn, "scheduled_tasks", "timezone")? {
208 conn.execute(
209 "ALTER TABLE scheduled_tasks ADD COLUMN timezone TEXT NOT NULL DEFAULT ''",
210 [],
211 )?;
212 }
213 Ok(())
214}
215
216pub(crate) fn get_schema_version(conn: &Connection) -> Result<i64, MicroClawError> {
217 conn.execute(
218 "CREATE TABLE IF NOT EXISTS db_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
219 [],
220 )?;
221 let raw: Option<String> = conn
222 .query_row(
223 "SELECT value FROM db_meta WHERE key = 'schema_version'",
224 [],
225 |row| row.get(0),
226 )
227 .optional()?;
228 Ok(raw.and_then(|s| s.parse::<i64>().ok()).unwrap_or(0))
229}
230
231pub(crate) fn set_schema_version(conn: &Connection, version: i64) -> Result<(), MicroClawError> {
232 conn.execute(
233 "INSERT INTO db_meta(key, value) VALUES('schema_version', ?1)
234 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
235 params![version.to_string()],
236 )?;
237 conn.execute(
238 "CREATE TABLE IF NOT EXISTS schema_migrations (
239 version INTEGER PRIMARY KEY,
240 applied_at TEXT NOT NULL,
241 note TEXT
242 )",
243 [],
244 )?;
245 conn.execute(
246 "INSERT OR REPLACE INTO schema_migrations(version, applied_at, note)
247 VALUES(?1, ?2, ?3)",
248 params![version, chrono::Utc::now().to_rfc3339(), "applied"],
249 )?;
250 Ok(())
251}
252
253pub(crate) fn apply_schema_migrations(conn: &Connection) -> Result<(), MicroClawError> {
254 let mut version = get_schema_version(conn)?;
255 if version < 1 {
256 set_schema_version(conn, 1)?;
257 version = 1;
258 }
259 if version < 2 {
260 ensure_chat_identity_schema(conn)?;
261 ensure_memory_schema(conn)?;
262 conn.execute(
263 "CREATE INDEX IF NOT EXISTS idx_memories_active_updated ON memories(is_archived, updated_at)",
264 [],
265 )?;
266 conn.execute(
267 "CREATE INDEX IF NOT EXISTS idx_memories_confidence ON memories(confidence)",
268 [],
269 )?;
270 set_schema_version(conn, 2)?;
271 version = 2;
272 }
273 if version < 3 {
274 conn.execute_batch(
275 "CREATE TABLE IF NOT EXISTS memory_reflector_runs (
276 id INTEGER PRIMARY KEY AUTOINCREMENT,
277 chat_id INTEGER NOT NULL,
278 started_at TEXT NOT NULL,
279 finished_at TEXT NOT NULL,
280 extracted_count INTEGER NOT NULL DEFAULT 0,
281 inserted_count INTEGER NOT NULL DEFAULT 0,
282 updated_count INTEGER NOT NULL DEFAULT 0,
283 skipped_count INTEGER NOT NULL DEFAULT 0,
284 dedup_method TEXT NOT NULL,
285 parse_ok INTEGER NOT NULL DEFAULT 1,
286 error_text TEXT
287 );
288 CREATE INDEX IF NOT EXISTS idx_memory_reflector_runs_chat_started
289 ON memory_reflector_runs(chat_id, started_at);
290 CREATE TABLE IF NOT EXISTS memory_injection_logs (
291 id INTEGER PRIMARY KEY AUTOINCREMENT,
292 chat_id INTEGER NOT NULL,
293 created_at TEXT NOT NULL,
294 retrieval_method TEXT NOT NULL,
295 candidate_count INTEGER NOT NULL DEFAULT 0,
296 selected_count INTEGER NOT NULL DEFAULT 0,
297 omitted_count INTEGER NOT NULL DEFAULT 0,
298 tokens_est INTEGER NOT NULL DEFAULT 0
299 );
300 CREATE INDEX IF NOT EXISTS idx_memory_injection_logs_chat_created
301 ON memory_injection_logs(chat_id, created_at);",
302 )?;
303 set_schema_version(conn, 3)?;
304 version = 3;
305 }
306 if version < 4 {
307 conn.execute_batch(
308 "CREATE TABLE IF NOT EXISTS memory_supersede_edges (
309 id INTEGER PRIMARY KEY AUTOINCREMENT,
310 from_memory_id INTEGER NOT NULL,
311 to_memory_id INTEGER NOT NULL,
312 reason TEXT,
313 created_at TEXT NOT NULL
314 );
315 CREATE INDEX IF NOT EXISTS idx_memory_supersede_from
316 ON memory_supersede_edges(from_memory_id, created_at);
317 CREATE INDEX IF NOT EXISTS idx_memory_supersede_to
318 ON memory_supersede_edges(to_memory_id, created_at);",
319 )?;
320 set_schema_version(conn, 4)?;
321 version = 4;
322 }
323 if version < 5 {
324 conn.execute_batch(
325 "CREATE TABLE IF NOT EXISTS auth_passwords (
326 id INTEGER PRIMARY KEY CHECK(id = 1),
327 password_hash TEXT NOT NULL,
328 created_at TEXT NOT NULL,
329 updated_at TEXT NOT NULL
330 );
331 CREATE TABLE IF NOT EXISTS auth_sessions (
332 session_id TEXT PRIMARY KEY,
333 label TEXT,
334 created_at TEXT NOT NULL,
335 expires_at TEXT NOT NULL,
336 last_seen_at TEXT NOT NULL,
337 revoked_at TEXT
338 );
339 CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);
340 CREATE TABLE IF NOT EXISTS api_keys (
341 id INTEGER PRIMARY KEY AUTOINCREMENT,
342 label TEXT NOT NULL,
343 key_hash TEXT NOT NULL UNIQUE,
344 prefix TEXT NOT NULL,
345 created_at TEXT NOT NULL,
346 revoked_at TEXT,
347 last_used_at TEXT,
348 expires_at TEXT,
349 rotated_from_key_id INTEGER
350 );
351 CREATE TABLE IF NOT EXISTS api_key_scopes (
352 api_key_id INTEGER NOT NULL,
353 scope TEXT NOT NULL,
354 PRIMARY KEY (api_key_id, scope)
355 );
356 CREATE INDEX IF NOT EXISTS idx_api_key_scopes_scope ON api_key_scopes(scope);",
357 )?;
358 set_schema_version(conn, 5)?;
359 version = 5;
360 }
361 if version < 6 {
362 ensure_sessions_schema(conn)?;
363 set_schema_version(conn, 6)?;
364 version = 6;
365 }
366 if version < 7 {
367 conn.execute_batch(
368 "CREATE TABLE IF NOT EXISTS metrics_history (
369 timestamp_ms INTEGER PRIMARY KEY,
370 llm_completions INTEGER NOT NULL DEFAULT 0,
371 llm_input_tokens INTEGER NOT NULL DEFAULT 0,
372 llm_output_tokens INTEGER NOT NULL DEFAULT 0,
373 http_requests INTEGER NOT NULL DEFAULT 0,
374 tool_executions INTEGER NOT NULL DEFAULT 0,
375 mcp_calls INTEGER NOT NULL DEFAULT 0,
376 mcp_rate_limited_rejections INTEGER NOT NULL DEFAULT 0,
377 mcp_bulkhead_rejections INTEGER NOT NULL DEFAULT 0,
378 mcp_circuit_open_rejections INTEGER NOT NULL DEFAULT 0,
379 active_sessions INTEGER NOT NULL DEFAULT 0
380 );
381 CREATE INDEX IF NOT EXISTS idx_metrics_history_ts ON metrics_history(timestamp_ms);",
382 )?;
383 set_schema_version(conn, 7)?;
384 version = 7;
385 }
386 if version < 8 {
387 if !table_has_column(conn, "api_keys", "expires_at")? {
388 conn.execute("ALTER TABLE api_keys ADD COLUMN expires_at TEXT", [])?;
389 }
390 if !table_has_column(conn, "api_keys", "rotated_from_key_id")? {
391 conn.execute(
392 "ALTER TABLE api_keys ADD COLUMN rotated_from_key_id INTEGER",
393 [],
394 )?;
395 }
396 conn.execute_batch(
397 "CREATE TABLE IF NOT EXISTS audit_logs (
398 id INTEGER PRIMARY KEY AUTOINCREMENT,
399 kind TEXT NOT NULL,
400 actor TEXT NOT NULL,
401 action TEXT NOT NULL,
402 target TEXT,
403 status TEXT NOT NULL,
404 detail TEXT,
405 created_at TEXT NOT NULL
406 );
407 CREATE INDEX IF NOT EXISTS idx_audit_logs_kind_created
408 ON audit_logs(kind, created_at DESC);",
409 )?;
410 set_schema_version(conn, 8)?;
411 version = 8;
412 }
413 if version < 9 {
414 conn.execute_batch(
415 "CREATE TABLE IF NOT EXISTS scheduled_task_dlq (
416 id INTEGER PRIMARY KEY AUTOINCREMENT,
417 task_id INTEGER NOT NULL,
418 chat_id INTEGER NOT NULL,
419 failed_at TEXT NOT NULL,
420 started_at TEXT NOT NULL,
421 finished_at TEXT NOT NULL,
422 duration_ms INTEGER NOT NULL,
423 error_summary TEXT,
424 replayed_at TEXT,
425 replay_note TEXT
426 );
427 CREATE INDEX IF NOT EXISTS idx_scheduled_task_dlq_task_failed
428 ON scheduled_task_dlq(task_id, failed_at DESC);
429 CREATE INDEX IF NOT EXISTS idx_scheduled_task_dlq_chat_failed
430 ON scheduled_task_dlq(chat_id, failed_at DESC);",
431 )?;
432 set_schema_version(conn, 9)?;
433 version = 9;
434 }
435 if version < 10 {
436 if !table_has_column(conn, "metrics_history", "mcp_rate_limited_rejections")? {
437 conn.execute(
438 "ALTER TABLE metrics_history ADD COLUMN mcp_rate_limited_rejections INTEGER NOT NULL DEFAULT 0",
439 [],
440 )?;
441 }
442 if !table_has_column(conn, "metrics_history", "mcp_bulkhead_rejections")? {
443 conn.execute(
444 "ALTER TABLE metrics_history ADD COLUMN mcp_bulkhead_rejections INTEGER NOT NULL DEFAULT 0",
445 [],
446 )?;
447 }
448 if !table_has_column(conn, "metrics_history", "mcp_circuit_open_rejections")? {
449 conn.execute(
450 "ALTER TABLE metrics_history ADD COLUMN mcp_circuit_open_rejections INTEGER NOT NULL DEFAULT 0",
451 [],
452 )?;
453 }
454 set_schema_version(conn, 10)?;
455 version = 10;
456 }
457 if version < 11 {
458 if !table_has_column(conn, "sessions", "skill_envs_json")? {
459 conn.execute("ALTER TABLE sessions ADD COLUMN skill_envs_json TEXT", [])?;
460 }
461 set_schema_version(conn, 11)?;
462 version = 11;
463 }
464 if version < 12 {
465 ensure_scheduled_tasks_schema(conn)?;
466 set_schema_version(conn, 12)?;
467 version = 12;
468 }
469 if version < 13 {
470 conn.execute_batch(
471 "CREATE TABLE IF NOT EXISTS subagent_runs (
472 run_id TEXT PRIMARY KEY,
473 parent_run_id TEXT,
474 depth INTEGER NOT NULL DEFAULT 1,
475 chat_id INTEGER NOT NULL,
476 caller_channel TEXT NOT NULL,
477 task TEXT NOT NULL,
478 context TEXT NOT NULL DEFAULT '',
479 status TEXT NOT NULL,
480 created_at TEXT NOT NULL,
481 started_at TEXT,
482 finished_at TEXT,
483 cancel_requested INTEGER NOT NULL DEFAULT 0,
484 error_text TEXT,
485 result_text TEXT,
486 input_tokens INTEGER NOT NULL DEFAULT 0,
487 output_tokens INTEGER NOT NULL DEFAULT 0,
488 total_tokens INTEGER NOT NULL DEFAULT 0,
489 provider TEXT NOT NULL DEFAULT '',
490 model TEXT NOT NULL DEFAULT ''
491 );
492 CREATE INDEX IF NOT EXISTS idx_subagent_runs_chat_created
493 ON subagent_runs(chat_id, created_at DESC);
494 CREATE INDEX IF NOT EXISTS idx_subagent_runs_chat_status
495 ON subagent_runs(chat_id, status);
496 CREATE INDEX IF NOT EXISTS idx_subagent_runs_parent_status
497 ON subagent_runs(parent_run_id, status);",
498 )?;
499 set_schema_version(conn, 13)?;
500 version = 13;
501 }
502 if version < 14 {
503 if !table_has_column(conn, "subagent_runs", "parent_run_id")? {
504 conn.execute(
505 "ALTER TABLE subagent_runs ADD COLUMN parent_run_id TEXT",
506 [],
507 )?;
508 }
509 if !table_has_column(conn, "subagent_runs", "depth")? {
510 conn.execute(
511 "ALTER TABLE subagent_runs ADD COLUMN depth INTEGER NOT NULL DEFAULT 1",
512 [],
513 )?;
514 }
515 conn.execute(
516 "CREATE INDEX IF NOT EXISTS idx_subagent_runs_parent_status
517 ON subagent_runs(parent_run_id, status)",
518 [],
519 )?;
520 set_schema_version(conn, 14)?;
521 version = 14;
522 }
523 if version < 15 {
524 conn.execute_batch(
525 "CREATE TABLE IF NOT EXISTS subagent_announces (
526 id INTEGER PRIMARY KEY AUTOINCREMENT,
527 run_id TEXT NOT NULL UNIQUE,
528 chat_id INTEGER NOT NULL,
529 caller_channel TEXT NOT NULL,
530 payload_text TEXT NOT NULL,
531 status TEXT NOT NULL DEFAULT 'pending',
532 attempts INTEGER NOT NULL DEFAULT 0,
533 next_attempt_at TEXT,
534 last_error TEXT,
535 created_at TEXT NOT NULL,
536 updated_at TEXT NOT NULL
537 );
538 CREATE INDEX IF NOT EXISTS idx_subagent_announces_status_next
539 ON subagent_announces(status, next_attempt_at);",
540 )?;
541 set_schema_version(conn, 15)?;
542 version = 15;
543 }
544 if version < 16 {
545 conn.execute_batch(
546 "CREATE TABLE IF NOT EXISTS subagent_events (
547 id INTEGER PRIMARY KEY AUTOINCREMENT,
548 run_id TEXT NOT NULL,
549 event_type TEXT NOT NULL,
550 detail TEXT,
551 created_at TEXT NOT NULL
552 );
553 CREATE INDEX IF NOT EXISTS idx_subagent_events_run_created
554 ON subagent_events(run_id, created_at ASC);",
555 )?;
556 set_schema_version(conn, 16)?;
557 version = 16;
558 }
559 if version < 17 {
560 conn.execute_batch(
561 "CREATE TABLE IF NOT EXISTS subagent_focus_bindings (
562 chat_id INTEGER PRIMARY KEY,
563 run_id TEXT NOT NULL,
564 updated_at TEXT NOT NULL
565 );",
566 )?;
567 set_schema_version(conn, 17)?;
568 version = 17;
569 }
570 if version < 18 {
571 if !table_has_column(conn, "subagent_runs", "token_budget")? {
572 conn.execute(
573 "ALTER TABLE subagent_runs ADD COLUMN token_budget INTEGER NOT NULL DEFAULT 0",
574 [],
575 )?;
576 }
577 if !table_has_column(conn, "subagent_runs", "artifact_json")? {
578 conn.execute(
579 "ALTER TABLE subagent_runs ADD COLUMN artifact_json TEXT",
580 [],
581 )?;
582 }
583 set_schema_version(conn, 18)?;
584 version = 18;
585 }
586 if version < 19 {
587 ensure_sessions_schema(conn)?;
588 set_schema_version(conn, 19)?;
589 version = 19;
590 }
591 if version < 20 {
592 if !table_has_column(conn, "memories", "valid_from")? {
594 conn.execute("ALTER TABLE memories ADD COLUMN valid_from TEXT", [])?;
595 }
596 if !table_has_column(conn, "memories", "valid_to")? {
597 conn.execute("ALTER TABLE memories ADD COLUMN valid_to TEXT", [])?;
598 }
599 conn.execute_batch(
600 "CREATE TABLE IF NOT EXISTS knowledge_graph (
601 id INTEGER PRIMARY KEY AUTOINCREMENT,
602 subject TEXT NOT NULL,
603 predicate TEXT NOT NULL,
604 object TEXT NOT NULL,
605 chat_id INTEGER,
606 valid_from TEXT NOT NULL,
607 valid_to TEXT,
608 confidence REAL NOT NULL DEFAULT 0.70,
609 source TEXT NOT NULL DEFAULT 'reflector',
610 source_memory_id INTEGER,
611 created_at TEXT NOT NULL
612 );
613 CREATE INDEX IF NOT EXISTS idx_kg_subject ON knowledge_graph(subject);
614 CREATE INDEX IF NOT EXISTS idx_kg_object ON knowledge_graph(object);
615 CREATE INDEX IF NOT EXISTS idx_kg_predicate ON knowledge_graph(predicate);
616 CREATE INDEX IF NOT EXISTS idx_kg_chat ON knowledge_graph(chat_id);
617 CREATE INDEX IF NOT EXISTS idx_kg_valid_range ON knowledge_graph(valid_from, valid_to);",
618 )?;
619 set_schema_version(conn, 20)?;
620 version = 20;
621 }
622 if version < 21 {
623 conn.execute_batch(
630 "CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
631 content,
632 sender_name,
633 chat_id UNINDEXED,
634 message_id UNINDEXED,
635 timestamp UNINDEXED,
636 is_from_bot UNINDEXED,
637 tokenize = 'unicode61 remove_diacritics 2'
638 );",
639 )?;
640 conn.execute_batch(
643 "INSERT INTO messages_fts(content, sender_name, chat_id, message_id, timestamp, is_from_bot)
644 SELECT content, sender_name, chat_id, id, timestamp, is_from_bot FROM messages;",
645 )?;
646 conn.execute_batch(
647 "CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN
648 INSERT INTO messages_fts(content, sender_name, chat_id, message_id, timestamp, is_from_bot)
649 VALUES (new.content, new.sender_name, new.chat_id, new.id, new.timestamp, new.is_from_bot);
650 END;
651 CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages BEGIN
652 DELETE FROM messages_fts WHERE chat_id = old.chat_id AND message_id = old.id;
653 END;
654 CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE ON messages BEGIN
655 DELETE FROM messages_fts WHERE chat_id = old.chat_id AND message_id = old.id;
656 INSERT INTO messages_fts(content, sender_name, chat_id, message_id, timestamp, is_from_bot)
657 VALUES (new.content, new.sender_name, new.chat_id, new.id, new.timestamp, new.is_from_bot);
658 END;",
659 )?;
660 set_schema_version(conn, 21)?;
661 version = 21;
662 }
663 if version < 22 {
664 conn.execute_batch(
667 "CREATE TABLE IF NOT EXISTS tool_result_cache (
668 cache_key TEXT PRIMARY KEY,
669 tool_name TEXT NOT NULL,
670 result_content TEXT NOT NULL,
671 is_error INTEGER NOT NULL DEFAULT 0,
672 metadata_json TEXT,
673 created_at TEXT NOT NULL,
674 expires_at TEXT NOT NULL
675 );
676 CREATE INDEX IF NOT EXISTS idx_tool_result_cache_tool_expires
677 ON tool_result_cache(tool_name, expires_at);
678 CREATE INDEX IF NOT EXISTS idx_tool_result_cache_expires
679 ON tool_result_cache(expires_at);",
680 )?;
681 set_schema_version(conn, 22)?;
682 version = 22;
683 }
684 if version < 23 {
685 conn.execute_batch(
690 "CREATE TABLE IF NOT EXISTS tool_result_artifacts (
691 artifact_id TEXT PRIMARY KEY,
692 chat_id INTEGER NOT NULL,
693 tool_name TEXT NOT NULL,
694 content TEXT NOT NULL,
695 total_chars INTEGER NOT NULL,
696 created_at TEXT NOT NULL,
697 expires_at TEXT NOT NULL
698 );
699 CREATE INDEX IF NOT EXISTS idx_tool_result_artifacts_chat
700 ON tool_result_artifacts(chat_id, expires_at);
701 CREATE INDEX IF NOT EXISTS idx_tool_result_artifacts_expires
702 ON tool_result_artifacts(expires_at);",
703 )?;
704 set_schema_version(conn, 23)?;
705 version = 23;
706 }
707 if version < 24 {
708 if !table_has_column(conn, "memories", "expires_at")? {
712 conn.execute("ALTER TABLE memories ADD COLUMN expires_at TEXT", [])?;
713 }
714 conn.execute(
715 "CREATE INDEX IF NOT EXISTS idx_memories_expires ON memories(expires_at)
716 WHERE expires_at IS NOT NULL",
717 [],
718 )?;
719 set_schema_version(conn, 24)?;
720 version = 24;
721 }
722 if version < 25 {
723 conn.execute_batch(
727 "CREATE TABLE IF NOT EXISTS skill_activation_logs (
728 id INTEGER PRIMARY KEY AUTOINCREMENT,
729 skill_name TEXT NOT NULL,
730 chat_id INTEGER,
731 activated_at TEXT NOT NULL
732 );
733 CREATE INDEX IF NOT EXISTS idx_skill_activation_name_time
734 ON skill_activation_logs(skill_name, activated_at DESC);
735 CREATE INDEX IF NOT EXISTS idx_skill_activation_time
736 ON skill_activation_logs(activated_at);",
737 )?;
738 set_schema_version(conn, 25)?;
739 version = 25;
740 }
741 if version < 26 {
742 if !table_has_column(conn, "subagent_runs", "label")? {
746 conn.execute("ALTER TABLE subagent_runs ADD COLUMN label TEXT", [])?;
747 }
748 if !table_has_column(conn, "subagent_runs", "progress_text")? {
749 conn.execute(
750 "ALTER TABLE subagent_runs ADD COLUMN progress_text TEXT",
751 [],
752 )?;
753 }
754 if !table_has_column(conn, "subagent_runs", "last_progress_at")? {
755 conn.execute(
756 "ALTER TABLE subagent_runs ADD COLUMN last_progress_at TEXT",
757 [],
758 )?;
759 }
760 set_schema_version(conn, 26)?;
761 version = 26;
762 }
763 if version < 27 {
764 if !table_has_column(conn, "audit_logs", "prev_hash")? {
769 conn.execute("ALTER TABLE audit_logs ADD COLUMN prev_hash TEXT", [])?;
770 }
771 if !table_has_column(conn, "audit_logs", "entry_hash")? {
772 conn.execute("ALTER TABLE audit_logs ADD COLUMN entry_hash TEXT", [])?;
773 }
774 set_schema_version(conn, 27)?;
775 version = 27;
776 }
777 if version < 28 {
778 conn.execute_batch(
784 "CREATE TABLE IF NOT EXISTS active_turns (
785 chat_id INTEGER PRIMARY KEY,
786 channel TEXT NOT NULL,
787 started_at TEXT NOT NULL
788 );",
789 )?;
790 set_schema_version(conn, 28)?;
791 version = 28;
792 }
793 if version < 29 {
794 conn.execute_batch(
800 "CREATE TABLE IF NOT EXISTS outbox_messages (
801 id INTEGER PRIMARY KEY AUTOINCREMENT,
802 chat_id INTEGER NOT NULL,
803 channel TEXT NOT NULL,
804 payload_text TEXT NOT NULL,
805 status TEXT NOT NULL DEFAULT 'pending',
806 attempts INTEGER NOT NULL DEFAULT 0,
807 next_attempt_at TEXT,
808 last_error TEXT,
809 created_at TEXT NOT NULL,
810 updated_at TEXT NOT NULL
811 );
812 CREATE INDEX IF NOT EXISTS idx_outbox_status_next
813 ON outbox_messages(status, next_attempt_at);",
814 )?;
815 if !table_has_column(conn, "active_turns", "progress_text")? {
816 conn.execute("ALTER TABLE active_turns ADD COLUMN progress_text TEXT", [])?;
817 }
818 set_schema_version(conn, 29)?;
819 version = 29;
820 }
821 if version < 30 {
822 conn.execute_batch(
827 "CREATE TABLE IF NOT EXISTS outbound_deliveries (
828 delivery_id TEXT PRIMARY KEY,
829 chat_id INTEGER NOT NULL,
830 channel TEXT NOT NULL,
831 full_payload_text TEXT NOT NULL,
832 status TEXT NOT NULL DEFAULT 'pending',
833 stored_at TEXT,
834 created_at TEXT NOT NULL,
835 updated_at TEXT NOT NULL
836 );
837 CREATE TABLE IF NOT EXISTS outbound_delivery_chunks (
838 id INTEGER PRIMARY KEY AUTOINCREMENT,
839 delivery_id TEXT NOT NULL,
840 chunk_index INTEGER NOT NULL,
841 total_chunks INTEGER NOT NULL,
842 payload_text TEXT NOT NULL,
843 idempotency_key TEXT NOT NULL,
844 status TEXT NOT NULL DEFAULT 'pending',
845 attempts INTEGER NOT NULL DEFAULT 0,
846 next_attempt_at TEXT,
847 last_error TEXT,
848 created_at TEXT NOT NULL,
849 updated_at TEXT NOT NULL,
850 UNIQUE(delivery_id, chunk_index),
851 UNIQUE(idempotency_key),
852 FOREIGN KEY(delivery_id) REFERENCES outbound_deliveries(delivery_id)
853 );
854 CREATE INDEX IF NOT EXISTS idx_delivery_chunks_status_next
855 ON outbound_delivery_chunks(status, next_attempt_at);
856 INSERT OR IGNORE INTO outbound_deliveries(
857 delivery_id, chat_id, channel, full_payload_text, status,
858 stored_at, created_at, updated_at
859 )
860 SELECT 'legacy-' || id, chat_id, channel, payload_text,
861 CASE WHEN status = 'delivered' THEN 'delivered'
862 WHEN status = 'failed' THEN 'failed' ELSE 'pending' END,
863 CASE WHEN status = 'delivered' THEN updated_at ELSE NULL END,
864 created_at, updated_at
865 FROM outbox_messages;
866 INSERT OR IGNORE INTO outbound_delivery_chunks(
867 delivery_id, chunk_index, total_chunks, payload_text,
868 idempotency_key, status, attempts, next_attempt_at,
869 last_error, created_at, updated_at
870 )
871 SELECT 'legacy-' || id, 0, 1, payload_text, 'legacy-' || id,
872 status, attempts, next_attempt_at, last_error,
873 created_at, updated_at
874 FROM outbox_messages;",
875 )?;
876 set_schema_version(conn, 30)?;
877 version = 30;
878 }
879 if version < 31 {
880 if !table_has_column(conn, "active_turns", "chat_type")? {
885 conn.execute(
886 "ALTER TABLE active_turns ADD COLUMN chat_type TEXT NOT NULL DEFAULT 'private'",
887 [],
888 )?;
889 }
890 if !table_has_column(conn, "active_turns", "phase")? {
891 conn.execute(
892 "ALTER TABLE active_turns ADD COLUMN phase TEXT NOT NULL DEFAULT 'starting'",
893 [],
894 )?;
895 }
896 if !table_has_column(conn, "active_turns", "iteration")? {
897 conn.execute(
898 "ALTER TABLE active_turns ADD COLUMN iteration INTEGER NOT NULL DEFAULT 0",
899 [],
900 )?;
901 }
902 if !table_has_column(conn, "active_turns", "session_json")? {
903 conn.execute("ALTER TABLE active_turns ADD COLUMN session_json TEXT", [])?;
904 }
905 if !table_has_column(conn, "active_turns", "resumable")? {
906 conn.execute(
907 "ALTER TABLE active_turns ADD COLUMN resumable INTEGER NOT NULL DEFAULT 0",
908 [],
909 )?;
910 }
911 if !table_has_column(conn, "active_turns", "tool_summary")? {
912 conn.execute("ALTER TABLE active_turns ADD COLUMN tool_summary TEXT", [])?;
913 }
914 if !table_has_column(conn, "active_turns", "last_checkpoint_at")? {
915 conn.execute(
916 "ALTER TABLE active_turns ADD COLUMN last_checkpoint_at TEXT",
917 [],
918 )?;
919 }
920 if !table_has_column(conn, "active_turns", "run_id")? {
921 conn.execute("ALTER TABLE active_turns ADD COLUMN run_id TEXT", [])?;
922 }
923 conn.execute(
924 "CREATE INDEX IF NOT EXISTS idx_active_turns_checkpoint
925 ON active_turns(resumable, last_checkpoint_at)",
926 [],
927 )?;
928 set_schema_version(conn, 31)?;
929 version = 31;
930 }
931 if version < 32 {
932 conn.execute_batch(
936 "CREATE TABLE IF NOT EXISTS goal_states (
937 goal_id TEXT PRIMARY KEY,
938 chat_id INTEGER NOT NULL,
939 objective TEXT NOT NULL,
940 status TEXT NOT NULL DEFAULT 'active',
941 constraints_json TEXT,
942 progress_json TEXT,
943 budget_json TEXT,
944 created_at TEXT NOT NULL,
945 updated_at TEXT NOT NULL,
946 completed_at TEXT
947 );
948 CREATE INDEX IF NOT EXISTS idx_goal_states_chat_status
949 ON goal_states(chat_id, status, updated_at DESC);
950
951 CREATE TABLE IF NOT EXISTS experience_runs (
952 run_id TEXT PRIMARY KEY,
953 goal_id TEXT,
954 chat_id INTEGER NOT NULL,
955 channel TEXT NOT NULL,
956 run_kind TEXT NOT NULL,
957 objective TEXT NOT NULL,
958 environment_fingerprint TEXT,
959 status TEXT NOT NULL DEFAULT 'running',
960 result_summary TEXT,
961 started_at TEXT NOT NULL,
962 finished_at TEXT,
963 duration_ms INTEGER,
964 FOREIGN KEY(goal_id) REFERENCES goal_states(goal_id)
965 );
966 CREATE INDEX IF NOT EXISTS idx_experience_runs_chat_started
967 ON experience_runs(chat_id, started_at DESC);
968 CREATE INDEX IF NOT EXISTS idx_experience_runs_goal_started
969 ON experience_runs(goal_id, started_at DESC);
970 CREATE INDEX IF NOT EXISTS idx_experience_runs_status
971 ON experience_runs(status, started_at DESC);
972
973 CREATE TABLE IF NOT EXISTS verifier_results (
974 id INTEGER PRIMARY KEY AUTOINCREMENT,
975 run_id TEXT NOT NULL,
976 verifier_type TEXT NOT NULL,
977 verifier_name TEXT NOT NULL,
978 verdict TEXT NOT NULL,
979 confidence REAL NOT NULL,
980 evidence TEXT,
981 scope TEXT,
982 verified_at TEXT NOT NULL,
983 valid_until TEXT,
984 UNIQUE(run_id, verifier_type, verifier_name),
985 FOREIGN KEY(run_id) REFERENCES experience_runs(run_id)
986 );
987 CREATE INDEX IF NOT EXISTS idx_verifier_results_run
988 ON verifier_results(run_id, verified_at DESC);
989
990 CREATE TABLE IF NOT EXISTS skill_versions (
991 id INTEGER PRIMARY KEY AUTOINCREMENT,
992 skill_name TEXT NOT NULL,
993 version INTEGER NOT NULL,
994 content TEXT NOT NULL,
995 content_hash TEXT NOT NULL,
996 source TEXT NOT NULL,
997 created_at TEXT NOT NULL,
998 UNIQUE(skill_name, version)
999 );
1000 CREATE INDEX IF NOT EXISTS idx_skill_versions_name_version
1001 ON skill_versions(skill_name, version DESC);
1002
1003 CREATE TABLE IF NOT EXISTS skill_lifecycle (
1004 skill_name TEXT PRIMARY KEY,
1005 state TEXT NOT NULL,
1006 active_version INTEGER NOT NULL,
1007 previous_trusted_version INTEGER,
1008 source TEXT NOT NULL,
1009 created_at TEXT NOT NULL,
1010 updated_at TEXT NOT NULL,
1011 state_reason TEXT
1012 );
1013 CREATE INDEX IF NOT EXISTS idx_skill_lifecycle_state
1014 ON skill_lifecycle(state, updated_at DESC);
1015
1016 CREATE TABLE IF NOT EXISTS skill_lifecycle_events (
1017 id INTEGER PRIMARY KEY AUTOINCREMENT,
1018 skill_name TEXT NOT NULL,
1019 from_state TEXT,
1020 to_state TEXT NOT NULL,
1021 version INTEGER NOT NULL,
1022 reason TEXT NOT NULL,
1023 created_at TEXT NOT NULL
1024 );
1025 CREATE INDEX IF NOT EXISTS idx_skill_lifecycle_events_name
1026 ON skill_lifecycle_events(skill_name, created_at DESC);
1027
1028 CREATE TABLE IF NOT EXISTS skill_outcomes (
1029 id INTEGER PRIMARY KEY AUTOINCREMENT,
1030 skill_name TEXT NOT NULL,
1031 skill_version INTEGER NOT NULL,
1032 run_id TEXT NOT NULL,
1033 verdict TEXT NOT NULL,
1034 verifier_type TEXT NOT NULL,
1035 confidence REAL NOT NULL,
1036 evidence TEXT,
1037 created_at TEXT NOT NULL,
1038 updated_at TEXT NOT NULL,
1039 UNIQUE(skill_name, run_id),
1040 FOREIGN KEY(run_id) REFERENCES experience_runs(run_id)
1041 );
1042 CREATE INDEX IF NOT EXISTS idx_skill_outcomes_name_created
1043 ON skill_outcomes(skill_name, created_at DESC);",
1044 )?;
1045 if !table_has_column(conn, "skill_activation_logs", "experience_run_id")? {
1046 conn.execute(
1047 "ALTER TABLE skill_activation_logs ADD COLUMN experience_run_id TEXT",
1048 [],
1049 )?;
1050 }
1051 if !table_has_column(conn, "skill_activation_logs", "skill_version")? {
1052 conn.execute(
1053 "ALTER TABLE skill_activation_logs ADD COLUMN skill_version INTEGER",
1054 [],
1055 )?;
1056 }
1057 conn.execute(
1058 "CREATE INDEX IF NOT EXISTS idx_skill_activation_experience
1059 ON skill_activation_logs(experience_run_id)",
1060 [],
1061 )?;
1062 set_schema_version(conn, 32)?;
1063 version = 32;
1064 }
1065 if version < 33 {
1066 for (column, definition) in [
1067 ("input_tokens", "INTEGER NOT NULL DEFAULT 0"),
1068 ("output_tokens", "INTEGER NOT NULL DEFAULT 0"),
1069 ("llm_requests", "INTEGER NOT NULL DEFAULT 0"),
1070 ("tool_calls", "INTEGER NOT NULL DEFAULT 0"),
1071 ("tool_errors", "INTEGER NOT NULL DEFAULT 0"),
1072 ("estimated_cost_usd", "REAL"),
1073 ] {
1074 if !table_has_column(conn, "experience_runs", column)? {
1075 conn.execute(
1076 &format!("ALTER TABLE experience_runs ADD COLUMN {column} {definition}"),
1077 [],
1078 )?;
1079 }
1080 }
1081 set_schema_version(conn, 33)?;
1082 version = 33;
1083 }
1084 if version < 34 {
1085 if !table_has_column(conn, "skill_outcomes", "verifier_name")? {
1086 conn.execute(
1087 "ALTER TABLE skill_outcomes ADD COLUMN verifier_name TEXT",
1088 [],
1089 )?;
1090 }
1091 if !table_has_column(conn, "skill_outcomes", "valid_until")? {
1092 conn.execute("ALTER TABLE skill_outcomes ADD COLUMN valid_until TEXT", [])?;
1093 }
1094 set_schema_version(conn, 34)?;
1095 version = 34;
1096 }
1097 if version < 35 {
1098 if !table_has_column(conn, "skill_outcomes", "attribution_confidence")? {
1099 conn.execute(
1100 "ALTER TABLE skill_outcomes
1101 ADD COLUMN attribution_confidence REAL NOT NULL DEFAULT 1.0",
1102 [],
1103 )?;
1104 }
1105 set_schema_version(conn, 35)?;
1106 version = 35;
1107 }
1108 if version < 36 {
1109 conn.execute_batch(
1110 "CREATE TABLE IF NOT EXISTS skill_governance_policy (
1111 singleton_id INTEGER PRIMARY KEY CHECK(singleton_id=1),
1112 candidate_failures_to_degrade INTEGER NOT NULL,
1113 trial_min_outcomes INTEGER NOT NULL,
1114 trial_promote_rate REAL NOT NULL,
1115 trial_degrade_rate REAL NOT NULL,
1116 trusted_min_outcomes INTEGER NOT NULL,
1117 trusted_degrade_rate REAL NOT NULL,
1118 updated_at TEXT NOT NULL
1119 );",
1120 )?;
1121 let policy = SkillGovernancePolicy::default();
1122 conn.execute(
1123 "INSERT OR IGNORE INTO skill_governance_policy(
1124 singleton_id, candidate_failures_to_degrade, trial_min_outcomes,
1125 trial_promote_rate, trial_degrade_rate, trusted_min_outcomes,
1126 trusted_degrade_rate, updated_at
1127 ) VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1128 params![
1129 policy.candidate_failures_to_degrade,
1130 policy.trial_min_outcomes,
1131 policy.trial_promote_rate,
1132 policy.trial_degrade_rate,
1133 policy.trusted_min_outcomes,
1134 policy.trusted_degrade_rate,
1135 chrono::Utc::now().to_rfc3339()
1136 ],
1137 )?;
1138 set_schema_version(conn, 36)?;
1139 version = 36;
1140 }
1141 if version < 37 {
1142 conn.execute_batch(
1143 "CREATE TABLE IF NOT EXISTS outcome_envelopes (
1144 envelope_id TEXT PRIMARY KEY,
1145 schema_version INTEGER NOT NULL,
1146 run_id TEXT NOT NULL,
1147 source_kind TEXT NOT NULL,
1148 source_name TEXT NOT NULL,
1149 verdict TEXT NOT NULL,
1150 confidence REAL NOT NULL,
1151 evidence TEXT,
1152 scope TEXT,
1153 valid_until TEXT,
1154 payload_json TEXT NOT NULL DEFAULT '{}',
1155 created_at TEXT NOT NULL,
1156 FOREIGN KEY(run_id) REFERENCES experience_runs(run_id)
1157 );
1158 CREATE INDEX IF NOT EXISTS idx_outcome_envelopes_run_created
1159 ON outcome_envelopes(run_id, created_at ASC);
1160
1161 CREATE TABLE IF NOT EXISTS experience_feedback (
1162 feedback_id TEXT NOT NULL,
1163 run_id TEXT NOT NULL,
1164 actor TEXT NOT NULL,
1165 verdict TEXT NOT NULL,
1166 confidence REAL NOT NULL,
1167 evidence TEXT,
1168 scope TEXT,
1169 created_at TEXT NOT NULL,
1170 updated_at TEXT NOT NULL,
1171 valid_until TEXT,
1172 PRIMARY KEY(run_id, actor, feedback_id),
1173 FOREIGN KEY(run_id) REFERENCES experience_runs(run_id)
1174 );
1175 CREATE INDEX IF NOT EXISTS idx_experience_feedback_run_updated
1176 ON experience_feedback(run_id, updated_at DESC);
1177
1178 CREATE TABLE IF NOT EXISTS experience_retrieval_logs (
1179 querying_run_id TEXT NOT NULL,
1180 source_run_id TEXT NOT NULL,
1181 rank INTEGER NOT NULL,
1182 selection_reason TEXT NOT NULL,
1183 relevance_score REAL NOT NULL,
1184 injected_at TEXT NOT NULL,
1185 PRIMARY KEY(querying_run_id, source_run_id),
1186 FOREIGN KEY(querying_run_id) REFERENCES experience_runs(run_id),
1187 FOREIGN KEY(source_run_id) REFERENCES experience_runs(run_id)
1188 );
1189 CREATE INDEX IF NOT EXISTS idx_experience_retrieval_query_rank
1190 ON experience_retrieval_logs(querying_run_id, rank ASC);
1191
1192 INSERT OR IGNORE INTO outcome_envelopes(
1193 envelope_id, schema_version, run_id, source_kind, source_name,
1194 verdict, confidence, evidence, scope, valid_until, payload_json,
1195 created_at
1196 )
1197 SELECT 'legacy-verifier:' || id, 1, run_id, verifier_type,
1198 verifier_name, verdict, confidence, evidence, scope,
1199 valid_until, '{}', verified_at
1200 FROM verifier_results;
1201
1202 INSERT OR IGNORE INTO experience_feedback(
1203 feedback_id, run_id, actor, verdict, confidence, evidence,
1204 scope, created_at, updated_at, valid_until
1205 )
1206 SELECT verifier_name, run_id, 'legacy', verdict, confidence,
1207 evidence, scope, verified_at, verified_at, valid_until
1208 FROM verifier_results
1209 WHERE verifier_type='human';",
1210 )?;
1211 set_schema_version(conn, 37)?;
1212 version = 37;
1213 }
1214 if version < 38 {
1215 for (column, definition) in [
1216 ("task_signature_version", "INTEGER NOT NULL DEFAULT 1"),
1217 ("task_type", "TEXT NOT NULL DEFAULT 'general'"),
1218 ("task_family", "TEXT NOT NULL DEFAULT 'general_assistance'"),
1219 ("capability_tags_json", "TEXT NOT NULL DEFAULT '[]'"),
1220 ("task_signature_hash", "TEXT NOT NULL DEFAULT ''"),
1221 ] {
1222 if !table_has_column(conn, "experience_runs", column)? {
1223 conn.execute(
1224 &format!("ALTER TABLE experience_runs ADD COLUMN {column} {definition}"),
1225 [],
1226 )?;
1227 }
1228 }
1229 if !table_has_column(conn, "skill_governance_policy", "utility_confidence_z")? {
1230 conn.execute(
1231 "ALTER TABLE skill_governance_policy
1232 ADD COLUMN utility_confidence_z REAL NOT NULL DEFAULT 1.96",
1233 [],
1234 )?;
1235 }
1236 if !table_has_column(
1237 conn,
1238 "skill_governance_policy",
1239 "trial_promote_utility_lower_bound",
1240 )? {
1241 conn.execute(
1242 "ALTER TABLE skill_governance_policy
1243 ADD COLUMN trial_promote_utility_lower_bound REAL NOT NULL DEFAULT 0.4",
1244 [],
1245 )?;
1246 }
1247 let mut stmt = conn.prepare(
1248 "SELECT run_id, objective, run_kind FROM experience_runs
1249 WHERE task_signature_hash=''",
1250 )?;
1251 let existing = stmt
1252 .query_map([], |row| {
1253 Ok((
1254 row.get::<_, String>(0)?,
1255 row.get::<_, String>(1)?,
1256 row.get::<_, String>(2)?,
1257 ))
1258 })?
1259 .collect::<Result<Vec<_>, _>>()?;
1260 drop(stmt);
1261 for (run_id, objective, run_kind) in existing {
1262 let signature = derive_task_signature(&objective, &run_kind);
1263 conn.execute(
1264 "UPDATE experience_runs SET task_signature_version=?2,
1265 task_type=?3, task_family=?4, capability_tags_json=?5,
1266 task_signature_hash=?6 WHERE run_id=?1",
1267 params![
1268 run_id,
1269 signature.version,
1270 signature.task_type,
1271 signature.task_family,
1272 serde_json::to_string(&signature.capability_tags)
1273 .unwrap_or_else(|_| "[]".into()),
1274 signature.signature_hash
1275 ],
1276 )?;
1277 }
1278 conn.execute(
1279 "CREATE INDEX IF NOT EXISTS idx_experience_runs_task_signature
1280 ON experience_runs(task_type, task_family, started_at DESC)",
1281 [],
1282 )?;
1283 set_schema_version(conn, 38)?;
1284 version = 38;
1285 }
1286 if version < 39 {
1287 conn.execute_batch(
1288 "CREATE TABLE IF NOT EXISTS skill_failure_patterns (
1289 pattern_id TEXT PRIMARY KEY,
1290 skill_name TEXT NOT NULL,
1291 skill_version INTEGER NOT NULL,
1292 task_type TEXT NOT NULL,
1293 task_family TEXT NOT NULL,
1294 environment_fingerprint TEXT,
1295 tool_name TEXT,
1296 error_category TEXT NOT NULL,
1297 failure_count INTEGER NOT NULL DEFAULT 0,
1298 recovery_successes INTEGER NOT NULL DEFAULT 0,
1299 state TEXT NOT NULL DEFAULT 'observed',
1300 cooldown_until TEXT,
1301 last_evidence TEXT,
1302 first_seen_at TEXT NOT NULL,
1303 updated_at TEXT NOT NULL,
1304 UNIQUE(skill_name, skill_version, task_family,
1305 environment_fingerprint, tool_name, error_category)
1306 );
1307 CREATE INDEX IF NOT EXISTS idx_skill_failure_patterns_lookup
1308 ON skill_failure_patterns(
1309 skill_name, skill_version, task_family, state, updated_at DESC
1310 );
1311 CREATE TABLE IF NOT EXISTS skill_failure_pattern_evidence (
1312 pattern_id TEXT NOT NULL,
1313 run_id TEXT NOT NULL,
1314 verdict TEXT NOT NULL,
1315 created_at TEXT NOT NULL,
1316 PRIMARY KEY(pattern_id, run_id),
1317 FOREIGN KEY(pattern_id) REFERENCES skill_failure_patterns(pattern_id),
1318 FOREIGN KEY(run_id) REFERENCES experience_runs(run_id)
1319 );
1320
1321 CREATE TABLE IF NOT EXISTS experience_retrieval_rejections (
1322 querying_run_id TEXT NOT NULL,
1323 source_run_id TEXT NOT NULL,
1324 rejection_reason TEXT NOT NULL,
1325 relevance_score REAL NOT NULL,
1326 rejected_at TEXT NOT NULL,
1327 PRIMARY KEY(querying_run_id, source_run_id),
1328 FOREIGN KEY(querying_run_id) REFERENCES experience_runs(run_id),
1329 FOREIGN KEY(source_run_id) REFERENCES experience_runs(run_id)
1330 );
1331 CREATE INDEX IF NOT EXISTS idx_experience_rejection_query
1332 ON experience_retrieval_rejections(querying_run_id, rejected_at ASC);",
1333 )?;
1334 for (column, definition) in [
1335 ("failure_pattern_min_failures", "INTEGER NOT NULL DEFAULT 2"),
1336 (
1337 "failure_pattern_cooldown_hours",
1338 "INTEGER NOT NULL DEFAULT 24",
1339 ),
1340 (
1341 "failure_pattern_recovery_successes",
1342 "INTEGER NOT NULL DEFAULT 2",
1343 ),
1344 ] {
1345 if !table_has_column(conn, "skill_governance_policy", column)? {
1346 conn.execute(
1347 &format!(
1348 "ALTER TABLE skill_governance_policy ADD COLUMN {column} {definition}"
1349 ),
1350 [],
1351 )?;
1352 }
1353 }
1354 let policy = SkillGovernancePolicy::default();
1355 let historical = {
1356 let mut stmt = conn.prepare(
1357 "SELECT o.skill_name, o.skill_version, o.run_id,
1358 r.task_type, r.task_family, r.environment_fingerprint,
1359 json_extract(e.payload_json, '$.tool_name'),
1360 COALESCE(e.evidence, o.evidence)
1361 FROM skill_outcomes o
1362 JOIN experience_runs r ON r.run_id=o.run_id
1363 LEFT JOIN outcome_envelopes e ON e.run_id=o.run_id
1364 AND e.verdict='failed' AND e.scope='tool_result'
1365 WHERE o.verdict='failed'
1366 AND o.attribution_confidence >= 0.999
1367 AND o.verifier_type IN (
1368 'deterministic','environmental','human','rule_based'
1369 )
1370 AND (o.valid_until IS NULL OR o.valid_until>?1)
1371 ORDER BY o.created_at ASC",
1372 )?;
1373 let rows = stmt
1374 .query_map(params![chrono::Utc::now().to_rfc3339()], |row| {
1375 Ok((
1376 row.get::<_, String>(0)?,
1377 row.get::<_, i64>(1)?,
1378 row.get::<_, String>(2)?,
1379 row.get::<_, String>(3)?,
1380 row.get::<_, String>(4)?,
1381 row.get::<_, Option<String>>(5)?,
1382 row.get::<_, Option<String>>(6)?,
1383 row.get::<_, Option<String>>(7)?,
1384 ))
1385 })?
1386 .collect::<Result<Vec<_>, _>>()?;
1387 rows
1388 };
1389 let now = chrono::Utc::now();
1390 let cooldown_until =
1391 (now + chrono::Duration::hours(policy.failure_pattern_cooldown_hours)).to_rfc3339();
1392 for (
1393 skill_name,
1394 skill_version,
1395 run_id,
1396 task_type,
1397 task_family,
1398 environment,
1399 tool_name,
1400 evidence,
1401 ) in historical
1402 {
1403 let category = classify_failure_category(evidence.as_deref());
1404 let canonical = format!(
1405 "{skill_name}\n{skill_version}\n{task_family}\n{}\n{}\n{category}",
1406 environment.as_deref().unwrap_or(""),
1407 tool_name.as_deref().unwrap_or("")
1408 );
1409 use sha2::{Digest, Sha256};
1410 let pattern_id = to_hex(&Sha256::digest(canonical.as_bytes()));
1411 conn.execute(
1412 "INSERT INTO skill_failure_patterns(
1413 pattern_id, skill_name, skill_version, task_type, task_family,
1414 environment_fingerprint, tool_name, error_category,
1415 failure_count, recovery_successes, state, cooldown_until,
1416 last_evidence, first_seen_at, updated_at
1417 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,0,0,'observed',NULL,?9,?10,?10)
1418 ON CONFLICT(pattern_id) DO UPDATE SET
1419 last_evidence=excluded.last_evidence,
1420 updated_at=excluded.updated_at",
1421 params![
1422 pattern_id,
1423 skill_name,
1424 skill_version,
1425 task_type,
1426 task_family,
1427 environment,
1428 tool_name,
1429 category,
1430 evidence,
1431 now.to_rfc3339()
1432 ],
1433 )?;
1434 if conn.execute(
1435 "INSERT OR IGNORE INTO skill_failure_pattern_evidence(
1436 pattern_id, run_id, verdict, created_at
1437 ) VALUES (?1,?2,'failed',?3)",
1438 params![pattern_id, run_id, now.to_rfc3339()],
1439 )? > 0
1440 {
1441 conn.execute(
1442 "UPDATE skill_failure_patterns
1443 SET failure_count=failure_count+1,
1444 state=CASE WHEN failure_count+1>=?2
1445 THEN 'active' ELSE 'observed' END,
1446 cooldown_until=CASE WHEN failure_count+1>=?2
1447 THEN ?3 ELSE NULL END,
1448 updated_at=?4 WHERE pattern_id=?1",
1449 params![
1450 pattern_id,
1451 policy.failure_pattern_min_failures,
1452 cooldown_until,
1453 now.to_rfc3339()
1454 ],
1455 )?;
1456 }
1457 }
1458 conn.execute(
1459 "UPDATE skill_lifecycle
1460 SET previous_trusted_version=CASE WHEN state='trusted'
1461 THEN active_version ELSE previous_trusted_version END,
1462 state='degraded',
1463 state_reason='historical active failure pattern backfilled',
1464 updated_at=?1
1465 WHERE state NOT IN ('degraded','archived') AND EXISTS(
1466 SELECT 1 FROM skill_failure_patterns p
1467 WHERE p.skill_name=skill_lifecycle.skill_name
1468 AND p.skill_version=skill_lifecycle.active_version
1469 AND p.state='active'
1470 )",
1471 params![now.to_rfc3339()],
1472 )?;
1473 set_schema_version(conn, 39)?;
1474 version = 39;
1475 }
1476 if version < 40 {
1477 conn.execute_batch(
1478 "CREATE TABLE IF NOT EXISTS experience_comparisons (
1479 comparison_id TEXT PRIMARY KEY,
1480 chat_id INTEGER NOT NULL,
1481 skill_name TEXT NOT NULL,
1482 skill_version INTEGER NOT NULL,
1483 task_type TEXT NOT NULL,
1484 task_family TEXT NOT NULL,
1485 environment_fingerprint TEXT,
1486 success_run_id TEXT NOT NULL,
1487 failure_run_id TEXT NOT NULL,
1488 minimal_difference TEXT NOT NULL,
1489 counterexample TEXT NOT NULL,
1490 created_at TEXT NOT NULL,
1491 UNIQUE(chat_id, skill_name, skill_version, task_family,
1492 environment_fingerprint, success_run_id, failure_run_id),
1493 FOREIGN KEY(success_run_id) REFERENCES experience_runs(run_id),
1494 FOREIGN KEY(failure_run_id) REFERENCES experience_runs(run_id)
1495 );
1496 CREATE INDEX IF NOT EXISTS idx_experience_comparisons_skill
1497 ON experience_comparisons(skill_name, skill_version, created_at DESC);
1498
1499 CREATE TABLE IF NOT EXISTS learning_claims (
1500 claim_id TEXT PRIMARY KEY,
1501 comparison_id TEXT NOT NULL,
1502 skill_name TEXT NOT NULL,
1503 base_version INTEGER NOT NULL,
1504 claim_version INTEGER NOT NULL,
1505 statement TEXT NOT NULL,
1506 applicability_json TEXT NOT NULL,
1507 confidence REAL NOT NULL,
1508 evidence_json TEXT NOT NULL,
1509 counterexamples_json TEXT NOT NULL,
1510 status TEXT NOT NULL DEFAULT 'candidate',
1511 supersedes_claim_id TEXT,
1512 created_at TEXT NOT NULL,
1513 updated_at TEXT NOT NULL,
1514 UNIQUE(skill_name, claim_version),
1515 FOREIGN KEY(comparison_id) REFERENCES experience_comparisons(comparison_id),
1516 FOREIGN KEY(supersedes_claim_id) REFERENCES learning_claims(claim_id)
1517 );
1518 CREATE INDEX IF NOT EXISTS idx_learning_claims_skill_status
1519 ON learning_claims(skill_name, status, updated_at DESC);
1520
1521 CREATE TABLE IF NOT EXISTS skill_candidates (
1522 candidate_id TEXT PRIMARY KEY,
1523 claim_id TEXT NOT NULL UNIQUE,
1524 skill_name TEXT NOT NULL,
1525 base_version INTEGER NOT NULL,
1526 candidate_version INTEGER NOT NULL,
1527 content TEXT NOT NULL,
1528 content_hash TEXT NOT NULL,
1529 status TEXT NOT NULL DEFAULT 'candidate',
1530 created_at TEXT NOT NULL,
1531 updated_at TEXT NOT NULL,
1532 UNIQUE(skill_name, candidate_version),
1533 FOREIGN KEY(claim_id) REFERENCES learning_claims(claim_id)
1534 );
1535 CREATE INDEX IF NOT EXISTS idx_skill_candidates_status
1536 ON skill_candidates(status, updated_at DESC);
1537
1538 CREATE TABLE IF NOT EXISTS shadow_observations (
1539 candidate_id TEXT NOT NULL,
1540 pair_key TEXT NOT NULL,
1541 arm TEXT NOT NULL,
1542 run_id TEXT NOT NULL,
1543 verdict TEXT NOT NULL,
1544 cost_usd REAL NOT NULL DEFAULT 0,
1545 duration_ms INTEGER NOT NULL DEFAULT 0,
1546 evidence TEXT,
1547 created_at TEXT NOT NULL,
1548 PRIMARY KEY(candidate_id, pair_key, arm),
1549 FOREIGN KEY(candidate_id) REFERENCES skill_candidates(candidate_id),
1550 FOREIGN KEY(run_id) REFERENCES experience_runs(run_id)
1551 );
1552 CREATE INDEX IF NOT EXISTS idx_shadow_observations_candidate
1553 ON shadow_observations(candidate_id, pair_key, arm);
1554
1555 CREATE TABLE IF NOT EXISTS shadow_evaluations (
1556 evaluation_id TEXT PRIMARY KEY,
1557 candidate_id TEXT NOT NULL UNIQUE,
1558 sample_count INTEGER NOT NULL,
1559 baseline_passed INTEGER NOT NULL,
1560 candidate_passed INTEGER NOT NULL,
1561 baseline_utility_lower_bound REAL NOT NULL,
1562 candidate_utility_lower_bound REAL NOT NULL,
1563 baseline_cost_usd REAL NOT NULL,
1564 candidate_cost_usd REAL NOT NULL,
1565 baseline_duration_ms INTEGER NOT NULL,
1566 candidate_duration_ms INTEGER NOT NULL,
1567 regression_count INTEGER NOT NULL,
1568 verdict TEXT NOT NULL,
1569 reason TEXT NOT NULL,
1570 updated_at TEXT NOT NULL,
1571 FOREIGN KEY(candidate_id) REFERENCES skill_candidates(candidate_id)
1572 );
1573
1574 CREATE TABLE IF NOT EXISTS learning_journal_events (
1575 id INTEGER PRIMARY KEY AUTOINCREMENT,
1576 event_type TEXT NOT NULL,
1577 entity_type TEXT NOT NULL,
1578 entity_id TEXT NOT NULL,
1579 summary TEXT NOT NULL,
1580 evidence_json TEXT NOT NULL DEFAULT '{}',
1581 undo_action TEXT,
1582 created_at TEXT NOT NULL
1583 );
1584 CREATE INDEX IF NOT EXISTS idx_learning_journal_created
1585 ON learning_journal_events(created_at DESC);",
1586 )?;
1587 for (column, definition) in [
1588 ("shadow_min_samples", "INTEGER NOT NULL DEFAULT 3"),
1589 (
1590 "shadow_promote_utility_margin",
1591 "REAL NOT NULL DEFAULT 0.05",
1592 ),
1593 ("shadow_max_cost_ratio", "REAL NOT NULL DEFAULT 1.2"),
1594 ("shadow_max_regressions", "INTEGER NOT NULL DEFAULT 0"),
1595 ] {
1596 if !table_has_column(conn, "skill_governance_policy", column)? {
1597 conn.execute(
1598 &format!(
1599 "ALTER TABLE skill_governance_policy ADD COLUMN {column} {definition}"
1600 ),
1601 [],
1602 )?;
1603 }
1604 }
1605 let tx = conn.unchecked_transaction()?;
1606 let run_ids = {
1607 let mut stmt = tx.prepare(
1608 "SELECT DISTINCT run_id FROM skill_outcomes
1609 WHERE attribution_confidence>=0.999
1610 AND verifier_type IN (
1611 'deterministic','environmental','human','rule_based'
1612 )
1613 ORDER BY created_at ASC",
1614 )?;
1615 let rows = stmt
1616 .query_map([], |row| row.get::<_, String>(0))?
1617 .collect::<Result<Vec<_>, _>>()?;
1618 rows
1619 };
1620 for run_id in run_ids {
1621 refresh_comparative_reflections_for_run(&tx, &run_id)?;
1622 }
1623 tx.commit()?;
1624 set_schema_version(conn, 40)?;
1625 version = 40;
1626 }
1627 if version < 41 {
1628 ensure_scheduled_tasks_schema(conn)?;
1631 set_schema_version(conn, 41)?;
1632 version = 41;
1633 }
1634 if version < 42 {
1635 conn.execute_batch(
1636 "CREATE TABLE IF NOT EXISTS learning_tracks (
1637 track_id TEXT PRIMARY KEY,
1638 chat_id INTEGER NOT NULL,
1639 name TEXT NOT NULL,
1640 objective TEXT NOT NULL,
1641 directions_json TEXT NOT NULL DEFAULT '[]',
1642 allowed_sources_json TEXT NOT NULL DEFAULT '[]',
1643 schedule TEXT NOT NULL,
1644 timezone TEXT NOT NULL,
1645 token_budget INTEGER NOT NULL DEFAULT 80000,
1646 max_sources INTEGER NOT NULL DEFAULT 20,
1647 promotion_mode TEXT NOT NULL DEFAULT 'propose',
1648 status TEXT NOT NULL DEFAULT 'active',
1649 next_run TEXT NOT NULL,
1650 last_run TEXT,
1651 created_at TEXT NOT NULL,
1652 updated_at TEXT NOT NULL,
1653 UNIQUE(chat_id, name)
1654 );
1655 CREATE INDEX IF NOT EXISTS idx_learning_tracks_due
1656 ON learning_tracks(status, next_run);
1657
1658 CREATE TABLE IF NOT EXISTS learning_epochs (
1659 epoch_id TEXT PRIMARY KEY,
1660 track_id TEXT NOT NULL,
1661 experience_run_id TEXT,
1662 status TEXT NOT NULL DEFAULT 'running',
1663 report TEXT,
1664 candidate_id TEXT,
1665 error TEXT,
1666 started_at TEXT NOT NULL,
1667 finished_at TEXT,
1668 FOREIGN KEY(track_id) REFERENCES learning_tracks(track_id)
1669 );
1670 CREATE INDEX IF NOT EXISTS idx_learning_epochs_track
1671 ON learning_epochs(track_id, started_at DESC);
1672
1673 CREATE TABLE IF NOT EXISTS learning_track_candidates (
1674 candidate_id TEXT PRIMARY KEY,
1675 epoch_id TEXT NOT NULL UNIQUE,
1676 skill_name TEXT NOT NULL,
1677 description TEXT NOT NULL,
1678 instructions TEXT NOT NULL,
1679 sources_json TEXT NOT NULL DEFAULT '[]',
1680 tests_json TEXT NOT NULL DEFAULT '[]',
1681 risk TEXT NOT NULL DEFAULT 'low',
1682 content_hash TEXT NOT NULL,
1683 status TEXT NOT NULL DEFAULT 'pending',
1684 created_at TEXT NOT NULL,
1685 updated_at TEXT NOT NULL,
1686 FOREIGN KEY(epoch_id) REFERENCES learning_epochs(epoch_id)
1687 );
1688 CREATE INDEX IF NOT EXISTS idx_learning_track_candidates_status
1689 ON learning_track_candidates(status, updated_at DESC);",
1690 )?;
1691 set_schema_version(conn, 42)?;
1692 version = 42;
1693 }
1694 if version < 43 {
1695 conn.execute_batch(
1696 "CREATE TABLE IF NOT EXISTS learning_candidate_evaluations (
1697 evaluation_id TEXT PRIMARY KEY,
1698 candidate_id TEXT NOT NULL UNIQUE,
1699 status TEXT NOT NULL DEFAULT 'running',
1700 sample_count INTEGER NOT NULL DEFAULT 0,
1701 baseline_passed INTEGER NOT NULL DEFAULT 0,
1702 candidate_passed INTEGER NOT NULL DEFAULT 0,
1703 regression_count INTEGER NOT NULL DEFAULT 0,
1704 baseline_tokens INTEGER NOT NULL DEFAULT 0,
1705 candidate_tokens INTEGER NOT NULL DEFAULT 0,
1706 baseline_duration_ms INTEGER NOT NULL DEFAULT 0,
1707 candidate_duration_ms INTEGER NOT NULL DEFAULT 0,
1708 reason TEXT,
1709 started_at TEXT NOT NULL,
1710 finished_at TEXT,
1711 FOREIGN KEY(candidate_id) REFERENCES learning_track_candidates(candidate_id)
1712 );
1713 CREATE INDEX IF NOT EXISTS idx_learning_candidate_evaluations_status
1714 ON learning_candidate_evaluations(status, started_at DESC);
1715
1716 CREATE TABLE IF NOT EXISTS learning_candidate_trials (
1717 trial_id TEXT PRIMARY KEY,
1718 evaluation_id TEXT NOT NULL,
1719 test_name TEXT NOT NULL,
1720 baseline_passed INTEGER NOT NULL,
1721 candidate_passed INTEGER NOT NULL,
1722 baseline_tokens INTEGER NOT NULL DEFAULT 0,
1723 candidate_tokens INTEGER NOT NULL DEFAULT 0,
1724 baseline_duration_ms INTEGER NOT NULL DEFAULT 0,
1725 candidate_duration_ms INTEGER NOT NULL DEFAULT 0,
1726 evidence_json TEXT NOT NULL DEFAULT '{}',
1727 created_at TEXT NOT NULL,
1728 FOREIGN KEY(evaluation_id) REFERENCES learning_candidate_evaluations(evaluation_id)
1729 );
1730 CREATE INDEX IF NOT EXISTS idx_learning_candidate_trials_evaluation
1731 ON learning_candidate_trials(evaluation_id, created_at);",
1732 )?;
1733 set_schema_version(conn, 43)?;
1734 version = 43;
1735 }
1736 if version != SCHEMA_VERSION_CURRENT {
1737 set_schema_version(conn, SCHEMA_VERSION_CURRENT)?;
1738 }
1739 Ok(())
1740}
1741
1742#[cfg(test)]
1743mod tests {
1744 use super::*;
1745 use crate::internal::storage::db::test_support::*;
1746
1747 #[test]
1748 fn test_schema_version_is_tracked() {
1749 let (db, dir) = test_db();
1750 let conn = db.lock_conn();
1751 let version: String = conn
1752 .query_row(
1753 "SELECT value FROM db_meta WHERE key = 'schema_version'",
1754 [],
1755 |row| row.get(0),
1756 )
1757 .unwrap();
1758 assert_eq!(version, SCHEMA_VERSION_CURRENT.to_string());
1759 drop(conn);
1760 cleanup(&dir);
1761 }
1762
1763 #[test]
1764 fn test_legacy_schema_is_upgraded_to_current_version() {
1765 let dir =
1766 std::env::temp_dir().join(format!("microclaw_legacy_upgrade_{}", uuid::Uuid::new_v4()));
1767 std::fs::create_dir_all(&dir).unwrap();
1768 let db_path = dir.join("microclaw.db");
1769 let conn = Connection::open(&db_path).unwrap();
1770 conn.execute_batch(
1771 "PRAGMA journal_mode=WAL;
1772 CREATE TABLE chats (
1773 chat_id INTEGER PRIMARY KEY,
1774 chat_title TEXT,
1775 chat_type TEXT NOT NULL DEFAULT 'private',
1776 last_message_time TEXT NOT NULL
1777 );
1778 CREATE TABLE messages (
1779 id TEXT NOT NULL,
1780 chat_id INTEGER NOT NULL,
1781 sender_name TEXT NOT NULL,
1782 content TEXT NOT NULL,
1783 is_from_bot INTEGER NOT NULL DEFAULT 0,
1784 timestamp TEXT NOT NULL,
1785 PRIMARY KEY (id, chat_id)
1786 );
1787 CREATE TABLE scheduled_tasks (
1788 id INTEGER PRIMARY KEY AUTOINCREMENT,
1789 chat_id INTEGER NOT NULL,
1790 prompt TEXT NOT NULL,
1791 schedule_type TEXT NOT NULL DEFAULT 'cron',
1792 schedule_value TEXT NOT NULL,
1793 next_run TEXT NOT NULL,
1794 last_run TEXT,
1795 status TEXT NOT NULL DEFAULT 'active',
1796 created_at TEXT NOT NULL
1797 );
1798 CREATE TABLE sessions (
1799 chat_id INTEGER PRIMARY KEY,
1800 messages_json TEXT NOT NULL,
1801 updated_at TEXT NOT NULL
1802 );
1803 CREATE TABLE memories (
1804 id INTEGER PRIMARY KEY AUTOINCREMENT,
1805 chat_id INTEGER,
1806 content TEXT NOT NULL,
1807 category TEXT NOT NULL,
1808 created_at TEXT NOT NULL,
1809 updated_at TEXT NOT NULL
1810 );",
1811 )
1812 .unwrap();
1813 drop(conn);
1814
1815 let db = Database::new(dir.to_str().unwrap()).unwrap();
1816 let conn = db.lock_conn();
1817 let version: String = conn
1818 .query_row(
1819 "SELECT value FROM db_meta WHERE key = 'schema_version'",
1820 [],
1821 |row| row.get(0),
1822 )
1823 .unwrap();
1824 assert_eq!(version, SCHEMA_VERSION_CURRENT.to_string());
1825
1826 let has_confidence = table_has_column(&conn, "memories", "confidence").unwrap();
1827 let has_source = table_has_column(&conn, "memories", "source").unwrap();
1828 let has_last_seen = table_has_column(&conn, "memories", "last_seen_at").unwrap();
1829 let has_archived = table_has_column(&conn, "memories", "is_archived").unwrap();
1830 assert!(has_confidence && has_source && has_last_seen && has_archived);
1831 assert!(table_has_column(&conn, "sessions", "parent_session_key").unwrap());
1832 assert!(table_has_column(&conn, "sessions", "fork_point").unwrap());
1833 assert!(table_has_column(&conn, "sessions", "label").unwrap());
1834 assert!(table_has_column(&conn, "sessions", "thinking_level").unwrap());
1835 assert!(table_has_column(&conn, "sessions", "verbose_level").unwrap());
1836 assert!(table_has_column(&conn, "sessions", "reasoning_level").unwrap());
1837 assert!(table_has_column(&conn, "scheduled_tasks", "timezone").unwrap());
1838
1839 let session_parent_index_exists: i64 = conn
1840 .query_row(
1841 "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_sessions_parent_session_key'",
1842 [],
1843 |row| row.get(0),
1844 )
1845 .unwrap();
1846 assert_eq!(session_parent_index_exists, 1);
1847
1848 let supersede_table_exists: i64 = conn
1849 .query_row(
1850 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memory_supersede_edges'",
1851 [],
1852 |row| row.get(0),
1853 )
1854 .unwrap();
1855 assert_eq!(supersede_table_exists, 1);
1856 let dlq_table_exists: i64 = conn
1857 .query_row(
1858 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='scheduled_task_dlq'",
1859 [],
1860 |row| row.get(0),
1861 )
1862 .unwrap();
1863 assert_eq!(dlq_table_exists, 1);
1864 drop(conn);
1865 cleanup(&dir);
1866 }
1867
1868 #[test]
1869 fn test_migration_matrix_upgrades_multiple_legacy_versions() {
1870 fn seed_legacy_db(dir: &std::path::Path, version: i64) {
1871 let db_path = dir.join("microclaw.db");
1872 let conn = Connection::open(&db_path).unwrap();
1873 conn.execute_batch(
1874 "PRAGMA journal_mode=WAL;
1875 CREATE TABLE chats (
1876 chat_id INTEGER PRIMARY KEY,
1877 chat_title TEXT,
1878 chat_type TEXT NOT NULL DEFAULT 'private',
1879 last_message_time TEXT NOT NULL
1880 );
1881 CREATE TABLE messages (
1882 id TEXT NOT NULL,
1883 chat_id INTEGER NOT NULL,
1884 sender_name TEXT NOT NULL,
1885 content TEXT NOT NULL,
1886 is_from_bot INTEGER NOT NULL DEFAULT 0,
1887 timestamp TEXT NOT NULL,
1888 PRIMARY KEY (id, chat_id)
1889 );
1890 CREATE TABLE scheduled_tasks (
1891 id INTEGER PRIMARY KEY AUTOINCREMENT,
1892 chat_id INTEGER NOT NULL,
1893 prompt TEXT NOT NULL,
1894 schedule_type TEXT NOT NULL DEFAULT 'cron',
1895 schedule_value TEXT NOT NULL,
1896 next_run TEXT NOT NULL,
1897 last_run TEXT,
1898 status TEXT NOT NULL DEFAULT 'active',
1899 created_at TEXT NOT NULL
1900 );
1901 CREATE TABLE sessions (
1902 chat_id INTEGER PRIMARY KEY,
1903 messages_json TEXT NOT NULL,
1904 updated_at TEXT NOT NULL
1905 );
1906 CREATE TABLE memories (
1907 id INTEGER PRIMARY KEY AUTOINCREMENT,
1908 chat_id INTEGER,
1909 content TEXT NOT NULL,
1910 category TEXT NOT NULL,
1911 created_at TEXT NOT NULL,
1912 updated_at TEXT NOT NULL
1913 );
1914 CREATE TABLE IF NOT EXISTS db_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
1915 )
1916 .unwrap();
1917 conn.execute(
1918 "INSERT OR REPLACE INTO db_meta(key, value) VALUES('schema_version', ?1)",
1919 params![version.to_string()],
1920 )
1921 .unwrap();
1922
1923 if version >= 5 {
1924 conn.execute_batch(
1925 "CREATE TABLE IF NOT EXISTS api_keys (
1926 id INTEGER PRIMARY KEY AUTOINCREMENT,
1927 label TEXT NOT NULL,
1928 key_hash TEXT NOT NULL UNIQUE,
1929 prefix TEXT NOT NULL,
1930 created_at TEXT NOT NULL,
1931 revoked_at TEXT,
1932 last_used_at TEXT
1933 );",
1934 )
1935 .unwrap();
1936 }
1937 if version >= 7 {
1938 conn.execute_batch(
1939 "CREATE TABLE IF NOT EXISTS metrics_history (
1940 timestamp_ms INTEGER PRIMARY KEY,
1941 llm_completions INTEGER NOT NULL DEFAULT 0,
1942 llm_input_tokens INTEGER NOT NULL DEFAULT 0,
1943 llm_output_tokens INTEGER NOT NULL DEFAULT 0,
1944 http_requests INTEGER NOT NULL DEFAULT 0,
1945 tool_executions INTEGER NOT NULL DEFAULT 0,
1946 mcp_calls INTEGER NOT NULL DEFAULT 0,
1947 active_sessions INTEGER NOT NULL DEFAULT 0
1948 );",
1949 )
1950 .unwrap();
1951 }
1952 if version >= 8 {
1953 conn.execute_batch(
1954 "ALTER TABLE api_keys ADD COLUMN expires_at TEXT;
1955 ALTER TABLE api_keys ADD COLUMN rotated_from_key_id INTEGER;",
1956 )
1957 .unwrap();
1958 }
1959 drop(conn);
1960 }
1961
1962 for version in [1_i64, 5_i64, 7_i64, 8_i64] {
1963 let dir = std::env::temp_dir().join(format!(
1964 "microclaw_migration_matrix_{}_{}",
1965 version,
1966 uuid::Uuid::new_v4()
1967 ));
1968 std::fs::create_dir_all(&dir).unwrap();
1969 seed_legacy_db(&dir, version);
1970
1971 let db = Database::new(dir.to_str().unwrap()).unwrap();
1972 let conn = db.lock_conn();
1973 let actual: String = conn
1974 .query_row(
1975 "SELECT value FROM db_meta WHERE key = 'schema_version'",
1976 [],
1977 |row| row.get(0),
1978 )
1979 .unwrap();
1980 assert_eq!(
1981 actual,
1982 SCHEMA_VERSION_CURRENT.to_string(),
1983 "legacy schema_version {} should migrate to current",
1984 version
1985 );
1986 assert!(table_has_column(&conn, "sessions", "parent_session_key").unwrap());
1987 assert!(table_has_column(&conn, "sessions", "fork_point").unwrap());
1988 assert!(table_has_column(&conn, "sessions", "label").unwrap());
1989 assert!(table_has_column(&conn, "sessions", "thinking_level").unwrap());
1990 assert!(table_has_column(&conn, "sessions", "verbose_level").unwrap());
1991 assert!(table_has_column(&conn, "sessions", "reasoning_level").unwrap());
1992 assert!(table_has_column(&conn, "scheduled_tasks", "timezone").unwrap());
1993 assert!(table_has_column(&conn, "api_keys", "expires_at").unwrap());
1994 assert!(table_has_column(&conn, "api_keys", "rotated_from_key_id").unwrap());
1995 assert!(
1996 table_has_column(&conn, "metrics_history", "mcp_rate_limited_rejections").unwrap()
1997 );
1998 assert!(table_has_column(&conn, "metrics_history", "mcp_bulkhead_rejections").unwrap());
1999 assert!(
2000 table_has_column(&conn, "metrics_history", "mcp_circuit_open_rejections").unwrap()
2001 );
2002 drop(conn);
2003 cleanup(&dir);
2004 }
2005 }
2006
2007 #[test]
2008 fn test_current_version_repairs_incomplete_scheduled_tasks_schema() {
2009 let dir = std::env::temp_dir().join(format!(
2010 "microclaw_incomplete_scheduled_tasks_{}",
2011 uuid::Uuid::new_v4()
2012 ));
2013 std::fs::create_dir_all(&dir).unwrap();
2014 let db_path = dir.join("microclaw.db");
2015 let conn = Connection::open(&db_path).unwrap();
2016 conn.execute_batch(
2017 "CREATE TABLE scheduled_tasks (
2018 id INTEGER PRIMARY KEY AUTOINCREMENT,
2019 chat_id INTEGER NOT NULL,
2020 prompt TEXT NOT NULL,
2021 schedule_type TEXT NOT NULL DEFAULT 'cron',
2022 schedule_value TEXT NOT NULL,
2023 next_run TEXT NOT NULL,
2024 last_run TEXT,
2025 status TEXT NOT NULL DEFAULT 'active',
2026 created_at TEXT NOT NULL
2027 );
2028 CREATE TABLE db_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
2029 INSERT INTO db_meta(key, value) VALUES('schema_version', '40');",
2030 )
2031 .unwrap();
2032 drop(conn);
2033
2034 let db = Database::new(dir.to_str().unwrap()).unwrap();
2035 let conn = db.lock_conn();
2036 for column in [
2037 "exit_criteria",
2038 "run_count",
2039 "max_runs",
2040 "not_after",
2041 "timezone",
2042 ] {
2043 assert!(
2044 table_has_column(&conn, "scheduled_tasks", column).unwrap(),
2045 "missing repaired scheduled_tasks.{column}"
2046 );
2047 }
2048 let version = get_schema_version(&conn).unwrap();
2049 assert_eq!(version, SCHEMA_VERSION_CURRENT);
2050 drop(conn);
2051
2052 assert!(db.get_tasks_for_chat(1).unwrap().is_empty());
2054 cleanup(&dir);
2055 }
2056}