feat(users): UserManager with per-user SQLCipher, and extract skald-core crate
Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.
## UserManager + per-user encryption (§9/§11)
New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.
New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).
- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
<0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
`create_owner_tables` (one owner's content, identical in every file). No FK in
the owner bucket may reach the registry — enforced by a standalone test.
Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
so a crash leaves an orphan file, never a user without a database. `open_db`
never creates: a missing file is an error, not a silent empty database.
Not consumed yet: no login, call sites still use the shared system.db pool.
## Extract crates/skald-core
The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:
- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
(sibling of `http_router`), so the core no longer downcasts to
`MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
teardown-and-respawn; the core defaults to the supervisor exit code. The core
loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
only emits tracing events.
All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::approval::{ApprovalRule, NewApprovalRule, RuleAction};
|
||||
|
||||
type RawRow = (i64, Option<String>, Option<String>, String, Option<String>, String, Option<String>, i64, Option<String>);
|
||||
|
||||
fn from_raw((id, agent_id, source, tool_pattern, path_pattern, action, note, priority, group_id): RawRow)
|
||||
-> anyhow::Result<ApprovalRule>
|
||||
{
|
||||
let action: RuleAction = action.parse()?;
|
||||
Ok(ApprovalRule { id, agent_id, source, tool_pattern, path_pattern, action, note, priority, group_id })
|
||||
}
|
||||
|
||||
/// Returns all rules ordered by priority ASC (lowest number = evaluated first).
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<ApprovalRule>> {
|
||||
let rows = sqlx::query_as::<_, RawRow>(
|
||||
"SELECT id, agent_id, source, tool_pattern, path_pattern, action, note, priority, group_id
|
||||
FROM approval_rules
|
||||
ORDER BY priority ASC, id ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(from_raw).collect()
|
||||
}
|
||||
|
||||
/// Returns rules applicable to `group_id`: group-specific first, then 'default' as fallback.
|
||||
/// If `group_id` is `None` or equals `"default"`, only default rules are returned.
|
||||
pub async fn list_for_group(pool: &SqlitePool, group_id: Option<&str>) -> Result<Vec<ApprovalRule>> {
|
||||
let effective = group_id.unwrap_or("default");
|
||||
let rows = sqlx::query_as::<_, RawRow>(
|
||||
"SELECT id, agent_id, source, tool_pattern, path_pattern, action, note, priority, group_id
|
||||
FROM approval_rules
|
||||
WHERE group_id = ?1 OR group_id = 'default'
|
||||
ORDER BY CASE WHEN group_id = ?1 THEN 0 ELSE 1 END, priority ASC, id ASC",
|
||||
)
|
||||
.bind(effective)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(from_raw).collect()
|
||||
}
|
||||
|
||||
/// Inserts a new rule; returns its id.
|
||||
pub async fn insert(pool: &SqlitePool, r: NewApprovalRule) -> Result<i64> {
|
||||
let priority = r.priority.unwrap_or(100);
|
||||
let group_id = r.group_id.as_deref().unwrap_or("default");
|
||||
let id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO approval_rules (agent_id, source, tool_pattern, path_pattern, action, note, priority, group_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(r.agent_id)
|
||||
.bind(r.source)
|
||||
.bind(r.tool_pattern)
|
||||
.bind(r.path_pattern)
|
||||
.bind(r.action.as_str())
|
||||
.bind(r.note)
|
||||
.bind(priority)
|
||||
.bind(group_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Updates an existing rule by id.
|
||||
pub async fn update(pool: &SqlitePool, id: i64, r: NewApprovalRule) -> Result<()> {
|
||||
let priority = r.priority.unwrap_or(100);
|
||||
let group_id = r.group_id.as_deref().unwrap_or("default");
|
||||
sqlx::query(
|
||||
"UPDATE approval_rules
|
||||
SET agent_id = ?, source = ?, tool_pattern = ?, path_pattern = ?, action = ?, note = ?, priority = ?, group_id = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(r.agent_id)
|
||||
.bind(r.source)
|
||||
.bind(r.tool_pattern)
|
||||
.bind(r.path_pattern)
|
||||
.bind(r.action.as_str())
|
||||
.bind(r.note)
|
||||
.bind(priority)
|
||||
.bind(group_id)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes a rule by id.
|
||||
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<()> {
|
||||
sqlx::query("DELETE FROM approval_rules WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use core_api::message_meta::MessageMetadata;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
User,
|
||||
Assistant,
|
||||
/// Invocation message from a calling agent to a sub-agent; mapped to `user`
|
||||
/// when rebuilding LLM context, invisible in the UI.
|
||||
Agent,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::Agent => "agent",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> anyhow::Result<Self> {
|
||||
match s {
|
||||
"user" => Ok(Role::User),
|
||||
"assistant" => Ok(Role::Assistant),
|
||||
"agent" => Ok(Role::Agent),
|
||||
other => anyhow::bail!("Unknown role: {other}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatMessage {
|
||||
pub id: i64,
|
||||
pub role: Role,
|
||||
pub content: String,
|
||||
pub status: String,
|
||||
pub input_tokens: Option<i64>,
|
||||
pub output_tokens: Option<i64>,
|
||||
/// True for messages injected synthetically (e.g. TIC notifications) — not
|
||||
/// typed by a real user. Stored in DB so the UI can skip them on reload.
|
||||
pub is_synthetic: bool,
|
||||
/// Chain-of-thought from reasoning models (e.g. DeepSeek thinking mode).
|
||||
/// Null for all other providers.
|
||||
pub reasoning_content: Option<String>,
|
||||
/// Cost of the turn in USD, when the provider reports it (OpenRouter).
|
||||
/// Null for providers that don't bill per-request.
|
||||
pub cost: Option<f64>,
|
||||
/// Generic structured metadata (JSON column): file attachments today,
|
||||
/// extensible later. `None` when the row has no metadata.
|
||||
pub metadata: Option<MessageMetadata>,
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Raw row tuple for the shared `SELECT` projection. sqlx 0.9 requires SQL to be
|
||||
/// `&'static str`, so the column list is repeated literally in each query below;
|
||||
/// keep it in sync with this tuple and [`row_to_message`].
|
||||
type Row = (
|
||||
i64, String, String, String, Option<i64>, Option<i64>, bool,
|
||||
Option<String>, Option<f64>, Option<String>, Option<String>,
|
||||
);
|
||||
|
||||
/// Maps a [`Row`] into a [`ChatMessage`]. Metadata that fails to parse is treated
|
||||
/// as absent (defensive: a malformed blob must not break history loading).
|
||||
fn row_to_message(r: Row) -> anyhow::Result<ChatMessage> {
|
||||
let (id, role, content, status, input_tokens, output_tokens, is_synthetic, reasoning_content, cost, metadata, created_at) = r;
|
||||
Ok(ChatMessage {
|
||||
id,
|
||||
role: Role::from_str(&role)?,
|
||||
content,
|
||||
status,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
is_synthetic,
|
||||
reasoning_content,
|
||||
cost,
|
||||
metadata: metadata.and_then(|s| serde_json::from_str(&s).ok()),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Appends a message with no structured metadata (the common case).
|
||||
pub async fn append(
|
||||
pool: &SqlitePool,
|
||||
session_stack_id: i64,
|
||||
role: &Role,
|
||||
content: &str,
|
||||
is_synthetic: bool,
|
||||
reasoning_content: Option<&str>,
|
||||
) -> anyhow::Result<i64> {
|
||||
append_with_metadata(pool, session_stack_id, role, content, is_synthetic, reasoning_content, None).await
|
||||
}
|
||||
|
||||
/// Like [`append`] but persists optional structured [`MessageMetadata`] (e.g. file
|
||||
/// attachments) as a JSON blob. Empty metadata is stored as `NULL`.
|
||||
pub async fn append_with_metadata(
|
||||
pool: &SqlitePool,
|
||||
session_stack_id: i64,
|
||||
role: &Role,
|
||||
content: &str,
|
||||
is_synthetic: bool,
|
||||
reasoning_content: Option<&str>,
|
||||
metadata: Option<&MessageMetadata>,
|
||||
) -> anyhow::Result<i64> {
|
||||
let metadata_json = metadata
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(serde_json::to_string)
|
||||
.transpose()?;
|
||||
let id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO chat_history (session_stack_id, role, content, is_synthetic, reasoning_content, metadata) \
|
||||
VALUES (?, ?, ?, ?, ?, ?) RETURNING id",
|
||||
)
|
||||
.bind(session_stack_id)
|
||||
.bind(role.as_str())
|
||||
.bind(content)
|
||||
.bind(is_synthetic as i64)
|
||||
.bind(reasoning_content)
|
||||
.bind(metadata_json)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn mark_failed(pool: &SqlitePool, id: i64) -> anyhow::Result<()> {
|
||||
sqlx::query("UPDATE chat_history SET status = 'failed' WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_usage(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
duration_ms: u64,
|
||||
cost: Option<f64>,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE chat_history
|
||||
SET input_tokens = ?, output_tokens = ?, duration_ms = ?, cost = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(input_tokens as i64)
|
||||
.bind(output_tokens as i64)
|
||||
.bind(duration_ms as i64)
|
||||
.bind(cost)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All ok messages for a stack frame, ordered chronologically.
|
||||
/// Used to rebuild LLM context for a specific agent.
|
||||
pub async fn for_stack(
|
||||
pool: &SqlitePool,
|
||||
session_stack_id: i64,
|
||||
) -> anyhow::Result<Vec<ChatMessage>> {
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT id, role, content, status, input_tokens, output_tokens, is_synthetic, reasoning_content, cost, metadata, created_at
|
||||
FROM chat_history
|
||||
WHERE session_stack_id = ? AND status = 'ok'
|
||||
ORDER BY id ASC",
|
||||
)
|
||||
.bind(session_stack_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(row_to_message).collect()
|
||||
}
|
||||
|
||||
/// All messages for a stack frame including failed ones, ordered chronologically.
|
||||
/// Used by the UI history API so the user can see cancelled messages.
|
||||
pub async fn for_stack_all(
|
||||
pool: &SqlitePool,
|
||||
session_stack_id: i64,
|
||||
) -> anyhow::Result<Vec<ChatMessage>> {
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT id, role, content, status, input_tokens, output_tokens, is_synthetic, reasoning_content, cost, metadata, created_at
|
||||
FROM chat_history
|
||||
WHERE session_stack_id = ?
|
||||
ORDER BY id ASC",
|
||||
)
|
||||
.bind(session_stack_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(row_to_message).collect()
|
||||
}
|
||||
|
||||
/// Ok messages for a stack frame whose id is strictly greater than `after_id`,
|
||||
/// ordered chronologically. Used by `build_openai_messages` when a compaction
|
||||
/// summary exists: only the "raw" messages after the summary boundary are loaded.
|
||||
pub async fn for_stack_since(
|
||||
pool: &SqlitePool,
|
||||
session_stack_id: i64,
|
||||
after_id: i64,
|
||||
) -> anyhow::Result<Vec<ChatMessage>> {
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT id, role, content, status, input_tokens, output_tokens, is_synthetic, reasoning_content, cost, metadata, created_at
|
||||
FROM chat_history
|
||||
WHERE session_stack_id = ? AND status = 'ok' AND id > ?
|
||||
ORDER BY id ASC",
|
||||
)
|
||||
.bind(session_stack_id)
|
||||
.bind(after_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(row_to_message).collect()
|
||||
}
|
||||
|
||||
/// Returns the most recent ok message for a stack frame, or `None` if empty.
|
||||
/// Used by Telegram's `/context` command to show last turn's token usage.
|
||||
pub async fn last_message_for_stack(
|
||||
pool: &SqlitePool,
|
||||
session_stack_id: i64,
|
||||
) -> anyhow::Result<Option<ChatMessage>> {
|
||||
let row = sqlx::query_as::<_, Row>(
|
||||
"SELECT id, role, content, status, input_tokens, output_tokens, is_synthetic, reasoning_content, cost, metadata, created_at
|
||||
FROM chat_history
|
||||
WHERE session_stack_id = ? AND status = 'ok'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(session_stack_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(row_to_message).transpose()
|
||||
}
|
||||
|
||||
/// Total cost (USD) of a whole session: all messages across every stack frame
|
||||
/// (main + sync sub-agents) that share this `session_id`. Async tasks live in
|
||||
/// their own session and are naturally excluded. Returns `None` when no message
|
||||
/// has a recorded cost (e.g. the provider does not report per-request pricing).
|
||||
///
|
||||
/// No `status` filter: money is spent even on turns later marked `failed`, so the
|
||||
/// total reflects real spend. Uses plain `SUM(cost)` so an all-NULL set yields
|
||||
/// `None`, distinguishing "no cost data" from a genuine `$0.00`.
|
||||
pub async fn total_cost_for_session(
|
||||
pool: &SqlitePool,
|
||||
session_id: i64,
|
||||
) -> anyhow::Result<Option<f64>> {
|
||||
let total: Option<f64> = sqlx::query_scalar(
|
||||
"SELECT SUM(ch.cost)
|
||||
FROM chat_history ch
|
||||
JOIN chat_sessions_stack css ON ch.session_stack_id = css.id
|
||||
WHERE css.session_id = ?",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Rough token estimate for a stack frame (sum of content lengths / 4).
|
||||
/// Used as a fallback when the LLM provider does not return usage data.
|
||||
pub async fn estimate_tokens_for_stack(
|
||||
pool: &SqlitePool,
|
||||
session_stack_id: i64,
|
||||
) -> anyhow::Result<u32> {
|
||||
let total_chars: i64 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(SUM(LENGTH(content)), 0)
|
||||
FROM chat_history
|
||||
WHERE session_stack_id = ? AND status = 'ok'",
|
||||
)
|
||||
.bind(session_stack_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok((total_chars / 4).max(0) as u32)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LlmToolCall {
|
||||
pub id: i64,
|
||||
pub message_id: i64,
|
||||
pub name: String,
|
||||
pub arguments: Option<String>,
|
||||
pub result: Option<String>,
|
||||
/// Result type tag: `"string"` (plain text, default) or `"json"` (structured
|
||||
/// payload, e.g. MCP `structuredContent`). Drives frontend rendering.
|
||||
pub result_type: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// Inserts a tool call in `running` state and returns its id.
|
||||
/// `message_id` is the assistant `chat_history` row that triggered the call.
|
||||
pub async fn append(
|
||||
pool: &SqlitePool,
|
||||
message_id: i64,
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
) -> anyhow::Result<i64> {
|
||||
let id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO chat_llm_tools (message_id, name, arguments, status) VALUES (?, ?, ?, 'running') RETURNING id",
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(name)
|
||||
.bind(arguments)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Marks a tool call as `pending` (waiting for explicit user approval or clarification).
|
||||
/// Called just before registering an approval/clarification channel so `'pending'`
|
||||
/// in the DB means "blocked on user input", not "still executing".
|
||||
pub async fn set_approval_pending(pool: &SqlitePool, id: i64) -> anyhow::Result<()> {
|
||||
sqlx::query("UPDATE chat_llm_tools SET status='pending' WHERE id=?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn complete(pool: &SqlitePool, id: i64, result: &str, result_type: &str) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE chat_llm_tools SET result = ?, result_type = ?, status = 'done' WHERE id = ?",
|
||||
)
|
||||
.bind(result)
|
||||
.bind(result_type)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fail(pool: &SqlitePool, id: i64, error: &str) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE chat_llm_tools SET result = ?, status = 'failed' WHERE id = ?",
|
||||
)
|
||||
.bind(error)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marks a tool call as `cancelled` — stopped by the user via `/stop`.
|
||||
/// Terminal and distinct from `failed`: a cancellation is deliberate, not an
|
||||
/// error, and is **not** picked up by `pending_for_stack` (never re-run on
|
||||
/// restart, unlike an interrupted `running` call).
|
||||
pub async fn cancel(pool: &SqlitePool, id: i64, note: &str) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE chat_llm_tools SET result = ?, status = 'cancelled' WHERE id = ?",
|
||||
)
|
||||
.bind(note)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marks a tool call as `rejected` — denied by an approval policy or a human.
|
||||
/// Terminal and distinct from `failed`: a denial is a policy decision, not an
|
||||
/// error, and is not re-run on restart.
|
||||
pub async fn reject(pool: &SqlitePool, id: i64, reason: &str) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE chat_llm_tools SET result = ?, status = 'rejected' WHERE id = ?",
|
||||
)
|
||||
.bind(reason)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All `running` or `pending` tool calls for a stack frame — used to resume interrupted sessions.
|
||||
/// `running`: tool was executing when the session was interrupted (re-execute).
|
||||
/// `pending`: tool was waiting for explicit user approval or clarification (re-gate or re-ask).
|
||||
pub async fn pending_for_stack(
|
||||
pool: &SqlitePool,
|
||||
session_stack_id: i64,
|
||||
) -> anyhow::Result<Vec<LlmToolCall>> {
|
||||
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String)>(
|
||||
"SELECT t.id, t.message_id, t.name, t.arguments, t.result, t.result_type, t.status
|
||||
FROM chat_llm_tools t
|
||||
JOIN chat_history h ON t.message_id = h.id
|
||||
WHERE h.session_stack_id = ?
|
||||
AND t.status IN ('running', 'pending')
|
||||
ORDER BY t.id ASC",
|
||||
)
|
||||
.bind(session_stack_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(row_to_tool).collect())
|
||||
}
|
||||
|
||||
/// All tool calls for a single assistant message, ordered chronologically.
|
||||
pub async fn for_message(
|
||||
pool: &SqlitePool,
|
||||
message_id: i64,
|
||||
) -> anyhow::Result<Vec<LlmToolCall>> {
|
||||
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String)>(
|
||||
"SELECT id, message_id, name, arguments, result, result_type, status
|
||||
FROM chat_llm_tools
|
||||
WHERE message_id = ?
|
||||
ORDER BY id ASC",
|
||||
)
|
||||
.bind(message_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(row_to_tool).collect())
|
||||
}
|
||||
|
||||
fn row_to_tool(
|
||||
(id, message_id, name, arguments, result, result_type, status): (
|
||||
i64, i64, String, Option<String>, Option<String>, String, String,
|
||||
),
|
||||
) -> LlmToolCall {
|
||||
LlmToolCall { id, message_id, name, arguments, result, result_type, status }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct ChatSession {
|
||||
pub id: i64,
|
||||
pub source: String,
|
||||
pub agent_id: String,
|
||||
/// True when a real user is actively participating (web, telegram).
|
||||
/// False for fully automated sessions (cron, tic).
|
||||
pub is_interactive: bool,
|
||||
/// True for short-lived task sessions (cron, tic) with no long-term
|
||||
/// conversational value. May be used to skip memory / analytics sinks.
|
||||
pub is_ephemeral: bool,
|
||||
/// Optional RunContext JSON blob assigned to this session.
|
||||
/// `None` resolves to the implicit "default" run_context at runtime.
|
||||
pub run_context: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
agent_id: &str,
|
||||
source: &str,
|
||||
is_interactive: bool,
|
||||
is_ephemeral: bool,
|
||||
) -> anyhow::Result<ChatSession> {
|
||||
let id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO chat_sessions (source, agent_id, is_interactive, is_ephemeral)
|
||||
VALUES (?, ?, ?, ?) RETURNING id",
|
||||
)
|
||||
.bind(source)
|
||||
.bind(agent_id)
|
||||
.bind(is_interactive as i64)
|
||||
.bind(is_ephemeral as i64)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(ChatSession {
|
||||
id,
|
||||
source: source.to_string(),
|
||||
agent_id: agent_id.to_string(),
|
||||
is_interactive,
|
||||
is_ephemeral,
|
||||
run_context: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_run_context(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
run_context: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query("UPDATE chat_sessions SET run_context = ? WHERE id = ?")
|
||||
.bind(run_context)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_by_id(pool: &SqlitePool, id: i64) -> anyhow::Result<Option<ChatSession>> {
|
||||
let row = sqlx::query_as::<_, (i64, String, String, bool, bool, Option<String>)>(
|
||||
"SELECT id, source, agent_id, is_interactive, is_ephemeral, run_context
|
||||
FROM chat_sessions WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|(id, source, agent_id, is_interactive, is_ephemeral, run_context)| ChatSession {
|
||||
id,
|
||||
source,
|
||||
agent_id,
|
||||
is_interactive,
|
||||
is_ephemeral,
|
||||
run_context,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionStack {
|
||||
pub id: i64,
|
||||
pub agent_id: String,
|
||||
pub depth: i64,
|
||||
pub parent_tool_call_id: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
session_id: i64,
|
||||
agent_id: &str,
|
||||
agent_prompt: Option<&str>,
|
||||
depth: i64,
|
||||
parent_tool_call_id: Option<i64>,
|
||||
) -> anyhow::Result<SessionStack> {
|
||||
let id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO chat_sessions_stack (session_id, agent_id, agent_prompt, depth, parent_tool_call_id)
|
||||
VALUES (?, ?, ?, ?, ?) RETURNING id",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(agent_id)
|
||||
.bind(agent_prompt)
|
||||
.bind(depth)
|
||||
.bind(parent_tool_call_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(SessionStack { id, agent_id: agent_id.to_string(), depth, parent_tool_call_id })
|
||||
}
|
||||
|
||||
/// Returns the deepest active (non-terminated) frame for a session.
|
||||
pub async fn active_for_session(
|
||||
pool: &SqlitePool,
|
||||
session_id: i64,
|
||||
) -> anyhow::Result<Option<SessionStack>> {
|
||||
let row = sqlx::query_as::<_, (i64, String, i64, Option<i64>)>(
|
||||
"SELECT id, agent_id, depth, parent_tool_call_id
|
||||
FROM chat_sessions_stack
|
||||
WHERE session_id = ?
|
||||
AND terminated_at IS NULL
|
||||
ORDER BY depth DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(row_to_stack))
|
||||
}
|
||||
|
||||
/// All active (non-terminated) frames for a session, ordered by depth ASC.
|
||||
/// Used by restart recovery to detect an interrupted parallel sub-agent batch:
|
||||
/// a purely linear stack has at most one active frame per depth, so ≥2 active
|
||||
/// frames at the same depth can only be a concurrent batch left mid-flight.
|
||||
pub async fn active_all_for_session(
|
||||
pool: &SqlitePool,
|
||||
session_id: i64,
|
||||
) -> anyhow::Result<Vec<SessionStack>> {
|
||||
let rows = sqlx::query_as::<_, (i64, String, i64, Option<i64>)>(
|
||||
"SELECT id, agent_id, depth, parent_tool_call_id
|
||||
FROM chat_sessions_stack
|
||||
WHERE session_id = ?
|
||||
AND terminated_at IS NULL
|
||||
ORDER BY depth ASC",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(row_to_stack).collect())
|
||||
}
|
||||
|
||||
/// Returns the root (depth=0) stack frame for a session.
|
||||
pub async fn main_for_session(
|
||||
pool: &SqlitePool,
|
||||
session_id: i64,
|
||||
) -> anyhow::Result<Option<SessionStack>> {
|
||||
let row = sqlx::query_as::<_, (i64, String, i64, Option<i64>)>(
|
||||
"SELECT id, agent_id, depth, parent_tool_call_id
|
||||
FROM chat_sessions_stack
|
||||
WHERE session_id = ? AND depth = 0
|
||||
ORDER BY id ASC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(row_to_stack))
|
||||
}
|
||||
|
||||
/// Returns all stack frames for a session (including terminated), ordered by id ASC.
|
||||
/// Used to reconstruct the full agent call tree from history.
|
||||
pub async fn all_for_session(
|
||||
pool: &SqlitePool,
|
||||
session_id: i64,
|
||||
) -> anyhow::Result<Vec<SessionStack>> {
|
||||
let rows = sqlx::query_as::<_, (i64, String, i64, Option<i64>)>(
|
||||
"SELECT id, agent_id, depth, parent_tool_call_id
|
||||
FROM chat_sessions_stack
|
||||
WHERE session_id = ?
|
||||
ORDER BY id ASC",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(row_to_stack).collect())
|
||||
}
|
||||
|
||||
pub async fn find_by_id(pool: &SqlitePool, id: i64) -> anyhow::Result<Option<SessionStack>> {
|
||||
let row = sqlx::query_as::<_, (i64, String, i64, Option<i64>)>(
|
||||
"SELECT id, agent_id, depth, parent_tool_call_id
|
||||
FROM chat_sessions_stack
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(row_to_stack))
|
||||
}
|
||||
|
||||
/// Marks a stack frame as terminated (agent completed or was cancelled).
|
||||
pub async fn terminate(pool: &SqlitePool, id: i64) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE chat_sessions_stack SET terminated_at = datetime('now') WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn row_to_stack(
|
||||
(id, agent_id, depth, parent_tool_call_id): (i64, String, i64, Option<i64>),
|
||||
) -> SessionStack {
|
||||
SessionStack { id, agent_id, depth, parent_tool_call_id }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_db_path(tag: &str) -> String {
|
||||
let mut p = std::env::temp_dir();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
p.push(format!("skald-test-{tag}-{}-{nanos}.db", std::process::id()));
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn cleanup(path: &str) {
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{path}{suffix}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates `active_all_for_session` against the real schema: two active
|
||||
/// frames at the same depth are the signature of an interrupted parallel
|
||||
/// sub-agent batch, and terminating one drops it from the active set.
|
||||
#[tokio::test]
|
||||
async fn active_all_reflects_parallel_siblings() {
|
||||
let path = temp_db_path("stack-parallel");
|
||||
let pool = crate::db::init_system_pool(&path).await.unwrap();
|
||||
let sid = 1;
|
||||
// `session_id` has a FK to chat_sessions (sqlx enables foreign_keys).
|
||||
sqlx::query("INSERT INTO chat_sessions (id) VALUES (?)")
|
||||
.bind(sid).execute(&pool).await.unwrap();
|
||||
|
||||
create(&pool, sid, "main", None, 0, None).await.unwrap();
|
||||
let a = create(&pool, sid, "task", Some("A"), 1, Some(101)).await.unwrap();
|
||||
create(&pool, sid, "task", Some("B"), 1, Some(102)).await.unwrap();
|
||||
|
||||
let active = active_all_for_session(&pool, sid).await.unwrap();
|
||||
assert_eq!(active.len(), 3, "root + two live siblings");
|
||||
assert_eq!(active.iter().filter(|f| f.depth == 1).count(), 2, "two frames share depth 1");
|
||||
|
||||
// Terminating one sibling removes it from the active set (used by reap).
|
||||
terminate(&pool, a.id).await.unwrap();
|
||||
let active = active_all_for_session(&pool, sid).await.unwrap();
|
||||
assert_eq!(active.len(), 2);
|
||||
assert!(active.iter().all(|f| f.id != a.id), "terminated frame is excluded");
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Persistent conversation summaries generated by the context compactor.
|
||||
//!
|
||||
//! Each row covers all `chat_history` messages up to and including
|
||||
//! `covers_up_to_message_id`. At most one active summary exists per stack;
|
||||
//! a new compaction creates a new row that supersedes the previous one.
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatSummary {
|
||||
pub id: i64,
|
||||
pub stack_id: i64,
|
||||
pub content: String,
|
||||
/// All chat_history rows with `id <= covers_up_to_message_id` are covered
|
||||
/// by this summary. `build_openai_messages` loads only rows *after* this id.
|
||||
pub covers_up_to_message_id: i64,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Persist a new summary for the given stack, returning the new row id.
|
||||
pub async fn save(
|
||||
pool: &SqlitePool,
|
||||
stack_id: i64,
|
||||
content: &str,
|
||||
covers_up_to_message_id: i64,
|
||||
) -> anyhow::Result<i64> {
|
||||
let id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO chat_summaries
|
||||
(stack_id, content, covers_up_to_message_id)
|
||||
VALUES (?, ?, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(stack_id)
|
||||
.bind(content)
|
||||
.bind(covers_up_to_message_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Returns the most recent summary for a stack, or `None` if none exists.
|
||||
pub async fn latest_for_stack(
|
||||
pool: &SqlitePool,
|
||||
stack_id: i64,
|
||||
) -> anyhow::Result<Option<ChatSummary>> {
|
||||
let row = sqlx::query_as::<_, (i64, i64, String, i64, String)>(
|
||||
"SELECT id, stack_id, content, covers_up_to_message_id, created_at
|
||||
FROM chat_summaries
|
||||
WHERE stack_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(stack_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|(id, stack_id, content, covers_up_to_message_id, created_at)| ChatSummary {
|
||||
id,
|
||||
stack_id,
|
||||
content,
|
||||
covers_up_to_message_id,
|
||||
created_at,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct ConfigEntry {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Get a config value by key.
|
||||
pub async fn get(pool: &SqlitePool, key: &str) -> anyhow::Result<Option<String>> {
|
||||
let row = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT value FROM config WHERE key = ?",
|
||||
)
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|(v,)| v))
|
||||
}
|
||||
|
||||
/// Upsert a config key/value pair.
|
||||
pub async fn set(pool: &SqlitePool, key: &str, value: &str) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO config (key, value, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a config entry.
|
||||
pub async fn delete(pool: &SqlitePool, key: &str) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM config WHERE key = ?")
|
||||
.bind(key)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct JobRun {
|
||||
pub id: i64,
|
||||
pub job_id: i64,
|
||||
pub session_id: Option<i64>,
|
||||
pub started_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
pub duration_ms: Option<i64>,
|
||||
pub status: String,
|
||||
pub final_response: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
pub async fn insert(
|
||||
pool: &SqlitePool,
|
||||
job_id: i64,
|
||||
session_id: Option<i64>,
|
||||
started_at: &str,
|
||||
completed_at: &str,
|
||||
duration_ms: i64,
|
||||
status: &str,
|
||||
final_response: Option<&str>,
|
||||
error: Option<&str>,
|
||||
) -> Result<JobRun> {
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO job_runs (job_id, session_id, started_at, completed_at, duration_ms, status, final_response, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(session_id)
|
||||
.bind(started_at)
|
||||
.bind(completed_at)
|
||||
.bind(duration_ms)
|
||||
.bind(status)
|
||||
.bind(final_response)
|
||||
.bind(error)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
|
||||
let row = sqlx::query_as::<_, JobRun>(
|
||||
"SELECT id, job_id, session_id, started_at, completed_at, duration_ms,
|
||||
status, final_response, error, created_at
|
||||
FROM job_runs WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn list_for_job(pool: &SqlitePool, job_id: i64, limit: i64) -> Result<Vec<JobRun>> {
|
||||
let rows = sqlx::query_as::<_, JobRun>(
|
||||
"SELECT id, job_id, session_id, started_at, completed_at, duration_ms,
|
||||
status, final_response, error, created_at
|
||||
FROM job_runs
|
||||
WHERE job_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct JobRunWithMeta {
|
||||
pub id: i64,
|
||||
pub job_id: i64,
|
||||
pub session_id: Option<i64>,
|
||||
pub started_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
pub duration_ms: Option<i64>,
|
||||
pub status: String,
|
||||
pub final_response: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub created_at: String,
|
||||
pub job_title: Option<String>,
|
||||
pub agent_id: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_all(pool: &SqlitePool, limit: i64) -> Result<Vec<JobRunWithMeta>> {
|
||||
let rows = sqlx::query_as::<_, JobRunWithMeta>(
|
||||
"SELECT jr.id, jr.job_id, jr.session_id, jr.started_at, jr.completed_at,
|
||||
jr.duration_ms, jr.status, jr.final_response, jr.error, jr.created_at,
|
||||
sj.title AS job_title, sj.agent_id, sj.kind
|
||||
FROM job_runs jr
|
||||
LEFT JOIN scheduled_jobs sj ON jr.job_id = sj.id
|
||||
ORDER BY jr.created_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! `known_tools` — every tool ever offered to the LLM, captured at injection
|
||||
//! time by [`crate::tool_discovery::ToolDiscovery`].
|
||||
//!
|
||||
//! This is the drift-proof half of tool visibility: instead of maintaining a
|
||||
//! parallel list of "all tools", we record what is actually assembled into the
|
||||
//! LLM request (`AgentRunConfig::all_tool_defs`). The approval / Security-groups
|
||||
//! UI merges these rows so tools injected outside the `ToolRegistry` (interface
|
||||
//! tools, plugin tools, provider tools) can still be assigned a permission.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct KnownTool {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// JSON parameters schema as last seen, if any.
|
||||
pub schema: Option<String>,
|
||||
}
|
||||
|
||||
/// Records (or refreshes) a tool by name. Idempotent: re-seeing a tool updates
|
||||
/// its description/schema and bumps `last_seen`.
|
||||
pub async fn upsert(
|
||||
pool: &SqlitePool,
|
||||
name: &str,
|
||||
description: &str,
|
||||
schema: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO known_tools (name, description, schema, first_seen, last_seen)
|
||||
VALUES (?1, ?2, ?3, strftime('%s','now'), strftime('%s','now'))
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
description = excluded.description,
|
||||
schema = excluded.schema,
|
||||
last_seen = excluded.last_seen",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(schema)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// All recorded tools, sorted by name.
|
||||
pub async fn all(pool: &SqlitePool) -> Result<Vec<KnownTool>> {
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"SELECT name, description, schema FROM known_tools ORDER BY name",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(name, description, schema)| KnownTool { name, description, schema })
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_db_path(tag: &str) -> String {
|
||||
let mut p = std::env::temp_dir();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
p.push(format!("skald-test-{tag}-{}-{nanos}.db", std::process::id()));
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn cleanup(path: &str) {
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{path}{suffix}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_is_idempotent_and_updates_metadata() {
|
||||
let path = temp_db_path("known-tools");
|
||||
let pool = crate::db::init_system_pool(&path).await.unwrap();
|
||||
|
||||
upsert(&pool, "send_voice_message", "v1", Some(r#"{"type":"object"}"#)).await.unwrap();
|
||||
upsert(&pool, "send_voice_message", "v2", None).await.unwrap();
|
||||
|
||||
let rows = all(&pool).await.unwrap();
|
||||
assert_eq!(rows.len(), 1, "same name must not create a second row");
|
||||
assert_eq!(rows[0].name, "send_voice_message");
|
||||
assert_eq!(rows[0].description, "v2", "description is refreshed on re-upsert");
|
||||
assert_eq!(rows[0].schema, None, "schema is refreshed on re-upsert");
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Background maintenance task for the `llm_requests` table.
|
||||
//!
|
||||
//! Periodically nulls out old payloads/headers and deletes expired rows according
|
||||
//! to the retention settings in [`LlmRequestsLogConfig`], then `VACUUM`s to reclaim
|
||||
//! freed pages. Extracted from `Skald::new` so the loop lives next to the queries it
|
||||
//! calls; the returned handle is registered with the `TaskSupervisor` for shutdown.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::LlmRequestsLogConfig;
|
||||
|
||||
/// Spawns the retention/cleanup loop for the `llm_requests` table.
|
||||
///
|
||||
/// First run happens 1 minute after startup, then every 12 hours. The loop exits
|
||||
/// when `shutdown` is cancelled. Callers should register the returned handle with
|
||||
/// the task supervisor so it is awaited on shutdown.
|
||||
pub fn spawn(
|
||||
pool: Arc<SqlitePool>,
|
||||
cfg: LlmRequestsLogConfig,
|
||||
shutdown: CancellationToken,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = shutdown.cancelled() => { return; }
|
||||
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
|
||||
}
|
||||
loop {
|
||||
if let Some(days) = cfg.cleanup_request_payload_after {
|
||||
match super::null_request_payload(&pool, days).await {
|
||||
Ok(n) if n > 0 => info!(rows = n, days, "llm_requests: nulled request payload"),
|
||||
Ok(_) => {}
|
||||
Err(e) => warn!(error = %e, "llm_requests: null request payload failed"),
|
||||
}
|
||||
}
|
||||
if let Some(days) = cfg.cleanup_response_payload_after {
|
||||
match super::null_response_payload(&pool, days).await {
|
||||
Ok(n) if n > 0 => info!(rows = n, days, "llm_requests: nulled response payload"),
|
||||
Ok(_) => {}
|
||||
Err(e) => warn!(error = %e, "llm_requests: null response payload failed"),
|
||||
}
|
||||
}
|
||||
if let Some(days) = cfg.cleanup_headers_after {
|
||||
match super::null_headers(&pool, days).await {
|
||||
Ok(n) if n > 0 => info!(rows = n, days, "llm_requests: nulled headers"),
|
||||
Ok(_) => {}
|
||||
Err(e) => warn!(error = %e, "llm_requests: null headers failed"),
|
||||
}
|
||||
}
|
||||
if let Some(days) = cfg.cleanup_rows_after {
|
||||
match super::delete_old_rows(&pool, days).await {
|
||||
Ok(n) if n > 0 => info!(deleted = n, days, "llm_requests: deleted old rows"),
|
||||
Ok(_) => {}
|
||||
Err(e) => warn!(error = %e, "llm_requests: delete old rows failed"),
|
||||
}
|
||||
}
|
||||
// VACUUM reclaims pages freed by DELETE/UPDATE NULL.
|
||||
match sqlx::query("VACUUM").execute(&*pool).await {
|
||||
Ok(_) => info!("llm_requests: VACUUM complete"),
|
||||
Err(e) => warn!(error = %e, "llm_requests: VACUUM failed"),
|
||||
}
|
||||
tokio::select! {
|
||||
_ = shutdown.cancelled() => { break; }
|
||||
_ = tokio::time::sleep(Duration::from_secs(12 * 3600)) => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! DB operations for the `llm_requests` table.
|
||||
//!
|
||||
//! Every `chat_with_tools` call is logged here by the
|
||||
//! [`crate::chatbot::logging::LoggingChatbotClient`] wrapper.
|
||||
//! Rows are retained for `llm.request_log.retention_days` days (default 14).
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub mod cleanup;
|
||||
|
||||
// ── Row struct ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct LlmRequestRow {
|
||||
pub session_id: Option<i64>,
|
||||
pub stack_id: Option<i64>,
|
||||
pub model_name: String,
|
||||
/// Full HTTP request body sent to the provider (compact JSON, no pretty-print).
|
||||
pub request_json: String,
|
||||
/// HTTP request headers as a compact JSON object (api-key redacted).
|
||||
pub request_headers: Option<String>,
|
||||
/// Full HTTP response body from the provider (compact JSON).
|
||||
pub response_json: Option<String>,
|
||||
/// HTTP response headers as a compact JSON object.
|
||||
pub response_headers: Option<String>,
|
||||
/// Error message when the HTTP call itself failed (no response available).
|
||||
pub error_text: Option<String>,
|
||||
pub input_tokens: Option<i64>,
|
||||
pub output_tokens: Option<i64>,
|
||||
/// Wall-clock time of the full HTTP round-trip in milliseconds.
|
||||
pub duration_ms: i64,
|
||||
/// Tokens served from the provider's prompt cache (already parsed by the client).
|
||||
pub cache_read_tokens: Option<i64>,
|
||||
/// Tokens written into the provider's prompt cache (Anthropic only).
|
||||
pub cache_creation_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
// ── Writes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn insert(pool: &SqlitePool, row: LlmRequestRow) -> Result<i64> {
|
||||
let id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO llm_requests (
|
||||
session_id, stack_id, model_name,
|
||||
request_json, request_headers,
|
||||
response_json, response_headers,
|
||||
error_text, input_tokens, output_tokens, duration_ms,
|
||||
cache_read_tokens, cache_creation_tokens
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(row.session_id)
|
||||
.bind(row.stack_id)
|
||||
.bind(&row.model_name)
|
||||
.bind(&row.request_json)
|
||||
.bind(&row.request_headers)
|
||||
.bind(&row.response_json)
|
||||
.bind(&row.response_headers)
|
||||
.bind(&row.error_text)
|
||||
.bind(row.input_tokens)
|
||||
.bind(row.output_tokens)
|
||||
.bind(row.duration_ms)
|
||||
.bind(row.cache_read_tokens)
|
||||
.bind(row.cache_creation_tokens)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
// ── Maintenance ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Physically deletes rows older than `days` days. Returns rows affected.
|
||||
pub async fn delete_old_rows(pool: &SqlitePool, days: u32) -> Result<u64> {
|
||||
let cutoff = format!("-{days} days");
|
||||
let n = sqlx::query("DELETE FROM llm_requests WHERE created_at < datetime('now', ?)")
|
||||
.bind(&cutoff)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Nulls out `request_json` for rows older than `days` days. Returns rows affected.
|
||||
pub async fn null_request_payload(pool: &SqlitePool, days: u32) -> Result<u64> {
|
||||
let cutoff = format!("-{days} days");
|
||||
let n = sqlx::query(
|
||||
"UPDATE llm_requests SET request_json = '' \
|
||||
WHERE request_json != '' AND created_at < datetime('now', ?)",
|
||||
)
|
||||
.bind(&cutoff)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Nulls out `response_json` for rows older than `days` days. Returns rows affected.
|
||||
pub async fn null_response_payload(pool: &SqlitePool, days: u32) -> Result<u64> {
|
||||
let cutoff = format!("-{days} days");
|
||||
let n = sqlx::query(
|
||||
"UPDATE llm_requests SET response_json = NULL \
|
||||
WHERE response_json IS NOT NULL AND created_at < datetime('now', ?)",
|
||||
)
|
||||
.bind(&cutoff)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Nulls out both header columns for rows older than `days` days. Returns rows affected.
|
||||
pub async fn null_headers(pool: &SqlitePool, days: u32) -> Result<u64> {
|
||||
let cutoff = format!("-{days} days");
|
||||
let n = sqlx::query(
|
||||
"UPDATE llm_requests \
|
||||
SET request_headers = NULL, response_headers = NULL \
|
||||
WHERE (request_headers IS NOT NULL OR response_headers IS NOT NULL) \
|
||||
AND created_at < datetime('now', ?)",
|
||||
)
|
||||
.bind(&cutoff)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
// ── Row type ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct McpEvent {
|
||||
pub id: i64,
|
||||
pub source: String,
|
||||
pub method: String,
|
||||
pub payload: String, // raw JSON of the "params" field
|
||||
pub processed: bool,
|
||||
pub processed_at: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// ── Write ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Insert a new event (processed = false).
|
||||
pub async fn insert(
|
||||
pool: &SqlitePool,
|
||||
source: &str,
|
||||
method: &str,
|
||||
payload: &str,
|
||||
) -> Result<i64> {
|
||||
let id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO mcp_events (source, method, payload)
|
||||
VALUES (?, ?, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(source)
|
||||
.bind(method)
|
||||
.bind(payload)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Mark a batch of events as processed (sets processed = 1, processed_at = now).
|
||||
pub async fn mark_processed(pool: &SqlitePool, ids: &[i64]) -> Result<()> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// Build a parameterised IN clause.
|
||||
let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
|
||||
let sql = format!(
|
||||
"UPDATE mcp_events
|
||||
SET processed = 1, processed_at = datetime('now')
|
||||
WHERE id IN ({placeholders})"
|
||||
);
|
||||
let mut q = sqlx::query(sqlx::AssertSqlSafe(sql));
|
||||
for id in ids {
|
||||
q = q.bind(id);
|
||||
}
|
||||
q.execute(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Read ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Oldest N pending (unprocessed) events, ordered oldest-first.
|
||||
/// Used by TicManager to fetch a bounded batch each tick.
|
||||
pub async fn pending_limited(pool: &SqlitePool, limit: i64) -> Result<Vec<McpEvent>> {
|
||||
let rows = sqlx::query_as::<_, (i64, String, String, String, bool, Option<String>, String)>(
|
||||
"SELECT id, source, method, payload, processed, processed_at, created_at
|
||||
FROM mcp_events
|
||||
WHERE processed = 0
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(row_to_event).collect())
|
||||
}
|
||||
|
||||
/// All pending (unprocessed) events, ordered oldest-first.
|
||||
pub async fn pending(pool: &SqlitePool) -> Result<Vec<McpEvent>> {
|
||||
let rows = sqlx::query_as::<_, (i64, String, String, String, bool, Option<String>, String)>(
|
||||
"SELECT id, source, method, payload, processed, processed_at, created_at
|
||||
FROM mcp_events
|
||||
WHERE processed = 0
|
||||
ORDER BY created_at ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(row_to_event).collect())
|
||||
}
|
||||
|
||||
/// All events (both processed and pending), most-recent first. Useful for debug/audit.
|
||||
pub async fn all_recent(pool: &SqlitePool, limit: i64) -> Result<Vec<McpEvent>> {
|
||||
let rows = sqlx::query_as::<_, (i64, String, String, String, bool, Option<String>, String)>(
|
||||
"SELECT id, source, method, payload, processed, processed_at, created_at
|
||||
FROM mcp_events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(row_to_event).collect())
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn row_to_event(
|
||||
(id, source, method, payload, processed, processed_at, created_at): (
|
||||
i64, String, String, String, bool, Option<String>, String,
|
||||
),
|
||||
) -> McpEvent {
|
||||
McpEvent { id, source, method, payload, processed, processed_at, created_at }
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub transport: String,
|
||||
pub command: Option<String>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl McpServerRow {
|
||||
pub fn args(&self) -> Vec<String> {
|
||||
self.args_json.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn env(&self) -> HashMap<String, String> {
|
||||
self.env_json.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
type RawRow = (i64, String, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>, i64);
|
||||
|
||||
fn from_raw(r: RawRow) -> McpServerRow {
|
||||
McpServerRow {
|
||||
id: r.0,
|
||||
name: r.1,
|
||||
transport: r.2,
|
||||
command: r.3,
|
||||
args_json: r.4,
|
||||
env_json: r.5,
|
||||
url: r.6,
|
||||
api_key: r.7,
|
||||
description: r.8,
|
||||
friendly_name: r.9,
|
||||
enabled: r.10 != 0,
|
||||
}
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, name, transport, command, args_json, env_json, url, api_key, description, friendly_name, enabled \
|
||||
FROM mcp_servers";
|
||||
|
||||
pub async fn all(pool: &SqlitePool) -> Result<Vec<McpServerRow>> {
|
||||
let rows = sqlx::query_as::<_, RawRow>(sqlx::AssertSqlSafe(format!("{SELECT} ORDER BY name")))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(from_raw).collect())
|
||||
}
|
||||
|
||||
pub async fn all_enabled(pool: &SqlitePool) -> Result<Vec<McpServerRow>> {
|
||||
let rows = sqlx::query_as::<_, RawRow>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE enabled = 1 ORDER BY name")))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(from_raw).collect())
|
||||
}
|
||||
|
||||
pub struct UpsertParams<'a> {
|
||||
pub name: &'a str,
|
||||
pub transport: &'a str,
|
||||
pub command: Option<&'a str>,
|
||||
pub args_json: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub url: Option<&'a str>,
|
||||
pub api_key: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
pub friendly_name: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub async fn upsert(pool: &SqlitePool, p: UpsertParams<'_>) -> Result<i64> {
|
||||
let row = sqlx::query_as::<_, (i64,)>(
|
||||
"INSERT INTO mcp_servers (name, transport, command, args_json, env_json, url, api_key, description, friendly_name, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
transport = excluded.transport,
|
||||
command = excluded.command,
|
||||
args_json = excluded.args_json,
|
||||
env_json = excluded.env_json,
|
||||
url = excluded.url,
|
||||
api_key = excluded.api_key,
|
||||
description = excluded.description,
|
||||
friendly_name = excluded.friendly_name,
|
||||
enabled = 1
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(p.name)
|
||||
.bind(p.transport)
|
||||
.bind(p.command)
|
||||
.bind(p.args_json)
|
||||
.bind(p.env_json)
|
||||
.bind(p.url)
|
||||
.bind(p.api_key)
|
||||
.bind(p.description)
|
||||
.bind(p.friendly_name)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn set_enabled(pool: &SqlitePool, name: &str, enabled: bool) -> Result<()> {
|
||||
sqlx::query("UPDATE mcp_servers SET enabled = ?1 WHERE name = ?2")
|
||||
.bind(enabled as i64)
|
||||
.bind(name)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, name: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM mcp_servers WHERE name = ?1")
|
||||
.bind(name)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
pub mod approval_rules;
|
||||
pub mod project_tickets;
|
||||
pub mod projects;
|
||||
pub mod chat_history;
|
||||
pub mod chat_llm_tools;
|
||||
pub mod chat_sessions;
|
||||
pub mod chat_sessions_stack;
|
||||
pub mod chat_summaries;
|
||||
pub mod config;
|
||||
pub mod job_runs;
|
||||
pub mod known_tools;
|
||||
pub mod llm_requests;
|
||||
pub mod mcp_events;
|
||||
pub mod mcp_servers;
|
||||
pub mod plugins;
|
||||
pub mod scheduled_jobs;
|
||||
pub mod scratchpad;
|
||||
pub mod session_mcp_grants;
|
||||
pub mod sources;
|
||||
pub mod stack_mcp_grants;
|
||||
pub mod tool_permission_groups;
|
||||
pub mod users;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::{SqlitePool, sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous}};
|
||||
|
||||
use crate::crypto::Dek;
|
||||
|
||||
/// Every database file lives here, so backup, export and per-user erasure are a
|
||||
/// matter of files rather than tables.
|
||||
pub const DATABASE_DIR: &str = "database";
|
||||
|
||||
/// System database: instance-wide state, shared by every user. Fixed path — not
|
||||
/// configurable.
|
||||
pub const SYSTEM_DB_PATH: &str = "database/system.db";
|
||||
|
||||
/// `{dir}/{userid}.db`. Keyed by the opaque user id and never the username, so a
|
||||
/// rename never has to touch the file. `dir` is a parameter rather than the
|
||||
/// [`DATABASE_DIR`] constant so nothing depends on the process's working
|
||||
/// directory — tests in particular.
|
||||
pub fn user_db_path(dir: &Path, user_id: &str) -> PathBuf {
|
||||
dir.join(format!("{user_id}.db"))
|
||||
}
|
||||
|
||||
/// The `-wal` and `-shm` sidecars SQLite keeps beside a database in WAL mode.
|
||||
/// Erasing a user means erasing these too.
|
||||
pub fn user_db_sidecars(path: &Path) -> [PathBuf; 2] {
|
||||
let ext = |suffix: &str| {
|
||||
let mut p = path.to_path_buf().into_os_string();
|
||||
p.push(suffix);
|
||||
PathBuf::from(p)
|
||||
};
|
||||
[ext("-wal"), ext("-shm")]
|
||||
}
|
||||
|
||||
fn ensure_parent(path: &Path) -> Result<()> {
|
||||
// `create_if_missing` creates the file, never its parent directory.
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create database directory {}", parent.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tuned(opts: SqliteConnectOptions) -> SqliteConnectOptions {
|
||||
// WAL lets readers run alongside a single writer, and `busy_timeout` makes a
|
||||
// writer *wait* for the lock instead of failing immediately with SQLITE_BUSY
|
||||
// ("database is locked"). Without these, concurrent writers — e.g. the
|
||||
// mobile-connector persisting its E2E `send_counter` while the chat loop /
|
||||
// cron write history — abort mid-operation, which silently drops outbound
|
||||
// mobile messages (inbox_update never reaches the device).
|
||||
opts.journal_mode(SqliteJournalMode::Wal)
|
||||
.synchronous(SqliteSynchronous::Normal)
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
}
|
||||
|
||||
/// Connect options for one user's database.
|
||||
///
|
||||
/// When `key` is present the pool is a SQLCipher pool: sqlx puts `PRAGMA key`
|
||||
/// ahead of every other pragma, so the page cipher is armed before `journal_mode`
|
||||
/// touches the file. Without a key the same code opens an ordinary SQLite file —
|
||||
/// which is exactly what a cleartext user gets.
|
||||
///
|
||||
/// The returned value carries the raw key. **Never** `{:?}` it: `SqliteConnectOptions`
|
||||
/// prints its pragmas, so a debug format anywhere near this would write the DEK,
|
||||
/// in hex, into the logs.
|
||||
fn user_options(path: &Path, key: Option<&Dek>, create: bool) -> SqliteConnectOptions {
|
||||
let opts = SqliteConnectOptions::new().filename(path).create_if_missing(create);
|
||||
let opts = match key {
|
||||
Some(dek) => opts.pragma("key", dek.to_pragma()),
|
||||
None => opts,
|
||||
};
|
||||
tuned(opts)
|
||||
}
|
||||
|
||||
/// Forces a page read so a wrong key fails here, at open time, rather than at the
|
||||
/// first unrelated query. SQLCipher answers "file is not a database" when the key
|
||||
/// does not decrypt the header.
|
||||
async fn probe(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query_scalar::<_, i64>("SELECT count(*) FROM sqlite_master")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.context("database did not open — wrong key, or not a database")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The system database: registry tables, plus — for now — the owner tables.
|
||||
///
|
||||
/// Owner tables live here **transitionally**. Nothing has been migrated to
|
||||
/// per-user pools yet, so sessions, history and jobs still land in `system.db`.
|
||||
/// When the call sites move to `UserManager::pool_of`, this second call goes
|
||||
/// away and the owner-without-a-user gets a file of its own.
|
||||
pub async fn init_system_pool(path: &str) -> Result<SqlitePool> {
|
||||
ensure_parent(Path::new(path))?;
|
||||
let opts = tuned(SqliteConnectOptions::from_str(path)?.create_if_missing(true));
|
||||
let pool = SqlitePool::connect_with(opts).await?;
|
||||
create_registry_tables(&pool).await?;
|
||||
create_owner_tables(&pool).await?;
|
||||
crate::boot::section("Database initialised".to_string());
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Provisions `database/{userid}.db` and lays down the owner schema.
|
||||
///
|
||||
/// Only this function may create a user's database. Login goes through
|
||||
/// [`open_user_pool`], which refuses to create anything: a missing file there is
|
||||
/// data loss, and creating a fresh empty one under the right password would hide
|
||||
/// it instead of reporting it.
|
||||
pub async fn create_user_pool(path: &Path, key: Option<&Dek>) -> Result<SqlitePool> {
|
||||
ensure_parent(path)?;
|
||||
let pool = SqlitePool::connect_with(user_options(path, key, true)).await?;
|
||||
probe(&pool).await?;
|
||||
create_owner_tables(&pool).await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Opens an existing user database. Never creates one — see [`create_user_pool`].
|
||||
pub async fn open_user_pool(path: &Path, key: Option<&Dek>) -> Result<SqlitePool> {
|
||||
let pool = SqlitePool::connect_with(user_options(path, key, false)).await?;
|
||||
probe(&pool).await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
// ── Registry tables ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Instance-wide, readable without any user key: the directory you must open
|
||||
// before you know who exists. Nothing here is scoped to one user.
|
||||
|
||||
async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS llm_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
type TEXT NOT NULL,
|
||||
api_key TEXT,
|
||||
base_url TEXT,
|
||||
description TEXT,
|
||||
removed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
// `name` is the unique identity + resolution key (LlmManager keys its
|
||||
// in-memory model map by name). There is deliberately NO
|
||||
// UNIQUE(provider_id, model_id): the same underlying model may be
|
||||
// registered multiple times under one provider with different aliases
|
||||
// and reasoning settings (e.g. "glm-4.6" vs "glm-4.6-thinking").
|
||||
"CREATE TABLE IF NOT EXISTS llm_models (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id INTEGER NOT NULL REFERENCES llm_providers(id) ON DELETE CASCADE,
|
||||
model_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
strength TEXT,
|
||||
scope TEXT NOT NULL DEFAULT '[]',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
extra_params TEXT,
|
||||
removed_at TEXT,
|
||||
context_length INTEGER,
|
||||
max_output_tokens INTEGER,
|
||||
knowledge_cutoff TEXT,
|
||||
capabilities TEXT NOT NULL DEFAULT '[]',
|
||||
reasoning TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS transcribe_models (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id INTEGER NOT NULL REFERENCES llm_providers(id),
|
||||
model_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
language TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
removed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(provider_id, model_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS image_generate_models (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id INTEGER NOT NULL REFERENCES llm_providers(id),
|
||||
model_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
removed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(provider_id, model_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS tts_models (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id INTEGER NOT NULL REFERENCES llm_providers(id),
|
||||
model_id TEXT NOT NULL,
|
||||
voice_id TEXT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
instructions TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
removed_at TEXT,
|
||||
response_format TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(provider_id, model_id)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS tool_permission_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS approval_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id TEXT,
|
||||
source TEXT,
|
||||
tool_pattern TEXT NOT NULL,
|
||||
action TEXT NOT NULL DEFAULT 'require'
|
||||
CHECK(action IN ('require', 'allow', 'deny')),
|
||||
note TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
path_pattern TEXT,
|
||||
group_id TEXT REFERENCES tool_permission_groups(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Every tool ever offered to the LLM, recorded by `ToolDiscovery` at
|
||||
// injection time. Lets the approval / Security-groups UI list and gate tools
|
||||
// that are injected dynamically outside the `ToolRegistry` (interface tools,
|
||||
// plugin tools, provider tools).
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS known_tools (
|
||||
name TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
schema TEXT,
|
||||
first_seen INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
last_seen INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Spend/telemetry metadata. Stays in the registry so cost charts run without
|
||||
// decrypting anything: the admin sees how much, when and which model — never
|
||||
// what was said. `session_id` / `stack_id` are bare integers, not foreign
|
||||
// keys, precisely because the rows they point at live in another file.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS llm_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER,
|
||||
stack_id INTEGER,
|
||||
model_name TEXT NOT NULL,
|
||||
request_json TEXT NOT NULL DEFAULT '',
|
||||
request_headers TEXT,
|
||||
response_json TEXT,
|
||||
response_headers TEXT,
|
||||
error_text TEXT,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
cache_read_tokens INTEGER,
|
||||
cache_creation_tokens INTEGER,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_llm_requests_created
|
||||
ON llm_requests (created_at)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// User directory + auth material. Read before every login, so it lives in
|
||||
// the registry — which means it must never hold anything that derives a
|
||||
// user's key: `database_password` is the DEK sealed under a key derived
|
||||
// from the password, useless without it.
|
||||
//
|
||||
// `role_id` has no `REFERENCES roles(id)` yet: sqlx turns on
|
||||
// `PRAGMA foreign_keys`, so pointing at a table that does not exist would
|
||||
// make every INSERT fail. The constraint lands with the `roles` table.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT,
|
||||
role_id TEXT NOT NULL,
|
||||
encrypted INTEGER NOT NULL,
|
||||
kdf_params TEXT,
|
||||
kdf_salt BLOB,
|
||||
database_password BLOB,
|
||||
password_hash BLOB,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
CHECK (
|
||||
(encrypted = 1 AND database_password IS NOT NULL AND password_hash IS NULL)
|
||||
OR (encrypted = 0 AND database_password IS NULL)
|
||||
)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Owner tables ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// One owner's content. The schema is identical in every file that has it — only
|
||||
// the file says who owns the rows — so this runs verbatim against `system.db`
|
||||
// and against each `database/{userid}.db`.
|
||||
//
|
||||
// **No foreign key here may point at a registry table.** SQLite cannot enforce a
|
||||
// key across files, not even through `ATTACH`, and sqlx turns on
|
||||
// `PRAGMA foreign_keys`: the `CREATE TABLE` would succeed and every `INSERT`
|
||||
// would fail. `create_owner_tables_stand_alone` in the tests guards this by
|
||||
// running the schema against a file that has nothing else in it.
|
||||
|
||||
pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'web',
|
||||
agent_id TEXT NOT NULL DEFAULT 'main',
|
||||
is_interactive INTEGER NOT NULL DEFAULT 1,
|
||||
is_ephemeral INTEGER NOT NULL DEFAULT 0,
|
||||
run_context TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS chat_sessions_stack (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL REFERENCES chat_sessions(id),
|
||||
agent_id TEXT NOT NULL DEFAULT 'main',
|
||||
agent_prompt TEXT,
|
||||
depth INTEGER NOT NULL DEFAULT 0,
|
||||
parent_tool_call_id INTEGER,
|
||||
terminated_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// `model_db_id` used to point at `llm_models(id)`. It was write-only — never
|
||||
// selected, never joined — and it was the one key that crossed into the
|
||||
// registry. Which model answered is already recorded in
|
||||
// `llm_requests.model_name`.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS chat_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_stack_id INTEGER NOT NULL REFERENCES chat_sessions_stack(id),
|
||||
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'agent')),
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'ok' CHECK(status IN ('ok', 'failed')),
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
duration_ms INTEGER,
|
||||
is_synthetic INTEGER NOT NULL DEFAULT 0,
|
||||
reasoning_content TEXT,
|
||||
cost REAL,
|
||||
metadata TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS chat_llm_tools (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL REFERENCES chat_history(id),
|
||||
name TEXT NOT NULL,
|
||||
arguments TEXT,
|
||||
result TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'pending', 'done', 'failed', 'cancelled', 'rejected')),
|
||||
result_type TEXT NOT NULL DEFAULT 'string' CHECK(result_type IN ('string', 'json')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_stack_session ON chat_sessions_stack(session_id)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_history_stack ON chat_history(session_stack_id)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_tools_message ON chat_llm_tools(message_id)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS chat_summaries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stack_id INTEGER NOT NULL REFERENCES chat_sessions_stack(id),
|
||||
content TEXT NOT NULL,
|
||||
covers_up_to_message_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_summaries_stack
|
||||
ON chat_summaries (stack_id)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS session_scratchpad (
|
||||
session_id INTEGER NOT NULL REFERENCES chat_sessions(id),
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (session_id, key)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS session_mcp_grants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
mcp_name TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(session_id, mcp_name)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS stack_mcp_grants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stack_id INTEGER NOT NULL,
|
||||
mcp_name TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(stack_id, mcp_name)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS scheduled_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
cron TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL DEFAULT 'main',
|
||||
session_id INTEGER REFERENCES chat_sessions(id),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_run_at TEXT,
|
||||
next_run_at TEXT,
|
||||
single_run INTEGER NOT NULL DEFAULT 0,
|
||||
running_session_id INTEGER,
|
||||
kind TEXT NOT NULL DEFAULT 'cron',
|
||||
parent_session_id INTEGER REFERENCES chat_sessions(id),
|
||||
run_context TEXT,
|
||||
running_since TEXT,
|
||||
origin_ref TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS job_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL REFERENCES scheduled_jobs(id),
|
||||
session_id INTEGER,
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
duration_ms INTEGER,
|
||||
status TEXT NOT NULL
|
||||
CHECK(status IN ('completed', 'failed', 'cancelled')),
|
||||
final_response TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_job_runs_job_id
|
||||
ON job_runs (job_id, created_at DESC)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
transport TEXT NOT NULL DEFAULT 'stdio',
|
||||
command TEXT,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
url TEXT,
|
||||
api_key TEXT,
|
||||
description TEXT,
|
||||
friendly_name TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS mcp_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
processed INTEGER NOT NULL DEFAULT 0,
|
||||
processed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_mcp_events_pending
|
||||
ON mcp_events (processed, created_at)
|
||||
WHERE processed = 0",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
active_session_id INTEGER REFERENCES chat_sessions(id),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS secrets (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
run_context TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS project_tickets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'todo'
|
||||
CHECK(status IN ('todo','pending','in_progress','done','failed')),
|
||||
agent_id TEXT NOT NULL DEFAULT 'main',
|
||||
run_context TEXT,
|
||||
job_id INTEGER REFERENCES scheduled_jobs(id),
|
||||
result TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
completed_at TEXT
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("skald-db-{tag}-{}-{nanos}", std::process::id()));
|
||||
p
|
||||
}
|
||||
|
||||
/// The guardrail the file split would have given for free.
|
||||
///
|
||||
/// `create_owner_tables` runs here against a database that holds *nothing
|
||||
/// else* — no `llm_models`, no `users`. Every table then takes a row. Any
|
||||
/// foreign key that reaches into a registry table passes `CREATE TABLE` and
|
||||
/// dies on the `INSERT`, right here, instead of lying dormant until a real
|
||||
/// user logs in and writes to their own file.
|
||||
#[tokio::test]
|
||||
async fn owner_tables_stand_alone_with_foreign_keys_on() {
|
||||
let dir = temp_dir("owner-standalone");
|
||||
let path = dir.join("owner.db");
|
||||
let pool = create_user_pool(&path, None).await.unwrap();
|
||||
|
||||
let (fk,): (i64,) = sqlx::query_as("PRAGMA foreign_keys")
|
||||
.fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(fk, 1, "the guardrail is meaningless without FK enforcement");
|
||||
|
||||
let one = |q: &'static str| sqlx::query(q).execute(&pool);
|
||||
|
||||
one("INSERT INTO chat_sessions (id, title) VALUES (1, 't')").await.unwrap();
|
||||
one("INSERT INTO chat_sessions_stack (id, session_id) VALUES (1, 1)").await.unwrap();
|
||||
one("INSERT INTO chat_history (id, session_stack_id, role, content) VALUES (1, 1, 'user', 'hi')")
|
||||
.await.unwrap();
|
||||
one("INSERT INTO chat_llm_tools (message_id, name) VALUES (1, 'exec')").await.unwrap();
|
||||
one("INSERT INTO chat_summaries (stack_id, content, covers_up_to_message_id) VALUES (1, 's', 1)")
|
||||
.await.unwrap();
|
||||
one("INSERT INTO session_scratchpad (session_id, key, value) VALUES (1, 'k', 'v')").await.unwrap();
|
||||
one("INSERT INTO session_mcp_grants (session_id, mcp_name) VALUES (1, 'm')").await.unwrap();
|
||||
one("INSERT INTO stack_mcp_grants (stack_id, mcp_name) VALUES (1, 'm')").await.unwrap();
|
||||
one("INSERT INTO scheduled_jobs (id, title, cron, prompt, session_id) VALUES (1, 't', '* * * * *', 'p', 1)")
|
||||
.await.unwrap();
|
||||
one("INSERT INTO job_runs (job_id, started_at, status) VALUES (1, 'now', 'completed')").await.unwrap();
|
||||
one("INSERT INTO mcp_servers (name) VALUES ('srv')").await.unwrap();
|
||||
one("INSERT INTO mcp_events (source, method, payload) VALUES ('s', 'm', '{}')").await.unwrap();
|
||||
one("INSERT INTO sources (id, active_session_id) VALUES ('web', 1)").await.unwrap();
|
||||
one("INSERT INTO secrets (key, value) VALUES ('k', 'v')").await.unwrap();
|
||||
one("INSERT INTO projects (id, name, path) VALUES (1, 'p', '/tmp')").await.unwrap();
|
||||
one("INSERT INTO project_tickets (project_id, title, job_id) VALUES (1, 't', 1)").await.unwrap();
|
||||
|
||||
pool.close().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A cleartext user's database is an ordinary SQLite file; an encrypted one
|
||||
/// is unreadable without the key, and does not even carry SQLite's header.
|
||||
#[tokio::test]
|
||||
async fn an_encrypted_database_is_opaque_without_its_key() {
|
||||
let dir = temp_dir("opaque");
|
||||
let clear_path = dir.join("clear.db");
|
||||
let enc_path = dir.join("enc.db");
|
||||
|
||||
let clear = create_user_pool(&clear_path, None).await.unwrap();
|
||||
clear.close().await;
|
||||
|
||||
let dek = Dek::random();
|
||||
let enc = create_user_pool(&enc_path, Some(&dek)).await.unwrap();
|
||||
sqlx::query("INSERT INTO chat_sessions (title) VALUES ('secret')")
|
||||
.execute(&enc).await.unwrap();
|
||||
enc.close().await;
|
||||
|
||||
let magic = b"SQLite format 3\0";
|
||||
assert_eq!(&std::fs::read(&clear_path).unwrap()[..16], magic);
|
||||
assert_ne!(&std::fs::read(&enc_path).unwrap()[..16], magic,
|
||||
"an encrypted file must not advertise itself as SQLite");
|
||||
|
||||
assert!(open_user_pool(&enc_path, None).await.is_err(), "no key must not open it");
|
||||
assert!(open_user_pool(&enc_path, Some(&Dek::random())).await.is_err(), "wrong key must not open it");
|
||||
|
||||
// ...and the right key still does, with the row intact.
|
||||
let reopened = open_user_pool(&enc_path, Some(&dek)).await.unwrap();
|
||||
let (title,): (String,) = sqlx::query_as("SELECT title FROM chat_sessions")
|
||||
.fetch_one(&reopened).await.unwrap();
|
||||
assert_eq!(title, "secret");
|
||||
reopened.close().await;
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Login must never conjure a database. A missing file is data loss and has
|
||||
/// to be reported, not papered over with a fresh empty one.
|
||||
#[tokio::test]
|
||||
async fn open_never_creates_a_database() {
|
||||
let dir = temp_dir("no-create");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("ghost.db");
|
||||
|
||||
assert!(open_user_pool(&path, None).await.is_err());
|
||||
assert!(open_user_pool(&path, Some(&Dek::random())).await.is_err());
|
||||
assert!(!path.exists(), "open_user_pool must not have created the file");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginRow {
|
||||
pub id: String,
|
||||
pub enabled: bool,
|
||||
pub config: String, // JSON blob
|
||||
}
|
||||
|
||||
/// Returns (enabled, config_json) for a plugin, or None if not yet in DB.
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> Result<Option<PluginRow>> {
|
||||
let row: Option<(String, i64, String)> = sqlx::query_as(
|
||||
"SELECT id, enabled, config FROM plugins WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(id, e, config)| PluginRow { id, enabled: e != 0, config }))
|
||||
}
|
||||
|
||||
/// Upserts both enabled flag and config JSON.
|
||||
pub async fn upsert(pool: &SqlitePool, id: &str, enabled: bool, config: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO plugins (id, enabled, config)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled,
|
||||
config = excluded.config",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(enabled as i64)
|
||||
.bind(config)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns all plugin rows. Used by the config watcher.
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<PluginRow>> {
|
||||
let rows: Vec<(String, i64, String)> = sqlx::query_as(
|
||||
"SELECT id, enabled, config FROM plugins ORDER BY id",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, e, config)| PluginRow { id, enabled: e != 0, config })
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct ProjectTicket {
|
||||
pub id: i64,
|
||||
pub project_id: i64,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub agent_id: String,
|
||||
pub run_context: Option<String>,
|
||||
pub job_id: Option<i64>,
|
||||
pub result: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub created_at: String,
|
||||
pub started_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
pub session_id: Option<i64>,
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT pt.id, pt.project_id, pt.title, pt.description, pt.status, pt.agent_id,
|
||||
pt.run_context, pt.job_id, pt.result, pt.error, pt.created_at,
|
||||
pt.started_at, pt.completed_at,
|
||||
COALESCE(sj.running_session_id,
|
||||
(SELECT session_id FROM job_runs
|
||||
WHERE job_id = pt.job_id ORDER BY id DESC LIMIT 1)
|
||||
) AS session_id
|
||||
FROM project_tickets pt
|
||||
LEFT JOIN scheduled_jobs sj ON sj.id = pt.job_id";
|
||||
|
||||
pub async fn list_for_project(pool: &SqlitePool, project_id: i64) -> Result<Vec<ProjectTicket>> {
|
||||
let rows = sqlx::query_as::<_, ProjectTicket>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT} WHERE pt.project_id = ? ORDER BY pt.id"
|
||||
)))
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<ProjectTicket>> {
|
||||
let row = sqlx::query_as::<_, ProjectTicket>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT} WHERE pt.id = ?"
|
||||
)))
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
project_id: i64,
|
||||
title: &str,
|
||||
description: &str,
|
||||
agent_id: &str,
|
||||
run_context: Option<&str>,
|
||||
) -> Result<ProjectTicket> {
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO project_tickets (project_id, title, description, agent_id, run_context)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(title)
|
||||
.bind(description)
|
||||
.bind(agent_id)
|
||||
.bind(run_context)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
|
||||
let row = sqlx::query_as::<_, ProjectTicket>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT} WHERE pt.id = ?"
|
||||
)))
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
|
||||
let n = sqlx::query("DELETE FROM project_tickets WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
pub async fn set_status(pool: &SqlitePool, id: i64, status: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE project_tickets SET status = ? WHERE id = ?")
|
||||
.bind(status)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark as in_progress and record the scheduled job that is running it.
|
||||
pub async fn start(pool: &SqlitePool, id: i64, job_id: i64) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE project_tickets
|
||||
SET status = 'in_progress', job_id = ?, started_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(job_id)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark as done or failed, recording result/error and timestamp.
|
||||
pub async fn complete(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
result: Option<&str>,
|
||||
error: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let status = if error.is_some() { "failed" } else { "done" };
|
||||
sqlx::query(
|
||||
"UPDATE project_tickets
|
||||
SET status = ?, result = ?, error = ?, completed_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(status)
|
||||
.bind(result)
|
||||
.bind(error)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset a ticket back to todo, clearing all run state.
|
||||
pub async fn reset(pool: &SqlitePool, id: i64) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE project_tickets
|
||||
SET status = 'todo', job_id = NULL, result = NULL, error = NULL,
|
||||
started_at = NULL, completed_at = NULL
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct Project {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub description: String,
|
||||
pub run_context: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, name, path, description, run_context, created_at, updated_at
|
||||
FROM projects";
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<Project>> {
|
||||
let rows = sqlx::query_as::<_, Project>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT} ORDER BY updated_at DESC"
|
||||
)))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: i64) -> Result<Option<Project>> {
|
||||
let row = sqlx::query_as::<_, Project>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE id = ?")))
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
name: &str,
|
||||
path: &str,
|
||||
description: &str,
|
||||
run_context: Option<&str>,
|
||||
) -> Result<Project> {
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO projects (name, path, description, run_context)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(path)
|
||||
.bind(description)
|
||||
.bind(run_context)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
|
||||
let row = sqlx::query_as::<_, Project>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE id = ?")))
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
name: &str,
|
||||
path: &str,
|
||||
description: &str,
|
||||
run_context: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let n = sqlx::query(
|
||||
"UPDATE projects
|
||||
SET name = ?, path = ?, description = ?, run_context = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(path)
|
||||
.bind(description)
|
||||
.bind(run_context)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Touch updated_at — called after every ticket operation so ordering by recency works.
|
||||
pub async fn touch(pool: &SqlitePool, id: i64) -> Result<()> {
|
||||
sqlx::query("UPDATE projects SET updated_at = datetime('now') WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
|
||||
let n = sqlx::query("DELETE FROM projects WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n > 0)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct ScheduledJob {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub cron: String,
|
||||
pub prompt: String,
|
||||
pub agent_id: String,
|
||||
pub session_id: Option<i64>,
|
||||
pub enabled: bool,
|
||||
pub last_run_at: Option<String>,
|
||||
pub next_run_at: Option<String>,
|
||||
pub single_run: bool,
|
||||
pub running_session_id: Option<i64>,
|
||||
pub running_since: Option<String>,
|
||||
pub kind: String,
|
||||
pub created_at: String,
|
||||
pub parent_session_id: Option<i64>,
|
||||
pub run_context: Option<String>,
|
||||
pub origin_ref: Option<String>,
|
||||
}
|
||||
|
||||
const SELECT: &str =
|
||||
"SELECT id, title, description, cron, prompt, agent_id, session_id,
|
||||
CAST(enabled AS BOOLEAN) AS enabled,
|
||||
last_run_at,
|
||||
next_run_at,
|
||||
CAST(single_run AS BOOLEAN) AS single_run,
|
||||
running_session_id,
|
||||
running_since,
|
||||
kind,
|
||||
created_at,
|
||||
parent_session_id,
|
||||
run_context,
|
||||
origin_ref
|
||||
FROM scheduled_jobs";
|
||||
|
||||
pub async fn get_by_id(pool: &SqlitePool, id: i64) -> Result<Option<ScheduledJob>> {
|
||||
sqlx::query_as::<_, ScheduledJob>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE id = ?")))
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<ScheduledJob>> {
|
||||
let rows = sqlx::query_as::<_, ScheduledJob>(sqlx::AssertSqlSafe(format!("{SELECT} ORDER BY id")))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Jobs enabled and due to run: next_run_at is in the past and not currently running.
|
||||
/// `now_rfc3339` should be `chrono::Utc::now().to_rfc3339()`.
|
||||
pub async fn list_due(pool: &SqlitePool, now_rfc3339: &str) -> Result<Vec<ScheduledJob>> {
|
||||
let rows = sqlx::query_as::<_, ScheduledJob>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT}
|
||||
WHERE kind = 'cron'
|
||||
AND enabled = 1
|
||||
AND next_run_at IS NOT NULL
|
||||
AND next_run_at <= ?
|
||||
AND running_session_id IS NULL
|
||||
ORDER BY next_run_at",
|
||||
)))
|
||||
.bind(now_rfc3339)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Jobs that were running when the process was last killed (running_session_id IS NOT NULL).
|
||||
pub async fn list_interrupted(pool: &SqlitePool) -> Result<Vec<ScheduledJob>> {
|
||||
let rows = sqlx::query_as::<_, ScheduledJob>(sqlx::AssertSqlSafe(format!(
|
||||
"{SELECT} WHERE running_session_id IS NOT NULL ORDER BY id",
|
||||
)))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &SqlitePool,
|
||||
title: &str,
|
||||
description: &str,
|
||||
cron: &str,
|
||||
prompt: &str,
|
||||
agent_id: &str,
|
||||
single_run: bool,
|
||||
next_run_at: Option<&str>,
|
||||
kind: &str,
|
||||
parent_session_id: Option<i64>,
|
||||
run_context: Option<&str>,
|
||||
origin_ref: Option<&str>,
|
||||
) -> Result<ScheduledJob> {
|
||||
let id = sqlx::query(
|
||||
"INSERT INTO scheduled_jobs (title, description, cron, prompt, agent_id, single_run, next_run_at, kind, parent_session_id, run_context, origin_ref)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(title)
|
||||
.bind(description)
|
||||
.bind(cron)
|
||||
.bind(prompt)
|
||||
.bind(agent_id)
|
||||
.bind(single_run as i64)
|
||||
.bind(next_run_at)
|
||||
.bind(kind)
|
||||
.bind(parent_session_id)
|
||||
.bind(run_context)
|
||||
.bind(origin_ref)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.last_insert_rowid();
|
||||
|
||||
let row = sqlx::query_as::<_, ScheduledJob>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE id = ?")))
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: i64) -> Result<bool> {
|
||||
// Clear the soft back-reference from project_tickets first: its job_id FK has
|
||||
// no ON DELETE action, so a ticket still pointing at this job would block the
|
||||
// scheduled_jobs DELETE with a FOREIGN KEY constraint failure.
|
||||
sqlx::query("UPDATE project_tickets SET job_id = NULL WHERE job_id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM job_runs WHERE job_id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let n = sqlx::query("DELETE FROM scheduled_jobs WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
pub async fn set_enabled(pool: &SqlitePool, id: i64, enabled: bool) -> Result<bool> {
|
||||
let n = sqlx::query("UPDATE scheduled_jobs SET enabled = ? WHERE id = ?")
|
||||
.bind(enabled as i64)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Update next_run_at without touching anything else (used when re-enabling a job).
|
||||
pub async fn set_next_run_at(pool: &SqlitePool, id: i64, next_run_at: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE scheduled_jobs SET next_run_at = ? WHERE id = ?")
|
||||
.bind(next_run_at)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark a job as in-flight. Called at the start of run_job(), before handle_message().
|
||||
pub async fn set_running(pool: &SqlitePool, id: i64, session_id: i64) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE scheduled_jobs SET running_session_id = ?, running_since = datetime('now') WHERE id = ?",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_run_context(pool: &SqlitePool, id: i64, run_context: Option<&str>) -> Result<bool> {
|
||||
let n = sqlx::query("UPDATE scheduled_jobs SET run_context = ? WHERE id = ?")
|
||||
.bind(run_context)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Mark a job as finished. Called at the end of run_job() regardless of outcome.
|
||||
///
|
||||
/// - Sets `last_run_at = now`, clears `running_session_id`.
|
||||
/// - If `next_run_at` is `Some`: updates the field (next scheduled fire).
|
||||
/// - If `next_run_at` is `None` (single-run job): sets `enabled = 0`.
|
||||
pub async fn finish_run(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
next_run_at: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE scheduled_jobs
|
||||
SET last_run_at = datetime('now'),
|
||||
running_session_id = NULL,
|
||||
running_since = NULL,
|
||||
next_run_at = COALESCE(?, next_run_at),
|
||||
enabled = CASE WHEN ? IS NULL THEN 0 ELSE enabled END
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(next_run_at)
|
||||
.bind(next_run_at)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub async fn upsert(pool: &SqlitePool, session_id: i64, key: &str, value: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO session_scratchpad (session_id, key, value)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (session_id, key) DO UPDATE SET value = excluded.value"
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn for_session(pool: &SqlitePool, session_id: i64) -> Result<Vec<(String, String)>> {
|
||||
let rows = sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT key, value FROM session_scratchpad WHERE session_id = ? ORDER BY key"
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Grant access to an MCP server for a session.
|
||||
/// Uses INSERT OR IGNORE so calling it multiple times is safe.
|
||||
pub async fn grant(pool: &SqlitePool, session_id: i64, mcp_name: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO session_mcp_grants (session_id, mcp_name)
|
||||
VALUES (?, ?)"
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(mcp_name)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke all MCP grants for a session.
|
||||
pub async fn revoke_all(pool: &SqlitePool, session_id: i64) -> Result<()> {
|
||||
sqlx::query("DELETE FROM session_mcp_grants WHERE session_id = ?")
|
||||
.bind(session_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the names of all MCP servers granted for this session.
|
||||
pub async fn list_for_session(pool: &SqlitePool, session_id: i64) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT mcp_name FROM session_mcp_grants WHERE session_id = ? ORDER BY granted_at"
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(name,)| name).collect())
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct Source {
|
||||
pub id: String,
|
||||
pub active_session_id: Option<i64>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Upsert a source, setting its active session.
|
||||
pub async fn upsert(pool: &SqlitePool, id: &str, session_id: i64) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO sources (id, active_session_id, updated_at)
|
||||
VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
active_session_id = excluded.active_session_id,
|
||||
updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(session_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find a source by id.
|
||||
pub async fn find(pool: &SqlitePool, id: &str) -> anyhow::Result<Option<Source>> {
|
||||
let row = sqlx::query_as::<_, (String, Option<i64>, String)>(
|
||||
"SELECT id, active_session_id, updated_at FROM sources WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|(id, active_session_id, updated_at)| Source { id, active_session_id, updated_at }))
|
||||
}
|
||||
|
||||
/// Returns the active session id for a source, if set.
|
||||
pub async fn active_session_id(pool: &SqlitePool, id: &str) -> anyhow::Result<Option<i64>> {
|
||||
let row = sqlx::query_as::<_, (Option<i64>,)>(
|
||||
"SELECT active_session_id FROM sources WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.and_then(|(sid,)| sid))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// Persist an MCP grant scoped to a specific stack frame (sub-agent).
|
||||
/// Uses INSERT OR IGNORE so calling it multiple times is safe.
|
||||
pub async fn grant(pool: &SqlitePool, stack_id: i64, mcp_name: &str) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO stack_mcp_grants (stack_id, mcp_name)
|
||||
VALUES (?, ?)",
|
||||
)
|
||||
.bind(stack_id)
|
||||
.bind(mcp_name)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the names of all MCP servers granted for this stack frame.
|
||||
pub async fn list_for_stack(pool: &SqlitePool, stack_id: i64) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT mcp_name FROM stack_mcp_grants WHERE stack_id = ? ORDER BY granted_at",
|
||||
)
|
||||
.bind(stack_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(name,)| name).collect())
|
||||
}
|
||||
|
||||
/// Removes all MCP grants for a stack frame. Called when the frame terminates.
|
||||
pub async fn delete_for_stack(pool: &SqlitePool, stack_id: i64) -> Result<()> {
|
||||
sqlx::query("DELETE FROM stack_mcp_grants WHERE stack_id = ?")
|
||||
.bind(stack_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolPermissionGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
type RawRow = (String, String, Option<String>, String);
|
||||
|
||||
fn from_raw((id, name, description, created_at): RawRow) -> ToolPermissionGroup {
|
||||
ToolPermissionGroup { id, name, description, created_at }
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<ToolPermissionGroup>> {
|
||||
let rows = sqlx::query_as::<_, RawRow>(
|
||||
"SELECT id, name, description, created_at
|
||||
FROM tool_permission_groups
|
||||
ORDER BY created_at ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(from_raw).collect())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> Result<Option<ToolPermissionGroup>> {
|
||||
let row = sqlx::query_as::<_, RawRow>(
|
||||
"SELECT id, name, description, created_at
|
||||
FROM tool_permission_groups WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(from_raw))
|
||||
}
|
||||
|
||||
pub async fn insert(pool: &SqlitePool, id: &str, name: &str, description: Option<&str>) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO tool_permission_groups (id, name, description) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn insert_or_ignore(pool: &SqlitePool, id: &str, name: &str, description: Option<&str>) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO tool_permission_groups (id, name, description) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update(pool: &SqlitePool, id: &str, name: &str, description: Option<&str>) -> Result<bool> {
|
||||
let rows = sqlx::query(
|
||||
"UPDATE tool_permission_groups SET name = ?, description = ? WHERE id = ?",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(rows > 0)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> Result<bool> {
|
||||
let rows = sqlx::query("DELETE FROM tool_permission_groups WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(rows > 0)
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
//! `users` — user directory and auth material.
|
||||
//!
|
||||
//! This table lives in the system DB, which anyone owning the box can read. So
|
||||
//! it must never store anything from which a user's key can be derived.
|
||||
//!
|
||||
//! For an **encrypted** user it holds the DEK *wrapped* under a key derived from
|
||||
//! the password: useless without the password, and the wrap's AEAD tag doubles
|
||||
//! as the password verifier. That is why an encrypted user has no
|
||||
//! `password_hash` — a second hash of the same password would only hand an
|
||||
//! offline attacker an easier target than the wrap itself.
|
||||
//!
|
||||
//! A **cleartext** user has no DB key to bind a verifier to, so it carries an
|
||||
//! ordinary Argon2id hash instead (harmless: that DB is readable anyway).
|
||||
//!
|
||||
//! [`Credentials`] makes the two shapes mutually exclusive in the type system,
|
||||
//! mirroring the `CHECK` constraint on the table.
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// KDF settings as JSON, e.g. `{"algo":"argon2id","m":65536,"t":3,"p":1}`.
|
||||
/// Not secret — calibrated on the box when the user is created.
|
||||
pub type KdfParams = String;
|
||||
|
||||
/// Argon2id verifier for a user whose database is not encrypted.
|
||||
#[derive(Clone)]
|
||||
pub struct ClearVerifier {
|
||||
pub kdf_params: KdfParams,
|
||||
pub kdf_salt: Vec<u8>,
|
||||
pub password_hash: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Auth material for a user. The variants mirror the table's `CHECK`: an
|
||||
/// encrypted user has a wrapped DEK and no hash; a cleartext user has no
|
||||
/// wrapped DEK, and may have no verifier at all (a role that cannot log in).
|
||||
#[derive(Clone)]
|
||||
pub enum Credentials {
|
||||
Encrypted {
|
||||
kdf_params: KdfParams,
|
||||
kdf_salt: Vec<u8>,
|
||||
/// DEK sealed with an AEAD under `KDF(password, kdf_salt)`. Changing the
|
||||
/// password re-wraps this value; the database itself is never re-encrypted.
|
||||
database_password: Vec<u8>,
|
||||
},
|
||||
Cleartext(Option<ClearVerifier>),
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
pub fn is_encrypted(&self) -> bool {
|
||||
matches!(self, Credentials::Encrypted { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of `users`.
|
||||
///
|
||||
/// Deliberately **not** `Serialize`: it carries the wrapped DEK and the password
|
||||
/// verifier, and this type must never be handed to an HTTP handler by accident.
|
||||
/// Use [`User::summary`] for anything that leaves the process.
|
||||
#[derive(Clone)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub role_id: String,
|
||||
pub credentials: Credentials,
|
||||
pub active: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// The public-safe projection of a [`User`] — no key material.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UserSummary {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub role_id: String,
|
||||
pub encrypted: bool,
|
||||
pub active: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn is_encrypted(&self) -> bool {
|
||||
self.credentials.is_encrypted()
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> UserSummary {
|
||||
UserSummary {
|
||||
id: self.id.clone(),
|
||||
username: self.username.clone(),
|
||||
display_name: self.display_name.clone(),
|
||||
role_id: self.role_id.clone(),
|
||||
encrypted: self.is_encrypted(),
|
||||
active: self.active,
|
||||
created_at: self.created_at.clone(),
|
||||
updated_at: self.updated_at.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hand-written so a stray `{:?}` — in a tracing span, an error context, a panic
|
||||
// message — cannot print key material.
|
||||
impl std::fmt::Debug for Credentials {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Credentials::Encrypted { .. } => f.write_str("Encrypted(<redacted>)"),
|
||||
Credentials::Cleartext(None) => f.write_str("Cleartext(no verifier)"),
|
||||
Credentials::Cleartext(Some(_)) => f.write_str("Cleartext(<redacted>)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for User {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("User")
|
||||
.field("id", &self.id)
|
||||
.field("username", &self.username)
|
||||
.field("display_name", &self.display_name)
|
||||
.field("role_id", &self.role_id)
|
||||
.field("credentials", &self.credentials)
|
||||
.field("active", &self.active)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Row mapping ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: String,
|
||||
username: String,
|
||||
display_name: Option<String>,
|
||||
role_id: String,
|
||||
encrypted: bool,
|
||||
kdf_params: Option<String>,
|
||||
kdf_salt: Option<Vec<u8>>,
|
||||
database_password: Option<Vec<u8>>,
|
||||
password_hash: Option<Vec<u8>>,
|
||||
active: bool,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
/// Builds a `&'static str` (sqlx rejects runtime-built SQL) while keeping the
|
||||
/// column list — which `Row`'s `FromRow` mirrors — in exactly one place.
|
||||
macro_rules! select {
|
||||
($tail:literal) => {
|
||||
concat!(
|
||||
"SELECT id, username, display_name, role_id, encrypted, kdf_params, kdf_salt, ",
|
||||
"database_password, password_hash, active, created_at, updated_at FROM users ",
|
||||
$tail
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
impl TryFrom<Row> for User {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(r: Row) -> Result<Self> {
|
||||
let broken = |what: &str| anyhow!("users row {}: {what}", r.id);
|
||||
let credentials = if r.encrypted {
|
||||
Credentials::Encrypted {
|
||||
kdf_params: r.kdf_params.ok_or_else(|| broken("encrypted without kdf_params"))?,
|
||||
kdf_salt: r.kdf_salt.ok_or_else(|| broken("encrypted without kdf_salt"))?,
|
||||
database_password: r.database_password
|
||||
.ok_or_else(|| broken("encrypted without database_password"))?,
|
||||
}
|
||||
} else {
|
||||
match r.password_hash {
|
||||
None => Credentials::Cleartext(None),
|
||||
Some(password_hash) => Credentials::Cleartext(Some(ClearVerifier {
|
||||
kdf_params: r.kdf_params.ok_or_else(|| broken("verifier without kdf_params"))?,
|
||||
kdf_salt: r.kdf_salt.ok_or_else(|| broken("verifier without kdf_salt"))?,
|
||||
password_hash,
|
||||
})),
|
||||
}
|
||||
};
|
||||
Ok(User {
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
display_name: r.display_name,
|
||||
role_id: r.role_id,
|
||||
credentials,
|
||||
active: r.active,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The four credential columns, in table order.
|
||||
type CredColumns<'a> = (bool, Option<&'a str>, Option<&'a [u8]>, Option<&'a [u8]>, Option<&'a [u8]>);
|
||||
|
||||
fn columns(c: &Credentials) -> CredColumns<'_> {
|
||||
match c {
|
||||
Credentials::Encrypted { kdf_params, kdf_salt, database_password } => (
|
||||
true,
|
||||
Some(kdf_params.as_str()),
|
||||
Some(kdf_salt.as_slice()),
|
||||
Some(database_password.as_slice()),
|
||||
None,
|
||||
),
|
||||
Credentials::Cleartext(None) => (false, None, None, None, None),
|
||||
Credentials::Cleartext(Some(v)) => (
|
||||
false,
|
||||
Some(v.kdf_params.as_str()),
|
||||
Some(v.kdf_salt.as_slice()),
|
||||
None,
|
||||
Some(v.password_hash.as_slice()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reads ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> Result<Option<User>> {
|
||||
let row = sqlx::query_as::<_, Row>(select!("WHERE id = ?1"))
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
row.map(User::try_from).transpose()
|
||||
}
|
||||
|
||||
/// Login entry point: `username` is the handle, `id` is opaque and stable.
|
||||
pub async fn by_username(pool: &SqlitePool, username: &str) -> Result<Option<User>> {
|
||||
let row = sqlx::query_as::<_, Row>(select!("WHERE username = ?1"))
|
||||
.bind(username)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
row.map(User::try_from).transpose()
|
||||
}
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<User>> {
|
||||
let rows = sqlx::query_as::<_, Row>(select!("ORDER BY username"))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
rows.into_iter().map(User::try_from).collect()
|
||||
}
|
||||
|
||||
pub async fn count(pool: &SqlitePool) -> Result<i64> {
|
||||
let (n,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
// ── Writes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `id` is supplied by the caller and must be opaque (never the username), so a
|
||||
/// rename never has to touch `database/{id}.db`.
|
||||
pub async fn insert(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
username: &str,
|
||||
display_name: Option<&str>,
|
||||
role_id: &str,
|
||||
credentials: &Credentials,
|
||||
) -> Result<()> {
|
||||
let (encrypted, kdf_params, kdf_salt, database_password, password_hash) = columns(credentials);
|
||||
sqlx::query(
|
||||
"INSERT INTO users
|
||||
(id, username, display_name, role_id, encrypted,
|
||||
kdf_params, kdf_salt, database_password, password_hash)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(username)
|
||||
.bind(display_name)
|
||||
.bind(role_id)
|
||||
.bind(encrypted)
|
||||
.bind(kdf_params)
|
||||
.bind(kdf_salt)
|
||||
.bind(database_password)
|
||||
.bind(password_hash)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces the auth material in one statement.
|
||||
///
|
||||
/// This is both "change password" (re-wrap the same DEK under a key derived from
|
||||
/// the new password — the database is never re-encrypted) and the encrypted ↔
|
||||
/// cleartext migration, since the variant carries the new shape.
|
||||
pub async fn set_credentials(pool: &SqlitePool, id: &str, credentials: &Credentials) -> Result<()> {
|
||||
let (encrypted, kdf_params, kdf_salt, database_password, password_hash) = columns(credentials);
|
||||
let n = sqlx::query(
|
||||
"UPDATE users SET
|
||||
encrypted = ?2,
|
||||
kdf_params = ?3,
|
||||
kdf_salt = ?4,
|
||||
database_password = ?5,
|
||||
password_hash = ?6,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(encrypted)
|
||||
.bind(kdf_params)
|
||||
.bind(kdf_salt)
|
||||
.bind(database_password)
|
||||
.bind(password_hash)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if n == 0 {
|
||||
bail!("no such user: {id}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_active(pool: &SqlitePool, id: &str, active: bool) -> Result<()> {
|
||||
let n = sqlx::query(
|
||||
"UPDATE users SET active = ?2, updated_at = datetime('now') WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(active)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if n == 0 {
|
||||
bail!("no such user: {id}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rename(pool: &SqlitePool, id: &str, username: &str, display_name: Option<&str>) -> Result<()> {
|
||||
let n = sqlx::query(
|
||||
"UPDATE users SET username = ?2, display_name = ?3, updated_at = datetime('now')
|
||||
WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(username)
|
||||
.bind(display_name)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if n == 0 {
|
||||
bail!("no such user: {id}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the directory row only. The caller still owns `database/{id}.db`:
|
||||
/// erasing a user means deleting that file too.
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM users WHERE id = ?1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Nested on purpose: also covers `init_system_pool` creating the parent directory.
|
||||
fn temp_db_path(tag: &str) -> String {
|
||||
let mut p = std::env::temp_dir();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
p.push(format!("skald-test-{tag}-{}-{nanos}", std::process::id()));
|
||||
p.push("database");
|
||||
p.push("system.db");
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn cleanup(path: &str) {
|
||||
if let Some(dir) = std::path::Path::new(path).parent().and_then(|p| p.parent()) {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypted() -> Credentials {
|
||||
Credentials::Encrypted {
|
||||
kdf_params: r#"{"algo":"argon2id","m":65536,"t":3,"p":1}"#.into(),
|
||||
kdf_salt: vec![1, 2, 3, 4],
|
||||
database_password: vec![0xDE, 0xAD, 0xBE, 0xEF],
|
||||
}
|
||||
}
|
||||
|
||||
fn cleartext() -> Credentials {
|
||||
Credentials::Cleartext(Some(ClearVerifier {
|
||||
kdf_params: r#"{"algo":"argon2id","m":65536,"t":3,"p":1}"#.into(),
|
||||
kdf_salt: vec![5, 6, 7, 8],
|
||||
password_hash: vec![0xAB, 0xCD],
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn init_system_pool_creates_the_database_directory() {
|
||||
let path = temp_db_path("users-mkdir");
|
||||
assert!(!std::path::Path::new(&path).parent().unwrap().exists());
|
||||
|
||||
let pool = crate::db::init_system_pool(&path).await.unwrap();
|
||||
assert!(std::path::Path::new(&path).exists(), "system.db must exist under a fresh database/");
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn encrypted_user_round_trips_and_keeps_the_wrapped_dek() {
|
||||
let path = temp_db_path("users-enc");
|
||||
let pool = crate::db::init_system_pool(&path).await.unwrap();
|
||||
|
||||
insert(&pool, "u-1", "ada", Some("Ada"), "admin", &encrypted()).await.unwrap();
|
||||
|
||||
let u = by_username(&pool, "ada").await.unwrap().expect("user by username");
|
||||
assert_eq!(u.id, "u-1");
|
||||
assert!(u.is_encrypted());
|
||||
assert!(u.active);
|
||||
match u.credentials {
|
||||
Credentials::Encrypted { database_password, kdf_salt, .. } => {
|
||||
assert_eq!(database_password, vec![0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
assert_eq!(kdf_salt, vec![1, 2, 3, 4]);
|
||||
}
|
||||
other => panic!("expected Encrypted, got {other:?}"),
|
||||
}
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleartext_user_round_trips_with_and_without_a_verifier() {
|
||||
let path = temp_db_path("users-clear");
|
||||
let pool = crate::db::init_system_pool(&path).await.unwrap();
|
||||
|
||||
insert(&pool, "u-1", "kid", None, "children", &cleartext()).await.unwrap();
|
||||
insert(&pool, "u-2", "kiosk", None, "children", &Credentials::Cleartext(None)).await.unwrap();
|
||||
|
||||
let with = get(&pool, "u-1").await.unwrap().unwrap();
|
||||
assert!(!with.is_encrypted());
|
||||
match with.credentials {
|
||||
Credentials::Cleartext(Some(v)) => assert_eq!(v.password_hash, vec![0xAB, 0xCD]),
|
||||
other => panic!("expected a verifier, got {other:?}"),
|
||||
}
|
||||
|
||||
let without = get(&pool, "u-2").await.unwrap().unwrap();
|
||||
assert!(matches!(without.credentials, Credentials::Cleartext(None)));
|
||||
|
||||
assert_eq!(count(&pool).await.unwrap(), 2);
|
||||
assert_eq!(list(&pool).await.unwrap().len(), 2);
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
/// Changing the password re-wraps the DEK; migrating to cleartext must clear it.
|
||||
#[tokio::test]
|
||||
async fn set_credentials_rewraps_and_migrates() {
|
||||
let path = temp_db_path("users-rewrap");
|
||||
let pool = crate::db::init_system_pool(&path).await.unwrap();
|
||||
|
||||
insert(&pool, "u-1", "ada", None, "admin", &encrypted()).await.unwrap();
|
||||
|
||||
let rewrapped = Credentials::Encrypted {
|
||||
kdf_params: r#"{"algo":"argon2id","m":65536,"t":3,"p":1}"#.into(),
|
||||
kdf_salt: vec![9, 9, 9],
|
||||
database_password: vec![0xFE, 0xED],
|
||||
};
|
||||
set_credentials(&pool, "u-1", &rewrapped).await.unwrap();
|
||||
match get(&pool, "u-1").await.unwrap().unwrap().credentials {
|
||||
Credentials::Encrypted { database_password, .. } => assert_eq!(database_password, vec![0xFE, 0xED]),
|
||||
other => panic!("expected Encrypted, got {other:?}"),
|
||||
}
|
||||
|
||||
set_credentials(&pool, "u-1", &cleartext()).await.unwrap();
|
||||
let u = get(&pool, "u-1").await.unwrap().unwrap();
|
||||
assert!(!u.is_encrypted(), "migrating must flip `encrypted` and drop the wrapped DEK");
|
||||
|
||||
assert!(set_credentials(&pool, "ghost", &cleartext()).await.is_err(), "unknown id must fail");
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
/// The SQL `CHECK` is the last line of defence when a row is written without
|
||||
/// going through [`Credentials`].
|
||||
#[tokio::test]
|
||||
async fn check_constraint_rejects_impossible_rows() {
|
||||
let path = temp_db_path("users-check");
|
||||
let pool = crate::db::init_system_pool(&path).await.unwrap();
|
||||
|
||||
// encrypted without a wrapped DEK
|
||||
let err = sqlx::query(
|
||||
"INSERT INTO users (id, username, role_id, encrypted) VALUES ('x', 'x', 'admin', 1)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
assert!(err.is_err(), "encrypted=1 requires database_password");
|
||||
|
||||
// encrypted *and* carrying a password hash
|
||||
let err = sqlx::query(
|
||||
"INSERT INTO users (id, username, role_id, encrypted, database_password, password_hash)
|
||||
VALUES ('y', 'y', 'admin', 1, X'00', X'01')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
assert!(err.is_err(), "an encrypted user must not also store a password hash");
|
||||
|
||||
// cleartext carrying a wrapped DEK
|
||||
let err = sqlx::query(
|
||||
"INSERT INTO users (id, username, role_id, encrypted, database_password)
|
||||
VALUES ('z', 'z', 'admin', 0, X'00')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
assert!(err.is_err(), "cleartext=0 must not store a wrapped DEK");
|
||||
|
||||
assert_eq!(count(&pool).await.unwrap(), 0);
|
||||
|
||||
pool.close().await;
|
||||
cleanup(&path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn debug_never_prints_key_material() {
|
||||
let u = User {
|
||||
id: "u-1".into(),
|
||||
username: "ada".into(),
|
||||
display_name: None,
|
||||
role_id: "admin".into(),
|
||||
credentials: encrypted(),
|
||||
active: true,
|
||||
created_at: "now".into(),
|
||||
updated_at: "now".into(),
|
||||
};
|
||||
let printed = format!("{u:?}");
|
||||
assert!(printed.contains("ada"));
|
||||
assert!(!printed.contains("222"), "no raw DEK bytes");
|
||||
assert!(!printed.contains("deadbeef") && !printed.contains("DEADBEEF"));
|
||||
assert!(printed.contains("<redacted>"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user