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,155 @@
|
||||
//! Typed, fire-and-forget event seam for a running agent turn.
|
||||
//!
|
||||
//! Every event a turn produces used to be sent inline as
|
||||
//! `tx.send(ServerEvent::X { .. }).await.ok()`, scattered across `llm_loop`,
|
||||
//! `resume`, `agent_dispatch`, and `approval`. `TurnEmitter` wraps the per-turn
|
||||
//! `mpsc::Sender<ServerEvent>` (which `ChatHub` bridges onto the global broadcast
|
||||
//! bus) and exposes one semantic method per event, so the loop speaks in domain
|
||||
//! terms (`emitter.tool_done(..)`) instead of constructing wire enums by hand.
|
||||
//!
|
||||
//! It is a zero-cost borrow wrapper: construct one at the top of a function that
|
||||
//! emits and pass `&TurnEmitter` to any helper. This is also the single seam a
|
||||
//! future event-bus / UI-vs-domain split would hook into.
|
||||
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use core_api::message_meta::Attachment;
|
||||
|
||||
use crate::events::ServerEvent;
|
||||
|
||||
/// Borrows the per-turn event sender and emits typed [`ServerEvent`]s.
|
||||
pub(super) struct TurnEmitter<'a> {
|
||||
tx: &'a mpsc::Sender<ServerEvent>,
|
||||
}
|
||||
|
||||
impl<'a> TurnEmitter<'a> {
|
||||
pub(super) fn new(tx: &'a mpsc::Sender<ServerEvent>) -> Self {
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
/// Send an event, dropping it silently if the receiver is gone (the same
|
||||
/// `.await.ok()` semantics every call site used before).
|
||||
async fn emit(&self, event: ServerEvent) {
|
||||
self.tx.send(event).await.ok();
|
||||
}
|
||||
|
||||
// ── User / assistant turn events ────────────────────────────────────────
|
||||
|
||||
/// A user message row was persisted (telnet-style echo).
|
||||
pub(super) async fn user_message(&self, message_id: i64, content: String, attachments: Vec<Attachment>) {
|
||||
self.emit(ServerEvent::UserMessage { message_id, content, attachments }).await;
|
||||
}
|
||||
|
||||
/// The assistant produced text alongside tool calls (reasoning before acting).
|
||||
pub(super) async fn thinking(&self, message_id: i64, content: String, input_tokens: Option<u32>, output_tokens: Option<u32>) {
|
||||
self.emit(ServerEvent::Thinking { message_id, content, input_tokens, output_tokens }).await;
|
||||
}
|
||||
|
||||
/// The assistant response is complete.
|
||||
pub(super) async fn done(&self, message_id: i64, stack_id: i64, content: String, input_tokens: Option<u32>, output_tokens: Option<u32>) {
|
||||
self.emit(ServerEvent::Done { message_id, stack_id, content, input_tokens, output_tokens }).await;
|
||||
}
|
||||
|
||||
/// The LLM was cut off by the token limit.
|
||||
pub(super) async fn truncated(&self, output_tokens: Option<u32>) {
|
||||
self.emit(ServerEvent::Truncated { output_tokens }).await;
|
||||
}
|
||||
|
||||
/// A fatal error occurred processing the request.
|
||||
pub(super) async fn error(&self, message: String) {
|
||||
self.emit(ServerEvent::Error { message }).await;
|
||||
}
|
||||
|
||||
// ── Tool-call lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn tool_start(
|
||||
&self,
|
||||
tool_call_id: i64,
|
||||
message_id: i64,
|
||||
name: String,
|
||||
arguments: Value,
|
||||
label_short: String,
|
||||
label_full: String,
|
||||
path: Option<String>,
|
||||
) {
|
||||
self.emit(ServerEvent::ToolStart {
|
||||
tool_call_id, message_id, name, arguments, label_short, label_full, path,
|
||||
}).await;
|
||||
}
|
||||
|
||||
pub(super) async fn tool_done(&self, tool_call_id: i64, result: String, result_type: String) {
|
||||
self.emit(ServerEvent::ToolDone { tool_call_id, result, result_type }).await;
|
||||
}
|
||||
|
||||
pub(super) async fn tool_error(&self, tool_call_id: i64, error: String) {
|
||||
self.emit(ServerEvent::ToolError { tool_call_id, error }).await;
|
||||
}
|
||||
|
||||
pub(super) async fn tool_cancelled(&self, tool_call_id: i64) {
|
||||
self.emit(ServerEvent::ToolCancelled { tool_call_id }).await;
|
||||
}
|
||||
|
||||
pub(super) async fn tool_rejected(&self, tool_call_id: i64, reason: String) {
|
||||
self.emit(ServerEvent::ToolRejected { tool_call_id, reason }).await;
|
||||
}
|
||||
|
||||
/// A file-write tool completed; ask clients holding the file to reload.
|
||||
pub(super) async fn file_changed(&self, path: String) {
|
||||
self.emit(ServerEvent::FileChanged { path }).await;
|
||||
}
|
||||
|
||||
// ── Approval / clarification prompts ────────────────────────────────────
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn pending_write(
|
||||
&self,
|
||||
request_id: i64,
|
||||
tool_call_id: i64,
|
||||
path: String,
|
||||
old_content: Option<String>,
|
||||
new_content: String,
|
||||
) {
|
||||
self.emit(ServerEvent::PendingWrite { request_id, tool_call_id, path, old_content, new_content }).await;
|
||||
}
|
||||
|
||||
pub(super) async fn approval_required(&self, request_id: i64, tool_call_id: i64, tool_name: String, arguments: Value) {
|
||||
self.emit(ServerEvent::ApprovalRequired { request_id, tool_call_id, tool_name, arguments }).await;
|
||||
}
|
||||
|
||||
// Note: `AgentQuestion` is emitted directly in `dispatch_ask_user_clarification`
|
||||
// because that one site inspects the send Result for diagnostic logging — it is
|
||||
// deliberately not wrapped here.
|
||||
|
||||
// ── Sub-agent stack frames ──────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn agent_start(
|
||||
&self,
|
||||
stack_id: i64,
|
||||
parent_tool_call_id: i64,
|
||||
agent_id: String,
|
||||
parent_agent_id: String,
|
||||
depth: i64,
|
||||
prompt_preview: String,
|
||||
) {
|
||||
self.emit(ServerEvent::AgentStart {
|
||||
stack_id, parent_tool_call_id, agent_id, parent_agent_id, depth, prompt_preview,
|
||||
}).await;
|
||||
}
|
||||
|
||||
pub(super) async fn agent_done(&self, stack_id: i64, agent_id: String, parent_agent_id: String, result_preview: String) {
|
||||
self.emit(ServerEvent::AgentDone { stack_id, agent_id, parent_agent_id, result_preview }).await;
|
||||
}
|
||||
|
||||
// ── LLM model fallback ──────────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn model_fallback(&self, from: String, to: String, reason: String) {
|
||||
self.emit(ServerEvent::ModelFallback { from, to, reason }).await;
|
||||
}
|
||||
|
||||
pub(super) async fn llm_failed(&self, tried: Vec<String>, last_error: String) {
|
||||
self.emit(ServerEvent::LlmFailed { tried, last_error }).await;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user