xiaoxixi d9ad58b84b feat(scheduler): unify scheduled task execution and delivery
Replace the dual task/monitor model, NO_REPLY string protocol, and Agent
self-delivery with a single Scheduled Run path: claim-time JobRun snapshots,
isolated Root/named Agent execution, exactly-once complete_scheduled_run
termination, and Scheduler-owned policy delivery through a persistent outbox.

- SQLite v11: drop job_kind/model/delete_after_run, add job_runs with
  status/outcome joint constraints and delivery lease columns; one-shot
  BEGIN IMMEDIATE migration with atomic rollback.
- Non-blocking JoinSet event loop with bounded run/delivery concurrency;
  terminal commit before any channel I/O; recover unfinished runs as unknown.
- ExecutionOrigin::Scheduled propagates to descendants, completion sink is
  top-level only, background delegation downgrades to foreground.
- Typed delivery receipts, fixed target_session_id, idempotent
  scheduled:<job_run_id> history insert.
- New cron_runs read-only tool; cron_add/update drop kind/model; WebUI and
  Health consume the same JobRun projection.
- Bump version to 1.22.0.
2026-08-21 14:59:02 +08:00

2783 lines
100 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

pub mod agent_inbox;
pub mod agent_run;
pub mod context_checkpoint;
pub mod error;
pub mod memory;
pub mod message;
pub mod scheduler;
pub mod session;
pub mod usage;
pub use context_checkpoint::{ContextCheckpoint, ContextCheckpointState, NewContextCheckpoint};
pub use error::StorageError;
pub use scheduler::{
ClaimedScheduledRun, DeliveryPolicy, JobRun, ScheduledDeliveryStatus, ScheduledJob,
ScheduledJobUpdate, ScheduledOutcomeKind, ScheduledRunCompletion, ScheduledRunStatus,
};
pub use usage::{SessionUsageTotals, TurnUsageRecord};
use sqlx::sqlite::{
SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteRow, SqliteSynchronous,
};
use sqlx::{Pool, Row, Sqlite};
use std::path::Path;
use tokio::time::{Duration, sleep};
const SCHEMA_VERSION: i64 = 11;
const INSERT_MESSAGE_SQL: &str = r#"
INSERT INTO messages (
id, session_id, seq, role, content, reasoning_content, provider_state,
turn_id, iteration, completion_status, client_visibility, turn_origin,
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#;
const INSERT_MESSAGE_IF_ABSENT_SQL: &str = r#"
INSERT INTO messages (
id, session_id, seq, role, content, reasoning_content, provider_state,
turn_id, iteration, completion_status, client_visibility, turn_origin,
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
"#;
pub(crate) fn insert_message_query<'a>(
session_id: &'a str,
msg: &'a crate::storage::message::MessageMeta,
) -> sqlx::query::Query<'a, Sqlite, sqlx::sqlite::SqliteArguments> {
sqlx::query(INSERT_MESSAGE_SQL)
.bind(&msg.id)
.bind(session_id)
.bind(msg.seq)
.bind(&msg.role)
.bind(&msg.content)
.bind(&msg.reasoning_content)
.bind(&msg.provider_state)
.bind(&msg.turn_id)
.bind(msg.iteration)
.bind(msg.completion_status.as_str())
.bind(msg.client_visibility.as_str())
.bind(msg.turn_origin.as_str())
.bind(&msg.media_refs)
.bind(&msg.tool_call_id)
.bind(&msg.tool_name)
.bind(&msg.tool_calls)
.bind(&msg.source)
.bind(msg.created_at)
}
pub(crate) fn insert_message_if_absent_query<'a>(
session_id: &'a str,
msg: &'a crate::storage::message::MessageMeta,
) -> sqlx::query::Query<'a, Sqlite, sqlx::sqlite::SqliteArguments> {
sqlx::query(INSERT_MESSAGE_IF_ABSENT_SQL)
.bind(&msg.id)
.bind(session_id)
.bind(msg.seq)
.bind(&msg.role)
.bind(&msg.content)
.bind(&msg.reasoning_content)
.bind(&msg.provider_state)
.bind(&msg.turn_id)
.bind(msg.iteration)
.bind(msg.completion_status.as_str())
.bind(msg.client_visibility.as_str())
.bind(msg.turn_origin.as_str())
.bind(&msg.media_refs)
.bind(&msg.tool_call_id)
.bind(&msg.tool_name)
.bind(&msg.tool_calls)
.bind(&msg.source)
.bind(msg.created_at)
}
fn message_meta_from_row(row: SqliteRow) -> crate::storage::message::MessageMeta {
let completion_status: String = row.get("completion_status");
crate::storage::message::MessageMeta {
id: row.get("id"),
session_id: row.get("session_id"),
seq: row.get("seq"),
role: row.get("role"),
content: row.get("content"),
reasoning_content: row.get("reasoning_content"),
provider_state: row.get("provider_state"),
turn_id: row.get("turn_id"),
iteration: row.get("iteration"),
completion_status: crate::bus::CompletionStatus::from_storage(&completion_status),
client_visibility: parse_visibility(row.get("client_visibility")),
turn_origin: parse_turn_origin(row.get("turn_origin")),
media_refs: row.get("media_refs"),
tool_call_id: row.get("tool_call_id"),
tool_name: row.get("tool_name"),
tool_calls: row.get("tool_calls"),
source: row.get("source"),
created_at: row.get("created_at"),
}
}
fn parse_visibility(value: String) -> crate::bus::ClientVisibility {
if value == "hidden" {
crate::bus::ClientVisibility::Hidden
} else {
crate::bus::ClientVisibility::Visible
}
}
fn parse_turn_origin(value: String) -> crate::bus::TurnOrigin {
match value.as_str() {
"agent_continuation" => crate::bus::TurnOrigin::AgentContinuation,
"scheduled" => crate::bus::TurnOrigin::Scheduled,
_ => crate::bus::TurnOrigin::User,
}
}
pub struct Storage {
pub(crate) pool: Pool<Sqlite>,
}
impl Storage {
/// 打开或创建数据库
pub async fn new(db_path: &Path) -> Result<Self, StorageError> {
let options = SqliteConnectOptions::new()
.filename(db_path)
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.busy_timeout(Duration::from_secs(5))
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(options)
.await?;
let storage = Self { pool };
storage.init_schema().await?;
Ok(storage)
}
/// 初始化数据库 schema
async fn init_schema(&self) -> Result<(), StorageError> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
channel TEXT NOT NULL,
chat_id TEXT NOT NULL,
dialog_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '新对话',
created_at INTEGER NOT NULL,
last_active_at INTEGER NOT NULL,
message_count INTEGER DEFAULT 0,
routing_info TEXT,
archived_at INTEGER,
deleted_at INTEGER,
last_consolidated_at INTEGER,
last_compressed_message_at INTEGER,
active_context_checkpoint_id TEXT,
context_generation INTEGER NOT NULL DEFAULT 0,
delivery_context TEXT,
delivery_context_updated_at INTEGER,
UNIQUE(channel, chat_id, dialog_id)
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS context_checkpoints (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
generation INTEGER NOT NULL,
parent_checkpoint_id TEXT,
summary TEXT NOT NULL,
first_retained_seq INTEGER NOT NULL,
source_max_seq INTEGER NOT NULL,
trigger_reason TEXT NOT NULL,
provider_kind TEXT NOT NULL,
model TEXT NOT NULL,
tokens_before INTEGER NOT NULL,
tokens_after INTEGER NOT NULL,
degraded INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE,
UNIQUE(session_id, generation)
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_context_checkpoints_session_created ON context_checkpoints(session_id, created_at DESC)",
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_sessions_chat
ON sessions(channel, chat_id, deleted_at)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
seq INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
media_refs TEXT,
tool_call_id TEXT,
tool_name TEXT,
tool_calls TEXT,
source TEXT,
reasoning_content TEXT,
provider_state TEXT,
turn_id TEXT,
iteration INTEGER,
completion_status TEXT NOT NULL DEFAULT 'completed',
client_visibility TEXT NOT NULL DEFAULT 'visible',
turn_origin TEXT NOT NULL DEFAULT 'user',
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_messages_session_seq
ON messages(session_id, seq)
"#,
)
.execute(&self.pool)
.await?;
// Session-scoped task plans. A session may have at most one active plan,
// while independent items can be executed concurrently.
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS task_plans (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
objective TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
closed_at INTEGER
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE UNIQUE INDEX IF NOT EXISTS idx_task_plans_active_session
ON task_plans(session_id) WHERE status = 'active'
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS task_items (
id TEXT NOT NULL,
plan_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
executor_kind TEXT,
execution_id TEXT,
result_summary TEXT,
error TEXT,
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY(plan_id, id),
FOREIGN KEY(plan_id) REFERENCES task_plans(id) ON DELETE CASCADE,
UNIQUE(plan_id, ordinal)
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_task_items_plan ON task_items(plan_id, ordinal)",
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
content TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'knowledge',
importance REAL NOT NULL DEFAULT 0.5,
session_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"#,
)
.execute(&self.pool)
.await?;
let memory_fts_exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'memory_fts')",
)
.fetch_one(&self.pool)
.await?;
// FTS5 virtual table for full-text search on memories
sqlx::query(
r#"
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
key,
content,
content=memories,
content_rowid=rowid
)
"#,
)
.execute(&self.pool)
.await?;
// Triggers to keep FTS5 index in sync with memories table
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memory_fts(rowid, key, content) VALUES (new.rowid, new.key, new.content);
END
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memory_fts(memory_fts, rowid, key, content)
VALUES ('delete', old.rowid, old.key, old.content);
END
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memory_fts(memory_fts, rowid, key, content)
VALUES ('delete', old.rowid, old.key, old.content);
INSERT INTO memory_fts(rowid, key, content)
VALUES (new.rowid, new.key, new.content);
END
"#,
)
.execute(&self.pool)
.await?;
// Only a newly-created index needs a backfill. Triggers keep an
// existing index current, so rebuilding it on every startup is wasted
// work proportional to the total memory corpus.
if !memory_fts_exists {
sqlx::query("INSERT INTO memory_fts(memory_fts) VALUES ('rebuild')")
.execute(&self.pool)
.await?;
}
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS llm_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at INTEGER NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
request_body TEXT NOT NULL,
response_body TEXT,
error TEXT,
duration_ms INTEGER
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS session_turn_usage (
turn_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
total_tokens INTEGER NOT NULL,
cached_input_tokens INTEGER,
request_count INTEGER NOT NULL,
last_prompt_tokens INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_session_turn_usage_session_created ON session_turn_usage(session_id, created_at)",
)
.execute(&self.pool)
.await?;
self.migrate_schema().await?;
Ok(())
}
/// Apply ordered, atomic migrations. Existing installations predate
/// `user_version`, so each step also checks the actual table shape.
async fn migrate_schema(&self) -> Result<(), StorageError> {
let current: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(&self.pool)
.await?;
if current > SCHEMA_VERSION {
return Err(StorageError::Migration(format!(
"database schema version {current} is newer than supported version {SCHEMA_VERSION}"
)));
}
if current == SCHEMA_VERSION {
return Ok(());
}
// Acquire the migration write lock before inspecting or modifying any
// legacy shape. This keeps the v11 rebuild on one connection and makes
// a concurrently running older Gateway fail startup cleanly instead of
// partially racing the schema migration.
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
// The legacy drops below are a pre-v8 rebuild concern: the batch
// "group" concept was removed in v8 and the old `background_tasks`
// table in v7. Gate them on `current < 8` so a v8 -> v9 upgrade only
// adds the new transcript table and preserves existing run history.
if current < 8 {
// Legacy table removed in schema v7; drop it so old databases do
// not keep dead rows around.
sqlx::query("DROP TABLE IF EXISTS background_tasks")
.execute(&mut *tx)
.await?;
// Schema v8 removes the batch "group" concept entirely: the
// `agent_run_groups` table is gone, and the run/inbox tables are
// rebuilt without their `group_id`/`scope_kind`/`scope_id` columns.
// Drop the transcript table before runs and the remaining tables in
// dependency order (messages -> inbox -> runs -> groups) so
// foreign-key enforcement never blocks the implicit row delete.
sqlx::query("DROP TABLE IF EXISTS agent_run_messages")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS agent_inbox_events")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS agent_runs")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE IF EXISTS agent_run_groups")
.execute(&mut *tx)
.await?;
}
let legacy_scheduler_exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'scheduled_jobs'",
)
.fetch_one(&mut *tx)
.await?;
let legacy_scheduler_exists = legacy_scheduler_exists == 1;
if legacy_scheduler_exists {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS job_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL REFERENCES scheduled_jobs(id) ON DELETE CASCADE,
started_at INTEGER NOT NULL,
finished_at INTEGER NOT NULL,
status TEXT NOT NULL,
output TEXT,
error TEXT,
duration_ms INTEGER NOT NULL,
result_kind TEXT,
delivery_status TEXT,
delivery_error TEXT
)
"#,
)
.execute(&mut *tx)
.await?;
}
for (table, column, definition) in [
("messages", "source", "source TEXT"),
("messages", "reasoning_content", "reasoning_content TEXT"),
("messages", "provider_state", "provider_state TEXT"),
("messages", "turn_id", "turn_id TEXT"),
("messages", "iteration", "iteration INTEGER"),
(
"messages",
"completion_status",
"completion_status TEXT NOT NULL DEFAULT 'completed'",
),
(
"messages",
"client_visibility",
"client_visibility TEXT NOT NULL DEFAULT 'visible'",
),
(
"messages",
"turn_origin",
"turn_origin TEXT NOT NULL DEFAULT 'user'",
),
("sessions", "archived_at", "archived_at INTEGER"),
(
"sessions",
"last_consolidated_at",
"last_consolidated_at INTEGER",
),
(
"sessions",
"last_compressed_message_at",
"last_compressed_message_at INTEGER",
),
(
"sessions",
"active_context_checkpoint_id",
"active_context_checkpoint_id TEXT",
),
(
"sessions",
"context_generation",
"context_generation INTEGER NOT NULL DEFAULT 0",
),
("sessions", "delivery_context", "delivery_context TEXT"),
(
"sessions",
"delivery_context_updated_at",
"delivery_context_updated_at INTEGER",
),
("scheduled_jobs", "locked_at", "locked_at INTEGER"),
("scheduled_jobs", "lock_owner", "lock_owner TEXT"),
("scheduled_jobs", "lease_until", "lease_until INTEGER"),
(
"scheduled_jobs",
"job_kind",
"job_kind TEXT NOT NULL DEFAULT 'task'",
),
(
"scheduled_jobs",
"delivery_policy",
"delivery_policy TEXT NOT NULL DEFAULT 'direct'",
),
("job_runs", "result_kind", "result_kind TEXT"),
("job_runs", "delivery_status", "delivery_status TEXT"),
("job_runs", "delivery_error", "delivery_error TEXT"),
] {
let pragma = format!("PRAGMA table_info({table})");
// `table` comes exclusively from the fixed migration list above.
let columns = sqlx::query(sqlx::AssertSqlSafe(pragma))
.fetch_all(&mut *tx)
.await?;
if !columns.is_empty()
&& !columns
.iter()
.any(|row| row.get::<String, _>("name") == column)
{
let alter = format!("ALTER TABLE {table} ADD COLUMN {definition}");
// All identifiers and definitions come from the fixed migration list above.
sqlx::query(sqlx::AssertSqlSafe(alter))
.execute(&mut *tx)
.await?;
}
}
let duplicate: Option<(String, i64, i64)> = sqlx::query_as(
r#"
SELECT session_id, seq, COUNT(*)
FROM messages
GROUP BY session_id, seq
HAVING COUNT(*) > 1
LIMIT 1
"#,
)
.fetch_optional(&mut *tx)
.await?;
if let Some((session_id, seq, count)) = duplicate {
return Err(StorageError::Migration(format!(
"cannot enforce unique message sequence: session {session_id} has {count} rows at seq {seq}"
)));
}
sqlx::query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_session_seq_unique ON messages(session_id, seq)",
)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS session_turn_usage (
turn_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
total_tokens INTEGER NOT NULL,
cached_input_tokens INTEGER,
request_count INTEGER NOT NULL,
last_prompt_tokens INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)
"#,
)
.execute(&mut *tx)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_session_turn_usage_session_created ON session_turn_usage(session_id, created_at)",
)
.execute(&mut *tx)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_messages_session_visibility_seq ON messages(session_id, client_visibility, seq)",
)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS context_checkpoints (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
generation INTEGER NOT NULL,
parent_checkpoint_id TEXT,
summary TEXT NOT NULL,
first_retained_seq INTEGER NOT NULL,
source_max_seq INTEGER NOT NULL,
trigger_reason TEXT NOT NULL,
provider_kind TEXT NOT NULL,
model TEXT NOT NULL,
tokens_before INTEGER NOT NULL,
tokens_after INTEGER NOT NULL,
degraded INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE,
UNIQUE(session_id, generation)
)
"#,
)
.execute(&mut *tx)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_context_checkpoints_session_created ON context_checkpoints(session_id, created_at DESC)",
)
.execute(&mut *tx)
.await?;
for statement in agent_run::AGENT_SCHEMA_STATEMENTS {
sqlx::query(*statement).execute(&mut *tx).await?;
}
scheduler::migrate_scheduler_v11(&mut tx, legacy_scheduler_exists).await?;
sqlx::query(sqlx::AssertSqlSafe(format!(
"PRAGMA user_version = {SCHEMA_VERSION}"
)))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn append_llm_call(
&self,
provider: &str,
model: &str,
request_body: &str,
response_body: Option<&str>,
error: Option<&str>,
duration_ms: u64,
) -> Result<(), StorageError> {
let now = chrono::Utc::now().timestamp_millis();
sqlx::query(
r#"
INSERT INTO llm_calls (created_at, provider, model, request_body, response_body, error, duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(now)
.bind(provider)
.bind(model)
.bind(request_body)
.bind(response_body)
.bind(error)
.bind(duration_ms as i64)
.execute(self.pool())
.await?;
// Prune to keep last 1000 records
self.prune_llm_calls(1000).await?;
Ok(())
}
async fn prune_llm_calls(&self, max_records: i64) -> Result<(), StorageError> {
sqlx::query(
r#"
DELETE FROM llm_calls WHERE id <= (
SELECT COALESCE(MAX(id), 0) - ? FROM llm_calls
)
"#,
)
.bind(max_records)
.execute(self.pool())
.await?;
Ok(())
}
/// 获取连接池引用(供内部 CRUD 使用)
pub(crate) fn pool(&self) -> &Pool<Sqlite> {
&self.pool
}
pub async fn upsert_session(
&self,
meta: &crate::storage::session::SessionMeta,
) -> Result<(), StorageError> {
sqlx::query(
r#"
INSERT INTO sessions (id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, archived_at, deleted_at, last_consolidated_at, last_compressed_message_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
last_active_at = excluded.last_active_at,
message_count = excluded.message_count,
routing_info = excluded.routing_info,
archived_at = excluded.archived_at,
deleted_at = excluded.deleted_at,
last_consolidated_at = excluded.last_consolidated_at,
last_compressed_message_at = excluded.last_compressed_message_at
"#,
)
.bind(&meta.id)
.bind(&meta.channel)
.bind(&meta.chat_id)
.bind(&meta.dialog_id)
.bind(&meta.title)
.bind(meta.created_at)
.bind(meta.last_active_at)
.bind(meta.message_count)
.bind(&meta.routing_info)
.bind(meta.archived_at)
.bind(meta.deleted_at)
.bind(meta.last_consolidated_at)
.bind(meta.last_compressed_message_at)
.execute(self.pool())
.await?;
Ok(())
}
pub async fn get_session(
&self,
id: &str,
) -> Result<crate::storage::session::SessionMeta, StorageError> {
let row = sqlx::query(
r#"
SELECT id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, archived_at, deleted_at, last_consolidated_at, last_compressed_message_at
FROM sessions WHERE id = ? AND deleted_at IS NULL
"#,
)
.bind(id)
.fetch_optional(self.pool())
.await?
.ok_or_else(|| StorageError::NotFound(id.to_string()))?;
Ok(crate::storage::session::SessionMeta {
id: row.get("id"),
channel: row.get("channel"),
chat_id: row.get("chat_id"),
dialog_id: row.get("dialog_id"),
title: row.get("title"),
created_at: row.get("created_at"),
last_active_at: row.get("last_active_at"),
message_count: row.get("message_count"),
routing_info: row.get("routing_info"),
archived_at: row.get("archived_at"),
deleted_at: row.get("deleted_at"),
last_consolidated_at: row.get("last_consolidated_at"),
last_compressed_message_at: row.get("last_compressed_message_at"),
})
}
pub async fn list_sessions(
&self,
channel: &str,
chat_id: &str,
limit: i64,
include_archived: bool,
) -> Result<Vec<crate::storage::session::SessionMeta>, StorageError> {
let rows = sqlx::query(
r#"
SELECT id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, archived_at, deleted_at, last_consolidated_at, last_compressed_message_at
FROM sessions
WHERE channel = ? AND chat_id = ? AND deleted_at IS NULL
AND (? OR archived_at IS NULL)
ORDER BY last_active_at DESC
LIMIT ?
"#,
)
.bind(channel)
.bind(chat_id)
.bind(include_archived)
.bind(limit)
.fetch_all(self.pool())
.await?;
Ok(rows
.into_iter()
.map(|row| crate::storage::session::SessionMeta {
id: row.get("id"),
channel: row.get("channel"),
chat_id: row.get("chat_id"),
dialog_id: row.get("dialog_id"),
title: row.get("title"),
created_at: row.get("created_at"),
last_active_at: row.get("last_active_at"),
message_count: row.get("message_count"),
routing_info: row.get("routing_info"),
archived_at: row.get("archived_at"),
deleted_at: row.get("deleted_at"),
last_consolidated_at: row.get("last_consolidated_at"),
last_compressed_message_at: row.get("last_compressed_message_at"),
})
.collect())
}
pub async fn touch_session(
&self,
id: &str,
message_count: i64,
last_active_at: i64,
) -> Result<(), StorageError> {
sqlx::query(
r#"
UPDATE sessions SET message_count = ?, last_active_at = ?
WHERE id = ?
"#,
)
.bind(message_count)
.bind(last_active_at)
.bind(id)
.execute(self.pool())
.await?;
Ok(())
}
pub async fn soft_delete_session(&self, id: &str) -> Result<(), StorageError> {
let now = chrono::Utc::now().timestamp_millis();
sqlx::query(r#"UPDATE sessions SET deleted_at = ? WHERE id = ?"#)
.bind(now)
.bind(id)
.execute(self.pool())
.await?;
Ok(())
}
pub async fn archive_session(&self, id: &str) -> Result<(), StorageError> {
let now = chrono::Utc::now().timestamp_millis();
sqlx::query(r#"UPDATE sessions SET archived_at = ? WHERE id = ? AND deleted_at IS NULL"#)
.bind(now)
.bind(id)
.execute(self.pool())
.await?;
Ok(())
}
pub async fn find_most_recent_session(
&self,
channel: &str,
chat_id: &str,
) -> Result<Option<crate::storage::session::SessionMeta>, StorageError> {
let row = sqlx::query(
r#"
SELECT id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, archived_at, deleted_at, last_consolidated_at, last_compressed_message_at
FROM sessions
WHERE channel = ? AND chat_id = ? AND deleted_at IS NULL AND archived_at IS NULL
ORDER BY last_active_at DESC
LIMIT 1
"#,
)
.bind(channel)
.bind(chat_id)
.fetch_optional(self.pool())
.await?;
match row {
Some(row) => Ok(Some(crate::storage::session::SessionMeta {
id: row.get("id"),
channel: row.get("channel"),
chat_id: row.get("chat_id"),
dialog_id: row.get("dialog_id"),
title: row.get("title"),
created_at: row.get("created_at"),
last_active_at: row.get("last_active_at"),
message_count: row.get("message_count"),
routing_info: row.get("routing_info"),
archived_at: row.get("archived_at"),
deleted_at: row.get("deleted_at"),
last_consolidated_at: row.get("last_consolidated_at"),
last_compressed_message_at: row.get("last_compressed_message_at"),
})),
None => Ok(None),
}
}
pub async fn append_message(
&self,
session_id: &str,
msg: &crate::storage::message::MessageMeta,
) -> Result<i64, StorageError> {
insert_message_query(session_id, msg)
.execute(self.pool())
.await?;
Ok(msg.seq)
}
pub async fn persist_message_if_absent(
&self,
session_id: &str,
msg: &crate::storage::message::MessageMeta,
meta: &crate::storage::session::SessionMeta,
) -> Result<bool, StorageError> {
let mut tx = self.pool.begin().await?;
let inserted = insert_message_if_absent_query(session_id, msg)
.execute(&mut *tx)
.await?
.rows_affected()
== 1;
if inserted {
sqlx::query(
r#"
INSERT INTO sessions (id, channel, chat_id, dialog_id, title, created_at,
last_active_at, message_count, routing_info, archived_at, deleted_at,
last_consolidated_at, last_compressed_message_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
last_active_at = excluded.last_active_at,
message_count = excluded.message_count,
routing_info = excluded.routing_info,
archived_at = excluded.archived_at,
deleted_at = excluded.deleted_at,
last_consolidated_at = excluded.last_consolidated_at,
last_compressed_message_at = excluded.last_compressed_message_at
"#,
)
.bind(&meta.id)
.bind(&meta.channel)
.bind(&meta.chat_id)
.bind(&meta.dialog_id)
.bind(&meta.title)
.bind(meta.created_at)
.bind(meta.last_active_at)
.bind(meta.message_count)
.bind(&meta.routing_info)
.bind(meta.archived_at)
.bind(meta.deleted_at)
.bind(meta.last_consolidated_at)
.bind(meta.last_compressed_message_at)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(inserted)
}
/// Atomically persist all messages produced by one logical turn together
/// with the resulting session metadata. A turn is either fully visible
/// after restart or not visible at all.
async fn persist_message_batch_inner(
&self,
session_id: &str,
msgs: &[crate::storage::message::MessageMeta],
meta: &crate::storage::session::SessionMeta,
usage: Option<&crate::storage::TurnUsageRecord>,
steer: Option<&crate::storage::agent_inbox::SteerConsumption>,
) -> Result<(), StorageError> {
let now = chrono::Utc::now().timestamp_millis();
let mut tx = self.pool.begin().await?;
for msg in msgs {
insert_message_query(session_id, msg)
.execute(&mut *tx)
.await?;
}
sqlx::query(
r#"
INSERT INTO sessions (id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, archived_at, deleted_at, last_consolidated_at, last_compressed_message_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
last_active_at = excluded.last_active_at,
message_count = excluded.message_count,
routing_info = excluded.routing_info,
archived_at = excluded.archived_at,
deleted_at = excluded.deleted_at,
last_consolidated_at = excluded.last_consolidated_at,
last_compressed_message_at = excluded.last_compressed_message_at
"#,
)
.bind(&meta.id)
.bind(&meta.channel)
.bind(&meta.chat_id)
.bind(&meta.dialog_id)
.bind(&meta.title)
.bind(meta.created_at)
.bind(meta.last_active_at)
.bind(meta.message_count)
.bind(&meta.routing_info)
.bind(meta.archived_at)
.bind(meta.deleted_at)
.bind(meta.last_consolidated_at)
.bind(meta.last_compressed_message_at)
.execute(&mut *tx)
.await?;
if let Some(usage) = usage {
debug_assert_eq!(session_id, usage.session_id);
let request_count = msgs
.iter()
.filter_map(|message| message.iteration)
.max()
.map_or(1_i64, |iteration| iteration.saturating_add(1));
sqlx::query(
r#"
INSERT INTO session_turn_usage (
turn_id, session_id, provider, model, prompt_tokens,
completion_tokens, total_tokens, cached_input_tokens,
request_count, last_prompt_tokens, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(turn_id) DO NOTHING
"#,
)
.bind(&usage.turn_id)
.bind(&usage.session_id)
.bind(&usage.provider)
.bind(&usage.model)
.bind(i64::from(usage.usage.prompt_tokens))
.bind(i64::from(usage.usage.completion_tokens))
.bind(i64::from(usage.usage.total_tokens))
.bind(usage.usage.cached_tokens.map(i64::from))
.bind(request_count)
.bind(i64::from(usage.last_prompt_tokens))
.bind(usage.created_at)
.execute(&mut *tx)
.await?;
}
if let Some(steer) = steer {
let mut consumed = 0i64;
for event_id in &steer.event_ids {
let rows = sqlx::query(
"UPDATE agent_inbox_events SET status = 'consumed', consumed_at = ?, \
updated_at = ? \
WHERE id = ? AND status = 'admitted' AND admitted_turn_id = ?",
)
.bind(now)
.bind(now)
.bind(event_id)
.bind(&steer.admitted_turn_id)
.execute(&mut *tx)
.await?
.rows_affected();
consumed += rows as i64;
}
if consumed > 0 {
sqlx::query(
"UPDATE agent_session_state \
SET pending_event_count = MAX(pending_event_count - ?, 0), \
revision = revision + 1, updated_at = ? \
WHERE root_session_id = ?",
)
.bind(consumed)
.bind(now)
.bind(session_id)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
Ok(())
}
pub async fn persist_message_batch(
&self,
session_id: &str,
msgs: &[crate::storage::message::MessageMeta],
meta: &crate::storage::session::SessionMeta,
) -> Result<(), StorageError> {
self.persist_message_batch_inner(session_id, msgs, meta, None, None)
.await
}
pub async fn persist_turn_batch(
&self,
session_id: &str,
msgs: &[crate::storage::message::MessageMeta],
meta: &crate::storage::session::SessionMeta,
usage: &crate::storage::TurnUsageRecord,
) -> Result<(), StorageError> {
self.persist_message_batch_inner(session_id, msgs, meta, Some(usage), None)
.await
}
/// Persist a Turn and consume the admitted steer events of the same Turn
/// in one transaction: the steer inputs appear in history exactly when
/// their durable events become `consumed`.
pub async fn persist_turn_batch_with_steer_consumption(
&self,
session_id: &str,
msgs: &[crate::storage::message::MessageMeta],
meta: &crate::storage::session::SessionMeta,
usage: &crate::storage::TurnUsageRecord,
steer: &crate::storage::agent_inbox::SteerConsumption,
) -> Result<(), StorageError> {
self.persist_message_batch_inner(session_id, msgs, meta, Some(usage), Some(steer))
.await
}
/// Persist a turn with bounded retry. Retrying the whole transaction keeps
/// message rows and metadata consistent on transient SQLite failures.
pub async fn persist_message_batch_with_retry(
&self,
session_id: &str,
msgs: &[crate::storage::message::MessageMeta],
meta: &crate::storage::session::SessionMeta,
) -> Result<(), StorageError> {
let delays = [100, 200, 300];
for (attempt, delay) in delays.iter().enumerate() {
match self.persist_message_batch(session_id, msgs, meta).await {
Ok(()) => return Ok(()),
Err(error) if attempt < delays.len() - 1 && error.is_transient() => {
tracing::warn!(attempt = attempt + 1, error = %error, "Turn persistence failed; retrying");
sleep(Duration::from_millis(*delay)).await;
}
Err(error) => return Err(error),
}
}
unreachable!()
}
pub async fn persist_turn_batch_with_retry(
&self,
session_id: &str,
msgs: &[crate::storage::message::MessageMeta],
meta: &crate::storage::session::SessionMeta,
usage: &crate::storage::TurnUsageRecord,
) -> Result<(), StorageError> {
let delays = [100, 200, 300];
for (attempt, delay) in delays.iter().enumerate() {
match self.persist_turn_batch(session_id, msgs, meta, usage).await {
Ok(()) => return Ok(()),
Err(error) if attempt < delays.len() - 1 && error.is_transient() => {
tracing::warn!(attempt = attempt + 1, error = %error, "Turn persistence failed; retrying");
sleep(Duration::from_millis(*delay)).await;
}
Err(error) => return Err(error),
}
}
unreachable!()
}
/// Persist a Turn with steer consumption and bounded retry.
pub async fn persist_turn_with_steer_with_retry(
&self,
session_id: &str,
msgs: &[crate::storage::message::MessageMeta],
meta: &crate::storage::session::SessionMeta,
usage: &crate::storage::TurnUsageRecord,
steer: &crate::storage::agent_inbox::SteerConsumption,
) -> Result<(), StorageError> {
let delays = [100, 200, 300];
for (attempt, delay) in delays.iter().enumerate() {
match self
.persist_turn_batch_with_steer_consumption(session_id, msgs, meta, usage, steer)
.await
{
Ok(()) => return Ok(()),
Err(error) if attempt < delays.len() - 1 && error.is_transient() => {
tracing::warn!(attempt = attempt + 1, error = %error, "Turn persistence failed; retrying");
sleep(Duration::from_millis(*delay)).await;
}
Err(error) => return Err(error),
}
}
unreachable!()
}
pub async fn get_session_usage_totals(
&self,
session_id: &str,
) -> Result<crate::storage::SessionUsageTotals, StorageError> {
let row = sqlx::query(
r#"
SELECT
COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
COALESCE(SUM(total_tokens), 0) AS total_tokens,
SUM(cached_input_tokens) AS cached_input_tokens,
COALESCE(SUM(request_count), 0) AS request_count,
COUNT(*) AS turn_count,
MIN(created_at) AS tracked_since
FROM session_turn_usage
WHERE session_id = ?
"#,
)
.bind(session_id)
.fetch_one(self.pool())
.await?;
let last = sqlx::query(
r#"
SELECT last_prompt_tokens, created_at
FROM session_turn_usage
WHERE session_id = ?
ORDER BY created_at DESC, rowid DESC
LIMIT 1
"#,
)
.bind(session_id)
.fetch_optional(self.pool())
.await?;
Ok(crate::storage::SessionUsageTotals {
prompt_tokens: u64::try_from(row.get::<i64, _>("prompt_tokens")).unwrap_or_default(),
completion_tokens: u64::try_from(row.get::<i64, _>("completion_tokens"))
.unwrap_or_default(),
total_tokens: u64::try_from(row.get::<i64, _>("total_tokens")).unwrap_or_default(),
cached_input_tokens: row
.get::<Option<i64>, _>("cached_input_tokens")
.and_then(|value| u64::try_from(value).ok()),
request_count: u64::try_from(row.get::<i64, _>("request_count")).unwrap_or_default(),
turn_count: u64::try_from(row.get::<i64, _>("turn_count")).unwrap_or_default(),
tracked_since: row.get("tracked_since"),
last_prompt_tokens: last
.as_ref()
.and_then(|value| u64::try_from(value.get::<i64, _>("last_prompt_tokens")).ok()),
last_observed_at: last.map(|value| value.get("created_at")),
})
}
pub async fn load_messages(
&self,
session_id: &str,
from_seq: i64,
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
let rows = sqlx::query(
r#"
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
turn_id, iteration, completion_status, client_visibility, turn_origin,
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
FROM messages
WHERE session_id = ? AND seq >= ?
ORDER BY seq ASC
"#,
)
.bind(session_id)
.bind(from_seq)
.fetch_all(self.pool())
.await?;
Ok(rows.into_iter().map(message_meta_from_row).collect())
}
pub async fn get_message(
&self,
session_id: &str,
message_id: &str,
) -> Result<Option<crate::storage::message::MessageMeta>, StorageError> {
let row = sqlx::query(
r#"
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
turn_id, iteration, completion_status, client_visibility, turn_origin,
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
FROM messages
WHERE session_id = ? AND id = ?
"#,
)
.bind(session_id)
.bind(message_id)
.fetch_optional(self.pool())
.await?;
Ok(row.map(message_meta_from_row))
}
pub async fn get_max_message_seq(&self, session_id: &str) -> Result<i64, StorageError> {
let row = sqlx::query(
"SELECT COALESCE(MAX(seq), 0) as max_seq FROM messages WHERE session_id = ?",
)
.bind(session_id)
.fetch_one(self.pool())
.await?;
Ok(row.get::<i64, _>("max_seq"))
}
/// Load a bounded tail of one session while preserving chronological order.
pub async fn load_recent_session_messages(
&self,
session_id: &str,
limit: u32,
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
let limit = limit.clamp(1, 2_000);
let rows = sqlx::query(
r#"
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
turn_id, iteration, completion_status, client_visibility, turn_origin,
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
FROM (
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
turn_id, iteration, completion_status, client_visibility, turn_origin,
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
FROM messages
WHERE session_id = ? AND client_visibility = 'visible'
ORDER BY seq DESC
LIMIT ?
)
ORDER BY seq ASC
"#,
)
.bind(session_id)
.bind(i64::from(limit))
.fetch_all(self.pool())
.await?;
Ok(rows.into_iter().map(message_meta_from_row).collect())
}
pub async fn load_messages_after_timestamp(
&self,
session_id: &str,
after_ts: i64,
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
let rows = sqlx::query(
r#"
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
turn_id, iteration, completion_status, client_visibility, turn_origin,
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
FROM messages
WHERE session_id = ? AND created_at > ?
ORDER BY seq ASC
"#,
)
.bind(session_id)
.bind(after_ts)
.fetch_all(self.pool())
.await?;
Ok(rows.into_iter().map(message_meta_from_row).collect())
}
pub async fn query_sessions_range(
&self,
offset: i64,
limit: i64,
) -> Result<(Vec<crate::storage::session::SessionMeta>, i64), StorageError> {
let count_row =
sqlx::query("SELECT COUNT(*) as total FROM sessions WHERE deleted_at IS NULL")
.fetch_one(self.pool())
.await?;
let total: i64 = count_row.get("total");
let rows = sqlx::query(
r#"
SELECT id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, archived_at, deleted_at, last_consolidated_at, last_compressed_message_at
FROM sessions
WHERE deleted_at IS NULL
ORDER BY last_active_at DESC
LIMIT ? OFFSET ?
"#,
)
.bind(limit)
.bind(offset)
.fetch_all(self.pool())
.await?;
let sessions: Vec<_> = rows
.into_iter()
.map(|row| crate::storage::session::SessionMeta {
id: row.get("id"),
channel: row.get("channel"),
chat_id: row.get("chat_id"),
dialog_id: row.get("dialog_id"),
title: row.get("title"),
created_at: row.get("created_at"),
last_active_at: row.get("last_active_at"),
message_count: row.get("message_count"),
routing_info: row.get("routing_info"),
archived_at: row.get("archived_at"),
deleted_at: row.get("deleted_at"),
last_consolidated_at: row.get("last_consolidated_at"),
last_compressed_message_at: row.get("last_compressed_message_at"),
})
.collect();
Ok((sessions, total))
}
pub async fn list_recent_messages(
&self,
session_id: &str,
count: i64,
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
let rows = sqlx::query(
r#"
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
turn_id, iteration, completion_status, client_visibility, turn_origin,
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
FROM messages
WHERE session_id = ?
ORDER BY seq DESC
LIMIT ?
"#,
)
.bind(session_id)
.bind(count)
.fetch_all(self.pool())
.await?;
let mut messages: Vec<_> = rows.into_iter().map(message_meta_from_row).collect();
messages.reverse();
Ok(messages)
}
pub async fn query_messages_range(
&self,
session_id: &str,
before_time: Option<i64>,
after_time: Option<i64>,
offset: i64,
limit: i64,
) -> Result<(Vec<crate::storage::message::MessageMeta>, i64), StorageError> {
let mut where_extra = String::new();
if before_time.is_some() {
where_extra.push_str(" AND created_at < ?");
}
if after_time.is_some() {
where_extra.push_str(" AND created_at > ?");
}
let count_sql = format!(
"SELECT COUNT(*) as total FROM messages WHERE session_id = ? AND client_visibility = 'visible'{}",
where_extra
);
let select_sql = format!(
r#"
SELECT id, session_id, seq, role, content, reasoning_content, provider_state,
turn_id, iteration, completion_status, client_visibility, turn_origin,
media_refs, tool_call_id, tool_name, tool_calls, source, created_at
FROM messages
WHERE session_id = ? AND client_visibility = 'visible'{}
ORDER BY seq ASC
LIMIT ? OFFSET ?
"#,
where_extra
);
// `where_extra` contains only the fixed timestamp predicates constructed above.
let mut count_query = sqlx::query(sqlx::AssertSqlSafe(count_sql)).bind(session_id);
if let Some(bt) = before_time {
count_query = count_query.bind(bt);
}
if let Some(at) = after_time {
count_query = count_query.bind(at);
}
let count_row = count_query.fetch_one(self.pool()).await?;
let total: i64 = count_row.get("total");
let mut select_query = sqlx::query(sqlx::AssertSqlSafe(select_sql)).bind(session_id);
if let Some(bt) = before_time {
select_query = select_query.bind(bt);
}
if let Some(at) = after_time {
select_query = select_query.bind(at);
}
let rows = select_query
.bind(limit)
.bind(offset)
.fetch_all(self.pool())
.await?;
let messages: Vec<_> = rows.into_iter().map(message_meta_from_row).collect();
Ok((messages, total))
}
pub async fn clear_messages(&self, session_id: &str) -> Result<(), StorageError> {
let mut tx = self.pool.begin().await?;
sqlx::query(r#"DELETE FROM messages WHERE session_id = ?"#)
.bind(session_id)
.execute(&mut *tx)
.await?;
let updated = sqlx::query(
r#"
UPDATE sessions
SET active_context_checkpoint_id = NULL,
context_generation = context_generation + 1,
last_compressed_message_at = NULL
WHERE id = ?
"#,
)
.bind(session_id)
.execute(&mut *tx)
.await?;
if updated.rows_affected() != 1 {
tx.rollback().await?;
return Err(StorageError::NotFound(session_id.to_string()));
}
tx.commit().await?;
Ok(())
}
/// 追加消息,带重试逻辑
/// 重试 3 次100/200/300ms 退避),仍失败返回错误
pub async fn append_message_with_retry(
&self,
session_id: &str,
msg: &crate::storage::message::MessageMeta,
) -> Result<i64, StorageError> {
let delays = [100, 200, 300];
for (i, delay) in delays.iter().enumerate() {
match self.append_message(session_id, msg).await {
Ok(seq) => return Ok(seq),
Err(e) if i < delays.len() - 1 => {
sleep(Duration::from_millis(*delay)).await;
tracing::warn!("Storage write failed, retrying: {}", e);
}
Err(e) => {
tracing::error!("Storage write failed after retries: {}", e);
return Err(e);
}
}
}
unreachable!()
}
/// Persist the channel's durable delivery context for a session. Only
/// channel-declared reusable values (thread/root identity) ever reach
/// this column; one-shot reply/reaction ids never do.
pub async fn update_session_delivery_context(
&self,
session_id: &str,
context_json: &str,
now: i64,
) -> Result<(), StorageError> {
sqlx::query(
"UPDATE sessions SET delivery_context = ?, delivery_context_updated_at = ? \
WHERE id = ?",
)
.bind(context_json)
.bind(now)
.bind(session_id)
.execute(self.pool())
.await?;
Ok(())
}
/// Durable delivery context previously saved for the session.
pub async fn get_session_delivery_context(
&self,
session_id: &str,
) -> Result<Option<String>, StorageError> {
let context: Option<String> = sqlx::query_scalar(
"SELECT delivery_context FROM sessions \
WHERE id = ? AND delivery_context IS NOT NULL",
)
.bind(session_id)
.fetch_optional(self.pool())
.await?;
Ok(context)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn create_test_storage() -> (Storage, TempDir) {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let storage = Storage::new(&db_path).await.unwrap();
(storage, dir)
}
async fn scheduler_schema_sql(storage: &Storage) -> Vec<(String, String, Option<String>)> {
sqlx::query_as::<_, (String, String, Option<String>)>(
r#"
SELECT type, name, sql FROM sqlite_master
WHERE name IN (
'scheduled_jobs', 'job_runs', 'idx_jobs_claimable',
'idx_job_runs_job_finished', 'idx_job_runs_recovery',
'idx_job_runs_delivery'
)
ORDER BY type, name
"#,
)
.fetch_all(storage.pool())
.await
.unwrap()
}
async fn create_v10_scheduler_schema(pool: &Pool<Sqlite>) {
sqlx::query(
r#"
CREATE TABLE scheduled_jobs (
id TEXT PRIMARY KEY, name TEXT NOT NULL, schedule TEXT NOT NULL,
prompt TEXT NOT NULL, channel TEXT NOT NULL, chat_id TEXT NOT NULL,
model TEXT, job_kind TEXT NOT NULL DEFAULT 'task',
delivery_policy TEXT NOT NULL DEFAULT 'direct',
enabled INTEGER NOT NULL DEFAULT 1,
delete_after_run INTEGER NOT NULL DEFAULT 0, next_run_at INTEGER NOT NULL,
last_run_at INTEGER, last_status TEXT, last_error TEXT,
locked_at INTEGER, lock_owner TEXT, lease_until INTEGER,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
)
"#,
)
.execute(pool)
.await
.unwrap();
sqlx::query(
r#"
CREATE TABLE job_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL REFERENCES scheduled_jobs(id) ON DELETE CASCADE,
started_at INTEGER NOT NULL, finished_at INTEGER NOT NULL,
status TEXT NOT NULL, output TEXT, error TEXT,
duration_ms INTEGER NOT NULL, result_kind TEXT,
delivery_status TEXT, delivery_error TEXT
)
"#,
)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn sqlite_runtime_guards_are_enabled() {
let (storage, _dir) = create_test_storage().await;
let journal_mode: String = sqlx::query_scalar("PRAGMA journal_mode")
.fetch_one(storage.pool())
.await
.unwrap();
let foreign_keys: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
.fetch_one(storage.pool())
.await
.unwrap();
let busy_timeout: i64 = sqlx::query_scalar("PRAGMA busy_timeout")
.fetch_one(storage.pool())
.await
.unwrap();
let schema_version: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(journal_mode, "wal");
assert_eq!(foreign_keys, 1);
assert_eq!(busy_timeout, 5000);
assert_eq!(schema_version, SCHEMA_VERSION);
let orphan = sqlx::query(
r#"
INSERT INTO messages (id, session_id, seq, role, content, created_at)
VALUES ('orphan', 'missing', 1, 'user', 'no parent', 1)
"#,
)
.execute(storage.pool())
.await;
assert!(orphan.is_err());
}
#[tokio::test]
async fn committed_turn_usage_is_aggregated_and_idempotent() {
let (storage, _dir) = create_test_storage().await;
let meta = crate::storage::session::SessionMeta {
id: "cli_chat:chat:dialog".to_string(),
channel: "cli_chat".to_string(),
chat_id: "chat".to_string(),
dialog_id: "dialog".to_string(),
title: "usage".to_string(),
created_at: 1,
last_active_at: 2,
message_count: 1,
routing_info: None,
archived_at: None,
deleted_at: None,
last_consolidated_at: None,
last_compressed_message_at: None,
};
let first = crate::storage::TurnUsageRecord {
session_id: meta.id.clone(),
turn_id: "turn-1".to_string(),
provider: "test".to_string(),
model: "model".to_string(),
usage: crate::providers::Usage {
prompt_tokens: 100,
completion_tokens: 20,
total_tokens: 120,
cached_tokens: Some(40),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
},
last_prompt_tokens: 75,
created_at: 10,
};
storage
.persist_turn_batch(&meta.id, &[], &meta, &first)
.await
.unwrap();
storage
.persist_turn_batch(&meta.id, &[], &meta, &first)
.await
.unwrap();
let second = crate::storage::TurnUsageRecord {
turn_id: "turn-2".to_string(),
usage: crate::providers::Usage {
prompt_tokens: 50,
completion_tokens: 10,
total_tokens: 60,
cached_tokens: None,
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
},
last_prompt_tokens: 45,
created_at: 20,
..first
};
storage
.persist_turn_batch(&meta.id, &[], &meta, &second)
.await
.unwrap();
let totals = storage.get_session_usage_totals(&meta.id).await.unwrap();
assert_eq!(totals.prompt_tokens, 150);
assert_eq!(totals.completion_tokens, 30);
assert_eq!(totals.total_tokens, 180);
assert_eq!(totals.cached_input_tokens, Some(40));
assert_eq!(totals.request_count, 2);
assert_eq!(totals.turn_count, 2);
assert_eq!(totals.tracked_since, Some(10));
assert_eq!(totals.last_prompt_tokens, Some(45));
assert_eq!(totals.last_observed_at, Some(20));
}
#[tokio::test]
async fn reopening_database_does_not_rebuild_existing_fts_index() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("fts.db");
let storage = Storage::new(&db_path).await.unwrap();
sqlx::query(
"INSERT INTO memory_fts(rowid, key, content) VALUES (999999, 'startup_sentinel', 'startup_sentinel')",
)
.execute(storage.pool())
.await
.unwrap();
drop(storage);
let reopened = Storage::new(&db_path).await.unwrap();
let sentinel_count: i64 = sqlx::query_scalar(
"SELECT count(*) FROM memory_fts WHERE memory_fts MATCH 'startup_sentinel'",
)
.fetch_one(reopened.pool())
.await
.unwrap();
assert_eq!(sentinel_count, 1);
}
#[tokio::test]
async fn webui_lists_and_filters_memories_without_search_text() {
let (storage, _dir) = create_test_storage().await;
for (key, category, updated_at) in [
(
"fact",
crate::memory::MemoryCategory::Knowledge,
"2026-01-01T00:00:00Z",
),
(
"summary",
crate::memory::MemoryCategory::Timeline,
"2026-01-02T00:00:00Z",
),
] {
storage
.upsert_memory(&crate::memory::MemoryEntry {
id: key.into(),
key: key.into(),
content: format!("content {key}"),
category,
importance: 0.5,
session_id: Some("cli:test:dialog".into()),
created_at: updated_at.into(),
updated_at: updated_at.into(),
})
.await
.unwrap();
}
let all = storage.list_memories(None, None, 10).await.unwrap();
assert_eq!(
all.iter()
.map(|entry| entry.key.as_str())
.collect::<Vec<_>>(),
vec!["summary", "fact"]
);
let knowledge = storage
.list_memories(Some(&crate::memory::MemoryCategory::Knowledge), None, 10)
.await
.unwrap();
assert_eq!(knowledge.len(), 1);
assert_eq!(knowledge[0].key, "fact");
}
#[tokio::test]
async fn legacy_schema_is_migrated_to_canonical_v11() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("legacy.db");
let pool = SqlitePoolOptions::new()
.connect_with(
SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true),
)
.await
.unwrap();
sqlx::query(
r#"
CREATE TABLE sessions (
id TEXT PRIMARY KEY, channel TEXT NOT NULL, chat_id TEXT NOT NULL,
dialog_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT 'new',
created_at INTEGER NOT NULL, last_active_at INTEGER NOT NULL,
message_count INTEGER DEFAULT 0, routing_info TEXT, deleted_at INTEGER,
UNIQUE(channel, chat_id, dialog_id)
)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
r#"
CREATE TABLE messages (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, seq INTEGER NOT NULL,
role TEXT NOT NULL, content TEXT NOT NULL, media_refs TEXT,
tool_call_id TEXT, tool_name TEXT, tool_calls TEXT,
created_at INTEGER NOT NULL
)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
r#"
CREATE TABLE scheduled_jobs (
id TEXT PRIMARY KEY, name TEXT NOT NULL, schedule TEXT NOT NULL,
prompt TEXT NOT NULL, channel TEXT NOT NULL, chat_id TEXT NOT NULL,
model TEXT, enabled INTEGER NOT NULL DEFAULT 1,
delete_after_run INTEGER NOT NULL DEFAULT 0, next_run_at INTEGER NOT NULL,
last_run_at INTEGER, last_status TEXT, last_error TEXT,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO sessions (id, channel, chat_id, dialog_id, created_at, last_active_at) VALUES ('cli:c:d', 'cli', 'c', 'd', 1, 1)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO messages (id, session_id, seq, role, content, created_at) VALUES ('m1', 'cli:c:d', 1, 'user', 'legacy message', 1)",
)
.execute(&pool)
.await
.unwrap();
drop(pool);
let storage = Storage::new(&db_path).await.unwrap();
for (table, expected) in [
(
"messages",
vec![
"source",
"reasoning_content",
"provider_state",
"turn_id",
"iteration",
"completion_status",
"client_visibility",
"turn_origin",
],
),
(
"sessions",
vec![
"archived_at",
"last_consolidated_at",
"last_compressed_message_at",
"active_context_checkpoint_id",
"context_generation",
"delivery_context",
"delivery_context_updated_at",
],
),
(
"scheduled_jobs",
vec![
"agent_id",
"delivery_policy",
"last_outcome",
"locked_at",
"lock_owner",
"lease_until",
],
),
] {
let columns = sqlx::query(sqlx::AssertSqlSafe(format!("PRAGMA table_info({table})")))
.fetch_all(storage.pool())
.await
.unwrap();
for column in expected {
assert!(
columns
.iter()
.any(|row| row.get::<String, _>("name") == column),
"missing migrated column {table}.{column}"
);
}
}
let scheduler_columns = sqlx::query("PRAGMA table_info(scheduled_jobs)")
.fetch_all(storage.pool())
.await
.unwrap()
.into_iter()
.map(|row| row.get::<String, _>("name"))
.collect::<Vec<_>>();
for removed in [
"model",
"job_kind",
"delete_after_run",
"last_status",
"last_error",
] {
assert!(
!scheduler_columns.iter().any(|column| column == removed),
"legacy scheduler column survived v11: {removed}"
);
}
let schema_version: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(schema_version, SCHEMA_VERSION);
for table in [
"task_plans",
"task_items",
"session_turn_usage",
"agent_runs",
"agent_session_state",
"agent_inbox_events",
"context_checkpoints",
] {
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
)
.bind(table)
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(exists, 1, "missing migrated table {table}");
}
let visibility: String =
sqlx::query_scalar("SELECT client_visibility FROM messages ORDER BY seq LIMIT 1")
.fetch_one(storage.pool())
.await
.unwrap_or_default();
assert_eq!(visibility, "visible");
let origin: String =
sqlx::query_scalar("SELECT turn_origin FROM messages ORDER BY seq LIMIT 1")
.fetch_one(storage.pool())
.await
.unwrap_or_default();
assert_eq!(origin, "user");
}
#[tokio::test]
async fn v10_scheduler_history_and_active_lease_migrate_to_v11() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("v10-scheduler.db");
let pool = SqlitePoolOptions::new()
.connect_with(
SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true),
)
.await
.unwrap();
create_v10_scheduler_schema(&pool).await;
sqlx::query(
r#"
INSERT INTO scheduled_jobs
(id, name, schedule, prompt, channel, chat_id, model, job_kind,
delivery_policy, enabled, next_run_at, last_run_at, last_status,
last_error, created_at, updated_at)
VALUES ('legacy-direct', 'Legacy direct', '{"type":"every","every_ms":60000}',
'check it', 'cli_chat', 'chat', 'obsolete-model', 'monitor',
'direct', 1, 2000, 1000, 'delivery_error', 'legacy diagnostic', 1, 2)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
r#"
INSERT INTO job_runs
(job_id, started_at, finished_at, status, output, error, duration_ms,
result_kind, delivery_status, delivery_error)
VALUES ('legacy-direct', 1000, 1100, 'delivery_error', 'legacy result', NULL,
100, NULL, 'failed', 'channel rejected target')
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
r#"
INSERT INTO scheduled_jobs
(id, name, schedule, prompt, channel, chat_id, job_kind,
delivery_policy, enabled, next_run_at, last_run_at, last_status,
locked_at, lock_owner, lease_until, created_at, updated_at)
VALUES ('legacy-locked', 'Legacy locked', '{"type":"every","every_ms":60000}',
'check lock', 'cli_chat', 'chat', 'task', 'never', 1, 2000,
1500, NULL, 1500, 'old-owner', 900000, 1, 2)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query("PRAGMA user_version = 10")
.execute(&pool)
.await
.unwrap();
drop(pool);
let storage = Storage::new(&db_path).await.unwrap();
let migrated_columns = sqlx::query("PRAGMA table_info(scheduled_jobs)")
.fetch_all(storage.pool())
.await
.unwrap()
.into_iter()
.map(|row| row.get::<String, _>("name"))
.collect::<Vec<_>>();
assert!(
migrated_columns
.iter()
.any(|column| column == "last_outcome"),
"unexpected post-migration scheduler schema: {migrated_columns:?}"
);
let direct = storage.get_scheduled_job("legacy-direct").await.unwrap();
assert_eq!(
direct.delivery_policy,
crate::storage::DeliveryPolicy::Always
);
assert_eq!(
direct.last_outcome,
Some(crate::storage::ScheduledOutcomeKind::Ok)
);
let historical = storage
.list_scheduled_job_runs("legacy-direct", 10)
.await
.unwrap();
assert_eq!(historical.len(), 1);
assert_eq!(
historical[0].status,
crate::storage::ScheduledRunStatus::Completed
);
assert_eq!(
historical[0].outcome,
Some(crate::storage::ScheduledOutcomeKind::Ok)
);
assert_eq!(
historical[0].delivery_status,
crate::storage::ScheduledDeliveryStatus::Failed
);
assert_eq!(
historical[0].diagnostic.as_deref(),
Some("legacy diagnostic")
);
let recovered = storage
.list_scheduled_job_runs("legacy-locked", 10)
.await
.unwrap();
assert_eq!(recovered.len(), 1);
assert_eq!(
recovered[0].status,
crate::storage::ScheduledRunStatus::Unknown
);
assert_eq!(
recovered[0].delivery_status,
crate::storage::ScheduledDeliveryStatus::NotRequested
);
let locked = storage.get_scheduled_job("legacy-locked").await.unwrap();
assert!(locked.lock_owner.is_none());
let columns = sqlx::query("PRAGMA table_info(scheduled_jobs)")
.fetch_all(storage.pool())
.await
.unwrap()
.into_iter()
.map(|row| row.get::<String, _>("name"))
.collect::<Vec<_>>();
for removed in [
"model",
"job_kind",
"delete_after_run",
"last_status",
"last_error",
] {
assert!(!columns.iter().any(|column| column == removed));
}
let violations = sqlx::query("PRAGMA foreign_key_check")
.fetch_all(storage.pool())
.await
.unwrap();
assert!(violations.is_empty());
let fresh_dir = tempfile::tempdir().unwrap();
let fresh = Storage::new(&fresh_dir.path().join("fresh.db"))
.await
.unwrap();
assert_eq!(
scheduler_schema_sql(&storage).await,
scheduler_schema_sql(&fresh).await
);
}
#[tokio::test]
async fn invalid_v10_scheduler_data_rolls_back_v11_migration() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("invalid-v10-scheduler.db");
let pool = SqlitePoolOptions::new()
.connect_with(
SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true),
)
.await
.unwrap();
create_v10_scheduler_schema(&pool).await;
sqlx::query(
r#"
INSERT INTO scheduled_jobs
(id, name, schedule, prompt, channel, chat_id, delivery_policy,
enabled, next_run_at, created_at, updated_at)
VALUES ('broken', 'Broken', '{not-json}', 'check', 'cli_chat', 'chat',
'always', 1, 1000, 1, 1)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query("PRAGMA user_version = 10")
.execute(&pool)
.await
.unwrap();
drop(pool);
let error = match Storage::new(&db_path).await {
Ok(_) => panic!("invalid v10 scheduler data unexpectedly migrated"),
Err(error) => error,
};
assert!(matches!(error, StorageError::Migration(_)));
let pool = SqlitePoolOptions::new()
.connect_with(SqliteConnectOptions::new().filename(&db_path))
.await
.unwrap();
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(version, 10);
let columns = sqlx::query("PRAGMA table_info(scheduled_jobs)")
.fetch_all(&pool)
.await
.unwrap()
.into_iter()
.map(|row| row.get::<String, _>("name"))
.collect::<Vec<_>>();
assert!(columns.iter().any(|column| column == "job_kind"));
assert!(!columns.iter().any(|column| column == "agent_id"));
for table in ["scheduled_jobs_v10_legacy", "job_runs_v10_legacy"] {
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
)
.bind(table)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(exists, 0);
}
}
#[tokio::test]
async fn newer_schema_version_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("future.db");
let pool = SqlitePoolOptions::new()
.connect_with(
SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true),
)
.await
.unwrap();
sqlx::query("PRAGMA user_version = 12")
.execute(&pool)
.await
.unwrap();
drop(pool);
let error = match Storage::new(&db_path).await {
Ok(_) => panic!("newer schema version unexpectedly opened"),
Err(error) => error,
};
assert!(matches!(error, StorageError::Migration(_)));
}
#[tokio::test]
async fn v3_migration_preserves_existing_reasoning_and_defaults_completion() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("v3.db");
let pool = SqlitePoolOptions::new()
.connect_with(
SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true),
)
.await
.unwrap();
sqlx::query(
r#"
CREATE TABLE sessions (
id TEXT PRIMARY KEY, channel TEXT NOT NULL, chat_id TEXT NOT NULL,
dialog_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT 'new',
created_at INTEGER NOT NULL, last_active_at INTEGER NOT NULL,
message_count INTEGER DEFAULT 0, routing_info TEXT, archived_at INTEGER,
deleted_at INTEGER, last_consolidated_at INTEGER,
last_compressed_message_at INTEGER,
UNIQUE(channel, chat_id, dialog_id)
)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
r#"
CREATE TABLE messages (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, seq INTEGER NOT NULL,
role TEXT NOT NULL, content TEXT NOT NULL, reasoning_content TEXT,
media_refs TEXT, tool_call_id TEXT, tool_name TEXT, tool_calls TEXT,
source TEXT, created_at INTEGER NOT NULL
)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO sessions (id, channel, chat_id, dialog_id, created_at, last_active_at) VALUES ('cli:c:d', 'cli', 'c', 'd', 1, 1)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO messages (id, session_id, seq, role, content, reasoning_content, created_at) VALUES ('m1', 'cli:c:d', 1, 'assistant', 'answer', 'existing reasoning', 1)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("PRAGMA user_version = 3")
.execute(&pool)
.await
.unwrap();
drop(pool);
let storage = Storage::new(&db_path).await.unwrap();
let messages = storage.load_messages("cli:c:d", 0).await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(
messages[0].reasoning_content.as_deref(),
Some("existing reasoning")
);
assert_eq!(messages[0].provider_state, None);
assert_eq!(messages[0].turn_id, None);
assert_eq!(messages[0].iteration, None);
assert_eq!(
messages[0].completion_status,
crate::bus::CompletionStatus::Completed
);
}
#[tokio::test]
async fn v8_migration_preserves_agent_runs_and_adds_transcript_table() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("v8.db");
let pool = SqlitePoolOptions::new()
.connect_with(
SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true),
)
.await
.unwrap();
// The v8 `agent_runs` shape is unchanged in v9: v9 only adds the
// transcript table. Build a v8 database holding a durable run so the
// upgrade must preserve it rather than dropping the table.
sqlx::query(agent_run::AGENT_SCHEMA_STATEMENTS[0])
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO agent_runs (id, root_session_id, caller_agent_id, caller_scope_id, \
agent_id, definition_hash, provider_profile, provider_name, model_id, mode, \
depth, execution_id, task, budget_json, status, runtime_generation, attempt, \
completion_slot_reserved, deadline_at, revision, created_at, updated_at) \
VALUES ('run-1', 'cli:c:d', 'ROOT', 'turn-1', 'researcher', 'hash', 'profile', \
'test', 'model', 'foreground', 1, 'exec-1', 'task', '{}', 'completed', 1, 1, \
0, 1000, 0, 1, 1)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("PRAGMA user_version = 8")
.execute(&pool)
.await
.unwrap();
drop(pool);
let storage = Storage::new(&db_path).await.unwrap();
let run = storage.get_agent_run("run-1").await.unwrap();
assert!(
run.is_some(),
"v8 agent run must survive the current upgrade without a rebuild"
);
assert_eq!(run.unwrap().status.as_str(), "completed");
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(version, SCHEMA_VERSION);
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'agent_run_messages'",
)
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(exists, 1, "agent_run_messages table must be created");
}
#[tokio::test]
async fn test_upsert_and_get_session() {
let (storage, _dir) = create_test_storage().await;
let meta = crate::storage::session::SessionMeta {
id: "cli_chat:sid123:dialog1".to_string(),
channel: "cli_chat".to_string(),
chat_id: "sid123".to_string(),
dialog_id: "dialog1".to_string(),
title: "测试会话".to_string(),
created_at: 1000,
last_active_at: 1000,
message_count: 0,
routing_info: Some(r#"{"type":"cli"}"#.to_string()),
archived_at: None,
deleted_at: None,
last_consolidated_at: None,
last_compressed_message_at: None,
};
storage.upsert_session(&meta).await.unwrap();
let loaded = storage.get_session(&meta.id).await.unwrap();
assert_eq!(loaded.title, "测试会话");
assert_eq!(loaded.channel, "cli_chat");
}
#[tokio::test]
async fn test_get_nonexistent_session() {
let (storage, _dir) = create_test_storage().await;
let result = storage.get_session("nonexistent").await;
assert!(result.is_err());
matches!(result.unwrap_err(), StorageError::NotFound(_));
}
#[tokio::test]
async fn test_list_sessions() {
let (storage, _dir) = create_test_storage().await;
for i in 0..5 {
let meta = crate::storage::session::SessionMeta {
id: format!("cli_chat:sid123:dialog{}", i),
channel: "cli_chat".to_string(),
chat_id: "sid123".to_string(),
dialog_id: format!("dialog{}", i),
title: format!("会话{}", i),
created_at: i * 1000,
last_active_at: i * 1000,
message_count: i,
routing_info: None,
archived_at: None,
deleted_at: None,
last_consolidated_at: None,
last_compressed_message_at: None,
};
storage.upsert_session(&meta).await.unwrap();
}
let sessions = storage
.list_sessions("cli_chat", "sid123", 10, false)
.await
.unwrap();
assert_eq!(sessions.len(), 5);
// 按 last_active_at DESC 排序
assert_eq!(sessions[0].dialog_id, "dialog4");
}
#[tokio::test]
async fn test_soft_delete() {
let (storage, _dir) = create_test_storage().await;
let meta = crate::storage::session::SessionMeta {
id: "cli_chat:sid123:dialog1".to_string(),
channel: "cli_chat".to_string(),
chat_id: "sid123".to_string(),
dialog_id: "dialog1".to_string(),
title: "测试".to_string(),
created_at: 1000,
last_active_at: 1000,
message_count: 0,
routing_info: None,
archived_at: None,
deleted_at: None,
last_consolidated_at: None,
last_compressed_message_at: None,
};
storage.upsert_session(&meta).await.unwrap();
storage.soft_delete_session(&meta.id).await.unwrap();
let result = storage.get_session(&meta.id).await;
assert!(result.is_err());
matches!(result.unwrap_err(), StorageError::NotFound(_));
}
#[tokio::test]
async fn test_append_and_load_messages() {
let (storage, _dir) = create_test_storage().await;
let session_meta = crate::storage::session::SessionMeta {
id: "cli_chat:sid123:dialog1".to_string(),
channel: "cli_chat".to_string(),
chat_id: "sid123".to_string(),
dialog_id: "dialog1".to_string(),
title: "测试".to_string(),
created_at: 1000,
last_active_at: 1000,
message_count: 0,
routing_info: None,
archived_at: None,
deleted_at: None,
last_consolidated_at: None,
last_compressed_message_at: None,
};
storage.upsert_session(&session_meta).await.unwrap();
let msg = crate::storage::message::MessageMeta {
id: "msg1".to_string(),
session_id: session_meta.id.clone(),
seq: 1,
role: "user".to_string(),
content: "你好".to_string(),
reasoning_content: None,
provider_state: None,
turn_id: None,
iteration: None,
completion_status: crate::bus::CompletionStatus::Completed,
client_visibility: crate::bus::ClientVisibility::Visible,
turn_origin: crate::bus::TurnOrigin::User,
media_refs: None,
tool_call_id: None,
tool_name: None,
tool_calls: None,
source: None,
created_at: 1000,
};
let seq = storage
.append_message(&session_meta.id, &msg)
.await
.unwrap();
assert_eq!(seq, 1);
let loaded = storage.load_messages(&session_meta.id, 0).await.unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].content, "你好");
for seq in 2..=5 {
let mut message = msg.clone();
message.id = format!("msg{seq}");
message.seq = seq;
message.content = format!("message {seq}");
storage
.append_message(&session_meta.id, &message)
.await
.unwrap();
}
let recent = storage
.load_recent_session_messages(&session_meta.id, 2)
.await
.unwrap();
assert_eq!(recent.len(), 2);
assert_eq!(recent[0].seq, 4);
assert_eq!(recent[1].seq, 5);
}
#[tokio::test]
async fn streaming_message_metadata_round_trips() {
let (storage, _dir) = create_test_storage().await;
let session_meta = crate::storage::session::SessionMeta {
id: "cli_chat:stream:dialog1".to_string(),
channel: "cli_chat".to_string(),
chat_id: "stream".to_string(),
dialog_id: "dialog1".to_string(),
title: "Stream metadata".to_string(),
created_at: 1000,
last_active_at: 1000,
message_count: 0,
routing_info: None,
archived_at: None,
deleted_at: None,
last_consolidated_at: None,
last_compressed_message_at: None,
};
storage.upsert_session(&session_meta).await.unwrap();
let message = crate::storage::message::MessageMeta {
id: "assistant-1".to_string(),
session_id: session_meta.id.clone(),
seq: 1,
role: "assistant".to_string(),
content: "partial".to_string(),
reasoning_content: Some("visible reasoning".to_string()),
provider_state: Some(
serde_json::json!({"provider":"anthropic","payload":{"signature":"opaque"}})
.to_string(),
),
turn_id: Some("turn-1".to_string()),
iteration: Some(2),
completion_status: crate::bus::CompletionStatus::Interrupted,
client_visibility: crate::bus::ClientVisibility::Visible,
turn_origin: crate::bus::TurnOrigin::User,
media_refs: None,
tool_call_id: None,
tool_name: None,
tool_calls: None,
source: None,
created_at: 1001,
};
storage
.append_message(&session_meta.id, &message)
.await
.unwrap();
let loaded = storage.load_messages(&session_meta.id, 0).await.unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(
loaded[0].reasoning_content.as_deref(),
Some("visible reasoning")
);
assert_eq!(loaded[0].provider_state, message.provider_state);
assert_eq!(loaded[0].turn_id.as_deref(), Some("turn-1"));
assert_eq!(loaded[0].iteration, Some(2));
assert_eq!(
loaded[0].completion_status,
crate::bus::CompletionStatus::Interrupted
);
}
#[tokio::test]
async fn test_persist_message_batch_is_atomic() {
let (storage, _dir) = create_test_storage().await;
let session_meta = crate::storage::session::SessionMeta {
id: "cli_chat:atomic:dialog1".to_string(),
channel: "cli_chat".to_string(),
chat_id: "atomic".to_string(),
dialog_id: "dialog1".to_string(),
title: "Atomic turn".to_string(),
created_at: 1000,
last_active_at: 2000,
message_count: 1,
routing_info: None,
archived_at: None,
deleted_at: None,
last_consolidated_at: None,
last_compressed_message_at: None,
};
storage.upsert_session(&session_meta).await.unwrap();
let message = crate::storage::message::MessageMeta {
id: "duplicate-id".to_string(),
session_id: session_meta.id.clone(),
seq: 1,
role: "assistant".to_string(),
content: "must roll back".to_string(),
reasoning_content: None,
provider_state: None,
turn_id: None,
iteration: None,
completion_status: crate::bus::CompletionStatus::Completed,
client_visibility: crate::bus::ClientVisibility::Visible,
turn_origin: crate::bus::TurnOrigin::User,
media_refs: None,
tool_call_id: None,
tool_name: None,
tool_calls: None,
source: None,
created_at: 2000,
};
let result = storage
.persist_message_batch(&session_meta.id, &[message.clone(), message], &session_meta)
.await;
assert!(result.is_err());
assert!(
storage
.load_messages(&session_meta.id, 0)
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn test_touch_session() {
let (storage, _dir) = create_test_storage().await;
let meta = crate::storage::session::SessionMeta {
id: "cli_chat:sid123:dialog1".to_string(),
channel: "cli_chat".to_string(),
chat_id: "sid123".to_string(),
dialog_id: "dialog1".to_string(),
title: "测试".to_string(),
created_at: 1000,
last_active_at: 1000,
message_count: 0,
routing_info: None,
archived_at: None,
deleted_at: None,
last_consolidated_at: None,
last_compressed_message_at: None,
};
storage.upsert_session(&meta).await.unwrap();
storage.touch_session(&meta.id, 5, 2000).await.unwrap();
let loaded = storage.get_session(&meta.id).await.unwrap();
assert_eq!(loaded.message_count, 5);
assert_eq!(loaded.last_active_at, 2000);
}
#[tokio::test]
async fn durable_delivery_context_round_trips() {
let (storage, _dir) = create_test_storage().await;
let meta = crate::storage::session::SessionMeta {
id: "feishu:chat-1:dialog".to_string(),
channel: "feishu".to_string(),
chat_id: "chat-1".to_string(),
dialog_id: "dialog".to_string(),
title: "t".to_string(),
created_at: 1,
last_active_at: 2,
message_count: 0,
routing_info: None,
archived_at: None,
deleted_at: None,
last_consolidated_at: None,
last_compressed_message_at: None,
};
storage.upsert_session(&meta).await.unwrap();
assert!(
storage
.get_session_delivery_context("feishu:chat-1:dialog")
.await
.unwrap()
.is_none()
);
let context = serde_json::json!({
"feishu.thread_id": "thread-9",
"feishu.chat_type": "group",
})
.to_string();
storage
.update_session_delivery_context("feishu:chat-1:dialog", &context, 50)
.await
.unwrap();
let loaded = storage
.get_session_delivery_context("feishu:chat-1:dialog")
.await
.unwrap()
.unwrap();
let parsed: std::collections::HashMap<String, String> =
serde_json::from_str(&loaded).unwrap();
assert_eq!(parsed.get("feishu.thread_id").unwrap(), "thread-9");
}
}